From 12eef27c124fea2a38ca6dd62211364a45969f30 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:31:47 +0200 Subject: [PATCH 1/2] feat(postgres): client-side defaults as Effects with defaultFn and onUpdate A PG.column takes defaultFn and onUpdate, each an Effect of a column value that cannot fail. Database.run runs them through fillDefaults: defaultFn for each row an insert leaves the column out of, onUpdate once for an update that does not set it (and for an insert, when the column has no other default). Their requirements flow into the write's type and into run's. compile refuses a write that still leaves a client default out. Co-Authored-By: Claude Opus 5.5 --- src/ch/client-defaults.ts | 75 +++++++++++ src/ch/compile.ts | 19 ++- src/ch/insert.ts | 60 ++++++--- src/ch/table.ts | 23 ++++ src/ch/update.ts | 41 +++--- src/core.ts | 5 +- src/database/client-defaults.test-d.ts | 67 ++++++++++ src/database/client-defaults.test.ts | 173 +++++++++++++++++++++++++ src/database/database.ts | 27 ++-- src/schema/pg-define.ts | 132 +++++++++++++++---- 10 files changed, 551 insertions(+), 71 deletions(-) create mode 100644 src/ch/client-defaults.ts create mode 100644 src/database/client-defaults.test-d.ts create mode 100644 src/database/client-defaults.test.ts diff --git a/src/ch/client-defaults.ts b/src/ch/client-defaults.ts new file mode 100644 index 0000000..1616af5 --- /dev/null +++ b/src/ch/client-defaults.ts @@ -0,0 +1,75 @@ +// Client-side defaults +// +// A Postgres column's `defaultFn` and `onUpdate` are Effects. `fillDefaults` +// runs them into an insert's rows or an update's SET, so the write that +// `compile` sees already has every value. `Database.run` calls it first; a +// write compiled without it, that still lacks a client default, is a defect. +// Running here, and not in `compile`, keeps `compile` free of requirements. + +import { Effect } from "effect" +import { isInsert, type AnyInsert, type CHInsert } from "./insert" +import type { ClientDefaults } from "./table" +import { isUpdate, type CHUpdate } from "./update" + +/** What a write's client defaults need to run: `never` for any other runnable. */ +export type RequirementsOf = W extends { readonly _phantom?: { readonly requirements: infer R } } ? R : never + +type Row = Readonly> + +/** The values a row leaves out, each from its Effect, in table order. */ +const fillRow = (row: Row, defaults: ClientDefaults["insert"]): Effect.Effect => { + const missing = Object.entries(defaults).filter(([column]) => row[column] === undefined) + if (missing.length === 0) return Effect.succeed(row) + return Effect.map( + Effect.forEach(missing, ([column, effect]) => Effect.map(effect, (value) => [column, value] as const)), + (filled) => ({ ...row, ...Object.fromEntries(filled) }), + ) +} + +/** + * Run the client defaults of a write's table: each `defaultFn` once for every + * row of an insert that leaves its column out (an `onUpdate` stands in for a + * column without a default), and each `onUpdate` once for an update that does + * not set its column. A given value, `null` included, is kept. Any other + * runnable, and an `INSERT ... SELECT`, comes back unchanged. + * + * `Database.run` does this itself. Call it before `compile` when you compile a + * write yourself. + */ +export const fillDefaults = (write: W): Effect.Effect> => { + if (isInsert(write)) return fillInsert(write) as Effect.Effect + if (isUpdate(write)) return fillUpdate(write) as Effect.Effect + return Effect.succeed(write) +} + +const fillInsert = (insert: AnyInsert): Effect.Effect => { + const defaults = insert._state.table.clientDefaults?.insert + const rows = insert._state.rows + if (defaults === undefined || rows === undefined) return Effect.succeed(insert) + return Effect.map(Effect.forEach(rows, (row) => fillRow(row, defaults)), (filled) => + // Unchanged when no row left a client default out. + filled.every((row, index) => row === rows[index]) + ? insert + : ((insert as CHInsert).values(filled as never) as AnyInsert), + ) +} + +const fillUpdate = (update: CHUpdate): Effect.Effect, never, any> => { + const defaults = update._state.table.clientDefaults?.update + const set = update._state.set + if (defaults === undefined || set === undefined || Object.keys(defaults).length === 0) return Effect.succeed(update) + // A SET record says which columns it writes; a callback says so only when + // compiled, so every `onUpdate` runs and the callback's own values win. + const given = typeof set === "function" ? {} : (set as Row) + return Effect.map(fillRow(given, defaults), (filled) => { + const added = Object.fromEntries(Object.entries(filled).filter(([column]) => given[column] === undefined)) + const next = + typeof set === "function" + ? ($: any) => { + const record = set($) as Row + return { ...record, ...Object.fromEntries(Object.entries(added).filter(([column]) => record[column] === undefined)) } + } + : { ...set, ...added } + return update.set(next as never) as CHUpdate + }) +} diff --git a/src/ch/compile.ts b/src/ch/compile.ts index 0aaaac1..07b876a 100644 --- a/src/ch/compile.ts +++ b/src/ch/compile.ts @@ -619,7 +619,7 @@ export function compileCH( /** A write statement: what `compile` takes besides a query. */ export type CHWrite = | AnyInsert - | CHUpdate + | CHUpdate | CHDelete /** What compiling a write takes: only the dialect. */ @@ -1913,6 +1913,10 @@ function compileInsert(insert: CHInsert, params: Record()) } + const unfilled = rows?.flatMap((row, index) => missingClientDefaults(table.clientDefaults?.insert, row).map((column) => `${column} in row ${index}`)) + if (unfilled !== undefined && unfilled.length > 0) { + return fail(new QueryBuilderDefect({ message: clientDefaultsMessage(where, "insert", unfilled) }), failedQuery()) + } const dialect = currentDialect() const { values, cell } = valueCells(table, params, where) @@ -2001,6 +2005,15 @@ function compileInsert(insert: CHInsert, params: Record> | undefined, + record: Readonly>, +): ReadonlyArray => (defaults === undefined ? [] : Object.keys(defaults).filter((column) => record[column] === undefined)) + +const clientDefaultsMessage = (where: string, kind: "insert" | "update", columns: ReadonlyArray): string => + `${where}: ${columns.join(", ")} ${columns.length === 1 ? "has" : "have"} a client default (defaultFn / onUpdate) that has not run; run the ${kind} with Database.run, or pass it through fillDefaults before compile` + /** The VALUES rows' columns, in table order, after checking every key. */ const valuesColumns = ( table: CHInsert["_state"]["table"], @@ -2078,6 +2091,10 @@ function compileUpdateOrDelete( const set = (write as CHUpdate)._state.set if (set === undefined) return fail(new QueryBuilderDefect({ message: `${where}: set() is required` }), [assignments, ""] as const) const record = typeof set === "function" ? set($) : set + const unfilled = missingClientDefaults(table.clientDefaults?.update, record as Record) + if (unfilled.length > 0) { + return fail(new QueryBuilderDefect({ message: clientDefaultsMessage(where, "update", unfilled) }), [assignments, ""] as const) + } assignments = setAssignments(table, record as Record, cell, (column, value, sql) => { if (column === tenant) setWrites.push({ value, sql }) }, where, "update") diff --git a/src/ch/insert.ts b/src/ch/insert.ts index bf6adc0..7246bca 100644 --- a/src/ch/insert.ts +++ b/src/ch/insert.ts @@ -201,12 +201,21 @@ export interface CHInsert< Computed extends string = never, Output = never, Params = never, + Req = never, > { readonly _tag: "CHInsert" /** @internal — runtime insert state */ readonly _state: CHInsertState - /** phantom. `output` is the row `Database.run` returns: none without RETURNING. */ - readonly _phantom?: { readonly cols: Cols; readonly output: Output; readonly params: (entries: Params) => void } + /** + * phantom. `output` is the row `Database.run` returns: none without + * RETURNING. `requirements` is what the table's client defaults need. + */ + readonly _phantom?: { + readonly cols: Cols + readonly output: Output + readonly params: (entries: Params) => void + readonly requirements: Req + } /** * The rows to insert: one row or an array. Calling it again replaces the @@ -215,7 +224,7 @@ export interface CHInsert< */ values>( rows: (R | ReadonlyArray) & OnlyColumns>, - ): CHInsert> + ): CHInsert, Req> /** * `INSERT ... SELECT`: insert the rows a query (or union) selects. Each @@ -225,7 +234,7 @@ export interface CHInsert< */ select | CHUnionQuery>( query: Q & SelectFits, Cols, Defaulted, Computed> & NeedsSelect>, - ): CHInsert> + ): CHInsert, Req> /** * ClickHouse `SETTINGS` for this insert, such as @@ -233,7 +242,7 @@ export interface CHInsert< * identifiers; values are written as literals. Calling it again replaces * them. On a dialect without insert settings (Postgres) compiling is a defect. */ - settings(settings: Readonly>): CHInsert + settings(settings: Readonly>): CHInsert /** * Return the inserted rows: every column with no arguments (Drizzle's bare @@ -242,20 +251,20 @@ export interface CHInsert< * derived row schema. Postgres only; on a dialect without RETURNING * (ClickHouse) compiling is a defect. Calling it again replaces the list. */ - returning(): CHInsert }, Params> + returning(): CHInsert }, Params, Req> returning( ...columns: [K, ...Array] - ): CHInsert }, Params> + ): CHInsert }, Params, Req> returning>>( fn: ($: ColumnAccessor) => S, - ): CHInsert, Params | ParamsIn> + ): CHInsert, Params | ParamsIn, Req> /** * `ON CONFLICT DO NOTHING`: skip a row that conflicts. With `returning`, a * skipped row returns nothing. Postgres only; replaces any earlier * `onConflict*`. */ - onConflictDoNothing(options?: OnConflictDoNothing): CHInsert + onConflictDoNothing(options?: OnConflictDoNothing): CHInsert /** * `ON CONFLICT (target) DO UPDATE SET ...`: an upsert. Postgres only; @@ -263,14 +272,14 @@ export interface CHInsert< */ onConflictDoUpdate, W = never>( options: OnConflictDoUpdate, - ): CHInsert | W> + ): CHInsert | W, Req> /** * Declare that this insert, such as a batch of rows from many tenants, deliberately spans tenants, forcing * `tenantScope: "cross-tenant"` and setting `crossTenantDeclared`. A * database with `requireTenantScope` runs it; see docs/tenant-scoping.md. */ - crossTenant(): CHInsert + crossTenant(): CHInsert } /** @@ -281,11 +290,11 @@ export interface CHInsert< * `onConflictDoUpdate` does not survive that (`ConflictTarget` is any * `string`; `any` is not `never`). TypeScript 7 accepts either. */ -export interface AnyInsert { +export interface AnyInsert { readonly _tag: "CHInsert" /** @internal — runtime insert state */ readonly _state: CHInsertState - readonly _phantom?: { readonly output: Output; readonly params: (entries: Params) => void } + readonly _phantom?: { readonly output: Output; readonly params: (entries: Params) => void; readonly requirements?: Req } } const makeInsert = ( @@ -325,13 +334,26 @@ export type CHInsertStart< Cols extends ColumnDefs = ColumnDefs, Defaulted extends string = never, Computed extends string = never, -> = Pick, "values" | "select"> + Req = never, +> = Pick, "values" | "select"> -/** Start an INSERT into `table`. Give its rows with `values` or `select`. */ -export function insertInto( - table: Table, -): CHInsertStart { - return makeInsert({ table: table as Table }) +/** + * Start an INSERT into `table`. Give its rows with `values` or `select`. The + * insert requires what the table's client defaults need (`Req`). + */ +export function insertInto< + Name extends string, + Cols extends ColumnDefs, + Defaulted extends string, + Computed extends string, + Req = never, +>(table: Table): CHInsertStart { + return makeInsert({ table: table as Table }) as CHInsertStart< + Cols, + Defaulted, + Computed, + Req + > } export const isInsert = (value: unknown): value is CHInsert => diff --git a/src/ch/table.ts b/src/ch/table.ts index bd7dc44..9a1941f 100644 --- a/src/ch/table.ts +++ b/src/ch/table.ts @@ -3,20 +3,36 @@ // A Table carries its name and column definitions at both the type level // (for inference) and runtime (for SQL generation). +import type { Effect } from "effect" import type { ColumnDefs, InferTS } from "./types" +/** + * Columns the client fills, as an Effect per column: `insert` runs for a + * column an insert leaves out, `update` for a column an update does not set. + * `Database.run` runs them; see `fillDefaults`. + */ +export interface ClientDefaults { + readonly insert: Readonly>> + readonly update: Readonly>> +} + /** * `Defaulted` and `Computed` describe inserts: the columns an insert may leave * out because the database fills them, and the ones it may not write at all * (ClickHouse `MATERIALIZED` and `ALIAS`). Both default to `string`, read as * "unknown", so a `Table` with more of either still satisfies a `Table` * written without them; the insert row type treats a bare `string` as none. + * + * `InsertR` and `UpdateR` are what the table's client defaults need: the + * services an insert or an update of it requires to run. */ export interface Table< Name extends string, Columns extends ColumnDefs, Defaulted extends string = string, Computed extends string = string, + InsertR = never, + UpdateR = never, > { readonly _tag: "Table" readonly name: Name @@ -37,6 +53,13 @@ export interface Table< readonly defaults?: ReadonlyArray /** Columns the database computes, which an insert may not write. */ readonly computed?: ReadonlyArray + /** Columns the client fills with an Effect; see `ClientDefaults`. */ + readonly clientDefaults?: ClientDefaults + /** + * phantom: the requirements of the client defaults. Contravariant, so a + * table that needs a service still satisfies a `Table` type written without one. + */ + readonly _requirements?: { readonly insert: (r: InsertR) => void; readonly update: (r: UpdateR) => void } } export interface TableOptions< diff --git a/src/ch/update.ts b/src/ch/update.ts index 7db95ef..482d83d 100644 --- a/src/ch/update.ts +++ b/src/ch/update.ts @@ -70,16 +70,21 @@ export interface CHUpdate< Output = never, Ready extends WriteReady = "ready", Params = never, + Req = never, > { readonly _tag: "CHUpdate" /** @internal — runtime update state */ readonly _state: CHUpdateState - /** phantom. `output` is the row `Database.run` returns: none without RETURNING. */ + /** + * phantom. `output` is the row `Database.run` returns: none without + * RETURNING. `requirements` is what the table's `onUpdate` defaults need. + */ readonly _phantom?: { readonly cols: Cols readonly output: Output readonly ready: Ready readonly params: (entries: Params) => void + readonly requirements: Req } /** Replace the SET record. It must set at least one column, and only columns the table can write. */ @@ -87,7 +92,7 @@ export interface CHUpdate< set: | (S & NonEmptySet & OnlyColumns>) | (($: ColumnAccessor) => S & NonEmptySet & OnlyColumns>), - ): CHUpdate> + ): CHUpdate, Req> /** * The rows to change, as in a query's `where`: conditions AND-joined, an @@ -96,30 +101,30 @@ export interface CHUpdate< */ where( fn: WhereFn, - ): CHUpdate> + ): CHUpdate, Req> /** Change every row. Without it (or `where`), the write cannot be compiled or run. */ - allRows(): CHUpdate + allRows(): CHUpdate /** * ClickHouse `SETTINGS` for this write, such as `{ mutations_sync: 2 }` so an * `ALTER TABLE ... UPDATE` waits for the mutation. Postgres refuses them. */ - settings(settings: Readonly>): CHUpdate + settings(settings: Readonly>): CHUpdate /** The changed rows, as for an insert: every column, the named ones, or a callback. Postgres only. */ - returning(): CHUpdate, Ready, Params> + returning(): CHUpdate, Ready, Params, Req> returning( ...columns: [K, ...Array] - ): CHUpdate }, Ready, Params> + ): CHUpdate }, Ready, Params, Req> returning>>( fn: ($: ColumnAccessor) => S, - ): CHUpdate, Ready, Params | ParamsIn> + ): CHUpdate, Ready, Params | ParamsIn, Req> /** * Declare that this update deliberately spans tenants, forcing * `tenantScope: "cross-tenant"` and setting `crossTenantDeclared`. A * database with `requireTenantScope` runs it; see docs/tenant-scoping.md. */ - crossTenant(): CHUpdate + crossTenant(): CHUpdate } export interface CHDelete< @@ -171,8 +176,8 @@ export interface CHDelete< export type NonEmptySet = [keyof S] extends [never] ? { readonly setAtLeastOneColumn: true } : unknown /** An update with no SET yet: only `set`, so it cannot be compiled before it says what to write. */ -export type CHUpdateStart = Pick< - CHUpdate, +export type CHUpdateStart = Pick< + CHUpdate, "set" > @@ -208,11 +213,15 @@ const makeDelete = (state: CHDeleteState): CHDelete => ({ ...writeClauses(state, makeDelete), }) -/** Start an UPDATE of `table`. Give what to write with `set`, and the rows with `where` or `allRows`. */ -export function update( - table: Table, -): CHUpdateStart { - return makeUpdate({ table: table as Table }) as CHUpdateStart +/** + * Start an UPDATE of `table`. Give what to write with `set`, and the rows with + * `where` or `allRows`. The update requires what the table's `onUpdate` + * defaults need (`Req`). + */ +export function update( + table: Table, +): CHUpdateStart { + return makeUpdate({ table: table as Table }) as CHUpdateStart } /** Start a DELETE from `table`. Give the rows with `where` or `allRows`. */ diff --git a/src/core.ts b/src/core.ts index b32adcf..dbd1aa9 100644 --- a/src/core.ts +++ b/src/core.ts @@ -16,7 +16,7 @@ export { } from "./ch/types" // Tables, as the query builder sees them. Define one with the dialect's `table`. -export { type SelectRowOf, type Table } from "./ch/table" +export { type ClientDefaults, type SelectRowOf, type Table } from "./ch/table" // Core expression primitives export { @@ -132,6 +132,9 @@ export { update, } from "./ch/update" +// Client-side defaults: the `defaultFn` / `onUpdate` Effects of a write's table. +export { fillDefaults, type RequirementsOf } from "./ch/client-defaults" + export { unionAll, type CHUnionQuery, type InferUnionOutput } from "./ch/union" // Compiled output. `compile` itself is per dialect. diff --git a/src/database/client-defaults.test-d.ts b/src/database/client-defaults.test-d.ts new file mode 100644 index 0000000..6f028cd --- /dev/null +++ b/src/database/client-defaults.test-d.ts @@ -0,0 +1,67 @@ +// Type-level tests: client-side defaults make a column optional in an insert, +// and their requirements become the requirements of the write that runs them. + +import { Context, DateTime, Effect } from "effect" +import { expectTypeOf } from "expect-type" +import * as PG from "../postgres" +import * as Db from "../database" + +class IdGen extends Context.Service }>()("test/IdGen") {} +class Audit extends Context.Service }>()("test/Audit") {} + +/** What an Effect requires, to assert on. */ +const services = (_: Effect.Effect) => expectTypeOf() + +const nextId = Effect.flatMap(Effect.service(IdGen), (gen) => gen.next) +const auditNow = Effect.flatMap(Effect.service(Audit), (audit) => audit.now) + +const Notes = PG.table("notes", { + columns: { + id: PG.column(PG.text, { defaultFn: nextId }), + body: PG.text, + updatedAt: PG.column(PG.timestamptz, { onUpdate: DateTime.now }), + }, +}) + +// defaultFn and onUpdate columns may be left out; the others may not. +expectTypeOf>().toEqualTypeOf<{ + readonly body: PG.InsertValue + readonly id?: PG.InsertValue | undefined + readonly updatedAt?: PG.InsertValue | undefined +}>() + +// The insert requires the id generator; the update does not, as `DateTime.now` needs only the clock. +const insert = PG.insertInto(Notes).values({ body: "a" }) +const update = PG.update(Notes).set({ body: "b" }).allRows() +expectTypeOf(Db.run(insert)).toEqualTypeOf< + Effect.Effect, Db.DatabaseError | PG.QueryBuilderError | PG.CompiledQueryDecodeError, Db.Database | IdGen> +>() +services(Db.run(update)).toEqualTypeOf() +services(PG.fillDefaults(insert)).toEqualTypeOf() + +// A query, and a write to a table without client defaults, require only the database. +const Plain = PG.table("plain", { columns: { id: PG.text } }) +const plainInsert = PG.insertInto(Plain).values({ id: "x" }) +services(Db.run(plainInsert)).toEqualTypeOf() +const select = PG.from(Notes).select("id") +services(Db.run(select)).toEqualTypeOf() + +// An onUpdate that needs a service: the update requires it, and so does an +// insert, which runs the onUpdate for a column with no other default. +const Audited = PG.table("audited", { + columns: { + id: PG.text, + at: PG.column(PG.timestamptz, { onUpdate: auditNow }), + stamped: PG.column(PG.timestamptz, { defaultExpr: "now()", onUpdate: auditNow }), + }, +}) +const auditedUpdate = PG.update(Audited).set({ id: "x" }).allRows() +services(Db.run(auditedUpdate)).toEqualTypeOf() +const auditedInsert = PG.insertInto(Audited).values({ id: "x" }) +services(Db.run(auditedInsert)).toEqualTypeOf() + +// The Effect must give a value of the column's type, and must not fail. +// @ts-expect-error -- a number is not a text value +PG.column(PG.text, { defaultFn: Effect.succeed(1) }) +// @ts-expect-error -- a default that can fail has no place to send its error +PG.column(PG.text, { defaultFn: Effect.fail("no id") }) diff --git a/src/database/client-defaults.test.ts b/src/database/client-defaults.test.ts new file mode 100644 index 0000000..11fc9be --- /dev/null +++ b/src/database/client-defaults.test.ts @@ -0,0 +1,173 @@ +// Client-side defaults: `defaultFn` and `onUpdate` on `PG.column` are Effects, +// run by `Database.run`, against PGlite. The Effects read a service and the +// test clock, so every value they write is known here. + +import { PgliteClient } from "@effect/sql-pglite" +import { assert, describe, expect, it, layer } from "@effect/vitest" +import { Cause, Context, DateTime, Effect, Exit, Layer, Ref } from "effect" +import * as TestClock from "effect/testing/TestClock" +import * as SqlClient from "effect/sql/SqlClient" +import * as PG from "../postgres" +import { postgresDialect } from "../pg/dialect" +import * as Db from "../database" + +class IdGen extends Context.Service }>()("test/IdGen") {} + +const nextId = Effect.flatMap(Effect.service(IdGen), (gen) => gen.next) + +/** Ids `id-1`, `id-2`, ... in the order they are asked for. */ +const IdGenLive = Layer.effect( + IdGen, + Effect.map(Ref.make(0), (counter) => ({ + next: Effect.map(Ref.updateAndGet(counter, (n) => n + 1), (n) => `id-${n}`), + })), +) + +const Live = Layer.effect( + Db.Database, + Effect.map(Effect.service(SqlClient.SqlClient), (sql) => Db.fromSqlClient(sql, { dialect: postgresDialect })), +).pipe(Layer.provideMerge(PgliteClient.layer({ postgresqlconf: "timezone = 'UTC'" }))) + +let tables = 0 +/** A fresh `notes` table per test: the client is shared across the file. */ +const freshNotes = Effect.gen(function* () { + const name = `notes_${++tables}` + yield* Db.execute({ + sql: `CREATE TABLE "${name}" (id text PRIMARY KEY, body text NOT NULL, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL)`, + }) + return PG.table(name, { + columns: { + id: PG.column(PG.text, { defaultFn: nextId }), + body: PG.text, + createdAt: PG.column(PG.timestamptz, { name: "created_at", defaultFn: DateTime.now }), + updatedAt: PG.column(PG.timestamptz, { name: "updated_at", onUpdate: DateTime.now }), + }, + primaryKey: ["id"], + }) +}) + +const at = (millis: number) => DateTime.makeUnsafe(millis) + +layer(Live)("client-side defaults on PGlite", (it) => { + describe("insert", () => { + it.effect("fills a column left out by running its defaultFn, one run per row", () => + Effect.gen(function* () { + const Notes = yield* freshNotes + yield* TestClock.setTime(1_000) + const rows = yield* Db.run( + PG.insertInto(Notes) + .values([{ body: "a" }, { body: "b" }]) + .returning("id", "body", "createdAt", "updatedAt"), + ) + expect(rows).toEqual([ + { id: "id-1", body: "a", createdAt: at(1_000), updatedAt: at(1_000) }, + { id: "id-2", body: "b", createdAt: at(1_000), updatedAt: at(1_000) }, + ]) + }).pipe(Effect.provide(IdGenLive)), + ) + + it.effect("keeps a value the row gives, and does not run that column's Effect", () => + Effect.gen(function* () { + const Notes = yield* freshNotes + yield* TestClock.setTime(2_000) + const rows = yield* Db.run( + PG.insertInto(Notes) + .values([ + { id: "mine", body: "a", createdAt: at(5), updatedAt: at(6) }, + { body: "b" }, + ]) + .returning("id", "createdAt", "updatedAt"), + ) + // The first row asked for no id, so the second gets the first one. + expect(rows).toEqual([ + { id: "mine", createdAt: at(5), updatedAt: at(6) }, + { id: "id-1", createdAt: at(2_000), updatedAt: at(2_000) }, + ]) + }).pipe(Effect.provide(IdGenLive)), + ) + }) + + describe("update", () => { + const seeded = Effect.gen(function* () { + const Notes = yield* freshNotes + yield* TestClock.setTime(1_000) + // The insert type requires IdGen even with the id given; the updates below need nothing. + yield* Db.run(PG.insertInto(Notes).values({ id: "n", body: "a" })).pipe(Effect.provide(IdGenLive)) + yield* TestClock.setTime(9_000) + return Notes + }) + + it.effect("sets an onUpdate column the SET leaves out, from a record or a callback", () => + Effect.gen(function* () { + const Notes = yield* seeded + const record = yield* Db.run( + PG.update(Notes) + .set({ body: "b" }) + .where(($) => [$.id.eq("n")]) + .returning("body", "createdAt", "updatedAt"), + ) + expect(record).toEqual([{ body: "b", createdAt: at(1_000), updatedAt: at(9_000) }]) + yield* TestClock.setTime(10_000) + const callback = yield* Db.run( + PG.update(Notes) + .set(($) => ({ body: $.id })) + .where(($) => [$.id.eq("n")]) + .returning("body", "updatedAt"), + ) + expect(callback).toEqual([{ body: "n", updatedAt: at(10_000) }]) + }), + ) + + it.effect("keeps an onUpdate column the SET gives", () => + Effect.gen(function* () { + const Notes = yield* seeded + const rows = yield* Db.run( + PG.update(Notes) + .set(() => ({ body: "b", updatedAt: at(7) })) + .where(($) => [$.id.eq("n")]) + .returning("updatedAt"), + ) + expect(rows).toEqual([{ updatedAt: at(7) }]) + }), + ) + }) + + describe("compile", () => { + it.effect("refuses a write that still lacks a client default; fillDefaults runs them first", () => + Effect.gen(function* () { + const Notes = yield* freshNotes + const insert = PG.insertInto(Notes).values({ body: "a" }) + const exit = yield* Effect.exit(PG.compile(insert)) + assert(Exit.isFailure(exit) && Cause.hasDies(exit.cause)) + expect(Cause.squash(exit.cause)).toBeInstanceOf(PG.QueryBuilderDefect) + expect(String((Cause.squash(exit.cause) as Error).message)).toContain("fillDefaults") + + yield* TestClock.setTime(3_000) + const filled = yield* PG.fillDefaults(insert) + const compiled = yield* PG.compile(filled) + expect(compiled.parameters).toEqual(["id-1", "a", "1970-01-01T00:00:03.000Z", "1970-01-01T00:00:03.000Z"]) + + const update = PG.update(Notes).set({ body: "b" }).allRows() + const refused = yield* Effect.exit(PG.compile(update)) + assert(Exit.isFailure(refused) && Cause.hasDies(refused.cause)) + expect((yield* PG.compile(yield* PG.fillDefaults(update))).sql).toContain(`"updated_at" = `) + }).pipe(Effect.provide(IdGenLive)), + ) + + it.effect("leaves an onUpdate column with a database default to the database on insert", () => + Effect.gen(function* () { + const Stamped = PG.table("stamped", { + columns: { + id: PG.text, + at: PG.column(PG.timestamptz, { defaultExpr: "now()", onUpdate: DateTime.now }), + }, + }) + const insert = PG.insertInto(Stamped).values({ id: "x" }) + expect((yield* PG.compile(insert)).sql).toBe(`INSERT INTO "stamped" ("id")\nVALUES ($1)`) + expect(yield* PG.fillDefaults(insert)).toBe(insert) + const update = yield* PG.fillDefaults(PG.update(Stamped).set({ id: "y" }).allRows()) + expect((yield* PG.compile(update)).sql).toBe(`UPDATE "stamped" SET "id" = $1, "at" = $2`) + }), + ) + }) +}) diff --git a/src/database/database.ts b/src/database/database.ts index aaf89d4..540194e 100644 --- a/src/database/database.ts +++ b/src/database/database.ts @@ -14,6 +14,7 @@ import { isSqlError } from "effect/sql/SqlError" import { compileCH, compileUnion, CompiledQueryDecodeError, type CompiledQuery } from "../ch/compile" import { noTransactions, type Dialect, type IsolationLevel, type TransactionSettings } from "../ch/dialect" import type { QueryBuilderError } from "../ch/errors" +import { fillDefaults, type RequirementsOf } from "../ch/client-defaults" import type { AnyInsert } from "../ch/insert" import type { CHDelete, CHUpdate } from "../ch/update" import type { CHQuery, NeedsSelect } from "../ch/query" @@ -47,7 +48,7 @@ export type Runnable = | CHQuery | CHUnionQuery | AnyInsert - | CHUpdate + | CHUpdate | CHDelete | CompiledQuery @@ -103,12 +104,13 @@ export interface DatabaseApi { * Compile a query for this database's dialect, run it, and decode its rows. * A write returns its RETURNING rows, or none without `returning`. * `params` fills the query's `param.*` markers. A query compiled elsewhere - * runs as it is, if it was compiled for this dialect. + * runs as it is, if it was compiled for this dialect. A write first runs + * its table's client defaults (`fillDefaults`), so it requires what they need. */ readonly run: ( query: Q & RunCheck & ParamsSatisfied, Given>, params?: Given, - ) => Effect.Effect>, DatabaseError | QueryBuilderError | CompiledQueryDecodeError> + ) => Effect.Effect>, DatabaseError | QueryBuilderError | CompiledQueryDecodeError, RequirementsOf> /** Run a statement and return its rows, decoded through `schema` when given. */ readonly query: { (statement: StatementInput): Effect.Effect>, DatabaseError> @@ -316,11 +318,13 @@ export const fromSqlClient = (sql: SqlClient.SqlClient, options: FromSqlClientOp // does: through `command`, which a ClickHouse client needs for a statement // with no result set. const run: DatabaseApi["run"] = (runnable, params) => - Effect.flatMap(Effect.flatMap(compileFor(runnable, params ?? {}), checkScope), (compiled) => - compiled.kind !== "select" && compiled.returning === undefined - ? Effect.as(execute(compiled), []) - : Effect.flatMap(rows(compiled), (wire) => compiled.decodeRows(wire)), - ) + Effect.flatMap(fillDefaults(runnable as Runnable), (filled) => + Effect.flatMap(Effect.flatMap(compileFor(filled, params ?? {}), checkScope), (compiled) => + compiled.kind !== "select" && compiled.returning === undefined + ? Effect.as(execute(compiled), []) + : Effect.flatMap(rows(compiled), (wire) => compiled.decodeRows(wire)), + ), + ) as Effect.Effect const retryContention = (effect: Effect.Effect, retry?: RetryOptions) => Effect.flatMap(Effect.serviceOption(sql.transactionService), (open): Effect.Effect => @@ -486,8 +490,11 @@ export const layerSqlClient = (options: FromSqlClientOptions): Layer.Layer( query: Q & RunCheck & ParamsSatisfied, Given>, params?: Given, -): Effect.Effect>, DatabaseError | QueryBuilderError | CompiledQueryDecodeError, Database> => - Effect.flatMap(Effect.service(Database), (db) => db.run(query, params)) +): Effect.Effect< + ReadonlyArray>, + DatabaseError | QueryBuilderError | CompiledQueryDecodeError, + Database | RequirementsOf +> => Effect.flatMap(Effect.service(Database), (db) => db.run(query, params)) /** `query` on the `Database` in context. */ export const query: { diff --git a/src/schema/pg-define.ts b/src/schema/pg-define.ts index 956d461..6c4023b 100644 --- a/src/schema/pg-define.ts +++ b/src/schema/pg-define.ts @@ -7,13 +7,14 @@ // rendered once, here, with the Postgres dialect. A definition that cannot // become DDL records a problem, and `pgEntitiesOf` fails with it. -import { Schema } from "effect" +import { Schema, type Effect } from "effect" import { compile as compileFragment } from "../sql/sql-fragment" import { withDialect } from "../ch/dialect" import type { Condition, Expr } from "../ch/expr" import { encodeColumnLiteral } from "../ch/literal" +import type { InsertValue } from "../ch/insert" import { createColumnAccessor, type ColumnAccessor } from "../ch/query" -import type { Table } from "../ch/table" +import type { ClientDefaults, Table } from "../ch/table" import { columnSqlName, type CHType, type ColumnDefs, type InferTS } from "../ch/types" import type { PgType } from "../pg/types" import { postgresDialect } from "../pg/dialect" @@ -52,7 +53,7 @@ const renderKeyParts = (key: DdlKey, columns: Col // Columns -export interface ColumnOptions> { +export interface ColumnOptions, DefaultR = never, UpdateR = never> { /** * The column's name in the database, when it differs from its key in * `columns`: `orgId: PG.column(PG.text, { name: "org_id" })` is read and @@ -73,6 +74,20 @@ export interface ColumnOptions> { * `__unique`, drizzle-orm's name; a string names it. */ readonly unique?: true | string | UniqueColumnOptions + /** + * A client-side default: an Effect that `Database.run` runs for each row + * an insert leaves the column out of, such as `DateTime.now` or an id from + * a service. Its requirements become the insert's. No DDL. + */ + readonly defaultFn?: Effect.Effect, never, DefaultR> + /** + * A client-side value for every update: an Effect that `Database.run` runs + * once for an update that does not set the column, such as `DateTime.now` + * for an `updatedAt`. An insert that leaves the column out runs it too, + * when the column has no other default. Its requirements become the + * update's. No DDL. + */ + readonly onUpdate?: Effect.Effect, never, UpdateR> } export interface UniqueColumnOptions { @@ -82,35 +97,46 @@ export interface UniqueColumnOptions { readonly nullsNotDistinct?: boolean } -/** `Given` records which options were set, so the table knows which columns an insert may leave out. */ +/** + * `Given` records which options were set, so the table knows which columns an + * insert may leave out. `DefaultR` and `UpdateR` are what its `defaultFn` and + * `onUpdate` need. + */ export interface ColumnSpec< T extends CHType, Given extends keyof ColumnOptions = keyof ColumnOptions, + DefaultR = never, + UpdateR = never, > { readonly _tag: "PgColumnSpec" readonly type: T - readonly options: ColumnOptions + readonly options: ColumnOptions readonly _given?: Given } /** A column with a default. A bare column type works too where none is needed. */ -export const column = , Given extends keyof ColumnOptions = never>( +export const column = < + T extends CHType, + Given extends keyof ColumnOptions = never, + DefaultR = never, + UpdateR = never, +>( type: T, - options: ColumnOptions & { readonly [K in Given]: unknown } = {} as ColumnOptions & { + options: ColumnOptions & { readonly [K in Given]: unknown } = {} as ColumnOptions & { readonly [K in Given]: unknown }, -): ColumnSpec => ({ _tag: "PgColumnSpec", type, options }) +): ColumnSpec, NoInfer> => ({ _tag: "PgColumnSpec", type, options }) -export type ColumnInput = CHType | ColumnSpec, any> +export type ColumnInput = CHType | ColumnSpec, any, any, any> /** The query-side column types of a `columns` record. */ export type ColumnsOf> = { - readonly [K in keyof I]: I[K] extends ColumnSpec ? T : I[K] extends CHType ? I[K] : never + readonly [K in keyof I]: I[K] extends ColumnSpec ? T : I[K] extends CHType ? I[K] : never } -/** Columns declared with `default`, `defaultExpr` or `identity`: an insert may leave them out. */ +/** Columns declared with `default`, `defaultExpr`, `identity`, `defaultFn` or `onUpdate`: an insert may leave them out. */ export type DefaultedColumnsOf> = { - [K in keyof I]: I[K] extends ColumnSpec + [K in keyof I]: I[K] extends ColumnSpec ? [Exclude] extends [never] ? never : K @@ -118,7 +144,24 @@ export type DefaultedColumnsOf> = { }[keyof I] & string -const isColumnSpec = (input: ColumnInput): input is ColumnSpec, any> => +/** + * What an insert into a table with these columns requires: each `defaultFn`'s + * requirements, or the `onUpdate`'s of a column without one. + */ +export type InsertRequirementsOf> = { + [K in keyof I]: I[K] extends ColumnSpec + ? "defaultFn" extends Given + ? DefaultR + : UpdateR + : never +}[keyof I] + +/** What an update of a table with these columns requires: each `onUpdate`'s requirements. */ +export type UpdateRequirementsOf> = { + [K in keyof I]: I[K] extends ColumnSpec ? UpdateR : never +}[keyof I] + +const isColumnSpec = (input: ColumnInput): input is ColumnSpec, any, any, any> => "_tag" in input && input._tag === "PgColumnSpec" /** The query-side type of a column input, carrying its `name` as `sqlName`. */ @@ -340,8 +383,13 @@ export interface TableDdl { readonly enums: ReadonlyArray } -export interface PgSchemaTable - extends Table { +export interface PgSchemaTable< + Name extends string, + Cols extends ColumnDefs, + Defaulted extends string = string, + InsertR = never, + UpdateR = never, +> extends Table { readonly ddl: TableDdl /** What is wrong with the definition; `pgEntitiesOf` fails when any table has one. */ readonly problems: ReadonlyArray @@ -401,7 +449,7 @@ const columnDefault = ( problems: ProblemSink, table: string, name: string, - spec: ColumnSpec>, + spec: ColumnSpec, any, any, any>, columns: ColumnDefs, ): string | null => { const { options } = spec @@ -424,31 +472,65 @@ const columnDefault = ( export function table>( name: Name, definition: ExternalTableDefinition, -): Table, DefaultedColumnsOf, never> +): Table< + Name, + ColumnsOf, + DefaultedColumnsOf, + never, + InsertRequirementsOf, + UpdateRequirementsOf +> export function table>( name: Name, definition: TableDefinition, -): PgSchemaTable, DefaultedColumnsOf> +): PgSchemaTable< + Name, + ColumnsOf, + DefaultedColumnsOf, + InsertRequirementsOf, + UpdateRequirementsOf +> export function table>( name: Name, definition: TableDefinition | ExternalTableDefinition, -): PgSchemaTable, DefaultedColumnsOf> | Table { +): PgSchemaTable, DefaultedColumnsOf, never, never> | Table { if ("external" in definition) { - return externalTable( + const external = externalTable( name, definition, - (input): input is ColumnSpec> => isColumnSpec(input as ColumnInput), + (input): input is ColumnSpec, any, any, any> => isColumnSpec(input as ColumnInput), [], - (spec) => columnTypeOfInput(spec as ColumnSpec>), + (spec) => columnTypeOfInput(spec as ColumnSpec, any, any, any>), ) + const clientDefaults = clientDefaultsOf(definition.columns) + return clientDefaults === undefined ? external : { ...external, clientDefaults } } return withRenderProblems(name, () => buildTable(name, definition)) } +/** + * The Effects behind `defaultFn` and `onUpdate`, or `undefined` when no column + * has one. An insert runs a column's `defaultFn`, or its `onUpdate` when it + * has no default of any kind. + */ +const clientDefaultsOf = (columns: Record): ClientDefaults | undefined => { + const insert: Record> = {} + const update: Record> = {} + for (const [column, input] of Object.entries(columns)) { + if (!isColumnSpec(input)) continue + const { defaultFn, onUpdate, default: literal, defaultExpr, identity } = input.options + const databaseDefault = literal !== undefined || defaultExpr !== undefined || identity !== undefined + if (defaultFn !== undefined) insert[column] = defaultFn + else if (onUpdate !== undefined && !databaseDefault) insert[column] = onUpdate + if (onUpdate !== undefined) update[column] = onUpdate + } + return Object.keys(insert).length === 0 && Object.keys(update).length === 0 ? undefined : { insert, update } +} + function buildTable>( name: Name, definition: TableDefinition, -): PgSchemaTable, DefaultedColumnsOf> { +): PgSchemaTable, DefaultedColumnsOf, never, never> { const problems: ProblemSink = [] checkIdentifier(problems, name, name) const inputs = Object.entries(definition.columns) @@ -459,7 +541,7 @@ function buildTable { checkIdentifier(problems, `${name}.${column}`, sqlName(column)) - const spec: ColumnSpec> = isColumnSpec(input) + const spec: ColumnSpec, any, any, any> = isColumnSpec(input) ? input : { _tag: "PgColumnSpec", type: input as CHType, options: {} } return { @@ -590,12 +672,14 @@ function buildTable columnEntities[position]!.default !== null || columnEntities[position]!.identity !== null) .map(([column]) => column) + const clientDefaults = clientDefaultsOf(definition.columns) return { _tag: "Table", name, columns: types, ...(definition.tenantColumn !== undefined ? { tenantColumn: definition.tenantColumn } : undefined), ...(defaults.length > 0 ? { defaults: defaults as unknown as Array> } : undefined), + ...(clientDefaults !== undefined ? { clientDefaults } : undefined), ddl: { dialect: "postgres", table: tableEntity, From 8588e884b0c827be83caed49937a90ec375c4d23 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:31:47 +0200 Subject: [PATCH 2/2] docs: client-side defaults, with a changeset Co-Authored-By: Claude Opus 5.5 --- .changeset/client-defaults.md | 5 ++++ design/gap-review.md | 3 ++- docs/database.md | 2 +- docs/inserts.md | 47 +++++++++++++++++++++++++++++++++++ docs/reference.md | 11 +++++++- docs/updates-and-deletes.md | 7 ++++++ 6 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 .changeset/client-defaults.md diff --git a/.changeset/client-defaults.md b/.changeset/client-defaults.md new file mode 100644 index 0000000..0b6899d --- /dev/null +++ b/.changeset/client-defaults.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-orm": minor +--- + +`PG.column` takes client-side defaults as Effects: `defaultFn` runs for each row an insert leaves the column out of, and `onUpdate` for each update that does not set it (and for an insert, when the column has no other default). An Effect can read the `Clock`, `Random` or a service, so `TestClock` and a test layer give exact values in tests. Such a column is optional in the insert row type, and the Effect's requirements become the write's: `Db.run(PG.insertInto(Notes).values(...))` requires `IdGen` when `id` is `{ defaultFn: IdGen.next }`. `Database.run` runs them; `fillDefaults(write)` runs them for a write you compile yourself, and `compile` refuses a write that still leaves one out. `Table` and `PgSchemaTable` gain two type parameters for the requirements; they default to `never`. diff --git a/design/gap-review.md b/design/gap-review.md index 852c5eb..11272ea 100644 --- a/design/gap-review.md +++ b/design/gap-review.md @@ -45,7 +45,8 @@ builder; **P1** commonly used; **P2** niche. - ClickHouse: FINAL, PREWHERE, LIMIT BY, SETTINGS on SELECT, the ARRAY JOIN clause. - Writes: UPDATE ... FROM with a typed VALUES source; ClickHouse `ALTER TABLE ... UPDATE/DELETE` and lightweight DELETE; JSONEachRow bulk insert (`encodeInsertRows`); a set-all-from-`excluded` - helper; client-side default hooks like `$defaultFn` / `$onUpdate`. + helper; ~~client-side default hooks like `$defaultFn` / `$onUpdate`~~ (built: `defaultFn` / + `onUpdate` on `PG.column`, as Effects). - Runtime: `runFirst` / `runSingle` / `stream`; `observe` with duration, rows and error; replica routing for `route()`; a `SET LOCAL` helper for RLS; a mock `Database` for users' tests. - Types: select, insert and update row types and Schemas per table. diff --git a/docs/database.md b/docs/database.md index 2e0cccd..0580e6d 100644 --- a/docs/database.md +++ b/docs/database.md @@ -83,7 +83,7 @@ Calling `withdraw(1, 30)` outside `transfer` does not compile: `requireTransacti | `fromSqlClient(sql, options)` | A `DatabaseApi` over a client. `options.dialect` is required; `options.requireTenantScope` refuses unscoped queries ([Tenant scoping](./tenant-scoping.md#enforcing-it)) | | `layerSqlClient(options)` | A `Database` layer over the `SqlClient` in context | | `Database` | The service | -| `run(query, params?)` | Compile a query for the database's dialect, run it, decode its rows | +| `run(query, params?)` | Compile a query for the database's dialect, run it, decode its rows. A write first runs its table's [client-side defaults](./inserts.md#client-side-defaults) and requires what they need | | `sql\`...\`` | A statement with every `${value}` bound; `sql.identifier(name)` for a table or column name | | `query(statement, schema?)` | Run a statement and return its rows, decoded when a schema is given | | `execute(statement)` | Run a statement and discard its rows | diff --git a/docs/inserts.md b/docs/inserts.md index 407ba91..428654c 100644 --- a/docs/inserts.md +++ b/docs/inserts.md @@ -51,6 +51,8 @@ The row type is derived from the column options that also write the [DDL](./migr it cannot drift from what the database fills in: - `default` or `defaultExpr` (both dialects) or `identity` (Postgres) makes a column optional. +- `defaultFn` or `onUpdate` (Postgres) makes a column optional too. The client fills it, not the + database: see [client-side defaults](#client-side-defaults). - `materialized` or `alias` (ClickHouse) makes a column not writable: it is not in the row type, and a row that names it anyway fails to compile. It stays readable. @@ -67,6 +69,50 @@ ClickHouse fills every column it is not given with a default, even without a `DE `0` for a number, `''` for a string. The row type still requires those columns unless they declare a default, so a forgotten value is a type error rather than a silent zero. +### Client-side defaults + +A `PG.column` can take a default that the client computes: `defaultFn` and `onUpdate` are +Effects, so they can read the `Clock`, `Random` or a service. They write no DDL. + +```ts +import { Context, DateTime, Effect } from "effect" +import * as Db from "@maple-dev/effect-orm/database" +import * as PG from "@maple-dev/effect-orm/postgres" + +class IdGen extends Context.Service }>()("IdGen") {} + +const Notes = PG.table("notes", { + columns: { + id: PG.column(PG.text, { defaultFn: Effect.flatMap(Effect.service(IdGen), (gen) => gen.next) }), + body: PG.text, + createdAt: PG.column(PG.timestamptz, { defaultFn: DateTime.now }), + updatedAt: PG.column(PG.timestamptz, { onUpdate: DateTime.now }), + }, + primaryKey: ["id"], +}) + +// Effect, ..., Database | IdGen> +const add = Db.run(PG.insertInto(Notes).values({ body: "hello" })) +``` + +- `Database.run` runs `defaultFn` once for each row that leaves its column out (or gives + `undefined`). A value the row gives, `null` included, is kept, and the Effect does not run. +- `onUpdate` runs once for each update that does not set its column (see + [Updating](./updates-and-deletes.md#set)). An insert that leaves the column out runs it too, + when the column has no `defaultFn`, `default`, `defaultExpr` or `identity`. +- What the Effects need is in the type of the write: the insert above requires `IdGen`, and + `Db.run` passes the requirement on. `DateTime.now` needs only the `Clock`, which every Effect + has, so it adds nothing. In a test, `TestClock` and a test `IdGen` layer give exact values. +- The Effects cannot fail: their error type is `never`. A default that can fail should handle + the failure itself. +- `compile` does not run Effects. To compile such a write yourself, pass it through + `fillDefaults` first: `Effect.flatMap(PG.fillDefaults(insert), (filled) => PG.compile(filled))`. + `compile` refuses (`QueryBuilderDefect`) a write that still leaves out a client default. +- An `INSERT ... SELECT` and `onConflictDoUpdate`'s SET get no client defaults; select or set + the column yourself. + +_(Backed by `src/database/client-defaults.test.ts` and `src/database/client-defaults.test-d.ts`.)_ + ## What it compiles to Columns are written in table order, whatever order the keys are in, so two rows with their keys @@ -240,6 +286,7 @@ A batch that mixes tenants on purpose, such as an ingest flush, declares it with | A param with no value | `QueryBuilderError` `UnresolvedParam` | | Over the dialect's bound-value limit | `QueryBuilderError` `InvalidArguments` | | Compiling without `values` | `QueryBuilderDefect` | +| Compiling a row that leaves out a client default | `QueryBuilderDefect` | | `returning` for a dialect without RETURNING | `QueryBuilderDefect` | | `onConflictDoUpdate` setting no or unknown columns | `QueryBuilderError` `InvalidArguments` | | `onConflict*` without ON CONFLICT, a bad target | `QueryBuilderDefect` | diff --git a/docs/reference.md b/docs/reference.md index abc2d46..713df99 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -47,6 +47,7 @@ Everything in this section is exported, identically, from both `/clickhouse` and | `update` | `(table) => CHUpdateStart`, then `CHUpdate`: `.set(record \| fn)`, `.where(fn)` or `.allRows()`, `.returning(...)`, `.settings(record)`. See [Updating and deleting](./updates-and-deletes.md) | | `deleteFrom` | `(table) => CHDelete`: `.where(fn)` or `.allRows()`, `.returning(...)`, `.settings(record)` | | `insertInto` | `(table) => CHInsertStart`, then `CHInsert`; `.values(row \| rows)` or `.select(query)` sets its rows, `.settings(record)` ClickHouse `SETTINGS`, `.returning(...)` the RETURNING list, `.onConflictDoNothing(options?)` / `.onConflictDoUpdate(options)` the ON CONFLICT clause (Postgres). See [Inserting rows](./inserts.md) | +| `fillDefaults` | `(write) => Effect>`: runs the `defaultFn` / `onUpdate` Effects of an insert's or update's table into its rows or SET. `Database.run` calls it; call it before `compile` when you compile a write yourself. See [client-side defaults](./inserts.md#client-side-defaults) | Tables come from the dialect's `table`: [`CH.table`](#tables-and-ddl) or [`PG.table`](#tables-and-ddl-1). @@ -181,6 +182,10 @@ decoded type of a column), `InferEncoded` (its wire type), `ColumnDefs`, `Nullab **Inference** — `InferOutput`, `InferQueryOutput`, `InferUnionOutput`, `SelectRowOf`, `InsertRow`, `InsertRowOf`, `UpdateSet`, `UpdateSetOf`. +**Client-side defaults** — `RequirementsOf` (what a write's client defaults need; `never` for +anything else), `ClientDefaults` (the `insert` and `update` Effects per column, on +`Table.clientDefaults`). + **What `compile` checks** — `NeedsSelect` (the query has a SELECT list) and `ParamsSatisfied` (the params object fills every required param). A helper generic over a query's output takes `CHQuery<…, Output> & NeedsSelect` so it can pass the query on @@ -453,7 +458,11 @@ const Users = PG.table("users", { `/clickhouse`: a view, a catalog table, one another tool migrates. No DDL. **`ColumnOptions`** — one of `default`, `defaultExpr`, `identity` (`"always"` or -`"by default"`); any of them lets an insert leave the column out (`DefaultedColumnsOf`). Also +`"by default"`); any of them lets an insert leave the column out (`DefaultedColumnsOf`). +`defaultFn` and `onUpdate` are client-side defaults: an `Effect` of a value of the column's type +that cannot fail, run by `Database.run` for an insert that leaves the column out and an update +that does not set it. Either one lets an insert leave the column out, and its requirements +become the write's ([client-side defaults](./inserts.md#client-side-defaults)). Also `name` (the column's name in SQL) and `unique` (`true`, a constraint name, or `UniqueColumnOptions`: `{ name?, nullsNotDistinct? }`), which names a one-column constraint `
__unique` by default. diff --git a/docs/updates-and-deletes.md b/docs/updates-and-deletes.md index d0abb33..f6ef067 100644 --- a/docs/updates-and-deletes.md +++ b/docs/updates-and-deletes.md @@ -35,6 +35,13 @@ names the record type. `update(table)` offers only `set` until it has one (its type is `CHUpdateStart`). +A Postgres column with `onUpdate` (an Effect, see +[client-side defaults](./inserts.md#client-side-defaults)) is set by every update that does not +set it: `Database.run` runs the Effect once and adds the value to the SET. With a SET callback, +the Effect runs before the callback, and a value the callback gives wins. The update requires +what the Effect needs. `compile` refuses an update that does not set the column, unless it went +through `fillDefaults` first. + ## WHERE, and writing every row `where` works as in a query: a list of conditions, AND-joined, with an `undefined` one skipped,