diff --git a/apps/telemetry-backend/src/prisma/contract.d.ts b/apps/telemetry-backend/src/prisma/contract.d.ts index 68dfc2d907..ba84c5aaf6 100644 --- a/apps/telemetry-backend/src/prisma/contract.d.ts +++ b/apps/telemetry-backend/src/prisma/contract.d.ts @@ -27,7 +27,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:41700ef5fda97339b39ea345a56aae72a1ff4be11ddc3ffcab7130bfc71c109d'>; + StorageHashBase<'sha256:8f73b933408b7f9c5a640c9e66325d74cdafe006ca933103dcf5fbc528cb08a8'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e'>; @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly TelemetryEvent: { @@ -97,16 +94,13 @@ type ContractBase = ContractType< readonly nativeType: 'int8'; readonly codecId: 'pg/int8@1'; readonly nullable: false; - readonly default: { - readonly kind: 'function'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly ingestedAt: { readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly installationId: { readonly nativeType: 'text'; diff --git a/apps/telemetry-backend/src/prisma/contract.json b/apps/telemetry-backend/src/prisma/contract.json index e034829982..bc9553fa1c 100644 --- a/apps/telemetry-backend/src/prisma/contract.json +++ b/apps/telemetry-backend/src/prisma/contract.json @@ -211,8 +211,7 @@ "id": { "codecId": "pg/int8@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int8", "nullable": false @@ -221,7 +220,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -280,7 +279,7 @@ } } }, - "storageHash": "sha256:41700ef5fda97339b39ea345a56aae72a1ff4be11ddc3ffcab7130bfc71c109d" + "storageHash": "sha256:8f73b933408b7f9c5a640c9e66325d74cdafe006ca933103dcf5fbc528cb08a8" }, "capabilities": { "postgres": { diff --git a/drive/calibration/failure-modes.md b/drive/calibration/failure-modes.md index 176ba6f715..90caefa6b9 100644 --- a/drive/calibration/failure-modes.md +++ b/drive/calibration/failure-modes.md @@ -100,6 +100,25 @@ Patterns to **catch** the F-family modes live in [`grep-library.md`](./grep-libr **Reference incident.** 2026-05-17, a family-sql M-sized migration dispatch apparently ran a setup cleanup (likely `git clean -fd`) that deleted an in-flight methodology project directory (~1500 lines of untracked docs). Survived only because the orchestrator had the content in conversation context and could re-write it. +### F6. Closed-set typing that satisfies enumeration-only tests while violating open-set spec promise + +**Symptom.** The implementation types a value as a closed union of N concrete shapes (e.g. `string | number | Date | bigint | Uint8Array`). The tests exercise exactly those N shapes — strings, numbers, Dates, bigints, Uint8Arrays — and they compile, so the gate goes green. But the spec or NFR demands an *open set* defined by some structural extractor (a codec descriptor's `TInput`, a target's column-type contract, an extension-provided schema). Values outside the closed union but inside the extractor's range fail to compile despite the spec promising they will. The reviewer sees green tests + matching enumeration in the implementation and signs off; the orchestrator's intent-validation step does not probe whether the test set non-trivially exercises the spec's open-set promise. + +**Detection signal.** + +- The implementation's value-input type is a finite union literal (no `extends infer T`, no extractor at the type level). +- Every test case's value is a member of the implementation's union literal — bytewise. +- The spec or NFR uses words like "codec-defined", "target-defined", "extension-defined", "user-defined", "any shape the X admits", "open-set", "branded types". +- Test names enumerate the spec's example list ("accepts string", "accepts number", "accepts Date") rather than probe the open-set property ("accepts an arbitrary branded type", "accepts an extension-owned class instance"). + +**Mitigation.** + +- When a spec NFR / AC references "codec-defined / target-defined / extension-defined / branded" types, the test set must exercise **at least one type the implementation cannot enumerate** — a branded type, a synthetic codec's `TInput`, an extension-owned class instance, or any other value that fails to compile under the closed union and only compiles when the extractor is real. +- The reviewer's checklist asks: "is at least one test case impossible to satisfy under a hand-coded closed union of the spec's example list?" If no — the test set is enumeration-only and the open-set promise is unverified. +- The orchestrator's intent-validation step probes whether the test set is tautological: name each test case's value, name each implementation-union member, and check whether the two sets are identical. Identical sets are a red flag. + +**Reference incident.** 2026-05-21 D10. The SQL DSL `.default(value)` parameter was typed `SqlDslLiteralInput = ColumnDefaultLiteralInputValue | bigint | Uint8Array` (a closed enumeration). The existing AC test (`contract-builder.default.test-d.ts`) exercised exactly those shapes — a string, a number, `null`, an object, a `Date`, a `bigint`, a `Uint8Array` — all members of the closed union. The spec NFR2 said: "JS-native default values pass through without JSON round-trips in the TS DSL. Date, bigint, Buffer, Uint8Array, **and codec-defined branded types** are accepted by `.default(...)` directly, where the codec's `TInput` admits them." The reviewer passed the dispatch (D2 R1); the orchestrator's intent-validation step did not probe the codec-defined arm. Caught by the user reading the implementation. Fixed by replacing the closed union with `CodecInputForDescriptor>`, which reads the codec's `TInput` off the descriptor's `codecFactory` slot, and by adding branded-type / nominal-class test cases that fail to compile under the closed union. + ## Slice-shape scope traps Patterns that have produced scope creep in the past — catch these at triage or slice-spec time, not at execution time. diff --git a/examples/cipherstash-integration/src/prisma/contract.d.ts b/examples/cipherstash-integration/src/prisma/contract.d.ts index a676ac70e7..f6b1b54413 100644 --- a/examples/cipherstash-integration/src/prisma/contract.d.ts +++ b/examples/cipherstash-integration/src/prisma/contract.d.ts @@ -44,9 +44,6 @@ export type CodecTypes = PgTypes & CipherstashTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & CipherstashQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly User: { diff --git a/examples/paradedb-demo/src/prisma/contract.d.ts b/examples/paradedb-demo/src/prisma/contract.d.ts index 5f145b1621..c0d564ae40 100644 --- a/examples/paradedb-demo/src/prisma/contract.d.ts +++ b/examples/paradedb-demo/src/prisma/contract.d.ts @@ -37,9 +37,6 @@ export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & ParadeDbQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Item: { diff --git a/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts b/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts index dc2f7cc062..4c340876f3 100644 --- a/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts +++ b/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts @@ -27,7 +27,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:17efb9380d28aece136dac05fd18e62d63d164332bae788d13e3e4339b486839'>; + StorageHashBase<'sha256:335d19f9d862a812f18a5f040982b6157c3a606008db953e752aff28d23c5ba0'>; export type ExecutionHash = ExecutionHashBase<'sha256:516d134296237bb5f427dfe28f42f79077d0b72cbcae281fdd1ba3c974b9568e'>; export type ProfileHash = @@ -36,9 +36,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -187,7 +184,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -233,8 +230,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'::text"; }; }; readonly type: { @@ -251,7 +248,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -296,7 +293,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-cloudflare-worker/src/prisma/contract.json b/examples/prisma-next-cloudflare-worker/src/prisma/contract.json index c04af7b581..84be9356af 100644 --- a/examples/prisma-next-cloudflare-worker/src/prisma/contract.json +++ b/examples/prisma-next-cloudflare-worker/src/prisma/contract.json @@ -423,7 +423,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -481,7 +481,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -502,8 +502,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "expression": "'open'::text", + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -563,7 +563,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -622,7 +622,7 @@ } } }, - "storageHash": "sha256:17efb9380d28aece136dac05fd18e62d63d164332bae788d13e3e4339b486839" + "storageHash": "sha256:335d19f9d862a812f18a5f040982b6157c3a606008db953e752aff28d23c5ba0" }, "execution": { "executionHash": "sha256:516d134296237bb5f427dfe28f42f79077d0b72cbcae281fdd1ba3c974b9568e", diff --git a/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts b/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts index d7cf1e288a..e80ab08f44 100644 --- a/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts @@ -15,7 +15,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:8ea47fc08b79e387a9136d5a4ac708723e4941a7b3cffe71d57ceca09229a486'>; + StorageHashBase<'sha256:2e6a5d2aa8394b2afb958a80fa950bc04c0fb8c6646afb4ceed34267b6f976bd'>; export type ExecutionHash = ExecutionHashBase<'sha256:0903547be862dca3fa2dbc62a85cd52e9ca595f00cf43b6b26a3da3d4b9740ae'>; export type ProfileHash = @@ -24,19 +24,16 @@ export type ProfileHash = export type CodecTypes = SqliteTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = Record; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Post: { - readonly id: CodecTypes['sql/char@1']['output']; + readonly id: Char<36>; readonly title: CodecTypes['sqlite/text@1']['output']; - readonly userId: CodecTypes['sql/char@1']['output']; + readonly userId: Char<36>; readonly createdAt: CodecTypes['sqlite/datetime@1']['output']; }; readonly User: { - readonly id: CodecTypes['sql/char@1']['output']; + readonly id: Char<36>; readonly email: CodecTypes['sqlite/text@1']['output']; readonly displayName: CodecTypes['sqlite/text@1']['output']; readonly createdAt: CodecTypes['sqlite/datetime@1']['output']; @@ -93,7 +90,7 @@ type ContractBase = ContractType< readonly nativeType: 'text'; readonly codecId: 'sqlite/datetime@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -139,7 +136,7 @@ type ContractBase = ContractType< readonly nativeType: 'text'; readonly codecId: 'sqlite/datetime@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; diff --git a/examples/prisma-next-demo-sqlite/src/prisma/contract.json b/examples/prisma-next-demo-sqlite/src/prisma/contract.json index 68b1719fa9..02994edf0e 100644 --- a/examples/prisma-next-demo-sqlite/src/prisma/contract.json +++ b/examples/prisma-next-demo-sqlite/src/prisma/contract.json @@ -155,7 +155,7 @@ "codecId": "sqlite/datetime@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -217,7 +217,7 @@ "codecId": "sqlite/datetime@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -253,7 +253,7 @@ } } }, - "storageHash": "sha256:8ea47fc08b79e387a9136d5a4ac708723e4941a7b3cffe71d57ceca09229a486" + "storageHash": "sha256:2e6a5d2aa8394b2afb958a80fa950bc04c0fb8c6646afb4ceed34267b6f976bd" }, "execution": { "executionHash": "sha256:0903547be862dca3fa2dbc62a85cd52e9ca595f00cf43b6b26a3da3d4b9740ae", diff --git a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.d.ts index f913830a56..285c6027ad 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -189,7 +186,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -233,8 +230,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -251,7 +248,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -283,7 +280,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.json b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.json index fb2a865bb6..0f430718ab 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.json @@ -403,8 +403,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -456,8 +456,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -478,8 +478,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -527,8 +527,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.d.ts index 0cabe8344a..4267ddb722 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -191,7 +188,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -235,8 +232,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -253,7 +250,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -290,7 +287,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.json b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.json index 4bf16cd064..b129cb8305 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.json @@ -413,8 +413,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -466,8 +466,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -488,8 +488,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -537,8 +537,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.d.ts index f913830a56..285c6027ad 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -189,7 +186,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -233,8 +230,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -251,7 +248,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -283,7 +280,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.json b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.json index fb2a865bb6..0f430718ab 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.json @@ -403,8 +403,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -456,8 +456,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -478,8 +478,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -527,8 +527,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.d.ts index f2a52069bf..23c42c90b9 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -191,7 +188,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -235,8 +232,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -253,7 +250,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -290,7 +287,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.json b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.json index 208e0efb3e..d1f0e885c8 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.json @@ -413,8 +413,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -466,8 +466,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -488,8 +488,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -537,8 +537,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.d.ts index 0cabe8344a..4267ddb722 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -191,7 +188,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -235,8 +232,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -253,7 +250,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -290,7 +287,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.json b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.json index 4bf16cd064..b129cb8305 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.json @@ -413,8 +413,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -466,8 +466,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -488,8 +488,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -537,8 +537,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.d.ts index ab2c5d2928..dcc58c4537 100644 --- a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.d.ts @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -189,7 +186,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -233,8 +230,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -251,7 +248,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -288,7 +285,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.json b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.json index e10d490d70..9ddc10409a 100644 --- a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.json @@ -434,8 +434,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -493,8 +493,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -515,8 +515,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -570,8 +570,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.d.ts index f2a52069bf..23c42c90b9 100644 --- a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -191,7 +188,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -235,8 +232,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -253,7 +250,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -290,7 +287,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.json b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.json index 208e0efb3e..d1f0e885c8 100644 --- a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.json @@ -413,8 +413,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -466,8 +466,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -488,8 +488,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -537,8 +537,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/src/prisma/contract.d.ts b/examples/prisma-next-demo/src/prisma/contract.d.ts index 1245f1b87c..a2a2315f74 100644 --- a/examples/prisma-next-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo/src/prisma/contract.d.ts @@ -30,7 +30,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:7926114e786c1a8fcc103d295a0c7fe5eb414b669ebb5b06a1e816d0019cbe7f'>; + StorageHashBase<'sha256:c57aaba9595d32bbb3c0101fbf63b80efb91e20a4335b091c0977885d29831b8'>; export type ExecutionHash = ExecutionHashBase<'sha256:516d134296237bb5f427dfe28f42f79077d0b72cbcae281fdd1ba3c974b9568e'>; export type ProfileHash = @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -193,7 +190,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -245,8 +242,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'::text"; }; }; readonly type: { @@ -263,7 +260,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -308,7 +305,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/src/prisma/contract.json b/examples/prisma-next-demo/src/prisma/contract.json index a303f372aa..ce34b94ea0 100644 --- a/examples/prisma-next-demo/src/prisma/contract.json +++ b/examples/prisma-next-demo/src/prisma/contract.json @@ -433,7 +433,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -497,7 +497,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -518,8 +518,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "expression": "'open'::text", + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -579,7 +579,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -638,7 +638,7 @@ } } }, - "storageHash": "sha256:7926114e786c1a8fcc103d295a0c7fe5eb414b669ebb5b06a1e816d0019cbe7f", + "storageHash": "sha256:c57aaba9595d32bbb3c0101fbf63b80efb91e20a4335b091c0977885d29831b8", "types": { "Embedding1536": { "codecId": "pg/vector@1", diff --git a/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts b/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts index 35a3e903a0..bc5dfb7f74 100644 --- a/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PostgisTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PostgisQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Cafe: { diff --git a/examples/react-router-demo/src/prisma/contract.d.ts b/examples/react-router-demo/src/prisma/contract.d.ts index ec8c649648..0f8cbfef18 100644 --- a/examples/react-router-demo/src/prisma/contract.d.ts +++ b/examples/react-router-demo/src/prisma/contract.d.ts @@ -27,7 +27,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:5d2b2c2468240c79ee5f380951267aa777888966aeba3bb19d573825189e7816'>; + StorageHashBase<'sha256:34be6468b5ac2a4785cb4b670ac355c919cce3011094b349655d607dfc9c581b'>; export type ExecutionHash = ExecutionHashBase<'sha256:8c5eef43d2153fd832b8288ed2d8ffc9f5afb62908f8b4b7e6a4b7018444c41f'>; export type ProfileHash = @@ -36,9 +36,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Post: { @@ -102,7 +99,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -142,7 +139,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; diff --git a/examples/react-router-demo/src/prisma/contract.json b/examples/react-router-demo/src/prisma/contract.json index c32e1a9649..df17fcfddf 100644 --- a/examples/react-router-demo/src/prisma/contract.json +++ b/examples/react-router-demo/src/prisma/contract.json @@ -143,7 +143,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -201,7 +201,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -232,7 +232,7 @@ } } }, - "storageHash": "sha256:5d2b2c2468240c79ee5f380951267aa777888966aeba3bb19d573825189e7816" + "storageHash": "sha256:34be6468b5ac2a4785cb4b670ac355c919cce3011094b349655d607dfc9c581b" }, "execution": { "executionHash": "sha256:8c5eef43d2153fd832b8288ed2d8ffc9f5afb62908f8b4b7e6a4b7018444c41f", diff --git a/packages/1-framework/0-foundation/contract/src/exports/types.ts b/packages/1-framework/0-foundation/contract/src/exports/types.ts index 3159589645..d2d88fc486 100644 --- a/packages/1-framework/0-foundation/contract/src/exports/types.ts +++ b/packages/1-framework/0-foundation/contract/src/exports/types.ts @@ -21,9 +21,6 @@ export type { export type { $, Brand, - ColumnDefault, - ColumnDefaultLiteralInputValue, - ColumnDefaultLiteralValue, ContractMarkerRecord, DocCollection, DocIndex, @@ -46,8 +43,6 @@ export type { export { coreHash, executionHash, - isColumnDefault, - isColumnDefaultLiteralInputValue, isExecutionMutationDefaultValue, profileHash, } from '../types'; diff --git a/packages/1-framework/0-foundation/contract/src/types.ts b/packages/1-framework/0-foundation/contract/src/types.ts index c01b2dc765..a1f44d98cf 100644 --- a/packages/1-framework/0-foundation/contract/src/types.ts +++ b/packages/1-framework/0-foundation/contract/src/types.ts @@ -76,51 +76,6 @@ export type JsonValue = | { readonly [key: string]: JsonValue } | readonly JsonValue[]; -export type ColumnDefaultLiteralValue = JsonValue; - -export type ColumnDefaultLiteralInputValue = ColumnDefaultLiteralValue | Date; - -/** - * Runtime predicate for `ColumnDefaultLiteralInputValue`. Authoring layers - * resolve template values from caller-supplied args (typed `unknown` at the - * boundary) and need to validate before constructing a `ColumnDefault`. - * Accepts JSON primitives, plain arrays/objects of JSON values, and `Date` - * instances. Rejects functions, class instances (other than `Date`), - * `undefined`, `bigint`, `symbol`, and arrays/objects containing those. - */ -export function isColumnDefaultLiteralInputValue( - value: unknown, -): value is ColumnDefaultLiteralInputValue { - if (value === null) return true; - const t = typeof value; - if (t === 'string' || t === 'number' || t === 'boolean') return true; - if (value instanceof Date) return true; - if (Array.isArray(value)) return value.every(isColumnDefaultLiteralInputValue); - if (t === 'object' && Object.getPrototypeOf(value) === Object.prototype) { - return Object.values(value as Record).every(isColumnDefaultLiteralInputValue); - } - return false; -} - -export type ColumnDefault = - | { - readonly kind: 'literal'; - readonly value: ColumnDefaultLiteralInputValue; - } - | { readonly kind: 'function'; readonly expression: string }; - -export function isColumnDefault(value: unknown): value is ColumnDefault { - if (typeof value !== 'object' || value === null) return false; - const kind = (value as { kind?: unknown }).kind; - if (kind === 'literal') { - return 'value' in value; - } - if (kind === 'function') { - return typeof (value as { expression?: unknown }).expression === 'string'; - } - return false; -} - export type ExecutionMutationDefaultValue = { readonly kind: 'generator'; readonly id: GeneratedValueSpec['id']; diff --git a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts index aeceece1fb..f3e6f35be4 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts @@ -1,7 +1,10 @@ export type { AuthoringArgRef, AuthoringArgumentDescriptor, + AuthoringColumnDefault, AuthoringColumnDefaultTemplate, + AuthoringColumnDefaultTemplateAutoincrement, + AuthoringColumnDefaultTemplateExpression, AuthoringContributions, AuthoringEntityContext, AuthoringEntityTypeDescriptor, diff --git a/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts b/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts index c12e0b15a5..8df08a1c8c 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts @@ -2,7 +2,7 @@ import type { JsonValue } from '@prisma-next/contract/types'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { Codec } from './codec'; -export type CodecTrait = 'equality' | 'order' | 'boolean' | 'numeric' | 'textual'; +export type CodecTrait = 'equality' | 'order' | 'boolean' | 'numeric' | 'textual' | 'autoincrement'; /** * Serializable codec identity carried by every codec-bearing AST node. diff --git a/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts b/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts index 4245c729d8..286d5712e2 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts @@ -1,13 +1,13 @@ /** - * `column()` packager + `ColumnSpec` shape + `ColumnHelperFor` variants for tying per-codec column helpers to their descriptor. + * `column()` packager + `ColumnSpec` shape + `ColumnHelperFor` variants for tying per-codec column helpers to their descriptor. * - * `ColumnSpec` extends {@link ColumnTypeDescriptor} so it remains a drop-in for contract authoring sites that consume `ColumnTypeDescriptor` shapes — both types live at the framework-components layer so the `extends` clause is real (no structural mirror). + * `ColumnSpec` extends {@link ColumnTypeDescriptor} so it remains a drop-in for contract authoring sites that consume `ColumnTypeDescriptor` shapes — both types live at the framework-components layer so the `extends` clause is real (no structural mirror). * - * `column()` is a trivial, non-polymorphic packager. Generic over `R` (the codec instance type returned by the descriptor's curried factory) and `P` (the typeParams record). The framework does NOT try to infer `R` and `P` from a descriptor — that path is the variance trap. Per-codec helpers absorb the descriptor relationship instead and tie themselves to their descriptor via `satisfies ColumnHelperFor` or `satisfies ColumnHelperForStrict`. + * `column()` is a trivial, non-polymorphic packager. Generic over `R` (the codec instance type returned by the descriptor's curried factory), `P` (the typeParams record), and `T` (the descriptor's `traits` tuple — surfaced so contract-authoring sites can read a column's traits at the static type level). The framework does NOT try to infer `R` and `P` from a descriptor — that path is the variance trap. Per-codec helpers absorb the descriptor relationship instead and tie themselves to their descriptor via `satisfies ColumnHelperFor` or `satisfies ColumnHelperForStrict`. */ import type { CodecDescriptor } from './codec-descriptor'; -import type { CodecInstanceContext } from './codec-types'; +import type { CodecInstanceContext, CodecTrait } from './codec-types'; /** * Authored column-type descriptor — the data shape an authoring site (PSL or TypeScript builders) attaches to a column to identify its codec and its native database type. @@ -21,35 +21,50 @@ export type ColumnTypeDescriptor = { readonly nativeType: string; readonly typeParams?: Record | undefined; readonly typeRef?: string; + readonly traits?: readonly CodecTrait[] | undefined; }; /** * Column spec carrying the codec factory closure alongside the {@link ColumnTypeDescriptor} fields. Codec authors return a `ColumnSpec` from per-codec column helpers; the runtime materializes the codec instance by calling `codecFactory(ctx)` once it knows the column's `CodecInstanceContext`. * * Extends {@link ColumnTypeDescriptor} so `ColumnSpec` instances flow directly into contract-authoring sites that consume the descriptor shape — no structural mirroring required. + * + * @template T The codec descriptor's `traits` tuple, surfaced at the static type level so contract-authoring sites (e.g. the SQL DSL's `.default(autoincrement())` gate) can read the literal traits a column carries. Per-codec helpers thread their descriptor's `traits` through this slot; helpers that omit traits collapse to `undefined` and consumers fall back to a no-traits behaviour. */ -export interface ColumnSpec | undefined> - extends ColumnTypeDescriptor { +export interface ColumnSpec< + R, + P extends Record | undefined, + T extends readonly CodecTrait[] | undefined = undefined, +> extends ColumnTypeDescriptor { readonly codecFactory: (ctx: CodecInstanceContext) => R; readonly typeParams: P; + readonly traits: T; } /** * Trivial column packager. Per-codec helpers call this directly with the result of `descriptor.factory(params)` — direct method invocation binds the descriptor's method-level generic at the call site and the literal flows through `R`. * * `nativeType` is the column's database-native type spelling — the value the postgres adapter's migration planner, the SQL renderer's cast policy, and the contract's `meta.db...nativeType` slot read. Per-codec helpers pass the literal native-type string for their codec (e.g. `'text'`, `'int4'`, `'character varying'`); for codecs whose native-type spelling depends on parameters (none today; reserved for future shapes), the helper computes the rendered string before calling `column`. The framework does not derive the value from `codecId` — that mapping is target-specific and lives at the helper. + * + * `traits` is the descriptor's `traits` tuple, threaded through `ColumnSpec`'s static type so trait-gated authoring (e.g. `.default(autoincrement())`) reads it at compile time. Helpers that omit `traits` produce a spec with `traits: undefined` — consumers fall back to a no-traits behaviour. Per-codec helpers pass `descriptor.traits` directly so the literal tuple flows into `T`. */ -export function column | undefined>( +export function column< + R, + P extends Record | undefined, + const T extends readonly CodecTrait[] | undefined = undefined, +>( codecFactory: (ctx: CodecInstanceContext) => R, codecId: string, typeParams: P, nativeType: string, -): ColumnSpec { + traits?: T, +): ColumnSpec { return { codecFactory, codecId, typeParams, nativeType, + traits: traits as T, }; } @@ -57,21 +72,29 @@ export function column | undefined>( * Coarse `satisfies` shape — checks the helper's typeParams record matches the descriptor's factory params. Catches "wrong typeParams shape" wiring mistakes; does NOT catch "wrong descriptor's factory" mistakes (the codec slot is left as `unknown`). * * Use when the codec's `ReturnType` is unstable (e.g. heavily overloaded factories where extraction widens too much). + * + * The traits slot is left as `readonly CodecTrait[] | undefined` so per-codec helpers can thread their literal traits tuple through without the satisfies check rejecting them. */ // biome-ignore lint/suspicious/noExplicitAny: variance erasure — `CodecDescriptor

` is invariant in P, so concrete subclasses do not extend `CodecDescriptor`; matches the existing `AnyCodecDescriptor` pattern export type ColumnHelperFor> = ( // biome-ignore lint/suspicious/noExplicitAny: helper signature is the verification subject; satisfies clauses can't narrow this without circular inference ...args: any[] -) => ColumnSpec>; +) => ColumnSpec, readonly CodecTrait[] | undefined>; /** * Strict `satisfies` shape — also checks the helper's codec is at least the *base* codec instance type the descriptor's factory returns. `ReturnType>` widens method generics to their constraint, so this only sanity-checks the wiring at the base type level. Literal preservation comes from the direct `descriptor.factory(...)` call inside the helper, not from `satisfies`. + * + * Traits slot widened to `readonly CodecTrait[] | undefined` for the same reason as the coarse variant. */ // biome-ignore lint/suspicious/noExplicitAny: variance erasure — `CodecDescriptor

` is invariant in P, so concrete subclasses do not extend `CodecDescriptor`; matches the existing `AnyCodecDescriptor` pattern export type ColumnHelperForStrict> = ( // biome-ignore lint/suspicious/noExplicitAny: helper signature is the verification subject; satisfies clauses can't narrow this without circular inference ...args: any[] -) => ColumnSpec>, ColumnHelperParams>; +) => ColumnSpec< + ReturnType>, + ColumnHelperParams, + readonly CodecTrait[] | undefined +>; /** * Coerce a descriptor's `factory` first parameter into the typeParams shape `ColumnSpec` accepts. Non-parameterized descriptors (factory with no params, or `params: void`) collapse to `undefined`; parameterized descriptors keep the params record shape. diff --git a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts index de7c76b597..5cccd09139 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts @@ -1,12 +1,8 @@ import type { - ColumnDefault, ExecutionMutationDefaultPhases, ExecutionMutationDefaultValue, } from '@prisma-next/contract/types'; -import { - isColumnDefaultLiteralInputValue, - isExecutionMutationDefaultValue, -} from '@prisma-next/contract/types'; +import { isExecutionMutationDefaultValue } from '@prisma-next/contract/types'; import { ifDefined } from '@prisma-next/utils/defined'; export type AuthoringArgRef = { @@ -59,19 +55,41 @@ export interface AuthoringTypeConstructorDescriptor { readonly output: AuthoringStorageTypeTemplate; } -export interface AuthoringColumnDefaultTemplateLiteral { - readonly kind: 'literal'; - readonly value: AuthoringTemplateValue; +/** + * Preset-template arm for a SQL expression default that bypasses codec + * dispatch entirely. Lowers directly to the contract IR's + * `{ kind: 'expression', expression }` shape. + */ +export interface AuthoringColumnDefaultTemplateExpression { + readonly kind: 'expression'; + readonly expression: AuthoringTemplateValue; } -export interface AuthoringColumnDefaultTemplateFunction { - readonly kind: 'function'; - readonly expression: AuthoringTemplateValue; +/** + * Preset-template arm for a target-mechanism default (Postgres + * SERIAL/IDENTITY, SQLite `INTEGER PRIMARY KEY AUTOINCREMENT`). Lowers to + * the contract IR's `{ kind: 'autoincrement' }` shape without invoking the + * codec; the DDL renderer relies on column-type emission for the + * semantics. + */ +export interface AuthoringColumnDefaultTemplateAutoincrement { + readonly kind: 'autoincrement'; } export type AuthoringColumnDefaultTemplate = - | AuthoringColumnDefaultTemplateLiteral - | AuthoringColumnDefaultTemplateFunction; + | AuthoringColumnDefaultTemplateExpression + | AuthoringColumnDefaultTemplateAutoincrement; + +/** + * Resolved authoring-default shape produced by + * {@link instantiateAuthoringFieldPreset}. Structurally identical to the + * contract IR's `ColumnDefault`: presets declare either a SQL expression + * (bypasses codec dispatch entirely) or the `autoincrement` target-mechanism + * sentinel. Authoring consumers forward this shape directly to the emitter. + */ +export type AuthoringColumnDefault = + | { readonly kind: 'expression'; readonly expression: string } + | { readonly kind: 'autoincrement' }; export interface AuthoringExecutionDefaultsTemplate { readonly onCreate?: AuthoringTemplateValue; @@ -533,31 +551,19 @@ function resolveAuthoringStorageTypeTemplate( function resolveAuthoringColumnDefaultTemplate( template: AuthoringColumnDefaultTemplate, args: readonly unknown[], -): ColumnDefault { - if (template.kind === 'literal') { - const value = resolveAuthoringTemplateValue(template.value, args); - if (value === undefined) { - throw new Error('Resolved authoring literal default must not be undefined'); - } - if (!isColumnDefaultLiteralInputValue(value)) { - throw new Error( - `Resolved authoring literal default must be a JSON-serializable value or Date, received ${String(value)}`, - ); - } - return { - kind: 'literal', - value, - }; +): AuthoringColumnDefault { + if (template.kind === 'autoincrement') { + return { kind: 'autoincrement' }; } const expression = resolveAuthoringTemplateValue(template.expression, args); if (expression === undefined || (typeof expression === 'object' && expression !== null)) { throw new Error( - `Resolved authoring function default expression must resolve to a primitive, received ${String(expression)}`, + `Resolved authoring expression default must resolve to a primitive, received ${String(expression)}`, ); } return { - kind: 'function', + kind: 'expression', expression: String(expression), }; } @@ -647,7 +653,7 @@ export function instantiateAuthoringFieldPreset( readonly typeParams?: Record; }; readonly nullable: boolean; - readonly default?: ColumnDefault; + readonly default?: AuthoringColumnDefault; readonly executionDefaults?: ExecutionMutationDefaultPhases; readonly id: boolean; readonly unique: boolean; diff --git a/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts b/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts index 730f8c6c95..086f382466 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts @@ -1,8 +1,8 @@ import type { - ColumnDefault, ExecutionMutationDefaultPhases, ExecutionMutationDefaultValue, } from '@prisma-next/contract/types'; +import type { AuthoringColumnDefault } from '../shared/framework-authoring'; interface SourcePosition { readonly offset: number; @@ -43,7 +43,7 @@ export interface DefaultFunctionLoweringContext { } export type LoweredDefaultValue = - | { readonly kind: 'storage'; readonly defaultValue: ColumnDefault } + | { readonly kind: 'storage'; readonly defaultValue: AuthoringColumnDefault } | { readonly kind: 'execution'; readonly generated: ExecutionMutationDefaultValue }; export type LoweredDefaultResult = diff --git a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts index 8077d78cbd..f2b4b8a680 100644 --- a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts +++ b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts @@ -322,7 +322,10 @@ describe('assembleScalarTypeDescriptors', () => { describe('assembleControlMutationDefaults', () => { const stubLower = () => ({ ok: true as const, - value: { kind: 'storage' as const, defaultValue: { kind: 'literal' as const, value: 0 } }, + value: { + kind: 'storage' as const, + defaultValue: { kind: 'expression' as const, expression: '0' }, + }, }); it('returns empty registry and generators when no descriptors contribute', () => { diff --git a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts index 00ee7fd642..0aee63b3d5 100644 --- a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts +++ b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts @@ -281,14 +281,14 @@ describe('authoring template resolution', () => { ); }); - it('rejects object-valued function default expressions', () => { + it('rejects object-valued expression defaults', () => { const descriptor = { kind: 'fieldPreset', output: { codecId: 'test/text@1', nativeType: 'text', default: { - kind: 'function', + kind: 'expression', expression: { kind: 'arg', index: 0, @@ -299,32 +299,10 @@ describe('authoring template resolution', () => { expect(() => instantiateAuthoringFieldPreset(descriptor, [{ sql: 'CURRENT_TIMESTAMP' }]), - ).toThrow(/Resolved authoring function default expression must resolve to a primitive/); + ).toThrow(/Resolved authoring expression default must resolve to a primitive/); }); - it('rejects literal defaults that resolve to undefined', () => { - const descriptor = { - kind: 'fieldPreset', - output: { - codecId: 'test/text@1', - nativeType: 'text', - default: { - kind: 'literal', - value: { - kind: 'arg', - index: 0, - path: ['missing'], - }, - }, - }, - } as const; - - expect(() => instantiateAuthoringFieldPreset(descriptor, [{}])).toThrow( - /Resolved authoring literal default must not be undefined/, - ); - }); - - it('resolves literal defaults and execution defaults from field presets', () => { + it('resolves expression defaults and execution defaults from field presets', () => { const descriptor = { kind: 'fieldPreset', output: { @@ -337,13 +315,8 @@ describe('authoring template resolution', () => { }, }, default: { - kind: 'literal', - value: { - length: { - kind: 'arg', - index: 0, - }, - }, + kind: 'expression', + expression: 'gen_random_uuid()', }, executionDefaults: { onCreate: { @@ -370,10 +343,8 @@ describe('authoring template resolution', () => { }, nullable: true, default: { - kind: 'literal', - value: { - length: 1536, - }, + kind: 'expression', + expression: 'gen_random_uuid()', }, executionDefaults: { onCreate: { kind: 'generator', id: 'vectorGenerated' }, @@ -468,14 +439,14 @@ describe('authoring template resolution', () => { ); }); - it('stringifies primitive function default expressions', () => { + it('stringifies primitive expression-default expressions', () => { const descriptor = { kind: 'fieldPreset', output: { codecId: 'test/text@1', nativeType: 'text', default: { - kind: 'function', + kind: 'expression', expression: { kind: 'arg', index: 0, @@ -485,8 +456,25 @@ describe('authoring template resolution', () => { } as const; expect(instantiateAuthoringFieldPreset(descriptor, [123]).default).toEqual({ - kind: 'function', + kind: 'expression', expression: '123', }); }); + + it('lowers autoincrement preset templates to the autoincrement arm', () => { + const descriptor = { + kind: 'fieldPreset', + output: { + codecId: 'test/int4@1', + nativeType: 'int4', + default: { + kind: 'autoincrement', + }, + }, + } as const; + + expect(instantiateAuthoringFieldPreset(descriptor, []).default).toEqual({ + kind: 'autoincrement', + }); + }); }); diff --git a/packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts b/packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts index 2b47111db3..ff3f738df0 100644 --- a/packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts +++ b/packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts @@ -84,7 +84,7 @@ describe('canonicalization', () => { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, updated_at: { codecId: 'pg/timestamptz@1', @@ -117,7 +117,7 @@ describe('canonicalization', () => { codecId: 'pg/text@1', nativeType: 'text', nullable: true, - default: { kind: 'literal', value: '' }, + default: { kind: 'expression', expression: "''" }, }, }, }, @@ -132,7 +132,7 @@ describe('canonicalization', () => { const columns = user['columns'] as Record; const bio = columns['bio'] as Record; expect(bio['nullable']).toBe(true); - expect(bio['default']).toEqual({ kind: 'literal', value: '' }); + expect(bio['default']).toEqual({ kind: 'expression', expression: "''" }); }); it('omits empty arrays and objects except required ones', () => { diff --git a/packages/2-sql/1-core/contract/src/exports/types.ts b/packages/2-sql/1-core/contract/src/exports/types.ts index 2acc7b4f43..9cc19c5ac0 100644 --- a/packages/2-sql/1-core/contract/src/exports/types.ts +++ b/packages/2-sql/1-core/contract/src/exports/types.ts @@ -1,5 +1,8 @@ export type { CodecTypesOf, + ColumnDefault, + ColumnDefaultLiteralInputValue, + ColumnDefaultLiteralValue, ContractWithTypeMaps, ExtractCodecTypes, ExtractFieldInputTypes, diff --git a/packages/2-sql/1-core/contract/src/ir/storage-column.ts b/packages/2-sql/1-core/contract/src/ir/storage-column.ts index 2b00d16a0e..ce44e48e03 100644 --- a/packages/2-sql/1-core/contract/src/ir/storage-column.ts +++ b/packages/2-sql/1-core/contract/src/ir/storage-column.ts @@ -1,5 +1,5 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; import { freezeNode } from '@prisma-next/framework-components/ir'; +import type { ColumnDefault } from '../types'; import { SqlNode } from './sql-node'; /** diff --git a/packages/2-sql/1-core/contract/src/types.ts b/packages/2-sql/1-core/contract/src/types.ts index c7d4bdc6de..1243ef83b9 100644 --- a/packages/2-sql/1-core/contract/src/types.ts +++ b/packages/2-sql/1-core/contract/src/types.ts @@ -1,6 +1,41 @@ +import type { JsonValue } from '@prisma-next/contract/types'; import type { CodecTrait } from '@prisma-next/framework-components/codec'; import type { ReferentialAction } from './ir/foreign-key'; +/** + * Storage-column default value, as it appears in the contract IR. + * + * Two shapes: + * - `{ kind: 'expression', expression }` — a SQL expression rendered by + * the column's codec (`codec.renderSqlLiteral`) or by an authoring + * escape hatch (e.g. PSL `@default(now())`, TS DSL `.defaultSql(...)`). + * The expression is target-dialect-flavoured text; consumers emit it + * verbatim under a `DEFAULT (...)` clause. + * - `{ kind: 'autoincrement' }` — a sentinel indicating that the column + * draws its value from a target-specific auto-increment mechanism + * (Postgres SERIAL/IDENTITY, SQLite `INTEGER PRIMARY KEY AUTOINCREMENT`). + * Renderers emit no `DEFAULT` clause; the column-type emission carries + * the semantics. + */ +export type ColumnDefault = + | { readonly kind: 'expression'; readonly expression: string } + | { readonly kind: 'autoincrement' }; + +/** + * JSON-shaped literal value accepted by codecs at lowering time. Used + * by PSL lowering before `codec.decodeJson` materialises the codec's + * `TInput`. + */ +export type ColumnDefaultLiteralValue = JsonValue; + +/** + * JS-native literal accepted by TS DSL `.default(...)` before codec + * dispatch. Extends `ColumnDefaultLiteralValue` with `Date`, the one + * non-JSON-native value the literal pass tolerates ahead of + * `codec.renderSqlLiteral`. + */ +export type ColumnDefaultLiteralInputValue = ColumnDefaultLiteralValue | Date; + export { ForeignKey, type ForeignKeyInput, diff --git a/packages/2-sql/1-core/contract/src/validators.ts b/packages/2-sql/1-core/contract/src/validators.ts index 1d6e4f30c4..fbd4a6fa64 100644 --- a/packages/2-sql/1-core/contract/src/validators.ts +++ b/packages/2-sql/1-core/contract/src/validators.ts @@ -16,29 +16,27 @@ import { type UniqueConstraintInput, } from './types'; -type ColumnDefaultLiteral = { - readonly kind: 'literal'; - readonly value: string | number | boolean | Record | unknown[] | null; -}; -type ColumnDefaultFunction = { readonly kind: 'function'; readonly expression: string }; -const literalKindSchema = type("'literal'"); -const functionKindSchema = type("'function'"); +const expressionKindSchema = type("'expression'"); +const autoincrementKindSchema = type("'autoincrement'"); const generatorKindSchema = type("'generator'"); const generatorIdSchema = type('string').narrow((value, ctx) => { return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value) ? true : ctx.mustBe('a flat generator id'); }); -export const ColumnDefaultLiteralSchema = type.declare().type({ - kind: literalKindSchema, - value: 'string | number | boolean | null | unknown[] | Record', +export const ColumnDefaultExpressionSchema = type({ + '+': 'reject', + kind: expressionKindSchema, + expression: 'string', }); -export const ColumnDefaultFunctionSchema = type.declare().type({ - kind: functionKindSchema, - expression: 'string', +export const ColumnDefaultAutoincrementSchema = type({ + '+': 'reject', + kind: autoincrementKindSchema, }); -export const ColumnDefaultSchema = ColumnDefaultLiteralSchema.or(ColumnDefaultFunctionSchema); +export const ColumnDefaultSchema = ColumnDefaultExpressionSchema.or( + ColumnDefaultAutoincrementSchema, +); const ExecutionMutationDefaultValueSchema = type({ '+': 'reject', @@ -300,10 +298,11 @@ const SqlContractSchema = type({ }); // NOTE: StorageColumnSchema, StorageTableSchema, and StorageSchema use bare type() -// instead of type.declare().type() because the ColumnDefault union's value field -// includes bigint | Date (runtime-only types after decoding) which cannot be expressed -// in Arktype's JSON validation DSL. The `as SqlStorage` cast in validateStorage() bridges -// the gap between the JSON-safe Arktype output and the runtime TypeScript type. +// instead of type.declare().type() because the surrounding shapes carry +// runtime-only branded fields (e.g. the branded `storageHash`) which cannot +// be expressed in Arktype's JSON validation DSL. The `as SqlStorage` cast in +// validateStorage() bridges the gap between the JSON-safe Arktype output and +// the runtime TypeScript type. /** * Validates the structural shape of SqlStorage using Arktype. @@ -622,9 +621,13 @@ export function validateSqlStorageConsistency(contract: Contract): v } for (const [colName, column] of Object.entries(table.columns)) { - if (!column.nullable && column.default?.kind === 'literal' && column.default.value === null) { + if ( + !column.nullable && + column.default?.kind === 'expression' && + column.default.expression.trim().toUpperCase() === 'NULL' + ) { throw new ContractValidationError( - `Namespace "${namespaceId}" table "${tableName}" column "${colName}" is NOT NULL but has a literal null default`, + `Namespace "${namespaceId}" table "${tableName}" column "${colName}" is NOT NULL but has a NULL default expression`, 'storage', ); } diff --git a/packages/2-sql/1-core/contract/test/validators.test.ts b/packages/2-sql/1-core/contract/test/validators.test.ts index c5fdcb23dd..a882d00ff8 100644 --- a/packages/2-sql/1-core/contract/test/validators.test.ts +++ b/packages/2-sql/1-core/contract/test/validators.test.ts @@ -127,6 +127,195 @@ describe('SQL contract validators', () => { } as unknown; expect(() => validateStorage(invalid)).toThrow(/either typeParams or typeRef, not both/); }); + + it('accepts column default with kind expression', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + createdAt: { + nativeType: 'timestamptz', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { kind: 'expression', expression: 'NOW()' }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).not.toThrow(); + }); + + it('accepts column default with kind autoincrement', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + id: { + nativeType: 'int4', + codecId: 'pg/int4@1', + nullable: false, + default: { kind: 'autoincrement' }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).not.toThrow(); + }); + + it('rejects legacy column default with kind literal', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + name: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: false, + default: { kind: 'literal', value: 'foo' }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).toThrow(); + }); + + it('rejects legacy column default with kind function', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + createdAt: { + nativeType: 'timestamptz', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { kind: 'function', expression: 'now()' }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).toThrow(); + }); + + it('rejects column default expression with non-string expression value', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + createdAt: { + nativeType: 'timestamptz', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { kind: 'expression', expression: 42 }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).toThrow(); + }); + + it('rejects autoincrement default with extra expression field', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + id: { + nativeType: 'int4', + codecId: 'pg/int4@1', + nullable: false, + default: { kind: 'autoincrement', expression: 'foo' }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).toThrow(); + }); + + it('rejects expression default with extra value field', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + createdAt: { + nativeType: 'timestamptz', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { kind: 'expression', expression: 'x', value: 42 }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).toThrow(); + }); }); describe('validateModel', () => { @@ -724,6 +913,38 @@ describe('SQL contract validators', () => { /non-existent column "user_uuid" in table "users"/, ); }); + + it('rejects NOT NULL column with NULL default expression', () => { + const userTable = table({ + name: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: false, + default: { kind: 'expression', expression: 'NULL' }, + }, + }); + const c = createContract({ + storage: unboundTables({ user: userTable }), + }); + expect(() => validateSqlContractFully(c)).toThrow( + /NOT NULL but has a NULL default expression/, + ); + }); + + it('accepts NULL default expression on a nullable column', () => { + const userTable = table({ + name: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: true, + default: { kind: 'expression', expression: 'NULL' }, + }, + }); + const c = createContract({ + storage: unboundTables({ user: userTable }), + }); + expect(() => validateSqlContractFully(c)).not.toThrow(); + }); }); describe('validateStorageSemantics', () => { @@ -830,7 +1051,7 @@ describe('SQL contract validators', () => { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false, - default: { kind: 'literal', value: 0 }, + default: { kind: 'expression', expression: '0' }, }, }, { fks: [fk('post', ['userId'], 'user', ['id'], { onDelete: 'setDefault' })] }, diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index dcdf7f4f89..5e167c0891 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -15,6 +15,7 @@ import type { AuthoringEntityTypeDescriptor, } from '@prisma-next/framework-components/authoring'; import { instantiateAuthoringEntityType } from '@prisma-next/framework-components/authoring'; +import type { CodecLookup } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; import type { ControlMutationDefaultRegistry, @@ -95,6 +96,15 @@ export interface InterpretPslDocumentToSqlContractInput { readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[]; readonly controlMutationDefaults?: ControlMutationDefaults; readonly authoringContributions?: AuthoringContributions; + /** + * Codec-id-keyed lookup threaded into PSL `@default(...)` lowering so + * literal defaults dispatch through `codec.decodeJson` + + * `codec.renderSqlLiteral`, and the parse-time `@default(autoincrement())` + * recognition gate can read `codec.descriptor.traits`. Optional — when + * absent, literal defaults that are not `null` surface a diagnostic + * (the lookup is required to render a literal as a SQL expression). + */ + readonly codecLookup?: CodecLookup; /** * Target-supplied `Namespace` factory threaded into * `buildSqlContractFromDefinition` for the contract's @@ -587,6 +597,7 @@ interface BuildModelNodeInput { readonly authoringContributions: AuthoringContributions | undefined; readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; readonly generatorDescriptorById: ReadonlyMap; + readonly codecLookup?: CodecLookup; readonly scalarTypeDescriptors: ReadonlyMap; readonly sourceId: string; readonly diagnostics: ContractSourceDiagnostic[]; @@ -618,6 +629,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult targetId: input.targetId, defaultFunctionRegistry: input.defaultFunctionRegistry, generatorDescriptorById: input.generatorDescriptorById, + ...(input.codecLookup !== undefined ? { codecLookup: input.codecLookup } : {}), diagnostics, sourceId, scalarTypeDescriptors: input.scalarTypeDescriptors, @@ -1590,6 +1602,7 @@ export function interpretPslDocumentToSqlContract( authoringContributions: input.authoringContributions, defaultFunctionRegistry, generatorDescriptorById, + ...(input.codecLookup !== undefined ? { codecLookup: input.codecLookup } : {}), scalarTypeDescriptors: input.scalarTypeDescriptors, sourceId, diagnostics, diff --git a/packages/2-sql/2-authoring/contract-psl/src/provider.ts b/packages/2-sql/2-authoring/contract-psl/src/provider.ts index b8ad523017..498169aea2 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/provider.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/provider.ts @@ -93,6 +93,7 @@ export function prismaContract(schemaPath: string, options: PrismaContractOption target: options.target, authoringContributions: context.authoringContributions, scalarTypeDescriptors, + codecLookup: context.codecLookup, ...ifDefined( 'composedExtensionPacks', context.composedExtensionPacks.length > 0 diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts index 57bf090f12..95288006a1 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts @@ -1,6 +1,7 @@ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types'; -import type { ColumnDefault, ExecutionMutationDefaultPhases } from '@prisma-next/contract/types'; +import type { ExecutionMutationDefaultPhases, JsonValue } from '@prisma-next/contract/types'; import type { + AuthoringColumnDefault, AuthoringContributions, AuthoringEntityTypeDescriptor, AuthoringFieldPresetDescriptor, @@ -15,6 +16,7 @@ import { isAuthoringTypeConstructorDescriptor, validateAuthoringHelperArguments, } from '@prisma-next/framework-components/authoring'; +import type { CodecLookup, CodecTrait } from '@prisma-next/framework-components/codec'; import type { ControlMutationDefaultRegistry, MutationDefaultGeneratorDescriptor, @@ -25,6 +27,7 @@ import type { PslSpan, PslTypeConstructorCall, } from '@prisma-next/psl-parser'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { lowerDefaultFunctionWithRegistry, parseDefaultFunctionCall, @@ -313,6 +316,7 @@ export function instantiatePslFieldPreset(input: { readonly diagnostics: ContractSourceDiagnostic[]; readonly sourceId: string; readonly entityLabel: string; + readonly codecLookup?: CodecLookup; }): | { readonly descriptor: ColumnDescriptor; @@ -340,16 +344,21 @@ export function instantiatePslFieldPreset(input: { try { validateAuthoringHelperArguments(helperPath, input.descriptor.args, args); const instantiated = instantiateAuthoringFieldPreset(input.descriptor, args); + const presetCodecId = instantiated.descriptor.codecId; + const presetDefault: ColumnDefault | undefined = + instantiated.default !== undefined + ? emitFunctionFormColumnDefault(instantiated.default) + : undefined; return { descriptor: { - codecId: instantiated.descriptor.codecId, + codecId: presetCodecId, nativeType: instantiated.descriptor.nativeType, ...(instantiated.descriptor.typeParams !== undefined ? { typeParams: instantiated.descriptor.typeParams } : {}), }, nullable: instantiated.nullable, - ...(instantiated.default !== undefined ? { default: instantiated.default } : {}), + ...(presetDefault !== undefined ? { default: presetDefault } : {}), ...(instantiated.executionDefaults !== undefined ? { executionDefaults: instantiated.executionDefaults } : {}), @@ -396,6 +405,7 @@ export function resolveFieldTypeDescriptor(input: { readonly composedExtensions: ReadonlySet; readonly familyId: string; readonly targetId: string; + readonly codecLookup?: CodecLookup; readonly diagnostics: ContractSourceDiagnostic[]; readonly sourceId: string; readonly entityLabel: string; @@ -413,6 +423,7 @@ export function resolveFieldTypeDescriptor(input: { diagnostics: input.diagnostics, sourceId: input.sourceId, entityLabel: input.entityLabel, + ...(input.codecLookup !== undefined ? { codecLookup: input.codecLookup } : {}), }); if (!instantiated) { return { ok: false, alreadyReported: true }; @@ -678,29 +689,144 @@ export function resolveDbNativeTypeAttribute(input: { } } -export function parseDefaultLiteralValue(expression: string): ColumnDefault | undefined { +/** + * Parse a PSL literal `@default(...)` argument into a `JsonValue`. PSL's + * scalar literal grammar is JSON-isomorphic for the values supported here + * — `null`, booleans, numbers, single- or double-quoted strings — so the + * parsed form is the codec's `decodeJson` input. Returns `undefined` when + * the expression is not a recognised literal (the caller falls through to + * the function-call branch). + */ +export function parseDefaultLiteralValue(expression: string): JsonValue | undefined { const trimmed = expression.trim(); - if (trimmed === 'true' || trimmed === 'false') { - return { kind: 'literal', value: trimmed === 'true' }; + if (trimmed === 'null') { + return null; } - const numericValue = Number(trimmed); - if (!Number.isNaN(numericValue) && trimmed.length > 0 && !/^(['"]).*\1$/.test(trimmed)) { - return { kind: 'literal', value: numericValue }; + if (trimmed === 'true' || trimmed === 'false') { + return trimmed === 'true'; } if (/^(['"]).*\1$/.test(trimmed)) { - return { kind: 'literal', value: unquoteStringLiteral(trimmed) }; + return unquoteStringLiteral(trimmed); + } + const numericValue = Number(trimmed); + if (!Number.isNaN(numericValue) && trimmed.length > 0) { + return numericValue; } return undefined; } +/** + * Local structural narrowing for codecs that expose their descriptor at + * runtime. The framework `Codec` interface (consumer surface) does not + * declare `descriptor`, but every concrete codec extends `CodecImpl` which + * carries it — so reading `codec.descriptor.traits` is the runtime path + * for trait introspection at the literal-default lowering boundary. + * + * Mirrors the same shape-narrowing pattern used by the TS DSL emitter + * (`packages/2-sql/2-authoring/contract-ts/src/build-contract.ts` — + * `CodecWithRenderSqlLiteral`). + */ +interface CodecWithDescriptorTraits { + readonly descriptor?: { readonly traits?: readonly CodecTrait[] }; +} + +/** + * Resolve the codec's runtime `traits` tuple for a given codec id, via + * `codecLookup.get(id)?.descriptor.traits`. Returns `undefined` when the + * lookup misses or the codec instance does not expose a descriptor (e.g. + * the layer-isolated test stubs in `contract-psl/test/fixtures.ts`). + */ +function resolveCodecTraits( + codecLookup: CodecLookup | undefined, + codecId: string, +): readonly CodecTrait[] | undefined { + const codec = codecLookup?.get(codecId) as CodecWithDescriptorTraits | undefined; + return codec?.descriptor?.traits; +} + +/** + * Translate the registry's storage-arm {@link AuthoringColumnDefault} into + * the contract IR's {@link ColumnDefault}. The `expression` and `autoincrement` + * arms pass through verbatim; both are structurally identical to the IR shape. + */ +function emitFunctionFormColumnDefault(authored: AuthoringColumnDefault): ColumnDefault { + if (authored.kind === 'autoincrement') { + return { kind: 'autoincrement' }; + } + return { kind: 'expression', expression: authored.expression }; +} + +/** + * Mirror of the TS DSL emitter's structural narrowing for codecs that + * carry `renderSqlLiteral`. SQL-family codecs implement the method (per + * `@prisma-next/sql-contract`'s codec interface); the framework-level + * `CodecLookup` types codecs as the narrower framework `Codec`, so we + * narrow at the call site rather than depending on the SQL-family type. + */ +interface CodecWithRenderSqlLiteral { + readonly id: string; + readonly decodeJson: (json: JsonValue) => unknown; + renderSqlLiteral(value: unknown): string; +} + +/** + * Special-case the `autoincrement()` function-form default at parse time + * so the trait-gate runs without consulting the registry. Returns: + * + * - `{ ok: true, value: { kind: 'autoincrement' } }` when the column's + * codec carries the `'autoincrement'` trait. The codec is NOT invoked; + * the DDL renderer's SERIAL/IDENTITY/AUTOINCREMENT column-type emission + * carries the semantics. + * - `{ ok: true, value: undefined }` to short-circuit with a diagnostic + * already pushed when the trait is absent. + * - `{ ok: false }` when the call is not the `autoincrement()` parse-time + * shape — caller falls through to the registry branch. + */ +function tryRecogniseAutoincrementParseTime(input: { + readonly call: ReturnType; + readonly modelName: string; + readonly fieldName: string; + readonly codecId: string; + readonly codecLookup: CodecLookup | undefined; + readonly sourceId: string; + readonly span: PslSpan; + readonly diagnostics: ContractSourceDiagnostic[]; +}): { readonly ok: true; readonly value: ColumnDefault | undefined } | { readonly ok: false } { + if (!input.call || input.call.name !== 'autoincrement') { + return { ok: false }; + } + if (input.call.args.length > 0) { + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_FUNCTION_ARGUMENT', + message: `Field "${input.modelName}.${input.fieldName}" @default(autoincrement()) does not accept arguments.`, + sourceId: input.sourceId, + span: input.call.span, + }); + return { ok: true, value: undefined }; + } + const traits = resolveCodecTraits(input.codecLookup, input.codecId); + if (!traits?.includes('autoincrement')) { + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_APPLICABILITY', + message: `Field "${input.modelName}.${input.fieldName}" @default(autoincrement()) requires a codec with the "autoincrement" trait; codec "${input.codecId}" does not carry it.`, + sourceId: input.sourceId, + span: input.span, + }); + return { ok: true, value: undefined }; + } + return { ok: true, value: { kind: 'autoincrement' } }; +} + export function lowerDefaultForField(input: { readonly modelName: string; readonly fieldName: string; readonly defaultAttribute: PslAttribute; readonly columnDescriptor: ColumnDescriptor; + readonly nullable: boolean; readonly generatorDescriptorById: ReadonlyMap; readonly sourceId: string; readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; + readonly codecLookup?: CodecLookup; readonly diagnostics: ContractSourceDiagnostic[]; }): { readonly defaultValue?: ColumnDefault; @@ -731,8 +857,68 @@ export function lowerDefaultForField(input: { } const literalDefault = parseDefaultLiteralValue(expressionEntry.value); - if (literalDefault) { - return { defaultValue: literalDefault }; + if (literalDefault !== undefined) { + // Literal pass mirrored from the TS DSL emitter (build-contract.ts + // emitColumnDefault): `null` on a nullable column rewrites to the SQL + // `NULL` keyword without invoking the codec; `null` on a NOT NULL + // column is a hard diagnostic naming the column path + codec id + + // PSL `file:line`. The rule is duplicated rather than factored to + // keep diagnostic envelopes (Error vs ContractSourceDiagnostic) per + // surface. + if (literalDefault === null) { + if (!input.nullable) { + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_VALUE', + message: `Field "${input.modelName}.${input.fieldName}" (codec "${input.columnDescriptor.codecId}") is NOT NULL but a null literal was supplied to @default(...). Either mark the field optional or supply a non-null default.`, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + }); + return {}; + } + return { defaultValue: { kind: 'expression', expression: 'NULL' } }; + } + + const codec = input.codecLookup?.get(input.columnDescriptor.codecId) as + | CodecWithRenderSqlLiteral + | undefined; + if (!codec) { + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_VALUE', + message: `Field "${input.modelName}.${input.fieldName}" @default(...) requires codec "${input.columnDescriptor.codecId}" to render the literal value; no codec lookup is available.`, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + }); + return {}; + } + + let decoded: unknown; + try { + decoded = codec.decodeJson(literalDefault); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_VALUE', + message: `Field "${input.modelName}.${input.fieldName}" @default(${expressionEntry.value}) is not valid for codec "${input.columnDescriptor.codecId}": ${message}`, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + }); + return {}; + } + + try { + return { + defaultValue: { kind: 'expression', expression: codec.renderSqlLiteral(decoded) }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_VALUE', + message: `Field "${input.modelName}.${input.fieldName}" @default(${expressionEntry.value}) could not be rendered by codec "${input.columnDescriptor.codecId}": ${message}`, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + }); + return {}; + } } const defaultFunctionCall = parseDefaultFunctionCall(expressionEntry.value, expressionEntry.span); @@ -746,6 +932,20 @@ export function lowerDefaultForField(input: { return {}; } + const autoincrementParseTime = tryRecogniseAutoincrementParseTime({ + call: defaultFunctionCall, + modelName: input.modelName, + fieldName: input.fieldName, + codecId: input.columnDescriptor.codecId, + codecLookup: input.codecLookup, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + diagnostics: input.diagnostics, + }); + if (autoincrementParseTime.ok) { + return autoincrementParseTime.value ? { defaultValue: autoincrementParseTime.value } : {}; + } + const lowered = lowerDefaultFunctionWithRegistry({ call: defaultFunctionCall, registry: input.defaultFunctionRegistry, @@ -763,7 +963,7 @@ export function lowerDefaultForField(input: { } if (lowered.value.kind === 'storage') { - return { defaultValue: lowered.value.defaultValue }; + return { defaultValue: emitFunctionFormColumnDefault(lowered.value.defaultValue) }; } const generatorDescriptor = input.generatorDescriptorById.get(lowered.value.generated.id); diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts index 753c93f310..93502df22a 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts @@ -1,11 +1,13 @@ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types'; -import type { ColumnDefault, ExecutionMutationDefaultPhases } from '@prisma-next/contract/types'; +import type { ExecutionMutationDefaultPhases } from '@prisma-next/contract/types'; import type { AuthoringContributions } from '@prisma-next/framework-components/authoring'; +import type { CodecLookup } from '@prisma-next/framework-components/codec'; import type { ControlMutationDefaultRegistry, MutationDefaultGeneratorDescriptor, } from '@prisma-next/framework-components/control'; import type { PslAttribute, PslField, PslModel } from '@prisma-next/psl-parser'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { ifDefined } from '@prisma-next/utils/defined'; import { getAttribute, @@ -55,6 +57,7 @@ export interface CollectResolvedFieldsInput { readonly targetId: string; readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; readonly generatorDescriptorById: ReadonlyMap; + readonly codecLookup?: CodecLookup; readonly diagnostics: ContractSourceDiagnostic[]; readonly sourceId: string; readonly scalarTypeDescriptors: ReadonlyMap; @@ -204,6 +207,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv targetId, defaultFunctionRegistry, generatorDescriptorById, + codecLookup, diagnostics, sourceId, scalarTypeDescriptors, @@ -248,6 +252,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv composedExtensions, familyId, targetId, + ...(codecLookup !== undefined ? { codecLookup } : {}), diagnostics, sourceId, entityLabel: `Field "${model.name}.${field.name}"`, @@ -332,9 +337,11 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv fieldName: field.name, defaultAttribute, columnDescriptor: descriptor, + nullable: Boolean(field.optional), generatorDescriptorById, sourceId, defaultFunctionRegistry, + ...(codecLookup !== undefined ? { codecLookup } : {}), diagnostics, }) : {}; diff --git a/packages/2-sql/2-authoring/contract-psl/test/default-function-registry.test.ts b/packages/2-sql/2-authoring/contract-psl/test/default-function-registry.test.ts index 8504c6c8b7..ad361c03f3 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/default-function-registry.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/default-function-registry.test.ts @@ -134,7 +134,7 @@ describe('default function registry', () => { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'custom()', }, }, @@ -170,7 +170,7 @@ describe('default function registry', () => { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'custom()', }, }, @@ -234,7 +234,7 @@ describe('default function registry', () => { expect(lowered.value).toMatchObject({ kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: String.raw`nextval(\"public\".\"user_id_seq\")`, }, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts index c945940bb2..4b265ba053 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -3,7 +3,7 @@ import type { AuthoringContributions, AuthoringEntityTypeNamespace, } from '@prisma-next/framework-components/authoring'; -import type { CodecLookup } from '@prisma-next/framework-components/codec'; +import type { CodecLookup, CodecTrait } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; import type { ControlMutationDefaults, @@ -223,10 +223,110 @@ const targetTypesByCodecId: Record = { 'pg/vector@1': ['vector'], }; +/** + * Test-only codec traits map keyed by codec id. Real targets ship these on + * each codec descriptor; the layer-isolated test lookup synthesises a + * minimum-viable subset so PSL lowering tests can read `traits` without + * pulling in the postgres pack. + */ +const traitsByCodecId: Record = { + 'pg/text@1': ['equality', 'order', 'textual'], + 'pg/bool@1': ['equality', 'boolean'], + 'pg/int4@1': ['equality', 'order', 'numeric', 'autoincrement'], + 'pg/int8@1': ['equality', 'order', 'numeric', 'autoincrement'], + 'pg/int2@1': ['equality', 'order', 'numeric', 'autoincrement'], + 'pg/float4@1': ['equality', 'order', 'numeric'], + 'pg/float8@1': ['equality', 'order', 'numeric'], + 'pg/numeric@1': ['equality', 'order', 'numeric'], + 'pg/timestamp@1': ['equality', 'order'], + 'pg/timestamptz@1': ['equality', 'order'], + 'pg/time@1': ['equality', 'order'], + 'pg/timetz@1': ['equality', 'order'], + 'pg/jsonb@1': [], + 'pg/json@1': [], + 'pg/bytea@1': ['equality'], + 'sql/char@1': ['equality', 'order', 'textual'], + 'sql/varchar@1': ['equality', 'order', 'textual'], + 'pg/vector@1': ['equality'], +}; + +interface PslTestCodecStub { + readonly id: string; + readonly descriptor: { readonly traits: readonly CodecTrait[] }; + decodeJson(value: unknown): unknown; + renderSqlLiteral(value: unknown): string; +} + +function renderSqlLiteralForTestCodec(codecId: string, value: unknown): string { + if (codecId === 'pg/text@1' || codecId === 'sql/char@1' || codecId === 'sql/varchar@1') { + if (typeof value !== 'string') { + throw new Error(`pg-text-like codec expects a string, received ${typeof value}`); + } + return `'${value.replaceAll("'", "''")}'`; + } + if ( + codecId === 'pg/int4@1' || + codecId === 'pg/int8@1' || + codecId === 'pg/int2@1' || + codecId === 'pg/float4@1' || + codecId === 'pg/float8@1' || + codecId === 'pg/numeric@1' + ) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`numeric codec expects a finite number, received ${typeof value}`); + } + return String(value); + } + if (codecId === 'pg/bool@1') { + if (typeof value !== 'boolean') { + throw new Error(`bool codec expects a boolean, received ${typeof value}`); + } + return value ? 'TRUE' : 'FALSE'; + } + return JSON.stringify(value); +} + +function decodeJsonForTestCodec(codecId: string, value: unknown): unknown { + if (codecId === 'pg/text@1' || codecId === 'sql/char@1' || codecId === 'sql/varchar@1') { + if (typeof value !== 'string') { + throw new Error(`pg-text-like codec expects a string, received ${typeof value}`); + } + return value; + } + if ( + codecId === 'pg/int4@1' || + codecId === 'pg/int8@1' || + codecId === 'pg/int2@1' || + codecId === 'pg/float4@1' || + codecId === 'pg/float8@1' || + codecId === 'pg/numeric@1' + ) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`numeric codec expects a finite number, received ${typeof value}`); + } + return value; + } + if (codecId === 'pg/bool@1') { + if (typeof value !== 'boolean') { + throw new Error(`bool codec expects a boolean, received ${typeof value}`); + } + return value; + } + return value; +} + export const postgresCodecLookup: CodecLookup = { get: (id: string) => { if (!targetTypesByCodecId[id]) return undefined; - return { id } as ReturnType; + const stub: PslTestCodecStub = { + id, + descriptor: { traits: traitsByCodecId[id] ?? [] }, + decodeJson: (value: unknown) => decodeJsonForTestCodec(id, value), + renderSqlLiteral: (value: unknown) => renderSqlLiteralForTestCodec(id, value), + }; + // Test stub omits `encode` / `decode` / `encodeJson` because PSL lowering + // never reads them — the cast acknowledges the structural narrowness. + return stub as unknown as ReturnType; }, targetTypesFor: (id: string) => targetTypesByCodecId[id], metaFor: () => undefined, @@ -260,7 +360,7 @@ export function createBuiltinLikeControlMutationDefaults(): ControlMutationDefau ok: true as const, value: { kind: 'storage' as const, - defaultValue: { kind: 'function' as const, expression: 'autoincrement()' }, + defaultValue: { kind: 'autoincrement' as const }, }, }; }, @@ -277,7 +377,7 @@ export function createBuiltinLikeControlMutationDefaults(): ControlMutationDefau ok: true as const, value: { kind: 'storage' as const, - defaultValue: { kind: 'function' as const, expression: 'now()' }, + defaultValue: { kind: 'expression' as const, expression: 'now()' }, }, }; }, @@ -414,7 +514,7 @@ export function createBuiltinLikeControlMutationDefaults(): ControlMutationDefau value: { kind: 'storage' as const, defaultValue: { - kind: 'function' as const, + kind: 'expression' as const, expression: rawExpression, }, }, diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.codec-owned-defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.codec-owned-defaults.test.ts new file mode 100644 index 0000000000..354e6292d5 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.codec-owned-defaults.test.ts @@ -0,0 +1,382 @@ +import type { CodecLookup } from '@prisma-next/framework-components/codec'; +import { parsePslDocument } from '@prisma-next/psl-parser'; +import { describe, expect, it, vi } from 'vitest'; +import { + type InterpretPslDocumentToSqlContractInput, + interpretPslDocumentToSqlContract as interpretPslDocumentToSqlContractInternal, +} from '../src/interpreter'; +import { + createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, + postgresScalarTypeDescriptors, + postgresTarget, +} from './fixtures'; +import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contract-storage'; +import { unboundTables } from './unbound-tables'; + +describe('PSL @default(...) codec-owned lowering', () => { + const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); + const interpretPslDocumentToSqlContract = ( + input: Omit, + ) => + interpretPslDocumentToSqlContractInternal({ + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + codecLookup: postgresCodecLookup, + ...input, + }); + + describe('literal defaults dispatch through codec.decodeJson + renderSqlLiteral', () => { + it('rejects @default(true) on an int column with a codec-typed diagnostic', () => { + const document = parsePslDocument({ + schema: `model M { + id Int @default(true) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + const diagnostic = result.failure.diagnostics.find( + (d) => + d.code === 'PSL_INVALID_DEFAULT_VALUE' && + d.message.includes('M.id') && + d.message.includes('pg/int4@1'), + ); + expect(diagnostic).toBeDefined(); + expect(diagnostic?.sourceId).toBe('schema.prisma'); + expect(diagnostic?.span?.start.line).toBe(2); + }); + + it('routes integer literal defaults through codec.decodeJson then renderSqlLiteral', () => { + const document = parsePslDocument({ + schema: `model M { + id Int @id + count Int @default(42) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['count']?.default).toEqual({ + kind: 'expression', + expression: '42', + }); + }); + + it('routes string literal defaults through the text codec', () => { + const document = parsePslDocument({ + schema: `model M { + id Int @id + label String @default("hello") +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['label']?.default).toEqual({ + kind: 'expression', + expression: "'hello'", + }); + }); + + it('invokes codec.decodeJson then codec.renderSqlLiteral in order', () => { + const decodeJson = vi.fn((value: unknown) => value); + const renderSqlLiteral = vi.fn((value: unknown) => String(value)); + const spyLookup: CodecLookup = { + get: (id) => { + if (id !== 'pg/int4@1') { + return postgresCodecLookup.get(id); + } + const stub = { + id, + descriptor: { traits: ['equality', 'order', 'numeric', 'autoincrement'] as const }, + decodeJson, + renderSqlLiteral, + }; + return stub as unknown as ReturnType; + }, + targetTypesFor: postgresCodecLookup.targetTypesFor, + metaFor: postgresCodecLookup.metaFor, + renderOutputTypeFor: postgresCodecLookup.renderOutputTypeFor, + }; + + const document = parsePslDocument({ + schema: `model M { + id Int @id + count Int @default(7) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContractInternal({ + document, + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + controlMutationDefaults: builtinControlMutationDefaults, + codecLookup: spyLookup, + }); + + expect(result.ok).toBe(true); + expect(decodeJson).toHaveBeenCalledWith(7); + expect(renderSqlLiteral).toHaveBeenCalledWith(7); + expect(decodeJson.mock.invocationCallOrder[0]).toBeLessThan( + renderSqlLiteral.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER, + ); + }); + }); + + describe('@default(autoincrement()) parse-time trait gating', () => { + it('lowers @default(autoincrement()) to { kind: "autoincrement" } on a trait-bearing codec, without invoking the codec', () => { + const decodeJson = vi.fn(); + const renderSqlLiteral = vi.fn(); + const spyLookup: CodecLookup = { + get: (id) => { + if (id !== 'pg/int4@1') { + return postgresCodecLookup.get(id); + } + const stub = { + id, + descriptor: { traits: ['equality', 'order', 'numeric', 'autoincrement'] as const }, + decodeJson, + renderSqlLiteral, + }; + return stub as unknown as ReturnType; + }, + targetTypesFor: postgresCodecLookup.targetTypesFor, + metaFor: postgresCodecLookup.metaFor, + renderOutputTypeFor: postgresCodecLookup.renderOutputTypeFor, + }; + + const document = parsePslDocument({ + schema: `model M { + id Int @id @default(autoincrement()) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContractInternal({ + document, + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + controlMutationDefaults: builtinControlMutationDefaults, + codecLookup: spyLookup, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['id']?.default).toEqual({ + kind: 'autoincrement', + }); + expect(decodeJson).not.toHaveBeenCalled(); + expect(renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('rejects @default(autoincrement()) on a non-trait codec with a span-carrying diagnostic', () => { + const document = parsePslDocument({ + schema: `model M { + label String @default(autoincrement()) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + const diagnostic = result.failure.diagnostics.find( + (d) => + d.code === 'PSL_INVALID_DEFAULT_APPLICABILITY' && + d.message.includes('M.label') && + d.message.includes('autoincrement') && + d.message.includes('pg/text@1'), + ); + expect(diagnostic).toBeDefined(); + expect(diagnostic?.sourceId).toBe('schema.prisma'); + expect(diagnostic?.span?.start.line).toBe(2); + }); + }); + + describe('function-form defaults pass through without invoking codec methods', () => { + it('lowers @default(now()) to { kind: "expression", expression: "now()" } without invoking codec methods', () => { + const decodeJson = vi.fn(); + const renderSqlLiteral = vi.fn(); + const spyLookup: CodecLookup = { + get: (id) => { + if (id !== 'pg/timestamptz@1') { + return postgresCodecLookup.get(id); + } + const stub = { + id, + descriptor: { traits: ['equality', 'order'] as const }, + decodeJson, + renderSqlLiteral, + }; + return stub as unknown as ReturnType; + }, + targetTypesFor: postgresCodecLookup.targetTypesFor, + metaFor: postgresCodecLookup.metaFor, + renderOutputTypeFor: postgresCodecLookup.renderOutputTypeFor, + }; + + const document = parsePslDocument({ + schema: `model M { + id Int @id + createdAt DateTime @default(now()) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContractInternal({ + document, + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + controlMutationDefaults: builtinControlMutationDefaults, + codecLookup: spyLookup, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['createdAt']?.default).toEqual({ + kind: 'expression', + expression: 'now()', + }); + expect(decodeJson).not.toHaveBeenCalled(); + expect(renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('lowers @default(dbgenerated("...")) verbatim as an expression default', () => { + const document = parsePslDocument({ + schema: `model M { + id Int @id + custom String @default(dbgenerated("gen_random_uuid()")) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['custom']?.default).toEqual({ + kind: 'expression', + expression: 'gen_random_uuid()', + }); + }); + }); + + describe('null literal default rule', () => { + it('routes @default(null) on a nullable column to { kind: "expression", expression: "NULL" } without invoking codec', () => { + const decodeJson = vi.fn(); + const renderSqlLiteral = vi.fn(); + const spyLookup: CodecLookup = { + get: (id) => { + if (id !== 'pg/text@1') { + return postgresCodecLookup.get(id); + } + const stub = { + id, + descriptor: { traits: ['equality', 'order', 'textual'] as const }, + decodeJson, + renderSqlLiteral, + }; + return stub as unknown as ReturnType; + }, + targetTypesFor: postgresCodecLookup.targetTypesFor, + metaFor: postgresCodecLookup.metaFor, + renderOutputTypeFor: postgresCodecLookup.renderOutputTypeFor, + }; + + const document = parsePslDocument({ + schema: `model M { + id Int @id + nickname String? @default(null) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContractInternal({ + document, + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + controlMutationDefaults: builtinControlMutationDefaults, + codecLookup: spyLookup, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['nickname']?.default).toEqual({ + kind: 'expression', + expression: 'NULL', + }); + expect(decodeJson).not.toHaveBeenCalled(); + expect(renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('rejects @default(null) on a NOT NULL column with a diagnostic carrying column path, codec id, and file:line', () => { + const document = parsePslDocument({ + schema: `model M { + id Int @id + label String @default(null) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + const diagnostic = result.failure.diagnostics.find( + (d) => + d.code === 'PSL_INVALID_DEFAULT_VALUE' && + d.message.includes('M.label') && + d.message.includes('pg/text@1') && + d.message.includes('NOT NULL'), + ); + expect(diagnostic).toBeDefined(); + expect(diagnostic?.sourceId).toBe('schema.prisma'); + expect(diagnostic?.span?.start.line).toBe(3); + }); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts index 4ef837e379..334b019a79 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts @@ -7,6 +7,7 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, postgresScalarTypeDescriptors, postgresTarget, sqliteScalarTypeDescriptors, @@ -23,6 +24,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { interpretPslDocumentToSqlContractInternal({ target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, + codecLookup: postgresCodecLookup, ...input, }); it('lowers supported default functions into execution and storage contract shapes', () => { @@ -97,13 +99,13 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { }, dbExpr: { default: { - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }, }, createdAt: { default: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, @@ -217,13 +219,13 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { columns: { touchedAt: { default: { - kind: 'function', + kind: 'expression', expression: 'clock_timestamp()', }, }, payload: { default: { - kind: 'function', + kind: 'expression', expression: "'{}'::jsonb", }, }, @@ -246,7 +248,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { output: { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz', - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }, updatedAt: { @@ -272,7 +274,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { output: { codecId: 'sqlite/datetime@1', nativeType: 'text', - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }, updatedAt: { @@ -311,7 +313,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); expect(unboundTables(storage)['timestamped']?.columns['createdAt']?.default).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); expect(result.value.execution?.mutations.defaults).toEqual([ @@ -443,7 +445,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { output: { codecId: 'pg/text@1', nativeType: 'text', - default: { kind: 'function', expression: "'synthetic-default'" }, + default: { kind: 'expression', expression: "'synthetic-default'" }, }, }, }, @@ -465,7 +467,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { nativeType: 'text', nullable: false, default: { - kind: 'function', + kind: 'expression', expression: "'synthetic-default'", }, }, @@ -552,7 +554,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { output: { codecId: 'pg/text@1', nativeType: 'text', - default: { kind: 'function', expression: "'synthetic-default'" }, + default: { kind: 'expression', expression: "'synthetic-default'" }, }, }, }, diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index d9432edbf4..f1ea07b95a 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -7,6 +7,7 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, postgresScalarTypeDescriptors, postgresTarget, } from './fixtures'; @@ -19,6 +20,7 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { interpretPslDocumentToSqlContractInternal({ target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, + codecLookup: postgresCodecLookup, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts index 3965996e65..6fa8871050 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts @@ -9,6 +9,7 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, postgresScalarTypeDescriptors, postgresTarget, testEnumEntityContributions, @@ -34,6 +35,7 @@ describe('interpretPslDocumentToSqlContract', () => { target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, + codecLookup: postgresCodecLookup, ...input, }); @@ -399,13 +401,13 @@ model Post { user: { columns: { id: { - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }, createdAt: { - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, isActive: { - default: { kind: 'literal', value: true }, + default: { kind: 'expression', expression: 'TRUE' }, }, nickname: { nullable: true, diff --git a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts index 536a8fae79..07dc3af16b 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts @@ -456,7 +456,7 @@ model Document { columns: { dbExpr: { default: { - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }, }, diff --git a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts index be2bdc1a65..1ff4a4f31d 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts @@ -34,7 +34,7 @@ const sqlFamilyPack = { codecId: 'sql/timestamp@1', nativeType: 'timestamp', default: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, @@ -138,7 +138,7 @@ const sqliteTimestampTargetPack = { codecId: 'sqlite/datetime@1', nativeType: 'text', default: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, @@ -188,7 +188,7 @@ const postgresTimestampTargetPack = { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz', default: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, diff --git a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts index 668fe914bb..0846ba8f52 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts @@ -4,8 +4,6 @@ import { computeStorageHash, } from '@prisma-next/contract/hashing'; import { - type ColumnDefault, - type ColumnDefaultLiteralInputValue, type Contract, type ContractField, type ContractModel, @@ -13,7 +11,6 @@ import { type ContractValueObject, coreHash, type ExecutionMutationDefault, - type JsonValue, type StorageHashBase, } from '@prisma-next/contract/types'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; @@ -26,6 +23,7 @@ import { } from '@prisma-next/sql-contract/index-types'; import { applyFkDefaults, + type ColumnDefault, isPostgresEnumStorageEntry, type PostgresEnumStorageEntry, type SqlNamespaceTablesInput, @@ -45,34 +43,78 @@ import type { ModelNode, ValueObjectFieldNode, } from './contract-definition'; +import type { AuthoredColumnDefault } from './contract-dsl'; type DomainFieldRef = | { readonly kind: 'scalar'; readonly many?: boolean } | { readonly kind: 'valueObject'; readonly name: string; readonly many?: boolean }; -function encodeDefaultLiteralValue( - value: ColumnDefaultLiteralInputValue, - codecId: string, - codecLookup?: CodecLookup, -): JsonValue { - const codec = codecLookup?.get(codecId); - if (codec) { - return codec.encodeJson(value); - } - return value as JsonValue; +/** + * Minimal local view of the SQL-codec interface — every codec resolved from + * a `CodecLookup` in a SQL-contract authoring context carries + * `renderSqlLiteral(value)` (per `@prisma-next/sql-contract` codec interface, + * homed in the relational-core lane). The framework-level `CodecLookup` is + * target-agnostic and types codecs as the narrower framework `Codec`, so the + * SQL-specific method is reachable via a structural narrowing at the call + * site rather than a wider workspace-level type dependency. + */ +interface CodecWithRenderSqlLiteral { + readonly id: string; + renderSqlLiteral(value: unknown): string; } -function encodeColumnDefault( - defaultInput: ColumnDefault, - codecId: string, +/** + * Translate the DSL-internal {@link AuthoredColumnDefault} into the contract + * IR's {@link ColumnDefault}. Dispatch table: + * + * - `autoincrement` arm → `{ kind: 'autoincrement' }`; the codec is NOT + * invoked (the renderer relies on column-type SERIAL/IDENTITY/AUTOINCREMENT + * semantics for the actual emission). + * - `expression` arm → `{ kind: 'expression', expression }`; the function- + * form source passes through verbatim; the codec is NOT invoked. + * - `codecValue` arm with a `null` value: + * - column is nullable → `{ kind: 'expression', expression: 'NULL' }`; + * handled in a literal pass before the codec dispatches. Codec is NOT + * invoked. + * - column is NOT NULL → throw with a diagnostic naming the column path + * (`table.column`) and the codec id. Codec is NOT invoked. + * - `codecValue` arm with any other value → `codec.renderSqlLiteral(value)` + * → `{ kind: 'expression', expression: }`. The codec is required; + * we surface a clear error when it is missing. + */ +function emitColumnDefault( + authored: AuthoredColumnDefault, + context: { + readonly codecId: string; + readonly tableName: string; + readonly columnName: string; + readonly nullable: boolean; + }, codecLookup?: CodecLookup, ): ColumnDefault { - if (defaultInput.kind === 'function') { - return { kind: 'function', expression: defaultInput.expression }; + if (authored.kind === 'autoincrement') { + return { kind: 'autoincrement' }; + } + if (authored.kind === 'expression') { + return { kind: 'expression', expression: authored.expression }; + } + if (authored.value === null) { + if (!context.nullable) { + throw new Error( + `Column "${context.tableName}.${context.columnName}" (codec "${context.codecId}") is NOT NULL but a null literal was supplied to .default(...). Either mark the column .optional() or supply a non-null default.`, + ); + } + return { kind: 'expression', expression: 'NULL' }; + } + const codec = codecLookup?.get(context.codecId) as CodecWithRenderSqlLiteral | undefined; + if (!codec) { + throw new Error( + `Column "${context.tableName}.${context.columnName}" .default(...) requires a codec lookup that resolves codec "${context.codecId}" to render the literal value as a SQL expression; received ${codecLookup ? 'a lookup that does not know the codec' : 'no lookup at all'}.`, + ); } return { - kind: 'literal', - value: encodeDefaultLiteralValue(defaultInput.value, codecId, codecLookup), + kind: 'expression', + expression: codec.renderSqlLiteral(authored.value), }; } @@ -148,12 +190,22 @@ const JSONB_NATIVE_TYPE = 'jsonb'; function buildStorageColumn( field: FieldNode | ValueObjectFieldNode, + tableName: string, codecLookup?: CodecLookup, ): StorageColumn { if (isValueObjectField(field)) { const encodedDefault = field.default !== undefined - ? encodeColumnDefault(field.default, JSONB_CODEC_ID, codecLookup) + ? emitColumnDefault( + field.default, + { + codecId: JSONB_CODEC_ID, + tableName, + columnName: field.columnName, + nullable: field.nullable, + }, + codecLookup, + ) : undefined; return { @@ -175,7 +227,16 @@ function buildStorageColumn( const codecId = field.descriptor.codecId; const encodedDefault = field.default !== undefined - ? encodeColumnDefault(field.default, codecId, codecLookup) + ? emitColumnDefault( + field.default, + { + codecId, + tableName, + columnName: field.columnName, + nullable: field.nullable, + }, + codecLookup, + ) : undefined; return { @@ -319,7 +380,7 @@ export function buildSqlContractFromDefinition( } } - const column = buildStorageColumn(field, codecLookup); + const column = buildStorageColumn(field, tableName, codecLookup); columns[field.columnName] = column; fieldToColumn[field.fieldName] = field.columnName; diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts index 3e1fa4e753..82947b529d 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts @@ -17,6 +17,8 @@ import { createComposedAuthoringHelpers, } from './composed-authoring-helpers'; import { + type AutoincrementSentinel, + autoincrement, type ContractInput, type ContractModelBuilder, field, @@ -413,5 +415,11 @@ export function defineContract( return buildContractFromDsl(builtDefinition); } -export type { ComposedAuthoringHelpers, ContractInput, ContractModelBuilder, ScalarFieldBuilder }; -export { field, model, rel }; +export type { + AutoincrementSentinel, + ComposedAuthoringHelpers, + ContractInput, + ContractModelBuilder, + ScalarFieldBuilder, +}; +export { autoincrement, field, model, rel }; diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts index d8bb38ace3..f45f341f28 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault, ExecutionMutationDefaultPhases } from '@prisma-next/contract/types'; +import type { ExecutionMutationDefaultPhases } from '@prisma-next/contract/types'; import type { ForeignKeyDefaultsState } from '@prisma-next/contract-authoring'; import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; @@ -9,6 +9,7 @@ import type { SqlNamespaceTablesInput, StorageTypeInstance, } from '@prisma-next/sql-contract/types'; +import type { AuthoredColumnDefault } from './contract-dsl'; export type { ExecutionMutationDefaultPhases }; @@ -17,7 +18,7 @@ export interface FieldNode { readonly columnName: string; readonly descriptor: ColumnTypeDescriptor; readonly nullable: boolean; - readonly default?: ColumnDefault; + readonly default?: AuthoredColumnDefault; readonly executionDefaults?: ExecutionMutationDefaultPhases; readonly many?: boolean; } @@ -83,7 +84,7 @@ export interface ValueObjectFieldNode { readonly columnName: string; readonly valueObjectName: string; readonly nullable: boolean; - readonly default?: ColumnDefault; + readonly default?: AuthoredColumnDefault; readonly executionDefaults?: ExecutionMutationDefaultPhases; readonly many?: boolean; } diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts index 5bf5be3327..fbe79b8146 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts @@ -1,14 +1,16 @@ import type { - ColumnDefault, - ColumnDefaultLiteralInputValue, ExecutionMutationDefaultPhases, ExecutionMutationDefaultValue, } from '@prisma-next/contract/types'; -import { isColumnDefault } from '@prisma-next/contract/types'; import type { ForeignKeyDefaultsState } from '@prisma-next/contract-authoring'; import type { AuthoringFieldPresetDescriptor } from '@prisma-next/framework-components/authoring'; import { instantiateAuthoringFieldPreset } from '@prisma-next/framework-components/authoring'; -import type { CodecLookup, ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; +import type { + Codec, + CodecLookup, + CodecTrait, + ColumnTypeDescriptor, +} from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, FamilyPackRef, @@ -16,6 +18,7 @@ import type { } from '@prisma-next/framework-components/components'; import type { Namespace } from '@prisma-next/framework-components/ir'; import type { + ColumnDefaultLiteralInputValue, PostgresEnumStorageEntry, SqlNamespaceTablesInput, StorageTypeInstance, @@ -23,6 +26,73 @@ import type { import { ifDefined } from '@prisma-next/utils/defined'; import type { NamedConstraintSpec } from './authoring-type-utils'; +/** + * Brand for the {@link autoincrement} sentinel. The brand is a private symbol + * so the only way to obtain a value of this type is through the factory — + * users cannot manufacture sentinels by hand. + */ +declare const autoincrementSentinelBrand: unique symbol; +export interface AutoincrementSentinel { + readonly [autoincrementSentinelBrand]: 'autoincrement'; +} + +const AUTOINCREMENT_SENTINEL = Object.freeze({ + __kind: 'autoincrementSentinel' as const, +}) as unknown as AutoincrementSentinel; + +/** + * Factory for the autoincrement default sentinel. Pass the result to + * `.default(autoincrement())` on a column whose codec descriptor carries the + * `'autoincrement'` trait — the contract emitter stamps + * `{ kind: 'autoincrement' }` into the IR without invoking the codec. + * + * Calling `.default(autoincrement())` on a column whose codec does not + * declare the trait is a compile error via {@link AllowAutoincrement}. + */ +export function autoincrement(): AutoincrementSentinel { + return AUTOINCREMENT_SENTINEL; +} + +export function isAutoincrementSentinel(value: unknown): value is AutoincrementSentinel { + return value === AUTOINCREMENT_SENTINEL; +} + +/** + * Extracts the autoincrement-permission arm of `.default(value)`'s parameter + * union. Resolves to {@link AutoincrementSentinel} iff the descriptor's + * `traits` tuple includes `'autoincrement'`; otherwise resolves to `never`, + * which removes the sentinel arm from the parameter type. + */ +export type AllowAutoincrement = Descriptor extends { + readonly traits: infer T; +} + ? T extends readonly CodecTrait[] + ? 'autoincrement' extends T[number] + ? AutoincrementSentinel + : never + : never + : never; + +/** + * DSL-internal authoring shape for a captured column default. Distinct from + * the contract IR's {@link import('@prisma-next/sql-contract/types').ColumnDefault} + * shape — the IR has only `expression` and `autoincrement` arms; the + * authoring shape additionally carries `codecValue`, a transient slot + * holding the user-supplied literal before the emitter dispatches it + * through `codec.renderSqlLiteral`. + * + * - `codecValue`: literal value awaiting codec dispatch at emit time. + * `.default(value)` stores this for any non-sentinel input. + * - `expression`: pre-rendered SQL fragment that passes through to the IR + * unchanged. `.defaultSql(expr)` stores this. + * - `autoincrement`: payload-free sentinel. `.default(autoincrement())` + * stores this; the emitter forwards it to the IR untouched. + */ +export type AuthoredColumnDefault = + | { readonly kind: 'codecValue'; readonly value: unknown } + | { readonly kind: 'expression'; readonly expression: string } + | { readonly kind: 'autoincrement' }; + export type NamingStrategy = 'identity' | 'snake_case'; export type NamingConfig = { @@ -36,6 +106,8 @@ type NamedConstraintNameSpec = { readonly name: Name; }; +type FieldDescriptorShape = ColumnTypeDescriptor; + export type ScalarFieldState< CodecId extends string = string, TypeRef extends NamedStorageTypeRef | undefined = undefined, @@ -43,13 +115,14 @@ export type ScalarFieldState< ColumnName extends string | undefined = string | undefined, IdSpec extends NamedConstraintSpec | undefined = undefined, UniqueSpec extends NamedConstraintSpec | undefined = undefined, + Descriptor extends FieldDescriptorShape = FieldDescriptorShape, > = { readonly kind: 'scalar'; - readonly descriptor?: (ColumnTypeDescriptor & { readonly codecId: CodecId }) | undefined; + readonly descriptor?: (Descriptor & { readonly codecId: CodecId }) | undefined; readonly typeRef?: TypeRef | undefined; readonly nullable: Nullable; readonly columnName?: ColumnName | undefined; - readonly default?: ColumnDefault | undefined; + readonly default?: AuthoredColumnDefault | undefined; readonly executionDefaults?: ExecutionMutationDefaultPhases | undefined; } & (IdSpec extends NamedConstraintSpec ? { readonly id: IdSpec } : { readonly id?: undefined }) & (UniqueSpec extends NamedConstraintSpec @@ -58,16 +131,70 @@ export type ScalarFieldState< type AnyScalarFieldState = { readonly kind: 'scalar'; - readonly descriptor?: (ColumnTypeDescriptor & { readonly codecId: string }) | undefined; + readonly descriptor?: FieldDescriptorShape | undefined; readonly typeRef?: NamedStorageTypeRef | undefined; readonly nullable: boolean; readonly columnName?: string | undefined; - readonly default?: ColumnDefault | undefined; + readonly default?: AuthoredColumnDefault | undefined; readonly executionDefaults?: ExecutionMutationDefaultPhases | undefined; readonly id?: NamedConstraintSpec | undefined; readonly unique?: NamedConstraintSpec | undefined; }; +/** Pulls the descriptor type out of a {@link ScalarFieldState}. */ +type FieldDescriptor = State extends { + readonly descriptor?: infer D; +} + ? Exclude + : never; + +/** + * Open fallback shape for `.default(value)` when the field descriptor + * carries no codec reference at the type level (e.g. raw + * {@link ColumnTypeDescriptor} shapes produced by ad-hoc or test + * helpers). Widens {@link ColumnDefaultLiteralInputValue} (the IR's + * pre-codec input envelope) with `bigint` and `Uint8Array`. Production + * column helpers (`column(...)`) surface a `codecFactory` slot, so they + * resolve through {@link CodecInputForDescriptor} instead and the + * codec's own `TInput` decides what compiles. + */ +type SqlDslLiteralInputFallback = ColumnDefaultLiteralInputValue | bigint | Uint8Array; + +/** + * Extract the codec's `TInput` from a field descriptor that carries a + * `codecFactory` slot — the shape produced by the framework `column()` + * packager. The factory's return type is the codec instance; the + * codec's fourth generic is `TInput`. When the descriptor surfaces no + * codec slot (e.g. a bare {@link ColumnTypeDescriptor}), this resolves + * to {@link SqlDslLiteralInputFallback} so legacy authoring sites keep + * the broad pre-codec literal surface. + * + * The factory parameter list is typed `never[]` so the extractor is + * agnostic to whether the descriptor's factory takes `void` or a params + * record — only the return type matters. + */ +export type CodecInputForDescriptor = D extends { + readonly codecFactory: (...args: never[]) => infer R; +} + ? R extends Codec + ? TInput + : SqlDslLiteralInputFallback + : SqlDslLiteralInputFallback; + +/** + * Compute the `.default(value)` parameter for a column builder state. + * Combines the descriptor-resolved codec input with the trait-gated + * autoincrement sentinel; columns whose descriptor lacks the + * `'autoincrement'` trait see only the codec-input arm. The codec's + * own `TInput` is the open-set source of truth for what + * `.default(...)` accepts: branded types, extension-owned classes, and + * any other shape the codec admits all compile via this seam without + * the DSL enumerating them. + */ +export type DefaultInputForState = + | CodecInputForDescriptor> + | AllowAutoincrement>; + type HasNamedConstraintId = State extends ScalarFieldState< string, @@ -75,7 +202,8 @@ type HasNamedConstraintId = boolean, string | undefined, infer IdSpec, - NamedConstraintSpec | undefined + NamedConstraintSpec | undefined, + FieldDescriptorShape > ? IdSpec extends NamedConstraintSpec ? true @@ -89,7 +217,8 @@ type HasNamedConstraintUnique = boolean, string | undefined, NamedConstraintSpec | undefined, - infer UniqueSpec + infer UniqueSpec, + FieldDescriptorShape > ? UniqueSpec extends NamedConstraintSpec ? true @@ -115,7 +244,8 @@ type ApplyFieldSqlSpec< infer Nullable, infer ColumnName, infer IdSpec, - infer UniqueSpec + infer UniqueSpec, + infer Descriptor > ? ScalarFieldState< CodecId, @@ -131,7 +261,8 @@ type ApplyFieldSqlSpec< ? UniqueSpec extends NamedConstraintSpec ? NamedConstraintSpec : UniqueSpec - : UniqueSpec + : UniqueSpec, + Descriptor > : never; @@ -141,13 +272,6 @@ export type GeneratedFieldSpec = { readonly generated: ExecutionMutationDefaultValue; }; -function toColumnDefault(value: ColumnDefaultLiteralInputValue | ColumnDefault): ColumnDefault { - if (isColumnDefault(value)) { - return value; - } - return { kind: 'literal', value }; -} - // Chaining methods use `as unknown as ` because TypeScript cannot narrow generic conditional return types through object spread. The runtime values are correct — the casts bridge the gap between the spread result and the compile-time conditional type that encodes the state transition. export class ScalarFieldBuilder { declare readonly __state: State; @@ -161,9 +285,10 @@ export class ScalarFieldBuilder - ? ScalarFieldState + ? ScalarFieldState : never > { return new ScalarFieldBuilder({ @@ -175,9 +300,10 @@ export class ScalarFieldBuilder - ? ScalarFieldState + ? ScalarFieldState : never); } @@ -190,9 +316,10 @@ export class ScalarFieldBuilder - ? ScalarFieldState + ? ScalarFieldState : never > { return new ScalarFieldBuilder({ @@ -204,23 +331,27 @@ export class ScalarFieldBuilder - ? ScalarFieldState + ? ScalarFieldState : never); } - default(value: ColumnDefaultLiteralInputValue | ColumnDefault): ScalarFieldBuilder { + default(value: DefaultInputForState): ScalarFieldBuilder { + const authored: AuthoredColumnDefault = isAutoincrementSentinel(value) + ? { kind: 'autoincrement' } + : { kind: 'codecValue', value }; return new ScalarFieldBuilder({ ...this.state, - default: toColumnDefault(value), + default: authored, }) as ScalarFieldBuilder; } defaultSql(expression: string): ScalarFieldBuilder { return new ScalarFieldBuilder({ ...this.state, - default: { kind: 'function', expression }, + default: { kind: 'expression', expression }, }) as ScalarFieldBuilder; } @@ -233,7 +364,8 @@ export class ScalarFieldBuilder ? ScalarFieldState< CodecId, @@ -241,7 +373,8 @@ export class ScalarFieldBuilder, - UniqueSpec + UniqueSpec, + Descriptor > : never > { @@ -254,7 +387,8 @@ export class ScalarFieldBuilder ? ScalarFieldState< CodecId, @@ -262,7 +396,8 @@ export class ScalarFieldBuilder, - UniqueSpec + UniqueSpec, + Descriptor > : never); } @@ -276,9 +411,18 @@ export class ScalarFieldBuilder - ? ScalarFieldState> + ? ScalarFieldState< + CodecId, + TypeRef, + Nullable, + ColumnName, + IdSpec, + NamedConstraintSpec, + Descriptor + > : never > { return new ScalarFieldBuilder({ @@ -290,9 +434,18 @@ export class ScalarFieldBuilder - ? ScalarFieldState> + ? ScalarFieldState< + CodecId, + TypeRef, + Nullable, + ColumnName, + IdSpec, + NamedConstraintSpec, + Descriptor + > : never); } @@ -324,9 +477,19 @@ export class ScalarFieldBuilder( +function columnField( descriptor: Descriptor, -): ScalarFieldBuilder> { +): ScalarFieldBuilder< + ScalarFieldState< + Descriptor['codecId'], + undefined, + false, + undefined, + undefined, + undefined, + Descriptor + > +> { return new ScalarFieldBuilder({ kind: 'scalar', descriptor, @@ -334,9 +497,19 @@ function columnField( }); } -function generatedField( +function generatedField( spec: GeneratedFieldSpec & { readonly type: Descriptor }, -): ScalarFieldBuilder> { +): ScalarFieldBuilder< + ScalarFieldState< + Descriptor['codecId'], + undefined, + false, + undefined, + undefined, + undefined, + Descriptor + > +> { return new ScalarFieldBuilder({ kind: 'scalar', descriptor: { diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts index d08a2f04d1..78db6b6752 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts @@ -1,12 +1,8 @@ -import type { - ColumnDefault, - Contract, - ContractRelation, - StorageHashBase, -} from '@prisma-next/contract/types'; +import type { Contract, ContractRelation, StorageHashBase } from '@prisma-next/contract/types'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; import type { IndexTypeRegistration } from '@prisma-next/sql-contract/index-types'; import type { + ColumnDefault, ContractWithTypeMaps, Index, PostgresEnumStorageEntry, diff --git a/packages/2-sql/2-authoring/contract-ts/src/exports/contract-builder.ts b/packages/2-sql/2-authoring/contract-ts/src/exports/contract-builder.ts index 1ec4a2404d..f33c6973de 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/exports/contract-builder.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/exports/contract-builder.ts @@ -1,10 +1,12 @@ export type { + AutoincrementSentinel, ComposedAuthoringHelpers, ContractInput, ContractModelBuilder, ScalarFieldBuilder, } from '../contract-builder'; export { + autoincrement, buildSqlContractFromDefinition, defineContract, field, diff --git a/packages/2-sql/2-authoring/contract-ts/test/authoring-helper-runtime.test.ts b/packages/2-sql/2-authoring/contract-ts/test/authoring-helper-runtime.test.ts index 5d3567b24f..4bf079df7b 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/authoring-helper-runtime.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/authoring-helper-runtime.test.ts @@ -26,7 +26,7 @@ const createdAtPreset = { output: { codecId: 'sql/timestamp@1', nativeType: 'timestamp', - default: { kind: 'function', expression: 'CURRENT_TIMESTAMP' }, + default: { kind: 'expression', expression: 'CURRENT_TIMESTAMP' }, }, } as const; diff --git a/packages/2-sql/2-authoring/contract-ts/test/build-contract.defaults.test.ts b/packages/2-sql/2-authoring/contract-ts/test/build-contract.defaults.test.ts new file mode 100644 index 0000000000..c848c4bab7 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-ts/test/build-contract.defaults.test.ts @@ -0,0 +1,335 @@ +/** + * Unit tests for the contract emitter's default-dispatch logic. + * + * Four paths exercised: + * 1. autoincrement() sentinel → { kind: 'autoincrement' }; codec NOT invoked. + * 2. .defaultSql(expr) function-form → { kind: 'expression', expression: '' }; codec NOT invoked. + * 3. null literal on a nullable column → { kind: 'expression', expression: 'NULL' }; codec NOT invoked. + * 4. null literal on a NOT NULL column → diagnostic naming column + codec id; codec NOT invoked. + * 5. Other literals → codec.renderSqlLiteral(value) → { kind: 'expression', expression: }. + * + * "Codec NOT invoked" is enforced by a spy codec whose renderSqlLiteral + * throws if called. + */ + +import type { JsonValue } from '@prisma-next/contract/types'; +import type { Codec, CodecCallContext, CodecLookup } from '@prisma-next/framework-components/codec'; +import type { TargetPackRef } from '@prisma-next/framework-components/components'; +import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import { describe, expect, it, vi } from 'vitest'; +import { buildSqlContractFromDefinition } from '../src/build-contract'; +import type { ContractDefinition, FieldNode, ModelNode } from '../src/contract-definition'; + +const postgresTargetPack: TargetPackRef<'sql', 'postgres'> = { + kind: 'target', + id: 'postgres', + familyId: 'sql', + targetId: 'postgres', + version: '0.0.1', +}; + +function spyCodec( + id: string, + renderSqlLiteral: (value: unknown) => string, +): Codec & { renderSqlLiteral: ReturnType } { + const spy = vi.fn(renderSqlLiteral); + // The framework `Codec` interface (from `framework-components`) types + // encode/decode as taking `CodecCallContext` and returning `Promise` + // / `Promise`. The SQL `Codec` extension (from `relational-core`) + // narrows the context to `SqlCodecCallContext` and adds `renderSqlLiteral`. + // Building a literal that satisfies both shapes precisely would couple this + // test to the SQL-lane types (a layering violation: contract-ts cannot + // depend on lanes). The `as unknown as` bridges the structural mismatch on + // the call-context type so the spy can stand in for a SQL codec via the + // framework-level surface that `buildSqlContractFromDefinition` consumes. + const codec: Codec & { renderSqlLiteral: ReturnType } = { + id, + encode: async (value: unknown, _ctx: CodecCallContext) => value, + decode: async (wire: unknown, _ctx: CodecCallContext) => wire, + encodeJson: (value: unknown) => value as JsonValue, + decodeJson: (json: JsonValue) => json, + renderSqlLiteral: spy, + } as unknown as Codec & { renderSqlLiteral: ReturnType }; + return codec; +} + +function codecLookupFor(codec: Codec & { id: string }): CodecLookup { + return { + get: (id: string) => (id === codec.id ? codec : undefined), + targetTypesFor: () => undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, + }; +} + +function fieldNode( + fieldName: string, + columnName: string, + codecId: string, + nativeType: string, + defaultValue: FieldNode['default'] | undefined, + nullable: boolean, +): FieldNode { + return { + fieldName, + columnName, + descriptor: { codecId, nativeType }, + nullable, + ...(defaultValue !== undefined ? { default: defaultValue } : {}), + }; +} + +const probePkField: FieldNode = { + fieldName: 'pkId', + columnName: 'pk_id', + descriptor: { codecId: 'pg/int4@1', nativeType: 'int4' }, + nullable: false, +}; + +function buildSingleColumnContract(field: FieldNode, codecLookup?: CodecLookup) { + const model: ModelNode = { + modelName: 'Probe', + tableName: 'probe', + namespaceId: UNBOUND_NAMESPACE_ID, + fields: [probePkField, field], + id: { columns: [probePkField.columnName] }, + }; + const definition: ContractDefinition = { + target: postgresTargetPack, + models: [model], + }; + const contract = buildSqlContractFromDefinition(definition, codecLookup); + const namespaces = contract.storage.namespaces as Record< + string, + { tables: Record }> } + >; + const column = namespaces[UNBOUND_NAMESPACE_ID]?.tables['probe']?.columns[field.columnName]; + return column as { default?: { kind: string; expression?: string } } | undefined; +} + +describe('build-contract: column default dispatch', () => { + it('lowers autoincrement sentinel to { kind: "autoincrement" } without invoking the codec', () => { + const codec = spyCodec('pg/int4@1', () => { + throw new Error('codec.renderSqlLiteral should not be invoked for autoincrement'); + }); + const column = buildSingleColumnContract( + fieldNode('id', 'id', 'pg/int4@1', 'int4', { kind: 'autoincrement' }, false), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ kind: 'autoincrement' }); + expect(codec.renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('passes function-form expression through unchanged; codec is not invoked', () => { + const codec = spyCodec('pg/timestamptz@1', () => { + throw new Error('codec.renderSqlLiteral should not be invoked for function-form'); + }); + const column = buildSingleColumnContract( + fieldNode( + 'createdAt', + 'created_at', + 'pg/timestamptz@1', + 'timestamptz', + { kind: 'expression', expression: 'now()' }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ kind: 'expression', expression: 'now()' }); + expect(codec.renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('renders null on a nullable column to expression NULL; codec is not invoked', () => { + const codec = spyCodec('pg/text@1', () => { + throw new Error('codec.renderSqlLiteral should not be invoked for null literal'); + }); + const column = buildSingleColumnContract( + fieldNode( + 'nickname', + 'nickname', + 'pg/text@1', + 'text', + { kind: 'codecValue', value: null }, + true, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ kind: 'expression', expression: 'NULL' }); + expect(codec.renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('rejects null on a NOT NULL column with a diagnostic naming the column and codec id', () => { + const codec = spyCodec('pg/text@1', () => { + throw new Error('codec.renderSqlLiteral should not be invoked when diagnostic raises'); + }); + expect(() => + buildSingleColumnContract( + fieldNode( + 'email', + 'email', + 'pg/text@1', + 'text', + { kind: 'codecValue', value: null }, + false, + ), + codecLookupFor(codec), + ), + ).toThrowError(/probe\.email.*pg\/text@1/s); + expect(codec.renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('invokes codec.renderSqlLiteral for non-null literal values and stamps the rendered expression', () => { + const codec = spyCodec('pg/text@1', (value) => `'${String(value).replace(/'/g, "''")}'`); + const column = buildSingleColumnContract( + fieldNode( + 'status', + 'status', + 'pg/text@1', + 'text', + { kind: 'codecValue', value: 'draft' }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ kind: 'expression', expression: "'draft'" }); + expect(codec.renderSqlLiteral).toHaveBeenCalledExactlyOnceWith('draft'); + }); + + it('throws when a literal default needs codec dispatch but no codec lookup is provided', () => { + expect(() => + buildSingleColumnContract( + fieldNode( + 'status', + 'status', + 'pg/text@1', + 'text', + { kind: 'codecValue', value: 'draft' }, + false, + ), + // no codec lookup + ), + ).toThrowError(/pg\/text@1/); + }); + + it('throws when the codec lookup returns no codec for the column id', () => { + expect(() => + buildSingleColumnContract( + fieldNode( + 'status', + 'status', + 'pg/text@1', + 'text', + { kind: 'codecValue', value: 'draft' }, + false, + ), + { + get: () => undefined, + targetTypesFor: () => undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, + }, + ), + ).toThrowError(/pg\/text@1/); + }); + + it('passes Date literal values directly to codec.renderSqlLiteral without JSON round-trip', () => { + const received: unknown[] = []; + const codec = spyCodec('pg/timestamptz@1', (value) => { + received.push(value); + if (!(value instanceof Date)) { + throw new Error('Expected codec to receive the Date instance directly'); + } + return `'${value.toISOString()}'::timestamptz`; + }); + const sample = new Date('2026-05-20T12:34:56.000Z'); + const column = buildSingleColumnContract( + fieldNode( + 'scheduledAt', + 'scheduled_at', + 'pg/timestamptz@1', + 'timestamptz', + { kind: 'codecValue', value: sample }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ + kind: 'expression', + expression: "'2026-05-20T12:34:56.000Z'::timestamptz", + }); + expect(received).toEqual([sample]); + }); + + it('passes bigint literal values directly to codec.renderSqlLiteral without JSON round-trip', () => { + const codec = spyCodec('pg/int8@1', (value) => { + if (typeof value !== 'bigint') { + throw new Error('Expected codec to receive the bigint directly'); + } + return `${value.toString()}::int8`; + }); + const column = buildSingleColumnContract( + fieldNode( + 'serial', + 'serial', + 'pg/int8@1', + 'int8', + { kind: 'codecValue', value: 9007199254740993n }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ + kind: 'expression', + expression: '9007199254740993::int8', + }); + }); + + it('passes Uint8Array literal values directly to codec.renderSqlLiteral without JSON round-trip', () => { + const codec = spyCodec('pg/bytea@1', (value) => { + if (!(value instanceof Uint8Array)) { + throw new Error('Expected codec to receive the Uint8Array directly'); + } + const hex = Array.from(value) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + return `'\\x${hex}'::bytea`; + }); + const column = buildSingleColumnContract( + fieldNode( + 'salt', + 'salt', + 'pg/bytea@1', + 'bytea', + { kind: 'codecValue', value: new Uint8Array([0xde, 0xad, 0xbe, 0xef]) }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ + kind: 'expression', + expression: "'\\xdeadbeef'::bytea", + }); + }); + + it('passes JSON object literal values to codec.renderSqlLiteral', () => { + const codec = spyCodec( + 'pg/jsonb@1', + (value) => `'${JSON.stringify(value).replace(/'/g, "''")}'::jsonb`, + ); + const column = buildSingleColumnContract( + fieldNode( + 'meta', + 'meta', + 'pg/jsonb@1', + 'jsonb', + { kind: 'codecValue', value: { plan: 'pro', seats: 10 } }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ + kind: 'expression', + expression: `'${JSON.stringify({ plan: 'pro', seats: 10 })}'::jsonb`, + }); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts index 213dd03de5..c6c839a94c 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts @@ -177,21 +177,26 @@ describe('shared contract definition lowering', () => { }); }); - it('encodes literal defaults through codecLookup during storage lowering', () => { + it('renders literal defaults through codecLookup.renderSqlLiteral during storage lowering', () => { const codecLookup: CodecLookup = { get: (id) => { if (id !== 'pg/timestamptz@1') { return undefined; } - return { + const codec = { id, encode: async (value: unknown) => value, decode: async (wire: unknown) => wire, encodeJson: (value: unknown) => value instanceof Date ? value.toISOString() : (value as string), decodeJson: (json: unknown) => new Date(json as string), + renderSqlLiteral: (value: unknown) => + value instanceof Date + ? `'${value.toISOString()}'::timestamptz` + : `'${String(value)}'::timestamptz`, }; + return codec as ReturnType; }, targetTypesFor: (id) => (id === 'pg/timestamptz@1' ? ['timestamptz'] : undefined), metaFor: () => undefined, @@ -215,7 +220,7 @@ describe('shared contract definition lowering', () => { }, nullable: false, default: { - kind: 'literal', + kind: 'codecValue', value: new Date('2025-01-01T00:00:00.000Z'), }, }, @@ -227,8 +232,8 @@ describe('shared contract definition lowering', () => { ); expect(unboundTables(contract.storage)['event']?.columns['scheduled_at']?.default).toEqual({ - kind: 'literal', - value: '2025-01-01T00:00:00.000Z', + kind: 'expression', + expression: "'2025-01-01T00:00:00.000Z'::timestamptz", }); }); @@ -285,7 +290,7 @@ describe('shared contract definition lowering', () => { }, nullable: false, default: { - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }, executionDefaults: { diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.default.test-d.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.default.test-d.ts new file mode 100644 index 0000000000..e239486b42 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.default.test-d.ts @@ -0,0 +1,204 @@ +/** + * Compile-time type tests for `.default(value)` on the TS DSL: + * + * - `.default(autoincrement())` is admitted only on column builders whose + * descriptor declares the `'autoincrement'` trait. Calling it on a + * descriptor without the trait is a compile error. + * - `.default(matchingTInput)` compiles for representative codec inputs. + * - `.default(invalidValue)` is a compile error across the same inputs. + * + * The tests use the trait-aware test helper rather than codec packs so the + * trait surfacing is independent of the production column helpers being + * updated. Production column helpers will eventually surface traits the + * same way (a separate dispatch); the type-level extractor is exercised + * here against the test-helper shape directly. + */ + +import { describe, test } from 'vitest'; +import { autoincrement, field } from '../src/contract-builder'; +import { columnDescriptor, columnDescriptorWithTraits } from './helpers/column-descriptor'; +import { syntheticCodecDescriptor } from './helpers/synthetic-codec-descriptor'; + +const int4Column = columnDescriptorWithTraits('pg/int4@1', [ + 'equality', + 'order', + 'numeric', + 'autoincrement', +] as const); +const textColumn = columnDescriptorWithTraits('pg/text@1', [ + 'equality', + 'order', + 'textual', +] as const); +const boolColumn = columnDescriptorWithTraits('pg/bool@1', ['equality', 'boolean'] as const); +const noTraitsColumn = columnDescriptor('pg/json@1'); + +describe('.default(autoincrement()) trait gating', () => { + test('compiles when codec descriptor declares the autoincrement trait', () => { + field.column(int4Column).default(autoincrement()); + }); + + test('compile error when codec descriptor lacks the autoincrement trait', () => { + // @ts-expect-error pg/text@1 does not carry the autoincrement trait + field.column(textColumn).default(autoincrement()); + // @ts-expect-error pg/bool@1 does not carry the autoincrement trait + field.column(boolColumn).default(autoincrement()); + }); + + test('compile error when descriptor surfaces no traits at the type level', () => { + // @ts-expect-error descriptor without `traits` field surfaces never as the sentinel arm + field.column(noTraitsColumn).default(autoincrement()); + }); +}); + +describe('.default(value) literal-input shape', () => { + test('accepts representative JSON-shaped literals', () => { + field.column(textColumn).default('hello'); + field.column(int4Column).default(42); + field.column(boolColumn).default(true); + field.column(textColumn).default(null); + field.column(noTraitsColumn).default({ foo: 'bar', nested: [1, 2, 3] }); + }); + + test('accepts Date as a non-JSON literal', () => { + const timestamptzColumn = columnDescriptor('pg/timestamptz@1'); + field.column(timestamptzColumn).default(new Date('2026-05-20')); + }); + + test('accepts bigint as a non-JSON literal', () => { + const int8Column = columnDescriptor('pg/int8@1'); + field.column(int8Column).default(9007199254740993n); + }); + + test('accepts Uint8Array as a non-JSON literal', () => { + const byteaColumn = columnDescriptor('pg/bytea@1'); + field.column(byteaColumn).default(new Uint8Array([0xde, 0xad, 0xbe, 0xef])); + }); + + test('compile error for functions, undefined, and unsupported objects', () => { + // @ts-expect-error function literal is not a permitted default value + field.column(textColumn).default(() => 'computed'); + // @ts-expect-error undefined is not a permitted default value + field.column(textColumn).default(undefined); + // @ts-expect-error symbol is not a permitted default value + field.column(textColumn).default(Symbol('s')); + }); +}); + +describe('autoincrement() sentinel identity', () => { + test('sentinel is recoverable via the autoincrement() factory', () => { + const a = autoincrement(); + const b = autoincrement(); + // referential identity check — every call returns the singleton sentinel, + // and the type system rejects sentinel reconstruction outside the factory. + if (a !== b) { + throw new Error('autoincrement() must return the same singleton sentinel on every call'); + } + }); +}); + +// These tests are the load-bearing proof that the `.default(value)` extractor +// is open-set: an arbitrary codec's `TInput` flows into the DSL without the +// DSL enumerating the shape. A closed enumeration would compile the legacy +// JSON / Date / bigint / Uint8Array cases above while rejecting the branded +// values and class instances below — which is exactly the failure-mode the +// extractor replaces. + +declare const emailAddressBrand: unique symbol; +type EmailAddress = string & { readonly [emailAddressBrand]: 'EmailAddress' }; + +declare const userIdBrand: unique symbol; +type UserId = number & { readonly [userIdBrand]: 'UserId' }; + +class Money { + // Private field makes Money nominal: structurally-equivalent plain objects + // do not satisfy the class type. This is the load-bearing distinction + // between "codec admits a class instance" and "codec admits a plain bag". + readonly #nominal = true; + constructor( + readonly amount: number, + readonly currency: string, + ) { + void this.#nominal; + } +} + +class Temperature { + readonly #nominal = true; + constructor(readonly celsius: number) { + void this.#nominal; + } +} + +describe('.default(value) extracts codec TInput from descriptor (branded scalars)', () => { + test('accepts a branded string when the codec admits it', () => { + const emailColumn = syntheticCodecDescriptor< + 'app/email@1', + readonly ['equality'], + EmailAddress + >('app/email@1', ['equality'] as const, 'text'); + const branded = 'user@example.com' as EmailAddress; + field.column(emailColumn).default(branded); + }); + + test('rejects an unbranded string for a brand-typed codec', () => { + const emailColumn = syntheticCodecDescriptor< + 'app/email@1', + readonly ['equality'], + EmailAddress + >('app/email@1', ['equality'] as const, 'text'); + // @ts-expect-error a plain string is not assignable to EmailAddress without the brand + field.column(emailColumn).default('user@example.com'); + }); + + test('accepts a branded number when the codec admits it', () => { + const userIdColumn = syntheticCodecDescriptor< + 'app/userId@1', + readonly ['equality', 'order'], + UserId + >('app/userId@1', ['equality', 'order'] as const, 'int4'); + const branded = 42 as UserId; + field.column(userIdColumn).default(branded); + }); + + test('rejects an unbranded number for a brand-typed codec', () => { + const userIdColumn = syntheticCodecDescriptor< + 'app/userId@1', + readonly ['equality', 'order'], + UserId + >('app/userId@1', ['equality', 'order'] as const, 'int4'); + // @ts-expect-error a plain number is not assignable to UserId without the brand + field.column(userIdColumn).default(7); + }); +}); + +describe('.default(value) extracts codec TInput from descriptor (class instances)', () => { + test('accepts a Money instance when the codec admits Money', () => { + const moneyColumn = syntheticCodecDescriptor<'app/money@1', readonly ['equality'], Money>( + 'app/money@1', + ['equality'] as const, + 'numeric', + ); + field.column(moneyColumn).default(new Money(99, 'USD')); + }); + + test('rejects an unrelated class instance for a Money-typed codec', () => { + const moneyColumn = syntheticCodecDescriptor<'app/money@1', readonly ['equality'], Money>( + 'app/money@1', + ['equality'] as const, + 'numeric', + ); + // @ts-expect-error Temperature is structurally incompatible with Money (different property set) + field.column(moneyColumn).default(new Temperature(20)); + }); + + test('rejects a plain object literal for a class-typed codec', () => { + const moneyColumn = syntheticCodecDescriptor<'app/money@1', readonly ['equality'], Money>( + 'app/money@1', + ['equality'] as const, + 'numeric', + ); + // @ts-expect-error plain object is missing the nominal class identity Money carries + field.column(moneyColumn).default({ amount: 99, currency: 'USD' }); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts index e77f33ecb1..a846680ef5 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts @@ -36,7 +36,7 @@ const sqlFamilyPack = { output: { codecId: 'sql/timestamp@1', nativeType: 'timestamp', - default: { kind: 'function', expression: 'CURRENT_TIMESTAMP' }, + default: { kind: 'expression', expression: 'CURRENT_TIMESTAMP' }, }, }, updatedAt: { @@ -274,7 +274,7 @@ describe('contract DSL helper vocabulary', () => { nativeType: 'timestamp', nullable: false, default: { - kind: 'function', + kind: 'expression', expression: 'CURRENT_TIMESTAMP', }, }); @@ -628,7 +628,7 @@ describe('contract DSL helper vocabulary', () => { typeParams: { length: 36 }, }); expect(unboundTables(contract.storage)['audit_entry']!.columns['created_at']!.default).toEqual({ - kind: 'function', + kind: 'expression', expression: 'CURRENT_TIMESTAMP', }); }); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.portability.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.portability.test.ts index 861ec1b1f4..13b502a650 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.portability.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.portability.test.ts @@ -103,7 +103,7 @@ describe('contract DSL portability coverage', () => { codecId: 'sql/timestamp@1', nativeType: 'timestamp', default: { - kind: 'function', + kind: 'expression', expression: 'CURRENT_TIMESTAMP', }, }); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts index 9b0a8044ae..8e1a8ae809 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts @@ -175,7 +175,7 @@ describe('contract DSL authoring surface', () => { const appUserColumns = storageTables['app_user']?.columns; expect(appUserColumns?.['created_at']?.default).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); expect(appUserColumns?.['role']?.typeRef).toBe('Role'); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts index d11c14cb5c..727948285e 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts @@ -14,7 +14,7 @@ const postgresTargetPack: TargetPackRef<'sql', 'postgres'> = { }; describe('value objects in contract definition builder', () => { - it('encodes value-object literal defaults through codecLookup during storage lowering', () => { + it('renders value-object literal defaults through codec.renderSqlLiteral during storage lowering', () => { const isMoneyValue = (value: unknown): value is { amount: number; currency: string } => typeof value === 'object' && value !== null && @@ -29,22 +29,20 @@ describe('value objects in contract definition builder', () => { return undefined; } - return { + const codec = { id, encode: async (value: unknown) => value, decode: async (wire: unknown) => wire, - encodeJson: (value: unknown) => { + encodeJson: (value: unknown) => value, + decodeJson: (json: unknown) => json, + renderSqlLiteral: (value: unknown) => { if (!isMoneyValue(value)) { throw new Error('Expected a Money value'); } - - return { - amount: value.amount.toString(), - currency: value.currency, - }; + return `'${JSON.stringify({ amount: value.amount.toString(), currency: value.currency })}'::jsonb`; }, - decodeJson: (json: unknown) => json, }; + return codec as ReturnType; }, targetTypesFor: (id) => (id === 'pg/jsonb@1' ? ['jsonb'] : undefined), metaFor: () => undefined, @@ -71,7 +69,7 @@ describe('value objects in contract definition builder', () => { valueObjectName: 'Money', nullable: false, default: { - kind: 'literal', + kind: 'codecValue', value: { amount: 12, currency: 'EUR', @@ -106,11 +104,8 @@ describe('value objects in contract definition builder', () => { ); expect(unboundTables(contract.storage)['invoice']?.columns['total']?.default).toEqual({ - kind: 'literal', - value: { - amount: '12', - currency: 'EUR', - }, + kind: 'expression', + expression: `'${JSON.stringify({ amount: '12', currency: 'EUR' })}'::jsonb`, }); }); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts index d66a48abac..85bc814608 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts @@ -41,10 +41,7 @@ const charColumn = columnDescriptor('sql/char@1', 'character'); describe('contract DSL runtime helpers', () => { it('normalizes defaults, generated descriptors, relation helpers, and input detection', () => { const literalDefault = field.column(textColumn).default('draft').build(); - const functionDefault = field - .column(textColumn) - .default({ kind: 'function', expression: 'now()' }) - .build(); + const functionDefault = field.column(textColumn).defaultSql('now()').build(); const generated = field .generated({ type: charColumn, @@ -65,8 +62,8 @@ describe('contract DSL runtime helpers', () => { const lazyBelongsTo = rel.belongsTo(() => User, { from: 'id', to: 'id' }).build(); - expect(literalDefault.default).toEqual({ kind: 'literal', value: 'draft' }); - expect(functionDefault.default).toEqual({ kind: 'function', expression: 'now()' }); + expect(literalDefault.default).toEqual({ kind: 'codecValue', value: 'draft' }); + expect(functionDefault.default).toEqual({ kind: 'expression', expression: 'now()' }); expect(generated.descriptor).toEqual({ codecId: 'sql/char@1', nativeType: 'character', diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract.logic.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract.logic.test.ts index a122b6e007..a70ded213f 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract.logic.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract.logic.test.ts @@ -424,7 +424,7 @@ describe('SqlContractSerializer logic validation', () => { codecId: 'pg/text@1', nativeType: 'text', nullable: false, - default: { kind: 'function', expression: 'gen_random_uuid()' }, + default: { kind: 'expression', expression: 'gen_random_uuid()' }, }, title: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, }, @@ -436,11 +436,11 @@ describe('SqlContractSerializer logic validation', () => { }), }; - it('accepts function defaults without capability gating', () => { + it('accepts expression defaults without capability gating', () => { expect(() => validateSqlContractFully>(baseContract)).not.toThrow(); }); - it('accepts multiple function defaults without capability gating', () => { + it('accepts multiple expression defaults and autoincrement sentinel without capability gating', () => { const contract = { ...baseContract, storage: sqlStorageFixture({ @@ -450,19 +450,19 @@ describe('SqlContractSerializer logic validation', () => { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }, createdAt: { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, externalId: { codecId: 'pg/text@1', nativeType: 'text', nullable: false, - default: { kind: 'function', expression: 'gen_random_uuid()' }, + default: { kind: 'expression', expression: 'gen_random_uuid()' }, }, title: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, }, @@ -476,32 +476,7 @@ describe('SqlContractSerializer logic validation', () => { expect(() => validateSqlContractFully>(contract)).not.toThrow(); }); - it('ignores non-function defaults (literal)', () => { - const contract = { - ...baseContract, - storage: sqlStorageFixture({ - Post: { - columns: { - id: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, - status: { - codecId: 'pg/text@1', - nativeType: 'text', - nullable: false, - default: { kind: 'literal', value: 'draft' }, - }, - }, - primaryKey: { columns: ['id'] }, - uniques: [], - indexes: [], - foreignKeys: [], - }, - }), - // No capabilities needed for non-function defaults - }; - expect(() => validateSqlContractFully>(contract)).not.toThrow(); - }); - - it('keeps ISO string defaults as strings for timestamp columns', () => { + it('preserves codec-rendered literal expressions on the column default', () => { const contract = { ...baseContract, storage: sqlStorageFixture({ @@ -512,7 +487,10 @@ describe('SqlContractSerializer logic validation', () => { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz', nullable: false, - default: { kind: 'literal', value: '2024-01-01T00:00:00.000Z' }, + default: { + kind: 'expression', + expression: "'2024-01-01T00:00:00.000Z'::timestamptz", + }, }, }, primaryKey: { columns: ['id'] }, @@ -525,10 +503,10 @@ describe('SqlContractSerializer logic validation', () => { const validated = validateSqlContractFully>(contract); const defaultValue = unboundTables(validated.storage)['Post']!.columns['createdAt']!.default; - if (defaultValue?.kind !== 'literal') { - throw new Error('Expected literal default'); + if (defaultValue?.kind !== 'expression') { + throw new Error('Expected expression default'); } - expect(defaultValue.value).toBe('2024-01-01T00:00:00.000Z'); + expect(defaultValue.expression).toBe("'2024-01-01T00:00:00.000Z'::timestamptz"); }); it('throws for default with unsupported kind', () => { @@ -555,7 +533,7 @@ describe('SqlContractSerializer logic validation', () => { expect(() => validateSqlContractFully>(contract)).toThrow(); }); - it('throws for default missing value', () => { + it('throws for legacy literal default shape (kind absent from new union)', () => { const contract = { ...baseContract, storage: sqlStorageFixture({ @@ -566,7 +544,7 @@ describe('SqlContractSerializer logic validation', () => { codecId: 'pg/text@1', nativeType: 'text', nullable: false, - default: { kind: 'literal' }, + default: { kind: 'literal', value: 'draft' }, }, }, primaryKey: { columns: ['id'] }, @@ -579,7 +557,7 @@ describe('SqlContractSerializer logic validation', () => { expect(() => validateSqlContractFully>(contract)).toThrow(); }); - it('throws for default expression with non-string type', () => { + it('throws for expression default with non-string expression', () => { const contract = { ...baseContract, storage: sqlStorageFixture({ @@ -590,7 +568,7 @@ describe('SqlContractSerializer logic validation', () => { codecId: 'pg/text@1', nativeType: 'text', nullable: false, - default: { kind: 'function', expression: 123 }, + default: { kind: 'expression', expression: 123 }, }, }, primaryKey: { columns: ['id'] }, diff --git a/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts b/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts index 48ce931619..fed7278546 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts @@ -1,10 +1,10 @@ -import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; +import type { CodecTrait, ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; -export function columnDescriptor( - codecId: string, +export function columnDescriptor( + codecId: TCodecId, nativeType?: string, typeParams?: Record, -): ColumnTypeDescriptor { +): ColumnTypeDescriptor & { readonly codecId: TCodecId } { const derived = nativeType ?? codecId.match(/^[^/]+\/([^@]+)@/)?.[1] ?? codecId; return { codecId, @@ -12,3 +12,32 @@ export function columnDescriptor( ...(typeParams ? { typeParams } : {}), }; } + +/** + * Test helper that produces a descriptor carrying a literal trait tuple at + * the type level. The TS DSL reads `descriptor.traits` to drive trait gating + * (e.g. `.default(autoincrement())` is admitted only when traits include + * `'autoincrement'`). Production column helpers will surface traits the + * same way once their packagers are updated; the test helper short-circuits + * to that shape directly. + */ +export function columnDescriptorWithTraits< + const TCodecId extends string, + const TTraits extends readonly CodecTrait[], +>( + codecId: TCodecId, + traits: TTraits, + nativeType?: string, + typeParams?: Record, +): ColumnTypeDescriptor & { + readonly codecId: TCodecId; + readonly traits: TTraits; +} { + const derived = nativeType ?? codecId.match(/^[^/]+\/([^@]+)@/)?.[1] ?? codecId; + return { + codecId, + nativeType: derived, + traits, + ...(typeParams ? { typeParams } : {}), + }; +} diff --git a/packages/2-sql/2-authoring/contract-ts/test/helpers/synthetic-codec-descriptor.ts b/packages/2-sql/2-authoring/contract-ts/test/helpers/synthetic-codec-descriptor.ts new file mode 100644 index 0000000000..56d67e218a --- /dev/null +++ b/packages/2-sql/2-authoring/contract-ts/test/helpers/synthetic-codec-descriptor.ts @@ -0,0 +1,58 @@ +/** + * Synthetic-codec test helper for the `.default(value)` type extractor. + * + * The DSL's {@link import('../../src/contract-dsl').CodecInputForDescriptor} + * extractor reads a codec's `TInput` off the field descriptor's + * `codecFactory` slot — the shape produced by the framework `column()` + * packager. Production tests already exercise that path via real codec + * packs; the synthetic helper here lets type-level tests probe the + * extractor with arbitrary `TInput` shapes (branded types, custom + * classes, etc.) without depending on production codecs. + * + * The helper returns a descriptor compatible with `FieldDescriptorShape` + * plus a `codecFactory` slot whose return type carries the configured + * `TInput`. The factory is never invoked at runtime in type-level tests; + * the helper exists purely to thread `TInput` into the descriptor's + * static type. + */ +import type { + Codec, + CodecCallContext, + CodecInstanceContext, + CodecTrait, + ColumnTypeDescriptor, +} from '@prisma-next/framework-components/codec'; + +export type SyntheticCodecDescriptor< + TCodecId extends string, + TTraits extends readonly CodecTrait[], + TInput, +> = ColumnTypeDescriptor & { + readonly codecId: TCodecId; + readonly traits: TTraits; + readonly codecFactory: (ctx: CodecInstanceContext) => Codec; +}; + +export function syntheticCodecDescriptor< + const TCodecId extends string, + const TTraits extends readonly CodecTrait[], + TInput, +>( + codecId: TCodecId, + traits: TTraits, + nativeType?: string, +): SyntheticCodecDescriptor { + const derived = nativeType ?? codecId.match(/^[^/]+\/([^@]+)@/)?.[1] ?? codecId; + return { + codecId, + nativeType: derived, + traits, + codecFactory: (): Codec => ({ + id: codecId, + encode: async (_value: TInput, _ctx: CodecCallContext) => undefined, + decode: async (_wire: unknown, _ctx: CodecCallContext) => undefined as TInput, + encodeJson: () => null, + decodeJson: () => undefined as TInput, + }), + }; +} diff --git a/packages/2-sql/3-tooling/emitter/src/index.ts b/packages/2-sql/3-tooling/emitter/src/index.ts index ace799460c..a4b26d884a 100644 --- a/packages/2-sql/3-tooling/emitter/src/index.ts +++ b/packages/2-sql/3-tooling/emitter/src/index.ts @@ -335,10 +335,6 @@ export const sqlEmission = { return [ 'export type LaneCodecTypes = CodecTypes;', `export type QueryOperationTypes = ${queryOperationTypes};`, - 'type DefaultLiteralValue =', - ' CodecId extends keyof CodecTypes', - " ? CodecTypes[CodecId]['output']", - ' : _Encoded;', ].join('\n'); }, @@ -445,11 +441,9 @@ function generateTableLiteralType(table: StorageTable): string { const nativeType = serializeValue(col.nativeType); const codecId = serializeValue(col.codecId); const defaultSpec = col.default - ? col.default.kind === 'literal' - ? `; readonly default: { readonly kind: 'literal'; readonly value: DefaultLiteralValue<${codecId}, ${serializeValue( - col.default.value, - )}> }` - : `; readonly default: { readonly kind: 'function'; readonly expression: ${serializeValue( + ? col.default.kind === 'autoincrement' + ? `; readonly default: { readonly kind: 'autoincrement' }` + : `; readonly default: { readonly kind: 'expression'; readonly expression: ${serializeValue( col.default.expression, )} }` : ''; diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts b/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts index a617f26c51..1c72a370b8 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts @@ -60,9 +60,11 @@ export interface CodecMeta { } /** - * SQL codec — extends the framework codec base by narrowing the per-call context to the SQL-family {@link SqlCodecCallContext} (adds `column?: SqlColumnRef`). TypeScript treats method-syntax declarations bivariantly, so the SQL narrowing is structurally compatible with the framework {@link BaseCodec} super-interface. + * SQL codec — extends the framework codec base by narrowing the per-call context to the SQL-family {@link SqlCodecCallContext} (adds `column?: SqlColumnRef`) and adding the required build-time `renderSqlLiteral(value)` method that lowers a `TInput` to a dialect-specific SQL expression. TypeScript treats method-syntax declarations bivariantly, so the SQL narrowing is structurally compatible with the framework {@link BaseCodec} super-interface. * - * Codec-id-keyed static metadata (`traits`, `targetTypes`, `meta`, `paramsSchema`, `renderOutputType`) lives on the unified {@link import('@prisma-next/framework-components/codec').CodecDescriptor} — the codec instance itself only carries `id` plus the four conversion methods. + * `renderSqlLiteral` is required on every SQL codec — there is no identity fallback. Codec authors decide the dialect-specific spelling of literal values (`TRUE`/`FALSE` vs `1`/`0`, ISO-8601 with `::timestamptz` cast vs bare strings, escape rules for embedded quotes/backslashes/NULL bytes, etc.). The returned string is a complete SQL fragment the DDL renderer wraps as `DEFAULT ()`. Adversarial inputs (quotes, backslashes, NULL bytes, unicode) must be escaped correctly per dialect — the emitter performs no string concatenation around the result. + * + * Codec-id-keyed static metadata (`traits`, `targetTypes`, `meta`, `paramsSchema`, `renderOutputType`) lives on the unified {@link import('@prisma-next/framework-components/codec').CodecDescriptor} — the codec instance itself carries `id`, the four conversion methods, and `renderSqlLiteral`. * * See `Codec` in `@prisma-next/framework-components/codec` for the codec contract that this interface extends. */ @@ -74,6 +76,7 @@ export interface Codec< > extends BaseCodec { encode(value: TInput, ctx: SqlCodecCallContext): Promise; decode(wire: TWire, ctx: SqlCodecCallContext): Promise; + renderSqlLiteral(value: TInput): string; } /** diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts index a276338058..80db0d35b2 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts @@ -6,6 +6,30 @@ import type { JsonValue } from '@prisma-next/contract/types'; +/** + * Standard-SQL escape for a single-quoted string literal. + * + * Doubles embedded single quotes (`O'Brien` -> `O''Brien`) and rejects embedded NULL bytes (`\0`) which most relational engines either truncate at or refuse outright. Backslashes pass through as literal characters — Postgres with `standard_conforming_strings` (the default since 9.1) and SQLite both treat backslashes literally inside `'…'` strings. + * + * The returned string excludes the wrapping quotes; callers concatenate them. + */ +export function escapeStandardSqlLiteral(value: string): string { + if (value.includes('\0')) { + throw new Error('SQL literal value cannot contain NULL bytes'); + } + return value.replace(/'/g, "''"); +} + +/** + * Read the Postgres native type name (e.g. `'integer'`, `'character'`, `'character varying'`) from a codec descriptor's `meta` slot. Returns `undefined` when the descriptor carries no Postgres meta — used by SQL base codec renderers (which are dialect-neutral by name but render with a Postgres-style `::cast` suffix when aliased through a Postgres descriptor). + */ +export function readPostgresNativeTypeFromMeta(meta: unknown): string | undefined { + const m = meta as + | { readonly db?: { readonly sql?: { readonly postgres?: { readonly nativeType?: string } } } } + | undefined; + return m?.db?.sql?.postgres?.nativeType; +} + export const SQL_CHAR_CODEC_ID = 'sql/char@1' as const; export const SQL_VARCHAR_CODEC_ID = 'sql/varchar@1' as const; export const SQL_INT_CODEC_ID = 'sql/int@1' as const; diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-impl.ts b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-impl.ts new file mode 100644 index 0000000000..99cb1e7ff1 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-impl.ts @@ -0,0 +1,19 @@ +/** + * Abstract base class for concrete SQL codec implementations. + * + * Extends the framework {@link CodecImpl} and adds the abstract `renderSqlLiteral(value)` method as the SQL family's structural enforcement of dialect-specific literal rendering. Concrete SQL codec subclasses (`SqlTextCodec`, `PgInt4Codec`, `SqliteIntegerCodec`, etc.) extend this class instead of the framework `CodecImpl`; the abstract method makes omitting `renderSqlLiteral` a compile-time error at the class-construction site. + * + * `renderSqlLiteral` returns a complete SQL fragment (e.g. `'TRUE'`, `'42'`, `''escaped string''`, `''2026-04-30T00:00:00Z'::timestamptz`) that the DDL renderer wraps as `DEFAULT ()`. Authors own dialect-specific escaping for adversarial inputs (single quotes, backslashes, NULL bytes, unicode) — the emitter performs no string concatenation around the result. + */ + +import type { CodecTrait } from '@prisma-next/framework-components/codec'; +import { CodecImpl } from '@prisma-next/framework-components/codec'; + +export abstract class SqlCodecImpl< + Id extends string = string, + TTraits extends readonly CodecTrait[] = readonly CodecTrait[], + TWire = unknown, + TInput = unknown, +> extends CodecImpl { + abstract renderSqlLiteral(value: TInput): string; +} diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts index dbcf1125b1..87b2e2f153 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts @@ -3,7 +3,7 @@ * * Each codec ships as three artifacts: * - * 1. A `SqlXCodec` class extending {@link CodecImpl} that wraps the module-level encode/decode constants exported from `sql-codec-helpers.ts` (the single source of truth for runtime behaviour). 2. A `SqlXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, params schema, and (where applicable) the emit-path `renderOutputType`. 3. A per-codec column helper (`sqlXColumn`) + * 1. A `SqlXCodec` class extending {@link SqlCodecImpl} that wraps the module-level encode/decode constants exported from `sql-codec-helpers.ts` (the single source of truth for runtime behaviour). 2. A `SqlXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, params schema, and (where applicable) the emit-path `renderOutputType`. 3. A per-codec column helper (`sqlXColumn`) * that calls `descriptor.factory(...)` directly and packages the result into a {@link ColumnSpec} via the framework {@link column} packager. The helper is tied to its descriptor with `satisfies ColumnHelperFor`. * * After TML-2357 this file is the canonical source of SQL base codec metadata and runtime behaviour — the legacy `mkCodec` / `defineCodec` carriers retired with the deletion sweep. @@ -13,7 +13,6 @@ import type { JsonValue } from '@prisma-next/contract/types'; import { type CodecCallContext, CodecDescriptorImpl, - CodecImpl, type CodecInstanceContext, type ColumnHelperFor, type ColumnHelperForStrict, @@ -23,6 +22,8 @@ import { import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type as arktype } from 'arktype'; import { + escapeStandardSqlLiteral, + readPostgresNativeTypeFromMeta, SQL_CHAR_CODEC_ID, SQL_FLOAT_CODEC_ID, SQL_INT_CODEC_ID, @@ -47,6 +48,12 @@ import { sqlVarcharEncode, sqlVarcharRenderOutputType, } from './sql-codec-helpers'; +import { SqlCodecImpl } from './sql-codec-impl'; + +function appendPgCast(literal: string, descriptor: { readonly meta?: unknown }): string { + const nativeType = readPostgresNativeTypeFromMeta(descriptor.meta); + return nativeType ? `${literal}::${nativeType}` : literal; +} type LengthParams = { readonly length?: number }; type PrecisionParams = { readonly precision?: number }; @@ -59,7 +66,7 @@ const precisionParamsSchema = arktype({ 'precision?': 'number.integer >= 0 & number.integer <= 6', }) satisfies StandardSchemaV1; -export class SqlTextCodec extends CodecImpl< +export class SqlTextCodec extends SqlCodecImpl< typeof SQL_TEXT_CODEC_ID, readonly ['equality', 'order', 'textual'], string, @@ -77,6 +84,9 @@ export class SqlTextCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return appendPgCast(`'${escapeStandardSqlLiteral(value)}'`, this.descriptor); + } } export class SqlTextDescriptor extends CodecDescriptorImpl { @@ -97,7 +107,7 @@ export const sqlTextColumn = () => sqlTextColumn satisfies ColumnHelperFor; sqlTextColumn satisfies ColumnHelperForStrict; -export class SqlIntCodec extends CodecImpl< +export class SqlIntCodec extends SqlCodecImpl< typeof SQL_INT_CODEC_ID, readonly ['equality', 'order', 'numeric'], number, @@ -115,6 +125,9 @@ export class SqlIntCodec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return appendPgCast(String(value), this.descriptor); + } } export class SqlIntDescriptor extends CodecDescriptorImpl { @@ -135,7 +148,7 @@ export const sqlIntColumn = () => sqlIntColumn satisfies ColumnHelperFor; sqlIntColumn satisfies ColumnHelperForStrict; -export class SqlFloatCodec extends CodecImpl< +export class SqlFloatCodec extends SqlCodecImpl< typeof SQL_FLOAT_CODEC_ID, readonly ['equality', 'order', 'numeric'], number, @@ -153,6 +166,9 @@ export class SqlFloatCodec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return appendPgCast(String(value), this.descriptor); + } } export class SqlFloatDescriptor extends CodecDescriptorImpl { @@ -173,7 +189,7 @@ export const sqlFloatColumn = () => sqlFloatColumn satisfies ColumnHelperFor; sqlFloatColumn satisfies ColumnHelperForStrict; -export class SqlCharCodec extends CodecImpl< +export class SqlCharCodec extends SqlCodecImpl< typeof SQL_CHAR_CODEC_ID, readonly ['equality', 'order', 'textual'], string, @@ -191,6 +207,9 @@ export class SqlCharCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return appendPgCast(`'${escapeStandardSqlLiteral(value)}'`, this.descriptor); + } } export class SqlCharDescriptor extends CodecDescriptorImpl { @@ -214,7 +233,7 @@ export const sqlCharColumn = (params: LengthParams = {}) => sqlCharColumn satisfies ColumnHelperFor; sqlCharColumn satisfies ColumnHelperForStrict; -export class SqlVarcharCodec extends CodecImpl< +export class SqlVarcharCodec extends SqlCodecImpl< typeof SQL_VARCHAR_CODEC_ID, readonly ['equality', 'order', 'textual'], string, @@ -232,6 +251,9 @@ export class SqlVarcharCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return appendPgCast(`'${escapeStandardSqlLiteral(value)}'`, this.descriptor); + } } export class SqlVarcharDescriptor extends CodecDescriptorImpl { @@ -255,7 +277,7 @@ export const sqlVarcharColumn = (params: LengthParams = {}) => sqlVarcharColumn satisfies ColumnHelperFor; sqlVarcharColumn satisfies ColumnHelperForStrict; -export class SqlTimestampCodec extends CodecImpl< +export class SqlTimestampCodec extends SqlCodecImpl< typeof SQL_TIMESTAMP_CODEC_ID, readonly ['equality', 'order'], Date, @@ -273,6 +295,9 @@ export class SqlTimestampCodec extends CodecImpl< decodeJson(json: JsonValue): Date { return sqlTimestampDecodeJson(json); } + renderSqlLiteral(value: Date): string { + return appendPgCast(`'${value.toISOString()}'`, this.descriptor); + } } export class SqlTimestampDescriptor extends CodecDescriptorImpl { diff --git a/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts b/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts index 629fd4b1b1..cc3a57cc44 100644 --- a/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts +++ b/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts @@ -2,6 +2,7 @@ export * from '../ast/adapter-types'; export * from '../ast/codec-types'; export * from '../ast/driver-types'; export * from '../ast/sql-codec-helpers'; +export * from '../ast/sql-codec-impl'; export * from '../ast/sql-codecs'; export * from '../ast/types'; export * from '../ast/util'; diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-impl.types.test-d.ts b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-impl.types.test-d.ts new file mode 100644 index 0000000000..cada9293fb --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-impl.types.test-d.ts @@ -0,0 +1,56 @@ +/** + * Negative type tests for the SQL codec construction factory. + * + * `SqlCodecImpl` declares `renderSqlLiteral` as an abstract method; subclasses that omit it are themselves abstract, and instantiating an abstract class with `new` is a compile-time error. This pins the SQL codec construction surface as the structural enforcement point for the codec-owned default-rendering contract. + */ + +import type { JsonValue } from '@prisma-next/contract/types'; +import type { AnyCodecDescriptor } from '@prisma-next/framework-components/codec'; +import { test } from 'vitest'; +import { SqlCodecImpl } from '../../src/ast/sql-codec-impl'; + +declare const fakeDescriptor: AnyCodecDescriptor; + +class CompleteSqlCodec extends SqlCodecImpl<'demo/complete@1', readonly [], string, string> { + override async encode(value: string): Promise { + return value; + } + override async decode(wire: string): Promise { + return wire; + } + override encodeJson(value: string): JsonValue { + return value; + } + override decodeJson(json: JsonValue): string { + return json as string; + } + override renderSqlLiteral(value: string): string { + return `'${value}'`; + } +} + +// @ts-expect-error non-abstract subclass that omits the abstract `renderSqlLiteral` is a compile error (TS2515). +class IncompleteSqlCodec extends SqlCodecImpl<'demo/incomplete@1', readonly [], string, string> { + override async encode(value: string): Promise { + return value; + } + override async decode(wire: string): Promise { + return wire; + } + override encodeJson(value: string): JsonValue { + return value; + } + override decodeJson(json: JsonValue): string { + return json as string; + } +} + +test('SqlCodecImpl admits construction when renderSqlLiteral is implemented', () => { + // Positive case — every abstract member is implemented, so `new` is allowed. + new CompleteSqlCodec(fakeDescriptor); +}); + +test('SqlCodecImpl rejects construction when renderSqlLiteral is omitted', () => { + // Reference the rejected class so it isn't pruned; the compile-time assertion lives on its declaration. + void IncompleteSqlCodec; +}); diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.render-sql-literal.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.render-sql-literal.test.ts new file mode 100644 index 0000000000..44f8a0b1db --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.render-sql-literal.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + sqlCharDescriptor, + sqlFloatDescriptor, + sqlIntDescriptor, + sqlTextDescriptor, + sqlTimestampDescriptor, + sqlVarcharDescriptor, +} from '../../src/ast/sql-codecs'; + +const instanceCtx = { name: '' }; + +describe('renderSqlLiteral on SQL base codecs', () => { + describe('sql/text@1', () => { + const codec = sqlTextDescriptor.factory()(instanceCtx); + + it('renders ASCII strings as quoted literals', () => { + expect(codec.renderSqlLiteral('hello')).toBe("'hello'"); + }); + + it('doubles embedded single quotes', () => { + expect(codec.renderSqlLiteral("O'Brien")).toBe("'O''Brien'"); + }); + + it('preserves backslashes as literal characters', () => { + expect(codec.renderSqlLiteral('a\\b')).toBe("'a\\b'"); + }); + + it('rejects NULL bytes', () => { + expect(() => codec.renderSqlLiteral('a\0b')).toThrow(); + }); + + it('passes unicode characters through verbatim', () => { + expect(codec.renderSqlLiteral('naïve résumé 日本語')).toBe("'naïve résumé 日本語'"); + }); + }); + + describe('sql/char@1', () => { + const codec = sqlCharDescriptor.factory({})(instanceCtx); + + it('renders fixed-length strings as quoted literals', () => { + expect(codec.renderSqlLiteral('abc')).toBe("'abc'"); + }); + + it('escapes embedded single quotes', () => { + expect(codec.renderSqlLiteral("a'b")).toBe("'a''b'"); + }); + }); + + describe('sql/varchar@1', () => { + const codec = sqlVarcharDescriptor.factory({})(instanceCtx); + + it('renders variable-length strings as quoted literals', () => { + expect(codec.renderSqlLiteral('hello')).toBe("'hello'"); + }); + + it('escapes embedded single quotes', () => { + expect(codec.renderSqlLiteral("a'b")).toBe("'a''b'"); + }); + }); + + describe('sql/int@1', () => { + const codec = sqlIntDescriptor.factory()(instanceCtx); + + it('renders integers as numeric literals', () => { + expect(codec.renderSqlLiteral(42)).toBe('42'); + }); + + it('renders zero', () => { + expect(codec.renderSqlLiteral(0)).toBe('0'); + }); + + it('renders negative integers', () => { + expect(codec.renderSqlLiteral(-7)).toBe('-7'); + }); + }); + + describe('sql/float@1', () => { + const codec = sqlFloatDescriptor.factory()(instanceCtx); + + it('renders floats as numeric literals', () => { + expect(codec.renderSqlLiteral(3.14)).toBe('3.14'); + }); + + it('renders integral floats', () => { + expect(codec.renderSqlLiteral(1)).toBe('1'); + }); + }); + + describe('sql/timestamp@1', () => { + const codec = sqlTimestampDescriptor.factory({})(instanceCtx); + + it('renders Date values as ISO-8601 string literals', () => { + const d = new Date('2026-04-30T12:34:56.789Z'); + expect(codec.renderSqlLiteral(d)).toBe("'2026-04-30T12:34:56.789Z'"); + }); + + it('renders epoch as ISO literal', () => { + expect(codec.renderSqlLiteral(new Date(0))).toBe("'1970-01-01T00:00:00.000Z'"); + }); + }); +}); diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/test-codec.ts b/packages/2-sql/4-lanes/relational-core/test/ast/test-codec.ts index 7ac22927e8..c634e5bc40 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/test-codec.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/test-codec.ts @@ -26,6 +26,7 @@ export function defineTestCodec< targetTypes?: readonly string[]; encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + renderSqlLiteral?: (value: TInput) => string; traits?: TTraits; } & JsonRoundTripConfig, ): Codec { @@ -36,6 +37,13 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const renderSqlLiteral = + config.renderSqlLiteral ?? + ((_value: TInput): string => { + throw new Error( + `defineTestCodec(${config.typeId}): renderSqlLiteral is not configured. Tests that exercise SQL literal rendering must supply a renderSqlLiteral implementation.`, + ); + }); return { id: config.typeId, encode: (value, ctx) => { @@ -54,5 +62,6 @@ export function defineTestCodec< }, encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + renderSqlLiteral, } as Codec; } diff --git a/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts b/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts index 8cf745a622..bb3604dd3a 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Article: { readonly id: Char<36>; readonly title: CodecTypes['pg/text@1']['output'] }; diff --git a/packages/2-sql/5-runtime/src/codecs/ast-codec-resolver.ts b/packages/2-sql/5-runtime/src/codecs/ast-codec-resolver.ts index acd8d72761..1fd79cdb00 100644 --- a/packages/2-sql/5-runtime/src/codecs/ast-codec-resolver.ts +++ b/packages/2-sql/5-runtime/src/codecs/ast-codec-resolver.ts @@ -58,9 +58,11 @@ export function createAstCodecResolver( : ref, ); const ctx = instanceContextFor(ref); - // The descriptor's `factory` is typed against its own `P`; the registry erases `P` to `unknown`, so callers narrow per codec id at the dispatch boundary. The descriptor's `paramsSchema` validates the input above before we forward it, so this narrow is safe by construction. + // The descriptor's `factory` is typed against its own `P`; the registry erases `P` to `unknown`, so callers narrow per codec id at the dispatch boundary. The descriptor's `paramsSchema` validates the input above before we forward it, so the param narrow is safe by construction. The cast routes through `unknown` because the framework `Codec` produced by an erased descriptor doesn't structurally carry the SQL family's required `renderSqlLiteral` method; every concrete SQL codec materialised here extends `SqlCodecImpl`, which enforces the method at the construction site. const codec = ( - descriptor.factory as (params: unknown) => (ctx: SqlCodecInstanceContext) => Codec + descriptor.factory as unknown as ( + params: unknown, + ) => (ctx: SqlCodecInstanceContext) => Codec )(validated)(ctx); cache.set(key, codec); diff --git a/packages/2-sql/5-runtime/test/codec-integrity.test.ts b/packages/2-sql/5-runtime/test/codec-integrity.test.ts index 5ea057888a..5071e315f3 100644 --- a/packages/2-sql/5-runtime/test/codec-integrity.test.ts +++ b/packages/2-sql/5-runtime/test/codec-integrity.test.ts @@ -20,6 +20,9 @@ describe('createExecutionContext — column codec integrity', () => { decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + renderSqlLiteral: () => { + throw new Error('renderSqlLiteral not configured on integrity-test stub codec'); + }, }; } diff --git a/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts b/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts index 0e01d818c9..d54ed883cf 100644 --- a/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts +++ b/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts @@ -33,6 +33,9 @@ describe('buildContractCodecRegistry — per-column codec instance context', () decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + renderSqlLiteral: () => { + throw new Error('renderSqlLiteral not configured on context-capturing stub codec'); + }, }; instances.push({ ctx, codec }); return codec; @@ -142,6 +145,9 @@ describe('buildContractCodecRegistry — forCodecRef content-keyed cache', () => decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + renderSqlLiteral: () => { + throw new Error('renderSqlLiteral not configured on pgvector-stub test codec'); + }, }; return Object.assign({}, codec, { meta: { length: params.length, ctxName: ctx.name }, @@ -388,6 +394,9 @@ describe('buildContractCodecRegistry — forColumn delegates to forCodecRef', () decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + renderSqlLiteral: () => { + throw new Error('renderSqlLiteral not configured on shared stub codec'); + }, }; instances.push({ ctx, codec }); return codec; diff --git a/packages/2-sql/5-runtime/test/test-codec.ts b/packages/2-sql/5-runtime/test/test-codec.ts index 4185017321..e6569f7062 100644 --- a/packages/2-sql/5-runtime/test/test-codec.ts +++ b/packages/2-sql/5-runtime/test/test-codec.ts @@ -28,6 +28,7 @@ export function defineTestCodec< targetTypes?: readonly string[]; encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + renderSqlLiteral?: (value: TInput) => string; traits?: TTraits; } & JsonRoundTripConfig, ): Codec { @@ -38,6 +39,13 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const renderSqlLiteral = + config.renderSqlLiteral ?? + ((_value: TInput): string => { + throw new Error( + `defineTestCodec(${config.typeId}): renderSqlLiteral is not configured. Tests that exercise SQL literal rendering must supply a renderSqlLiteral implementation.`, + ); + }); return { id: config.typeId, encode: (value, ctx) => { @@ -56,5 +64,6 @@ export function defineTestCodec< }, encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + renderSqlLiteral, } as Codec; } diff --git a/packages/2-sql/9-family/src/core/control-adapter.ts b/packages/2-sql/9-family/src/core/control-adapter.ts index 0a9d818751..0d4055f2e5 100644 --- a/packages/2-sql/9-family/src/core/control-adapter.ts +++ b/packages/2-sql/9-family/src/core/control-adapter.ts @@ -11,7 +11,11 @@ import type { LowererContext, } from '@prisma-next/sql-relational-core/ast'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; -import type { DefaultNormalizer, NativeTypeNormalizer } from './schema-verify/verify-sql-schema'; +import type { + DefaultNormalizer, + NativeTypeNormalizer, + SchemaDefaultValueParser, +} from './schema-verify/verify-sql-schema'; /** * SQL control adapter interface for control-plane operations. @@ -77,6 +81,15 @@ export interface SqlControlAdapter */ readonly normalizeDefault?: DefaultNormalizer; + /** + * Optional target-specific parser that extracts the codec-comparable + * {@link JsonValue} out of a raw schema-side default expression. The + * verifier uses it to round-trip the introspected literal through the + * column's codec (`decodeJson` → `renderSqlLiteral`) and compare against + * the contract-side codec-rendered expression. + */ + readonly parseSchemaDefaultValue?: SchemaDefaultValueParser; + /** * Optional target-specific normalizer for schema native type names. * When provided, schema native types (from introspection) are normalized diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index 7f9a888c91..71782719dc 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -19,6 +19,7 @@ import type { } from '@prisma-next/framework-components/control'; import { APP_SPACE_ID, + extractCodecLookup, SchemaTreeNode, VERIFY_CODE_HASH_MISMATCH, VERIFY_CODE_MARKER_MISSING, @@ -533,7 +534,9 @@ export function createSqlFamilyInstance( strict: options.strict, typeMetadataRegistry, frameworkComponents: options.frameworkComponents, + codecLookup: extractCodecLookup(options.frameworkComponents), ...ifDefined('normalizeDefault', controlAdapter.normalizeDefault), + ...ifDefined('parseSchemaDefaultValue', controlAdapter.parseSchemaDefaultValue), ...ifDefined('normalizeNativeType', controlAdapter.normalizeNativeType), ...ifDefined('resolveExistingEnumValues', controlAdapter.resolveExistingEnumValues), }); diff --git a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts index b90b9218f3..a898e88249 100644 --- a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts +++ b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts @@ -1,5 +1,6 @@ -import type { ColumnDefault, Contract } from '@prisma-next/contract/types'; +import type { Contract } from '@prisma-next/contract/types'; import type { MigrationPlannerConflict } from '@prisma-next/framework-components/control'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { type ForeignKey, type Index, diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts index 2dddf3ffac..b52b21a370 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts @@ -1,7 +1,6 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; const DEFAULT_FUNCTION_ATTRIBUTES: Readonly> = { - 'autoincrement()': '@default(autoincrement())', 'now()': '@default(now())', }; @@ -17,9 +16,9 @@ export function mapDefault( options?: DefaultMappingOptions, ): DefaultMappingResult { switch (columnDefault.kind) { - case 'literal': - return { attribute: `@default(${formatLiteralValue(columnDefault.value)})` }; - case 'function': { + case 'autoincrement': + return { attribute: '@default(autoincrement())' }; + case 'expression': { const attribute = options?.functionAttributes?.[columnDefault.expression] ?? DEFAULT_FUNCTION_ATTRIBUTES[columnDefault.expression] ?? @@ -30,27 +29,3 @@ export function mapDefault( } } } - -function formatLiteralValue(value: unknown): string { - if (value === null) { - return 'null'; - } - - switch (typeof value) { - case 'boolean': - case 'number': - return String(value); - case 'string': - return quoteString(value); - default: - return quoteString(JSON.stringify(value)); - } -} - -function quoteString(str: string): string { - return `"${escapeString(str)}"`; -} - -function escapeString(str: string): string { - return JSON.stringify(str).slice(1, -1); -} diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts index 44c3d56b10..726aa1c152 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import type { DefaultMappingOptions } from './default-mapping'; /** diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/raw-default-parser.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/raw-default-parser.ts index 57beb5f696..74ade6c721 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/raw-default-parser.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/raw-default-parser.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; const NEXTVAL_PATTERN = /^nextval\s*\(/i; const NOW_FUNCTION_PATTERN = /^(now\s*\(\s*\)|CURRENT_TIMESTAMP)$/i; @@ -43,32 +43,32 @@ export function parseRawDefault( const normalizedType = nativeType?.toLowerCase(); if (NEXTVAL_PATTERN.test(trimmed)) { - return { kind: 'function', expression: 'autoincrement()' }; + return { kind: 'autoincrement' }; } const canonicalTimestamp = canonicalizeTimestampDefault(trimmed); if (canonicalTimestamp) { - return { kind: 'function', expression: canonicalTimestamp }; + return { kind: 'expression', expression: canonicalTimestamp }; } if (UUID_PATTERN.test(trimmed) || UUID_OSSP_PATTERN.test(trimmed)) { - return { kind: 'function', expression: 'gen_random_uuid()' }; + return { kind: 'expression', expression: 'gen_random_uuid()' }; } if (NULL_PATTERN.test(trimmed)) { - return { kind: 'literal', value: null }; + return { kind: 'expression', expression: 'NULL' }; } if (TRUE_PATTERN.test(trimmed)) { - return { kind: 'literal', value: true }; + return { kind: 'expression', expression: 'true' }; } if (FALSE_PATTERN.test(trimmed)) { - return { kind: 'literal', value: false }; + return { kind: 'expression', expression: 'false' }; } if (NUMERIC_PATTERN.test(trimmed)) { - return { kind: 'literal', value: Number(trimmed) }; + return { kind: 'expression', expression: trimmed }; } const stringMatch = trimmed.match(STRING_LITERAL_PATTERN); @@ -76,16 +76,17 @@ export function parseRawDefault( const unescaped = stringMatch[1].replace(/''/g, "'"); if (normalizedType === 'json' || normalizedType === 'jsonb') { if (JSON_CAST_SUFFIX.test(trimmed)) { - return { kind: 'function', expression: trimmed }; + return { kind: 'expression', expression: trimmed }; } try { - return { kind: 'literal', value: JSON.parse(unescaped) }; + const parsed = JSON.parse(unescaped); + return { kind: 'expression', expression: JSON.stringify(parsed) }; } catch { // Fall through to the string form for malformed/non-JSON values. } } - return { kind: 'literal', value: unescaped }; + return { kind: 'expression', expression: unescaped }; } - return { kind: 'function', expression: trimmed }; + return { kind: 'expression', expression: trimmed }; } diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts index 3fdfd3df2f..64b3c89825 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts @@ -1,4 +1,3 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; import type { PslAttribute, PslAttributeArgument, @@ -13,6 +12,7 @@ import type { PslTypesBlock, } from '@prisma-next/framework-components/psl-ast'; import { UNSPECIFIED_PSL_NAMESPACE_ID } from '@prisma-next/framework-components/psl-ast'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import type { SqlColumnIR, SqlSchemaIR, SqlTableIR } from '@prisma-next/sql-schema-ir/types'; import type { DefaultMappingOptions } from './default-mapping'; import { mapDefault } from './default-mapping'; diff --git a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts index 3ac2c37431..b87816b9d8 100644 --- a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts +++ b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts @@ -6,7 +6,8 @@ * by migration planners and other tools that need to compare schema states. */ -import type { ColumnDefault, Contract } from '@prisma-next/contract/types'; +import type { Contract, JsonValue } from '@prisma-next/contract/types'; +import type { CodecLookup } from '@prisma-next/framework-components/codec'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { OperationContext, @@ -14,6 +15,7 @@ import type { SchemaVerificationNode, VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { isPostgresEnumStorageEntry, isStorageTypeInstance, @@ -24,7 +26,6 @@ import { type StorageTypeInstance, } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; -import { canonicalStringify } from '@prisma-next/utils/canonical-stringify'; import { ifDefined } from '@prisma-next/utils/defined'; import { extractCodecControlHooks } from '../assembly'; import type { CodecControlHooks } from '../migrations/types'; @@ -53,6 +54,30 @@ export type DefaultNormalizer = ( */ export type NativeTypeNormalizer = (nativeType: string) => string; +/** + * Function type for parsing a raw schema-side SQL default expression into a + * codec-comparable {@link JsonValue}. + * + * Returns `undefined` when the raw expression is not a simple literal + * (e.g. function-form like `now()`, autoincrement `nextval(...)`); the + * verifier then falls back to the legacy normalizer-based string compare + * path for those cases. + * + * For literal forms, the parser strips the dialect's casts (`::type`), + * unquotes string literals, parses bare numerics / booleans, and normalises + * dialect-specific value shapes (e.g. Postgres's space-separated + * `'2024-01-15 10:30:00+00'` timestamps to ISO-8601 UTC) so the codec's + * strict `decodeJson` accepts the result. + * + * The verifier dispatches the returned value through `codec.decodeJson` → + * `codec.renderSqlLiteral` to produce a contract-canonical expression that + * compares cleanly against `contract.default.expression`. + */ +export type SchemaDefaultValueParser = ( + rawDefault: string, + nativeType: string, +) => JsonValue | undefined; + /** * Options for the pure schema verification function. */ @@ -99,6 +124,28 @@ export interface VerifySqlSchemaOptions { schema: SqlSchemaIR, enumType: PostgresEnumStorageEntry, ) => readonly string[] | null; + /** + * Codec-id-keyed lookup used by the codec-aware default comparison path. + * + * Threaded alongside {@link SchemaDefaultValueParser}: when both are + * supplied and the column carries a known `codecId`, the verifier + * round-trips the introspected literal through `codec.decodeJson` → + * `codec.renderSqlLiteral` and compares the canonical contract-side form + * against `contract.default.expression`. When either input is missing — + * or the column's codec is not in the lookup — the verifier falls back to + * the legacy {@link DefaultNormalizer} string-compare path. + * + * Production call sites (Postgres / SQLite planners and runners) build + * this via {@link extractCodecLookup} over the same `frameworkComponents` + * they already pass to the verifier. + */ + readonly codecLookup?: CodecLookup; + /** + * Per-target parser that extracts the codec-comparable {@link JsonValue} + * out of a raw schema-side default expression. See + * {@link SchemaDefaultValueParser} for the contract. + */ + readonly parseSchemaDefaultValue?: SchemaDefaultValueParser; } /** @@ -121,6 +168,8 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase normalizeDefault, normalizeNativeType, resolveExistingEnumValues, + codecLookup, + parseSchemaDefaultValue, } = options; const startTime = Date.now(); @@ -155,6 +204,8 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase storageTypes, ...ifDefined('normalizeDefault', normalizeDefault), ...ifDefined('normalizeNativeType', normalizeNativeType), + ...ifDefined('codecLookup', codecLookup), + ...ifDefined('parseSchemaDefaultValue', parseSchemaDefaultValue), }); validateFrameworkComponentsForExtensions(contract, options.frameworkComponents); @@ -337,6 +388,8 @@ function verifySchemaTables(options: { storageTypes: Readonly>; normalizeDefault?: DefaultNormalizer; normalizeNativeType?: NativeTypeNormalizer; + codecLookup?: CodecLookup; + parseSchemaDefaultValue?: SchemaDefaultValueParser; }): { issues: SchemaIssue[]; rootChildren: SchemaVerificationNode[] } { const { contract, @@ -347,6 +400,8 @@ function verifySchemaTables(options: { storageTypes, normalizeDefault, normalizeNativeType, + codecLookup, + parseSchemaDefaultValue, } = options; const issues: SchemaIssue[] = []; const rootChildren: SchemaVerificationNode[] = []; @@ -402,6 +457,8 @@ function verifySchemaTables(options: { storageTypes, ...ifDefined('normalizeDefault', normalizeDefault), ...ifDefined('normalizeNativeType', normalizeNativeType), + ...ifDefined('codecLookup', codecLookup), + ...ifDefined('parseSchemaDefaultValue', parseSchemaDefaultValue), }); rootChildren.push(buildTableNode(tableName, tablePath, tableChildren)); } @@ -453,6 +510,8 @@ function verifyTableChildren(options: { storageTypes: Readonly>; normalizeDefault?: DefaultNormalizer; normalizeNativeType?: NativeTypeNormalizer; + codecLookup?: CodecLookup; + parseSchemaDefaultValue?: SchemaDefaultValueParser; }): SchemaVerificationNode[] { const { contractTable, @@ -467,6 +526,8 @@ function verifyTableChildren(options: { storageTypes, normalizeDefault, normalizeNativeType, + codecLookup, + parseSchemaDefaultValue, } = options; const tableChildren: SchemaVerificationNode[] = []; const columnNodes = collectContractColumnNodes({ @@ -482,6 +543,8 @@ function verifyTableChildren(options: { storageTypes, ...ifDefined('normalizeDefault', normalizeDefault), ...ifDefined('normalizeNativeType', normalizeNativeType), + ...ifDefined('codecLookup', codecLookup), + ...ifDefined('parseSchemaDefaultValue', parseSchemaDefaultValue), }); if (columnNodes.length > 0) { tableChildren.push(buildColumnsNode(tablePath, columnNodes)); @@ -620,6 +683,8 @@ function collectContractColumnNodes(options: { storageTypes: Readonly>; normalizeDefault?: DefaultNormalizer; normalizeNativeType?: NativeTypeNormalizer; + codecLookup?: CodecLookup; + parseSchemaDefaultValue?: SchemaDefaultValueParser; }): SchemaVerificationNode[] { const { contractTable, @@ -634,6 +699,8 @@ function collectContractColumnNodes(options: { storageTypes, normalizeDefault, normalizeNativeType, + codecLookup, + parseSchemaDefaultValue, } = options; const columnNodes: SchemaVerificationNode[] = []; @@ -678,6 +745,8 @@ function collectContractColumnNodes(options: { storageTypes, ...ifDefined('normalizeDefault', normalizeDefault), ...ifDefined('normalizeNativeType', normalizeNativeType), + ...ifDefined('codecLookup', codecLookup), + ...ifDefined('parseSchemaDefaultValue', parseSchemaDefaultValue), }), ); } @@ -734,6 +803,8 @@ function verifyColumn(options: { storageTypes: Readonly>; normalizeDefault?: DefaultNormalizer; normalizeNativeType?: NativeTypeNormalizer; + codecLookup?: CodecLookup; + parseSchemaDefaultValue?: SchemaDefaultValueParser; }): SchemaVerificationNode { const { tableName, @@ -748,6 +819,8 @@ function verifyColumn(options: { storageTypes, normalizeDefault, normalizeNativeType, + codecLookup, + parseSchemaDefaultValue, } = options; const columnChildren: SchemaVerificationNode[] = []; let columnStatus: VerificationStatus = 'pass'; @@ -872,6 +945,10 @@ function verifyColumn(options: { schemaColumn.default, normalizeDefault, schemaNativeType, + resolvedContractColumn.codecId + ? codecLookup?.get(resolvedContractColumn.codecId) + : undefined, + parseSchemaDefaultValue, ) ) { const expectedDescription = describeColumnDefault(contractColumn.default); @@ -1154,41 +1231,201 @@ function resolveContractColumnTypeMetadata( */ function describeColumnDefault(columnDefault: ColumnDefault): string { switch (columnDefault.kind) { - case 'literal': - return `literal(${formatLiteralValue(columnDefault.value)})`; - case 'function': + case 'autoincrement': + return 'autoincrement'; + case 'expression': return columnDefault.expression; } } +/** + * Structural narrowing for SQL-family codecs that carry `renderSqlLiteral`. + * Mirrors the same shape used by the PSL parser (see + * `psl-column-resolution.ts` § `CodecWithRenderSqlLiteral`): the + * framework-level {@link CodecLookup} returns the narrower framework + * `Codec`, so the call site narrows structurally rather than depending on + * the SQL-family `Codec` interface from `sql-relational-core/ast`. + */ +interface CodecWithRenderSqlLiteral { + readonly id: string; + decodeJson(json: JsonValue): unknown; + renderSqlLiteral(value: unknown): string; +} + +function hasRenderSqlLiteral( + codec: { decodeJson(json: JsonValue): unknown } | undefined, +): codec is CodecWithRenderSqlLiteral { + return ( + codec !== undefined && + 'renderSqlLiteral' in codec && + typeof (codec as { renderSqlLiteral?: unknown }).renderSqlLiteral === 'function' + ); +} + +/** + * Case-insensitive, whitespace-tolerant SQL expression comparison. + * + * Two codec round-tripped forms may differ only in casing or whitespace + * (e.g. `TRUE` vs `true`, `'foo'::text` vs `'foo' :: text`) — the collapse + * pinned here is conservative enough that semantically equal forms compare + * equal while syntactically distinct forms (`'foo'` vs `'bar'`) do not. + */ +function expressionsEqual(a: string, b: string): boolean { + const normalise = (expr: string) => expr.toLowerCase().replace(/\s+/g, ''); + return normalise(a) === normalise(b); +} + +/** + * Structural equality for JSON-shaped typed values (objects + arrays + + * primitives). Used by the codec round-trip path so JSONB-style + * key-order-independent comparison succeeds when both sides decode to + * the same semantic value but the codec's `renderSqlLiteral` + * (e.g. `JSON.stringify`) is order-sensitive. + * + * Other codec output types fall back to JS `===` (handled in the + * primitive arm). `Date` instances compare by `.getTime()` so two Date + * values built from the same instant compare equal even when constructed + * via different string forms. + */ +function jsonValuesStructurallyEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== typeof b) return false; + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime(); + } + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (!jsonValuesStructurallyEqual(a[i], b[i])) return false; + } + return true; + } + if (typeof a === 'object' && typeof b === 'object') { + const aRecord = a as Record; + const bRecord = b as Record; + const aKeys = Object.keys(aRecord); + const bKeys = Object.keys(bRecord); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (!Object.hasOwn(bRecord, key)) return false; + if (!jsonValuesStructurallyEqual(aRecord[key], bRecord[key])) return false; + } + return true; + } + return false; +} + /** * Compares a contract ColumnDefault against a schema raw default string for semantic equality. * - * When a normalizer is provided, the raw schema default is first normalized to a ColumnDefault - * before comparison. Without a normalizer, falls back to direct string comparison against - * the contract expression. + * Three layers of comparison, in order: + * + * 1. **Codec round-trip.** When the column's codec is available in the + * lookup AND the per-target {@link SchemaDefaultValueParser} extracts a + * {@link JsonValue} out of the raw schema default, dispatch through + * `codec.decodeJson(value)` → `codec.renderSqlLiteral(typed)` to produce + * a contract-canonical expression. Compare that canonical form against + * `contract.default.expression`. The codec is the canonical comparison + * oracle — both sides go through `renderSqlLiteral` (the contract side + * at emit time, the schema side here at verify time). + * + * 2. **Legacy normalizer.** When the codec round-trip is unavailable (no + * codec, no parser, parser returned undefined, decodeJson threw), + * fall back to the per-target {@link DefaultNormalizer} that converts + * the raw schema default into a normalised {@link ColumnDefault} and + * compares against the contract default with case-insensitive + * whitespace-tolerant expression matching. * - * @param contractDefault - The expected default from the contract (normalized ColumnDefault) - * @param schemaDefault - The raw default expression from the database (string) - * @param normalizer - Optional target-specific normalizer to convert raw defaults - * @param nativeType - The column's native type, passed to normalizer for context + * 3. **Direct string compare.** When no normalizer is provided, compare + * the contract expression directly against the raw schema string (with + * a lenient bare-vs-quoted check for legacy fixtures). + * + * `kind: 'autoincrement'` always short-circuits to kind-equality on + * whichever side a normalised value is available (codec round-trip is + * skipped — codec is NOT invoked for autoincrement, matching the producer + * convention in `build-contract.ts` and `psl-column-resolution.ts`). + * + * @param contractDefault - The expected default from the contract. + * @param schemaDefault - The raw default expression from the database. + * @param normalizer - Optional target-specific normalizer to convert raw defaults. + * @param nativeType - The column's native type, passed to normalizer / parser for context. + * @param codec - Optional codec for the column (resolved via `codecLookup.get(codecId)`). + * @param valueParser - Optional per-target parser that extracts a JsonValue from the raw default. */ function columnDefaultsEqual( contractDefault: ColumnDefault, schemaDefault: string, normalizer?: DefaultNormalizer, nativeType?: string, + codec?: { decodeJson(json: JsonValue): unknown } | undefined, + valueParser?: SchemaDefaultValueParser, ): boolean { - // If no normalizer provided, fall back to direct string comparison - if (!normalizer) { - if (contractDefault.kind === 'function') { - return contractDefault.expression === schemaDefault; + // 1. Codec round-trip. + // + // Skipped for autoincrement contract defaults — codec is never invoked on + // the autoincrement arm (producer side: `build-contract.ts`, + // `psl-column-resolution.ts`). The autoincrement match flows through the + // normalizer path (which detects `nextval(...)` and produces `{ kind: + // 'autoincrement' }`). + if (contractDefault.kind === 'expression' && hasRenderSqlLiteral(codec) && valueParser) { + const schemaParsedValue = valueParser(schemaDefault, nativeType ?? ''); + if (schemaParsedValue !== undefined) { + try { + const schemaTyped = codec.decodeJson(schemaParsedValue); + const schemaCanonical = codec.renderSqlLiteral(schemaTyped); + if (expressionsEqual(contractDefault.expression, schemaCanonical)) { + return true; + } + // Round-trip the contract-side expression through the same parser + // + codec so cases where the contract carries a literal whose + // codec re-render does NOT reproduce the contract expression + // verbatim (e.g. JSONB key-order: `'{"a":1,"b":2}'::jsonb` vs the + // codec's `JSON.stringify` output) still compare equal when both + // sides decode to the same typed value. + const contractParsedValue = valueParser(contractDefault.expression, nativeType ?? ''); + if (contractParsedValue !== undefined) { + try { + const contractTyped = codec.decodeJson(contractParsedValue); + const contractCanonical = codec.renderSqlLiteral(contractTyped); + if (expressionsEqual(contractCanonical, schemaCanonical)) { + return true; + } + // Structural comparison on the typed values handles cases + // where the codec's `renderSqlLiteral` is order-sensitive on a + // structure that should be order-independent (the canonical + // example is JSONB: `JSON.stringify({a:1,b:2})` ≠ + // `JSON.stringify({b:2,a:1})` even though the JSONB values are + // semantically equal). The structural compare is JSON-value + // shaped: the typed value reduces to a {@link JsonValue}-like + // tree when both sides went through `decodeJson` whose return + // is `JsonValue` or a JS-native value `JSON.stringify`-stable. + if (jsonValuesStructurallyEqual(contractTyped, schemaTyped)) { + return true; + } + } catch { + // contract side failed to round-trip; fall through. + } + } + // Both round-trips done; canonicals don't match — fall through to + // the normalizer path so the legacy compare can still rescue + // cases like `'draft'::text` vs `draft` that the codec's + // per-dialect cast wrapping would otherwise reject. + } catch { + // decodeJson threw — likely because the parsed value's shape + // doesn't satisfy the codec's strict input contract. Fall through + // to the normalizer path. + } } - const normalizedValue = normalizeLiteralValue(contractDefault.value, nativeType); - if (typeof normalizedValue === 'string') { - return normalizedValue === schemaDefault || `'${normalizedValue}'` === schemaDefault; + } + + // 2/3. Legacy normalizer + direct string compare. + if (!normalizer) { + if (contractDefault.kind === 'autoincrement') { + return false; } - return String(normalizedValue) === schemaDefault; + const expr = contractDefault.expression; + return expr === schemaDefault || `'${expr}'` === schemaDefault; } // Normalize the raw schema default using target-specific logic @@ -1202,66 +1439,11 @@ function columnDefaultsEqual( if (contractDefault.kind !== normalizedSchema.kind) { return false; } - if (contractDefault.kind === 'literal' && normalizedSchema.kind === 'literal') { - const contractValue = normalizeLiteralValue(contractDefault.value, nativeType); - const schemaValue = normalizeLiteralValue(normalizedSchema.value, nativeType); - return literalValuesEqual(contractValue, schemaValue); + if (contractDefault.kind === 'autoincrement') { + return true; } - if (contractDefault.kind === 'function' && normalizedSchema.kind === 'function') { - // Normalize function expressions for comparison (case-insensitive, whitespace-tolerant) - const normalizeExpr = (expr: string) => expr.toLowerCase().replace(/\s+/g, ''); - return normalizeExpr(contractDefault.expression) === normalizeExpr(normalizedSchema.expression); + if (contractDefault.kind === 'expression' && normalizedSchema.kind === 'expression') { + return expressionsEqual(contractDefault.expression, normalizedSchema.expression); } return false; } - -function isTemporalNativeType(nativeType?: string): boolean { - if (!nativeType) return false; - const normalized = nativeType.toLowerCase(); - return normalized.includes('timestamp') || normalized === 'date'; -} - -function normalizeLiteralValue(value: unknown, nativeType?: string): unknown { - if (value instanceof Date) { - return value.toISOString(); - } - if (typeof value === 'string' && isTemporalNativeType(nativeType)) { - const parsed = new Date(value); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - return value; -} - -function literalValuesEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) { - return canonicalStringify(a) === canonicalStringify(b); - } - if (typeof a === 'object' && a !== null && typeof b === 'string') { - try { - return canonicalStringify(a) === canonicalStringify(JSON.parse(b)); - } catch { - return false; - } - } - if (typeof a === 'string' && typeof b === 'object' && b !== null) { - try { - return canonicalStringify(JSON.parse(a)) === canonicalStringify(b); - } catch { - return false; - } - } - return false; -} - -function formatLiteralValue(value: unknown): string { - if (value instanceof Date) { - return value.toISOString(); - } - if (typeof value === 'string') { - return value; - } - return JSON.stringify(value); -} diff --git a/packages/2-sql/9-family/src/core/timestamp-now-generator.ts b/packages/2-sql/9-family/src/core/timestamp-now-generator.ts index 661d1b506e..882976878c 100644 --- a/packages/2-sql/9-family/src/core/timestamp-now-generator.ts +++ b/packages/2-sql/9-family/src/core/timestamp-now-generator.ts @@ -56,7 +56,7 @@ export function temporalAuthoringPresets< output: { codecId, nativeType, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }, updatedAt: { diff --git a/packages/2-sql/9-family/src/exports/schema-verify.ts b/packages/2-sql/9-family/src/exports/schema-verify.ts index 04ecacd7a5..4b964ca399 100644 --- a/packages/2-sql/9-family/src/exports/schema-verify.ts +++ b/packages/2-sql/9-family/src/exports/schema-verify.ts @@ -12,7 +12,9 @@ export { isUniqueConstraintSatisfied, } from '../core/schema-verify/verify-helpers'; export type { + DefaultNormalizer, NativeTypeNormalizer, + SchemaDefaultValueParser, VerifySqlSchemaOptions, } from '../core/schema-verify/verify-sql-schema'; export { verifySqlSchema } from '../core/schema-verify/verify-sql-schema'; diff --git a/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts b/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts index f2dde40b31..17929e3f54 100644 --- a/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts +++ b/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts @@ -1,6 +1,7 @@ -import type { ColumnDefault, Contract, StorageHashBase } from '@prisma-next/contract/types'; +import type { Contract, StorageHashBase } from '@prisma-next/contract/types'; import { profileHash } from '@prisma-next/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { SqlStorage, type StorageColumn, type StorageTable } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; @@ -10,16 +11,9 @@ import { detectDestructiveChanges, } from '../src/core/migrations/contract-to-schema-ir'; -const testRenderer: DefaultRenderer = (def: ColumnDefault, column: StorageColumn) => { - if (def.kind === 'function') return def.expression; - const { value } = def; - if (typeof value === 'string') return `'${value.replaceAll("'", "''")}'`; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (value === null) return 'NULL'; - const json = JSON.stringify(value); - const isJsonColumn = column.nativeType === 'json' || column.nativeType === 'jsonb'; - if (isJsonColumn) return `'${json}'::${column.nativeType}`; - return `'${json}'`; +const testRenderer: DefaultRenderer = (def: ColumnDefault, _column: StorageColumn) => { + if (def.kind === 'autoincrement') return 'autoincrement()'; + return def.expression; }; function wrap(storage: SqlStorage): Contract { @@ -284,7 +278,7 @@ describe('contractToSchemaIR', () => { expect(result.tables['T']!.columns['id']!.nativeType).toBe('character'); }); - it('converts literal column defaults', () => { + it('converts expression column defaults', () => { const storage = new SqlStorage({ storageHash: 'sha256:test' as StorageHashBase, namespaces: { @@ -295,7 +289,7 @@ describe('contractToSchemaIR', () => { columns: { status: col({ nativeType: 'text', - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: "'active'" }, }), }, }), @@ -308,7 +302,7 @@ describe('contractToSchemaIR', () => { expect(result.tables['T']!.columns['status']!.default).toBe("'active'"); }); - it('escapes single quotes in string literal defaults', () => { + it('converts now() expression column defaults', () => { const storage = new SqlStorage({ storageHash: 'sha256:test' as StorageHashBase, namespaces: { @@ -317,33 +311,9 @@ describe('contractToSchemaIR', () => { tables: { T: table({ columns: { - author: col({ - nativeType: 'text', - default: { kind: 'literal', value: "O'Reilly" }, - }), - }, - }), - }, - }, - }, - }); - - const result = contractToSchemaIR(wrap(storage), { renderDefault: testRenderer }); - expect(result.tables['T']!.columns['author']!.default).toBe("'O''Reilly'"); - }); - - it('escapes repeated single quotes in string literal defaults', () => { - const storage = new SqlStorage({ - storageHash: 'sha256:test' as StorageHashBase, - namespaces: { - [UNBOUND_NAMESPACE_ID]: { - id: UNBOUND_NAMESPACE_ID, - tables: { - T: table({ - columns: { - textValue: col({ - nativeType: 'text', - default: { kind: 'literal', value: "a'b''c" }, + createdAt: col({ + nativeType: 'timestamptz', + default: { kind: 'expression', expression: 'now()' }, }), }, }), @@ -353,10 +323,10 @@ describe('contractToSchemaIR', () => { }); const result = contractToSchemaIR(wrap(storage), { renderDefault: testRenderer }); - expect(result.tables['T']!.columns['textValue']!.default).toBe("'a''b''''c'"); + expect(result.tables['T']!.columns['createdAt']!.default).toBe('now()'); }); - it('converts function column defaults', () => { + it('converts autoincrement column defaults', () => { const storage = new SqlStorage({ storageHash: 'sha256:test' as StorageHashBase, namespaces: { @@ -365,9 +335,9 @@ describe('contractToSchemaIR', () => { tables: { T: table({ columns: { - createdAt: col({ - nativeType: 'timestamptz', - default: { kind: 'function', expression: 'now()' }, + id: col({ + nativeType: 'int4', + default: { kind: 'autoincrement' }, }), }, }), @@ -377,7 +347,7 @@ describe('contractToSchemaIR', () => { }); const result = contractToSchemaIR(wrap(storage), { renderDefault: testRenderer }); - expect(result.tables['T']!.columns['createdAt']!.default).toBe('now()'); + expect(result.tables['T']!.columns['id']!.default).toBe('autoincrement()'); }); it('omits default field when column has no default', () => { diff --git a/packages/2-sql/9-family/test/field-event-planner.test.ts b/packages/2-sql/9-family/test/field-event-planner.test.ts index 977c35b862..92bbf6981e 100644 --- a/packages/2-sql/9-family/test/field-event-planner.test.ts +++ b/packages/2-sql/9-family/test/field-event-planner.test.ts @@ -231,12 +231,12 @@ describe('planFieldEventOperations', () => { it("fires 'altered' when the default value changes", () => { const fromContract = contract({ User: table({ - flag: col({ codecId: 'cs/string@1', default: { kind: 'literal', value: 'a' } }), + flag: col({ codecId: 'cs/string@1', default: { kind: 'expression', expression: 'a' } }), }), }); const newContract = contract({ User: table({ - flag: col({ codecId: 'cs/string@1', default: { kind: 'literal', value: 'b' } }), + flag: col({ codecId: 'cs/string@1', default: { kind: 'expression', expression: 'b' } }), }), }); diff --git a/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts index 82a45f69bb..e561027126 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts @@ -3,14 +3,14 @@ import { mapDefault } from '../../src/core/psl-contract-infer/default-mapping'; import { createPostgresDefaultMapping } from '../../src/core/psl-contract-infer/postgres-default-mapping'; describe('mapDefault', () => { - it('maps autoincrement()', () => { - expect(mapDefault({ kind: 'function', expression: 'autoincrement()' })).toEqual({ + it('maps autoincrement', () => { + expect(mapDefault({ kind: 'autoincrement' })).toEqual({ attribute: '@default(autoincrement())', }); }); it('maps now()', () => { - expect(mapDefault({ kind: 'function', expression: 'now()' })).toEqual({ + expect(mapDefault({ kind: 'expression', expression: 'now()' })).toEqual({ attribute: '@default(now())', }); }); @@ -18,7 +18,7 @@ describe('mapDefault', () => { it('maps gen_random_uuid() when Postgres mapping is injected', () => { expect( mapDefault( - { kind: 'function', expression: 'gen_random_uuid()' }, + { kind: 'expression', expression: 'gen_random_uuid()' }, createPostgresDefaultMapping(), ), ).toEqual({ @@ -28,75 +28,57 @@ describe('mapDefault', () => { it('maps unmapped Postgres defaults to dbgenerated when Postgres mapping is injected', () => { expect( - mapDefault({ kind: 'function', expression: "'{}'::jsonb" }, createPostgresDefaultMapping()), + mapDefault({ kind: 'expression', expression: "'{}'::jsonb" }, createPostgresDefaultMapping()), ).toEqual({ attribute: `@default(dbgenerated(${JSON.stringify("'{}'::jsonb")}))`, }); }); - it('maps boolean true', () => { - expect(mapDefault({ kind: 'literal', value: true })).toEqual({ - attribute: '@default(true)', + it('maps boolean true expression', () => { + expect(mapDefault({ kind: 'expression', expression: 'true' })).toEqual({ + comment: '// Raw default: true', }); }); - it('maps boolean false', () => { - expect(mapDefault({ kind: 'literal', value: false })).toEqual({ - attribute: '@default(false)', + it('maps boolean false expression', () => { + expect(mapDefault({ kind: 'expression', expression: 'false' })).toEqual({ + comment: '// Raw default: false', }); }); - it('maps number', () => { - expect(mapDefault({ kind: 'literal', value: 42 })).toEqual({ - attribute: '@default(42)', + it('maps number expression', () => { + expect(mapDefault({ kind: 'expression', expression: '42' })).toEqual({ + comment: '// Raw default: 42', }); }); - it('maps string', () => { - expect(mapDefault({ kind: 'literal', value: 'hello' })).toEqual({ - attribute: '@default("hello")', - }); - }); - - it('maps string with quotes', () => { - expect(mapDefault({ kind: 'literal', value: 'he said "hi"' })).toEqual({ - attribute: '@default("he said \\"hi\\"")', - }); - }); - - it('escapes control characters in string defaults', () => { - expect(mapDefault({ kind: 'literal', value: 'line 1\nline 2\t"quoted"' })).toEqual({ - attribute: '@default("line 1\\nline 2\\t\\"quoted\\"")', + it('maps string expression', () => { + expect(mapDefault({ kind: 'expression', expression: 'hello' })).toEqual({ + comment: '// Raw default: hello', }); }); it('unrecognized function becomes comment', () => { - expect(mapDefault({ kind: 'function', expression: 'custom_func()' })).toEqual({ + expect(mapDefault({ kind: 'expression', expression: 'custom_func()' })).toEqual({ comment: '// Raw default: custom_func()', }); }); it('treats Postgres-specific functions as raw defaults without injected mapping', () => { - expect(mapDefault({ kind: 'function', expression: 'gen_random_uuid()' })).toEqual({ + expect(mapDefault({ kind: 'expression', expression: 'gen_random_uuid()' })).toEqual({ comment: '// Raw default: gen_random_uuid()', }); }); - it('maps null literal', () => { - expect(mapDefault({ kind: 'literal', value: null })).toEqual({ - attribute: '@default(null)', - }); - }); - - it('maps large number literal', () => { - expect(mapDefault({ kind: 'literal', value: 9007199254740991 })).toEqual({ - attribute: '@default(9007199254740991)', + it('maps NULL expression', () => { + expect(mapDefault({ kind: 'expression', expression: 'NULL' })).toEqual({ + comment: '// Raw default: NULL', }); }); - it('stringifies unsupported literal defaults', () => { - expect(mapDefault({ kind: 'literal', value: { nested: ['value'] } })).toEqual({ - attribute: '@default("{\\"nested\\":[\\"value\\"]}")', + it('maps large number expression', () => { + expect(mapDefault({ kind: 'expression', expression: '9007199254740991' })).toEqual({ + comment: '// Raw default: 9007199254740991', }); }); }); diff --git a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts index 68a24000d6..3ab2e21e61 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts @@ -18,31 +18,31 @@ describe('printPsl', () => { name: 'id', nativeType: 'int4', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' } as unknown as string, + default: { kind: 'autoincrement' } as unknown as string, }, title: { name: 'title', nativeType: 'text', nullable: false, - default: { kind: 'literal', value: 'Untitled' } as unknown as string, + default: { kind: 'expression', expression: 'Untitled' } as unknown as string, }, is_published: { name: 'is_published', nativeType: 'bool', nullable: false, - default: { kind: 'literal', value: false } as unknown as string, + default: { kind: 'expression', expression: 'false' } as unknown as string, }, view_count: { name: 'view_count', nativeType: 'int4', nullable: false, - default: { kind: 'literal', value: 0 } as unknown as string, + default: { kind: 'expression', expression: '0' } as unknown as string, }, created_at: { name: 'created_at', nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' } as unknown as string, + default: { kind: 'expression', expression: 'now()' } as unknown as string, }, }, primaryKey: { columns: ['id'] }, @@ -58,9 +58,9 @@ describe('printPsl', () => { model Post { id Int @id @default(autoincrement()) - title String @default("Untitled") - isPublished Boolean @default(false) @map("is_published") - viewCount Int @default(0) @map("view_count") + title String @default(dbgenerated("Untitled")) + isPublished Boolean @default(dbgenerated("false")) @map("is_published") + viewCount Int @default(dbgenerated("0")) @map("view_count") createdAt DateTime @default(now()) @map("created_at") @@map("post") @@ -363,7 +363,7 @@ describe('printPsl', () => { name: 'id', nativeType: 'uuid', nullable: false, - default: { kind: 'function', expression: 'gen_random_uuid()' } as unknown as string, + default: { kind: 'expression', expression: 'gen_random_uuid()' } as unknown as string, }, }, primaryKey: { columns: ['id'] }, @@ -462,7 +462,7 @@ describe('printPsl', () => { name: 'computed', nativeType: 'text', nullable: false, - default: { kind: 'function', expression: 'my_custom_func()' } as unknown as string, + default: { kind: 'expression', expression: 'my_custom_func()' } as unknown as string, }, payload: { name: 'payload', @@ -525,7 +525,7 @@ describe('printPsl', () => { "// Contract inferred from the live database schema. Edit as needed, then run \`prisma-next contract emit\`. model Counter { - id BigInt @id @default(9223372036854776000) + id BigInt @id @default(dbgenerated("9223372036854775807")) @@map("counter") } diff --git a/packages/2-sql/9-family/test/psl-contract-infer/psl-printer-round-trip.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/psl-printer-round-trip.test.ts new file mode 100644 index 0000000000..fc16857696 --- /dev/null +++ b/packages/2-sql/9-family/test/psl-contract-infer/psl-printer-round-trip.test.ts @@ -0,0 +1,122 @@ +import { parsePslDocument } from '@prisma-next/psl-parser'; +import { printPsl } from '@prisma-next/psl-printer'; +import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import { describe, expect, it } from 'vitest'; +import { sqlSchemaIrToPslAst } from '../../src/core/psl-contract-infer/sql-schema-ir-to-psl-ast'; + +function roundTrip(schemaIR: SqlSchemaIR): string { + return printPsl(sqlSchemaIrToPslAst(schemaIR)); +} + +function assertParsesSilently(pslText: string): void { + const result = parsePslDocument({ schema: pslText, sourceId: 'round-trip.psl' }); + expect(result.ok).toBe(true); +} + +describe('PSL printer round-trip across new ColumnDefault union', () => { + it('autoincrement default prints and re-parses without error', () => { + const schemaIR: SqlSchemaIR = { + tables: { + item: { + name: 'item', + columns: { + id: { + name: 'id', + nativeType: 'int4', + nullable: false, + default: { kind: 'autoincrement' } as unknown as string, + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }; + + const printed = roundTrip(schemaIR); + expect(printed).toContain('@default(autoincrement())'); + assertParsesSilently(printed); + }); + + it('now() expression default prints and re-parses without error', () => { + const schemaIR: SqlSchemaIR = { + tables: { + event: { + name: 'event', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + created_at: { + name: 'created_at', + nativeType: 'timestamptz', + nullable: false, + default: { kind: 'expression', expression: 'now()' } as unknown as string, + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }; + + const printed = roundTrip(schemaIR); + expect(printed).toContain('@default(now())'); + assertParsesSilently(printed); + }); + + it('raw expression default prints via dbgenerated and re-parses without error', () => { + const schemaIR: SqlSchemaIR = { + tables: { + feature: { + name: 'feature', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + enabled: { + name: 'enabled', + nativeType: 'bool', + nullable: false, + default: { kind: 'expression', expression: 'TRUE' } as unknown as string, + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }; + + const printed = roundTrip(schemaIR); + expect(printed).toContain('dbgenerated'); + assertParsesSilently(printed); + }); + + it('gen_random_uuid() expression default prints and re-parses without error', () => { + const schemaIR: SqlSchemaIR = { + tables: { + token: { + name: 'token', + columns: { + id: { + name: 'id', + nativeType: 'uuid', + nullable: false, + default: { kind: 'expression', expression: 'gen_random_uuid()' } as unknown as string, + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }; + + const printed = roundTrip(schemaIR); + expect(printed).toContain('dbgenerated'); + assertParsesSilently(printed); + }); +}); diff --git a/packages/2-sql/9-family/test/psl-contract-infer/raw-default-parser.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/raw-default-parser.test.ts index 2b70f3db60..5feb2f9569 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/raw-default-parser.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/raw-default-parser.test.ts @@ -4,139 +4,138 @@ import { parseRawDefault } from '../../src/core/psl-contract-infer/raw-default-p describe('parseRawDefault', () => { it('recognizes nextval (autoincrement)', () => { expect(parseRawDefault("nextval('user_id_seq'::regclass)")).toEqual({ - kind: 'function', - expression: 'autoincrement()', + kind: 'autoincrement', }); }); it('recognizes now()', () => { - expect(parseRawDefault('now()')).toEqual({ kind: 'function', expression: 'now()' }); + expect(parseRawDefault('now()')).toEqual({ kind: 'expression', expression: 'now()' }); }); it('recognizes CURRENT_TIMESTAMP', () => { expect(parseRawDefault('CURRENT_TIMESTAMP')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); }); it('recognizes clock_timestamp()', () => { expect(parseRawDefault('clock_timestamp()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'clock_timestamp()', }); }); it('recognizes timestamp-cast now() defaults', () => { expect(parseRawDefault('now()::timestamp')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); expect(parseRawDefault("('now'::text)::timestamp without time zone")).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); }); it('recognizes timestamp-cast clock_timestamp() defaults', () => { expect(parseRawDefault('clock_timestamp()::timestamp with time zone')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'clock_timestamp()', }); }); it('preserves timestamp string literals when they are not canonical time functions', () => { expect(parseRawDefault("'2024-01-01 00:00:00'::timestamp")).toEqual({ - kind: 'literal', - value: '2024-01-01 00:00:00', + kind: 'expression', + expression: '2024-01-01 00:00:00', }); }); it('recognizes gen_random_uuid()', () => { expect(parseRawDefault('gen_random_uuid()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }); }); it('recognizes uuid_generate_v4()', () => { expect(parseRawDefault('uuid_generate_v4()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }); }); it('recognizes boolean true', () => { - expect(parseRawDefault('true')).toEqual({ kind: 'literal', value: true }); - expect(parseRawDefault('TRUE')).toEqual({ kind: 'literal', value: true }); + expect(parseRawDefault('true')).toEqual({ kind: 'expression', expression: 'true' }); + expect(parseRawDefault('TRUE')).toEqual({ kind: 'expression', expression: 'true' }); }); it('recognizes boolean false', () => { - expect(parseRawDefault('false')).toEqual({ kind: 'literal', value: false }); + expect(parseRawDefault('false')).toEqual({ kind: 'expression', expression: 'false' }); }); it('recognizes NULL literals', () => { - expect(parseRawDefault('NULL::jsonb')).toEqual({ kind: 'literal', value: null }); + expect(parseRawDefault('NULL::jsonb')).toEqual({ kind: 'expression', expression: 'NULL' }); }); it('recognizes integer literals', () => { - expect(parseRawDefault('42')).toEqual({ kind: 'literal', value: 42 }); - expect(parseRawDefault('-1')).toEqual({ kind: 'literal', value: -1 }); + expect(parseRawDefault('42')).toEqual({ kind: 'expression', expression: '42' }); + expect(parseRawDefault('-1')).toEqual({ kind: 'expression', expression: '-1' }); }); it('recognizes decimal literals', () => { - expect(parseRawDefault('3.14')).toEqual({ kind: 'literal', value: 3.14 }); + expect(parseRawDefault('3.14')).toEqual({ kind: 'expression', expression: '3.14' }); }); - it('parses large integer literals as numbers (precision loss expected)', () => { + it('parses large integer literals as expression strings without precision loss', () => { const result = parseRawDefault('9223372036854775807'); expect(result).toEqual({ - kind: 'literal', - value: Number('9223372036854775807'), + kind: 'expression', + expression: '9223372036854775807', }); }); it('recognizes string literals', () => { - expect(parseRawDefault("'hello'")).toEqual({ kind: 'literal', value: 'hello' }); + expect(parseRawDefault("'hello'")).toEqual({ kind: 'expression', expression: 'hello' }); }); it('recognizes string literals with type cast', () => { - expect(parseRawDefault("'hello'::text")).toEqual({ kind: 'literal', value: 'hello' }); + expect(parseRawDefault("'hello'::text")).toEqual({ kind: 'expression', expression: 'hello' }); }); it('preserves jsonb string defaults as raw expressions when native type context matters', () => { expect(parseRawDefault("'{}'::jsonb", 'jsonb')).toEqual({ - kind: 'function', + kind: 'expression', expression: "'{}'::jsonb", }); }); it('parses inline json literals when no cast is present', () => { expect(parseRawDefault('\'{"enabled":true}\'', 'json')).toEqual({ - kind: 'literal', - value: { enabled: true }, + kind: 'expression', + expression: '{"enabled":true}', }); }); it('falls back to string literals when inline json parsing fails', () => { expect(parseRawDefault("'not-json'", 'jsonb')).toEqual({ - kind: 'literal', - value: 'not-json', + kind: 'expression', + expression: 'not-json', }); }); it('unescapes single quotes in strings', () => { - expect(parseRawDefault("'it''s'")).toEqual({ kind: 'literal', value: "it's" }); + expect(parseRawDefault("'it''s'")).toEqual({ kind: 'expression', expression: "it's" }); }); it('returns unrecognized function expressions as-is', () => { expect(parseRawDefault('my_func()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'my_func()', }); }); it('trims whitespace', () => { - expect(parseRawDefault(' true ')).toEqual({ kind: 'literal', value: true }); + expect(parseRawDefault(' true ')).toEqual({ kind: 'expression', expression: 'true' }); }); }); diff --git a/packages/2-sql/9-family/test/schema-verify.codec-defaults.test.ts b/packages/2-sql/9-family/test/schema-verify.codec-defaults.test.ts new file mode 100644 index 0000000000..293f071216 --- /dev/null +++ b/packages/2-sql/9-family/test/schema-verify.codec-defaults.test.ts @@ -0,0 +1,468 @@ +/** + * Codec-aware schema-default comparison: the verifier round-trips the + * introspected raw literal through the column's codec (`decodeJson` → + * `renderSqlLiteral`) so canonical Postgres / SQLite literal forms (e.g. + * `'9007199254740991'::bigint`, `'2024-01-15 10:30:00+00'::timestamptz`) + * collapse to the same contract-side canonical form the codec produced at + * emit time. Without codec dispatch the comparison reduces to string + * normalisation, which is too weak to reconcile the two forms. + */ +import type { JsonValue } from '@prisma-next/contract/types'; +import type { Codec, CodecLookup } from '@prisma-next/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import type { SchemaDefaultValueParser } from '../src/core/schema-verify/verify-sql-schema'; +import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; +import { + createContractTable, + createSchemaTable, + createTestContract, + createTestSchemaIR, + emptyTypeMetadataRegistry, +} from './schema-verify.helpers'; + +function makeCodec(overrides: { + readonly id: string; + readonly decodeJson: (json: JsonValue) => unknown; + readonly renderSqlLiteral: (value: unknown) => string; +}): Codec { + const stub = { + id: overrides.id, + encode: async (v: unknown) => v, + decode: async (v: unknown) => v, + encodeJson: (v: unknown) => v as JsonValue, + decodeJson: overrides.decodeJson, + renderSqlLiteral: overrides.renderSqlLiteral, + }; + return stub as unknown as Codec; +} + +function makeLookup(map: Record): CodecLookup { + return { + get: (id) => map[id], + targetTypesFor: () => undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, + }; +} + +/** + * Mimics `parsePostgresDefaultValue` shape: extracts the JS-comparable value + * out of a raw Postgres literal (strip `::type` cast and outer quotes for + * string forms; recognise bare numerics and booleans; normalise space-form + * timestamps to ISO-8601 UTC so the timestamptz codec's strict `decodeJson` + * accepts them). + */ +const testValueParser: SchemaDefaultValueParser = ( + rawDefault: string, + nativeType: string, +): JsonValue | undefined => { + const trimmed = rawDefault.trim(); + + // Strip outer cast `::type` (possibly quoted) + const stripCast = (s: string): string => { + const m = s.match(/^(.*?)\s*::\s*(?:"[^"]+"|[\w\s]+)(?:\(\d+(?:,\d+)?\))?$/); + return m?.[1] ?? s; + }; + + const inner = stripCast(trimmed); + + // Timestamp-like native types: parse the inner string as a Date, return + // canonical ISO-8601 UTC form for the strict timestamptz codec. + if (/timestamp/i.test(nativeType)) { + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + const str = stringMatch?.[1] ?? inner; + const date = new Date(str.replace(/''/g, "'")); + if (!Number.isNaN(date.getTime())) { + return date.toISOString(); + } + } + + // Booleans + if (/^true$/i.test(inner)) return true; + if (/^false$/i.test(inner)) return false; + + // Numerics: bare `9007199254740991` OR quoted `'9007199254740991'` + const numericMatch = inner.match(/^'?(-?\d+(?:\.\d+)?)'?$/); + if (numericMatch?.[1] !== undefined) { + if (/^(?:int|bigint|smallint|numeric|float|real|double)/i.test(nativeType)) { + const n = Number(numericMatch[1]); + if (Number.isFinite(n)) return n; + } + } + + // JSON literals: `'{...}'::jsonb` → parsed object + if (/json/i.test(nativeType)) { + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + if (stringMatch?.[1] !== undefined) { + try { + return JSON.parse(stringMatch[1].replace(/''/g, "'")); + } catch { + return undefined; + } + } + } + + // Quoted strings: strip outer quotes + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + if (stringMatch?.[1] !== undefined) { + return stringMatch[1].replace(/''/g, "'"); + } + + return undefined; +}; + +const bigintCodec = makeCodec({ + id: 'pg/int8@1', + decodeJson: (json) => json, + renderSqlLiteral: (value) => String(value), +}); + +const timestamptzCodec = makeCodec({ + id: 'pg/timestamptz@1', + decodeJson: (json) => { + if (typeof json !== 'string') throw new Error('expected ISO string'); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(json)) { + throw new Error(`Invalid ISO timestamp: ${json}`); + } + return new Date(json); + }, + renderSqlLiteral: (value) => { + const date = value as Date; + return `'${date.toISOString()}'::timestamp with time zone`; + }, +}); + +const jsonbCodec = makeCodec({ + id: 'pg/jsonb@1', + decodeJson: (json) => json, + renderSqlLiteral: (value) => `'${JSON.stringify(value)}'::jsonb`, +}); + +const boolCodec = makeCodec({ + id: 'pg/bool@1', + decodeJson: (json) => json, + renderSqlLiteral: (value) => (value ? 'TRUE' : 'FALSE'), +}); + +const int4Codec = makeCodec({ + id: 'pg/int4@1', + decodeJson: (json) => json, + renderSqlLiteral: (value) => String(value), +}); + +describe('verifySqlSchema — codec-aware default comparison', () => { + it('treats bigint contract default as equal to quoted-cast Postgres form via codec round-trip', () => { + const contract = createTestContract({ + literal_defaults: createContractTable({ + big_count: { + nativeType: 'bigint', + codecId: 'pg/int8@1', + nullable: false, + default: { kind: 'expression', expression: '9007199254740991' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + literal_defaults: createSchemaTable('literal_defaults', { + big_count: { + nativeType: 'bigint', + nullable: false, + default: "'9007199254740991'::bigint", + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/int8@1': bigintCodec }), + parseSchemaDefaultValue: testValueParser, + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('treats timestamptz contract default as equal to space-separated Postgres form via codec round-trip', () => { + const contract = createTestContract({ + event: createContractTable({ + scheduled_at: { + nativeType: 'timestamp with time zone', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { + kind: 'expression', + expression: "'2024-01-15T10:30:00.000Z'::timestamp with time zone", + }, + }, + }), + }); + + const schema = createTestSchemaIR({ + event: createSchemaTable('event', { + scheduled_at: { + nativeType: 'timestamp with time zone', + nullable: false, + default: "'2024-01-15 10:30:00+00'::timestamp with time zone", + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/timestamptz@1': timestamptzCodec }), + parseSchemaDefaultValue: testValueParser, + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('reports default_mismatch when codec round-trip produces a different canonical form', () => { + const contract = createTestContract({ + event: createContractTable({ + scheduled_at: { + nativeType: 'timestamp with time zone', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { + kind: 'expression', + expression: "'2024-01-15T10:30:00.000Z'::timestamp with time zone", + }, + }, + }), + }); + + const schema = createTestSchemaIR({ + event: createSchemaTable('event', { + scheduled_at: { + nativeType: 'timestamp with time zone', + nullable: false, + default: "'2099-12-31 23:59:59+00'::timestamp with time zone", + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/timestamptz@1': timestamptzCodec }), + parseSchemaDefaultValue: testValueParser, + }); + + expect(result.ok).toBe(false); + expect(result.schema.issues).toContainEqual( + expect.objectContaining({ + kind: 'default_mismatch', + table: 'event', + column: 'scheduled_at', + }), + ); + }); + + it('treats JSONB defaults as equal even when schema returns the object with reordered keys', () => { + const contract = createTestContract({ + literal_defaults: createContractTable({ + payload: { + nativeType: 'jsonb', + codecId: 'pg/jsonb@1', + nullable: false, + default: { kind: 'expression', expression: '\'{"a":1,"b":2}\'::jsonb' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + literal_defaults: createSchemaTable('literal_defaults', { + payload: { + nativeType: 'jsonb', + nullable: false, + // Postgres reserialised the JSONB with reordered keys + default: '\'{"b":2,"a":1}\'::jsonb', + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/jsonb@1': jsonbCodec }), + parseSchemaDefaultValue: testValueParser, + }); + + // Both sides decode to a structurally equal object; codec.renderSqlLiteral + // re-serialises both through `JSON.stringify` so the canonicals collapse to + // the same key order (the one JSON.stringify produces on the parsed object). + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('treats autoincrement contract default as equal to nextval schema default via the per-target normalizer', () => { + // Autoincrement round-trip: contract `{ kind: 'autoincrement' }` against + // Postgres-introspected `nextval('seq_name'::regclass)` form. The + // existing per-target `normalizeDefault` path produces `{ kind: + // 'autoincrement' }` from the raw default; the codec-aware compare + // short-circuits to the autoincrement kind-equality branch before any + // codec round-trip is attempted. + const contract = createTestContract({ + literal_defaults: createContractTable({ + id: { + nativeType: 'integer', + codecId: 'pg/int4@1', + nullable: false, + default: { kind: 'autoincrement' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + literal_defaults: createSchemaTable('literal_defaults', { + id: { + nativeType: 'integer', + nullable: false, + default: "nextval('literal_defaults_id_seq'::regclass)", + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/int4@1': int4Codec }), + parseSchemaDefaultValue: testValueParser, + normalizeDefault: (raw) => + /^nextval\s*\(/i.test(raw.trim()) ? { kind: 'autoincrement' } : undefined, + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('treats bool contract default TRUE as equal to schema-side bare true via codec round-trip', () => { + const contract = createTestContract({ + literal_defaults: createContractTable({ + active: { + nativeType: 'boolean', + codecId: 'pg/bool@1', + nullable: false, + default: { kind: 'expression', expression: 'TRUE' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + literal_defaults: createSchemaTable('literal_defaults', { + active: { + nativeType: 'boolean', + nullable: false, + default: 'true', + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/bool@1': boolCodec }), + parseSchemaDefaultValue: testValueParser, + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('falls back to the legacy normalizer path when no codec or parser is supplied', () => { + // No codecLookup or parseSchemaDefaultValue → fall back to the legacy + // string-normalised compare via the optional normalizer. Required so + // callers without codec dispatch wired up still get the prior + // verification behaviour. + const contract = createTestContract({ + user: createContractTable({ + status: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: false, + default: { kind: 'expression', expression: 'draft' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + user: createSchemaTable('user', { + status: { nativeType: 'text', nullable: false, default: 'draft' }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('falls back to the legacy normalizer path when the column codec is not in the lookup', () => { + // Codec lookup misses → codec-aware compare cannot run; verifier falls + // back to the legacy normalizer path so unknown codecs degrade + // gracefully rather than reporting spurious mismatches. + const contract = createTestContract({ + user: createContractTable({ + status: { + nativeType: 'text', + codecId: 'pg/unknown@1', + nullable: false, + default: { kind: 'expression', expression: 'draft' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + user: createSchemaTable('user', { + status: { nativeType: 'text', nullable: false, default: "'draft'::text" }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({}), + parseSchemaDefaultValue: testValueParser, + normalizeDefault: (raw) => { + const m = raw.trim().match(/^'((?:[^']|'')*)'(?:::.+)?$/); + return m?.[1] !== undefined + ? { kind: 'expression', expression: m[1].replace(/''/g, "'") } + : { kind: 'expression', expression: raw.trim() }; + }, + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/2-sql/9-family/test/schema-verify.defaults.test.ts b/packages/2-sql/9-family/test/schema-verify.defaults.test.ts index 3ca7ad4b60..464c715e67 100644 --- a/packages/2-sql/9-family/test/schema-verify.defaults.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.defaults.test.ts @@ -1,5 +1,5 @@ -import { type ColumnDefault, type Contract, executionHash } from '@prisma-next/contract/types'; -import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { type Contract, executionHash } from '@prisma-next/contract/types'; +import type { ColumnDefault, SqlStorage } from '@prisma-next/sql-contract/types'; import { describe, expect, it } from 'vitest'; import type { DefaultNormalizer } from '../src/core/schema-verify/verify-sql-schema'; import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; @@ -22,35 +22,35 @@ const testNormalizer: DefaultNormalizer = (rawDefault: string): ColumnDefault | const nowPattern = /^(now\s*\(\s*\)|CURRENT_TIMESTAMP)$/i; const clockPattern = /^clock_timestamp\s*\(\s*\)$/i; const timestampCastSuffix = /::timestamp(?:tz|\s+(?:with|without)\s+time\s+zone)?$/i; - if (nowPattern.test(trimmed)) return { kind: 'function', expression: 'now()' }; - if (clockPattern.test(trimmed)) return { kind: 'function', expression: 'clock_timestamp()' }; + if (nowPattern.test(trimmed)) return { kind: 'expression', expression: 'now()' }; + if (clockPattern.test(trimmed)) return { kind: 'expression', expression: 'clock_timestamp()' }; if (timestampCastSuffix.test(trimmed)) { let inner = trimmed.replace(timestampCastSuffix, '').trim(); if (inner.startsWith('(') && inner.endsWith(')')) { inner = inner.slice(1, -1).trim(); } - if (nowPattern.test(inner)) return { kind: 'function', expression: 'now()' }; - if (clockPattern.test(inner)) return { kind: 'function', expression: 'clock_timestamp()' }; + if (nowPattern.test(inner)) return { kind: 'expression', expression: 'now()' }; + if (clockPattern.test(inner)) return { kind: 'expression', expression: 'clock_timestamp()' }; inner = inner.replace(/::text$/i, '').trim(); - if (/^'now'$/i.test(inner)) return { kind: 'function', expression: 'now()' }; + if (/^'now'$/i.test(inner)) return { kind: 'expression', expression: 'now()' }; } // NULL or NULL::type if (/^NULL(?:::(?:"[^"]+"|[\w\s]+)(?:\(\d+(?:,\d+)?\))?)?$/i.test(trimmed)) { - return { kind: 'literal', value: null }; + return { kind: 'expression', expression: 'NULL' }; } // Boolean literals if (/^true$/i.test(trimmed)) { - return { kind: 'literal', value: true }; + return { kind: 'expression', expression: 'true' }; } if (/^false$/i.test(trimmed)) { - return { kind: 'literal', value: false }; + return { kind: 'expression', expression: 'false' }; } // Numeric literals if (/^-?\d+(\.\d+)?$/.test(trimmed)) { - return { kind: 'literal', value: Number(trimmed) }; + return { kind: 'expression', expression: trimmed }; } // String literals: 'value'::type or just 'value' @@ -58,15 +58,11 @@ const testNormalizer: DefaultNormalizer = (rawDefault: string): ColumnDefault | const stringMatch = trimmed.match(/^'((?:[^']|'')*)'(?:::(?:"[^"]+"|[\w\s]+)(?:\(\d+\))?)?$/); if (stringMatch?.[1] !== undefined) { const unescaped = stringMatch[1].replace(/''/g, "'"); - try { - return { kind: 'literal', value: JSON.parse(unescaped) }; - } catch { - return { kind: 'literal', value: unescaped }; - } + return { kind: 'expression', expression: unescaped }; } // Fallback - return { kind: 'function', expression: trimmed }; + return { kind: 'expression', expression: trimmed }; }; describe('verifySqlSchema - defaults', () => { @@ -76,7 +72,7 @@ describe('verifySqlSchema - defaults', () => { id: { nativeType: 'int4', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }), }); @@ -112,7 +108,7 @@ describe('verifySqlSchema - defaults', () => { status: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: 'draft' }, + default: { kind: 'expression', expression: 'draft' }, }, }), }); @@ -153,12 +149,12 @@ describe('verifySqlSchema - defaults', () => { created_at: { nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, label: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: 'draft' }, + default: { kind: 'expression', expression: 'draft' }, }, }), }); @@ -193,47 +189,13 @@ describe('verifySqlSchema - defaults', () => { expect(result.schema.issues).toHaveLength(0); }); - it('treats JSON defaults as equal when schema normalizer returns string literals', () => { - const contract = createTestContract({ - literal_defaults: createContractTable({ - metadata: { - nativeType: 'jsonb', - nullable: false, - default: { kind: 'literal', value: { key: 'default' } }, - }, - }), - }); - - const schema = createTestSchemaIR({ - literal_defaults: createSchemaTable('literal_defaults', { - metadata: { - nativeType: 'jsonb', - nullable: false, - default: '\'{"key": "default"}\'::jsonb', - }, - }), - }); - - const result = verifySqlSchema({ - contract, - schema, - strict: false, - typeMetadataRegistry: emptyTypeMetadataRegistry, - frameworkComponents: [], - normalizeDefault: testNormalizer, - }); - - expect(result.ok).toBe(true); - expect(result.schema.issues).toHaveLength(0); - }); - - it('matches JSONB default with different key order (stable key sort)', () => { + it('treats JSON defaults as equal when schema normalizer returns matching expression', () => { const contract = createTestContract({ literal_defaults: createContractTable({ metadata: { nativeType: 'jsonb', nullable: false, - default: { kind: 'literal', value: { alpha: 1, beta: 2, gamma: 3 } }, + default: { kind: 'expression', expression: '{"key":"default"}' }, }, }), }); @@ -243,8 +205,7 @@ describe('verifySqlSchema - defaults', () => { metadata: { nativeType: 'jsonb', nullable: false, - // Postgres may canonicalize key order differently - default: '\'{"gamma":3,"alpha":1,"beta":2}\'::jsonb', + default: '\'{"key":"default"}\'::jsonb', }, }), }); @@ -268,7 +229,7 @@ describe('verifySqlSchema - defaults', () => { payload: { nativeType: 'jsonb', nullable: false, - default: { kind: 'literal', value: { $type: 'bigint', value: '42' } }, + default: { kind: 'expression', expression: '{"$type":"bigint","value":"42"}' }, }, }), }); @@ -303,7 +264,7 @@ describe('verifySqlSchema - defaults', () => { nativeType: 'text', nullable: false, // Contract default value - default: { kind: 'literal', value: 'draft' }, + default: { kind: 'expression', expression: 'draft' }, }, }), }); @@ -384,7 +345,7 @@ describe('verifySqlSchema - timestamp default normalization', () => { created_at: { nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }), }); @@ -418,7 +379,7 @@ describe('verifySqlSchema - timestamp default normalization', () => { created_at: { nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }), }); @@ -454,7 +415,7 @@ describe('verifySqlSchema - null default normalization', () => { value: { nativeType: 'text', nullable: true, - default: { kind: 'literal', value: null }, + default: { kind: 'expression', expression: 'NULL' }, }, }), }); @@ -490,7 +451,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { provisionStatus: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: 'ready' }, + default: { kind: 'expression', expression: 'ready' }, }, }), }); @@ -524,7 +485,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { role: { nativeType: 'character varying', nullable: false, - default: { kind: 'literal', value: 'member' }, + default: { kind: 'expression', expression: 'member' }, }, }), }); @@ -558,7 +519,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { kind: { nativeType: 'EnvironmentModelKind', nullable: false, - default: { kind: 'literal', value: 'production' }, + default: { kind: 'expression', expression: 'production' }, }, }), }); @@ -592,7 +553,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { title: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: "it's a default" }, + default: { kind: 'expression', expression: "it's a default" }, }, }), }); @@ -626,7 +587,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { allowRemoteDatabases: { nativeType: 'bool', nullable: false, - default: { kind: 'literal', value: true }, + default: { kind: 'expression', expression: 'true' }, }, }), }); @@ -660,7 +621,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { status: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: 'active' }, }, }), }); @@ -700,7 +661,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { value: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: '' }, + default: { kind: 'expression', expression: '' }, }, }), }); @@ -734,7 +695,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { billingState: { nativeType: 'BillingState', nullable: false, - default: { kind: 'literal', value: 'atRisk' }, + default: { kind: 'expression', expression: 'atRisk' }, }, }), }); @@ -768,7 +729,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { billingState: { nativeType: 'BillingState', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: 'active' }, }, }), }); diff --git a/packages/2-sql/9-family/test/schema-verify.helpers.ts b/packages/2-sql/9-family/test/schema-verify.helpers.ts index 5929e92c86..c1eb920c60 100644 --- a/packages/2-sql/9-family/test/schema-verify.helpers.ts +++ b/packages/2-sql/9-family/test/schema-verify.helpers.ts @@ -2,14 +2,10 @@ * Shared test helpers for schema verification tests. */ -import { - type ColumnDefault, - type Contract, - profileHash, - type StorageHashBase, -} from '@prisma-next/contract/types'; +import { type Contract, profileHash, type StorageHashBase } from '@prisma-next/contract/types'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { applyFkDefaults, type ReferentialAction, diff --git a/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts b/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts index bacd5cfff6..2de1aaa01d 100644 --- a/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts +++ b/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts @@ -16,13 +16,13 @@ import { type AnyCodecDescriptor, type CodecCallContext, CodecDescriptorImpl, - CodecImpl, type CodecInstanceContext, type ColumnHelperFor, type ColumnSpec, column, } from '@prisma-next/framework-components/codec'; import { isRuntimeError, runtimeError } from '@prisma-next/framework-components/runtime'; +import { SqlCodecImpl } from '@prisma-next/sql-relational-core/ast'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { ArkErrors, ark, type Type, type } from 'arktype'; @@ -156,7 +156,7 @@ function renderArktypeJsonOutputType(params: ArktypeJsonTypeParams): string { return expression.length > 0 ? expression : 'unknown'; } -export class ArktypeJsonCodecClass extends CodecImpl< +export class ArktypeJsonCodecClass extends SqlCodecImpl< typeof ARKTYPE_JSON_CODEC_ID, readonly ['equality'], string | JsonValue, @@ -184,6 +184,14 @@ export class ArktypeJsonCodecClass extends CodecImpl< decodeJson(json: JsonValue): TInferred { return validateSchema(this.schema, json); } + + renderSqlLiteral(value: TInferred): string { + const json = serializeWire(value); + if (json.includes('\0')) { + throw new Error('arktype-json literal cannot contain NULL bytes'); + } + return `'${json.replace(/'/g, "''")}'::jsonb`; + } } const arktypeJsonParamsSchema = type({ @@ -233,7 +241,11 @@ export const arktypeJsonDescriptor = new ArktypeJsonDescriptor(); */ export function arktypeJsonColumn>( schema: S, -): ColumnSpec, ArktypeJsonTypeParams> { +): ColumnSpec< + ArktypeJsonCodecClass, + ArktypeJsonTypeParams, + typeof arktypeJsonDescriptor.traits +> { if (!isArktypeSchemaLike(schema)) { throw new Error( typeof schema !== 'function' @@ -252,6 +264,7 @@ export function arktypeJsonColumn>( arktypeJsonDescriptor.codecId, params, ARKTYPE_JSON_NATIVE_TYPE, + arktypeJsonDescriptor.traits, ); } diff --git a/packages/3-extensions/arktype-json/test/arktype-json-codec.render-sql-literal.test.ts b/packages/3-extensions/arktype-json/test/arktype-json-codec.render-sql-literal.test.ts new file mode 100644 index 0000000000..9ef5517f79 --- /dev/null +++ b/packages/3-extensions/arktype-json/test/arktype-json-codec.render-sql-literal.test.ts @@ -0,0 +1,34 @@ +import { type } from 'arktype'; +import { describe, expect, it } from 'vitest'; +import { arktypeJsonColumn } from '../src/core/arktype-json-codec'; + +const instanceCtx = { name: '' }; + +describe('renderSqlLiteral on arktype/json@1', () => { + it('renders schema-validated objects as quoted JSON literals with jsonb cast', () => { + const codec = arktypeJsonColumn(type({ a: 'number' })).codecFactory(instanceCtx); + expect(codec.renderSqlLiteral({ a: 1 })).toBe('\'{"a":1}\'::jsonb'); + }); + + it('doubles embedded single quotes inside string fields', () => { + const codec = arktypeJsonColumn(type({ msg: 'string' })).codecFactory(instanceCtx); + expect(codec.renderSqlLiteral({ msg: "O'Brien" })).toBe('\'{"msg":"O\'\'Brien"}\'::jsonb'); + }); + + it('handles arrays', () => { + const codec = arktypeJsonColumn(type('number[]')).codecFactory(instanceCtx); + expect(codec.renderSqlLiteral([1, 2, 3])).toBe("'[1,2,3]'::jsonb"); + }); + + it('renders unicode through verbatim (JSON serialises non-ASCII as is by default)', () => { + const codec = arktypeJsonColumn(type({ name: 'string' })).codecFactory(instanceCtx); + expect(codec.renderSqlLiteral({ name: '日本語' })).toBe('\'{"name":"日本語"}\'::jsonb'); + }); + + it('escapes NULL bytes via JSON unicode encoding (no raw \\0 leaks into the literal)', () => { + const codec = arktypeJsonColumn(type({ msg: 'string' })).codecFactory(instanceCtx); + const rendered = codec.renderSqlLiteral({ msg: 'a\0b' }); + expect(rendered).toBe('\'{"msg":"a\\u0000b"}\'::jsonb'); + expect(rendered.includes('\0')).toBe(false); + }); +}); diff --git a/packages/3-extensions/arktype-json/test/arktype-json-codec.types.test-d.ts b/packages/3-extensions/arktype-json/test/arktype-json-codec.types.test-d.ts index 1127fbd6f2..dc44e81b1e 100644 --- a/packages/3-extensions/arktype-json/test/arktype-json-codec.types.test-d.ts +++ b/packages/3-extensions/arktype-json/test/arktype-json-codec.types.test-d.ts @@ -114,6 +114,10 @@ test('arktypeJsonColumn: result is ColumnSpec with typed codecFactory', () => { const ProductSchema = type({ name: 'string', price: 'number' }); const col = arktypeJsonColumn(ProductSchema); expectTypeOf(col).toExtend< - ColumnSpec, ArktypeJsonTypeParams> + ColumnSpec< + ArktypeJsonCodecClass<{ name: string; price: number }>, + ArktypeJsonTypeParams, + typeof arktypeJsonDescriptor.traits + > >(); }); diff --git a/packages/3-extensions/cipherstash/src/contract.d.ts b/packages/3-extensions/cipherstash/src/contract.d.ts index 851a6f7ce1..8c60d60cfb 100644 --- a/packages/3-extensions/cipherstash/src/contract.d.ts +++ b/packages/3-extensions/cipherstash/src/contract.d.ts @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly EqlV2Configuration: { diff --git a/packages/3-extensions/cipherstash/src/execution/cell-codec-factory.ts b/packages/3-extensions/cipherstash/src/execution/cell-codec-factory.ts index debd5d71c6..eb25324481 100644 --- a/packages/3-extensions/cipherstash/src/execution/cell-codec-factory.ts +++ b/packages/3-extensions/cipherstash/src/execution/cell-codec-factory.ts @@ -27,13 +27,13 @@ */ import type { JsonValue } from '@prisma-next/contract/types'; -import { - type AnyCodecDescriptor, - CodecImpl, - type CodecTrait, -} from '@prisma-next/framework-components/codec'; +import type { AnyCodecDescriptor, CodecTrait } from '@prisma-next/framework-components/codec'; import { runtimeError } from '@prisma-next/framework-components/runtime'; -import type { Codec, SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast'; +import { + type Codec, + type SqlCodecCallContext, + SqlCodecImpl, +} from '@prisma-next/sql-relational-core/ast'; import { CIPHERSTASH_CODEC_TRAITS, EQL_V2_ENCRYPTED_TYPE } from '../extension-metadata/constants'; import type { EncryptedEnvelopeBase } from './envelope-base'; import type { CipherstashSdk } from './sdk'; @@ -102,7 +102,7 @@ export interface CipherstashCellCodecOptions E; } -export class CipherstashCellCodec> extends CodecImpl< +export class CipherstashCellCodec> extends SqlCodecImpl< string, readonly CodecTrait[], unknown, @@ -188,6 +188,19 @@ export class CipherstashCellCodec> exte 'cipherstash codec: decodeJson is not supported; envelopes do not round-trip through JSON.', ); } + + renderSqlLiteral(_value: E): string { + // Cipherstash envelopes are opaque encrypted ciphertext bound to a `(table, column)` routing + // context. They cannot be rendered as a static DDL `DEFAULT ()` literal — encryption + // happens at insert time via the bulk-encrypt middleware. `.default()` on an + // encrypted column is a programming error. + throw runtimeError( + 'RUNTIME.ENCODE_FAILED', + `cipherstash ${this.descriptor.codecId}: renderSqlLiteral is not supported on encrypted-cell codecs. ` + + 'Cipherstash columns cannot carry a literal DDL default — encryption is bound to insert-time middleware.', + { codecId: this.descriptor.codecId, reason: 'cipherstash-renderSqlLiteral-unsupported' }, + ); + } } /** diff --git a/packages/3-extensions/paradedb/src/contract.d.ts b/packages/3-extensions/paradedb/src/contract.d.ts index 989e081442..a7d94ed9c7 100644 --- a/packages/3-extensions/paradedb/src/contract.d.ts +++ b/packages/3-extensions/paradedb/src/contract.d.ts @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = Record; export type FieldInputTypes = Record; diff --git a/packages/3-extensions/pgvector/src/contract.d.ts b/packages/3-extensions/pgvector/src/contract.d.ts index bd84f5b729..627347b271 100644 --- a/packages/3-extensions/pgvector/src/contract.d.ts +++ b/packages/3-extensions/pgvector/src/contract.d.ts @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = Record; export type FieldInputTypes = Record; diff --git a/packages/3-extensions/pgvector/src/core/codecs.ts b/packages/3-extensions/pgvector/src/core/codecs.ts index 91a27ecbfb..381e04a863 100644 --- a/packages/3-extensions/pgvector/src/core/codecs.ts +++ b/packages/3-extensions/pgvector/src/core/codecs.ts @@ -15,13 +15,12 @@ import { type AnyCodecDescriptor, type CodecCallContext, CodecDescriptorImpl, - CodecImpl, type CodecInstanceContext, type ColumnHelperFor, type ColumnHelperForStrict, column, } from '@prisma-next/framework-components/codec'; -import type { ExtractCodecTypes } from '@prisma-next/sql-relational-core/ast'; +import { type ExtractCodecTypes, SqlCodecImpl } from '@prisma-next/sql-relational-core/ast'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type as arktype } from 'arktype'; import { VECTOR_CODEC_ID, VECTOR_MAX_DIM } from './constants'; @@ -43,7 +42,7 @@ const vectorParamsSchema = arktype({ const PG_VECTOR_META = { db: { sql: { postgres: { nativeType: 'vector' } } } } as const; -export class PgVectorCodec extends CodecImpl< +export class PgVectorCodec extends SqlCodecImpl< typeof VECTOR_CODEC_ID, readonly ['equality'], string, @@ -104,6 +103,11 @@ export class PgVectorCodec extends CodecImpl< this.assertVector(json); return json; } + + renderSqlLiteral(value: number[]): string { + this.assertVector(value); + return `'[${value.join(',')}]'::vector`; + } } export class PgVectorDescriptor extends CodecDescriptorImpl { diff --git a/packages/3-extensions/pgvector/test/codecs.render-sql-literal.test.ts b/packages/3-extensions/pgvector/test/codecs.render-sql-literal.test.ts new file mode 100644 index 0000000000..36f08f6c05 --- /dev/null +++ b/packages/3-extensions/pgvector/test/codecs.render-sql-literal.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { pgVectorDescriptor } from '../src/core/codecs'; + +const instanceCtx = { name: '' }; + +describe('renderSqlLiteral on pg/vector@1', () => { + it('renders fixed-dimension vectors as bracketed-list literals with vector cast', () => { + const codec = pgVectorDescriptor.factory({ length: 3 })(instanceCtx); + expect(codec.renderSqlLiteral([1, 2, 3])).toBe("'[1,2,3]'::vector"); + }); + + it('renders floating-point components verbatim', () => { + const codec = pgVectorDescriptor.factory({ length: 2 })(instanceCtx); + expect(codec.renderSqlLiteral([0.5, -1.25])).toBe("'[0.5,-1.25]'::vector"); + }); + + it('rejects dimension mismatches before rendering', () => { + const codec = pgVectorDescriptor.factory({ length: 3 })(instanceCtx); + expect(() => codec.renderSqlLiteral([1, 2])).toThrow(); + }); + + it('rejects non-array inputs', () => { + const codec = pgVectorDescriptor.factory({ length: 3 })(instanceCtx); + // @ts-expect-error type-level rejection mirrors the runtime assertion + expect(() => codec.renderSqlLiteral('foo')).toThrow(); + }); +}); diff --git a/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts index 945b27bfb4..34d8ec3979 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts @@ -1,9 +1,4 @@ -import { - type ColumnDefaultLiteralInputValue, - type Contract, - coreHash, - profileHash, -} from '@prisma-next/contract/types'; +import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types'; import { type CodecControlHooks, INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; @@ -459,11 +454,11 @@ describe('NOT NULL column without default uses temporary default', () => { nativeType: 'bool', codecId: 'pg/bool@1', nullable: false, - default: { kind: 'literal', value: true }, + default: { kind: 'expression', expression: 'true' }, }); expect(addCol.execute.map((step) => step.sql)).toEqual([ - `ALTER TABLE ${qualifiedUserTable} ADD COLUMN "active" bool DEFAULT true NOT NULL`, + `ALTER TABLE ${qualifiedUserTable} ADD COLUMN "active" bool DEFAULT (true) NOT NULL`, ]); }); }); @@ -608,7 +603,7 @@ function planAddColumn( nullable: boolean; typeParams?: Record; typeRef?: string; - default?: { kind: 'literal'; value: ColumnDefaultLiteralInputValue }; + default?: { kind: 'expression'; expression: string } | { kind: 'autoincrement' }; }, options?: { frameworkComponents?: ReadonlyArray>; diff --git a/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts index a532a281c3..d8d9204c87 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts @@ -265,13 +265,13 @@ describe('contractToSchemaIR → planner round-trip', () => { nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: "'active'" }, }, createdAt: { nativeType: 'timestamptz', codecId: 'pg/timestamptz@1', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }, primaryKey: { columns: ['id'] }, @@ -713,7 +713,7 @@ const DEMO_BASE_TABLES = { createdAt: col({ nativeType: 'timestamptz', codecId: 'pg/timestamptz@1', - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }), kind: col({ nativeType: 'user_type', @@ -740,7 +740,7 @@ const DEMO_BASE_TABLES = { createdAt: col({ nativeType: 'timestamptz', codecId: 'pg/timestamptz@1', - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }), embedding: col({ nativeType: 'vector', diff --git a/packages/3-extensions/postgis/src/contract.d.ts b/packages/3-extensions/postgis/src/contract.d.ts index dc605bd014..1ba93a79fd 100644 --- a/packages/3-extensions/postgis/src/contract.d.ts +++ b/packages/3-extensions/postgis/src/contract.d.ts @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = Record; export type FieldInputTypes = Record; diff --git a/packages/3-extensions/sql-orm-client/test/create-input.test-d.ts b/packages/3-extensions/sql-orm-client/test/create-input.test-d.ts index b9f86defa1..5a6045b71b 100644 --- a/packages/3-extensions/sql-orm-client/test/create-input.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/create-input.test-d.ts @@ -15,7 +15,7 @@ type CreateInputContract = Contract< codecId: 'pg/int4@1'; nullable: false; default: { - kind: 'function'; + kind: 'expression'; expression: "nextval('user_id_seq'::regclass)"; }; }; @@ -27,7 +27,7 @@ type CreateInputContract = Contract< codecId: 'pg/text@1'; nullable: false; default: { - kind: 'function'; + kind: 'expression'; expression: 'now()'; }; }; diff --git a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts index 0747b7be25..4861e7727b 100644 --- a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts +++ b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; diff --git a/packages/3-extensions/sql-orm-client/test/test-codec.ts b/packages/3-extensions/sql-orm-client/test/test-codec.ts index 3b8e5c00af..f8a63bb0e1 100644 --- a/packages/3-extensions/sql-orm-client/test/test-codec.ts +++ b/packages/3-extensions/sql-orm-client/test/test-codec.ts @@ -26,6 +26,7 @@ export function defineTestCodec< targetTypes?: readonly string[]; encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + renderSqlLiteral?: (value: TInput) => string; traits?: TTraits; } & JsonRoundTripConfig, ): Codec { @@ -36,6 +37,13 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const renderSqlLiteral = + config.renderSqlLiteral ?? + ((_value: TInput): string => { + throw new Error( + `defineTestCodec(${config.typeId}): renderSqlLiteral is not configured. Tests that exercise SQL literal rendering must supply a renderSqlLiteral implementation.`, + ); + }); return { id: config.typeId, encode: (value, ctx) => { @@ -54,5 +62,6 @@ export function defineTestCodec< }, encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + renderSqlLiteral, } as Codec; } diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts index 34bb22285f..8c848c1aa5 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts @@ -7,6 +7,31 @@ */ import type { JsonValue } from '@prisma-next/contract/types'; +import type { AnyCodecDescriptor } from '@prisma-next/framework-components/codec'; + +/** + * Escape a string value for embedding in a Postgres single-quoted SQL literal. + * + * Doubles embedded single quotes (`O'Brien` -> `O''Brien`). Rejects embedded NULL bytes — Postgres truncates UTF-8 strings at the first `\0` byte, so silently passing one through would yield a corrupted literal. Backslashes pass through as literal characters because the runtime assumes `standard_conforming_strings = on` (Postgres default since 9.1). + * + * Returns the inner content without the surrounding quotes; callers concatenate them. + */ +export function escapePgLiteralBody(value: string): string { + if (value.includes('\0')) { + throw new Error('Postgres literal cannot contain NULL bytes'); + } + return value.replace(/'/g, "''"); +} + +/** + * Read the Postgres-native type name (e.g. `'integer'`, `'jsonb'`, `'timestamp with time zone'`) recorded on a codec descriptor's `meta` slot. Returns `undefined` if the descriptor carries no Postgres native-type meta — callers that need a cast then fall back to emitting a bare quoted literal and rely on Postgres's column-context inference. + */ +export function readPgNativeType(descriptor: AnyCodecDescriptor): string | undefined { + const meta = descriptor.meta as + | { readonly db?: { readonly sql?: { readonly postgres?: { readonly nativeType?: string } } } } + | undefined; + return meta?.db?.sql?.postgres?.nativeType; +} export function renderLength( typeName: string, diff --git a/packages/3-targets/3-targets/postgres/src/core/codecs.ts b/packages/3-targets/3-targets/postgres/src/core/codecs.ts index 6fda34d78d..20f9f395b0 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codecs.ts @@ -3,7 +3,7 @@ * * Each codec ships as three artifacts: * - * 1. A `PgXCodec` class extending {@link CodecImpl} that wraps the module-level encode/decode/encodeJson/decodeJson constants exported from `codec-helpers.ts` (the single source of truth for non-trivial runtime conversions; trivial identity passthroughs are inlined). 2. A `PgXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, params schema, meta, and (where applicable) + * 1. A `PgXCodec` class extending {@link SqlCodecImpl} that wraps the module-level encode/decode/encodeJson/decodeJson constants exported from `codec-helpers.ts` (the single source of truth for non-trivial runtime conversions; trivial identity passthroughs are inlined). 2. A `PgXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, params schema, meta, and (where applicable) * the emit-path `renderOutputType`. 3. A per-codec column helper (`pgXColumn`) that calls `descriptor.factory(...)` directly and packages the result into a {@link ColumnSpec} via the framework {@link column} packager. The helper is tied to its descriptor with `satisfies ColumnHelperFor` (and `ColumnHelperForStrict` where the resolved codec type is well-defined). * * After TML-2357 this is the canonical source of Postgres codec metadata and runtime behaviour — the legacy `mkCodec` / `defineCodec` carriers (and the parallel `byScalar`/`codecDescriptorDefinitions`/ `codecDescriptorList` collection exports) retired with the deletion sweep. @@ -17,7 +17,6 @@ import { type AnyCodecDescriptor, type CodecCallContext, CodecDescriptorImpl, - CodecImpl, type CodecInstanceContext, type ColumnHelperFor, type ColumnHelperForStrict, @@ -26,6 +25,7 @@ import { } from '@prisma-next/framework-components/codec'; import { SqlCharCodec, + SqlCodecImpl, SqlFloatCodec, SqlIntCodec, SqlVarcharCodec, @@ -39,6 +39,7 @@ import { import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type as arktype } from 'arktype'; import { + escapePgLiteralBody, pgEnumRenderOutputType, pgIntervalDecode, pgJsonbDecode, @@ -51,6 +52,7 @@ import { pgTimestampEncodeJson, pgTimestamptzDecodeJson, pgTimestamptzEncodeJson, + readPgNativeType, renderLength, renderPrecision, } from './codec-helpers'; @@ -98,6 +100,15 @@ const precisionParamsSchema = arktype({ 'precision?': 'number.integer >= 0 & number.integer <= 6', }) satisfies StandardSchemaV1; +/** + * Render a string value as a Postgres SQL literal with a `::` cast read from the codec descriptor's meta. The cast pins the column-type at the literal so the DDL `DEFAULT ()` clause is unambiguous regardless of the column's textual context. + */ +function renderPgQuotedLiteral(value: string, descriptor: AnyCodecDescriptor): string { + const nativeType = readPgNativeType(descriptor); + const quoted = `'${escapePgLiteralBody(value)}'`; + return nativeType ? `${quoted}::${nativeType}` : quoted; +} + const PG_TEXT_META = { db: { sql: { postgres: { nativeType: 'text' } } } } as const; const PG_INT4_META = { db: { sql: { postgres: { nativeType: 'integer' } } } } as const; const PG_INT2_META = { db: { sql: { postgres: { nativeType: 'smallint' } } } } as const; @@ -121,7 +132,7 @@ const PG_INTERVAL_META = { db: { sql: { postgres: { nativeType: 'interval' } } } const PG_JSON_META = { db: { sql: { postgres: { nativeType: 'json' } } } } as const; const PG_JSONB_META = { db: { sql: { postgres: { nativeType: 'jsonb' } } } } as const; -export class PgTextCodec extends CodecImpl< +export class PgTextCodec extends SqlCodecImpl< typeof PG_TEXT_CODEC_ID, readonly ['equality', 'order', 'textual'], string, @@ -139,6 +150,9 @@ export class PgTextCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return renderPgQuotedLiteral(value, this.descriptor); + } } export class PgTextDescriptor extends CodecDescriptorImpl { @@ -155,14 +169,20 @@ export class PgTextDescriptor extends CodecDescriptorImpl { export const pgTextDescriptor = new PgTextDescriptor(); export const pgTextColumn = () => - column(pgTextDescriptor.factory(), pgTextDescriptor.codecId, undefined, 'text'); + column( + pgTextDescriptor.factory(), + pgTextDescriptor.codecId, + undefined, + 'text', + pgTextDescriptor.traits, + ); pgTextColumn satisfies ColumnHelperFor; pgTextColumn satisfies ColumnHelperForStrict; -export class PgInt4Codec extends CodecImpl< +export class PgInt4Codec extends SqlCodecImpl< typeof PG_INT4_CODEC_ID, - readonly ['equality', 'order', 'numeric'], + readonly ['equality', 'order', 'numeric', 'autoincrement'], number, number > { @@ -178,11 +198,14 @@ export class PgInt4Codec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class PgInt4Descriptor extends CodecDescriptorImpl { override readonly codecId = PG_INT4_CODEC_ID; - override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly traits = ['equality', 'order', 'numeric', 'autoincrement'] as const; override readonly targetTypes = ['int4'] as const; override readonly meta = PG_INT4_META; override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; @@ -194,14 +217,20 @@ export class PgInt4Descriptor extends CodecDescriptorImpl { export const pgInt4Descriptor = new PgInt4Descriptor(); export const pgInt4Column = () => - column(pgInt4Descriptor.factory(), pgInt4Descriptor.codecId, undefined, 'int4'); + column( + pgInt4Descriptor.factory(), + pgInt4Descriptor.codecId, + undefined, + 'int4', + pgInt4Descriptor.traits, + ); pgInt4Column satisfies ColumnHelperFor; pgInt4Column satisfies ColumnHelperForStrict; -export class PgInt2Codec extends CodecImpl< +export class PgInt2Codec extends SqlCodecImpl< typeof PG_INT2_CODEC_ID, - readonly ['equality', 'order', 'numeric'], + readonly ['equality', 'order', 'numeric', 'autoincrement'], number, number > { @@ -217,11 +246,14 @@ export class PgInt2Codec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class PgInt2Descriptor extends CodecDescriptorImpl { override readonly codecId = PG_INT2_CODEC_ID; - override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly traits = ['equality', 'order', 'numeric', 'autoincrement'] as const; override readonly targetTypes = ['int2'] as const; override readonly meta = PG_INT2_META; override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; @@ -233,14 +265,20 @@ export class PgInt2Descriptor extends CodecDescriptorImpl { export const pgInt2Descriptor = new PgInt2Descriptor(); export const pgInt2Column = () => - column(pgInt2Descriptor.factory(), pgInt2Descriptor.codecId, undefined, 'int2'); + column( + pgInt2Descriptor.factory(), + pgInt2Descriptor.codecId, + undefined, + 'int2', + pgInt2Descriptor.traits, + ); pgInt2Column satisfies ColumnHelperFor; pgInt2Column satisfies ColumnHelperForStrict; -export class PgInt8Codec extends CodecImpl< +export class PgInt8Codec extends SqlCodecImpl< typeof PG_INT8_CODEC_ID, - readonly ['equality', 'order', 'numeric'], + readonly ['equality', 'order', 'numeric', 'autoincrement'], number, number > { @@ -256,11 +294,14 @@ export class PgInt8Codec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class PgInt8Descriptor extends CodecDescriptorImpl { override readonly codecId = PG_INT8_CODEC_ID; - override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly traits = ['equality', 'order', 'numeric', 'autoincrement'] as const; override readonly targetTypes = ['int8'] as const; override readonly meta = PG_INT8_META; override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; @@ -272,12 +313,18 @@ export class PgInt8Descriptor extends CodecDescriptorImpl { export const pgInt8Descriptor = new PgInt8Descriptor(); export const pgInt8Column = () => - column(pgInt8Descriptor.factory(), pgInt8Descriptor.codecId, undefined, 'int8'); + column( + pgInt8Descriptor.factory(), + pgInt8Descriptor.codecId, + undefined, + 'int8', + pgInt8Descriptor.traits, + ); pgInt8Column satisfies ColumnHelperFor; pgInt8Column satisfies ColumnHelperForStrict; -export class PgFloat4Codec extends CodecImpl< +export class PgFloat4Codec extends SqlCodecImpl< typeof PG_FLOAT4_CODEC_ID, readonly ['equality', 'order', 'numeric'], number, @@ -295,6 +342,9 @@ export class PgFloat4Codec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class PgFloat4Descriptor extends CodecDescriptorImpl { @@ -311,12 +361,18 @@ export class PgFloat4Descriptor extends CodecDescriptorImpl { export const pgFloat4Descriptor = new PgFloat4Descriptor(); export const pgFloat4Column = () => - column(pgFloat4Descriptor.factory(), pgFloat4Descriptor.codecId, undefined, 'float4'); + column( + pgFloat4Descriptor.factory(), + pgFloat4Descriptor.codecId, + undefined, + 'float4', + pgFloat4Descriptor.traits, + ); pgFloat4Column satisfies ColumnHelperFor; pgFloat4Column satisfies ColumnHelperForStrict; -export class PgFloat8Codec extends CodecImpl< +export class PgFloat8Codec extends SqlCodecImpl< typeof PG_FLOAT8_CODEC_ID, readonly ['equality', 'order', 'numeric'], number, @@ -334,6 +390,9 @@ export class PgFloat8Codec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class PgFloat8Descriptor extends CodecDescriptorImpl { @@ -350,12 +409,18 @@ export class PgFloat8Descriptor extends CodecDescriptorImpl { export const pgFloat8Descriptor = new PgFloat8Descriptor(); export const pgFloat8Column = () => - column(pgFloat8Descriptor.factory(), pgFloat8Descriptor.codecId, undefined, 'float8'); + column( + pgFloat8Descriptor.factory(), + pgFloat8Descriptor.codecId, + undefined, + 'float8', + pgFloat8Descriptor.traits, + ); pgFloat8Column satisfies ColumnHelperFor; pgFloat8Column satisfies ColumnHelperForStrict; -export class PgBoolCodec extends CodecImpl< +export class PgBoolCodec extends SqlCodecImpl< typeof PG_BOOL_CODEC_ID, readonly ['equality', 'boolean'], boolean, @@ -373,6 +438,9 @@ export class PgBoolCodec extends CodecImpl< decodeJson(json: JsonValue): boolean { return json as boolean; } + renderSqlLiteral(value: boolean): string { + return value ? 'TRUE' : 'FALSE'; + } } export class PgBoolDescriptor extends CodecDescriptorImpl { @@ -389,12 +457,18 @@ export class PgBoolDescriptor extends CodecDescriptorImpl { export const pgBoolDescriptor = new PgBoolDescriptor(); export const pgBoolColumn = () => - column(pgBoolDescriptor.factory(), pgBoolDescriptor.codecId, undefined, 'bool'); + column( + pgBoolDescriptor.factory(), + pgBoolDescriptor.codecId, + undefined, + 'bool', + pgBoolDescriptor.traits, + ); pgBoolColumn satisfies ColumnHelperFor; pgBoolColumn satisfies ColumnHelperForStrict; -export class PgNumericCodec extends CodecImpl< +export class PgNumericCodec extends SqlCodecImpl< typeof PG_NUMERIC_CODEC_ID, readonly ['equality', 'order', 'numeric'], string | number, @@ -412,6 +486,9 @@ export class PgNumericCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return renderPgQuotedLiteral(value, this.descriptor); + } } export class PgNumericDescriptor extends CodecDescriptorImpl { @@ -431,12 +508,18 @@ export class PgNumericDescriptor extends CodecDescriptorImpl { export const pgNumericDescriptor = new PgNumericDescriptor(); export const pgNumericColumn = (params: NumericParams) => - column(pgNumericDescriptor.factory(params), pgNumericDescriptor.codecId, params, 'numeric'); + column( + pgNumericDescriptor.factory(params), + pgNumericDescriptor.codecId, + params, + 'numeric', + pgNumericDescriptor.traits, + ); pgNumericColumn satisfies ColumnHelperFor; pgNumericColumn satisfies ColumnHelperForStrict; -export class PgTimestampCodec extends CodecImpl< +export class PgTimestampCodec extends SqlCodecImpl< typeof PG_TIMESTAMP_CODEC_ID, readonly ['equality', 'order'], Date, @@ -454,6 +537,9 @@ export class PgTimestampCodec extends CodecImpl< decodeJson(json: JsonValue): Date { return pgTimestampDecodeJson(json); } + renderSqlLiteral(value: Date): string { + return renderPgQuotedLiteral(value.toISOString(), this.descriptor); + } } export class PgTimestampDescriptor extends CodecDescriptorImpl { @@ -474,12 +560,18 @@ export class PgTimestampDescriptor extends CodecDescriptorImpl export const pgTimestampDescriptor = new PgTimestampDescriptor(); export const pgTimestampColumn = (params: PrecisionParams = {}) => - column(pgTimestampDescriptor.factory(params), pgTimestampDescriptor.codecId, params, 'timestamp'); + column( + pgTimestampDescriptor.factory(params), + pgTimestampDescriptor.codecId, + params, + 'timestamp', + pgTimestampDescriptor.traits, + ); pgTimestampColumn satisfies ColumnHelperFor; pgTimestampColumn satisfies ColumnHelperForStrict; -export class PgTimestamptzCodec extends CodecImpl< +export class PgTimestamptzCodec extends SqlCodecImpl< typeof PG_TIMESTAMPTZ_CODEC_ID, readonly ['equality', 'order'], Date, @@ -497,6 +589,9 @@ export class PgTimestamptzCodec extends CodecImpl< decodeJson(json: JsonValue): Date { return pgTimestamptzDecodeJson(json); } + renderSqlLiteral(value: Date): string { + return renderPgQuotedLiteral(value.toISOString(), this.descriptor); + } } export class PgTimestamptzDescriptor extends CodecDescriptorImpl { @@ -522,12 +617,13 @@ export const pgTimestamptzColumn = (params: PrecisionParams = {}) => pgTimestamptzDescriptor.codecId, params, 'timestamptz', + pgTimestamptzDescriptor.traits, ); pgTimestamptzColumn satisfies ColumnHelperFor; pgTimestamptzColumn satisfies ColumnHelperForStrict; -export class PgTimeCodec extends CodecImpl< +export class PgTimeCodec extends SqlCodecImpl< typeof PG_TIME_CODEC_ID, readonly ['equality', 'order'], string, @@ -545,6 +641,9 @@ export class PgTimeCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return renderPgQuotedLiteral(value, this.descriptor); + } } export class PgTimeDescriptor extends CodecDescriptorImpl { @@ -565,12 +664,18 @@ export class PgTimeDescriptor extends CodecDescriptorImpl { export const pgTimeDescriptor = new PgTimeDescriptor(); export const pgTimeColumn = (params: PrecisionParams = {}) => - column(pgTimeDescriptor.factory(params), pgTimeDescriptor.codecId, params, 'time'); + column( + pgTimeDescriptor.factory(params), + pgTimeDescriptor.codecId, + params, + 'time', + pgTimeDescriptor.traits, + ); pgTimeColumn satisfies ColumnHelperFor; pgTimeColumn satisfies ColumnHelperForStrict; -export class PgTimetzCodec extends CodecImpl< +export class PgTimetzCodec extends SqlCodecImpl< typeof PG_TIMETZ_CODEC_ID, readonly ['equality', 'order'], string, @@ -588,6 +693,9 @@ export class PgTimetzCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return renderPgQuotedLiteral(value, this.descriptor); + } } export class PgTimetzDescriptor extends CodecDescriptorImpl { @@ -608,12 +716,18 @@ export class PgTimetzDescriptor extends CodecDescriptorImpl { export const pgTimetzDescriptor = new PgTimetzDescriptor(); export const pgTimetzColumn = (params: PrecisionParams = {}) => - column(pgTimetzDescriptor.factory(params), pgTimetzDescriptor.codecId, params, 'timetz'); + column( + pgTimetzDescriptor.factory(params), + pgTimetzDescriptor.codecId, + params, + 'timetz', + pgTimetzDescriptor.traits, + ); pgTimetzColumn satisfies ColumnHelperFor; pgTimetzColumn satisfies ColumnHelperForStrict; -export class PgBitCodec extends CodecImpl< +export class PgBitCodec extends SqlCodecImpl< typeof PG_BIT_CODEC_ID, readonly ['equality', 'order'], string, @@ -631,6 +745,9 @@ export class PgBitCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return `B'${escapePgLiteralBody(value)}'`; + } } export class PgBitDescriptor extends CodecDescriptorImpl { @@ -650,12 +767,18 @@ export class PgBitDescriptor extends CodecDescriptorImpl { export const pgBitDescriptor = new PgBitDescriptor(); export const pgBitColumn = (params: LengthParams = {}) => - column(pgBitDescriptor.factory(params), pgBitDescriptor.codecId, params, 'bit'); + column( + pgBitDescriptor.factory(params), + pgBitDescriptor.codecId, + params, + 'bit', + pgBitDescriptor.traits, + ); pgBitColumn satisfies ColumnHelperFor; pgBitColumn satisfies ColumnHelperForStrict; -export class PgVarbitCodec extends CodecImpl< +export class PgVarbitCodec extends SqlCodecImpl< typeof PG_VARBIT_CODEC_ID, readonly ['equality', 'order'], string, @@ -673,6 +796,9 @@ export class PgVarbitCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return `B'${escapePgLiteralBody(value)}'`; + } } export class PgVarbitDescriptor extends CodecDescriptorImpl { @@ -692,12 +818,18 @@ export class PgVarbitDescriptor extends CodecDescriptorImpl { export const pgVarbitDescriptor = new PgVarbitDescriptor(); export const pgVarbitColumn = (params: LengthParams = {}) => - column(pgVarbitDescriptor.factory(params), pgVarbitDescriptor.codecId, params, 'bit varying'); + column( + pgVarbitDescriptor.factory(params), + pgVarbitDescriptor.codecId, + params, + 'bit varying', + pgVarbitDescriptor.traits, + ); pgVarbitColumn satisfies ColumnHelperFor; pgVarbitColumn satisfies ColumnHelperForStrict; -export class PgByteaCodec extends CodecImpl< +export class PgByteaCodec extends SqlCodecImpl< typeof PG_BYTEA_CODEC_ID, readonly ['equality'], Uint8Array, @@ -725,6 +857,9 @@ export class PgByteaCodec extends CodecImpl< } return new Uint8Array(decoded); } + renderSqlLiteral(value: Uint8Array): string { + return renderPgQuotedLiteral(`\\x${Buffer.from(value).toString('hex')}`, this.descriptor); + } } export class PgByteaDescriptor extends CodecDescriptorImpl { @@ -741,12 +876,18 @@ export class PgByteaDescriptor extends CodecDescriptorImpl { export const pgByteaDescriptor = new PgByteaDescriptor(); export const pgByteaColumn = () => - column(pgByteaDescriptor.factory(), pgByteaDescriptor.codecId, undefined, 'bytea'); + column( + pgByteaDescriptor.factory(), + pgByteaDescriptor.codecId, + undefined, + 'bytea', + pgByteaDescriptor.traits, + ); pgByteaColumn satisfies ColumnHelperFor; pgByteaColumn satisfies ColumnHelperForStrict; -export class PgIntervalCodec extends CodecImpl< +export class PgIntervalCodec extends SqlCodecImpl< typeof PG_INTERVAL_CODEC_ID, readonly ['equality', 'order'], string | Record, @@ -764,6 +905,9 @@ export class PgIntervalCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return renderPgQuotedLiteral(value, this.descriptor); + } } export class PgIntervalDescriptor extends CodecDescriptorImpl { @@ -784,7 +928,13 @@ export class PgIntervalDescriptor extends CodecDescriptorImpl { export const pgIntervalDescriptor = new PgIntervalDescriptor(); export const pgIntervalColumn = (params: PrecisionParams = {}) => - column(pgIntervalDescriptor.factory(params), pgIntervalDescriptor.codecId, params, 'interval'); + column( + pgIntervalDescriptor.factory(params), + pgIntervalDescriptor.codecId, + params, + 'interval', + pgIntervalDescriptor.traits, + ); pgIntervalColumn satisfies ColumnHelperFor; pgIntervalColumn satisfies ColumnHelperForStrict; @@ -793,7 +943,7 @@ const enumParamsSchema = arktype({ 'values?': 'string[]', }); -export class PgEnumCodec extends CodecImpl< +export class PgEnumCodec extends SqlCodecImpl< typeof PG_ENUM_CODEC_ID, readonly ['equality', 'order'], string, @@ -811,6 +961,10 @@ export class PgEnumCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + // `pg/enum@1` is the only Postgres codec without a static `nativeType` on its descriptor — the enum type name is per-enum and lives on the column declaration, not on the codec id. Emit a bare quoted literal and rely on Postgres's column-context cast in DDL. + return `'${escapePgLiteralBody(value)}'`; + } } export class PgEnumDescriptor extends CodecDescriptorImpl { @@ -829,12 +983,18 @@ export class PgEnumDescriptor extends CodecDescriptorImpl { export const pgEnumDescriptor = new PgEnumDescriptor(); export const pgEnumColumn = (params: EnumParams = {}) => - column(pgEnumDescriptor.factory(params), pgEnumDescriptor.codecId, params, 'enum'); + column( + pgEnumDescriptor.factory(params), + pgEnumDescriptor.codecId, + params, + 'enum', + pgEnumDescriptor.traits, + ); pgEnumColumn satisfies ColumnHelperFor; pgEnumColumn satisfies ColumnHelperForStrict; -export class PgJsonCodec extends CodecImpl< +export class PgJsonCodec extends SqlCodecImpl< typeof PG_JSON_CODEC_ID, readonly [], string | JsonValue, @@ -852,6 +1012,9 @@ export class PgJsonCodec extends CodecImpl< decodeJson(json: JsonValue): JsonValue { return json; } + renderSqlLiteral(value: JsonValue): string { + return renderPgQuotedLiteral(JSON.stringify(value), this.descriptor); + } } export class PgJsonDescriptor extends CodecDescriptorImpl { @@ -868,12 +1031,18 @@ export class PgJsonDescriptor extends CodecDescriptorImpl { export const pgJsonDescriptor = new PgJsonDescriptor(); export const pgJsonColumn = () => - column(pgJsonDescriptor.factory(), pgJsonDescriptor.codecId, undefined, 'json'); + column( + pgJsonDescriptor.factory(), + pgJsonDescriptor.codecId, + undefined, + 'json', + pgJsonDescriptor.traits, + ); pgJsonColumn satisfies ColumnHelperFor; pgJsonColumn satisfies ColumnHelperForStrict; -export class PgJsonbCodec extends CodecImpl< +export class PgJsonbCodec extends SqlCodecImpl< typeof PG_JSONB_CODEC_ID, readonly ['equality'], string | JsonValue, @@ -891,6 +1060,9 @@ export class PgJsonbCodec extends CodecImpl< decodeJson(json: JsonValue): JsonValue { return json; } + renderSqlLiteral(value: JsonValue): string { + return renderPgQuotedLiteral(JSON.stringify(value), this.descriptor); + } } export class PgJsonbDescriptor extends CodecDescriptorImpl { @@ -907,12 +1079,18 @@ export class PgJsonbDescriptor extends CodecDescriptorImpl { export const pgJsonbDescriptor = new PgJsonbDescriptor(); export const pgJsonbColumn = () => - column(pgJsonbDescriptor.factory(), pgJsonbDescriptor.codecId, undefined, 'jsonb'); + column( + pgJsonbDescriptor.factory(), + pgJsonbDescriptor.codecId, + undefined, + 'jsonb', + pgJsonbDescriptor.traits, + ); pgJsonbColumn satisfies ColumnHelperFor; pgJsonbColumn satisfies ColumnHelperForStrict; -// `meta`. The factories instantiate the SQL-base codec class (`SqlCharCodec` etc.) passing `this` (the pg-alias descriptor) so `codec.id` resolves to the pg-alias codec id via `CodecImpl`'s `descriptor.codecId` proxy. --------------------------------------------------------------------------- +// `meta`. The factories instantiate the SQL-base codec class (`SqlCharCodec` etc.) passing `this` (the pg-alias descriptor) so `codec.id` resolves to the pg-alias codec id via `SqlCodecImpl`'s `descriptor.codecId` proxy. --------------------------------------------------------------------------- const PG_CHAR_META = { db: { sql: { postgres: { nativeType: 'character' } } } } as const; const PG_VARCHAR_META = { @@ -938,7 +1116,13 @@ export class PgCharDescriptor extends CodecDescriptorImpl { export const pgCharDescriptor = new PgCharDescriptor(); export const pgCharColumn = (params: LengthParams = {}) => - column(pgCharDescriptor.factory(params), pgCharDescriptor.codecId, params, 'character'); + column( + pgCharDescriptor.factory(params), + pgCharDescriptor.codecId, + params, + 'character', + pgCharDescriptor.traits, + ); pgCharColumn satisfies ColumnHelperFor; @@ -964,6 +1148,7 @@ export const pgVarcharColumn = (params: LengthParams = {}) => pgVarcharDescriptor.codecId, params, 'character varying', + pgVarcharDescriptor.traits, ); pgVarcharColumn satisfies ColumnHelperFor; @@ -982,7 +1167,13 @@ export class PgIntDescriptor extends CodecDescriptorImpl { export const pgIntDescriptor = new PgIntDescriptor(); export const pgIntColumn = () => - column(pgIntDescriptor.factory(), pgIntDescriptor.codecId, undefined, 'int4'); + column( + pgIntDescriptor.factory(), + pgIntDescriptor.codecId, + undefined, + 'int4', + pgIntDescriptor.traits, + ); pgIntColumn satisfies ColumnHelperFor; @@ -1000,7 +1191,13 @@ export class PgFloatDescriptor extends CodecDescriptorImpl { export const pgFloatDescriptor = new PgFloatDescriptor(); export const pgFloatColumn = () => - column(pgFloatDescriptor.factory(), pgFloatDescriptor.codecId, undefined, 'float8'); + column( + pgFloatDescriptor.factory(), + pgFloatDescriptor.codecId, + undefined, + 'float8', + pgFloatDescriptor.traits, + ); pgFloatColumn satisfies ColumnHelperFor; diff --git a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts index 29634cf672..3ae30eaee7 100644 --- a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts +++ b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts @@ -1,4 +1,5 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; +import type { JsonValue } from '@prisma-next/contract/types'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; /** * Pre-compiled regex patterns for performance. @@ -12,11 +13,6 @@ const TEXT_CAST_SUFFIX = /::text$/i; const NOW_LITERAL_PATTERN = /^'now'$/i; const UUID_PATTERN = /^gen_random_uuid\s*\(\s*\)$/i; const UUID_OSSP_PATTERN = /^uuid_generate_v4\s*\(\s*\)$/i; -const NULL_PATTERN = /^NULL(?:::.+)?$/i; -const TRUE_PATTERN = /^true$/i; -const FALSE_PATTERN = /^false$/i; -const NUMERIC_PATTERN = /^-?\d+(\.\d+)?$/; -const STRING_LITERAL_PATTERN = /^'((?:[^']|'')*)'(?:::(?:"[^"]+"|[\w\s]+)(?:\(\d+\))?)?$/; /** * Returns the canonical expression for a timestamp default function, or undefined @@ -64,68 +60,147 @@ function canonicalizeTimestampDefault(expr: string): string | undefined { */ export function parsePostgresDefault( rawDefault: string, - nativeType?: string, + _nativeType?: string, ): ColumnDefault | undefined { const trimmed = rawDefault.trim(); - const normalizedType = nativeType?.toLowerCase(); - const isBigInt = normalizedType === 'bigint' || normalizedType === 'int8'; if (NEXTVAL_PATTERN.test(trimmed)) { - return { kind: 'function', expression: 'autoincrement()' }; + return { kind: 'autoincrement' }; } const canonicalTimestamp = canonicalizeTimestampDefault(trimmed); if (canonicalTimestamp) { - return { kind: 'function', expression: canonicalTimestamp }; + return { kind: 'expression', expression: canonicalTimestamp }; } if (UUID_PATTERN.test(trimmed)) { - return { kind: 'function', expression: 'gen_random_uuid()' }; + return { kind: 'expression', expression: 'gen_random_uuid()' }; } if (UUID_OSSP_PATTERN.test(trimmed)) { - return { kind: 'function', expression: 'gen_random_uuid()' }; + return { kind: 'expression', expression: 'gen_random_uuid()' }; } - if (NULL_PATTERN.test(trimmed)) { - return { kind: 'literal', value: null }; - } + return { kind: 'expression', expression: trimmed }; +} + +/** + * Matches an outer `::` cast suffix (possibly quoted, possibly with + * length / precision parameters). Used by {@link parsePostgresDefaultValue} + * to strip the column-type cast before unquoting / number-parsing. + */ +const CAST_SUFFIX = /\s*::\s*(?:"[^"]+"|[\w\s]+)(?:\(\d+(?:,\d+)?\))?$/; - if (TRUE_PATTERN.test(trimmed)) { - return { kind: 'literal', value: true }; +/** + * Returns the SQL literal value with its outer `::` cast stripped. + * Handles quoted enum/type names (`::"BillingState"`) and parameterised + * types (`::numeric(10,2)`). + */ +function stripOuterCast(s: string): string { + return s.replace(CAST_SUFFIX, ''); +} + +/** + * Extracts the codec-comparable {@link JsonValue} out of a raw Postgres + * column default expression (the value `pg_get_expr` returns). + * + * The verifier round-trips this {@link JsonValue} through the column's + * codec (`codec.decodeJson(...)` → `codec.renderSqlLiteral(...)`) and + * compares the result against the contract-side expression. The + * comparison is therefore codec-canonical: two textually different + * Postgres-canonical forms collapse to one contract-canonical form when + * they decode to the same typed value. + * + * Returns `undefined` for non-literal forms (function calls like `now()`, + * `nextval(...)`, `gen_random_uuid()`); the verifier falls back to the + * legacy normalizer-based string compare for those. + * + * Recognised literal forms: + * + * - Quoted strings (`'foo'`, `'it''s'`) with optional `::type` cast → + * the unquoted string. + * - Bare numerics (`9007199254740991`, `3.14`) and quoted numerics + * (`'9007199254740991'::bigint`) on a numeric `nativeType` → the + * parsed number. + * - Boolean literals (`true`, `false`, case-insensitive) → the boolean. + * - Timestamp-typed literals: the inner string is parsed via `new Date` + * and emitted in canonical ISO-8601 UTC form so the codec's strict + * `decodeJson` accepts it. Both Postgres-canonical + * `'2024-01-15 10:30:00+00'` and ISO-T forms collapse to the same JS + * `Date`. + * - JSON / JSONB literals (`'{"key":"value"}'::jsonb`) → the parsed + * `JsonValue`. + * + * Adversarial inputs are handled conservatively: malformed JSON returns + * `undefined`, invalid dates return `undefined`, etc. The verifier's + * fallback path picks them up. + */ +export function parsePostgresDefaultValue( + rawDefault: string, + nativeType: string, +): JsonValue | undefined { + const trimmed = rawDefault.trim(); + + // Non-literal forms — short-circuit so the verifier falls back to the + // normalizer path (which detects autoincrement / timestamp functions). + if ( + NEXTVAL_PATTERN.test(trimmed) || + NOW_FUNCTION_PATTERN.test(trimmed) || + CLOCK_TIMESTAMP_PATTERN.test(trimmed) || + UUID_PATTERN.test(trimmed) || + UUID_OSSP_PATTERN.test(trimmed) || + canonicalizeTimestampDefault(trimmed) !== undefined + ) { + return undefined; } - if (FALSE_PATTERN.test(trimmed)) { - return { kind: 'literal', value: false }; + + const inner = stripOuterCast(trimmed); + + // Timestamp-typed: parse via `new Date` and emit ISO-8601 UTC so the + // codec's strict `decodeJson` accepts the value. Both + // `'2024-01-15 10:30:00+00'` and `'2024-01-15T10:30:00.000Z'` collapse + // here. + if (/timestamp|date|time/i.test(nativeType)) { + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + const candidate = stringMatch?.[1]?.replace(/''/g, "'") ?? inner; + const date = new Date(candidate); + if (!Number.isNaN(date.getTime())) { + return date.toISOString(); + } } - if (NUMERIC_PATTERN.test(trimmed)) { - const num = Number(trimmed); - if (!Number.isFinite(num)) return undefined; - if (isBigInt && !Number.isSafeInteger(num)) { - return { kind: 'literal', value: trimmed }; + // Boolean literals + if (/^true$/i.test(inner)) return true; + if (/^false$/i.test(inner)) return false; + + // Numerics — bare or quoted-with-cast — on a numeric nativeType. + if (/^(?:int|bigint|smallint|numeric|decimal|float|real|double|serial)/i.test(nativeType)) { + const numericMatch = inner.match(/^'?(-?\d+(?:\.\d+)?)'?$/); + if (numericMatch?.[1] !== undefined) { + const n = Number(numericMatch[1]); + if (Number.isFinite(n)) return n; } - return { kind: 'literal', value: num }; } - const stringMatch = trimmed.match(STRING_LITERAL_PATTERN); - if (stringMatch?.[1] !== undefined) { - const unescaped = stringMatch[1].replace(/''/g, "'"); - if (normalizedType === 'json' || normalizedType === 'jsonb') { + // JSON / JSONB literals — parse the inner quoted body + if (/json/i.test(nativeType)) { + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + if (stringMatch?.[1] !== undefined) { try { - return { kind: 'literal', value: JSON.parse(unescaped) }; + return JSON.parse(stringMatch[1].replace(/''/g, "'")); } catch { - // Keep legacy behavior for malformed/non-JSON string content. - } - } - if (isBigInt && NUMERIC_PATTERN.test(unescaped)) { - const num = Number(unescaped); - if (Number.isSafeInteger(num)) { - return { kind: 'literal', value: num }; + return undefined; } - return { kind: 'literal', value: unescaped }; } - return { kind: 'literal', value: unescaped }; } - return { kind: 'function', expression: trimmed }; + // Quoted strings — strip outer quotes, unescape doubled single quotes. + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + if (stringMatch?.[1] !== undefined) { + return stringMatch[1].replace(/''/g, "'"); + } + + // No recognised literal shape — let the verifier fall back to the + // legacy normalizer path. + return undefined; } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 9a300211c4..6bfa240c28 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts @@ -191,7 +191,7 @@ function toColumnSpec( codecHooks as Map, storageTypes as Record, ), - defaultSql: buildColumnDefaultSql(column.default, column), + defaultSql: buildColumnDefaultSql(column.default), nullable: column.nullable, }; } @@ -337,7 +337,7 @@ function mapIssueToCall( ), ); } - const defaultSql = buildColumnDefaultSql(column.default, column); + const defaultSql = buildColumnDefaultSql(column.default); if (!defaultSql) return ok([]); return ok([new SetDefaultCall(tableSchema(issue), issue.table, issue.column, defaultSql)]); } @@ -473,7 +473,7 @@ function mapIssueToCall( issue.column ]; if (!column?.default) return ok([]); - const defaultSql = buildColumnDefaultSql(column.default, column); + const defaultSql = buildColumnDefaultSql(column.default); if (!defaultSql) return ok([]); return ok([ new SetDefaultCall(tableSchema(issue), issue.table, issue.column, defaultSql, 'widening'), diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts index 12a1b883aa..089cbd1c40 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts @@ -23,7 +23,7 @@ export function buildCreateTableSql( const parts = [ quoteIdentifier(columnName), buildColumnTypeSql(column, codecHooks, storageTypes), - buildColumnDefaultSql(column.default, column), + buildColumnDefaultSql(column.default), column.nullable ? '' : 'NOT NULL', ].filter(Boolean); return parts.join(' '); @@ -57,20 +57,6 @@ function assertSafeNativeType(nativeType: string): void { } } -/** - * Sanity check against accidental SQL injection from malformed contract files. - * Rejects semicolons, SQL comment tokens, and dollar-quoting. - * Not a comprehensive security boundary — the contract is developer-authored. - */ -function assertSafeDefaultExpression(expression: string): void { - if (expression.includes(';') || /--|\/\*|\$\$|\bSELECT\b/i.test(expression)) { - throw new Error( - `Unsafe default expression in contract: "${expression}". ` + - 'Default expressions must not contain semicolons, SQL comment tokens, dollar-quoting, or subqueries.', - ); - } -} - /** * Renders the SQL type for a column in DDL context. * @@ -88,7 +74,7 @@ export function buildColumnTypeSql( if (allowPseudoTypes) { const columnDefault = column.default; - if (columnDefault?.kind === 'function' && columnDefault.expression === 'autoincrement()') { + if (columnDefault?.kind === 'autoincrement') { if (resolved.nativeType === 'int4' || resolved.nativeType === 'integer') { return 'SERIAL'; } @@ -151,51 +137,21 @@ function expandParameterizedTypeSql( } /** Autoincrement columns use SERIAL types, so this returns empty for them. */ -export function buildColumnDefaultSql( - columnDefault: PostgresColumnDefault | undefined, - column?: StorageColumn, -): string { +export function buildColumnDefaultSql(columnDefault: PostgresColumnDefault | undefined): string { if (!columnDefault) { return ''; } switch (columnDefault.kind) { - case 'literal': - return `DEFAULT ${renderDefaultLiteral(columnDefault.value, column)}`; - case 'function': { - if (columnDefault.expression === 'autoincrement()') { - return ''; - } - assertSafeDefaultExpression(columnDefault.expression); + case 'autoincrement': + return ''; + case 'expression': return `DEFAULT (${columnDefault.expression})`; - } case 'sequence': return `DEFAULT nextval('${escapeLiteral(quoteIdentifier(columnDefault.name))}'::regclass)`; } } -export function renderDefaultLiteral(value: unknown, column?: StorageColumn): string { - const isJsonColumn = column?.nativeType === 'json' || column?.nativeType === 'jsonb'; - - if (value instanceof Date) { - return `'${escapeLiteral(value.toISOString())}'`; - } - if (typeof value === 'string') { - return `'${escapeLiteral(value)}'`; - } - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - if (value === null) { - return 'NULL'; - } - const json = JSON.stringify(value); - if (isJsonColumn) { - return `'${escapeLiteral(json)}'::${column.nativeType}`; - } - return `'${escapeLiteral(json)}'`; -} - export function buildAddColumnSql( qualifiedTableName: string, columnName: string, @@ -206,7 +162,7 @@ export function buildAddColumnSql( ): string { const typeSql = buildColumnTypeSql(column, codecHooks, storageTypes); const defaultSql = - buildColumnDefaultSql(column.default, column) || + buildColumnDefaultSql(column.default) || (temporaryDefault ? `DEFAULT ${temporaryDefault}` : ''); const parts = [ `ALTER TABLE ${qualifiedTableName}`, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts index dc9e91748e..c8258a164b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts @@ -207,7 +207,7 @@ function buildColumnSpec( return { name: column, typeSql: buildColumnTypeSql(col, mutableHooks, mutableTypes), - defaultSql: buildColumnDefaultSql(col.default, col), + defaultSql: buildColumnDefaultSql(col.default), nullable: overrides?.nullable ?? col.nullable, }; } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 9e7ab32ae6..24e3c87e5c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -17,7 +17,8 @@ import type { MigrationScaffoldContext, SchemaIssue, } from '@prisma-next/framework-components/control'; -import { parsePostgresDefault } from '../default-normalizer'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; +import { parsePostgresDefault, parsePostgresDefaultValue } from '../default-normalizer'; import { normalizeSchemaNativeType } from '../native-type-normalizer'; import { readExistingEnumValues } from './enum-planning'; import { planIssues } from './issue-planner'; @@ -218,6 +219,8 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr typeMetadataRegistry: new Map(), frameworkComponents: options.frameworkComponents, normalizeDefault: parsePostgresDefault, + parseSchemaDefaultValue: parsePostgresDefaultValue, + codecLookup: extractCodecLookup(options.frameworkComponents), normalizeNativeType: normalizeSchemaNativeType, resolveExistingEnumValues: (schema, enumType) => readExistingEnumValues(schema, enumType.nativeType), diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts index 2cdd4bcfd6..ea46a032d7 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts @@ -15,12 +15,12 @@ import type { import { runnerFailure, runnerSuccess } from '@prisma-next/family-sql/control'; import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import type { ControlDriverInstance } from '@prisma-next/framework-components/control'; -import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; +import { APP_SPACE_ID, extractCodecLookup } from '@prisma-next/framework-components/control'; import { SqlQueryError } from '@prisma-next/sql-errors'; import { ifDefined } from '@prisma-next/utils/defined'; import type { Result } from '@prisma-next/utils/result'; import { notOk, ok, okVoid } from '@prisma-next/utils/result'; -import { parsePostgresDefault } from '../default-normalizer'; +import { parsePostgresDefault, parsePostgresDefaultValue } from '../default-normalizer'; import { normalizeSchemaNativeType } from '../native-type-normalizer'; import { readExistingEnumValues } from './enum-planning'; import type { PostgresPlanTargetDetails } from './planner-target-details'; @@ -187,6 +187,8 @@ class PostgresMigrationRunner implements SqlMigrationRunner readExistingEnumValues(schema, enumType.nativeType), diff --git a/packages/3-targets/3-targets/postgres/src/core/types.ts b/packages/3-targets/3-targets/postgres/src/core/types.ts index 7357c78190..0b75e5b44f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/types.ts +++ b/packages/3-targets/3-targets/postgres/src/core/types.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; export type PostgresColumnDefault = | ColumnDefault diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index a96bd7b45c..0da368aaee 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault, Contract } from '@prisma-next/contract/types'; +import type { Contract } from '@prisma-next/contract/types'; import type { SqlControlFamilyInstance, SqlControlTargetDescriptor, @@ -9,11 +9,10 @@ import type { ControlTargetInstance, MigrationRunner, } from '@prisma-next/framework-components/control'; -import type { SqlStorage, StorageColumn } from '@prisma-next/sql-contract/types'; +import type { ColumnDefault, SqlStorage, StorageColumn } from '@prisma-next/sql-contract/types'; import { ifDefined } from '@prisma-next/utils/defined'; import { postgresTargetDescriptorMeta } from '../core/descriptor-meta'; import { createPostgresMigrationPlanner } from '../core/migrations/planner'; -import { renderDefaultLiteral } from '../core/migrations/planner-ddl-builders'; import type { PostgresPlanTargetDetails } from '../core/migrations/planner-target-details'; import { createPostgresMigrationRunner } from '../core/migrations/runner'; import { PostgresContractSerializer } from '../core/postgres-contract-serializer'; @@ -45,11 +44,13 @@ function buildNativeTypeExpander( }; } -export function postgresRenderDefault(def: ColumnDefault, column: StorageColumn): string { - if (def.kind === 'function') { - return def.expression; +export function postgresRenderDefault(def: ColumnDefault, _column: StorageColumn): string { + switch (def.kind) { + case 'autoincrement': + return 'autoincrement()'; + case 'expression': + return def.expression; } - return renderDefaultLiteral(def.value, column); } const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresPlanTargetDetails> = diff --git a/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts b/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts index 480093cfcc..481b2147ec 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts @@ -1 +1,4 @@ -export { parsePostgresDefault } from '../core/default-normalizer'; +export { + parsePostgresDefault, + parsePostgresDefaultValue, +} from '../core/default-normalizer'; diff --git a/packages/3-targets/3-targets/postgres/src/exports/planner-ddl-builders.ts b/packages/3-targets/3-targets/postgres/src/exports/planner-ddl-builders.ts index 82eddca21d..aad7070438 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/planner-ddl-builders.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/planner-ddl-builders.ts @@ -4,5 +4,4 @@ export { buildColumnTypeSql, buildCreateTableSql, buildForeignKeySql, - renderDefaultLiteral, } from '../core/migrations/planner-ddl-builders'; diff --git a/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts index 9e82d4e121..311b1a3431 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts @@ -395,7 +395,9 @@ describe('codecs-class', () => { it('exposes traits and targetTypes for each codec', () => { expect(pgTextDescriptor.traits).toEqual(['equality', 'order', 'textual']); - expect(pgInt4Descriptor.traits).toEqual(['equality', 'order', 'numeric']); + expect(pgInt2Descriptor.traits).toEqual(['equality', 'order', 'numeric', 'autoincrement']); + expect(pgInt4Descriptor.traits).toEqual(['equality', 'order', 'numeric', 'autoincrement']); + expect(pgInt8Descriptor.traits).toEqual(['equality', 'order', 'numeric', 'autoincrement']); expect(pgBoolDescriptor.traits).toEqual(['equality', 'boolean']); expect(pgJsonDescriptor.traits).toEqual([]); expect(pgJsonbDescriptor.traits).toEqual(['equality']); diff --git a/packages/3-targets/3-targets/postgres/test/codecs.render-sql-literal.test.ts b/packages/3-targets/3-targets/postgres/test/codecs.render-sql-literal.test.ts new file mode 100644 index 0000000000..675728ac49 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/codecs.render-sql-literal.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; +import { + pgBitDescriptor, + pgBoolDescriptor, + pgByteaDescriptor, + pgCharDescriptor, + pgEnumDescriptor, + pgFloat4Descriptor, + pgFloat8Descriptor, + pgInt2Descriptor, + pgInt4Descriptor, + pgInt8Descriptor, + pgIntervalDescriptor, + pgJsonbDescriptor, + pgJsonDescriptor, + pgNumericDescriptor, + pgTextDescriptor, + pgTimeDescriptor, + pgTimestampDescriptor, + pgTimestamptzDescriptor, + pgTimetzDescriptor, + pgVarbitDescriptor, + pgVarcharDescriptor, +} from '../src/core/codecs'; + +const instanceCtx = { name: '' }; + +describe('renderSqlLiteral on Postgres codecs', () => { + describe('pg/text@1', () => { + const codec = pgTextDescriptor.factory()(instanceCtx); + + it('renders ASCII strings', () => { + expect(codec.renderSqlLiteral('hello')).toBe("'hello'::text"); + }); + + it('doubles embedded single quotes', () => { + expect(codec.renderSqlLiteral("O'Brien")).toBe("'O''Brien'::text"); + }); + + it('preserves backslashes verbatim', () => { + expect(codec.renderSqlLiteral('a\\b')).toBe("'a\\b'::text"); + }); + + it('rejects NULL bytes', () => { + expect(() => codec.renderSqlLiteral('a\0b')).toThrow(); + }); + + it('passes unicode through verbatim', () => { + expect(codec.renderSqlLiteral('naïve résumé 日本語')).toBe("'naïve résumé 日本語'::text"); + }); + }); + + describe('pg/int4@1', () => { + const codec = pgInt4Descriptor.factory()(instanceCtx); + + it('renders integers as numeric literals', () => { + expect(codec.renderSqlLiteral(42)).toBe('42'); + }); + + it('renders negative integers', () => { + expect(codec.renderSqlLiteral(-7)).toBe('-7'); + }); + + it('renders zero', () => { + expect(codec.renderSqlLiteral(0)).toBe('0'); + }); + }); + + describe('pg/int2@1', () => { + const codec = pgInt2Descriptor.factory()(instanceCtx); + + it('renders integers', () => { + expect(codec.renderSqlLiteral(7)).toBe('7'); + }); + }); + + describe('pg/int8@1', () => { + const codec = pgInt8Descriptor.factory()(instanceCtx); + + it('renders integers', () => { + expect(codec.renderSqlLiteral(123456789)).toBe('123456789'); + }); + }); + + describe('pg/float4@1', () => { + const codec = pgFloat4Descriptor.factory()(instanceCtx); + + it('renders floats', () => { + expect(codec.renderSqlLiteral(3.14)).toBe('3.14'); + }); + }); + + describe('pg/float8@1', () => { + const codec = pgFloat8Descriptor.factory()(instanceCtx); + + it('renders doubles', () => { + expect(codec.renderSqlLiteral(1.234567890123)).toBe('1.234567890123'); + }); + }); + + describe('pg/bool@1', () => { + const codec = pgBoolDescriptor.factory()(instanceCtx); + + it('renders true as TRUE', () => { + expect(codec.renderSqlLiteral(true)).toBe('TRUE'); + }); + + it('renders false as FALSE', () => { + expect(codec.renderSqlLiteral(false)).toBe('FALSE'); + }); + }); + + describe('pg/numeric@1', () => { + const codec = pgNumericDescriptor.factory({ precision: 10, scale: 2 })(instanceCtx); + + it('renders decimal strings with numeric cast', () => { + expect(codec.renderSqlLiteral('3.14')).toBe("'3.14'::numeric"); + }); + + it('escapes embedded single quotes', () => { + // Defensive — numeric values shouldn't contain quotes, but the renderer escapes for safety. + expect(codec.renderSqlLiteral("12'34")).toBe("'12''34'::numeric"); + }); + }); + + describe('pg/timestamp@1', () => { + const codec = pgTimestampDescriptor.factory({})(instanceCtx); + + it('renders Date with timestamp cast', () => { + const d = new Date('2026-04-30T12:34:56.789Z'); + expect(codec.renderSqlLiteral(d)).toBe( + "'2026-04-30T12:34:56.789Z'::timestamp without time zone", + ); + }); + }); + + describe('pg/timestamptz@1', () => { + const codec = pgTimestamptzDescriptor.factory({})(instanceCtx); + + it('renders Date with timestamptz cast', () => { + const d = new Date('2026-04-30T12:34:56.789Z'); + expect(codec.renderSqlLiteral(d)).toBe( + "'2026-04-30T12:34:56.789Z'::timestamp with time zone", + ); + }); + }); + + describe('pg/time@1', () => { + const codec = pgTimeDescriptor.factory({})(instanceCtx); + + it('renders time strings', () => { + expect(codec.renderSqlLiteral('12:34:56')).toBe("'12:34:56'::time"); + }); + }); + + describe('pg/timetz@1', () => { + const codec = pgTimetzDescriptor.factory({})(instanceCtx); + + it('renders time-with-timezone strings', () => { + expect(codec.renderSqlLiteral('12:34:56+02')).toBe("'12:34:56+02'::timetz"); + }); + }); + + describe('pg/bit@1', () => { + const codec = pgBitDescriptor.factory({})(instanceCtx); + + it('renders bit strings', () => { + expect(codec.renderSqlLiteral('1010')).toBe("B'1010'"); + }); + }); + + describe('pg/varbit@1', () => { + const codec = pgVarbitDescriptor.factory({})(instanceCtx); + + it('renders variable-bit strings', () => { + expect(codec.renderSqlLiteral('10101')).toBe("B'10101'"); + }); + }); + + describe('pg/bytea@1', () => { + const codec = pgByteaDescriptor.factory()(instanceCtx); + + it('renders Uint8Array as hex-formatted bytea literal', () => { + expect(codec.renderSqlLiteral(new Uint8Array([0xde, 0xad, 0xbe, 0xef]))).toBe( + "'\\xdeadbeef'::bytea", + ); + }); + + it('renders empty arrays', () => { + expect(codec.renderSqlLiteral(new Uint8Array([]))).toBe("'\\x'::bytea"); + }); + }); + + describe('pg/interval@1', () => { + const codec = pgIntervalDescriptor.factory({})(instanceCtx); + + it('renders interval strings with cast', () => { + expect(codec.renderSqlLiteral('1 day')).toBe("'1 day'::interval"); + }); + + it('escapes embedded single quotes', () => { + expect(codec.renderSqlLiteral("1 day'")).toBe("'1 day'''::interval"); + }); + }); + + describe('pg/enum@1', () => { + const codec = pgEnumDescriptor.factory({})(instanceCtx); + + it('renders enum values as bare quoted literals (column-context cast)', () => { + expect(codec.renderSqlLiteral('active')).toBe("'active'"); + }); + + it('escapes embedded single quotes', () => { + expect(codec.renderSqlLiteral("a'b")).toBe("'a''b'"); + }); + }); + + describe('pg/json@1', () => { + const codec = pgJsonDescriptor.factory()(instanceCtx); + + it('renders JSON objects as quoted JSON with json cast', () => { + expect(codec.renderSqlLiteral({ a: 1 })).toBe('\'{"a":1}\'::json'); + }); + + it('renders JSON arrays', () => { + expect(codec.renderSqlLiteral([1, 2, 3])).toBe("'[1,2,3]'::json"); + }); + + it('escapes single quotes inside string values', () => { + expect(codec.renderSqlLiteral({ msg: "O'Brien" })).toBe('\'{"msg":"O\'\'Brien"}\'::json'); + }); + }); + + describe('pg/jsonb@1', () => { + const codec = pgJsonbDescriptor.factory()(instanceCtx); + + it('renders JSON objects as quoted JSON with jsonb cast', () => { + expect(codec.renderSqlLiteral({ a: 1 })).toBe('\'{"a":1}\'::jsonb'); + }); + + it('renders strings', () => { + expect(codec.renderSqlLiteral('hello')).toBe('\'"hello"\'::jsonb'); + }); + }); + + describe('pg/char@1 (aliased over SqlCharCodec)', () => { + const codec = pgCharDescriptor.factory({})(instanceCtx); + + it('renders fixed-length strings as character literals', () => { + expect(codec.renderSqlLiteral('a')).toBe("'a'::character"); + }); + }); + + describe('pg/varchar@1 (aliased over SqlVarcharCodec)', () => { + const codec = pgVarcharDescriptor.factory({})(instanceCtx); + + it('renders variable-length strings as character-varying literals', () => { + expect(codec.renderSqlLiteral('hello')).toBe("'hello'::character varying"); + }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts b/packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts index 0e40843d41..3edfb34076 100644 --- a/packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts +++ b/packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts @@ -30,7 +30,9 @@ test('list entries extend AnyCodecDescriptor', () => { test('pgInt4Descriptor.traits is a readonly literal tuple, not widened', () => { type Traits = PgInt4Descriptor['traits']; - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + readonly ['equality', 'order', 'numeric', 'autoincrement'] + >(); expectTypeOf().toExtend(); }); @@ -51,7 +53,7 @@ test('CodecTypes is keyed by codec id and exposes input/output/traits', () => { expectTypeOf().toExtend<{ readonly input: number; readonly output: number; - readonly traits: 'equality' | 'order' | 'numeric'; + readonly traits: 'equality' | 'order' | 'numeric' | 'autoincrement'; }>(); expectTypeOf().toExtend<{ diff --git a/packages/3-targets/3-targets/sqlite/src/core/codecs.ts b/packages/3-targets/3-targets/sqlite/src/core/codecs.ts index 748d5dfed4..c7eae4d73a 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/codecs.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/codecs.ts @@ -3,7 +3,7 @@ * * Each codec ships as three artifacts: * - * 1. A `SqliteXCodec` class extending {@link CodecImpl} that wraps the encode/decode/encodeJson/decodeJson conversions inline. SQLite's runtime conversions are simple enough that there is no shared helper module; the class bodies are the single source of truth. 2. A `SqliteXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, and params schema. SQLite codecs do not carry + * 1. A `SqliteXCodec` class extending {@link SqlCodecImpl} that wraps the encode/decode/encodeJson/decodeJson conversions inline. SQLite's runtime conversions are simple enough that there is no shared helper module; the class bodies are the single source of truth. 2. A `SqliteXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, and params schema. SQLite codecs do not carry * `meta` (no per-target native-type meta today) and are all non-parameterized. 3. A per-codec column helper (`sqliteXColumn`) that calls `descriptor.factory()` directly and packages the result into a {@link ColumnSpec} via the framework {@link column} packager. The helper is tied to its descriptor with `satisfies ColumnHelperFor` + `ColumnHelperForStrict` (every SQLite codec's resolved type is well-defined). * * After TML-2357 this is the canonical source of SQLite codec metadata and runtime behaviour — the legacy `mkCodec` / `defineCodec` carriers (and the parallel `byScalar` / `codecDescriptorDefinitions` collection exports) retired with the deletion sweep. @@ -16,7 +16,6 @@ import { type AnyCodecDescriptor, type CodecCallContext, CodecDescriptorImpl, - CodecImpl, type CodecInstanceContext, type ColumnHelperFor, type ColumnHelperForStrict, @@ -24,6 +23,8 @@ import { voidParamsSchema, } from '@prisma-next/framework-components/codec'; import { + escapeStandardSqlLiteral, + SqlCodecImpl, sqlCharDescriptor, sqlFloatDescriptor, sqlIntDescriptor, @@ -39,7 +40,7 @@ import { SQLITE_TEXT_CODEC_ID, } from './codec-ids'; -export class SqliteTextCodec extends CodecImpl< +export class SqliteTextCodec extends SqlCodecImpl< typeof SQLITE_TEXT_CODEC_ID, readonly ['equality', 'order', 'textual'], string, @@ -57,6 +58,9 @@ export class SqliteTextCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return `'${escapeStandardSqlLiteral(value)}'`; + } } export class SqliteTextDescriptor extends CodecDescriptorImpl { @@ -72,14 +76,20 @@ export class SqliteTextDescriptor extends CodecDescriptorImpl { export const sqliteTextDescriptor = new SqliteTextDescriptor(); export const sqliteTextColumn = () => - column(sqliteTextDescriptor.factory(), sqliteTextDescriptor.codecId, undefined, 'text'); + column( + sqliteTextDescriptor.factory(), + sqliteTextDescriptor.codecId, + undefined, + 'text', + sqliteTextDescriptor.traits, + ); sqliteTextColumn satisfies ColumnHelperFor; sqliteTextColumn satisfies ColumnHelperForStrict; -export class SqliteIntegerCodec extends CodecImpl< +export class SqliteIntegerCodec extends SqlCodecImpl< typeof SQLITE_INTEGER_CODEC_ID, - readonly ['equality', 'order', 'numeric'], + readonly ['equality', 'order', 'numeric', 'autoincrement'], number, number > { @@ -95,11 +105,14 @@ export class SqliteIntegerCodec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class SqliteIntegerDescriptor extends CodecDescriptorImpl { override readonly codecId = SQLITE_INTEGER_CODEC_ID; - override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly traits = ['equality', 'order', 'numeric', 'autoincrement'] as const; override readonly targetTypes = ['integer'] as const; override readonly paramsSchema = voidParamsSchema; override factory(): (ctx: CodecInstanceContext) => SqliteIntegerCodec { @@ -110,12 +123,18 @@ export class SqliteIntegerDescriptor extends CodecDescriptorImpl { export const sqliteIntegerDescriptor = new SqliteIntegerDescriptor(); export const sqliteIntegerColumn = () => - column(sqliteIntegerDescriptor.factory(), sqliteIntegerDescriptor.codecId, undefined, 'integer'); + column( + sqliteIntegerDescriptor.factory(), + sqliteIntegerDescriptor.codecId, + undefined, + 'integer', + sqliteIntegerDescriptor.traits, + ); sqliteIntegerColumn satisfies ColumnHelperFor; sqliteIntegerColumn satisfies ColumnHelperForStrict; -export class SqliteRealCodec extends CodecImpl< +export class SqliteRealCodec extends SqlCodecImpl< typeof SQLITE_REAL_CODEC_ID, readonly ['equality', 'order', 'numeric'], number, @@ -133,6 +152,9 @@ export class SqliteRealCodec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class SqliteRealDescriptor extends CodecDescriptorImpl { @@ -148,12 +170,18 @@ export class SqliteRealDescriptor extends CodecDescriptorImpl { export const sqliteRealDescriptor = new SqliteRealDescriptor(); export const sqliteRealColumn = () => - column(sqliteRealDescriptor.factory(), sqliteRealDescriptor.codecId, undefined, 'real'); + column( + sqliteRealDescriptor.factory(), + sqliteRealDescriptor.codecId, + undefined, + 'real', + sqliteRealDescriptor.traits, + ); sqliteRealColumn satisfies ColumnHelperFor; sqliteRealColumn satisfies ColumnHelperForStrict; -export class SqliteBlobCodec extends CodecImpl< +export class SqliteBlobCodec extends SqlCodecImpl< typeof SQLITE_BLOB_CODEC_ID, readonly ['equality'], Uint8Array, @@ -174,6 +202,9 @@ export class SqliteBlobCodec extends CodecImpl< } return new Uint8Array(Buffer.from(json, 'base64')); } + renderSqlLiteral(value: Uint8Array): string { + return `X'${Buffer.from(value).toString('hex')}'`; + } } export class SqliteBlobDescriptor extends CodecDescriptorImpl { @@ -189,12 +220,18 @@ export class SqliteBlobDescriptor extends CodecDescriptorImpl { export const sqliteBlobDescriptor = new SqliteBlobDescriptor(); export const sqliteBlobColumn = () => - column(sqliteBlobDescriptor.factory(), sqliteBlobDescriptor.codecId, undefined, 'blob'); + column( + sqliteBlobDescriptor.factory(), + sqliteBlobDescriptor.codecId, + undefined, + 'blob', + sqliteBlobDescriptor.traits, + ); sqliteBlobColumn satisfies ColumnHelperFor; sqliteBlobColumn satisfies ColumnHelperForStrict; -export class SqliteDatetimeCodec extends CodecImpl< +export class SqliteDatetimeCodec extends SqlCodecImpl< typeof SQLITE_DATETIME_CODEC_ID, readonly ['equality', 'order'], string, @@ -223,6 +260,9 @@ export class SqliteDatetimeCodec extends CodecImpl< } return this.parseDate(json); } + renderSqlLiteral(value: Date): string { + return `'${value.toISOString()}'`; + } } export class SqliteDatetimeDescriptor extends CodecDescriptorImpl { @@ -238,12 +278,18 @@ export class SqliteDatetimeDescriptor extends CodecDescriptorImpl { export const sqliteDatetimeDescriptor = new SqliteDatetimeDescriptor(); export const sqliteDatetimeColumn = () => - column(sqliteDatetimeDescriptor.factory(), sqliteDatetimeDescriptor.codecId, undefined, 'text'); + column( + sqliteDatetimeDescriptor.factory(), + sqliteDatetimeDescriptor.codecId, + undefined, + 'text', + sqliteDatetimeDescriptor.traits, + ); sqliteDatetimeColumn satisfies ColumnHelperFor; sqliteDatetimeColumn satisfies ColumnHelperForStrict; -export class SqliteJsonCodec extends CodecImpl< +export class SqliteJsonCodec extends SqlCodecImpl< typeof SQLITE_JSON_CODEC_ID, readonly ['equality'], string | JsonValue, @@ -261,6 +307,9 @@ export class SqliteJsonCodec extends CodecImpl< decodeJson(json: JsonValue): JsonValue { return json; } + renderSqlLiteral(value: JsonValue): string { + return `'${escapeStandardSqlLiteral(JSON.stringify(value))}'`; + } } export class SqliteJsonDescriptor extends CodecDescriptorImpl { @@ -276,12 +325,18 @@ export class SqliteJsonDescriptor extends CodecDescriptorImpl { export const sqliteJsonDescriptor = new SqliteJsonDescriptor(); export const sqliteJsonColumn = () => - column(sqliteJsonDescriptor.factory(), sqliteJsonDescriptor.codecId, undefined, 'text'); + column( + sqliteJsonDescriptor.factory(), + sqliteJsonDescriptor.codecId, + undefined, + 'text', + sqliteJsonDescriptor.traits, + ); sqliteJsonColumn satisfies ColumnHelperFor; sqliteJsonColumn satisfies ColumnHelperForStrict; -export class SqliteBigintCodec extends CodecImpl< +export class SqliteBigintCodec extends SqlCodecImpl< typeof SQLITE_BIGINT_CODEC_ID, readonly ['equality', 'order', 'numeric'], number | bigint, @@ -302,6 +357,9 @@ export class SqliteBigintCodec extends CodecImpl< } return BigInt(json); } + renderSqlLiteral(value: bigint): string { + return value.toString(); + } } export class SqliteBigintDescriptor extends CodecDescriptorImpl { @@ -317,7 +375,13 @@ export class SqliteBigintDescriptor extends CodecDescriptorImpl { export const sqliteBigintDescriptor = new SqliteBigintDescriptor(); export const sqliteBigintColumn = () => - column(sqliteBigintDescriptor.factory(), sqliteBigintDescriptor.codecId, undefined, 'integer'); + column( + sqliteBigintDescriptor.factory(), + sqliteBigintDescriptor.codecId, + undefined, + 'integer', + sqliteBigintDescriptor.traits, + ); sqliteBigintColumn satisfies ColumnHelperFor; sqliteBigintColumn satisfies ColumnHelperForStrict; diff --git a/packages/3-targets/3-targets/sqlite/src/core/control-target.ts b/packages/3-targets/3-targets/sqlite/src/core/control-target.ts index cd2f5feeef..63d85dd874 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/control-target.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/control-target.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault, Contract } from '@prisma-next/contract/types'; +import type { Contract } from '@prisma-next/contract/types'; import type { SqlControlFamilyInstance, SqlControlTargetDescriptor, @@ -9,10 +9,13 @@ import type { MigrationPlanner, MigrationRunner, } from '@prisma-next/framework-components/control'; -import { SqlStorage, type StorageColumn } from '@prisma-next/sql-contract/types'; +import { + type ColumnDefault, + SqlStorage, + type StorageColumn, +} from '@prisma-next/sql-contract/types'; import { sqliteTargetDescriptorMeta } from './descriptor-meta'; import { createSqliteMigrationPlanner } from './migrations/planner'; -import { renderDefaultLiteral } from './migrations/planner-ddl-builders'; import type { SqlitePlanTargetDetails } from './migrations/planner-target-details'; import { createSqliteMigrationRunner } from './migrations/runner'; import { SqliteContractSerializer } from './sqlite-contract-serializer'; @@ -23,13 +26,13 @@ function isSqlContract(contract: Contract | null): contract is Contract = diff --git a/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts b/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts index 5fac409975..29e5d12a72 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts @@ -7,17 +7,8 @@ * `target-sqlite` reaching into `adapter-sqlite`. */ -import type { ColumnDefault } from '@prisma-next/contract/types'; - -const NULL_PATTERN = /^NULL$/i; -const INTEGER_PATTERN = /^-?\d+$/; -const REAL_PATTERN = /^-?\d+\.\d+(?:[eE][+-]?\d+)?$/; -const HEX_PATTERN = /^0[xX][\dA-Fa-f]+$/; -const STRING_LITERAL_PATTERN = /^'((?:[^']|'')*)'$/; - -function isNumericLiteral(value: string): boolean { - return INTEGER_PATTERN.test(value) || REAL_PATTERN.test(value) || HEX_PATTERN.test(value); -} +import type { JsonValue } from '@prisma-next/contract/types'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; /** * Strips a single matched wrapping pair of outer parens from `s`. Conservative: @@ -42,7 +33,7 @@ export function stripOuterParens(s: string): string { export function parseSqliteDefault( rawDefault: string, - nativeType?: string, + _nativeType?: string, ): ColumnDefault | undefined { let trimmed = rawDefault.trim(); @@ -62,31 +53,81 @@ export function parseSqliteDefault( // form for verification. const lower = trimmed.toLowerCase(); if (lower === 'current_timestamp' || lower === "datetime('now')" || lower === 'datetime("now")') { - return { kind: 'function', expression: 'now()' }; + return { kind: 'expression', expression: 'now()' }; + } + + return { kind: 'expression', expression: trimmed }; +} + +/** + * Extracts the codec-comparable {@link JsonValue} out of a raw SQLite + * column default expression (the value `pragma_table_info.dflt_value` + * returns). Mirror of `parsePostgresDefaultValue` in + * `target-postgres/src/core/default-normalizer.ts`; the verifier dispatches + * the returned {@link JsonValue} through the column's codec + * (`codec.decodeJson(...)` → `codec.renderSqlLiteral(...)`) and compares + * the result against the contract-side expression. + * + * Returns `undefined` for non-literal forms (`CURRENT_TIMESTAMP`, + * `datetime('now')`); the verifier falls back to the legacy normalizer + * path for those. + * + * SQLite is loose-typed at the storage layer: it stores affinities, not + * strict per-column types. The parser therefore relies on the `nativeType` + * hint to disambiguate quoted numerics from quoted strings. + */ +export function parseSqliteDefaultValue( + rawDefault: string, + nativeType: string, +): JsonValue | undefined { + let trimmed = rawDefault.trim(); + + // Strip outer parens iteratively (SQLite wraps expressions like `(1)` in + // parens; the recreate-table postcheck builder mirrors this). + while (true) { + const stripped = stripOuterParens(trimmed).trim(); + if (stripped === trimmed) break; + trimmed = stripped; + } + + const lower = trimmed.toLowerCase(); + if (lower === 'current_timestamp' || lower === "datetime('now')" || lower === 'datetime("now")') { + return undefined; } - if (NULL_PATTERN.test(trimmed)) { - return { kind: 'literal', value: null }; + // Boolean literals (SQLite supports both `1`/`0` and `true`/`false`). + if (/^true$/i.test(trimmed)) return true; + if (/^false$/i.test(trimmed)) return false; + + // Numerics — bare or quoted-with-cast — on a numeric nativeType + // (SQLite's affinity is integer / real / numeric). + if (/^(?:int|bigint|smallint|numeric|real|float|double)/i.test(nativeType)) { + const numericMatch = trimmed.match(/^'?(-?\d+(?:\.\d+)?)'?$/); + if (numericMatch?.[1] !== undefined) { + const n = Number(numericMatch[1]); + if (Number.isFinite(n)) return n; + } } - // SQLite integers are 64-bit, so values outside the JS safe-integer range can't - // be faithfully represented as `number`. Mirror `parsePostgresDefault`'s bigint - // handling: parse as JS `number` when safe, fall back to the raw text otherwise. - if (isNumericLiteral(trimmed)) { - const num = Number(trimmed); - if (!Number.isFinite(num)) return undefined; - if (nativeType?.toLowerCase() === 'integer' && !Number.isSafeInteger(num)) { - return { kind: 'literal', value: trimmed }; + // JSON literals — SQLite's text-JSON columns store JSON as TEXT; + // `pragma_table_info.dflt_value` returns the quoted JSON literal. + if (/json/i.test(nativeType)) { + const stringMatch = trimmed.match(/^'((?:[^']|'')*)'$/); + if (stringMatch?.[1] !== undefined) { + try { + return JSON.parse(stringMatch[1].replace(/''/g, "'")); + } catch { + return undefined; + } } - return { kind: 'literal', value: num }; } - const stringMatch = trimmed.match(STRING_LITERAL_PATTERN); + // Quoted strings — strip outer single quotes; SQLite uses `''` for + // embedded quotes (same as Postgres). + const stringMatch = trimmed.match(/^'((?:[^']|'')*)'$/); if (stringMatch?.[1] !== undefined) { - const unescaped = stringMatch[1].replace(/''/g, "'"); - return { kind: 'literal', value: unescaped }; + return stringMatch[1].replace(/''/g, "'"); } - // Unrecognized expression — preserve as function - return { kind: 'function', expression: trimmed }; + return undefined; } diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts index 7d80b17307..49fffbbb05 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts @@ -46,6 +46,7 @@ import type { import { buildColumnDefaultSql, buildColumnTypeSql, + type ColumnDefaultContext, isInlineAutoincrementPrimaryKey, } from './planner-ddl-builders'; import { @@ -204,6 +205,7 @@ function isMissing(issue: SchemaIssue): boolean { * `StorageColumn` again — they deal in pre-rendered SQL fragments. */ export function toColumnSpec( + tableName: string, name: string, column: StorageColumn, storageTypes: Readonly>, @@ -213,7 +215,12 @@ export function toColumnSpec( column, storageTypes as Record, ); - const defaultSql = buildColumnDefaultSql(column.default); + const context: ColumnDefaultContext = { + tableName, + columnName: name, + isIntegerPrimaryKey: inlineAutoincrementPrimaryKey, + }; + const defaultSql = buildColumnDefaultSql(column.default, context); return { name, typeSql, @@ -230,11 +237,18 @@ export function toColumnSpec( * renderer emits `INTEGER PRIMARY KEY AUTOINCREMENT` inline. */ export function toTableSpec( + tableName: string, table: StorageTable, storageTypes: Readonly>, ): SqliteTableSpec { const columns: SqliteColumnSpec[] = Object.entries(table.columns).map(([name, column]) => - toColumnSpec(name, column, storageTypes, isInlineAutoincrementPrimaryKey(table, name)), + toColumnSpec( + tableName, + name, + column, + storageTypes, + isInlineAutoincrementPrimaryKey(table, name), + ), ); const uniques: SqliteUniqueSpec[] = table.uniques.map((u) => ({ columns: u.columns, @@ -309,7 +323,7 @@ function mapIssueToCall( ), ); } - const tableSpec = toTableSpec(contractTable, ctx.storageTypes); + const tableSpec = toTableSpec(issue.table, contractTable, ctx.storageTypes); const calls: SqliteOpFactoryCall[] = [new CreateTableCall(issue.table, tableSpec)]; const declaredIndexColumnKeys = new Set(); for (const index of contractTable.indexes) { @@ -345,6 +359,7 @@ function mapIssueToCall( } const contractTable = contractTable2; const columnSpec = toColumnSpec( + issue.table, issue.column, column, ctx.storageTypes, diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts index 59ab1bfcf4..9fcb5fc08b 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts @@ -15,7 +15,7 @@ import { type StorageTable, type StorageTypeInstance, } from '@prisma-next/sql-contract/types'; -import { escapeLiteral, quoteIdentifier } from '../sql-utils'; +import { quoteIdentifier } from '../sql-utils'; type SqliteColumnDefault = StorageColumn['default']; @@ -30,15 +30,6 @@ function assertSafeNativeType(nativeType: string): void { } } -function assertSafeDefaultExpression(expression: string): void { - if (expression.includes(';') || /--|\/\*|\bSELECT\b/i.test(expression)) { - throw new Error( - `Unsafe default expression in contract: "${expression}". ` + - 'Default expressions must not contain semicolons, SQL comment tokens, or subqueries.', - ); - } -} - /** * Renders the column's DDL type token (e.g. `"INTEGER"`, `"TEXT"`). * Resolves `typeRef` against `storageTypes` and validates the resulting @@ -53,46 +44,47 @@ export function buildColumnTypeSql( return resolved.nativeType.toUpperCase(); } +export interface ColumnDefaultContext { + readonly tableName: string; + readonly columnName: string; + readonly isIntegerPrimaryKey: boolean; +} + /** * Renders the column's `DEFAULT …` clause. Returns the empty string when - * there is no default, and also when the default is `autoincrement()` — - * SQLite encodes that as `INTEGER PRIMARY KEY AUTOINCREMENT` inline on the - * column definition, not as a separate DEFAULT. + * there is no default, and also when the default is `autoincrement` on a + * valid `INTEGER PRIMARY KEY` column — SQLite encodes that as + * `INTEGER PRIMARY KEY AUTOINCREMENT` inline on the column definition, not + * as a separate DEFAULT. + * + * Throws a diagnostic when `kind: 'autoincrement'` arrives on a column that + * is not `INTEGER PRIMARY KEY` — SQLite's autoincrement mechanism only + * operates on the rowid alias column. */ -export function buildColumnDefaultSql(columnDefault: SqliteColumnDefault | undefined): string { +export function buildColumnDefaultSql( + columnDefault: SqliteColumnDefault | undefined, + context?: ColumnDefaultContext, +): string { if (!columnDefault) return ''; switch (columnDefault.kind) { - case 'literal': - return `DEFAULT ${renderDefaultLiteral(columnDefault.value)}`; - case 'function': { - if (columnDefault.expression === 'autoincrement()') return ''; + case 'autoincrement': { + if (!context?.isIntegerPrimaryKey) { + const columnPath = context ? `${context.tableName}.${context.columnName}` : ''; + throw new Error( + `Column "${columnPath}" has kind 'autoincrement' but is not an INTEGER PRIMARY KEY. ` + + 'SQLite AUTOINCREMENT is only valid on INTEGER PRIMARY KEY columns.', + ); + } + return ''; + } + case 'expression': { if (columnDefault.expression === 'now()') return "DEFAULT (datetime('now'))"; - assertSafeDefaultExpression(columnDefault.expression); return `DEFAULT (${columnDefault.expression})`; } } } -export function renderDefaultLiteral(value: unknown): string { - if (value instanceof Date) { - return `'${escapeLiteral(value.toISOString())}'`; - } - if (typeof value === 'string') { - return `'${escapeLiteral(value)}'`; - } - if (typeof value === 'number' || typeof value === 'bigint') { - return String(value); - } - if (typeof value === 'boolean') { - return value ? '1' : '0'; - } - if (value === null) { - return 'NULL'; - } - return `'${escapeLiteral(JSON.stringify(value))}'`; -} - export function buildCreateIndexSql( tableName: string, indexName: string, @@ -109,7 +101,7 @@ export function buildDropIndexSql(indexName: string): string { /** * True when the column is rendered inline as `INTEGER PRIMARY KEY - * AUTOINCREMENT`. Requires the column's default to be `autoincrement()` and + * AUTOINCREMENT`. Requires the column's default to be `autoincrement` and * the column to be the sole member of the table's primary key — anything * else falls back to a separate PRIMARY KEY constraint with a default * AUTOINCREMENT semantics expressed elsewhere. @@ -118,7 +110,7 @@ export function isInlineAutoincrementPrimaryKey(table: StorageTable, columnName: if (table.primaryKey?.columns.length !== 1) return false; if (table.primaryKey.columns[0] !== columnName) return false; const column = table.columns[columnName]; - return column?.default?.kind === 'function' && column.default.expression === 'autoincrement()'; + return column?.default?.kind === 'autoincrement'; } type ResolvedColumnTypeMetadata = Pick; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts index 7770bdaff6..f0282648db 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts @@ -163,7 +163,7 @@ export const recreateTableStrategy: CallMigrationStrategy = (issues, ctx) => { // Flatten the contract table to a self-contained spec — the Call holds // pre-rendered SQL fragments only, no `StorageColumn` or `storageTypes`. - const tableSpec = toTableSpec(contractTable, ctx.storageTypes); + const tableSpec = toTableSpec(tableName, contractTable, ctx.storageTypes); const seenIndexColumnKeys = new Set(); const indexes: SqliteIndexSpec[] = []; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 57a47e9564..6bc32d8ab4 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -17,7 +17,8 @@ import type { MigrationScaffoldContext, SchemaIssue, } from '@prisma-next/framework-components/control'; -import { parseSqliteDefault } from '../default-normalizer'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; +import { parseSqliteDefault, parseSqliteDefaultValue } from '../default-normalizer'; import { normalizeSqliteNativeType } from '../native-type-normalizer'; import { planIssues } from './issue-planner'; import { @@ -176,6 +177,8 @@ export class SqliteMigrationPlanner typeMetadataRegistry: new Map(), frameworkComponents: options.frameworkComponents, normalizeDefault: parseSqliteDefault, + parseSchemaDefaultValue: parseSqliteDefaultValue, + codecLookup: extractCodecLookup(options.frameworkComponents), normalizeNativeType: normalizeSqliteNativeType, }); return verifyResult.schema.issues; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts index 434ad3aec2..8dba0cef0b 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts @@ -16,11 +16,11 @@ import { runnerFailure, runnerSuccess } from '@prisma-next/family-sql/control'; import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import { type ContractMarkerRow, parseContractMarkerRow } from '@prisma-next/family-sql/verify'; import type { ControlDriverInstance } from '@prisma-next/framework-components/control'; -import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; +import { APP_SPACE_ID, extractCodecLookup } from '@prisma-next/framework-components/control'; import { ifDefined } from '@prisma-next/utils/defined'; import type { Result } from '@prisma-next/utils/result'; import { notOk, ok, okVoid } from '@prisma-next/utils/result'; -import { parseSqliteDefault } from '../default-normalizer'; +import { parseSqliteDefault, parseSqliteDefaultValue } from '../default-normalizer'; import { normalizeSqliteNativeType } from '../native-type-normalizer'; import type { SqlitePlanTargetDetails } from './planner-target-details'; import { @@ -158,6 +158,8 @@ class SqliteMigrationRunner implements SqlMigrationRunner' }; + +describe('renderSqlLiteral on SQLite codecs', () => { + describe('sqlite/text@1', () => { + const codec = sqliteTextDescriptor.factory()(instanceCtx); + + it('renders ASCII strings as quoted literals', () => { + expect(codec.renderSqlLiteral('hello')).toBe("'hello'"); + }); + + it('doubles embedded single quotes', () => { + expect(codec.renderSqlLiteral("O'Brien")).toBe("'O''Brien'"); + }); + + it('preserves backslashes verbatim', () => { + expect(codec.renderSqlLiteral('a\\b')).toBe("'a\\b'"); + }); + + it('rejects NULL bytes', () => { + expect(() => codec.renderSqlLiteral('a\0b')).toThrow(); + }); + + it('passes unicode through verbatim', () => { + expect(codec.renderSqlLiteral('naïve résumé 日本語')).toBe("'naïve résumé 日本語'"); + }); + }); + + describe('sqlite/integer@1', () => { + const codec = sqliteIntegerDescriptor.factory()(instanceCtx); + + it('renders integers as numeric literals', () => { + expect(codec.renderSqlLiteral(42)).toBe('42'); + }); + + it('renders negatives', () => { + expect(codec.renderSqlLiteral(-7)).toBe('-7'); + }); + + it('renders zero', () => { + expect(codec.renderSqlLiteral(0)).toBe('0'); + }); + + it('carries the autoincrement trait via descriptor', () => { + expect(sqliteIntegerDescriptor.traits).toContain('autoincrement'); + }); + }); + + describe('sqlite/real@1', () => { + const codec = sqliteRealDescriptor.factory()(instanceCtx); + + it('renders floats as numeric literals', () => { + expect(codec.renderSqlLiteral(3.14)).toBe('3.14'); + }); + }); + + describe('sqlite/blob@1', () => { + const codec = sqliteBlobDescriptor.factory()(instanceCtx); + + it('renders Uint8Array as a sqlite hex blob literal', () => { + expect(codec.renderSqlLiteral(new Uint8Array([0xde, 0xad, 0xbe, 0xef]))).toBe("X'deadbeef'"); + }); + + it('renders empty blobs', () => { + expect(codec.renderSqlLiteral(new Uint8Array([]))).toBe("X''"); + }); + }); + + describe('sqlite/datetime@1', () => { + const codec = sqliteDatetimeDescriptor.factory()(instanceCtx); + + it('renders Date as ISO-8601 string literal', () => { + expect(codec.renderSqlLiteral(new Date('2026-04-30T12:34:56.789Z'))).toBe( + "'2026-04-30T12:34:56.789Z'", + ); + }); + }); + + describe('sqlite/json@1', () => { + const codec = sqliteJsonDescriptor.factory()(instanceCtx); + + it('renders JSON objects as quoted JSON strings', () => { + expect(codec.renderSqlLiteral({ a: 1 })).toBe('\'{"a":1}\''); + }); + + it('escapes embedded single quotes inside JSON string values', () => { + expect(codec.renderSqlLiteral({ msg: "O'Brien" })).toBe('\'{"msg":"O\'\'Brien"}\''); + }); + }); + + describe('sqlite/bigint@1', () => { + const codec = sqliteBigintDescriptor.factory()(instanceCtx); + + it('renders bigints as numeric literals', () => { + expect(codec.renderSqlLiteral(9007199254740993n)).toBe('9007199254740993'); + }); + + it('renders negative bigints', () => { + expect(codec.renderSqlLiteral(-42n)).toBe('-42'); + }); + + it('does NOT carry the autoincrement trait (sqlite limits autoincrement to INTEGER PRIMARY KEY)', () => { + expect(sqliteBigintDescriptor.traits).not.toContain('autoincrement'); + }); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts index 8cb9759bab..0453a26e34 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts @@ -82,7 +82,7 @@ describe('recreateTableStrategy', () => { nativeType: 'text', codecId: 'sqlite/text@1', nullable: true, - default: { kind: 'literal', value: '' }, + default: { kind: 'expression', expression: "''" }, }, }, primaryKey: { columns: ['id'] }, @@ -158,7 +158,7 @@ describe('recreateTableStrategy', () => { nativeType: 'text', codecId: 'sqlite/text@1', nullable: true, - default: { kind: 'literal', value: '' }, + default: { kind: 'expression', expression: "''" }, }, }, primaryKey: { columns: ['id'] }, diff --git a/packages/3-targets/3-targets/sqlite/test/planner-ddl-builders.test.ts b/packages/3-targets/3-targets/sqlite/test/planner-ddl-builders.test.ts index 46463a5323..04ce8377a3 100644 --- a/packages/3-targets/3-targets/sqlite/test/planner-ddl-builders.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/planner-ddl-builders.test.ts @@ -6,7 +6,6 @@ import { buildCreateIndexSql, buildDropIndexSql, isInlineAutoincrementPrimaryKey, - renderDefaultLiteral, } from '../src/core/migrations/planner-ddl-builders'; function makeColumn(overrides: Partial = {}): StorageColumn { @@ -59,54 +58,63 @@ describe('buildColumnDefaultSql', () => { expect(buildColumnDefaultSql(undefined)).toBe(''); }); - it('renders literal string default', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: 'hello' })).toBe("DEFAULT 'hello'"); + it('renders expression default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: 'random()' })).toBe( + 'DEFAULT (random())', + ); }); - it('renders literal number default', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: 42 })).toBe('DEFAULT 42'); + it('renders string expression default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: "'hello'" })).toBe( + "DEFAULT ('hello')", + ); }); - it('renders literal boolean as 0/1', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: true })).toBe('DEFAULT 1'); - expect(buildColumnDefaultSql({ kind: 'literal', value: false })).toBe('DEFAULT 0'); + it('renders numeric expression default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: '42' })).toBe('DEFAULT (42)'); }); - it('renders NULL literal', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: null })).toBe('DEFAULT NULL'); + it('renders NULL expression default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: 'NULL' })).toBe( + 'DEFAULT (NULL)', + ); }); - it("renders now() as datetime('now')", () => { - expect(buildColumnDefaultSql({ kind: 'function', expression: 'now()' })).toBe( + it("renders now() as datetime('now') — dialect-specific translation preserved", () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: 'now()' })).toBe( "DEFAULT (datetime('now'))", ); }); - it('returns empty for autoincrement()', () => { - expect(buildColumnDefaultSql({ kind: 'function', expression: 'autoincrement()' })).toBe(''); - }); - - it('renders custom function default', () => { - expect(buildColumnDefaultSql({ kind: 'function', expression: 'random()' })).toBe( - 'DEFAULT (random())', - ); + it('returns empty for autoincrement on INTEGER PRIMARY KEY column', () => { + expect( + buildColumnDefaultSql( + { kind: 'autoincrement' }, + { tableName: 'users', columnName: 'id', isIntegerPrimaryKey: true }, + ), + ).toBe(''); }); - it('rejects unsafe default expressions', () => { + it('throws diagnostic for autoincrement on non-INTEGER-PK column', () => { expect(() => - buildColumnDefaultSql({ kind: 'function', expression: 'foo(); DROP TABLE' }), - ).toThrow(/Unsafe/); + buildColumnDefaultSql( + { kind: 'autoincrement' }, + { tableName: 'users', columnName: 'name', isIntegerPrimaryKey: false }, + ), + ).toThrow('users.name'); }); -}); -describe('renderDefaultLiteral', () => { - it('renders Date as ISO8601 string', () => { - const d = new Date('2024-01-15T10:30:00.000Z'); - expect(renderDefaultLiteral(d)).toBe("'2024-01-15T10:30:00.000Z'"); + it('throws diagnostic for autoincrement on non-INTEGER-PK TEXT column', () => { + expect(() => + buildColumnDefaultSql( + { kind: 'autoincrement' }, + { tableName: 'orders', columnName: 'ref_id', isIntegerPrimaryKey: false }, + ), + ).toThrow('orders.ref_id'); }); - it('renders JSON objects', () => { - expect(renderDefaultLiteral({ key: 'val' })).toBe('\'{"key":"val"}\''); + it('throws for autoincrement with no column context', () => { + expect(() => buildColumnDefaultSql({ kind: 'autoincrement' })).toThrow(); }); }); @@ -137,13 +145,13 @@ describe('buildDropIndexSql', () => { }); describe('isInlineAutoincrementPrimaryKey', () => { - it('is true for sole-column PK with autoincrement() default', () => { + it('is true for sole-column PK with autoincrement default', () => { const table = makeTable({ columns: { id: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }), }, primaryKey: { columns: ['id'] }, @@ -158,7 +166,7 @@ describe('isInlineAutoincrementPrimaryKey', () => { seq: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }), }, primaryKey: { columns: ['id'] }, @@ -172,7 +180,7 @@ describe('isInlineAutoincrementPrimaryKey', () => { a: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }), b: makeColumn({ nativeType: 'integer', nullable: false }), }, @@ -181,7 +189,7 @@ describe('isInlineAutoincrementPrimaryKey', () => { expect(isInlineAutoincrementPrimaryKey(table, 'a')).toBe(false); }); - it('is false when default is not autoincrement()', () => { + it('is false when default is not autoincrement', () => { const table = makeTable({ columns: { id: makeColumn({ nativeType: 'integer', nullable: false }), diff --git a/packages/3-targets/3-targets/sqlite/test/typed-descriptor-flow.test-d.ts b/packages/3-targets/3-targets/sqlite/test/typed-descriptor-flow.test-d.ts index c38cd001c6..b8b33429db 100644 --- a/packages/3-targets/3-targets/sqlite/test/typed-descriptor-flow.test-d.ts +++ b/packages/3-targets/3-targets/sqlite/test/typed-descriptor-flow.test-d.ts @@ -23,7 +23,9 @@ test('list entries extend AnyCodecDescriptor', () => { test('sqliteIntegerDescriptor.traits is a readonly literal tuple, not widened', () => { type Traits = SqliteIntegerDescriptor['traits']; - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + readonly ['equality', 'order', 'numeric', 'autoincrement'] + >(); expectTypeOf().toExtend(); }); @@ -44,7 +46,7 @@ test('CodecTypes is keyed by codec id and exposes input/output/traits', () => { expectTypeOf().toExtend<{ readonly input: number; readonly output: number; - readonly traits: 'equality' | 'order' | 'numeric'; + readonly traits: 'equality' | 'order' | 'numeric' | 'autoincrement'; }>(); expectTypeOf().toExtend<{ diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index 9a15189230..dddc4f677f 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -20,7 +20,10 @@ import type { SqlTableIR, SqlUniqueIR, } from '@prisma-next/sql-schema-ir/types'; -import { parsePostgresDefault } from '@prisma-next/target-postgres/default-normalizer'; +import { + parsePostgresDefault, + parsePostgresDefaultValue, +} from '@prisma-next/target-postgres/default-normalizer'; import { readExistingEnumValues } from '@prisma-next/target-postgres/enum-planning'; import { normalizeSchemaNativeType } from '@prisma-next/target-postgres/native-type-normalizer'; import { ifDefined } from '@prisma-next/utils/defined'; @@ -56,6 +59,15 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { */ readonly normalizeDefault = parsePostgresDefault; + /** + * Target-specific parser that extracts the codec-comparable JsonValue + * out of a raw Postgres default expression. Threaded into + * `verifySqlSchema` so the verifier can round-trip introspected literals + * through the column's codec (`decodeJson` → `renderSqlLiteral`) and + * compare against the contract-side codec-rendered expression. + */ + readonly parseSchemaDefaultValue = parsePostgresDefaultValue; + /** * Target-specific normalizer for Postgres schema native type names. * Used by schema verification to normalize introspected type names diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts index d00e4a0df2..8b6018aea6 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts @@ -97,7 +97,7 @@ function lowerAutoincrement(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'autoincrement()', }, }, @@ -121,7 +121,7 @@ function lowerNow(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, @@ -265,7 +265,7 @@ function lowerDbgenerated(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: rawExpression, }, }, diff --git a/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts b/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts index 197f75de01..e3c8be6a30 100644 --- a/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts +++ b/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts @@ -58,16 +58,19 @@ export function varcharColumn(length: number): ColumnTypeDescriptor & { export const int4Column = { codecId: PG_INT4_CODEC_ID, nativeType: 'int4', + traits: ['equality', 'order', 'numeric', 'autoincrement'], } as const satisfies ColumnTypeDescriptor; export const int2Column = { codecId: PG_INT2_CODEC_ID, nativeType: 'int2', + traits: ['equality', 'order', 'numeric', 'autoincrement'], } as const satisfies ColumnTypeDescriptor; export const int8Column = { codecId: PG_INT8_CODEC_ID, nativeType: 'int8', + traits: ['equality', 'order', 'numeric', 'autoincrement'], } as const satisfies ColumnTypeDescriptor; export const float4Column = { diff --git a/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts b/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts index b31bfdbe2c..d9c8f8d1cf 100644 --- a/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts @@ -235,149 +235,148 @@ describe('parsePostgresDefault normalizer', () => { it('normalizes common default expressions', () => { // Autoincrement patterns expect(parsePostgresDefault("nextval('user_id_seq'::regclass)")).toEqual({ - kind: 'function', - expression: 'autoincrement()', + kind: 'autoincrement', }); // Timestamp functions expect(parsePostgresDefault('now()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); expect(parsePostgresDefault('CURRENT_TIMESTAMP')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); // clock_timestamp() is distinct from now() — returns wall-clock time, not transaction time expect(parsePostgresDefault('clock_timestamp()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'clock_timestamp()', }); // UUID function expect(parsePostgresDefault('gen_random_uuid()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }); - // Boolean literals + // Boolean literals (returned as raw expression strings) expect(parsePostgresDefault('true')).toEqual({ - kind: 'literal', - value: true, + kind: 'expression', + expression: 'true', }); expect(parsePostgresDefault('false')).toEqual({ - kind: 'literal', - value: false, + kind: 'expression', + expression: 'false', }); - // Numeric literals + // Numeric literals (returned as raw expression strings) expect(parsePostgresDefault('42')).toEqual({ - kind: 'literal', - value: 42, + kind: 'expression', + expression: '42', }); expect(parsePostgresDefault('3.14')).toEqual({ - kind: 'literal', - value: 3.14, + kind: 'expression', + expression: '3.14', }); expect(parsePostgresDefault('-123.45')).toEqual({ - kind: 'literal', - value: -123.45, + kind: 'expression', + expression: '-123.45', }); - // String literals (type casts are stripped) + // String literals (returned with casts preserved) expect(parsePostgresDefault("'hello'::text")).toEqual({ - kind: 'literal', - value: 'hello', + kind: 'expression', + expression: "'hello'::text", }); expect(parsePostgresDefault('\'ok\'::"BillingState"')).toEqual({ - kind: 'literal', - value: 'ok', + kind: 'expression', + expression: '\'ok\'::"BillingState"', }); expect(parsePostgresDefault("'Hello''s'::text")).toEqual({ - kind: 'literal', - value: "Hello's", + kind: 'expression', + expression: "'Hello''s'::text", }); expect(parsePostgresDefault("'plain text'")).toEqual({ - kind: 'literal', - value: 'plain text', + kind: 'expression', + expression: "'plain text'", }); // uuid_generate_v4() from uuid-ossp extension is normalized to gen_random_uuid() expect(parsePostgresDefault('uuid_generate_v4()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }); }); }); -describe('parsePostgresDefault strips type casts from string literals', () => { - it('strips ::text cast from simple string literal', () => { +describe('parsePostgresDefault preserves string literals with casts', () => { + it('preserves ::text cast on simple string literal', () => { expect(parsePostgresDefault("'ready'::text")).toEqual({ - kind: 'literal', - value: 'ready', + kind: 'expression', + expression: "'ready'::text", }); }); - it('strips ::character varying cast', () => { + it('preserves ::character varying cast', () => { expect(parsePostgresDefault("'hello'::character varying")).toEqual({ - kind: 'literal', - value: 'hello', + kind: 'expression', + expression: "'hello'::character varying", }); }); - it('strips ::character varying(255) cast with length', () => { + it('preserves ::character varying(255) cast with length', () => { expect(parsePostgresDefault("'hello'::character varying(255)")).toEqual({ - kind: 'literal', - value: 'hello', + kind: 'expression', + expression: "'hello'::character varying(255)", }); }); - it('strips quoted enum cast like ::"MyEnum"', () => { + it('preserves quoted enum cast like ::"MyEnum"', () => { expect(parsePostgresDefault('\'active\'::"StatusEnum"')).toEqual({ - kind: 'literal', - value: 'active', + kind: 'expression', + expression: '\'active\'::"StatusEnum"', }); }); - it('strips quoted camelCase enum cast like ::"EnvironmentModelKind"', () => { + it('preserves quoted camelCase enum cast like ::"EnvironmentModelKind"', () => { expect(parsePostgresDefault('\'ready\'::"EnvironmentModelKind"')).toEqual({ - kind: 'literal', - value: 'ready', + kind: 'expression', + expression: '\'ready\'::"EnvironmentModelKind"', }); }); it('preserves plain string literal without cast', () => { expect(parsePostgresDefault("'plain text'")).toEqual({ - kind: 'literal', - value: 'plain text', + kind: 'expression', + expression: "'plain text'", }); }); - it('strips cast from string with escaped quotes', () => { + it('preserves string with escaped quotes', () => { expect(parsePostgresDefault("'it''s ready'::text")).toEqual({ - kind: 'literal', - value: "it's ready", + kind: 'expression', + expression: "'it''s ready'::text", }); }); - it('strips ::varchar cast', () => { + it('preserves ::varchar cast', () => { expect(parsePostgresDefault("'default_value'::varchar")).toEqual({ - kind: 'literal', - value: 'default_value', + kind: 'expression', + expression: "'default_value'::varchar", }); }); - it('strips ::bpchar cast (blank-padded char)', () => { + it('preserves ::bpchar cast (blank-padded char)', () => { expect(parsePostgresDefault("'Y'::bpchar")).toEqual({ - kind: 'literal', - value: 'Y', + kind: 'expression', + expression: "'Y'::bpchar", }); }); - it('strips cast from empty string literal', () => { + it('preserves empty string literal with cast', () => { expect(parsePostgresDefault("''::text")).toEqual({ - kind: 'literal', - value: '', + kind: 'expression', + expression: "''::text", }); }); }); @@ -396,7 +395,7 @@ describe('parsePostgresDefault normalizes cast-wrapped timestamp defaults', () = { input: 'current_timestamp' }, ])('normalizes "$input" to now()', ({ input }) => { expect(parsePostgresDefault(input)).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); }); @@ -407,7 +406,7 @@ describe('parsePostgresDefault normalizes cast-wrapped timestamp defaults', () = { input: '(clock_timestamp())::timestamptz' }, ])('normalizes "$input" to clock_timestamp()', ({ input }) => { expect(parsePostgresDefault(input)).toEqual({ - kind: 'function', + kind: 'expression', expression: 'clock_timestamp()', }); }); @@ -415,16 +414,14 @@ describe('parsePostgresDefault normalizes cast-wrapped timestamp defaults', () = describe('parsePostgresDefault rejects non-timestamp expressions with timestamp casts', () => { it.each([ - { input: 'random()::timestamptz', expectedExpr: 'random()::timestamptz' }, - { input: "'yesterday'::timestamp without time zone", expectedValue: 'yesterday' }, - { input: "'2024-01-01'::timestamp without time zone", expectedValue: '2024-01-01' }, - ])('does not normalize "$input" to now()', ({ input, expectedExpr, expectedValue }) => { - const result = parsePostgresDefault(input); - if (expectedExpr) { - expect(result).toEqual({ kind: 'function', expression: expectedExpr }); - } else { - expect(result).toEqual({ kind: 'literal', value: expectedValue }); - } + { input: 'random()::timestamptz' }, + { input: "'yesterday'::timestamp without time zone" }, + { input: "'2024-01-01'::timestamp without time zone" }, + ])('does not normalize "$input" to now()', ({ input }) => { + expect(parsePostgresDefault(input)).toEqual({ + kind: 'expression', + expression: input, + }); }); }); @@ -438,107 +435,107 @@ describe('parsePostgresDefault normalizes NULL defaults', () => { { input: 'NULL::character varying(255)' }, { input: 'NULL::"MyEnum"' }, { input: 'NULL::jsonb' }, - ])('normalizes "$input" to null literal', ({ input }) => { + ])('normalizes "$input" to expression', ({ input }) => { expect(parsePostgresDefault(input)).toEqual({ - kind: 'literal', - value: null, + kind: 'expression', + expression: input, }); }); }); describe('parsePostgresDefault handles extension type defaults', () => { - it('parses citext string literal with cast', () => { + it('preserves citext string literal with cast', () => { expect(parsePostgresDefault("'hello'::citext", 'citext')).toEqual({ - kind: 'literal', - value: 'hello', + kind: 'expression', + expression: "'hello'::citext", }); }); - it('parses ltree string literal with cast', () => { + it('preserves ltree string literal with cast', () => { expect(parsePostgresDefault("'root.child'::ltree", 'ltree')).toEqual({ - kind: 'literal', - value: 'root.child', + kind: 'expression', + expression: "'root.child'::ltree", }); }); - it('parses gen_random_uuid() for uuid columns', () => { + it('normalizes gen_random_uuid() for uuid columns', () => { expect(parsePostgresDefault('gen_random_uuid()', 'uuid')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }); }); - it('parses empty jsonb object default', () => { + it('preserves empty jsonb object default', () => { expect(parsePostgresDefault("'{}'::jsonb", 'jsonb')).toEqual({ - kind: 'literal', - value: {}, + kind: 'expression', + expression: "'{}'::jsonb", }); }); - it('parses empty jsonb array default', () => { + it('preserves empty jsonb array default', () => { expect(parsePostgresDefault("'[]'::jsonb", 'jsonb')).toEqual({ - kind: 'literal', - value: [], + kind: 'expression', + expression: "'[]'::jsonb", }); }); }); -describe('parsePostgresDefault parses JSON literals for json/jsonb columns', () => { - it('parses object literal for jsonb native type', () => { +describe('parsePostgresDefault preserves JSON literals for json/jsonb columns', () => { + it('preserves object literal for jsonb native type', () => { expect(parsePostgresDefault('\'{"key": "default"}\'::jsonb', 'jsonb')).toEqual({ - kind: 'literal', - value: { key: 'default' }, + kind: 'expression', + expression: '\'{"key": "default"}\'::jsonb', }); }); - it('parses array literal for jsonb native type', () => { + it('preserves array literal for jsonb native type', () => { expect(parsePostgresDefault('\'["alpha", "beta"]\'::jsonb', 'jsonb')).toEqual({ - kind: 'literal', - value: ['alpha', 'beta'], + kind: 'expression', + expression: '\'["alpha", "beta"]\'::jsonb', }); }); - it('parses object literal for json native type', () => { + it('preserves object literal for json native type', () => { expect(parsePostgresDefault('\'{"ok": true}\'::json', 'json')).toEqual({ - kind: 'literal', - value: { ok: true }, + kind: 'expression', + expression: '\'{"ok": true}\'::json', }); }); - it('falls back to string when JSON parsing fails', () => { + it('preserves non-JSON string literal', () => { expect(parsePostgresDefault("'not-json'::jsonb", 'jsonb')).toEqual({ - kind: 'literal', - value: 'not-json', + kind: 'expression', + expression: "'not-json'::jsonb", }); }); }); describe('parsePostgresDefault handles bigint defaults', () => { - it('parses bare safe integer for int8 as number', () => { + it('preserves bare integer for int8 as expression', () => { expect(parsePostgresDefault('42', 'int8')).toEqual({ - kind: 'literal', - value: 42, + kind: 'expression', + expression: '42', }); }); - it('parses bare unsafe integer for int8 as string', () => { + it('preserves bare unsafe integer for int8 as expression', () => { expect(parsePostgresDefault('9999999999999999999', 'bigint')).toEqual({ - kind: 'literal', - value: '9999999999999999999', + kind: 'expression', + expression: '9999999999999999999', }); }); - it('parses quoted safe integer for int8 as number', () => { + it('preserves quoted safe integer for int8 as expression', () => { expect(parsePostgresDefault("'42'::bigint", 'bigint')).toEqual({ - kind: 'literal', - value: 42, + kind: 'expression', + expression: "'42'::bigint", }); }); - it('parses quoted unsafe integer for int8 as string', () => { + it('preserves quoted unsafe integer for int8 as expression', () => { expect(parsePostgresDefault("'9999999999999999999'::bigint", 'int8')).toEqual({ - kind: 'literal', - value: '9999999999999999999', + kind: 'expression', + expression: "'9999999999999999999'::bigint", }); }); }); diff --git a/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts b/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts index 6e36df09db..847d4a1612 100644 --- a/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts @@ -47,7 +47,10 @@ describe('createPostgresDefaultFunctionRegistry', () => { const result = handler.lower({ call: makeCall('autoincrement'), context: stubContext }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'autoincrement()' } }, + value: { + kind: 'storage', + defaultValue: { kind: 'expression', expression: 'autoincrement()' }, + }, }); }); @@ -56,7 +59,7 @@ describe('createPostgresDefaultFunctionRegistry', () => { const result = handler.lower({ call: makeCall('now'), context: stubContext }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'now()' } }, + value: { kind: 'storage', defaultValue: { kind: 'expression', expression: 'now()' } }, }); }); @@ -142,7 +145,7 @@ describe('createPostgresDefaultFunctionRegistry', () => { ok: true, value: { kind: 'storage', - defaultValue: { kind: 'function', expression: 'gen_random_uuid()' }, + defaultValue: { kind: 'expression', expression: 'gen_random_uuid()' }, }, }); }); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts index 25c8e992e7..0164ac77a4 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts @@ -7,7 +7,6 @@ import { buildColumnTypeSql, buildCreateTableSql, buildForeignKeySql, - renderDefaultLiteral, } from '@prisma-next/target-postgres/planner-ddl-builders'; import { describe, expect, it } from 'vitest'; @@ -29,7 +28,7 @@ describe('buildColumnTypeSql', () => { it('returns SERIAL for int4 with autoincrement', () => { const column = col({ nativeType: 'int4', - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }); expect(buildColumnTypeSql(column, noHooks)).toBe('SERIAL'); }); @@ -37,7 +36,7 @@ describe('buildColumnTypeSql', () => { it('returns BIGSERIAL for int8 with autoincrement', () => { const column = col({ nativeType: 'int8', - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }); expect(buildColumnTypeSql(column, noHooks)).toBe('BIGSERIAL'); }); @@ -45,7 +44,7 @@ describe('buildColumnTypeSql', () => { it('returns SMALLSERIAL for int2 with autoincrement', () => { const column = col({ nativeType: 'int2', - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }); expect(buildColumnTypeSql(column, noHooks)).toBe('SMALLSERIAL'); }); @@ -89,24 +88,28 @@ describe('buildColumnDefaultSql', () => { expect(buildColumnDefaultSql(undefined)).toBe(''); }); - it('renders literal string default', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: 'hello' })).toBe("DEFAULT 'hello'"); + it('renders expression string default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: "'hello'::text" })).toBe( + "DEFAULT ('hello'::text)", + ); }); - it('renders literal number default', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: 42 })).toBe('DEFAULT 42'); + it('renders expression number default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: '42' })).toBe('DEFAULT (42)'); }); - it('renders literal boolean default', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: true })).toBe('DEFAULT true'); + it('renders expression boolean default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: 'true' })).toBe( + 'DEFAULT (true)', + ); }); - it('returns empty string for autoincrement function', () => { - expect(buildColumnDefaultSql({ kind: 'function', expression: 'autoincrement()' })).toBe(''); + it('returns empty string for autoincrement', () => { + expect(buildColumnDefaultSql({ kind: 'autoincrement' })).toBe(''); }); - it('renders non-autoincrement function default', () => { - expect(buildColumnDefaultSql({ kind: 'function', expression: 'now()' })).toBe( + it('renders expression function default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: 'now()' })).toBe( 'DEFAULT (now())', ); }); @@ -116,44 +119,6 @@ describe('buildColumnDefaultSql', () => { `DEFAULT nextval('"user_id_seq"'::regclass)`, ); }); - - it('rejects unsafe function expressions', () => { - expect(() => - buildColumnDefaultSql({ kind: 'function', expression: 'now(); DROP TABLE users' }), - ).toThrow('Unsafe default expression'); - }); -}); - -// --------------------------------------------------------------------------- -// renderDefaultLiteral -// --------------------------------------------------------------------------- - -describe('renderDefaultLiteral', () => { - it('renders string', () => { - expect(renderDefaultLiteral('hello')).toBe("'hello'"); - }); - - it('renders number', () => { - expect(renderDefaultLiteral(42)).toBe('42'); - }); - - it('renders boolean', () => { - expect(renderDefaultLiteral(false)).toBe('false'); - }); - - it('renders null', () => { - expect(renderDefaultLiteral(null)).toBe('NULL'); - }); - - it('renders JSON object for jsonb column', () => { - const result = renderDefaultLiteral({ key: 'val' }, col({ nativeType: 'jsonb' })); - expect(result).toBe(`'{"key":"val"}'::jsonb`); - }); - - it('renders JSON object without cast for non-json column', () => { - const result = renderDefaultLiteral({ key: 'val' }); - expect(result).toBe(`'{"key":"val"}'`); - }); }); // --------------------------------------------------------------------------- @@ -195,12 +160,12 @@ describe('buildAddColumnSql', () => { col({ nativeType: 'bool', nullable: false, - default: { kind: 'literal', value: true }, + default: { kind: 'expression', expression: 'true' }, }), noHooks, 'false', ); - expect(sql).toContain('DEFAULT true'); + expect(sql).toContain('DEFAULT (true)'); expect(sql).not.toContain('DEFAULT false'); }); }); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts index b280a7e25e..b4a063ced0 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts @@ -206,7 +206,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'untitled' }, + default: { kind: 'expression', expression: "'untitled'::text" }, }, }), }, @@ -558,7 +558,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'draft' }, + default: { kind: 'expression', expression: "'draft'::text" }, }, }), }, @@ -574,7 +574,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: "'active'::text" }, }, }), }, @@ -610,7 +610,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: "'active'::text" }, }, }), }, @@ -626,7 +626,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'int4', codecId: 'pg/int4@1', nullable: false, - default: { kind: 'literal', value: 1 }, + default: { kind: 'expression', expression: '1' }, }, }), }, @@ -680,7 +680,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'unknown' }, + default: { kind: 'expression', expression: "'unknown'::text" }, }, }), }, @@ -930,7 +930,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false, - default: { kind: 'literal', value: '00000000-0000-0000-0000-000000000000' }, + default: { kind: 'expression', expression: "'00000000-0000-0000-0000-000000000000'" }, }, }), }, @@ -946,7 +946,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false, - default: { kind: 'function', expression: 'gen_random_uuid()' }, + default: { kind: 'expression', expression: 'gen_random_uuid()' }, }, }), }, @@ -1096,7 +1096,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: "'active'::text" }, }, }), }, @@ -1216,7 +1216,10 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'timestamptz', codecId: 'pg/timestamptz@1', nullable: false, - default: { kind: 'literal', value: '2023-01-01T00:00:00.000Z' }, + default: { + kind: 'expression', + expression: "'2023-01-01 00:00:00+00'::timestamp with time zone", + }, }, }), }, diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts index d53e4a4e32..51be6f9cf5 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts @@ -131,13 +131,13 @@ describe.sequential('Schema verification after runner - integration', () => { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }, createdAt: { nativeType: 'timestamptz', codecId: 'pg/timestamptz@1', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, email: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, }, diff --git a/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts b/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts index e2bcf486d4..4bffeac906 100644 --- a/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts @@ -90,7 +90,7 @@ function selectWithParam(column: string, codecId: string | undefined, value: unk describe('renderLoweredSql cast policy', () => { it('emits $N:: when the codec nativeType is outside the inferrable set', () => { - const fooCodec: Codec = defineTestCodec({ + const fooCodec: SqlCodec = defineTestCodec({ typeId: 'app/test-foo@1', encode: (value: string): string => value, decode: (wire: string): string => wire, @@ -112,7 +112,7 @@ describe('renderLoweredSql cast policy', () => { }); it('emits plain $N when the codec nativeType is inferrable', () => { - const integerCodec: Codec = defineTestCodec({ + const integerCodec: SqlCodec = defineTestCodec({ typeId: 'pg/int4@1', encode: (value: number): number => value, decode: (wire: number): number => wire, @@ -134,7 +134,7 @@ describe('renderLoweredSql cast policy', () => { }); it('emits plain $N when the codec carries no nativeType metadata', () => { - const enumCodec: Codec = defineTestCodec({ + const enumCodec: SqlCodec = defineTestCodec({ typeId: 'pg/enum@1', encode: (value: string): string => value, decode: (wire: string): string => wire, @@ -211,7 +211,7 @@ describe('renderLoweredSql cast policy', () => { describe('renderLoweredSql cast policy via stack-derived lookup', () => { it('emits the extension-codec cast when the codec is contributed via stack.extensionPacks', () => { - const geographyCodec: Codec = defineTestCodec({ + const geographyCodec: SqlCodec = defineTestCodec({ typeId: 'app/geography@1', encode: (value: string): string => value, decode: (wire: string): string => wire, diff --git a/packages/3-targets/6-adapters/postgres/test/test-codec.ts b/packages/3-targets/6-adapters/postgres/test/test-codec.ts index 3b8e5c00af..f8a63bb0e1 100644 --- a/packages/3-targets/6-adapters/postgres/test/test-codec.ts +++ b/packages/3-targets/6-adapters/postgres/test/test-codec.ts @@ -26,6 +26,7 @@ export function defineTestCodec< targetTypes?: readonly string[]; encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + renderSqlLiteral?: (value: TInput) => string; traits?: TTraits; } & JsonRoundTripConfig, ): Codec { @@ -36,6 +37,13 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const renderSqlLiteral = + config.renderSqlLiteral ?? + ((_value: TInput): string => { + throw new Error( + `defineTestCodec(${config.typeId}): renderSqlLiteral is not configured. Tests that exercise SQL literal rendering must supply a renderSqlLiteral implementation.`, + ); + }); return { id: config.typeId, encode: (value, ctx) => { @@ -54,5 +62,6 @@ export function defineTestCodec< }, encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + renderSqlLiteral, } as Codec; } diff --git a/packages/3-targets/6-adapters/sqlite/src/core/column-types.ts b/packages/3-targets/6-adapters/sqlite/src/core/column-types.ts index 2f8622cf24..88d0f95bc4 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/column-types.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/column-types.ts @@ -16,6 +16,7 @@ export const textColumn = { export const integerColumn = { codecId: SQLITE_INTEGER_CODEC_ID, nativeType: 'integer', + traits: ['equality', 'order', 'numeric', 'autoincrement'], } as const; export const realColumn = { diff --git a/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts b/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts index ee262df33a..e374cae792 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts @@ -17,7 +17,10 @@ import type { SqlTableIR, SqlUniqueIR, } from '@prisma-next/sql-schema-ir/types'; -import { parseSqliteDefault } from '@prisma-next/target-sqlite/default-normalizer'; +import { + parseSqliteDefault, + parseSqliteDefaultValue, +} from '@prisma-next/target-sqlite/default-normalizer'; import { normalizeSqliteNativeType } from '@prisma-next/target-sqlite/native-type-normalizer'; import { ifDefined } from '@prisma-next/utils/defined'; import { renderLoweredSql } from './adapter'; @@ -70,6 +73,7 @@ export class SqliteControlAdapter implements SqlControlAdapter<'sqlite'> { readonly targetId = 'sqlite' as const; readonly normalizeDefault = parseSqliteDefault; + readonly parseSchemaDefaultValue = parseSqliteDefaultValue; readonly normalizeNativeType = normalizeSqliteNativeType; /** diff --git a/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts b/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts index 2867dd3af4..5fda46c9f8 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts @@ -110,7 +110,7 @@ function lowerAutoincrement(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'autoincrement()', }, }, @@ -134,7 +134,7 @@ function lowerNow(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, @@ -291,7 +291,7 @@ function lowerDbgenerated(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression, }, }, diff --git a/packages/3-targets/6-adapters/sqlite/src/core/descriptor-meta.ts b/packages/3-targets/6-adapters/sqlite/src/core/descriptor-meta.ts index 72e01bfd90..72f201c14f 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/descriptor-meta.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/descriptor-meta.ts @@ -1,3 +1,5 @@ +import { sqliteCodecRegistry } from '@prisma-next/target-sqlite/codecs'; + export const sqliteAdapterDescriptorMeta = { kind: 'adapter', familyId: 'sql', @@ -16,6 +18,7 @@ export const sqliteAdapterDescriptorMeta = { }, types: { codecTypes: { + codecDescriptors: Array.from(sqliteCodecRegistry.values()), import: { package: '@prisma-next/adapter-sqlite/codec-types', named: 'CodecTypes', diff --git a/packages/3-targets/6-adapters/sqlite/test/control-adapter.test.ts b/packages/3-targets/6-adapters/sqlite/test/control-adapter.test.ts index eab1c3223a..067ccc5df6 100644 --- a/packages/3-targets/6-adapters/sqlite/test/control-adapter.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/control-adapter.test.ts @@ -132,63 +132,55 @@ describe('SqliteControlAdapter.introspect', () => { describe('parseSqliteDefault', () => { it('normalizes CURRENT_TIMESTAMP to now()', () => { expect(parseSqliteDefault('CURRENT_TIMESTAMP')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); }); it("normalizes datetime('now') to now()", () => { expect(parseSqliteDefault("(datetime('now'))")).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); }); - it('preserves CURRENT_DATE distinctly', () => { + it('preserves CURRENT_DATE as expression', () => { expect(parseSqliteDefault('CURRENT_DATE')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'CURRENT_DATE', }); }); - it('preserves CURRENT_TIME distinctly', () => { + it('preserves CURRENT_TIME as expression', () => { expect(parseSqliteDefault('CURRENT_TIME')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'CURRENT_TIME', }); }); - it('parses NULL default', () => { - expect(parseSqliteDefault('NULL')).toEqual({ kind: 'literal', value: null }); + it('parses NULL default as expression', () => { + expect(parseSqliteDefault('NULL')).toEqual({ kind: 'expression', expression: 'NULL' }); }); - it('returns number for safe-range integers and falls back to string for 64-bit values', () => { - expect(parseSqliteDefault('42', 'integer')).toEqual({ kind: 'literal', value: 42 }); - expect(parseSqliteDefault('0', 'integer')).toEqual({ kind: 'literal', value: 0 }); - const big = '9999999999999999999'; - expect(parseSqliteDefault(big, 'integer')).toEqual({ kind: 'literal', value: big }); + it('parses numeric default as expression', () => { + expect(parseSqliteDefault('42')).toEqual({ kind: 'expression', expression: '42' }); + expect(parseSqliteDefault('0')).toEqual({ kind: 'expression', expression: '0' }); + expect(parseSqliteDefault('3.14')).toEqual({ kind: 'expression', expression: '3.14' }); }); - it('returns number for real nativeType', () => { - expect(parseSqliteDefault('3.14', 'real')).toEqual({ kind: 'literal', value: 3.14 }); - expect(parseSqliteDefault('0xFF', 'real')).toEqual({ kind: 'literal', value: 255 }); - expect(parseSqliteDefault('1.5e3', 'real')).toEqual({ kind: 'literal', value: 1500 }); - }); - - it('returns number when nativeType is unknown', () => { - expect(parseSqliteDefault('42')).toEqual({ kind: 'literal', value: 42 }); - }); - - it('parses string literal default', () => { - expect(parseSqliteDefault("'hello'")).toEqual({ kind: 'literal', value: 'hello' }); + it('parses string literal default as expression', () => { + expect(parseSqliteDefault("'hello'")).toEqual({ + kind: 'expression', + expression: "'hello'", + }); }); - it('preserves unrecognized expressions as function', () => { - expect(parseSqliteDefault('abs(-5)')).toEqual({ kind: 'function', expression: 'abs(-5)' }); + it('preserves unrecognized expressions', () => { + expect(parseSqliteDefault('abs(-5)')).toEqual({ kind: 'expression', expression: 'abs(-5)' }); }); it('strips outer parentheses', () => { - expect(parseSqliteDefault('(42)')).toEqual({ kind: 'literal', value: 42 }); + expect(parseSqliteDefault('(42)')).toEqual({ kind: 'expression', expression: '42' }); }); }); diff --git a/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts b/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts index 3561a1fd0d..d478577cbb 100644 --- a/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts @@ -41,7 +41,7 @@ describe('createSqliteDefaultFunctionRegistry — dbgenerated canonicalization', }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'now()' } }, + value: { kind: 'storage', defaultValue: { kind: 'expression', expression: 'now()' } }, }); }); @@ -52,7 +52,7 @@ describe('createSqliteDefaultFunctionRegistry — dbgenerated canonicalization', }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'now()' } }, + value: { kind: 'storage', defaultValue: { kind: 'expression', expression: 'now()' } }, }); }); @@ -63,7 +63,7 @@ describe('createSqliteDefaultFunctionRegistry — dbgenerated canonicalization', }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'now()' } }, + value: { kind: 'storage', defaultValue: { kind: 'expression', expression: 'now()' } }, }); }); @@ -74,7 +74,7 @@ describe('createSqliteDefaultFunctionRegistry — dbgenerated canonicalization', }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'random()' } }, + value: { kind: 'storage', defaultValue: { kind: 'expression', expression: 'random()' } }, }); }); }); diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts index 165b777288..3f70cdd25c 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts @@ -96,13 +96,13 @@ describe('SQLite planner + introspection round-trip', () => { id: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }), email: makeColumn({ nativeType: 'text', nullable: false }), active: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'literal', value: 1 }, + default: { kind: 'expression', expression: '1' }, }), }, primaryKey: { columns: ['id'] }, @@ -161,7 +161,7 @@ describe('SQLite planner + introspection round-trip', () => { id: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }), value: makeColumn({ nativeType: 'text', nullable: true }), }, diff --git a/projects/codec-owned-defaults/plan.md b/projects/codec-owned-defaults/plan.md new file mode 100644 index 0000000000..d1a9cdd9f1 --- /dev/null +++ b/projects/codec-owned-defaults/plan.md @@ -0,0 +1,155 @@ +# Codec-owned column defaults + +## Summary + +Reshape the contract IR's `ColumnDefault` to `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }` and move literal-rendering responsibility onto a required `renderSqlLiteral(value: TInput): string` method on the SQL codec interface. The `expression` branch carries every default that lowers to a `DEFAULT ()` clause — codec-rendered literals and raw function-form expressions alike. The `autoincrement` branch is a payload-free sentinel for the one default that doesn't emit a `DEFAULT` clause at all (it's realized as SERIAL/IDENTITY / INTEGER PRIMARY KEY AUTOINCREMENT at the column type). The legacy `kind: 'literal'`/`value: JsonValue` payload is removed. Both authoring surfaces (TS DSL, PSL) lower literals through codec methods at emit time. Success means: dialect coverage enforced at type-check time, no `decodeContractDefaults` runtime pass, autoincrement still works, and Mongo untouched. + +**Spec:** [spec.md](./spec.md) + +## Collaborators + +| Role | Person/Team | Context | +| ------------ | ------------------------------ | ---------------------------------------------------------------------- | +| Maker | Serhii Tatarintsev | Drives execution | +| Reviewer | _TBD — see Open Items_ | Architectural review of the codec-interface change | +| Collaborator | Anyone touching SQL codecs | Postgres/SQLite codec authors will need to add `renderSqlLiteral` | +| Collaborator | PSL authoring owners | PSL parser + printer paths flip atomically with the IR | + +## Shipping Strategy + +This is a workspace-internal change (no external consumers of `contract.json` exist outside the repo). The implicit gate between old and new behaviour is the contract IR shape itself — when the validator flips to `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }`, every producer (TS DSL emitter, PSL parser) must produce the new shape in lockstep, and every consumer (DDL renderer, PSL printer) must read it. + +The plan separates milestones by what can land independently: + +- **M1** is purely additive: every SQL codec gains `renderSqlLiteral`, and the factory requires it. No producer or consumer of `ColumnDefault` changes yet — DDL rendering still runs through the existing `renderDefaultLiteral` per-type logic. Safe to ship alone; if anything went wrong, no behavior has changed. +- **M2** is the atomic flip. The IR shape, both authoring surfaces, both DDL renderers, the type re-homing, the literal-pass for `null`, the diagnostic for `NULL` on `NOT NULL`, and `decodeContractDefaults` removal all land together. Fixtures are regenerated as part of the same change so `pnpm fixtures:check` proves the shape on disk. No feature flag — the test suite and `fixtures:check` are the shipping gate. +- **M3** is doc/cleanup, behaviour unchanged. + +No backward-compat shims, in line with project policy: call sites flip in lockstep with the IR. + +## Test Design + +Test cases derived from the spec's acceptance criteria (Contract IR, SQL Codec, Authoring TS DSL, Authoring PSL, Semantics & Diagnostics, Mongo, Quality Gates) and from the Security non-functional section (adversarial inputs). + +| AC | TC | Test Case | Type | Milestone | Expected Outcome | +| ----------- | ----- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------- | --------- | ------------------------------------------------------------------------------------------- | +| AC-CODEC-1 | TC-1 | SQL codec interface declares `renderSqlLiteral(value: TInput): string` as a required method | Type test | M1 | Property exists; signature matches `TInput → string` | +| AC-CODEC-2 | TC-2 | SQL codec factory rejects construction of any codec missing `renderSqlLiteral` | Negative type test | M1 | Compile error when factory input omits the method | +| AC-CODEC-3 | TC-3 | Each Postgres codec's `renderSqlLiteral` produces the expected dialect-specific expression for representative inputs | Unit | M1 | Output strings match expected DDL fragments | +| AC-CODEC-4 | TC-4 | Each SQLite codec's `renderSqlLiteral` produces the expected dialect-specific expression for representative inputs | Unit | M1 | Output strings match expected DDL fragments | +| AC-CODEC-5 | TC-5 | No codec implementation throws at runtime for valid `TInput` values | Unit | M1 | Each codec returns a string | +| AC-CODEC-6 | TC-6 | Codec renderers correctly escape adversarial inputs: single quotes, backslashes, NULL bytes, unicode (Security NFR) | Unit | M1 | Output safely escaped per dialect; round-trips through the database | +| AC-IR-1 | TC-7 | Arktype `ColumnDefaultSchema` accepts `{ kind: 'expression', expression: string }` and `{ kind: 'autoincrement' }`, and rejects the legacy `{ kind: 'literal', value }` / `{ kind: 'function', expression }` shapes | Unit | M2 | Validation passes for new shape; fails for legacy | +| AC-IR-2 | TC-8 | `ColumnDefault`, `ColumnDefaultLiteralValue`, `ColumnDefaultLiteralInputValue` are exported only from `packages/2-sql/1-core/contract/src/`; not exported from framework foundation | Static (import) | M2 | Imports resolve from SQL package; no framework export | +| AC-IR-3 | TC-9 | All fixture contracts (`test/integration/test/**/contract.json` and equivalent) emit only the new shape for column defaults: every default is either `{ kind: 'expression', expression }` or `{ kind: 'autoincrement' }` | Integration | M2 | All fixture contracts match new shape; no `value` field, no `literal`/`function` kinds | +| AC-IR-4 | TC-10 | `pnpm fixtures:check` passes | Integration | M2 | Exit 0 | +| AC-TS-1 | TC-11 | TS DSL `.default(literal)` produces a contract whose `default` is `{ kind: 'expression', expression: }`. TS DSL `.default(autoincrement())` on a codec carrying the `autoincrement` trait lowers to `{ kind: 'autoincrement' }`. | Integration | M2 | Emitted `contract.json` carries the union shape only; no `value` field | +| AC-TS (new) | TC-11a | TS DSL: `.default(autoincrement())` compiles on column builders whose codec carries the `autoincrement` trait (e.g. `pg/int4@1`, `sqlite/integer@1`) and fails to compile on builders whose codec does not (e.g. `pg/text@1`, `pg/bool@1`) | Type test (±) | M2 | Compiles for trait-bearing codecs; compile error for others | +| AC-TS-2 (+) | TC-12 | TS DSL `.default(matchingTInput)` compiles for representative codecs (string, int, bool, Date, bigint, Buffer, json) | Type test (+) | M2 | Compiles | +| AC-TS-2 (−) | TC-13 | TS DSL `.default(invalidValue)` fails to compile across the same representative codecs | Type test (−) | M2 | Compile error | +| AC-PSL-1 | TC-14 | PSL literal-default lowering invokes `codec.decodeJson(jsonValue)` then `codec.renderSqlLiteral(decoded)` | Unit | M2 | Spy verifies call order; final `default` is `{ kind: 'expression', expression }` | +| AC-PSL-2 | TC-15 | PSL `@default(true)` on an int column emits a diagnostic naming the column path, codec id, and PSL source `file:line` | Unit | M2 | Diagnostic message contains all three fields | +| AC-PSL-3 | TC-16 | PSL `@default(now())` lands as `{ kind: 'expression', expression: 'now()' }` without invoking codec methods. PSL `@default(autoincrement())` on a column whose codec carries the `autoincrement` trait lowers to `{ kind: 'autoincrement' }`; on a column whose codec lacks the trait, PSL emits a diagnostic naming the column, codec id, and PSL source location. | Unit | M2 | Function-form bypasses codec; autoincrement gated on trait | +| AC-PSL-4 | TC-17 | PSL printer reads the new `ColumnDefault` union: `{ kind: 'autoincrement' }` prints as `@default(autoincrement())`; `{ kind: 'expression', expression }` maps known sentinels via `DEFAULT_FUNCTION_ATTRIBUTES`, otherwise raw-expression form | Unit | M2 | Printer output handles both branches | +| AC-PSL-4 | TC-18 | PSL → contract → PSL round-trip survives without crashing (literal form may differ) | Integration | M2 | No errors; second-pass contract semantically equivalent | +| AC-SEM-1 | TC-19 | NOT NULL column with a `null` literal default is rejected before codec dispatch with a diagnostic naming the column | Unit | M2 | Diagnostic raised; `codec.renderSqlLiteral` not called | +| AC-SEM-2 | TC-20 | Nullable column with a `null` literal default renders to `{ kind: 'expression', expression: 'NULL' }` without invoking the codec | Unit | M2 | Codec not invoked; expression is `"NULL"` | +| AC-SEM-3 | TC-21 | `decodeContractDefaults` no longer exists in `packages/2-sql/1-core/contract/src/validate.ts` | Static (grep) | M2 | Symbol absent | +| AC-SEM-4 | TC-22 | `Date`, `bigint`, `Buffer`, JSON values render to expected Postgres SQL expressions | Unit | M1 | Specific expression strings match (covered as part of TC-3) | +| AC-SEM-4 | TC-23 | `Date`, `bigint`, `Buffer`, JSON values render to expected SQLite SQL expressions | Unit | M1 | Specific expression strings match (covered as part of TC-4) | +| AC-MONGO-1 | TC-24 | Mongo codec interface (`mongo-codec/src/codecs.ts`) and Mongo concrete codecs are unchanged in surface | Static (diff check) | M2 | No surface-level diff to Mongo codec types | +| AC-MONGO-2 | TC-25 | Mongo authoring/emission tests pass after the change | Integration | M2 | Existing Mongo test suite green | +| AC-QA-1 | TC-26 | `pnpm typecheck` passes across the workspace | Validation gate | M2 | Exit 0 | +| AC-QA-2 | TC-27 | `pnpm lint` passes across all packages | Validation gate | M2 | Exit 0 | +| AC-QA-3 | TC-28 | `pnpm lint:deps` passes (no new layering violations) | Validation gate | M2 | Exit 0 | +| AC-QA-4 | TC-29 | `pnpm test:packages` passes | Validation gate | M2 | Exit 0 | +| AC-QA-5 | TC-30 | `pnpm test:e2e` passes (covers Postgres DDL emission end-to-end) | Validation gate | M2 | Exit 0 | +| AC-QA-6 | TC-31 | No new `any`, `@ts-expect-error` (outside negative type tests), or `as unknown as` introduced | Static (lint+grep) | M3 | No new instances | +| AC-IR-1 / AC-SEM (new) | TC-32 | DDL renderer emits no `DEFAULT` clause for `{ kind: 'autoincrement' }` on Postgres and SQLite; column-type SERIAL/IDENTITY (Postgres) and INTEGER PRIMARY KEY AUTOINCREMENT (SQLite) emission is unchanged | Integration (DDL) | M2 | Generated DDL omits the `DEFAULT` clause; SERIAL/AUTOINCREMENT column-type semantics intact | + +## Milestones + +### Milestone 1: SQL codec foundation — `renderSqlLiteral` required + +Adds `renderSqlLiteral(value: TInput): string` to the SQL codec interface, makes it required at the codec factory, and implements it on every Postgres and SQLite codec with adversarial-input unit tests. Purely additive — no producer or consumer of `ColumnDefault` changes yet. The DDL renderer continues to use its existing `renderDefaultLiteral` per-type logic until M2. + +Demonstrable: a unit test calls `pgCodec.renderSqlLiteral(value)` and asserts the dialect-specific expression. The compile-time negative test demonstrates the factory rejects codecs missing the method. + +**Tasks:** + +- [ ] Add `renderSqlLiteral(value: TInput): string` to the SQL `Codec` interface in `packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts` (satisfies TC-1) +- [ ] Update the SQL codec factory in `packages/2-sql/4-lanes/relational-core/src/ast/codec-factory.ts` to require `renderSqlLiteral` on the input config (satisfies TC-2; downstream type-check failure surfaces missing implementations) +- [ ] Add a negative type test asserting the factory rejects a config that omits `renderSqlLiteral` (satisfies TC-2) +- [ ] Implement `renderSqlLiteral` on the SQL base codecs in `packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts` (`sql/char@1`, `sql/varchar@1`, `sql/int@1`, `sql/float@1`, `sql/text@1`, `sql/timestamp@1`, and any others present); add unit tests including adversarial inputs (satisfies TC-3/TC-4 indirectly via aliasing, TC-5, TC-6) +- [ ] Implement `renderSqlLiteral` on every Postgres codec in `packages/3-targets/3-targets/postgres/src/core/codecs.ts` (`pg/text@1`, `pg/int4@1`, `pg/int2@1`, `pg/int8@1`, `pg/float4@1`, `pg/float8@1`, `pg/bool@1`, `pg/enum@1`, `pg/json@1`, `pg/jsonb@1`, plus any aliased-from-base codecs); tag the integer codecs (`pg/int2@1`, `pg/int4@1`, `pg/int8@1`) with the `autoincrement` trait. Codecs that need their native type for casts (e.g. `pg/jsonb@1` producing `'<...>'::jsonb`) read it from their own descriptor's `meta.db.sql.postgres.nativeType` — no signature widening needed. `pg/enum@1` is an exception (no `meta`; enum type name is per-enum, not codec-static): emit bare `''` and rely on Postgres's column-context cast in DDL — sufficient for `DEFAULT` emission. Add unit tests covering valid inputs and adversarial inputs (quotes, backslashes, NULL bytes, unicode) per codec (satisfies TC-3, TC-5, TC-6, TC-22) +- [ ] Implement `renderSqlLiteral` on every SQLite codec in `packages/3-targets/3-targets/sqlite/src/core/codecs.ts` (`sqlite/text@1`, `sqlite/integer@1`, `sqlite/real@1`, `sqlite/blob@1`, `sqlite/datetime@1`, `sqlite/json@1`, `sqlite/bigint@1`); tag `sqlite/integer@1` only with the `autoincrement` trait. Add unit tests including adversarial inputs (satisfies TC-4, TC-5, TC-6, TC-23) +- [ ] Implement `renderSqlLiteral` on the pgvector extension codec in `packages/3-extensions/pgvector/src/core/codecs.ts` (`pg/vector@1`) with adversarial-input unit tests (satisfies TC-3, TC-5, TC-6 for vector) +- [ ] Implement `renderSqlLiteral` on the arktype-json extension codec in `packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts` (`arktype/json@1`). Note this codec is constructed inside a per-typeParams factory function (`arktypeJsonCodecForSchema`), so the implementation must be present at the call to the SQL `codec(...)` factory; add adversarial-input tests (satisfies TC-3, TC-5, TC-6 for arktype-json) + +**Validation gate:** + +- `pnpm typecheck` +- `pnpm test:packages` +- `pnpm lint` + +### Milestone 2: IR collapse, authoring lower-through-codec, DDL switch + +The atomic flip. `ColumnDefault` collapses to `{ expression: string }`. Both authoring surfaces lower literals through `renderSqlLiteral` (TS DSL) or `decodeJson + renderSqlLiteral` (PSL). DDL renderers in Postgres and SQLite read `expression` directly. `decodeContractDefaults` is removed. `null` literal defaults are handled in a literal pass before codec dispatch; `NULL` on `NOT NULL` columns is rejected. All fixture contracts are regenerated. Mongo paths are validated as untouched. + +Demonstrable: emitted `contract.json` files contain only `{ expression: string }` for defaults; `pnpm fixtures:check` passes; Postgres e2e exercises DDL emission through the new path. + +**Tasks:** + +- [ ] Re-home `ColumnDefault`, `ColumnDefaultLiteralValue`, `ColumnDefaultLiteralInputValue` to `packages/2-sql/1-core/contract/src/types.ts`. Reshape `ColumnDefault` to the discriminated union `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }`. Update the Arktype validator in `packages/2-sql/1-core/contract/src/validators.ts` to match (two narrowed schemas joined). Remove any framework-foundation export of these types (satisfies TC-7, TC-8) +- [ ] Remove `decodeContractDefaults` from `packages/2-sql/1-core/contract/src/validate.ts` and remove its call site from `validateContract` (satisfies TC-21) +- [ ] Update TS DSL in `packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts`: + - `.default(value)` types `value` as `TInput | AllowAutoincrement` where `AllowAutoincrement` resolves to the `AutoincrementSentinel` brand iff the codec's `traits` array contains `'autoincrement'`, otherwise `never`. The conditional uses `HasTrait` over the existing `traits` field. + - Export a top-level `autoincrement()` function that returns a uniquely-branded sentinel value (e.g. backed by `Symbol('autoincrement')`). + - Keep `.defaultSql(expression)` (or equivalent) as the explicit function-form escape hatch. + - (satisfies TC-11a, TC-12, TC-13) +- [ ] Update the contract emitter in `packages/2-sql/2-authoring/contract-ts/src/build-contract.ts`: + - If `value === AutoincrementSentinel`, produce `{ kind: 'autoincrement' }` (codec not invoked). + - Function-form defaults pass through as `{ kind: 'expression', expression: '' }`. + - `null` literal defaults: literal pass produces `{ kind: 'expression', expression: 'NULL' }` (codec not invoked). + - `null` literal default on a `NOT NULL` column: emit a diagnostic naming the column path and codec id; no contract entry produced. + - Other literals: invoke `codec.renderSqlLiteral(value)` and stamp `{ kind: 'expression', expression: }`. + - (satisfies TC-11, TC-19, TC-20) +- [ ] Update PSL literal-default lowering in `packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts` (and adjacent PSL files as needed): + - `@default(autoincrement())` is recognized at parse time. If the column codec's `traits` include `'autoincrement'`, it lowers to `{ kind: 'autoincrement' }`. Otherwise PSL emits a diagnostic naming the column path, codec id, and PSL source `file:line`. + - Other function-form (`now()`, `gen_random_uuid()`, etc.) lands as `{ kind: 'expression', expression: '' }` directly. + - Literal: `codec.decodeJson(jsonValue)` then `codec.renderSqlLiteral(decoded)`, stamped as `{ kind: 'expression', expression }`. + - `decodeJson` failures surface as PSL diagnostics carrying column path, codec id, and PSL source `file:line`. + - Same `null` / `NOT NULL` rules as TS DSL emitter (shared literal pass). + - (satisfies TC-14, TC-15, TC-16) +- [ ] Update the PSL printer's `mapDefault` function in `packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts` (lines 15–32). Today it switches on legacy `columnDefault.kind` ('literal' / 'function'); after the reshape it switches on the new union: `'autoincrement'` prints as `@default(autoincrement())`; `'expression'` consults `DEFAULT_FUNCTION_ATTRIBUTES` to map known sentinels (e.g. `now()`, `gen_random_uuid()`) back to PSL attributes, otherwise emits raw-expression form (e.g. `` @default(``) ``). Drop the `formatLiteralValue` helper if it becomes unreachable. Add a round-trip integration test (PSL → contract → PSL) asserting it survives without crashing (literal form may differ). (satisfies TC-17, TC-18) +- [ ] Simplify `buildColumnDefaultSql` in `packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts` to switch on the new `ColumnDefault` union: `kind: 'autoincrement'` returns the empty string (no `DEFAULT` clause; SERIAL/IDENTITY column-type emission elsewhere is unchanged); `kind: 'expression'` returns `DEFAULT (${expression})`. Remove the per-type `renderDefaultLiteral` switch (its responsibilities have moved into the codecs). Delete `assertSafeDefaultExpression` and its call site — the contract is developer-authored and the function's own docstring already states it is not a security boundary. (satisfies TC-32 for Postgres) +- [ ] Apply the analogous simplification to the SQLite DDL renderer in `packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts`. Preserve the existing `now()` → `datetime('now')` translation for the `expression` branch (it remains a useful dialect-specific shorthand); delete `assertSafeDefaultExpression`. Add a runtime/emit-time diagnostic that rejects `{ kind: 'autoincrement' }` on any column that is not an `INTEGER PRIMARY KEY` (SQLite's autoincrement mechanism only operates on the rowid column); the diagnostic names the column path. (satisfies TC-32 for SQLite) +- [ ] Regenerate every fixture contract under `test/integration/test/**/contract.json` (and equivalents found by the scout) so `pnpm fixtures:check` passes against the new shape (satisfies TC-9, TC-10) +- [ ] Verify the Mongo codec interface and concrete Mongo codecs in `packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts` are unchanged at their public surface; run the Mongo-specific test suite to confirm no regression (satisfies TC-24, TC-25) + +**Validation gate:** + +- `pnpm typecheck` +- `pnpm test:packages` +- `pnpm test:e2e` +- `pnpm lint` +- `pnpm lint:deps` +- `pnpm fixtures:check` + +### Milestone 3: Close-out + +Final verification, ADR housekeeping, and project deletion. + +**Tasks:** + +- [ ] Verify every acceptance criterion against its TC(s); record evidence in the close-out PR description (satisfies TC-26 through TC-30 as gate re-runs; satisfies TC-31 as static check) +- [ ] Update ADR 167 (`docs/architecture docs/adrs/ADR 167 - Typed default literal pipeline and extensibility.md`) to status "superseded by codec-owned-defaults"; add a close-out / pointer section to ADR 184 (`docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md`) referencing this work and what it implemented +- [ ] Delete `projects/codec-owned-defaults/` (spec, plan, any transient artefacts). The close-out PR title or body must reference the Linear issue identifier so its linked GitHub integration auto-transitions on merge + +**Validation gate:** + +- `pnpm typecheck` +- `pnpm test:packages` +- `pnpm test:e2e` +- `pnpm lint` +- `pnpm lint:deps` diff --git a/projects/codec-owned-defaults/spec.md b/projects/codec-owned-defaults/spec.md new file mode 100644 index 0000000000..c5345817f0 --- /dev/null +++ b/projects/codec-owned-defaults/spec.md @@ -0,0 +1,161 @@ +# Summary + +Column defaults in the contract IR are stored as `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }`. The `expression` branch holds every default that lowers to a `DEFAULT ()` DDL clause — codec-rendered literals and raw function-form expressions alike. The `autoincrement` branch is a payload-free sentinel: it doesn't emit a `DEFAULT` clause at all and is realized at the column-type level (SERIAL/IDENTITY in Postgres, INTEGER PRIMARY KEY AUTOINCREMENT in SQLite). The SQL codec layer owns lowering literal values to dialect-specific expressions via a required `renderSqlLiteral(value: TInput): string` method. Both authoring surfaces (TS DSL and PSL) lower literals through codec methods — TS DSL invokes `renderSqlLiteral` directly, PSL chains `decodeJson + renderSqlLiteral` for runtime validation and rendering. `.default(value)` on the TS DSL is unconditionally available, with the value typed as the column codec's `TInput`. + +# Description + +A column default in `contract.json` sits in `storage.tables[].columns[].default` as a discriminated union: + +- **`{ kind: 'expression'; expression: string }`** — the expression is a complete SQL fragment that the DDL renderer wraps as `DEFAULT ()`. This branch holds both codec-rendered literals (e.g. `'TRUE'`, `'1'`, `'2026-04-30T00:00:00Z'::timestamptz`) and raw function-form expressions authored directly (`now()`, `gen_random_uuid()`). +- **`{ kind: 'autoincrement' }`** — payload-free sentinel. Realized at the column-type level (SERIAL/IDENTITY in Postgres, INTEGER PRIMARY KEY AUTOINCREMENT in SQLite); no `DEFAULT` clause is emitted. + +The contract is target-bound by the time defaults are rendered — every column carries a `codecId`, and the codec for that id owns the dialect-specific spelling of any literal value (`TRUE` vs `1`, `'2026-04-30T00:00:00Z'::timestamptz` vs ISO strings, JSON casts, escape rules). + +Authoring captures literal values transiently: + +- **TS DSL.** `.default(value)` accepts either the column codec's `TInput` or the `autoincrement()` sentinel, where the sentinel is admitted only when the column codec carries the `autoincrement` trait. For a non-sentinel value, the contract emitter dispatches to `codec.renderSqlLiteral(value)` and stamps `{ kind: 'expression', expression: }` into the contract. For the sentinel, the emitter stamps `{ kind: 'autoincrement' }` and bypasses the codec. Function-form authoring (e.g. `.defaultSql('now()')`) also bypasses the codec, landing as `{ kind: 'expression', expression: '' }`. +- **PSL.** The parser produces a `JsonValue` from the schema literal (PSL grammar is JSON-isomorphic). `codec.decodeJson(value)` validates and converts to `TInput`; `codec.renderSqlLiteral(decoded)` produces the expression, recorded as `{ kind: 'expression', expression }`. `decodeJson` failures surface as PSL diagnostics with file:line from the PSL AST. + +The literal value never reaches `contract.json` — the SQL expression does. + +Function-form defaults — `@default(now())`, `@default(gen_random_uuid())` — land directly as `{ kind: 'expression', expression: '' }` without invoking codec methods. The function-form path expresses defaults that aren't reducible to a typed JS value. `@default(autoincrement())` is the only authored form that lands as the `autoincrement` sentinel rather than an expression; it is recognized at parse time, gated on the column codec carrying the `autoincrement` trait (PSL emits a diagnostic if the trait is absent), and lowered to `{ kind: 'autoincrement' }`. + +`null` literal defaults render to `{ kind: 'expression', expression: 'NULL' }` uniformly across dialects, handled in the literal pass before codec dispatch. `renderSqlLiteral(value: TInput)` never receives `null` or `undefined`; codec authors can rely on a defined value. + +Mongo column defaults are runtime-applied (Mongo has no DDL-level default mechanism) and live in `execution.mutations.defaults[]`, separate from the storage-level column default this spec governs. `ColumnDefault` therefore lives in the SQL domain, not the framework foundation; Mongo is unaffected. + +# Requirements + +## Functional Requirements + +**FR1. Contract IR — default shape.** `storage.tables[].columns[].default` is the discriminated union `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }`. The `expression` branch carries every default that lowers to a `DEFAULT ()` clause; the `autoincrement` sentinel is payload-free and is realized at the column-type level. The Arktype validator and SQL types reflect this shape. The legacy `kind: 'literal'` branch's `value: JsonValue` payload is removed — literal-form defaults are merged into the `expression` branch and carry only the rendered SQL string. + +**FR2. SQL codec method — `renderSqlLiteral` (required).** The SQL codec interface declares `renderSqlLiteral(value: TInput): string` as a required method. The SQL codec factory rejects construction of any codec that omits it. No identity fallback — codec authors decide dialect-specific spelling explicitly. + +**FR3. TS DSL `.default(value)` is unconditionally available.** Every TS DSL column builder exposes `.default(value)`. The parameter accepts the column codec's `TInput`. It additionally accepts the `autoincrement()` sentinel, conditionally — only when the column codec carries the `autoincrement` trait. The contract emitter invokes `codec.renderSqlLiteral` for non-sentinel values and stamps `{ kind: 'autoincrement' }` for the sentinel. + +**FR3a. `autoincrement` codec trait.** SQL codecs that support autoincrement column-type emission declare an `autoincrement` trait via the existing `traits` array on the codec interface. Postgres tags `pg/int2@1`, `pg/int4@1`, `pg/int8@1`; SQLite tags `sqlite/integer@1` only. Codecs without the trait do not accept the sentinel — `.default(autoincrement())` is a compile error on those columns, and PSL `@default(autoincrement())` produces a build-time diagnostic naming the column, codec id, and PSL source location. + +**FR4. PSL lowers literals through the codec.** PSL literal defaults dispatch through `codec.decodeJson` followed by `codec.renderSqlLiteral`. `decodeJson` failures surface as PSL diagnostics with file:line. + +**FR5. Function-form defaults.** Defaults expressed directly as SQL expressions (e.g. `@default(now())`, `@default(gen_random_uuid())`, `.defaultSql('...')`) land as `{ kind: 'expression', expression: '' }` without invoking codec methods. The single exception is `autoincrement()`, recognized at parse time and lowered to `{ kind: 'autoincrement' }` (payload-free); the DDL renderer emits no `DEFAULT` clause for that branch and relies on column-type SERIAL/IDENTITY/AUTOINCREMENT semantics. + +**FR6. NULL defaults.** A `null` literal default renders to `{ kind: 'expression', expression: 'NULL' }`, handled in the literal pass without invoking the codec. `null` literal defaults on NOT NULL columns are rejected with a diagnostic naming the column. + +**FR7. `ColumnDefault` is a SQL-domain type.** `ColumnDefault` and its associated literal-input types live in the SQL domain, not the framework foundation. The Mongo codec interface gains nothing. + +## Non-Functional Requirements + +**NFR1. Type safety.** `.default(invalidValue)` where `invalidValue` does not match the column codec's `TInput` is a compile error in the TS DSL. No `any`, `as`, or `@ts-expect-error` in the implementation. + +**NFR2. JS-native default values pass through without JSON round-trips in the TS DSL.** `Date`, `bigint`, `Buffer`, `Uint8Array`, and codec-defined branded types are accepted by `.default(...)` directly, where the codec's `TInput` admits them. PSL inputs go through `JsonValue` (PSL grammar is JSON-isomorphic), then through `codec.decodeJson` to `TInput` before rendering. + +**NFR3. Dialect coverage is structural.** Because `renderSqlLiteral` is required by the codec factory, coverage is enforced at type-check time, not asserted by tests. + +**NFR4. Diagnostics.** Failures include the column path (`table.column`) and codec id. PSL-side failures additionally include the PSL source location (file:line). Covered failure modes: NOT NULL with NULL default; PSL value rejected by `codec.decodeJson` (type mismatch, malformed input). + +## Non-goals + +- **Mongo storage defaults.** Mongo's runtime-applied default story (literal generators in `execution.mutations.defaults[]`) is a separate spec. +- **Surfacing the default's typed value in `contract.d.ts` column signatures** (e.g. making defaulted columns optional on insert types). +- **Reverse parsing of SQL expressions back to JS values.** The literal-to-expression direction is one-way at emit time. PSL printer round-trip is lossy by design: a contract whose defaults originated as PSL `@default(true)` may print back as `@default(\`TRUE\`)` (i.e. the rendered SQL expression in raw-expression form). Behaviour is preserved; literal form is not. If round-trip fidelity becomes a concern later, a codec-side reverse hook can be added without changing the IR. +- **PSL static type checking of default values against codec `TInput`.** PSL is parsed at runtime; type checking happens at lowering via `codec.decodeJson`. +- **Migration tooling that diff-renders defaults across versions.** + +# Acceptance Criteria + +## Contract IR + +- [ ] `ColumnDefault` is the discriminated union `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }`. The legacy `kind: 'literal'` and `kind: 'function'` variants no longer exist; the legacy `value: JsonValue` payload is gone. +- [ ] `ColumnDefault`, `ColumnDefaultLiteralValue`, and `ColumnDefaultLiteralInputValue` live under `packages/2-sql/1-core/contract/src/`. None are exported from the framework foundation. +- [ ] All fixture contracts (`test/integration/test/**/contract.json` and equivalent) emit only the new shape: every default is either `{ kind: 'expression', expression: }` or `{ kind: 'autoincrement' }`. No `value` field, no `literal`/`function` kinds appear. +- [ ] `pnpm fixtures:check` passes. + +## SQL Codec + +- [ ] The SQL codec interface declares `renderSqlLiteral(value: TInput): string` as a required method. +- [ ] The SQL codec factory rejects construction of any codec that omits `renderSqlLiteral`. A compile-time test demonstrates this. +- [ ] All Postgres codecs implement `renderSqlLiteral`. +- [ ] All SQLite codecs implement `renderSqlLiteral` consistently with the SQLite dialect. +- [ ] No codec implementation throws "not implemented" at runtime. +- [ ] Each codec's renderer is unit-tested with adversarial inputs (quotes, backslashes, NULL bytes, unicode). + +## Authoring — TS DSL + +- [ ] The contract emitter invokes `codec.renderSqlLiteral` during emission; literal-form defaults on disk are `{ kind: 'expression', expression: }`. +- [ ] `.default(value)` is available on every column builder, with `value` typed as the column codec's `TInput`. Compile-time tests demonstrate that mismatched types fail to compile and matching types succeed across a representative set of codecs. +- [ ] `.default(autoincrement())` compiles on column builders whose codec carries the `autoincrement` trait, and fails to compile on column builders whose codec does not. The TS DSL emitter lowers `.default(autoincrement())` to `{ kind: 'autoincrement' }` without invoking the codec. + +## Authoring — PSL + +- [ ] PSL literal defaults dispatch through `codec.decodeJson` followed by `codec.renderSqlLiteral` and land as `{ kind: 'expression', expression }`. +- [ ] PSL `@default()` (e.g. `@default(true)` on an int column) fails with a diagnostic naming the column, codec id, and PSL source location. +- [ ] PSL function-form defaults (e.g. `@default(now())`, `@default(gen_random_uuid())`) land as `{ kind: 'expression', expression: '' }` without invoking codec methods. `@default(autoincrement())` is recognized at parse time and, if the column codec carries the `autoincrement` trait, lowers to `{ kind: 'autoincrement' }`; otherwise PSL emits a diagnostic naming the column, codec id, and PSL source location. +- [ ] PSL printer reads the new `ColumnDefault` shape: `{ kind: 'autoincrement' }` prints as `@default(autoincrement())`; `{ kind: 'expression', expression }` maps known sentinels (e.g. `now()`, `gen_random_uuid()`) back to PSL attributes via the existing `DEFAULT_FUNCTION_ATTRIBUTES` lookup, otherwise emits raw-expression form. A round-trip test asserts that defaults survive PSL → contract → PSL emission without crashing (literal form is allowed to differ). + +## Semantics & Diagnostics + +- [ ] NOT NULL columns with `null` literal defaults are rejected before codec dispatch, with a diagnostic naming the column. +- [ ] `null` defaults render to `{ kind: 'expression', expression: 'NULL' }`, handled in the literal pass. +- [ ] No `decodeContractDefaults` function exists in `packages/2-sql/1-core/contract/src/validate.ts`. +- [ ] Tests assert `Date`, `bigint`, `Buffer`, and JSON values render to the expected SQL expressions for each dialect. +- [ ] DDL renderer emits no `DEFAULT` clause for `{ kind: 'autoincrement' }`; SERIAL/IDENTITY (Postgres) and INTEGER PRIMARY KEY AUTOINCREMENT (SQLite) column-type emission is unchanged. + +## Mongo + +- [ ] The Mongo codec interface and concrete Mongo codecs are unchanged. +- [ ] Mongo authoring/emission paths do not regress. + +## Quality Gates + +- [ ] `pnpm typecheck` passes across the workspace. +- [ ] `pnpm lint` passes across all packages. +- [ ] `pnpm lint:deps` passes (no new layering violations). +- [ ] `pnpm test:packages` passes. +- [ ] `pnpm test:e2e` passes (covers end-to-end emission and DDL paths through Postgres). +- [ ] No `any`, `@ts-expect-error` (outside negative type tests), or `as unknown as` casts introduced. + +# Other Considerations + +## Security + +`renderSqlLiteral` produces SQL fragments embedded in DDL. Implementations must escape values correctly per dialect (single quotes, backslashes, identifier-vs-literal context). Escaping is owned by the codec; the emitter does no string concatenation. Adversarial-input unit tests (quotes, backslashes, NULL bytes, unicode) accompany each codec's renderer. + +## Cost + +Negligible. Build/emit cost gains one method dispatch per column with a default; no runtime cost surface. + +## Observability + +No new metrics or alerts. Lowering failures surface as build/emit-time errors with column path and codec id. + +## Data Protection + +Not applicable. No personal data flows through this change. + +## Analytics + +Not applicable. Internal tooling change. + +# References + +## Code + +- `packages/1-framework/1-core/framework-components/src/shared/codec-types.ts` — framework codec interface (`encodeJson` / `decodeJson`) +- `packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts` — SQL codec interface; declares `renderSqlLiteral` +- `packages/2-sql/1-core/contract/src/types.ts` — SQL `ColumnDefault` +- `packages/2-sql/1-core/contract/src/validators.ts` — SQL Arktype validators +- `packages/2-sql/1-core/contract/src/validate.ts` — contract validation entry +- `packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts` — TS DSL `.default(...)` +- `packages/2-sql/2-authoring/contract-ts/src/build-contract.ts` — TS DSL emission path +- `packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts` — PSL literal default parsing +- `packages/1-framework/2-authoring/psl-printer/src/schema-validation.ts` — PSL printer +- `packages/3-targets/3-targets/postgres/src/core/codecs.ts` — Postgres codec implementations +- `packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts` — Postgres DDL renderer; delegates to `renderSqlLiteral` +- `packages/3-targets/3-targets/sqlite/src/core/codecs.ts` — SQLite codec implementations +- `packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts` — Mongo codec (unaffected) + +## Architectural context + +- `docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md` — the broader codec-owned serialization plan; this spec implements the DDL-rendering and PSL-rendering halves directly on the SQL codec interface. +- `docs/architecture docs/adrs/ADR 167 - Typed default literal pipeline and extensibility.md` — older ADR on the typed default pipeline; superseded by this spec. diff --git a/test/e2e/framework/test/ddl.test.ts b/test/e2e/framework/test/ddl.test.ts index 0f60103bad..4811282345 100644 --- a/test/e2e/framework/test/ddl.test.ts +++ b/test/e2e/framework/test/ddl.test.ts @@ -34,19 +34,19 @@ describe('DDL E2E Tests', { timeout: 30000 }, () => { "created_at" timestamptz DEFAULT (now()) NOT NULL, "id" character(36) NOT NULL, "name" text NOT NULL, - "scheduled_at" timestamptz DEFAULT '2024-01-15T10:30:00.000Z' NOT NULL, + "scheduled_at" timestamptz DEFAULT ('2024-01-15T10:30:00.000Z'::timestamp with time zone) NOT NULL, PRIMARY KEY ("id") ); CREATE TABLE "literal_defaults" ( - "active" bool DEFAULT true NOT NULL, - "big_count" int8 DEFAULT 9007199254740991 NOT NULL, + "active" bool DEFAULT (TRUE) NOT NULL, + "big_count" int8 DEFAULT (9007199254740991) NOT NULL, "id" SERIAL NOT NULL, - "label" text DEFAULT 'draft' NOT NULL, - "metadata" jsonb DEFAULT '{"key":"default"}'::jsonb NOT NULL, - "rating" float8 DEFAULT 3.14 NOT NULL, - "score" int4 DEFAULT 0 NOT NULL, - "tags" jsonb DEFAULT '["alpha","beta"]'::jsonb NOT NULL, + "label" text DEFAULT ('draft'::text) NOT NULL, + "metadata" jsonb DEFAULT ('{"key":"default"}'::jsonb) NOT NULL, + "rating" float8 DEFAULT (3.14) NOT NULL, + "score" int4 DEFAULT (0) NOT NULL, + "tags" jsonb DEFAULT ('["alpha","beta"]'::jsonb) NOT NULL, PRIMARY KEY ("id") ); diff --git a/test/e2e/framework/test/fixtures/contract.ts b/test/e2e/framework/test/fixtures/contract.ts index 1397de3d9b..907c6d9f79 100644 --- a/test/e2e/framework/test/fixtures/contract.ts +++ b/test/e2e/framework/test/fixtures/contract.ts @@ -24,7 +24,13 @@ import pgvectorPack from '@prisma-next/extension-pgvector/pack'; import sqlFamily from '@prisma-next/family-sql/pack'; import { extractCodecLookup } from '@prisma-next/framework-components/control'; import { uuidv7 } from '@prisma-next/ids'; -import { defineContract, field, model, rel } from '@prisma-next/sql-contract-ts/contract-builder'; +import { + autoincrement, + defineContract, + field, + model, + rel, +} from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; import { type } from 'arktype'; @@ -37,7 +43,7 @@ const profileSchema = type({ const UserBase = model('User', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), email: field.column(varcharColumn(255)).unique({ name: 'user_email_key' }), createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'), updatedAt: field.column(timestamptzColumn).optional().column('update_at'), @@ -47,7 +53,7 @@ const UserBase = model('User', { const PostBase = model('Post', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), userId: field.column(int4Column), title: field.column(textColumn), createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'), @@ -59,7 +65,7 @@ const PostBase = model('Post', { const Comment = model('Comment', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), postId: field.column(int4Column), content: field.column(textColumn), createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'), @@ -99,7 +105,7 @@ export const contract = defineContract({ ParamTypes: model('ParamTypes', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), name: field.column(varcharColumn(255)).optional(), code: field.column(charColumn(16)).optional(), price: field.column(numericColumn(10, 2)).optional(), @@ -121,7 +127,7 @@ export const contract = defineContract({ name: field.column(textColumn), scheduledAt: field .column(timestamptzColumn) - .default({ kind: 'literal', value: new Date('2024-01-15T10:30:00.000Z') }) + .default(new Date('2024-01-15T10:30:00.000Z')) .column('scheduled_at'), createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'), }, @@ -129,7 +135,7 @@ export const contract = defineContract({ LiteralDefaults: model('LiteralDefaults', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), label: field.column(textColumn).default('draft'), score: field.column(int4Column).default(0), rating: field.column(float8Column).default(3.14), @@ -142,7 +148,7 @@ export const contract = defineContract({ Embedding: model('Embedding', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), embedding: field.column(vector(1536)), profile: field.column(arktypeJson(profileSchema)), }, diff --git a/test/e2e/framework/test/fixtures/generated/contract.d.ts b/test/e2e/framework/test/fixtures/generated/contract.d.ts index c1d962c3ff..3a93d954e9 100644 --- a/test/e2e/framework/test/fixtures/generated/contract.d.ts +++ b/test/e2e/framework/test/fixtures/generated/contract.d.ts @@ -31,7 +31,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:c01040e095a1fe5dd776c2d5afe4a7767c506e044e96ecd46bfa369a75a13733'>; + StorageHashBase<'sha256:2a8f1be899b83214689b90fd64943b9007becceadbaca802522bdadb29854f69'>; export type ExecutionHash = ExecutionHashBase<'sha256:adc296c2bde14cd4e6a8a85ba202108dc7a320b5870a14d7dd8e2d2e2f5a7f27'>; export type ProfileHash = @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes & ArktypeJsonTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Comment: { @@ -181,10 +178,7 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'function'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly postId: { readonly nativeType: 'int4'; @@ -200,7 +194,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly update_at: { readonly nativeType: 'timestamptz'; @@ -219,10 +213,7 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'function'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -269,18 +260,15 @@ type ContractBase = ContractType< readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue< - 'pg/timestamptz@1', - '2024-01-15T10:30:00.000Z' - >; + readonly kind: 'expression'; + readonly expression: "'2024-01-15T10:30:00.000Z'::timestamp with time zone"; }; }; readonly created_at: { readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -294,54 +282,42 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'function'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly label: { readonly nativeType: 'text'; readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'draft'>; + readonly kind: 'expression'; + readonly expression: "'draft'::text"; }; }; readonly score: { readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/int4@1', 0>; - }; + readonly default: { readonly kind: 'expression'; readonly expression: '0' }; }; readonly rating: { readonly nativeType: 'float8'; readonly codecId: 'pg/float8@1'; readonly nullable: false; - readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/float8@1', 3.14>; - }; + readonly default: { readonly kind: 'expression'; readonly expression: '3.14' }; }; readonly active: { readonly nativeType: 'bool'; readonly codecId: 'pg/bool@1'; readonly nullable: false; - readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/bool@1', true>; - }; + readonly default: { readonly kind: 'expression'; readonly expression: 'TRUE' }; }; readonly big_count: { readonly nativeType: 'int8'; readonly codecId: 'pg/int8@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/int8@1', 9007199254740991>; + readonly kind: 'expression'; + readonly expression: '9007199254740991'; }; }; readonly metadata: { @@ -349,8 +325,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/jsonb@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/jsonb@1', { readonly key: 'default' }>; + readonly kind: 'expression'; + readonly expression: '\'{"key":"default"}\'::jsonb'; }; }; readonly tags: { @@ -358,8 +334,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/jsonb@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/jsonb@1', readonly ['alpha', 'beta']>; + readonly kind: 'expression'; + readonly expression: '\'["alpha","beta"]\'::jsonb'; }; }; }; @@ -374,10 +350,7 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'function'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly name: { readonly nativeType: 'character varying'; @@ -445,10 +418,7 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'function'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly userId: { readonly nativeType: 'int4'; @@ -464,7 +434,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly update_at: { readonly nativeType: 'timestamptz'; @@ -493,10 +463,7 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'function'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly email: { readonly nativeType: 'character varying'; @@ -508,7 +475,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly update_at: { readonly nativeType: 'timestamptz'; diff --git a/test/e2e/framework/test/fixtures/generated/contract.json b/test/e2e/framework/test/fixtures/generated/contract.json index 3f267f0614..05649c6f7d 100644 --- a/test/e2e/framework/test/fixtures/generated/contract.json +++ b/test/e2e/framework/test/fixtures/generated/contract.json @@ -625,7 +625,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -633,8 +633,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -672,8 +671,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -715,7 +713,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -736,8 +734,8 @@ "scheduled_at": { "codecId": "pg/timestamptz@1", "default": { - "kind": "literal", - "value": "2024-01-15T10:30:00.000Z" + "expression": "'2024-01-15T10:30:00.000Z'::timestamp with time zone", + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -757,8 +755,8 @@ "active": { "codecId": "pg/bool@1", "default": { - "kind": "literal", - "value": true + "expression": "TRUE", + "kind": "expression" }, "nativeType": "bool", "nullable": false @@ -766,8 +764,8 @@ "big_count": { "codecId": "pg/int8@1", "default": { - "kind": "literal", - "value": 9007199254740991 + "expression": "9007199254740991", + "kind": "expression" }, "nativeType": "int8", "nullable": false @@ -775,8 +773,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -784,8 +781,8 @@ "label": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "draft" + "expression": "'draft'::text", + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -793,10 +790,8 @@ "metadata": { "codecId": "pg/jsonb@1", "default": { - "kind": "literal", - "value": { - "key": "default" - } + "expression": "'{\"key\":\"default\"}'::jsonb", + "kind": "expression" }, "nativeType": "jsonb", "nullable": false @@ -804,8 +799,8 @@ "rating": { "codecId": "pg/float8@1", "default": { - "kind": "literal", - "value": 3.14 + "expression": "3.14", + "kind": "expression" }, "nativeType": "float8", "nullable": false @@ -813,8 +808,8 @@ "score": { "codecId": "pg/int4@1", "default": { - "kind": "literal", - "value": 0 + "expression": "0", + "kind": "expression" }, "nativeType": "int4", "nullable": false @@ -822,11 +817,8 @@ "tags": { "codecId": "pg/jsonb@1", "default": { - "kind": "literal", - "value": [ - "alpha", - "beta" - ] + "expression": "'[\"alpha\",\"beta\"]'::jsonb", + "kind": "expression" }, "nativeType": "jsonb", "nullable": false @@ -886,8 +878,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -941,7 +932,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -949,8 +940,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -996,7 +986,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -1012,8 +1002,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -1048,7 +1037,7 @@ } } }, - "storageHash": "sha256:c01040e095a1fe5dd776c2d5afe4a7767c506e044e96ecd46bfa369a75a13733" + "storageHash": "sha256:2a8f1be899b83214689b90fd64943b9007becceadbaca802522bdadb29854f69" }, "execution": { "executionHash": "sha256:adc296c2bde14cd4e6a8a85ba202108dc7a320b5870a14d7dd8e2d2e2f5a7f27", diff --git a/test/e2e/framework/test/sqlite/fixtures/contract.ts b/test/e2e/framework/test/sqlite/fixtures/contract.ts index 7d7c9e14cf..0816381838 100644 --- a/test/e2e/framework/test/sqlite/fixtures/contract.ts +++ b/test/e2e/framework/test/sqlite/fixtures/contract.ts @@ -4,10 +4,14 @@ import { jsonColumn, textColumn, } from '@prisma-next/adapter-sqlite/column-types'; +import sqliteAdapter from '@prisma-next/adapter-sqlite/control'; import sqlFamilyPack from '@prisma-next/family-sql/pack'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; import { defineContract, field, model, rel } from '@prisma-next/sql-contract-ts/contract-builder'; import sqlitePack from '@prisma-next/target-sqlite/pack'; +const sqliteCodecLookup = extractCodecLookup([sqliteAdapter]); + const User = model('User', { fields: { id: field.column(integerColumn).id(), @@ -63,6 +67,7 @@ const Item = model('Item', { export const contract = defineContract({ family: sqlFamilyPack, target: sqlitePack, + codecLookup: sqliteCodecLookup, capabilities: { sql: { lateral: false, diff --git a/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts b/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts index b890c2fc99..09d4d7c743 100644 --- a/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts +++ b/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts @@ -15,7 +15,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:f52ad65b0b6148af276fd084c349f8f21a4a4da2ba8644dfac21b53dd47d9791'>; + StorageHashBase<'sha256:55e5e8254d07de5085f404759d2c325862d538704622df14e5f2db8a34a9d01b'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:213031a5ce861b455f22bc065769080ea0357fabcb999de0190524ecd32531f7'>; @@ -23,9 +23,6 @@ export type ProfileHash = export type CodecTypes = SqliteTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = Record; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Comment: { @@ -152,10 +149,7 @@ type ContractBase = ContractType< readonly nativeType: 'text'; readonly codecId: 'sqlite/text@1'; readonly nullable: false; - readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'sqlite/text@1', 'unnamed'>; - }; + readonly default: { readonly kind: 'expression'; readonly expression: "'unnamed'" }; }; }; primaryKey: { readonly columns: readonly ['id'] }; diff --git a/test/e2e/framework/test/sqlite/fixtures/generated/contract.json b/test/e2e/framework/test/sqlite/fixtures/generated/contract.json index f860e9437f..75f397dfe2 100644 --- a/test/e2e/framework/test/sqlite/fixtures/generated/contract.json +++ b/test/e2e/framework/test/sqlite/fixtures/generated/contract.json @@ -385,8 +385,8 @@ "label": { "codecId": "sqlite/text@1", "default": { - "kind": "literal", - "value": "unnamed" + "expression": "'unnamed'", + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -537,7 +537,7 @@ } } }, - "storageHash": "sha256:f52ad65b0b6148af276fd084c349f8f21a4a4da2ba8644dfac21b53dd47d9791" + "storageHash": "sha256:55e5e8254d07de5085f404759d2c325862d538704622df14e5f2db8a34a9d01b" }, "capabilities": { "sql": { diff --git a/test/e2e/framework/test/sqlite/migrations/harness.ts b/test/e2e/framework/test/sqlite/migrations/harness.ts index 897faf9f8b..6bb6353a4c 100644 --- a/test/e2e/framework/test/sqlite/migrations/harness.ts +++ b/test/e2e/framework/test/sqlite/migrations/harness.ts @@ -15,6 +15,7 @@ import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import { APP_SPACE_ID, createControlStack, + extractCodecLookup, type MigrationOperationPolicy, } from '@prisma-next/framework-components/control'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; @@ -37,7 +38,13 @@ const familyInstance = sqlFamilyDescriptor.create( const fw = [sqliteTargetDescriptor, sqliteAdapterDescriptor, sqliteDriverDescriptor] as const; -export const pack = { family: sqlFamilyPack, target: sqlitePack } as const; +const sqliteCodecLookup = extractCodecLookup([sqliteAdapterDescriptor]); + +export const pack = { + family: sqlFamilyPack, + target: sqlitePack, + codecLookup: sqliteCodecLookup, +} as const; export const int = field.column(integerColumn); export const text = field.column(textColumn); export { integerColumn, textColumn }; diff --git a/test/integration/test/authoring/parity/callback-mode-scalars/contract.ts b/test/integration/test/authoring/parity/callback-mode-scalars/contract.ts index 8114d9035c..c2b581a5a3 100644 --- a/test/integration/test/authoring/parity/callback-mode-scalars/contract.ts +++ b/test/integration/test/authoring/parity/callback-mode-scalars/contract.ts @@ -1,18 +1,27 @@ import * as pg from '@prisma-next/adapter-postgres/column-types'; +import postgresAdapter from '@prisma-next/adapter-postgres/control'; import pgvector from '@prisma-next/extension-pgvector/pack'; import sqlFamily from '@prisma-next/family-sql/pack'; -import { defineContract, rel } from '@prisma-next/sql-contract-ts/contract-builder'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; +import { autoincrement, defineContract, rel } from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; +const postgresCodecLookup = extractCodecLookup([postgresAdapter]); + export const contract = defineContract( - { family: sqlFamily, target: postgresPack, extensionPacks: { pgvector } }, + { + family: sqlFamily, + target: postgresPack, + extensionPacks: { pgvector }, + codecLookup: postgresCodecLookup, + }, ({ field, model, type }) => { const types = { Embedding: type.pgvector.Vector(1536), } as const; const User = model('User', { fields: { - id: field.column(pg.int4Column).defaultSql('autoincrement()').id(), + id: field.column(pg.int4Column).default(autoincrement()).id(), email: field.column(pg.textColumn).unique(), age: field.column(pg.int4Column), isActive: field.column(pg.boolColumn).default(true), @@ -24,7 +33,7 @@ export const contract = defineContract( }).sql({ table: 'user' }); const Post = model('Post', { fields: { - id: field.column(pg.int4Column).defaultSql('autoincrement()').id(), + id: field.column(pg.int4Column).default(autoincrement()).id(), userId: field.column(pg.int4Column), title: field.column(pg.textColumn), rating: field.column(pg.float8Column).optional(), diff --git a/test/integration/test/authoring/parity/callback-mode-scalars/expected.contract.json b/test/integration/test/authoring/parity/callback-mode-scalars/expected.contract.json index d30e16dcfd..61825ec13d 100644 --- a/test/integration/test/authoring/parity/callback-mode-scalars/expected.contract.json +++ b/test/integration/test/authoring/parity/callback-mode-scalars/expected.contract.json @@ -168,8 +168,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -225,7 +224,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -244,8 +243,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -253,8 +251,8 @@ "isActive": { "codecId": "pg/bool@1", "default": { - "kind": "literal", - "value": true + "expression": "TRUE", + "kind": "expression" }, "nativeType": "bool", "nullable": false @@ -284,7 +282,7 @@ } } }, - "storageHash": "sha256:8fa256466ba485fa9d52edc98e76680a9afd7042e65db1fcbb35b8518cf9fd28", + "storageHash": "sha256:9fb852123286e17142dac46de6aca8d6d4e6bbd423fe8737b5395b7681a34a19", "types": { "Embedding": { "codecId": "pg/vector@1", diff --git a/test/integration/test/authoring/parity/core-surface/contract.ts b/test/integration/test/authoring/parity/core-surface/contract.ts index d975fb0f75..b7bbff9b6f 100644 --- a/test/integration/test/authoring/parity/core-surface/contract.ts +++ b/test/integration/test/authoring/parity/core-surface/contract.ts @@ -7,10 +7,20 @@ import { textColumn, timestamptzColumn, } from '@prisma-next/adapter-postgres/column-types'; +import postgresAdapter from '@prisma-next/adapter-postgres/control'; import sqlFamily from '@prisma-next/family-sql/pack'; -import { defineContract, field, model, rel } from '@prisma-next/sql-contract-ts/contract-builder'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; +import { + autoincrement, + defineContract, + field, + model, + rel, +} from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; +const postgresCodecLookup = extractCodecLookup([postgresAdapter]); + const types = { Email: { kind: 'codec-instance', @@ -23,7 +33,7 @@ const types = { const User = model('User', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), email: field.namedType(types.Email).unique(), role: field.namedType(types.Role), createdAt: field.column(timestamptzColumn).defaultSql('now()'), @@ -34,7 +44,7 @@ const User = model('User', { const Post = model('Post', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), userId: field.column(int4Column), title: field.column(textColumn), rating: field.column(float8Column).optional(), @@ -56,6 +66,7 @@ const Post = model('Post', { export const contract = defineContract({ family: sqlFamily, target: postgresPack, + codecLookup: postgresCodecLookup, types, models: { User, diff --git a/test/integration/test/authoring/parity/core-surface/expected.contract.json b/test/integration/test/authoring/parity/core-surface/expected.contract.json index f07ec53d3b..1d5f70131f 100644 --- a/test/integration/test/authoring/parity/core-surface/expected.contract.json +++ b/test/integration/test/authoring/parity/core-surface/expected.contract.json @@ -148,8 +148,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -208,7 +207,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -222,8 +221,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -231,8 +229,8 @@ "isActive": { "codecId": "pg/bool@1", "default": { - "kind": "literal", - "value": true + "expression": "TRUE", + "kind": "expression" }, "nativeType": "bool", "nullable": false @@ -276,7 +274,7 @@ } } }, - "storageHash": "sha256:b6a07cf933f780f0be10605786b9d3434fda472d5327582cbe8dc7bf3551644e", + "storageHash": "sha256:a7d2bd70f2de893a5ca386b1dfe11a2b1ad3153330afe224ce665b90f77b1bc0", "types": { "Email": { "codecId": "pg/text@1", diff --git a/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json b/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json index e997afe3a2..781971d41f 100644 --- a/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json +++ b/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json @@ -39,7 +39,7 @@ "codecId": "pg/text@1", "default": { "expression": "gen_random_uuid()", - "kind": "function" + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -55,7 +55,7 @@ } } }, - "storageHash": "sha256:f83367183867bad124b5fc91b8913d1a1e36bccedfba80a471df5751201d63cc" + "storageHash": "sha256:8d5a1931090ec689b5165b7b50fa47c317641003b00ca2fe7af81e8c35c2fd63" }, "capabilities": { "postgres": { diff --git a/test/integration/test/authoring/parity/pgvector-named-type/contract.ts b/test/integration/test/authoring/parity/pgvector-named-type/contract.ts index 66b2616c46..39c116914e 100644 --- a/test/integration/test/authoring/parity/pgvector-named-type/contract.ts +++ b/test/integration/test/authoring/parity/pgvector-named-type/contract.ts @@ -1,6 +1,11 @@ import { int4Column } from '@prisma-next/adapter-postgres/column-types'; import sqlFamily from '@prisma-next/family-sql/pack'; -import { defineContract, field, model } from '@prisma-next/sql-contract-ts/contract-builder'; +import { + autoincrement, + defineContract, + field, + model, +} from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; const embedding1536Type = { @@ -19,7 +24,7 @@ export const contract = defineContract({ models: { Document: model('Document', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), embedding: field.namedType(embedding1536Type), }, }).sql({ table: 'document' }), diff --git a/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json b/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json index ef3572cbeb..509b373b81 100644 --- a/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json +++ b/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json @@ -54,8 +54,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -71,7 +70,7 @@ } } }, - "storageHash": "sha256:923650d8962586ae327c2224a8d4a589abf94ce9465182179bf7939228ef8cef", + "storageHash": "sha256:d59787db380c4912fea23876a391db524a5717e7d2d87e8ddbd3f8df82c2be28", "types": { "Embedding1536": { "codecId": "pg/vector@1", diff --git a/test/integration/test/authoring/side-by-side/postgres/contract.json b/test/integration/test/authoring/side-by-side/postgres/contract.json index d7acbe4d22..f7b0810eb8 100644 --- a/test/integration/test/authoring/side-by-side/postgres/contract.json +++ b/test/integration/test/authoring/side-by-side/postgres/contract.json @@ -151,8 +151,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -211,8 +210,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -235,7 +233,7 @@ } } }, - "storageHash": "sha256:0dd598e2a231ab7b6d81a6d82aed7e54e2894e3f4036809a80e71191086834fa" + "storageHash": "sha256:5bfa8fda93d8540d8baf252e32d130da152f41c09e8f8929af050774c6309246" }, "capabilities": { "postgres": { diff --git a/test/integration/test/authoring/side-by-side/postgres/contract.ts b/test/integration/test/authoring/side-by-side/postgres/contract.ts index 32d40493b7..9d4a2491db 100644 --- a/test/integration/test/authoring/side-by-side/postgres/contract.ts +++ b/test/integration/test/authoring/side-by-side/postgres/contract.ts @@ -4,12 +4,18 @@ import { timestamptzColumn, } from '@prisma-next/adapter-postgres/column-types'; import sqlFamily from '@prisma-next/family-sql/pack'; -import { defineContract, field, model, rel } from '@prisma-next/sql-contract-ts/contract-builder'; +import { + autoincrement, + defineContract, + field, + model, + rel, +} from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; const UserBase = model('User', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), name: field.column(textColumn), email: field.column(textColumn), bio: field.column(textColumn).optional(), @@ -18,7 +24,7 @@ const UserBase = model('User', { const Post = model('Post', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), authorId: field.column(int4Column), title: field.column(textColumn), publishedAt: field.column(timestamptzColumn).optional(), diff --git a/test/integration/test/contract-builder.default.production-helpers.test-d.ts b/test/integration/test/contract-builder.default.production-helpers.test-d.ts new file mode 100644 index 0000000000..5628260504 --- /dev/null +++ b/test/integration/test/contract-builder.default.production-helpers.test-d.ts @@ -0,0 +1,59 @@ +/** + * Compile-time type tests for `.default(autoincrement())` against the + * **production** Postgres column helpers (`pgInt4Column()`, `pgTextColumn()`, + * etc.) — as opposed to the synthetic test helper at + * `packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts` + * which short-circuits to a trait-bearing descriptor shape directly. + * + * The production helpers go through the framework `column()` packager, which + * surfaces the codec descriptor's `traits` tuple at the static type level. + * This test verifies the trait gate's reach extends from the synthetic test + * helper to the real production column helpers. + * + * Lives under `test/integration/` (not `packages/2-sql/2-authoring/contract-ts/test/`) + * because the `sql` domain is forbidden from importing the `targets` domain + * per `architecture.config.json § crossDomainRules` — and the production + * helpers live in `@prisma-next/target-postgres`. The integration-tests + * workspace already depends on both packages, which is the + * layering-correct home for cross-cutting compile tests. + */ + +import { autoincrement, field } from '@prisma-next/sql-contract-ts/contract-builder'; +import { pgBoolColumn, pgInt4Column, pgTextColumn } from '@prisma-next/target-postgres/codecs'; +import { describe, test } from 'vitest'; + +describe('.default(autoincrement()) trait gating against production column helpers', () => { + test('compiles when production codec helper carries the autoincrement trait', () => { + field.column(pgInt4Column()).default(autoincrement()); + }); + + test('compile error when production codec helper lacks the autoincrement trait', () => { + // @ts-expect-error pg/text@1 does not carry the autoincrement trait + field.column(pgTextColumn()).default(autoincrement()); + // @ts-expect-error pg/bool@1 does not carry the autoincrement trait + field.column(pgBoolColumn()).default(autoincrement()); + }); +}); + +describe('.default(value) extracts codec TInput from production column helpers', () => { + test('pgTextColumn().default(string) compiles', () => { + field.column(pgTextColumn()).default('hello'); + }); + + test('pgInt4Column().default(number) compiles', () => { + field.column(pgInt4Column()).default(42); + }); + + test('pgBoolColumn().default(boolean) compiles', () => { + field.column(pgBoolColumn()).default(true); + }); + + test('rejects values outside the codec TInput', () => { + // @ts-expect-error pg/text@1 codec TInput is string, not number + field.column(pgTextColumn()).default(42); + // @ts-expect-error pg/int4@1 codec TInput is number, not string + field.column(pgInt4Column()).default('not a number'); + // @ts-expect-error pg/bool@1 codec TInput is boolean, not string + field.column(pgBoolColumn()).default('true'); + }); +}); diff --git a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts index 0e4367a430..31596eefff 100644 --- a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts +++ b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts @@ -7,10 +7,19 @@ import { textColumn, timestamptzColumn, } from '@prisma-next/adapter-postgres/column-types'; +import postgresAdapter from '@prisma-next/adapter-postgres/control'; import sqlFamily from '@prisma-next/family-sql/pack'; -import { defineContract, field, model } from '@prisma-next/sql-contract-ts/contract-builder'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; +import { + autoincrement, + defineContract, + field, + model, +} from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; +const postgresCodecLookup = extractCodecLookup([postgresAdapter]); + const types = { Email: { kind: 'codec-instance', @@ -23,7 +32,7 @@ const types = { const User = model('User', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), email: field.namedType(types.Email).unique(), role: field.namedType(types.Role), createdAt: field.column(timestamptzColumn).defaultSql('now()'), @@ -34,7 +43,7 @@ const User = model('User', { const Post = model('Post', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), userId: field.column(int4Column), title: field.column(textColumn), rating: field.column(float8Column).optional(), @@ -57,6 +66,7 @@ const Post = model('Post', { export const contract = defineContract({ family: sqlFamily, target: postgresPack, + codecLookup: postgresCodecLookup, types, models: { User, diff --git a/test/integration/test/fixtures/contract.d.ts b/test/integration/test/fixtures/contract.d.ts index a341a84817..5950a06d97 100644 --- a/test/integration/test/fixtures/contract.d.ts +++ b/test/integration/test/fixtures/contract.d.ts @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly User: { diff --git a/test/integration/test/sql-builder/fixtures/generated/contract.d.ts b/test/integration/test/sql-builder/fixtures/generated/contract.d.ts index 8cf745a622..bb3604dd3a 100644 --- a/test/integration/test/sql-builder/fixtures/generated/contract.d.ts +++ b/test/integration/test/sql-builder/fixtures/generated/contract.d.ts @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Article: { readonly id: Char<36>; readonly title: CodecTypes['pg/text@1']['output'] }; diff --git a/test/integration/test/value-objects/fixtures/generated/sql-contract.d.ts b/test/integration/test/value-objects/fixtures/generated/sql-contract.d.ts index 1aebfc51a0..b8f1b64980 100644 --- a/test/integration/test/value-objects/fixtures/generated/sql-contract.d.ts +++ b/test/integration/test/value-objects/fixtures/generated/sql-contract.d.ts @@ -46,8 +46,7 @@ type ContractBase = ContractShape< readonly codecId: 'pg/int4@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: 'autoincrement()'; + readonly kind: 'autoincrement'; }; }; readonly name: { diff --git a/test/integration/test/value-objects/fixtures/generated/sql-contract.json b/test/integration/test/value-objects/fixtures/generated/sql-contract.json index 368944ccfb..56518536b5 100644 --- a/test/integration/test/value-objects/fixtures/generated/sql-contract.json +++ b/test/integration/test/value-objects/fixtures/generated/sql-contract.json @@ -70,8 +70,7 @@ "nativeType": "int4", "nullable": false, "default": { - "kind": "function", - "expression": "autoincrement()" + "kind": "autoincrement" } }, "name": {