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, 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,