diff --git a/docs/architecture docs/ADR-INDEX.md b/docs/architecture docs/ADR-INDEX.md index db9f8fa10cc4..ceb895f031c9 100644 --- a/docs/architecture docs/ADR-INDEX.md +++ b/docs/architecture docs/ADR-INDEX.md @@ -67,6 +67,7 @@ This document provides a comprehensive index of all Architectural Decision Recor | 157 | Execution enums | Defines execution-plane enum behavior derived from explicit storage enforcement; builds on ADR 155 and ADR 156 | [ADR 157 - Execution enums.md](adrs/ADR%20157%20-%20Execution%20enums.md) | | 158 | Execution mutation defaults | Defines execution-plane mutation defaults (`execution.mutations.defaults`) and a section-owned hashing model to avoid marker churn | [ADR 158 - Execution mutation defaults.md](adrs/ADR%20158%20-%20Execution%20mutation%20defaults.md) | | 168 | Postgres JSON and JSONB typed columns | Adds first-class PostgreSQL `json`/`jsonb` codec and column support with Standard Schema-based typed emission in `contract.d.ts` | [ADR 168 - Postgres JSON and JSONB typed columns.md](adrs/ADR%20168%20-%20Postgres%20JSON%20and%20JSONB%20typed%20columns.md) | +| 184 | Codec-owned value serialization | Codecs own every value boundary: `encodeJson`/`decodeJson` for contract JSON and `encodePsl`/`decodePsl` for PSL literals are required `Codec` members dispatched by codec id; DDL methods stay future work | [ADR 184 - Codec-owned value serialization.md](adrs/ADR%20184%20-%20Codec-owned%20value%20serialization.md) | | 186 | Codec-dispatched type rendering | Codecs own TypeScript type rendering via `renderOutputType` and `FieldOutputTypes`; removes `EmissionSpi.generateModelsType?` override and legacy renderer infrastructure | [ADR 186 - Codec-dispatched type rendering.md](adrs/ADR%20186%20-%20Codec-dispatched%20type%20rendering.md) | | 169 | Declared applicability for mutation default generators | Records the decision to validate generator/column compatibility via contributor-declared applicability and to assemble generator implementations via composed registries | [ADR 169 - Declared applicability for mutation default generators.md](adrs/ADR%20169%20-%20Declared%20applicability%20for%20mutation%20default%20generators.md) | | 160 | Plan grouping keys for multi-statement orchestration | Adds `meta.groupingKey` to correlate multiple statement executions that serve one higher-level operation | [ADR 160 - Plan grouping keys for multi-statement orchestration.md](adrs/ADR%20160%20-%20Plan%20grouping%20keys%20for%20multi-statement%20orchestration.md) | diff --git a/docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md b/docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md index 401a1e4a0efe..7f21e5524ebf 100644 --- a/docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md +++ b/docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md @@ -173,6 +173,61 @@ ADR 167 proposed a standalone `DefaultLiteralCodec` interface, parallel to `Code Rejected because this isn't a separate kind of codec — it's an extension of codec responsibilities. The codec already owns the type; value serialization is part of what owning a type means. +## Amendment — PSL literal methods live on `Codec` (2026-09-16) + +`encodePsl` and `decodePsl` are required members of the `Codec` interface and abstract members of `CodecImpl`, next to `encode`, `decode`, `encodeJson`, and `decodeJson`. Every codec states the PSL literal that denotes its values; there is no default on the base class. + +```ts +/** A PSL scalar literal as its content, with the fence removed and escapes resolved. */ +interface PslLiteral { + readonly kind: 'string' | 'number' | 'boolean'; + /** string: the characters between the quotes with escapes resolved. number: the digits exactly as written. boolean: 'true' or 'false'. */ + readonly text: string; +} + +interface Codec { + // ... encode, decode, encodeJson, decodeJson + encodePsl(value: TInput): PslLiteral; + decodePsl(literal: PslLiteral): TInput; +} +``` + +The codec never sees the fence, and a number's digits reach it verbatim: a big integer or a decimal is never converted to a JavaScript number before the codec reads it. The PSL interpreter passes each `@default` literal to the column codec's `decodePsl` and stores the result through `encodeJson`; a literal the codec refuses is the diagnostic `PSL_INVALID_DEFAULT_LITERAL`, carrying the codec's message. The `contract infer` printer calls `encodePsl` on the value `decodeJson` read from the contract and writes the literal with the fence and escapes added. + +The `PslLiteralCodec` interface sketched above is not a separate entity and never was: it is the consumer's view of the same codec, the dependency inversion the "Single interface with all boundaries" alternative describes. That alternative is therefore no longer rejected for PSL. It stays rejected for DDL: `encodeDdl` and `decodeDdl` are not built. Until they are, a raw SQL default carries any value the JSON and PSL forms cannot express, and the Prisma 7 contract source keeps turning `Bytes` and `DateTime` literal defaults into raw SQL expressions, because verification cannot yet compare those as typed values. + +### The rule for a codec's PSL form + +One rule, applied to every codec, keyed on the JSON form `encodeJson` produces: + +- A JSON string is a string literal holding that string: `{ kind: 'string', text }`. +- A JSON number is a number literal with no exponent: `{ kind: 'number', text }`, the exact decimal text of the value. +- A JSON boolean is a boolean literal. +- A JSON object, array, or null is a string literal holding the JSON text: `{ kind: 'string', text: JSON.stringify(json) }`, which `decodePsl` parses and hands to `decodeJson`. The JSON codecs, `arktype/json@1`, `pg/vector@1`, and the Mongo vector codec take this form. + +The shared pairs in `@internal/framework-components/codec` implement the rule: `encodeStringPsl`/`decodeStringPsl`, `encodeNumberPsl`/`decodeNumberPsl`, `decodeWholeNumberPsl`, `encodeFloatPsl`/`decodeFloatPsl`, `encodeBooleanPsl`/`decodeBooleanPsl`, and `encodeJsonTextPsl`/`decodeJsonTextPsl`. Every decode error has one shape, ` reads ; got ` (for example `pg/int4@1 reads a whole number literal; got a number 1.5`, `pg/float8@1 reads a number literal or "NaN", "Infinity", "-Infinity"; got a boolean true`, `pg/jsonb@1 reads a string literal holding JSON text; got a number 1`), so the interpreter's diagnostic names the codec. + +```ts +class PgTextCodec extends CodecImpl<'pg/text@1', readonly ['equality', 'order', 'textual'], string, string> { + // ... encode, decode, encodeJson, decodeJson + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } +} +``` + +The named exceptions, each the way PSL is already written: + +- `pg/float4@1` and `pg/float8@1` write `NaN`, `Infinity`, and `-Infinity` as the quoted strings `"NaN"`, `"Infinity"`, `"-Infinity"`, because PSL has no number token for them, and read both that string and the number token the tokenizer produces for the bare text. Their JSON form and their wire form carry those three values as that text; finite values stay numbers. +- `pg/int8@1`, `pg/unboundedint@1`, and `pg/numeric@1` read the digits from `text` and print them as text, so every digit of a big integer or a decimal survives. `pg/numeric@1` canonicalises leading zeros and the sign of zero (`007` → `7`, `-0` → `0`, `-0.00` → `0.00`; trailing zeros are kept), writes the three special values as quoted strings, and also reads a quoted decimal string. +- The integer codecs (`pg/int4@1`, `pg/int2@1`, `pg/int8@1`, `pg/int8number@1`, `pg/unboundedint@1`, `sql/int@1`, `sqlite/integer@1`, `sqlite/bigint@1`, `sqlite/bigintnumber@1`, `mongo/int32@1`) reject a fraction: `pg/int4@1 reads a whole number literal; got a number 1.5`. +- `sql/float@1` and `sqlite/real@1` refuse non-finite values in PSL as they do in JSON. +- A codec whose JSON form is a string but whose value is not (`pg/bytea@1` and `sqlite/blob@1` as base64 or hex text, `pg/geometry@1` as HEXEWKB, `pg/interval@1` as an ISO duration, the Temporal codecs, and the `Date` codecs) uses the string rule and carries `encodeJson`/`decodeJson` through it: `encodePsl` writes `encodeJson(value)` as the string, `decodePsl` returns `decodeJson(text)`. +- The Mongo `mongoCodec({...})` factory takes `encodePsl` and `decodePsl` as required config members, so every Mongo codec declares its PSL form explicitly too. + ## Supersedes - **ADR 167 v2** (deferred codec-keyed `DefaultLiteralCodec` SPI) — this ADR generalizes and implements the concept. The v1 hardcoded pipeline is replaced. diff --git a/docs/reference/codec-authoring-guide.md b/docs/reference/codec-authoring-guide.md index 3208932121ac..389e0efb5075 100644 --- a/docs/reference/codec-authoring-guide.md +++ b/docs/reference/codec-authoring-guide.md @@ -6,7 +6,7 @@ This guide describes the canonical authoring shape for codecs in Prisma 8: **cla A codec is **three artifacts**: -1. A **codec class** that extends `CodecImpl` and implements all four conversion methods: `encode`, `decode`, `encodeJson`, and `decodeJson`. +1. A **codec class** that extends `CodecImpl` and implements all six conversion methods: `encode` and `decode` for the driver wire form, `encodeJson` and `decodeJson` for the JSON form stored in contract artifacts, and `encodePsl` and `decodePsl` for the PSL literal that denotes a value in schema source (`@default(...)`). The PSL pair is synchronous and required; the shared pairs in `@internal/framework-components/codec` (`encodeStringPsl`/`decodeStringPsl`, `encodeNumberPsl`/`decodeNumberPsl`, `decodeWholeNumberPsl`, `encodeFloatPsl`/`decodeFloatPsl`, `encodeBooleanPsl`/`decodeBooleanPsl`, `encodeJsonTextPsl`/`decodeJsonTextPsl`) cover the common shapes, and [ADR 184](../architecture%20docs/adrs/ADR%20184%20-%20Codec-owned%20value%20serialization.md) records the rule for which shape a codec takes. 2. A **descriptor class** that extends `CodecDescriptorImpl

` for a target-neutral codec, or the target-owned `PostgresCodecDescriptor

` / `SqliteCodecDescriptor

` for a target-bound SQL codec, and declares the codec id, traits, target types, params schema, and the curried factory that materializes codec instances. 3. A **per-codec column helper function** that calls `descriptor.factory(...)` directly and packages the result into a `ColumnSpec` via the framework-supplied `column(...)` packager. The helper carries a `satisfies ColumnHelperFor` clause that ties it to its descriptor at compile time. @@ -17,6 +17,8 @@ The framework imports live at `@internal/framework-components/codec`: - `ColumnHelperFor` / `ColumnHelperForStrict` — `satisfies` shapes for per-codec helpers. - `column(codecFactory, codecId, typeParams, nativeType)` — column-spec packager (`nativeType` is the database spelling for migrations and contract meta). - `voidParamsSchema` — Standard Schema validator for `P = void` (non-parameterized codecs). +- `PslLiteral` — the `{ kind: 'string' | 'number' | 'boolean', text }` shape `encodePsl` returns and `decodePsl` receives, with the fence removed and escapes resolved. +- `encodeStringPsl`, `decodeStringPsl`, `encodeNumberPsl`, `decodeNumberPsl`, `decodeWholeNumberPsl`, `encodeFloatPsl`, `decodeFloatPsl`, `encodeBooleanPsl`, `decodeBooleanPsl`, `encodeJsonTextPsl`, `decodeJsonTextPsl` — the shared PSL pairs; each decode helper takes the codec id so its error names the codec. - `Codec<...>`, `CodecDescriptor

`, `AnyCodecDescriptor` — consumer-facing interfaces (consumers depend on these; target-neutral authors extend the `*Impl` classes, while target-bound SQL authors use target-owned bases). SQL codecs use the same framework `CodecImpl` base. Their `encodeJson` and `decodeJson` methods define the codec's JSON-safe contract representation; `decode` remains responsible for the driver's ordinary column wire value. Keep that representation stable and mutually consistent, and keep `decodeJson` compatible with the values the current SQL JSON renderer returns for the codec. This distinction matters for types such as PostgreSQL `bytea` and extension-defined types whose values inside database-produced JSON may differ from their normal driver representation. @@ -56,6 +58,9 @@ import { CodecImpl, type ColumnHelperFor, column, + decodeStringPsl, + encodeStringPsl, + type PslLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; import type { ProjectionExpr } from '@internal/sql-relational-core/ast'; @@ -76,6 +81,8 @@ class PgTextCodec extends CodecImpl< } return json; } + encodePsl(value: string): PslLiteral { return encodeStringPsl(value); } + decodePsl(literal: PslLiteral): string { return decodeStringPsl(this.id, literal); } } class PgTextDescriptor extends PostgresCodecDescriptor { @@ -104,6 +111,32 @@ text satisfies ColumnHelperFor; The factory is **constant**: every call returns the same shared codec instance. The runtime relies on this contract — non-parameterized columns sharing a codec id share one resolved codec without explicit caching. +The PSL pair is the string rule: a `pg/text@1` value is a JSON string, so it is written as a string literal (`@default("hello")`) and read back from one. `decodeStringPsl(this.id, literal)` throws `pg/text@1 reads a string literal; got a number 5` for any other literal kind, and the PSL interpreter turns that into `PSL_INVALID_DEFAULT_LITERAL` at the attribute. + +#### A JSON-valued codec (`pg/jsonb@1`) + +A codec whose JSON form is an object, array, or null takes the JSON-text rule: the PSL literal is a string holding the JSON text (`@default("{\"a\":1}")`, `@default("[1, 2]")`, `@default("null")`), which `decodePsl` parses and hands to `decodeJson`. + +```ts +import { + decodeJsonTextPsl, + encodeJsonTextPsl, +} from '@internal/framework-components/codec'; + +class PgJsonbCodec extends CodecImpl<'pg/jsonb@1', readonly ['equality'], string | JsonValue, JsonValue> { + async encode(value: JsonValue, _ctx: CodecCallContext) { return JSON.stringify(value); } + async decode(wire: string | JsonValue, _ctx: CodecCallContext) { + return typeof wire === 'string' ? (JSON.parse(wire) as JsonValue) : wire; + } + encodeJson(value: JsonValue) { return value; } + decodeJson(json: JsonValue) { return json; } + encodePsl(value: JsonValue): PslLiteral { return encodeJsonTextPsl(value); } + decodePsl(literal: PslLiteral): JsonValue { return decodeJsonTextPsl(this.id, literal); } +} +``` + +A codec whose value is not a string but whose JSON form is one (`pg/bytea@1` as base64, the Temporal codecs) also writes a string literal, carrying `encodeJson`/`decodeJson` through it: `encodeStringPsl(this.encodeJson(value))` and `this.decodeJson(decodeStringPsl(this.id, literal))`. + ### Case 2 — Parameterized codec with literal preservation (`pg/vector@1`) ```ts @@ -475,7 +508,7 @@ The class hierarchy isn't load-bearing for variance preservation (per-codec help - **`override` discipline.** With `noImplicitOverride`, every concrete-subclass member that touches an inherited member must carry `override`. Forgetting it surfaces as a typecheck error. - **Don't widen the factory return at the descriptor.** Concrete descriptors should declare their factory's typed return (`(ctx) => VectorCodec`, not `(ctx) => Codec<...>`). The widened return loses literal preservation at consumer sites. - **Don't extract codec types via `Parameters` / `ReturnType` of the descriptor's `factory`.** TypeScript widens method generics to their constraint in those forms. Use the per-codec helper's typed return (`ColumnSpec`) and project with `R extends Codec ? T : never`. -- **Don't reach through the codec instance for metadata.** The runtime `Codec` instance is narrow (id + four conversion methods). Read traits / target types / meta from `descriptor` (e.g. `context.codecDescriptors.descriptorFor(codecId).traits`). +- **Don't reach through the codec instance for metadata.** The runtime `Codec` instance is narrow (id + six conversion methods). Read traits / target types / meta from `descriptor` (e.g. `context.codecDescriptors.descriptorFor(codecId).traits`). ## See also diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 2398c43d0d85..bbaeaf13b164 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -415,7 +415,7 @@ The TypeScript contract module imports something outside the contract-source imp ### CONTRACT.SOURCE_DIAGNOSTIC -One finding a contract source reported with a code that is not yet dotted, such as the Prisma 8 PSL interpreter's `PSL_UNSUPPORTED_FIELD_TYPE` or a parser's `PSL_PARSE_ERROR`. This is its only producer case: it exists until those codes convert to dotted ones, and a source code that is already dotted, such as `PSL.PRISMA7_VIEW_UNSUPPORTED`, is reported under its own code instead. Never raised on its own; carried, one per such source diagnostic, in the `diagnostics` list of a `CONTRACT.SOURCE_LOAD_FAILED` error during `contract emit`, printed under it in the terminal and serialized as the envelope's `diagnostics` in JSON. `summary` is `:: : ` (the location is omitted when the source gave none). `where` carries `path` and `line`. Payload: `code` (the source's own diagnostic code). Fix: edit the schema at each location the findings name, then run `prisma contract emit` again. +One finding a contract source reported with a code that is not yet dotted, such as the Prisma 8 PSL interpreter's `PSL_UNSUPPORTED_FIELD_TYPE` or `PSL_INVALID_DEFAULT_LITERAL`, or a parser's `PSL_PARSE_ERROR`. `PSL_INVALID_DEFAULT_LITERAL` is a `@default(...)` literal the column codec does not read; its message is `Field ".": @default() is not a value of : ` (for example `pg/int4@1 reads a whole number literal; got a number 1.5`), and the fix is to write the literal in the form the codec reads: a whole number on an integer column, a string holding JSON text on a JSON column, a quoted `"NaN"` on a float column. This is its only producer case: it exists until those codes convert to dotted ones, and a source code that is already dotted, such as `PSL.PRISMA7_VIEW_UNSUPPORTED`, is reported under its own code instead. Never raised on its own; carried, one per such source diagnostic, in the `diagnostics` list of a `CONTRACT.SOURCE_LOAD_FAILED` error during `contract emit`, printed under it in the terminal and serialized as the envelope's `diagnostics` in JSON. `summary` is `:: : ` (the location is omitted when the source gave none). `where` carries `path` and `line`. Payload: `code` (the source's own diagnostic code). Fix: edit the schema at each location the findings name, then run `prisma contract emit` again. ### CONTRACT.SOURCE_LOAD_FAILED @@ -537,7 +537,7 @@ An attribute Prisma 7 for the target does not have, or one the source does not r ### PSL.PRISMA7_UNKNOWN_DEFAULT -A `@default` value the source cannot read: an unknown function, an enum member on a non-enum field or a non-member, a number with a fraction on an `Int` or `BigInt` field, a malformed JSON or base64 literal, or `dbgenerated()` with no expression on a required field. Use a literal, an enum member, or a supported function. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +A `@default` value the source cannot read: an unknown function, an enum member on a non-enum field or a non-member, a literal the column codec does not read (reported as `Field ".": @default() is not a value of : `, for example a number with a fraction on an `Int` or `BigInt` field, a number on a `String`, `Bytes`, `DateTime`, or `Boolean` field, or a string that is not JSON on a `Json` field), or `dbgenerated()` with no expression on a required field. Use a literal the codec reads, an enum member, or a supported function. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_UNSUPPORTED_TYPE diff --git a/packages/1-framework/1-core/framework-components/src/exports/codec.ts b/packages/1-framework/1-core/framework-components/src/exports/codec.ts index eea036829c85..bc9a1c4d6022 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/codec.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/codec.ts @@ -17,6 +17,7 @@ export type { CodecRef, CodecRegistry, CodecTrait, + PslLiteral, } from '../shared/codec-types'; export { emptyCodecLookup, voidParamsSchema } from '../shared/codec-types'; export type { @@ -26,6 +27,21 @@ export type { ColumnTypeDescriptor, } from '../shared/column-spec'; export { column } from '../shared/column-spec'; +export { + decodeBooleanPsl, + decodeFloatPsl, + decodeJsonTextPsl, + decodeNumberPsl, + decodeStringPsl, + decodeWholeNumberPsl, + encodeBooleanPsl, + encodeFloatPsl, + encodeJsonTextPsl, + encodeNumberPsl, + encodeStringPsl, + pslLiteralKindError, + pslLiteralReadsError, +} from '../shared/psl-literal-helpers'; export { renderTsLiteral } from '../shared/render-ts-literal'; export { CONTRACT_CODEC_DESCRIPTOR_MISSING, 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 e5f148bdfc7f..f560f547d77f 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 @@ -13,6 +13,13 @@ export function isCodecTrait(value: unknown): value is CodecTrait { return CODEC_TRAITS.some((trait) => trait === value); } +/** A PSL scalar literal as its content, with the fence removed and escapes resolved. */ +export interface PslLiteral { + readonly kind: 'string' | 'number' | 'boolean'; + /** string: the characters between the quotes with escapes resolved. number: the digits exactly as written. boolean: 'true' or 'false'. */ + readonly text: string; +} + /** * Serializable codec identity carried by every codec-bearing AST node. * diff --git a/packages/1-framework/1-core/framework-components/src/shared/codec.ts b/packages/1-framework/1-core/framework-components/src/shared/codec.ts index 75f8a8cec974..3ea2fa425a0f 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/codec.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/codec.ts @@ -10,10 +10,10 @@ import type { JsonValue } from '@internal/contract/types'; import type { CodecDescriptor } from './codec-descriptor'; -import type { CodecCallContext, CodecTrait } from './codec-types'; +import type { CodecCallContext, CodecTrait, PslLiteral } from './codec-types'; /** - * A codec is the contract between an application value and its driver-wire and JSON representations. + * A codec is the contract between an application value and its driver-wire, JSON, and PSL literal representations. * * The author's mental model is two JS-side types — `TInput` (the application JS type) and `TWire` (the database driver wire format) — plus a target-defined `JsonValue`. The codec translates `TInput` to `TWire` on writes and back on ordinary reads, and to/from the target's JSON representation for contract artifacts and database-produced JSON values. * @@ -22,12 +22,13 @@ import type { CodecCallContext, CodecTrait } from './codec-types'; * - **Wire** (`TWire`): the format exchanged with the database driver. * - **JSON** (`JsonValue`): the target-defined JSON-safe form used in contract artifacts. It uses the exact scalar shape the target produces inside JSON values, which can differ from the ordinary wire format. * - * The runtime instance carries only its `id` (the descriptor's `codecId`, set by the factory) and the four conversion methods. Static metadata (`traits`, `targetTypes`) and the build-time `renderOutputType` renderer live on the {@link CodecDescriptor} keyed by `codecId` — the read-surface single source of truth. Consumers that need them resolve through `descriptorFor(codecId)`. + * The runtime instance carries only its `id` (the descriptor's `codecId`, set by the factory) and the six conversion methods. Static metadata (`traits`, `targetTypes`) and the build-time `renderOutputType` renderer live on the {@link CodecDescriptor} keyed by `codecId` — the read-surface single source of truth. Consumers that need them resolve through `descriptorFor(codecId)`. * - * Codec methods split into two groups: + * Codec methods split into three pairs: * * - **Query-time** methods (`encode`, `decode`) run per row/parameter at the IO boundary; they are required and Promise-returning. The per-family codec factory accepts sync or async author functions and lifts sync ones to Promise-shaped methods automatically. * - **JSON** methods (`encodeJson`, `decodeJson`) run when the contract is serialized or loaded. Runtimes may also use `decodeJson` for values embedded in database-produced JSON results. They stay synchronous so contract validation and client construction are synchronous. + * - **PSL** methods (`encodePsl`, `decodePsl`) run when a schema is read and when a schema is printed. They translate between the codec's value and the {@link PslLiteral} that denotes it in schema source. They are synchronous and required: every codec states its own PSL form. * * Target-family codec interfaces extend this base; family-specific concerns (e.g. the SQL `column?` per-call context) layer on through the `CodecCallContext` extension pattern. */ @@ -49,12 +50,16 @@ export interface Codec< encodeJson(value: TInput): JsonValue; /** Converts the target-defined JSON representation back to the JS input type. Synchronous; called during contract loading via `family.deserializeContract` and may be called by runtimes for embedded JSON values. */ decodeJson(json: JsonValue): TInput; + /** The PSL literal that denotes this value in schema source. */ + encodePsl(value: TInput): PslLiteral; + /** The value a PSL literal denotes. Throws when the literal is not a value of this type. */ + decodePsl(literal: PslLiteral): TInput; } /** * Abstract base class for concrete codec implementations. * - * Codec authors extend this class with their typed `Id`, `TTraits`, `TWire`, `TInput` and override all four abstract conversion methods: `encode`, `decode`, `encodeJson`, and `decodeJson`. The runtime instance carries only its `id` (proxied through the descriptor so alias subclasses inherit the descriptor's id automatically) and the conversion methods — static metadata lives on the {@link CodecDescriptor}. + * Codec authors extend this class with their typed `Id`, `TTraits`, `TWire`, `TInput` and override all six abstract conversion methods: `encode`, `decode`, `encodeJson`, `decodeJson`, `encodePsl`, and `decodePsl`. The runtime instance carries only its `id` (proxied through the descriptor so alias subclasses inherit the descriptor's id automatically) and the conversion methods — static metadata lives on the {@link CodecDescriptor}. */ export abstract class CodecImpl< Id extends string = string, @@ -77,4 +82,6 @@ export abstract class CodecImpl< abstract decode(wire: TWire, ctx: CodecCallContext): Promise; abstract encodeJson(value: TInput): JsonValue; abstract decodeJson(json: JsonValue): TInput; + abstract encodePsl(value: TInput): PslLiteral; + abstract decodePsl(literal: PslLiteral): TInput; } diff --git a/packages/1-framework/1-core/framework-components/src/shared/psl-literal-helpers.ts b/packages/1-framework/1-core/framework-components/src/shared/psl-literal-helpers.ts new file mode 100644 index 000000000000..fa0f2664dee9 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/shared/psl-literal-helpers.ts @@ -0,0 +1,108 @@ +/** + * Shared `encodePsl` / `decodePsl` pairs for the common codec shapes. A codec whose value is a JS string, number, or boolean, or whose JSON form is a JSON document, delegates to one pair here. Every decode error has the same shape: ` reads a literal; got `. + */ + +import type { JsonValue } from '@internal/contract/types'; +import { blindCast } from '@internal/utils/casts'; +import type { PslLiteral } from './codec-types'; + +function describeLiteral(literal: PslLiteral): string { + const text = literal.kind === 'string' ? JSON.stringify(literal.text) : literal.text; + return `a ${literal.kind} ${text}`; +} + +/** The error a codec throws when `literal` is not what it reads; `reads` completes "` reads ...`". */ +export function pslLiteralReadsError( + codecId: string, + reads: string, + literal: PslLiteral, + detail = '', +): Error { + return new Error(`${codecId} reads ${reads}; got ${describeLiteral(literal)}${detail}`); +} + +/** The error a codec throws when `literal` is not the kind it reads. */ +export function pslLiteralKindError(codecId: string, expected: string, literal: PslLiteral): Error { + return pslLiteralReadsError(codecId, `a ${expected} literal`, literal); +} + +export function encodeStringPsl(value: string): PslLiteral { + return { kind: 'string', text: value }; +} + +export function decodeStringPsl(codecId: string, literal: PslLiteral): string { + if (literal.kind !== 'string') throw pslLiteralKindError(codecId, 'string', literal); + return literal.text; +} + +/** PSL has no exponent syntax, so the decimal point moves to where the exponent puts it. */ +export function encodeNumberPsl(value: number): PslLiteral { + const [coefficient = '', exponent] = String(value).split('e'); + if (exponent === undefined) return { kind: 'number', text: coefficient }; + const sign = coefficient.startsWith('-') ? '-' : ''; + const [whole = '', fraction = ''] = coefficient.slice(sign.length).split('.'); + const digits = `${whole}${fraction}`; + const point = whole.length + Number(exponent); + if (point <= 0) return { kind: 'number', text: `${sign}0.${'0'.repeat(-point)}${digits}` }; + if (point >= digits.length) { + return { kind: 'number', text: `${sign}${digits}${'0'.repeat(point - digits.length)}` }; + } + return { kind: 'number', text: `${sign}${digits.slice(0, point)}.${digits.slice(point)}` }; +} + +export function decodeNumberPsl(codecId: string, literal: PslLiteral): number { + if (literal.kind !== 'number') throw pslLiteralKindError(codecId, 'number', literal); + return Number(literal.text); +} + +const WHOLE_NUMBER_TEXT = /^-?\d+$/; + +/** The digits of a whole number literal, as written; an integer codec turns them into its own number type. */ +export function decodeWholeNumberPsl(codecId: string, literal: PslLiteral): string { + if (literal.kind !== 'number' || !WHOLE_NUMBER_TEXT.test(literal.text)) { + throw pslLiteralKindError(codecId, 'whole number', literal); + } + return literal.text; +} + +const NON_FINITE_TEXT = /^(?:NaN|-?Infinity)$/; + +/** PSL has no number token for a non-finite value, so it is written as the quoted string `"NaN"`, `"Infinity"` or `"-Infinity"`. */ +export function encodeFloatPsl(value: number): PslLiteral { + return Number.isFinite(value) ? encodeNumberPsl(value) : { kind: 'string', text: String(value) }; +} + +export function decodeFloatPsl(codecId: string, literal: PslLiteral): number { + if (literal.kind === 'number') return Number(literal.text); + if (literal.kind === 'string' && NON_FINITE_TEXT.test(literal.text)) return Number(literal.text); + throw pslLiteralReadsError( + codecId, + 'a number literal or "NaN", "Infinity", "-Infinity"', + literal, + ); +} + +export function encodeBooleanPsl(value: boolean): PslLiteral { + return { kind: 'boolean', text: value ? 'true' : 'false' }; +} + +export function decodeBooleanPsl(codecId: string, literal: PslLiteral): boolean { + if (literal.kind !== 'boolean') throw pslLiteralKindError(codecId, 'boolean', literal); + return literal.text === 'true'; +} + +const JSON_TEXT = 'a string literal holding JSON text'; + +export function encodeJsonTextPsl(json: JsonValue): PslLiteral { + return { kind: 'string', text: JSON.stringify(json) }; +} + +export function decodeJsonTextPsl(codecId: string, literal: PslLiteral): JsonValue { + if (literal.kind !== 'string') throw pslLiteralReadsError(codecId, JSON_TEXT, literal); + try { + return blindCast(JSON.parse(literal.text)); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw pslLiteralReadsError(codecId, JSON_TEXT, literal, ` that is not valid JSON: ${reason}`); + } +} diff --git a/packages/1-framework/1-core/framework-components/test/codec-types.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/codec-types.types.test-d.ts index cfe1e8a5ceae..544d83e3e3c2 100644 --- a/packages/1-framework/1-core/framework-components/test/codec-types.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/codec-types.types.test-d.ts @@ -32,7 +32,7 @@ test('decodeJson is required and synchronous', () => { expectTypeOf().not.toExtend>(); }); -test('Codec instance carries only id + the four conversion methods (plus phantom)', () => { +test('Codec instance carries only id + the six conversion methods (plus phantom)', () => { // The runtime instance is narrowed to id + behavior (TML-2357); codec-id-keyed static metadata (`traits`, `targetTypes`, `renderOutputType`) lives on `CodecDescriptor` keyed by codecId. The `__codecTraits` slot is a type-only phantom carrier (always `undefined` at runtime) and double-underscored to signal that it is not part of the consumer-facing API surface. type CodecStringKeys = Extract; const expectedKeys = [ @@ -41,6 +41,8 @@ test('Codec instance carries only id + the four conversion methods (plus phantom 'decode', 'encodeJson', 'decodeJson', + 'encodePsl', + 'decodePsl', '__codecTraits', ] as const; type ExpectedKeys = (typeof expectedKeys)[number]; diff --git a/packages/1-framework/1-core/framework-components/test/codec.test.ts b/packages/1-framework/1-core/framework-components/test/codec.test.ts index 98d9f0fddf21..626b60753db3 100644 --- a/packages/1-framework/1-core/framework-components/test/codec.test.ts +++ b/packages/1-framework/1-core/framework-components/test/codec.test.ts @@ -16,6 +16,11 @@ import { CodecImpl, type CodecInstanceContext, type CodecTrait, + decodeJsonTextPsl, + decodeNumberPsl, + encodeJsonTextPsl, + encodeNumberPsl, + type PslLiteral, voidParamsSchema, } from '../src/exports/codec'; @@ -32,6 +37,12 @@ class Int4FixtureCodec extends CodecImpl<'demo/int4@1', readonly ['equality'], n decodeJson(json: JsonValue): number { return json as number; } + encodePsl(value: number): PslLiteral { + return encodeNumberPsl(value); + } + decodePsl(literal: PslLiteral): number { + return decodeNumberPsl(this.id, literal); + } } class Int4FixtureDescriptor extends CodecDescriptorImpl { @@ -79,6 +90,12 @@ class VectorFixtureCodec extends CodecImpl< decodeJson(json: JsonValue): number[] { return json as number[]; } + encodePsl(value: number[]): PslLiteral { + return encodeJsonTextPsl(value); + } + decodePsl(literal: PslLiteral): number[] { + return decodeJsonTextPsl(this.id, literal) as number[]; + } } class VectorFixtureDescriptor extends CodecDescriptorImpl { diff --git a/packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts index 2effb56a5b3f..b90e83a61b08 100644 --- a/packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts @@ -22,6 +22,11 @@ import { type ColumnHelperForStrict, type ColumnSpec, column, + decodeJsonTextPsl, + decodeNumberPsl, + encodeJsonTextPsl, + encodeNumberPsl, + type PslLiteral, voidParamsSchema, } from '../src/exports/codec'; @@ -38,6 +43,12 @@ class Int4FixtureCodec extends CodecImpl<'demo/int4@1', readonly ['equality'], n decodeJson(json: JsonValue): number { return json as number; } + encodePsl(value: number): PslLiteral { + return encodeNumberPsl(value); + } + decodePsl(literal: PslLiteral): number { + return decodeNumberPsl(this.id, literal); + } } class Int4FixtureDescriptor extends CodecDescriptorImpl implements CodecDescriptor { @@ -91,6 +102,12 @@ class VectorFixtureCodec extends CodecImpl< decodeJson(json: JsonValue): number[] { return json as number[]; } + encodePsl(value: number[]): PslLiteral { + return encodeJsonTextPsl(value); + } + decodePsl(literal: PslLiteral): number[] { + return decodeJsonTextPsl(this.id, literal) as number[]; + } } class VectorFixtureDescriptor 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 b3a2df664ba6..d95217411811 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 @@ -15,8 +15,9 @@ import { } from '../src/control/control-stack'; import type { Codec } from '../src/shared/codec'; import type { AnyCodecDescriptor } from '../src/shared/codec-descriptor'; -import type { CodecLookup } from '../src/shared/codec-types'; +import type { CodecLookup, PslLiteral } from '../src/shared/codec-types'; import type { ComponentDescriptor } from '../src/shared/framework-components'; +import { decodeJsonTextPsl, encodeJsonTextPsl } from '../src/shared/psl-literal-helpers'; import { isRuntimeError } from '../src/shared/runtime-error'; function createDescriptor( @@ -1490,6 +1491,8 @@ describe('validateScalarTypeCodecIds', () => { decode: async (v: unknown) => v, encodeJson: (v: unknown) => v as JsonValue, decodeJson: (v: JsonValue) => v, + encodePsl: (v: unknown) => encodeJsonTextPsl(v as JsonValue), + decodePsl: (literal: PslLiteral) => decodeJsonTextPsl(id, literal), } : undefined, targetTypesFor: (id: string) => (id === 'test/text@1' ? ['text'] : undefined), diff --git a/packages/1-framework/1-core/framework-components/test/materialize-codec.test.ts b/packages/1-framework/1-core/framework-components/test/materialize-codec.test.ts index f57fefef9e06..22dfa172dd63 100644 --- a/packages/1-framework/1-core/framework-components/test/materialize-codec.test.ts +++ b/packages/1-framework/1-core/framework-components/test/materialize-codec.test.ts @@ -10,7 +10,12 @@ import { type CodecInstanceContext, type CodecRef, type CodecTrait, + decodeJsonTextPsl, + decodeNumberPsl, + encodeJsonTextPsl, + encodeNumberPsl, materializeCodec, + type PslLiteral, voidParamsSchema, } from '../src/exports/codec'; @@ -27,6 +32,12 @@ class Int4FixtureCodec extends CodecImpl<'demo/int4@1', readonly ['equality'], n decodeJson(json: JsonValue): number { return json as number; } + encodePsl(value: number): PslLiteral { + return encodeNumberPsl(value); + } + decodePsl(literal: PslLiteral): number { + return decodeNumberPsl(this.id, literal); + } } class Int4FixtureDescriptor extends CodecDescriptorImpl { @@ -74,6 +85,12 @@ class VectorFixtureCodec extends CodecImpl< decodeJson(json: JsonValue): number[] { return json as number[]; } + encodePsl(value: number[]): PslLiteral { + return encodeJsonTextPsl(value); + } + decodePsl(literal: PslLiteral): number[] { + return decodeJsonTextPsl(this.id, literal) as number[]; + } } class VectorFixtureDescriptor extends CodecDescriptorImpl { diff --git a/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts b/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts index 79588b7c42e7..be2e922838d9 100644 --- a/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts +++ b/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts @@ -38,6 +38,9 @@ import { CodecDescriptorImpl, CodecImpl, type CodecInstanceContext, + decodeStringPsl, + encodeStringPsl, + type PslLiteral, voidParamsSchema, } from '../src/exports/codec'; import type { PslExtensionBlock } from '../src/exports/psl-ast'; @@ -75,6 +78,12 @@ class StubStringCodec extends CodecImpl<'stub/string@1', readonly ['textual'], s } return json; } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } class StubStringDescriptor extends CodecDescriptorImpl { diff --git a/packages/1-framework/1-core/framework-components/test/psl-literal-helpers.test.ts b/packages/1-framework/1-core/framework-components/test/psl-literal-helpers.test.ts new file mode 100644 index 000000000000..b3172c8a63cd --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/psl-literal-helpers.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { + decodeBooleanPsl, + decodeFloatPsl, + decodeJsonTextPsl, + decodeNumberPsl, + decodeStringPsl, + decodeWholeNumberPsl, + encodeBooleanPsl, + encodeFloatPsl, + encodeJsonTextPsl, + encodeNumberPsl, + encodeStringPsl, + pslLiteralKindError, +} from '../src/exports/codec'; + +describe('string helpers', () => { + it('round-trips a string through a string literal', () => { + const literal = encodeStringPsl('a"b\\c'); + expect(literal).toEqual({ kind: 'string', text: 'a"b\\c' }); + expect(decodeStringPsl('pg/text@1', literal)).toBe('a"b\\c'); + }); + + it('rejects a number literal naming the codec', () => { + expect(() => decodeStringPsl('pg/text@1', { kind: 'number', text: '1' })).toThrow( + 'pg/text@1 reads a string literal; got a number 1', + ); + }); +}); + +describe('number helpers', () => { + it.each([ + [1, '1'], + [-1.25, '-1.25'], + [0, '0'], + [1e-7, '0.0000001'], + [1.5e-10, '0.00000000015'], + [1e21, '1000000000000000000000'], + [-1.25e22, '-12500000000000000000000'], + [123.456, '123.456'], + ])('writes %s as %s with no exponent', (value, text) => { + expect(encodeNumberPsl(value)).toEqual({ kind: 'number', text }); + }); + + it('round-trips a number through a number literal', () => { + expect(decodeNumberPsl('pg/int4@1', encodeNumberPsl(42))).toBe(42); + expect(decodeNumberPsl('pg/float8@1', encodeNumberPsl(1e-7))).toBe(1e-7); + }); + + it('reads a keyword number literal', () => { + expect(decodeNumberPsl('pg/float8@1', { kind: 'number', text: 'NaN' })).toBeNaN(); + expect(decodeNumberPsl('pg/float8@1', { kind: 'number', text: '-Infinity' })).toBe( + Number.NEGATIVE_INFINITY, + ); + }); + + it('rejects a string literal naming the codec', () => { + expect(() => decodeNumberPsl('pg/int4@1', { kind: 'string', text: '1' })).toThrow( + 'pg/int4@1 reads a number literal; got a string "1"', + ); + }); +}); + +describe('boolean helpers', () => { + it('round-trips a boolean through a boolean literal', () => { + expect(encodeBooleanPsl(true)).toEqual({ kind: 'boolean', text: 'true' }); + expect(encodeBooleanPsl(false)).toEqual({ kind: 'boolean', text: 'false' }); + expect(decodeBooleanPsl('pg/bool@1', encodeBooleanPsl(true))).toBe(true); + expect(decodeBooleanPsl('pg/bool@1', encodeBooleanPsl(false))).toBe(false); + }); + + it('rejects a string literal naming the codec', () => { + expect(() => decodeBooleanPsl('pg/bool@1', { kind: 'string', text: 'true' })).toThrow( + 'pg/bool@1 reads a boolean literal; got a string "true"', + ); + }); +}); + +describe('JSON text helpers', () => { + it('round-trips a JSON value through a string literal holding JSON text', () => { + const json = { a: 1, b: ['x', null, true], c: 'quote " backslash \\' }; + const literal = encodeJsonTextPsl(json); + expect(literal).toEqual({ kind: 'string', text: JSON.stringify(json) }); + expect(decodeJsonTextPsl('pg/jsonb@1', literal)).toEqual(json); + }); + + it('round-trips JSON null', () => { + expect(decodeJsonTextPsl('pg/jsonb@1', encodeJsonTextPsl(null))).toBeNull(); + }); + + it('rejects a number literal naming the codec', () => { + expect(() => decodeJsonTextPsl('pg/jsonb@1', { kind: 'number', text: '1' })).toThrow( + 'pg/jsonb@1 reads a string literal holding JSON text; got a number 1', + ); + }); + + it('rejects a string that is not JSON naming the codec', () => { + expect(() => decodeJsonTextPsl('pg/jsonb@1', { kind: 'string', text: '{a:1}' })).toThrow( + /^pg\/jsonb@1 reads a string literal holding JSON text; got a string "\{a:1\}" that is not valid JSON: /, + ); + }); +}); + +describe('pslLiteralKindError', () => { + it('names the codec, the expected kind, and the literal it got', () => { + expect(pslLiteralKindError('pg/int4@1', 'number', { kind: 'boolean', text: 'true' })).toEqual( + new Error('pg/int4@1 reads a number literal; got a boolean true'), + ); + }); +}); + +describe('whole number helpers', () => { + it('returns the digits of a whole number literal as written', () => { + expect(decodeWholeNumberPsl('pg/int8@1', { kind: 'number', text: '9007199254740993' })).toBe( + '9007199254740993', + ); + expect(decodeWholeNumberPsl('pg/int4@1', { kind: 'number', text: '-7' })).toBe('-7'); + }); + + it('rejects a fraction naming the codec', () => { + expect(() => decodeWholeNumberPsl('pg/int4@1', { kind: 'number', text: '1.5' })).toThrow( + 'pg/int4@1 reads a whole number literal; got a number 1.5', + ); + }); + + it('rejects a non-finite number text', () => { + expect(() => decodeWholeNumberPsl('pg/int8@1', { kind: 'number', text: 'NaN' })).toThrow( + 'pg/int8@1 reads a whole number literal; got a number NaN', + ); + }); + + it('rejects a string literal naming the codec', () => { + expect(() => decodeWholeNumberPsl('pg/int4@1', { kind: 'string', text: '1' })).toThrow( + 'pg/int4@1 reads a whole number literal; got a string "1"', + ); + }); +}); + +describe('float helpers', () => { + it('writes a finite value as a number literal', () => { + expect(encodeFloatPsl(1.5)).toEqual({ kind: 'number', text: '1.5' }); + expect(decodeFloatPsl('pg/float8@1', { kind: 'number', text: '1.5' })).toBe(1.5); + }); + + it.each([ + [Number.NaN, 'NaN'], + [Number.POSITIVE_INFINITY, 'Infinity'], + [Number.NEGATIVE_INFINITY, '-Infinity'], + ])('writes %s as the quoted string %s and reads both spellings', (value, text) => { + expect(encodeFloatPsl(value)).toEqual({ kind: 'string', text }); + expect(decodeFloatPsl('pg/float8@1', { kind: 'string', text })).toBe(value); + expect(decodeFloatPsl('pg/float8@1', { kind: 'number', text })).toBe(value); + }); + + it('rejects any other string naming the codec', () => { + expect(() => decodeFloatPsl('pg/float8@1', { kind: 'string', text: '1.5' })).toThrow( + 'pg/float8@1 reads a number literal or "NaN", "Infinity", "-Infinity"; got a string "1.5"', + ); + }); + + it('rejects a boolean literal naming the codec', () => { + expect(() => decodeFloatPsl('pg/float4@1', { kind: 'boolean', text: 'true' })).toThrow( + 'pg/float4@1 reads a number literal or "NaN", "Infinity", "-Infinity"; got a boolean true', + ); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/psl-literal.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/psl-literal.types.test-d.ts new file mode 100644 index 000000000000..21344693387e --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/psl-literal.types.test-d.ts @@ -0,0 +1,68 @@ +import type { JsonValue } from '@internal/contract/types'; +import { expectTypeOf, test } from 'vitest'; +import { + type Codec, + type CodecCallContext, + CodecImpl, + type PslLiteral, +} from '../src/exports/codec'; + +test('PslLiteral carries a kind and the literal text', () => { + expectTypeOf().toEqualTypeOf<'string' | 'number' | 'boolean'>(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf<{ kind: 'number'; text: '1.50' }>().toMatchTypeOf(); + expectTypeOf<{ kind: 'json'; text: '{}' }>().not.toMatchTypeOf(); +}); + +test('Codec requires encodePsl and decodePsl', () => { + expectTypeOf().toEqualTypeOf<(value: unknown) => PslLiteral>(); + expectTypeOf().toEqualTypeOf<(literal: PslLiteral) => unknown>(); + expectTypeOf< + Codec['decodePsl'] + >().returns.toEqualTypeOf(); +}); + +test('a CodecImpl subclass without the PSL methods does not compile', () => { + // @ts-expect-error encodePsl and decodePsl are abstract and must be implemented + class MissingPslCodec extends CodecImpl<'demo/int4@1', readonly [], number, number> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } + } + expectTypeOf().toMatchTypeOf(); +}); + +test('a CodecImpl subclass with the PSL methods compiles', () => { + class CompletePslCodec extends CodecImpl<'demo/int4@1', readonly [], number, number> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } + encodePsl(value: number): PslLiteral { + return { kind: 'number', text: String(value) }; + } + decodePsl(literal: PslLiteral): number { + return Number(literal.text); + } + } + expectTypeOf().toMatchTypeOf< + Codec<'demo/int4@1', readonly [], number, number> + >(); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/literal.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/literal.ts new file mode 100644 index 000000000000..61e55240328e --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/literal.ts @@ -0,0 +1,27 @@ +import type { PslLiteral } from '@internal/framework-components/codec'; +import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@internal/utils/result'; +import { + BooleanLiteralExprAst, + NumberLiteralExprAst, + StringLiteralExprAst, +} from '../../syntax/ast/expressions'; +import type { AttributeCtx, LiteralArgType } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** A string, number, or boolean literal as the content a codec reads: quotes removed and escapes resolved for a string, the digits exactly as written for a number. */ +export function literal(): LiteralArgType { + return { + kind: 'literal', + label: 'literal', + parse: (arg, ctx): Result => { + const text = StringLiteralExprAst.cast(arg.syntax)?.value(); + if (text !== undefined) return ok({ kind: 'string', text }); + const digits = NumberLiteralExprAst.cast(arg.syntax)?.token()?.text; + if (digits !== undefined) return ok({ kind: 'number', text: digits }); + const flag = BooleanLiteralExprAst.cast(arg.syntax)?.value(); + if (flag !== undefined) return ok({ kind: 'boolean', text: flag ? 'true' : 'false' }); + return notOk([leafDiagnostic(ctx, arg, 'Expected a string, number, or boolean literal')]); + }, + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts index f83597ca987a..ab5fdbf0ea45 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts @@ -1,3 +1,4 @@ +import type { PslLiteral } from '@internal/framework-components/codec'; import type { PslDiagnostic, PslSpan } from '@internal/framework-components/psl-ast'; import type { Result } from '@internal/utils/result'; import type { Simplify, UnionToIntersection } from '@internal/utils/types'; @@ -31,6 +32,7 @@ export type ArgTypeKind = | 'int' | 'json' | 'list' + | 'literal' | 'num' | 'oneOf' | 'record' @@ -129,6 +131,11 @@ export interface FixedNumArgType + extends ArgTypeOutput { + readonly kind: 'literal'; +} + export interface NumLiteral { readonly text: string; } @@ -215,6 +222,7 @@ export type InspectableArgType = | IntArgType | JsonArgType | ListArgType + | LiteralArgType | FixedNumArgType | UnrestrictedNumArgType | NumLiteralArgType diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts index dbda10f7bb02..2c7aa55f6e28 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts @@ -49,6 +49,7 @@ export { int } from '../attribute-spec/combinators/int'; export { json } from '../attribute-spec/combinators/json'; export type { ListOptions } from '../attribute-spec/combinators/list'; export { list } from '../attribute-spec/combinators/list'; +export { literal } from '../attribute-spec/combinators/literal'; export { num } from '../attribute-spec/combinators/num'; export { numLiteral } from '../attribute-spec/combinators/num-literal'; export { oneOf } from '../attribute-spec/combinators/one-of'; @@ -78,9 +79,11 @@ export type { FuncCallSig, InferAttr, InspectableArgType, + LiteralArgType, ModelAttributeCtx, NamedOut, NumLiteral, + NumLiteralArgType, OptionalArgType, OutOf, Param, diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.literal.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.literal.test.ts new file mode 100644 index 000000000000..87ce3ded2f01 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.literal.test.ts @@ -0,0 +1,69 @@ +import { ok } from '@internal/utils/result'; +import { describe, expect, it } from 'vitest'; +import type { AttributeCtx } from '../src/exports'; +import { literal } from '../src/exports'; +import { Cursor, parseAttribute } from '../src/parse'; +import { FieldAttributeAst } from '../src/syntax/ast/attributes'; +import type { ExpressionAst } from '../src/syntax/ast/expressions'; +import { createSyntaxTree } from '../src/syntax/red'; + +function argOf(exprSource: string): { expr: ExpressionAst; ctx: AttributeCtx } { + const cursor = new Cursor(`@default(${exprSource})`); + const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + if (!node) throw new Error('expected a field attribute'); + const expr = [...(node.argList()?.args() ?? [])][0]?.value(); + if (!expr) throw new Error('expected an argument expression'); + return { expr, ctx: { sourceId: 'schema.prisma', sourceFile: cursor.sourceFile } }; +} + +describe('literal', () => { + it('reads a string literal with its escapes resolved', () => { + const { expr, ctx } = argOf('"a\\"b\\\\c\\n"'); + + const result = literal().parse(expr, ctx); + + expect(result).toEqual(ok({ kind: 'string', text: 'a"b\\c\n' })); + }); + + it.each([ + ['trailing zeros', '1.50'], + ['an integer beyond 2^53', '9007199254740993'], + ['a negative decimal', '-1.25'], + ['a keyword number', 'NaN'], + ['a negative keyword number', '-Infinity'], + ])('keeps %s as written', (_name, source) => { + const { expr, ctx } = argOf(source); + + const result = literal().parse(expr, ctx); + + expect(result).toEqual(ok({ kind: 'number', text: source })); + }); + + it.each(['true', 'false'])('reads %s as a boolean literal', (source) => { + const { expr, ctx } = argOf(source); + + const result = literal().parse(expr, ctx); + + expect(result).toEqual(ok({ kind: 'boolean', text: source })); + }); + + it.each([ + ['a function call', 'now()'], + ['an identifier', 'Foo'], + ['a list', '[1, 2]'], + ])('rejects %s', (_name, source) => { + const { expr, ctx } = argOf(source); + + const result = literal().parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.message).toBe('Expected a string, number, or boolean literal'); + } + }); + + it('carries the literal kind and label', () => { + expect(literal()).toMatchObject({ kind: 'literal', label: 'literal' }); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-printer/test/declarative-policy-select.round-trip.test.ts b/packages/1-framework/2-authoring/psl-printer/test/declarative-policy-select.round-trip.test.ts index 929da3e37e3d..7e57d58c2652 100644 --- a/packages/1-framework/2-authoring/psl-printer/test/declarative-policy-select.round-trip.test.ts +++ b/packages/1-framework/2-authoring/psl-printer/test/declarative-policy-select.round-trip.test.ts @@ -22,6 +22,9 @@ import { CodecDescriptorImpl, CodecImpl, type CodecInstanceContext, + decodeStringPsl, + encodeStringPsl, + type PslLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; import { @@ -80,6 +83,12 @@ class FixturePolicyTextCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } class FixturePolicyTextDescriptor extends CodecDescriptorImpl { diff --git a/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts b/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts index 3b42ce7e9b28..3ad5a3de3082 100644 --- a/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts +++ b/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts @@ -20,6 +20,11 @@ import { CodecDescriptorImpl, CodecImpl, type CodecInstanceContext, + decodeNumberPsl, + decodeStringPsl, + encodeNumberPsl, + encodeStringPsl, + type PslLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; import { @@ -69,6 +74,12 @@ class StubPolicyTextCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } class StubPolicyTextDescriptor extends CodecDescriptorImpl { @@ -233,6 +244,12 @@ describe('generic extension-block printer (P2)', () => { encodeJson(value: number): JsonValue { return value; } + encodePsl(value: number): PslLiteral { + return encodeNumberPsl(value); + } + decodePsl(literal: PslLiteral): number { + return decodeNumberPsl(this.id, literal); + } } class NumericExpressionDescriptor extends CodecDescriptorImpl { diff --git a/packages/1-framework/3-tooling/language-server/src/completion-values.ts b/packages/1-framework/3-tooling/language-server/src/completion-values.ts index 2812082baf0f..513623338137 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-values.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-values.ts @@ -126,6 +126,7 @@ function valueItems( case 'num': return scalarItems(input, type.value === undefined ? [] : [String(type.value)]); case 'bool': + case 'literal': return scalarItems(input, ['true', 'false']); case 'fieldRef': case 'referencedFieldRef': diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/README.md b/packages/2-mongo-family/1-foundation/mongo-codec/README.md index 9e826e4b1148..288498c7b9fa 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/README.md +++ b/packages/2-mongo-family/1-foundation/mongo-codec/README.md @@ -4,7 +4,7 @@ Codec interface and registry for MongoDB value serialization. ## Responsibilities -- **Codec interface**: `MongoCodec` — declares how a JS value translates to and from the BSON-shaped wire format the Mongo driver exchanges, plus the JSON-safe form stored in contract artifacts. Same four generics as the framework `Codec` base; the codec instance carries only `id` plus the four conversion methods. Trait annotations (`equality`, `order`, `boolean`, `numeric`, `textual`, `vector`) for operator gating live on the unified `CodecDescriptor` (see [ADR 208](../../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md)). +- **Codec interface**: `MongoCodec` — declares how a JS value translates to and from the BSON-shaped wire format the Mongo driver exchanges, plus the JSON-safe form stored in contract artifacts. Same four generics as the framework `Codec` base; the codec instance carries only `id` plus the six conversion methods (`encode`/`decode`, `encodeJson`/`decodeJson`, and `encodePsl`/`decodePsl`, the last pair being required members of the `mongoCodec({...})` config). Trait annotations (`equality`, `order`, `boolean`, `numeric`, `textual`, `vector`) for operator gating live on the unified `CodecDescriptor` (see [ADR 208](../../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md)). - **Codec factory**: `mongoCodec()` — creates frozen codec instances from a config object. Both `encode` and `decode` are required so `TInput` and `TWire` are always covered by an explicit author function — the factory installs no identity fallback. `encode` and `decode` may be authored as sync or async functions and are lifted to Promise-returning query-time methods automatically. Build-time methods (`encodeJson`, `decodeJson`) are synchronous and default to identity when omitted. - **Codec registry**: `MongoCodecRegistry` and `newMongoCodecRegistry()` — a map-based container that stores and retrieves codecs by ID, with duplicate-ID protection - **Type-level helper**: `MongoCodecInput` for extracting the JS application type from a codec type. Trait metadata lives on the unified `CodecDescriptor` (see [ADR 208](../../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md)). diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts b/packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts index 857069872d58..d0f1fa2c655c 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts @@ -3,6 +3,7 @@ import type { Codec as BaseCodec, CodecCallContext, CodecTrait, + PslLiteral, } from '@internal/framework-components/codec'; export type MongoCodecTrait = CodecTrait; @@ -37,7 +38,7 @@ type JsonRoundTripConfig = [TInput] extends [JsonValue] * * Author `encode` and `decode` as sync or async functions; the factory produces a {@link MongoCodec} whose query-time methods follow the boundary contract documented on the framework {@link BaseCodec}. Authors receive a second `ctx` options argument carrying the per-call context; ignore it if you don't need it. * - * Both `encode` and `decode` are required so `TInput` and `TWire` are always covered by an explicit author function — the factory installs no identity fallback. `encodeJson` and `decodeJson` default to identity **only when `TInput` is assignable to `JsonValue`**; otherwise both are required so the contract artifact stays JSON-safe. + * Both `encode` and `decode` are required so `TInput` and `TWire` are always covered by an explicit author function — the factory installs no identity fallback. `encodeJson` and `decodeJson` default to identity **only when `TInput` is assignable to `JsonValue`**; otherwise both are required so the contract artifact stays JSON-safe. `encodePsl` and `decodePsl` are always required: every codec states the PSL literal that denotes its values, and the shared pairs in `@internal/framework-components/codec` (`encodeStringPsl`/`decodeStringPsl`, `encodeNumberPsl`/`decodeNumberPsl`, ...) cover the common shapes. * * Codec-id-keyed static metadata (`traits`, `targetTypes`, `renderOutputType`) lives on the unified `CodecDescriptor` rather than on the codec instance itself (TML-2357). */ @@ -51,6 +52,8 @@ export function mongoCodec< typeId: Id; encode: (value: TInput, ctx: CodecCallContext) => TWire | Promise; decode: (wire: TWire, ctx: CodecCallContext) => TInput | Promise; + encodePsl: (value: TInput) => PslLiteral; + decodePsl: (literal: PslLiteral) => TInput; } & JsonRoundTripConfig, ): MongoCodec { const identity = (v: unknown) => v; @@ -79,6 +82,8 @@ export function mongoCodec< }, encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + encodePsl: config.encodePsl, + decodePsl: config.decodePsl, }; } diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test-d.ts b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test-d.ts index 667279083c1f..c450213838d6 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test-d.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test-d.ts @@ -13,6 +13,8 @@ test('mongoCodec() accepts a `(value, ctx)` encode author', () => { typeId: 'demo/ctx-encode@1', encode: (value: string, _ctx?: CodecCallContext) => value, decode: (wire: string) => wire, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); expectTypeOf(c.encode).toBeFunction(); expectTypeOf[1]>().toEqualTypeOf(); @@ -23,6 +25,8 @@ test('mongoCodec() accepts a `(value, ctx)` decode author', () => { typeId: 'demo/ctx-decode@1', encode: (value: string) => value, decode: (wire: string, _ctx?: CodecCallContext) => wire, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); expectTypeOf[1]>().toEqualTypeOf(); }); @@ -32,6 +36,8 @@ test('mongoCodec() accepts a single-arg `(value)` encode author and exposes a Pr typeId: 'demo/single-encode@1', encode: (value: string) => value, decode: (wire: string) => wire, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); expectTypeOf>().toExtend>(); }); @@ -41,6 +47,8 @@ test('MongoCodec.encode and MongoCodec.decode require a ctx argument', () => { typeId: 'demo/require-ctx@1', encode: (value: string) => value, decode: (wire: string) => wire, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); // @ts-expect-error — ctx is non-optional on the MongoCodec interface c.encode('x'); diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test.ts b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test.ts index 13054cef83d3..ecd542da70ec 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test.ts @@ -8,6 +8,8 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { typeId: 'demo/single-arg-encode@1', encode: (value: string) => value.toUpperCase(), decode: (wire: string) => wire, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); expect(await c.encode('hi', {})).toBe('HI'); }); @@ -21,6 +23,8 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { return value; }, decode: (wire: string) => wire, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const controller = new AbortController(); const ctx: CodecCallContext = { signal: controller.signal }; @@ -38,6 +42,8 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { observed = ctx; return wire; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const controller = new AbortController(); const ctx: CodecCallContext = { signal: controller.signal }; @@ -55,6 +61,8 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { return value; }, decode: (wire: string) => wire, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const controller = new AbortController(); await c.encode('x', { signal: controller.signal }); @@ -70,6 +78,8 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { return value; }, decode: (wire: string) => wire, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const ctx: CodecCallContext = {}; await c.encode('x', ctx); @@ -81,6 +91,8 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { typeId: 'demo/async-ctx@1', encode: async (value: string, _ctx?: CodecCallContext) => `enc:${value}`, decode: (wire: string) => wire, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); expect(await c.encode('x', { signal: new AbortController().signal })).toBe('enc:x'); }); diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-psl.test-d.ts b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-psl.test-d.ts new file mode 100644 index 000000000000..3f6f1f3f7275 --- /dev/null +++ b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-psl.test-d.ts @@ -0,0 +1,29 @@ +import { + decodeStringPsl, + encodeStringPsl, + type PslLiteral, +} from '@internal/framework-components/codec'; +import { expectTypeOf, test } from 'vitest'; +import { mongoCodec } from '../src/codecs'; + +test('the factory requires encodePsl and decodePsl', () => { + // @ts-expect-error encodePsl and decodePsl are required; the factory installs no default + mongoCodec({ + typeId: 'demo/text@1', + encode: (value: string) => value, + decode: (wire: string) => wire, + }); +}); + +test('the members are typed by TInput', () => { + const codec = mongoCodec({ + typeId: 'demo/text@1', + encode: (value: string) => value, + decode: (wire: string) => wire, + encodePsl: (value) => encodeStringPsl(value), + decodePsl: (literal) => decodeStringPsl('demo/text@1', literal), + }); + expectTypeOf(codec.encodePsl).parameter(0).toEqualTypeOf(); + expectTypeOf(codec.encodePsl).returns.toEqualTypeOf(); + expectTypeOf(codec.decodePsl).returns.toEqualTypeOf(); +}); diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-psl.test.ts b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-psl.test.ts new file mode 100644 index 000000000000..d18daeb3cc04 --- /dev/null +++ b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-psl.test.ts @@ -0,0 +1,22 @@ +import { decodeStringPsl, encodeStringPsl } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { mongoCodec } from '../src/codecs'; + +describe('mongoCodec() PSL members', () => { + const codec = mongoCodec({ + typeId: 'test/text@1', + encode: (value: string) => value, + decode: (wire: string) => wire, + encodePsl: (value: string) => encodeStringPsl(value.toUpperCase()), + decodePsl: (literal) => `read:${decodeStringPsl('test/text@1', literal)}`, + }); + + it('passes encodePsl and decodePsl through', () => { + expect(codec.encodePsl('a')).toEqual({ kind: 'string', text: 'A' }); + expect(codec.decodePsl({ kind: 'string', text: 'A' })).toBe('read:A'); + }); + + it('lets a decodePsl error propagate', () => { + expect(() => codec.decodePsl({ kind: 'number', text: '1' })).toThrow('test/text@1'); + }); +}); diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test-d.ts b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test-d.ts index c866433fedcc..2dd9f810e52b 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test-d.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test-d.ts @@ -16,6 +16,8 @@ test('MongoCodecInput extracts the JS application type used for both write input typeId: 'demo/text@1', encode: (value: string) => value, decode: (wire: string) => wire, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); expectTypeOf>().toEqualTypeOf(); diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test.ts b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test.ts index dce071c1b3ad..928ca2440fd6 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test.ts @@ -10,6 +10,8 @@ describe('mongoCodec()', () => { typeId: 'test/string@1', decode: (wire: string) => wire, encode: (value: string) => value, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); expect(codec.id).toBe('test/string@1'); @@ -22,6 +24,8 @@ describe('mongoCodec()', () => { typeId: 'test/upper@1', decode: (wire: string) => wire.toUpperCase(), encode: (value: string) => value.toLowerCase(), + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); expect(await codec.decode('hello', {})).toBe('HELLO'); @@ -33,6 +37,8 @@ describe('mongoCodec()', () => { typeId: 'test/sync@1', decode: (wire: string) => wire, encode: (value: string) => value, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const decoded = codec.decode('x', {}); @@ -46,6 +52,8 @@ describe('mongoCodec()', () => { typeId: 'test/async@1', decode: async (wire: string) => `decoded:${wire}`, encode: async (value: string) => `encoded:${value}`, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); expect(await codec.decode('a', {})).toBe('decoded:a'); @@ -59,6 +67,8 @@ describe('MongoCodecRegistry', () => { typeId: id, decode: (wire: JsonValue) => wire, encode: (value: JsonValue) => value, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text, }); } diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts index 212905053b80..d0fbbd391945 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts @@ -26,6 +26,8 @@ const mongoCodecLookup: CodecLookup = { decode: async (w: unknown) => w, encodeJson: (v: unknown) => v, decodeJson: (j: unknown) => j, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, } as ReturnType; }, targetTypesFor: (id: string) => mongoTargetTypes[id], diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index 642ec2902416..3bffad5b24b7 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -42,6 +42,8 @@ const mongoCodecLookup: CodecLookup = { decode: async (w: unknown) => w, encodeJson: (v: unknown) => v, decodeJson: (j: unknown) => j, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, } as ReturnType; }, targetTypesFor: (id: string) => mongoTargetTypes[id], diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts index b39097e0b224..d30816b7e842 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts @@ -66,6 +66,8 @@ const mongoCodecLookup: CodecLookup = { decode: async (w: unknown) => w, encodeJson: (v: unknown) => v, decodeJson: (j: unknown) => j, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, } as ReturnType; }, targetTypesFor: (id: string) => mongoTargetTypes[id], diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts index 28d795443014..faecbb56492a 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts @@ -26,6 +26,8 @@ const stringCodec: Codec = { decode: async (wire: unknown) => wire, encodeJson: (value) => value as JsonValue, decodeJson: (json) => json, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; const enumEntityType = { diff --git a/packages/2-mongo-family/7-runtime/test/codecs/decoding.test.ts b/packages/2-mongo-family/7-runtime/test/codecs/decoding.test.ts index 93e0998b1d97..862a6ea717ac 100644 --- a/packages/2-mongo-family/7-runtime/test/codecs/decoding.test.ts +++ b/packages/2-mongo-family/7-runtime/test/codecs/decoding.test.ts @@ -27,6 +27,8 @@ function registryWithDefaults(): MongoCodecRegistry { typeId: 'mongo/string@1', encode: (v: string) => v, decode: (w: string) => w, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); registry.register( @@ -34,6 +36,8 @@ function registryWithDefaults(): MongoCodecRegistry { typeId: 'mongo/objectId@1', encode: (v: string) => new ObjectId(v), decode: (w: { toHexString: () => string }) => w.toHexString(), + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); return registry; @@ -65,6 +69,8 @@ describe('decodeMongoRow', () => { typeId: 'test/spy@1', encode: (v: string) => v, decode: decodeSpy, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); const shape: MongoResultShape = { @@ -127,6 +133,8 @@ describe('decodeMongoRow', () => { if (w === 'bad') throw new Error('boom'); return w; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); const shapeThrow: MongoResultShape = { @@ -251,6 +259,8 @@ describe('decodeMongoRow', () => { // Codec authors throwing a non-Error happens — the wrapper has to render something for the message. The cast is a deliberate exercise of `wrapDecodeFailure`'s `error instanceof Error` false-branch (pure type-system: `throw` accepts `unknown`). throw 'string-error' as unknown as Error; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); const shape: MongoResultShape = { @@ -278,6 +288,8 @@ describe('decodeMongoRow', () => { decode: () => { throw new Error('boom'); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); const shape: MongoResultShape = { @@ -306,6 +318,8 @@ describe('decodeMongoRow', () => { decode: () => { throw new Error('boom'); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); const shape: MongoResultShape = { @@ -408,6 +422,8 @@ describe('decodeMongoRow', () => { decode: () => { throw new Error('inner'); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); const shape: MongoResultShape = { @@ -446,6 +462,8 @@ describe('decodeMongoRow', () => { decode: () => { throw envelope; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); const shape: MongoResultShape = { @@ -472,6 +490,8 @@ describe('decodeMongoRow', () => { decode: () => { throw envelope; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); const shape: MongoResultShape = { @@ -501,6 +521,8 @@ describe('decodeMongoRow', () => { callOrder.push('a-start'); return dA.promise.then((s) => `${w}:${s}`); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); registry.register( @@ -511,6 +533,8 @@ describe('decodeMongoRow', () => { callOrder.push('b-start'); return dB.promise.then((s) => `${w}:${s}`); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); const shape: MongoResultShape = { diff --git a/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts b/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts index 34c97ab5be1c..a01822cb1149 100644 --- a/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts +++ b/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts @@ -62,6 +62,8 @@ describe('Mongo runtime decode integration', () => { decode: () => { throw new Error('decode explosion'); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); ctx.codecs.register(failing); diff --git a/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts b/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts index f04f37f51e8b..66f4706bad2e 100644 --- a/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts +++ b/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts @@ -68,6 +68,8 @@ describe('createMongoExecutionContext', () => { typeId: 'test/custom@1', decode: (wire: string) => `decoded:${wire}`, encode: (value: string) => value, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const pack: MongoRuntimeExtensionDescriptor<'mongo'> = { kind: 'extension', @@ -97,6 +99,8 @@ describe('createMongoExecutionContext', () => { typeId: 'mongo/string@1', decode: (wire: string) => wire, encode: (value: string) => value, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const conflictingPack: MongoRuntimeExtensionDescriptor<'mongo'> = { kind: 'extension', diff --git a/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts b/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts index 6088d32add83..88e4bdd921b1 100644 --- a/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts +++ b/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts @@ -60,6 +60,8 @@ const mongoStringCodec: Codec = { if (typeof json !== 'string') throw new Error(`expected string, got ${typeof json}`); return json; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; const mongoIntCodec: Codec = { @@ -71,6 +73,8 @@ const mongoIntCodec: Codec = { if (typeof json !== 'number') throw new Error(`expected number, got ${typeof json}`); return json; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; const testCodecLookup: CodecLookup = { diff --git a/packages/2-sql/2-authoring/contract-prisma7/src/defaults.ts b/packages/2-sql/2-authoring/contract-prisma7/src/defaults.ts index 3691c1fe91ab..67997c185dae 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/src/defaults.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/src/defaults.ts @@ -1,6 +1,6 @@ import type { ContractSourceDiagnostic } from '@internal/config/config-types'; -import type { ExecutionMutationDefaultValue, JsonValue } from '@internal/contract/types'; -import type { CodecLookup } from '@internal/framework-components/codec'; +import type { ExecutionMutationDefaultValue } from '@internal/contract/types'; +import type { CodecLookup, PslLiteral } from '@internal/framework-components/codec'; import type { ControlMutationDefaults } from '@internal/framework-components/control'; import type { FieldSymbol, PslSpan, ResolvedAttribute } from '@internal/psl-parser'; import type { ExpressionAst } from '@internal/psl-parser/syntax'; @@ -13,12 +13,12 @@ import { printSyntax, StringLiteralExprAst, } from '@internal/psl-parser/syntax'; -import { numberLiteralDefault } from '@internal/sql-contract-psl/resolution'; import type { AuthoredColumnDefault, AuthoredColumnDefaultLiteralValue, } from '@internal/sql-contract-ts/contract-builder'; import { blindCast } from '@internal/utils/casts'; +import { InternalError } from '@internal/utils/internal-error'; import { prisma7Diagnostic } from './diagnostics'; import type { Prisma7LiteralDefaultForm } from './target-binding'; @@ -102,9 +102,9 @@ export function lowerPrisma7Default( } const rawLiteral = - input.literalForm?.kind === 'sqlExpression' - ? sqlExpressionDefault(expression, input.literalForm, input.field.list) - : undefined; + input.literalForm === undefined + ? undefined + : sqlExpressionDefault(expression, input.literalForm, input.field.list); if (rawLiteral === 'unreadable') { return unknown('holds a value this contract source does not read.', attribute.span); } @@ -115,65 +115,111 @@ export function lowerPrisma7Default( }; } - const scalar = scalarValue(expression, input, unknown); + const scalar = literalValue(expression, input, unknown); if (scalar === undefined) return undefined; return { storage: { kind: 'literal', value: scalar }, onCreate: undefined }; } -function scalarValue( +function pslLiteral(expression: ExpressionAst): PslLiteral | undefined { + const text = StringLiteralExprAst.cast(expression.syntax)?.value(); + if (text !== undefined) return { kind: 'string', text }; + const digits = NumberLiteralExprAst.cast(expression.syntax)?.token()?.text; + if (digits !== undefined) return { kind: 'number', text: digits }; + const flag = BooleanLiteralExprAst.cast(expression.syntax)?.value(); + if (flag !== undefined) return { kind: 'boolean', text: flag ? 'true' : 'false' }; + return undefined; +} + +/** The literal read through the column codec, or an enum member's storage value. */ +function literalValue( expression: ExpressionAst, input: LowerPrisma7DefaultInput, unknown: (reason: string, span: PslSpan) => undefined, ): AuthoredColumnDefaultLiteralValue | undefined { const span = input.attribute.span; - const isJson = input.literalForm?.kind === 'json'; + const source = printSyntax(expression.syntax).trim(); + const codec = input.codecLookup.get(input.codecId); + if (codec === undefined) { + throw new InternalError( + `Field "${input.modelName}.${input.field.name}" resolved to codec "${input.codecId}", but the codec lookup has no codec for it. The lookup that resolved the column must also carry its codec.`, + ); + } + const decode = (literal: PslLiteral): AuthoredColumnDefaultLiteralValue | undefined => { + try { + return blindCast< + AuthoredColumnDefaultLiteralValue, + 'decodePsl returns the codec its own value type, which the contract builder hands back to the same codec through encodeJson' + >(codec.decodePsl(literal)); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + input.diagnostics.push( + prisma7Diagnostic( + 'PSL.PRISMA7_UNKNOWN_DEFAULT', + `Field "${input.modelName}.${input.field.name}": @default(${source}) is not a value of ${input.codecId}: ${reason}`, + input.sourceId, + span, + ), + ); + return undefined; + } + }; const jsonNull = (holds: string): undefined => { input.diagnostics.push( prisma7Diagnostic( 'PSL.PRISMA7_JSON_NULL_DEFAULT_UNSUPPORTED', - `Field "${input.modelName}.${input.field.name}": @default(${printSyntax(expression.syntax).trim()}) ${holds} the JSON value null, which the contract cannot tell apart from SQL NULL. Remove the @default or give it another JSON value; either changes the column default on Prisma 7's next migration.`, + `Field "${input.modelName}.${input.field.name}": @default(${source}) ${holds} the JSON value null, which the contract cannot tell apart from SQL NULL. Remove the @default or give it another JSON value; either changes the column default on Prisma 7's next migration.`, input.sourceId, span, ), ); return undefined; }; + const isJson = input.field.typeName === 'Json'; const array = ArrayLiteralAst.cast(expression.syntax); if (array !== undefined) { const values: AuthoredColumnDefaultLiteralValue[] = []; for (const element of array.elements()) { - const value = elementValue(element, input); + const member = IdentifierAst.cast(element.syntax)?.name(); + const literal = member === undefined ? pslLiteral(element) : undefined; + const value = + member !== undefined + ? input.enumMembers?.get(member) + : literal === undefined + ? undefined + : decode(literal); if (value === undefined) { - return unknown( - rejectedNumberReason(element, input) ?? 'lists may only hold literals or enum members.', - span, - ); + return literal === undefined + ? unknown('lists may only hold literals or enum members.', span) + : undefined; } values.push(value); } if (isJson && values.includes(null)) return jsonNull('holds'); return values; } - const value = elementValue(expression, input); - if (isJson && value === null) return jsonNull('is'); - if (value !== undefined) return value; - const numberReason = rejectedNumberReason(expression, input); - if (numberReason !== undefined) return unknown(numberReason, span); - const identifier = IdentifierAst.cast(expression.syntax)?.name(); - if (identifier !== undefined) { + const member = IdentifierAst.cast(expression.syntax)?.name(); + if (member !== undefined) { + const value = input.enumMembers?.get(member); + if (value !== undefined) return value; return unknown( input.enumMembers === undefined - ? `refers to "${identifier}", but the field is not an enum.` - : `refers to "${identifier}", which is not a member of the field's enum.`, + ? `refers to "${member}", but the field is not an enum.` + : `refers to "${member}", which is not a member of the field's enum.`, span, ); } - return unknown('holds a value this contract source does not read.', span); + const literal = pslLiteral(expression); + if (literal === undefined) { + return unknown('holds a value this contract source does not read.', span); + } + const value = decode(literal); + if (isJson && value === null) return jsonNull('is'); + return value; } function sqlExpressionDefault( expression: ExpressionAst, - form: Extract, + form: Prisma7LiteralDefaultForm, isList: boolean, ): { readonly expression: string } | 'unreadable' | undefined { const array = isList ? ArrayLiteralAst.cast(expression.syntax) : undefined; @@ -193,63 +239,6 @@ function sqlExpressionDefault( return { expression: form.list(literals) }; } -/** The Prisma 7 scalars whose number defaults must be whole numbers, as each is named in a message. */ -const WHOLE_NUMBER_SCALARS: Readonly> = { - Int: 'an Int', - BigInt: 'a BigInt', -}; - -const WHOLE_NUMBER_TEXT = /^-?\d+$/; - -/** The number literal the column codec reads neither as a number nor as text, such as `1.5` for a `BigInt`, which Prisma 7 rejects too. */ -function rejectedNumberReason( - expression: ExpressionAst, - input: LowerPrisma7DefaultInput, -): string | undefined { - const text = NumberLiteralExprAst.cast(expression.syntax)?.token()?.text; - if (text === undefined || numberValue(text, input) !== undefined) return undefined; - const { typeName } = input.field; - const wholeNumberScalar = Object.hasOwn(WHOLE_NUMBER_SCALARS, typeName) - ? WHOLE_NUMBER_SCALARS[typeName] - : undefined; - return wholeNumberScalar === undefined - ? `holds ${text}, which is not a valid ${typeName} value.` - : `holds ${text}, which is not an integer; ${wholeNumberScalar} default must be a whole number.`; -} - -/** A number default for the field: Prisma 7 accepts only whole numbers for `Int` and `BigInt`. */ -function numberValue( - text: string, - input: LowerPrisma7DefaultInput, -): AuthoredColumnDefaultLiteralValue | undefined { - if (Object.hasOwn(WHOLE_NUMBER_SCALARS, input.field.typeName) && !WHOLE_NUMBER_TEXT.test(text)) { - return undefined; - } - return numberLiteralDefault(text, input.codecId, input.codecLookup); -} - -function elementValue( - expression: ExpressionAst, - input: LowerPrisma7DefaultInput, -): AuthoredColumnDefaultLiteralValue | undefined { - const member = IdentifierAst.cast(expression.syntax)?.name(); - if (member !== undefined) return input.enumMembers?.get(member); - const number = NumberLiteralExprAst.cast(expression.syntax)?.token()?.text; - if (number !== undefined) return numberValue(number, input); - const text = StringLiteralExprAst.cast(expression.syntax)?.value(); - if (text !== undefined) { - if (input.literalForm?.kind === 'json') { - try { - return blindCast(JSON.parse(text)); - } catch { - return undefined; - } - } - return text; - } - return BooleanLiteralExprAst.cast(expression.syntax)?.value(); -} - function lowerFunction( call: FunctionCallAst, input: LowerPrisma7DefaultInput, diff --git a/packages/2-sql/2-authoring/contract-prisma7/src/target-binding.ts b/packages/2-sql/2-authoring/contract-prisma7/src/target-binding.ts index 2b6989779223..22250dcf5f10 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/src/target-binding.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/src/target-binding.ts @@ -8,18 +8,14 @@ export interface Prisma7ColumnType { readonly typeParams?: Readonly> | undefined; } -/** How a literal `@default` is read for a column whose codec does not take the value as written. */ -export type Prisma7LiteralDefaultForm = - /** A string holding JSON text, parsed. */ - | { readonly kind: 'json' } - /** A string, carried as the SQL expression of the default the database stores. */ - | { - readonly kind: 'sqlExpression'; - /** The SQL literal for one written value, or `undefined` when the text is not a value of the column's type. */ - readonly literal: (text: string) => string | undefined; - /** The SQL expression for a list default made of `literal` results. */ - readonly list: (literals: readonly string[]) => string; - }; +/** How a literal `@default` is read for a column whose codec does not take the value as written: a string, carried as the SQL expression of the default the database stores. */ +export type Prisma7LiteralDefaultForm = { + readonly kind: 'sqlExpression'; + /** The SQL literal for one written value, or `undefined` when the text is not a value of the column's type. */ + readonly literal: (text: string) => string | undefined; + /** The SQL expression for a list default made of `literal` results. */ + readonly list: (literals: readonly string[]) => string; +}; /** Everything a target supplies to the Prisma 7 interpreter. */ export interface Prisma7TargetBinding { diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/defaults.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/defaults.test.ts index 7135f89b0900..e7ad9e9d137d 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/defaults.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/defaults.test.ts @@ -132,6 +132,13 @@ describe('Decimal and BigInt number defaults', () => { }); }); +describe('JSON string defaults', () => { + it('lower to the JSON document the string holds, through the column codec', async () => { + const { columns } = await loadFixtureTable('defaults', 'Defaults'); + expect(columns['jsonLiteral']?.['default']).toEqual({ kind: 'literal', value: { a: 1 } }); + }); +}); + describe('Number defaults on String, Bytes, DateTime and Boolean fields', () => { it('are rejected, as Prisma 7 rejects them', async () => { const schemaPath = join(fixturesDir, 'number-default-spellings', 'other-types.prisma'); @@ -141,10 +148,10 @@ describe('Number defaults on String, Bytes, DateTime and Boolean fields', () => expect( result.ok ? [] : result.failure.diagnostics.map((diagnostic) => diagnostic.message), ).toEqual([ - 'Field "OtherTypes.name": @default holds 5, which is not a valid String value.', - 'Field "OtherTypes.payload": @default holds 1234, which is not a valid Bytes value.', - 'Field "OtherTypes.at": @default holds 0, which is not a valid DateTime value.', - 'Field "OtherTypes.flag": @default holds 1, which is not a valid Boolean value.', + 'Field "OtherTypes.name": @default(5) is not a value of pg/text@1: pg/text@1 reads a string literal; got a number 5', + 'Field "OtherTypes.payload": @default(1234) is not a value of pg/bytea@1: pg/bytea@1 reads a string literal; got a number 1234', + 'Field "OtherTypes.at": @default(0) is not a value of pg/timestamp-temporal@1: pg/timestamp-temporal@1 reads a string literal; got a number 0', + 'Field "OtherTypes.flag": @default(1) is not a value of pg/bool@1: pg/bool@1 reads a boolean literal; got a number 1', ]); }); }); diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/integer-default-not-whole-number/expected-diagnostics.json b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/integer-default-not-whole-number/expected-diagnostics.json index 143d1fbcdfce..6b62a44be661 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/integer-default-not-whole-number/expected-diagnostics.json +++ b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/integer-default-not-whole-number/expected-diagnostics.json @@ -3,30 +3,30 @@ "code": "PSL.PRISMA7_UNKNOWN_DEFAULT", "file": "schema.prisma", "line": 7, - "message": "Field \"M.big\": @default holds 1.5, which is not an integer; a BigInt default must be a whole number." + "message": "Field \"M.big\": @default(1.5) is not a value of pg/int8@1: pg/int8@1 reads a whole number literal; got a number 1.5" }, { "code": "PSL.PRISMA7_UNKNOWN_DEFAULT", "file": "schema.prisma", "line": 8, - "message": "Field \"M.bigs\": @default holds 2.5, which is not an integer; a BigInt default must be a whole number." + "message": "Field \"M.bigs\": @default([1, 2.5]) is not a value of pg/int8@1: pg/int8@1 reads a whole number literal; got a number 2.5" }, { "code": "PSL.PRISMA7_UNKNOWN_DEFAULT", "file": "schema.prisma", "line": 9, - "message": "Field \"M.int\": @default holds 1.5, which is not an integer; an Int default must be a whole number." + "message": "Field \"M.int\": @default(1.5) is not a value of pg/int4@1: pg/int4@1 reads a whole number literal; got a number 1.5" }, { "code": "PSL.PRISMA7_UNKNOWN_DEFAULT", "file": "schema.prisma", "line": 10, - "message": "Field \"M.small\": @default holds 2.5, which is not an integer; an Int default must be a whole number." + "message": "Field \"M.small\": @default(2.5) is not a value of pg/int2@1: pg/int2@1 reads a whole number literal; got a number 2.5" }, { "code": "PSL.PRISMA7_UNKNOWN_DEFAULT", "file": "schema.prisma", "line": 11, - "message": "Field \"M.ints\": @default holds 2.5, which is not an integer; an Int default must be a whole number." + "message": "Field \"M.ints\": @default([1, 2.5]) is not a value of pg/int4@1: pg/int4@1 reads a whole number literal; got a number 2.5" } ] diff --git a/packages/2-sql/2-authoring/contract-psl/README.md b/packages/2-sql/2-authoring/contract-psl/README.md index 27d17d6d7f27..56314cfada19 100644 --- a/packages/2-sql/2-authoring/contract-psl/README.md +++ b/packages/2-sql/2-authoring/contract-psl/README.md @@ -29,6 +29,8 @@ This keeps core/CLI source-agnostic while giving PSL-first SQL users a one-line - Return `notOk` with structured diagnostics for unsupported constructs - Keep interpretation deterministic for equivalent AST inputs +Literal defaults: the written form of a `@default(...)` literal is whatever the column's codec accepts through its `decodePsl`. A JSON column takes a string holding JSON text (`Jsonb @default("{}")`, `Json @default("[1, 2]")`); a number is read exactly as written, so a big integer or a decimal keeps every digit (`BigInt @default(9007199254740993)`, `Decimal @default(1.50)`); a Postgres float takes `"NaN"`, `"Infinity"`, or `"-Infinity"` as a quoted string. A literal the codec cannot read is the diagnostic `PSL_INVALID_DEFAULT_LITERAL` at the attribute, with the codec's own message (`Field "M.count": @default(1.5) is not a value of pg/int4@1: pg/int4@1 reads a whole number literal; got a number 1.5`). + Determinism note: - Relation metadata emission is intentionally **sorted by storage table name, then model name, then relation field name** (not PSL declaration order) so `contract.json` snapshots and hashes are stable across environments. diff --git a/packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts index 1aaaf53eec8e..15606367efe0 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts @@ -1,5 +1,4 @@ export { buildEntityTypesByDiscriminator } from '../interpreter'; -export { numberLiteralDefault } from '../number-literal-default'; export { type ColumnDescriptor, type ResolveFieldTypeResult, diff --git a/packages/2-sql/2-authoring/contract-psl/src/number-literal-default.ts b/packages/2-sql/2-authoring/contract-psl/src/number-literal-default.ts deleted file mode 100644 index 8614085eb00e..000000000000 --- a/packages/2-sql/2-authoring/contract-psl/src/number-literal-default.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { JsonValue } from '@internal/contract/types'; -import type { Codec, CodecLookup } from '@internal/framework-components/codec'; -import type { AuthoredColumnDefaultLiteralValue } from '@internal/sql-contract-ts/contract-builder'; - -/** - * The default value a number literal gives a column whose codec holds numbers. Returns `undefined` - * when the codec does not hold numbers, or reads the literal neither as a JSON number nor as - * decimal text. - */ -export function numberLiteralDefault( - text: string, - codecId: string, - codecLookup: CodecLookup | undefined, -): AuthoredColumnDefaultLiteralValue | undefined { - const codec = numberHoldingCodec(codecLookup, codecId); - if (codec === undefined) return undefined; - const number = Number(text); - if (tryDecodeJson(codec, number) !== undefined) return number; - const decoded = tryDecodeJson(codec, canonicalDecimalText(text)); - return decoded !== undefined && isNumberValue(decoded.value) ? decoded.value : undefined; -} - -function numberHoldingCodec( - codecLookup: CodecLookup | undefined, - codecId: string, -): Codec | undefined { - const holdsNumbers = codecLookup?.descriptorFor?.(codecId)?.traits.includes('numeric') === true; - return holdsNumbers ? codecLookup?.get(codecId) : undefined; -} - -const DECIMAL_NUMERAL = /^(-?)0*(\d+)(\.\d+)?$/; - -/** - * Leading zeros and the sign of zero never change a decimal. Trailing zeros are kept, because a - * column without a scale keeps them. - */ -function canonicalDecimalText(text: string): string { - const numeral = DECIMAL_NUMERAL.exec(text); - if (numeral === null) return text; - const [, sign = '', whole = '', fraction = ''] = numeral; - const digits = `${whole}${fraction}`; - return /^[0.]+$/.test(digits) ? digits : `${sign}${digits}`; -} - -function isNumberValue(value: unknown): value is string | number | bigint { - return typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint'; -} - -function tryDecodeJson(codec: Codec, json: JsonValue): { readonly value: unknown } | undefined { - try { - return { value: codec.decodeJson(json) }; - } catch { - return undefined; - } -} 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 c90a76cbf820..dd94cbe254f3 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 @@ -22,7 +22,11 @@ import { isAuthoringTypeConstructorDescriptor, validateAuthoringHelperArguments, } from '@internal/framework-components/authoring'; -import type { AnyCodecDescriptor, CodecLookup } from '@internal/framework-components/codec'; +import type { + AnyCodecDescriptor, + CodecLookup, + PslLiteral, +} from '@internal/framework-components/codec'; import type { ControlMutationDefaultRegistry, MutationDefaultGeneratorDescriptor, @@ -30,26 +34,27 @@ import type { import type { FieldSymbol, ModelSymbol, - NumLiteral, PslSpan, ResolvedTypeConstructorCall, SymbolTable, } from '@internal/psl-parser'; -import type { SourceFile } from '@internal/psl-parser/syntax'; +import { nodePslSpan } from '@internal/psl-parser'; +import type { FieldAttributeAst, SourceFile } from '@internal/psl-parser/syntax'; import type { AuthoredColumnDefault, AuthoredColumnDefaultLiteralValue, } from '@internal/sql-contract-ts/contract-builder'; +import { blindCast } from '@internal/utils/casts'; import { InternalError } from '@internal/utils/internal-error'; import { contractError } from './contract-errors'; import { lowerDefaultFunctionWithRegistry } from './default-function-registry'; -import { numberLiteralDefault } from './number-literal-default'; import { mapPslHelperArgs } from './psl-authoring-arguments'; import { fieldSpecContext, findFieldAttributeNode, interpretFieldAttribute, + PSL_INVALID_DEFAULT_LITERAL, sqlAttributeSpecs, } from './sql-attribute-specs'; @@ -736,79 +741,104 @@ export function lowerDefaultForField(input: { }); if (interpreted === undefined) return {}; const value = interpreted.value; - const literalValue = ( - literal: string | boolean | NumLiteral, - ): AuthoredColumnDefaultLiteralValue => - typeof literal === 'object' - ? (numberLiteralDefault(literal.text, input.columnDescriptor.codecId, input.codecLookup) ?? - Number(literal.text)) - : literal; - - if (Array.isArray(value)) { - return { defaultValue: { kind: 'literal', value: value.map(literalValue) } }; - } - - if (typeof value === 'object' && 'text' in value) { - return { defaultValue: { kind: 'literal', value: literalValue(value) } }; - } - - if (typeof value === 'object') { - const lowered = lowerDefaultFunctionWithRegistry({ - call: value, - registry: input.defaultFunctionRegistry, - context: { - sourceId: input.sourceId, - modelName: input.modelName, - fieldName: input.fieldName, - columnCodecId: input.columnDescriptor.codecId, - }, - }); - - if (!lowered.ok) { - input.diagnostics.push(lowered.diagnostic); - return {}; - } - - if (lowered.value.kind === 'storage') { - return { defaultValue: lowered.value.defaultValue }; + if (typeof value === 'string') return { defaultValue: { kind: 'literal', value } }; + + if (Array.isArray(value) || isPslLiteral(value)) { + const codecId = input.columnDescriptor.codecId; + const codec = input.codecLookup?.get(codecId); + if (codec === undefined) { + throw new InternalError( + `Column "${input.modelName}.${input.fieldName}" resolved to codec "${codecId}", but the codec lookup has no codec for it. The lookup that resolved the column must also carry its codec.`, + ); } - - const generatorDescriptor = input.generatorDescriptorById.get(lowered.value.generated.id); - if (!generatorDescriptor) { + try { + const decoded = Array.isArray(value) + ? value.map((element) => codec.decodePsl(element)) + : codec.decodePsl(value); + return { + defaultValue: { + kind: 'literal', + value: blindCast< + AuthoredColumnDefaultLiteralValue, + 'decodePsl returns the codec its own value type, which encodeColumnDefault hands back to the same codec through encodeJson' + >(decoded), + }, + }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); input.diagnostics.push({ - code: 'PSL_INVALID_DEFAULT_APPLICABILITY', - message: `Default generator "${lowered.value.generated.id}" is not available in the composed mutation default registry.`, + code: PSL_INVALID_DEFAULT_LITERAL, + message: `Field "${input.modelName}.${input.fieldName}": @default(${defaultArgumentText(node, input.sourceFile)}) is not a value of ${codecId}: ${reason}`, sourceId: input.sourceId, - span: value.span, + span: nodePslSpan(node.syntax, input.sourceFile), }); return {}; } + } - // Preset-only generators (e.g. `timestampNow`) co-register their codec through the preset descriptor, so they don't carry an `applicableCodecIds` list. Such a generator surfacing on the `@default(...)` lowering path is itself the bug — emit a diagnostic pointing the user at the correct authoring surface. - if (generatorDescriptor.applicableCodecIds === undefined) { - input.diagnostics.push({ - code: 'PSL_INVALID_DEFAULT_APPLICABILITY', - message: `Default generator "${generatorDescriptor.id}" is not applicable to "@default(...)" lowering. Use the corresponding field preset (e.g. \`temporal.${generatorDescriptor.id === 'timestampNow' ? 'updatedAt' : generatorDescriptor.id}()\`) instead.`, - sourceId: input.sourceId, - span: value.span, - }); - return {}; - } + const lowered = lowerDefaultFunctionWithRegistry({ + call: value, + registry: input.defaultFunctionRegistry, + context: { + sourceId: input.sourceId, + modelName: input.modelName, + fieldName: input.fieldName, + columnCodecId: input.columnDescriptor.codecId, + }, + }); - if (!generatorDescriptor.applicableCodecIds.includes(input.columnDescriptor.codecId)) { - input.diagnostics.push({ - code: 'PSL_INVALID_DEFAULT_APPLICABILITY', - message: `Default generator "${generatorDescriptor.id}" is not applicable to "${input.modelName}.${input.fieldName}" with codecId "${input.columnDescriptor.codecId}".`, - sourceId: input.sourceId, - span: value.span, - }); - return {}; - } + if (!lowered.ok) { + input.diagnostics.push(lowered.diagnostic); + return {}; + } + + if (lowered.value.kind === 'storage') { + return { defaultValue: lowered.value.defaultValue }; + } + + const generatorDescriptor = input.generatorDescriptorById.get(lowered.value.generated.id); + if (!generatorDescriptor) { + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_APPLICABILITY', + message: `Default generator "${lowered.value.generated.id}" is not available in the composed mutation default registry.`, + sourceId: input.sourceId, + span: value.span, + }); + return {}; + } - return { executionDefaults: { onCreate: lowered.value.generated } }; + // Preset-only generators (e.g. `timestampNow`) co-register their codec through the preset descriptor, so they don't carry an `applicableCodecIds` list. Such a generator surfacing on the `@default(...)` lowering path is itself the bug — emit a diagnostic pointing the user at the correct authoring surface. + if (generatorDescriptor.applicableCodecIds === undefined) { + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_APPLICABILITY', + message: `Default generator "${generatorDescriptor.id}" is not applicable to "@default(...)" lowering. Use the corresponding field preset (e.g. \`temporal.${generatorDescriptor.id === 'timestampNow' ? 'updatedAt' : generatorDescriptor.id}()\`) instead.`, + sourceId: input.sourceId, + span: value.span, + }); + return {}; + } + + if (!generatorDescriptor.applicableCodecIds.includes(input.columnDescriptor.codecId)) { + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_APPLICABILITY', + message: `Default generator "${generatorDescriptor.id}" is not applicable to "${input.modelName}.${input.fieldName}" with codecId "${input.columnDescriptor.codecId}".`, + sourceId: input.sourceId, + span: value.span, + }); + return {}; } - return { defaultValue: { kind: 'literal', value } }; + return { executionDefaults: { onCreate: lowered.value.generated } }; +} + +function isPslLiteral(value: object): value is PslLiteral { + return 'text' in value; +} + +function defaultArgumentText(node: FieldAttributeAst, sourceFile: SourceFile): string { + return [...(node.argList()?.args() ?? [])] + .map((arg) => sourceFile.text.slice(arg.syntax.offset, arg.syntax.endOffset)) + .join(', '); } export function resolveColumnDescriptor( diff --git a/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts b/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts index 1f9c900dc249..bcebf42dfa32 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts @@ -1,4 +1,5 @@ import type { ContractSourceDiagnostic } from '@internal/config/config-types'; +import type { PslLiteral } from '@internal/framework-components/codec'; import type { ControlMutationDefaultRegistry } from '@internal/framework-components/control'; import type { ContributedPslDiagnosticCode, @@ -17,7 +18,6 @@ import type { InferAttr, ModelAttributeCtx, ModelSymbol, - NumLiteral, PslSpan, RejectingArgType, SymbolTable, @@ -33,9 +33,9 @@ import { interpretAttribute, leafDiagnostic, list, + literal, modelAttribute, nodePslSpan, - numLiteral, oneOf, optional, record, @@ -178,18 +178,15 @@ const mapFieldSpec = fieldAttribute('map', { refine: validateMappedName, }); -type DefaultArgValue = - | string - | NumLiteral - | boolean - | (string | NumLiteral | boolean)[] - | TypedFuncCall; +/** An enum member lowers to its name; every other literal reaches the column codec as a {@link PslLiteral}. */ +type EnumMemberName = string; + +type DefaultArgValue = PslLiteral | PslLiteral[] | TypedFuncCall | EnumMemberName; function scalarDefaultArms( isList: boolean, registry: ControlMutationDefaultRegistry, ): readonly [ArgType, ...ArgType[]] { - const literal = () => oneOf(str(), numLiteral(), bool()); const funcArms = [...registry.entries()].map(([name, entry]) => funcCall( name, @@ -199,7 +196,7 @@ function scalarDefaultArms( >(entry.signature), ), ); - return isList ? [list(literal()), ...funcArms] : [str(), numLiteral(), bool(), ...funcArms]; + return isList ? [list(literal()), ...funcArms] : [literal(), ...funcArms]; } function noEnumMember(): RejectingArgType { @@ -429,6 +426,10 @@ export const PSL_CHECK_EXPRESSION_EMPTY: ContributedPslDiagnosticCode = */ export const PSL_CHECK_ON_STI_VARIANT: ContributedPslDiagnosticCode = 'PSL_CHECK_ON_STI_VARIANT'; +/** A `@default(...)` literal the column codec's `decodePsl` does not read. */ +export const PSL_INVALID_DEFAULT_LITERAL: ContributedPslDiagnosticCode = + 'PSL_INVALID_DEFAULT_LITERAL'; + const checkModelSpec = modelAttribute('check', { documentation: 'Declares a named database CHECK constraint on this table.', named: { 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 d21ddb17f171..a6bb57dccce1 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -18,7 +18,21 @@ import { type PslExtensionBlock, resolveEnumCodecId, } from '@internal/framework-components/authoring'; -import type { CodecLookup } from '@internal/framework-components/codec'; +import { + type Codec, + type CodecLookup, + decodeBooleanPsl, + decodeFloatPsl, + decodeJsonTextPsl, + decodeStringPsl, + decodeWholeNumberPsl, + encodeBooleanPsl, + encodeFloatPsl, + encodeJsonTextPsl, + encodeNumberPsl, + encodeStringPsl, + pslLiteralKindError, +} from '@internal/framework-components/codec'; import type { ExtensionPackRef, TargetPackRef } from '@internal/framework-components/components'; import type { ControlMutationDefaultEntry, @@ -559,11 +573,115 @@ const targetTypesByCodecId: Record = { 'pg/vector@1': ['vector'], }; +/** + * How each fixture codec reads and writes its PSL literal, built from the shared framework helpers so these interpreter tests stay independent of a target pack. Behaviour that only the real codec has (`pg/numeric@1` canonicalisation, exact big integers) is covered in `target-postgres/test/psl-interpreter-literal-defaults.test.ts`. + */ +type PslShape = 'string' | 'whole' | 'bigint' | 'float' | 'decimal' | 'boolean' | 'json'; + +const pslShapeByCodecId: Record = { + 'pg/text@1': 'string', + 'pg/int@1': 'whole', + 'pg/bool@1': 'boolean', + 'pg/int4@1': 'whole', + 'pg/int8@1': 'bigint', + 'pg/float8@1': 'float', + 'pg/numeric@1': 'decimal', + 'pg/timestamptz-temporal@1': 'string', + 'pg/jsonb@1': 'json', + 'pg/bytea@1': 'string', + 'sql/char@1': 'string', + 'sql/varchar@1': 'string', + 'pg/int2@1': 'whole', + 'pg/float4@1': 'float', + 'pg/timestamp-temporal@1': 'string', + 'pg/date-temporal@1': 'string', + 'pg/time-temporal@1': 'string', + 'pg/timetz@1': 'string', + 'pg/json@1': 'json', + 'pg/vector@1': 'json', +}; + +const NON_FINITE_TEXT = /^(?:NaN|-?Infinity)$/; + +function pslMembers( + id: string, + shape: PslShape, +): Pick { + const identity = (value: unknown) => value as never; + switch (shape) { + case 'string': + return { + encodeJson: identity, + decodeJson: identity, + encodePsl: (value) => encodeStringPsl(value as string), + decodePsl: (literal) => decodeStringPsl(id, literal), + }; + case 'whole': + return { + encodeJson: identity, + decodeJson: identity, + encodePsl: (value) => encodeNumberPsl(value as number), + decodePsl: (literal) => Number(decodeWholeNumberPsl(id, literal)), + }; + case 'bigint': + return { + encodeJson: (value) => String(value), + decodeJson: (json) => BigInt(json as string), + encodePsl: (value) => ({ kind: 'number', text: String(value) }), + decodePsl: (literal) => BigInt(decodeWholeNumberPsl(id, literal)), + }; + case 'float': + return { + encodeJson: (value) => (Number.isFinite(value) ? (value as number) : String(value)), + decodeJson: (json) => Number(json), + encodePsl: (value) => encodeFloatPsl(value as number), + decodePsl: (literal) => decodeFloatPsl(id, literal), + }; + case 'decimal': + return { + encodeJson: identity, + decodeJson: identity, + encodePsl: (value) => + NON_FINITE_TEXT.test(value as string) + ? { kind: 'string', text: value as string } + : { kind: 'number', text: value as string }, + decodePsl: (literal) => { + if (literal.kind === 'number') return literal.text; + if (literal.kind === 'string' && NON_FINITE_TEXT.test(literal.text)) return literal.text; + throw pslLiteralKindError(id, 'number', literal); + }, + }; + case 'boolean': + return { + encodeJson: identity, + decodeJson: identity, + encodePsl: (value) => encodeBooleanPsl(value as boolean), + decodePsl: (literal) => decodeBooleanPsl(id, literal), + }; + case 'json': + return { + encodeJson: identity, + decodeJson: identity, + encodePsl: (value) => encodeJsonTextPsl(value as JsonValue), + decodePsl: (literal) => decodeJsonTextPsl(id, literal), + }; + } +} + +const fixtureCodecs = new Map( + Object.entries(pslShapeByCodecId).map(([id, shape]) => [ + id, + { + id, + encode: async (value: unknown) => value, + decode: async (wire: unknown) => wire, + ...pslMembers(id, shape), + }, + ]), +); + export const postgresCodecLookup: CodecLookup = { - get: (id: string) => { - if (!targetTypesByCodecId[id]) return undefined; - return { id } as ReturnType; - }, + get: (id: string) => fixtureCodecs.get(id), targetTypesFor: (id: string) => targetTypesByCodecId[id], renderOutputTypeFor: () => undefined, }; 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 1dee7cc1250b..d657e66bb92b 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 @@ -11,6 +11,7 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, postgresNativeScalarTypeDescriptors, postgresScalarAuthoringTypes, postgresScalarTypeDescriptors, @@ -50,9 +51,142 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, capabilities: { sql: { scalarList: true } }, + codecLookup: postgresCodecLookup, ...interpreterInput, }); }; + + function literalDefaults(schema: string) { + const document = symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }); + const result = interpretPslDocumentToSqlContract({ + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + if (!result.ok) return { columns: {}, diagnostics: result.failure.diagnostics }; + const table = unboundTables(sqlStorageFromSuccessfulSqlInterpretation(result.value))['N']; + const columns = Object.fromEntries( + Object.entries(table?.columns ?? {}).flatMap(([name, column]) => + column.default === undefined ? [] : [[name, column.default]], + ), + ); + return { columns, diagnostics: [] }; + } + + const invalidLiteral = ( + field: string, + source: string, + codecId: string, + reason: string, + column: number, + ) => ({ + code: 'PSL_INVALID_DEFAULT_LITERAL', + message: `Field "N.${field}": @default(${source}) is not a value of ${codecId}: ${reason}`, + sourceId: 'schema.prisma', + span: expect.objectContaining({ start: expect.objectContaining({ line: 3, column }) }), + }); + + describe('literal defaults read through the column codec', () => { + it('reads a JSON document from a string literal', () => { + expect( + literalDefaults(`model N { + id Int @id + object Jsonb @default("{}") + array Json @default("[1, 2]") + nothing Json @default("null") +}`), + ).toEqual({ + columns: { + object: { kind: 'literal', value: {} }, + array: { kind: 'literal', value: [1, 2] }, + nothing: { kind: 'literal', value: null }, + }, + diagnostics: [], + }); + }); + + it('stores a number in the JSON form the codec writes', () => { + expect( + literalDefaults(`model N { + id Int @id + big BigInt @default(9007199254740993) + price Decimal @default(1.50) + nan Float @default("NaN") + ratio Float @default(1.5) + count Int @default(-5) +}`), + ).toEqual({ + columns: { + big: { kind: 'literal', value: '9007199254740993' }, + price: { kind: 'literal', value: '1.50' }, + nan: { kind: 'literal', value: 'NaN' }, + ratio: { kind: 'literal', value: 1.5 }, + count: { kind: 'literal', value: -5 }, + }, + diagnostics: [], + }); + }); + + it('reads strings, booleans, and lists', () => { + expect( + literalDefaults(`model N { + id Int @id + quoted String @default("a\\"b") + flag Boolean @default(true) + numbers Int[] @default([1, 2]) +}`), + ).toEqual({ + columns: { + quoted: { kind: 'literal', value: 'a"b' }, + flag: { kind: 'literal', value: true }, + numbers: { kind: 'literal', value: [1, 2] }, + }, + diagnostics: [], + }); + }); + + it.each([ + [ + 'ratio Int @default(1.5)', + 'ratio', + '1.5', + 'pg/int4@1', + 'pg/int4@1 reads a whole number literal; got a number 1.5', + 13, + ], + [ + 'count Int @default("1")', + 'count', + '"1"', + 'pg/int4@1', + 'pg/int4@1 reads a whole number literal; got a string "1"', + 13, + ], + [ + 'payload Bytes @default(1234)', + 'payload', + '1234', + 'pg/bytea@1', + 'pg/bytea@1 reads a string literal; got a number 1234', + 17, + ], + [ + 'xs Int[] @default([1, "x"])', + 'xs', + '[1, "x"]', + 'pg/int4@1', + 'pg/int4@1 reads a whole number literal; got a string "x"', + 12, + ], + ])( + 'reports %s as PSL_INVALID_DEFAULT_LITERAL at the attribute', + (field, name, source, codecId, reason, column) => { + expect(literalDefaults(`model N {\n id Int @id\n ${field}\n}`)).toEqual({ + columns: {}, + diagnostics: [invalidLiteral(name, source, codecId, reason, column)], + }); + }, + ); + }); it('lowers supported default functions into execution and storage contract shapes', () => { const document = symbolTableInputFromParseArgs({ schema: `model Defaults { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts index 48b05961dce7..4a7f8c9f4ed9 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts @@ -7,6 +7,7 @@ import { import { createBuiltinLikeControlMutationDefaults, modelsOf, + postgresCodecLookup, postgresNativeScalarTypeDescriptors, postgresScalarAuthoringTypes, postgresScalarTypeDescriptors, @@ -24,6 +25,7 @@ const baseInput = { composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, capabilities: { sql: { scalarList: true } }, + codecLookup: postgresCodecLookup, } as const; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts index 9b3f6064fc27..d6fd562a7ffc 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts @@ -42,6 +42,8 @@ const textCodec: Codec = { if (typeof json !== 'string') throw new Error(`expected string, got ${typeof json}`); return json; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; const int4Codec: Codec = { @@ -53,6 +55,8 @@ const int4Codec: Codec = { if (typeof json !== 'number') throw new Error(`expected number, got ${typeof json}`); return json; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; const pgIntCodec: Codec = { ...int4Codec, id: 'pg/int@1' }; diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.no-check.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.no-check.test.ts index 41db991baea2..6b1c7a504b11 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.no-check.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.no-check.test.ts @@ -30,6 +30,8 @@ const textCodec: Codec = { if (typeof json !== 'string') throw new Error(`expected string, got ${typeof json}`); return json; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; const int4Codec: Codec = { ...textCodec, id: 'pg/int4@1' }; diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.number-defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.number-defaults.test.ts deleted file mode 100644 index 42377db56810..000000000000 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.number-defaults.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { JsonValue } from '@internal/contract/types'; -import type { - AnyCodecDescriptor, - CodecLookup, - CodecTrait, -} from '@internal/framework-components/codec'; -import { describe, expect, it } from 'vitest'; -import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; -import { interpretPslDocumentToSqlContract } from '../src/interpreter'; -import { - createBuiltinLikeControlMutationDefaults, - postgresNativeScalarTypeDescriptors, - postgresScalarAuthoringTypes, - postgresTarget, - symbolTableInputFromParseArgs, -} from './fixtures'; -import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contract-storage'; -import { unboundTables } from './unbound-tables'; - -interface TestCodec { - readonly traits: readonly CodecTrait[]; - readonly encodeJson: (value: never) => JsonValue; - readonly decodeJson: (json: JsonValue) => unknown; -} - -function text(json: JsonValue): string { - if (typeof json !== 'string') throw new Error('JSON value must be text'); - return json; -} - -const numberCodec: TestCodec = { - traits: ['equality', 'order', 'numeric'], - encodeJson: (value: number) => value, - decodeJson: (json) => json, -}; - -const testCodecs: Readonly> = { - 'pg/numeric@1': { - traits: ['equality', 'order', 'numeric'], - encodeJson: (value: string) => value, - decodeJson: text, - }, - 'pg/int8@1': { - traits: ['equality', 'order', 'numeric'], - encodeJson: (value: bigint | number) => BigInt(value).toString(), - decodeJson: (json) => BigInt(text(json)), - }, - 'pg/int4@1': numberCodec, - 'pg/float8@1': numberCodec, - 'pg/bytea@1': { - traits: ['equality'], - encodeJson: (value: unknown) => value as JsonValue, - decodeJson: text, - }, -}; - -const codecLookup: CodecLookup = { - get: (id) => { - const codec = testCodecs[id]; - if (codec === undefined) return undefined; - return { - id, - encode: async (value: unknown) => value, - decode: async (wire: unknown) => wire, - encodeJson: codec.encodeJson as (value: unknown) => JsonValue, - decodeJson: codec.decodeJson, - }; - }, - descriptorFor: (id) => { - const codec = testCodecs[id]; - return codec === undefined - ? undefined - : ({ codecId: id, traits: codec.traits } as unknown as AnyCodecDescriptor); - }, - targetTypesFor: () => undefined, - renderOutputTypeFor: () => undefined, -}; - -function columnDefaults(model: string) { - const document = symbolTableInputFromParseArgs({ schema: model, sourceId: 'schema.prisma' }); - const result = interpretPslDocumentToSqlContract({ - ...document, - target: postgresTarget, - scalarColumnDescriptors: postgresNativeScalarTypeDescriptors, - authoringContributions: { type: postgresScalarAuthoringTypes, field: {} }, - composedExtensionContracts: new Map(), - createNamespace: createTestSqlNamespace, - capabilities: { sql: { scalarList: true } }, - controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), - codecLookup, - }); - if (!result.ok) throw new Error(JSON.stringify(result.failure)); - const table = unboundTables(sqlStorageFromSuccessfulSqlInterpretation(result.value))['N']; - return Object.fromEntries( - Object.entries(table?.columns ?? {}).flatMap(([name, column]) => - column.default === undefined ? [] : [[name, column.default]], - ), - ); -} - -describe('number literal defaults', () => { - it('lower to the decimal text written, without leading zeros or the sign of zero, on a numeric column whose codec reads text', () => { - expect( - columnDefaults(`types { - Price = Numeric(10, 2) -} - -model N { - id Int @id - long Decimal @default(12345678901234567890.123456789) - tiny Decimal @default(0.000000000000000001) - negative Decimal @default(-1.25) - whole Decimal @default(10) - bareTrailingZeros Decimal @default(1.50) - scaledTrailingZeros Price @default(1.50) - notANumber Decimal @default(NaN) - negativeZero Decimal @default(-0) - leadingZeros Decimal @default(007) - leadingZeroFraction Decimal @default(00.10) - negativeLeadingZero Decimal @default(-007.50) - scaledNegativeZero Price @default(-0.00) -}`), - ).toEqual({ - long: { kind: 'literal', value: '12345678901234567890.123456789' }, - tiny: { kind: 'literal', value: '0.000000000000000001' }, - negative: { kind: 'literal', value: '-1.25' }, - whole: { kind: 'literal', value: '10' }, - bareTrailingZeros: { kind: 'literal', value: '1.50' }, - scaledTrailingZeros: { kind: 'literal', value: '1.50' }, - notANumber: { kind: 'literal', value: 'NaN' }, - negativeZero: { kind: 'literal', value: '0' }, - leadingZeros: { kind: 'literal', value: '7' }, - leadingZeroFraction: { kind: 'literal', value: '0.10' }, - negativeLeadingZero: { kind: 'literal', value: '-7.50' }, - scaledNegativeZero: { kind: 'literal', value: '0.00' }, - }); - }); - - it('lower to every digit written on a big integer column', () => { - expect( - columnDefaults(`model N { - id Int @id - big BigInt @default(9007199254740993) - smallest BigInt @default(-9223372036854775808) - safe BigInt @default(42) -}`), - ).toEqual({ - big: { kind: 'literal', value: '9007199254740993' }, - smallest: { kind: 'literal', value: '-9223372036854775808' }, - safe: { kind: 'literal', value: '42' }, - }); - }); - - it('lower each list element from its text', () => { - expect( - columnDefaults(`model N { - id Int @id - decimals Decimal[] @default([12345678901234567890.123456789, 1.50, -0, 007]) - bigs BigInt[] @default([9007199254740993, -1]) - ints Int[] @default([1, -2]) -}`), - ).toEqual({ - decimals: { - kind: 'literal', - value: ['12345678901234567890.123456789', '1.50', '0', '7'], - }, - bigs: { kind: 'literal', value: ['9007199254740993', '-1'] }, - ints: { kind: 'literal', value: [1, -2] }, - }); - }); - - it('stay numbers on columns whose codec reads a JSON number', () => { - expect( - columnDefaults(`model N { - id Int @id - count Int @default(-5) - ratio Float @default(1.50) -}`), - ).toEqual({ - count: { kind: 'literal', value: -5 }, - ratio: { kind: 'literal', value: 1.5 }, - }); - }); - - it('stay numbers on a column whose codec does not hold numbers, even when it reads text', () => { - expect( - columnDefaults(`model N { - id Int @id - payload Bytes @default(1234) -}`), - ).toEqual({ - payload: { kind: 'literal', value: 1234 }, - }); - }); -}); diff --git a/packages/2-sql/2-authoring/contract-psl/test/provider.enum-member-attributes.test.ts b/packages/2-sql/2-authoring/contract-psl/test/provider.enum-member-attributes.test.ts index 1f3f416a466e..9d118dc96d6b 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/provider.enum-member-attributes.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/provider.enum-member-attributes.test.ts @@ -20,6 +20,8 @@ const textCodec: Codec = { decode: async (wire: unknown) => wire, encodeJson: (value) => value as JsonValue, decodeJson: (json) => json, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; async function loadWithEnumSupport(schemaPath: string) { diff --git a/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts b/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts index cb43671f9624..2a25b8fa3f9c 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts @@ -240,9 +240,7 @@ describe('sqlAttributeSpecs.field.default', () => { expect(value.kind).toBe('oneOf'); expect(value.alternatives.map((alt) => alt.kind)).toEqual([ - 'str', - 'num', - 'bool', + 'literal', 'funcCall', 'funcCall', 'funcCall', @@ -277,7 +275,7 @@ describe('sqlAttributeSpecs.field.default', () => { const listDefault = listMetadata(value.alternatives[0] ?? value); expect(listDefault).toMatchObject({ kind: 'list' }); - expect(listDefault.of).toMatchObject({ kind: 'oneOf' }); + expect(listDefault.of).toMatchObject({ kind: 'literal' }); expect( value.alternatives.slice(1).map((alt) => (alt as FuncCallMetadata).name), ).toEqual(['autoincrement', 'now', 'uuid', 'cuid', 'ulid', 'nanoid', 'dbgenerated']); @@ -385,7 +383,7 @@ model Post { it('accepts scalar literals on a scalar field, keeping a number as written', () => { const schema = 'model Post {\n id Int @id\n price Decimal @default(1.50)\n}\n'; expect(interpretDefault(schema, 'price')).toEqual({ - value: { value: { text: '1.50' } }, + value: { value: { kind: 'number', text: '1.50' } }, diagnostics: [], }); }); @@ -393,7 +391,7 @@ model Post { it('accepts a list literal on a list field and rejects a list on a scalar field', () => { expect( interpretDefault('model Post {\n id Int @id\n tags String[] @default(["a"])\n}\n', 'tags'), - ).toEqual({ value: { value: ['a'] }, diagnostics: [] }); + ).toEqual({ value: { value: [{ kind: 'string', text: 'a' }] }, diagnostics: [] }); const rejected = interpretDefault( 'model Post {\n id Int @id\n tag String @default(["a"])\n}\n', 'tag', diff --git a/packages/2-sql/2-authoring/contract-ts/test/check-constraint.authoring.test.ts b/packages/2-sql/2-authoring/contract-ts/test/check-constraint.authoring.test.ts index e18c7243d916..d0b5f6f061b7 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/check-constraint.authoring.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/check-constraint.authoring.test.ts @@ -514,6 +514,8 @@ describe('check emission — guards', () => { decodeJson: (() => { throw new Error('unused'); }) as Codec['decodeJson'], + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, encode: (() => { throw new Error('unused'); }) as Codec['encode'], 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 ffeb0c4140cd..b509df5cf6f8 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 @@ -200,6 +200,8 @@ describe('shared contract definition lowering', () => { encodeJson: (value: unknown) => value instanceof Date ? value.toISOString() : (value as string), decodeJson: (json: unknown) => new Date(json as string), + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; }, targetTypesFor: (id) => (id === 'pg/timestamptz-temporal@1' ? ['timestamptz'] : undefined), 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 8f28f92b4473..23713ea4ff0a 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 @@ -47,6 +47,8 @@ describe('value objects in contract definition builder', () => { }; }, decodeJson: (json: unknown) => json, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; }, targetTypesFor: (id) => (id === 'pg/jsonb@1' ? ['jsonb'] : undefined), diff --git a/packages/2-sql/2-authoring/contract-ts/test/enum-type.codec-encoding.test.ts b/packages/2-sql/2-authoring/contract-ts/test/enum-type.codec-encoding.test.ts index 682cdc052c88..580771f094ee 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/enum-type.codec-encoding.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/enum-type.codec-encoding.test.ts @@ -29,6 +29,8 @@ function stubCodec(id: string, encodeJson: (value: unknown) => JsonValue): Codec id, encodeJson: encodeJson as Codec['encodeJson'], decodeJson: ((json: JsonValue) => json) as Codec['decodeJson'], + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, encode: (() => Promise.reject(new Error('unused'))) as Codec['encode'], decode: (() => Promise.reject(new Error('unused'))) as Codec['decode'], }; diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.typeref-resolver.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.typeref-resolver.test.ts index bfc4967388ca..b7c68daf246d 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.typeref-resolver.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.typeref-resolver.test.ts @@ -15,6 +15,8 @@ function vectorCodecLookup(): CodecLookup { decode: async (w: unknown) => w, encodeJson: (v: unknown) => v as never, decodeJson: (j: unknown) => j as never, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, } as ReturnType; return { get: (id) => (id === 'pg/vector@1' ? vectorCodec : undefined), diff --git a/packages/2-sql/4-lanes/relational-core/README.md b/packages/2-sql/4-lanes/relational-core/README.md index 8be11214a853..35b4902001c4 100644 --- a/packages/2-sql/4-lanes/relational-core/README.md +++ b/packages/2-sql/4-lanes/relational-core/README.md @@ -93,7 +93,7 @@ flowchart TD ### Codec authoring (class form) -SQL codec authors extend the framework `CodecImpl` base (and pair the codec with a `CodecDescriptorImpl` registration) per [ADR 208 — Higher-order codecs for parameterized types](../../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md). Each codec class declares `encode`, `decode`, `encodeJson`, and `decodeJson`. The JSON methods use the exact scalar shape produced by the corresponding database inside JSON values; include decoding calls `decodeJson`, while ordinary column decoding calls `decode`. +SQL codec authors extend the framework `CodecImpl` base (and pair the codec with a `CodecDescriptorImpl` registration) per [ADR 208 — Higher-order codecs for parameterized types](../../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md). Each codec class declares `encode`, `decode`, `encodeJson`, `decodeJson`, `encodePsl`, and `decodePsl`; the PSL pair reads and writes the literal that denotes a value in schema source, through the shared pairs in `@internal/framework-components/codec` (see [ADR 184](../../../../docs/architecture%20docs/adrs/ADR%20184%20-%20Codec-owned%20value%20serialization.md)). The JSON methods use the exact scalar shape produced by the corresponding database inside JSON values; include decoding calls `decodeJson`, while ordinary column decoding calls `decode`. - Query-time methods (`encode` / `decode`) are typed as `Promise<…>`-returning at the public boundary; sync method bodies are accepted via TypeScript bivariance and the runtime always awaits. - Build-time methods (`encodeJson` / `decodeJson` / `renderOutputType?`) stay synchronous so contract validation and client construction stay synchronous. 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 14d81e7e387e..ee854c795502 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 @@ -18,8 +18,16 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + decodeNumberPsl, + decodeStringPsl, + decodeWholeNumberPsl, + encodeNumberPsl, + encodeStringPsl, + type PslLiteral, + pslLiteralReadsError, voidParamsSchema, } from '@internal/framework-components/codec'; +import { structuredError } from '@internal/utils/structured-error'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type as arktype } from 'arktype'; import { @@ -68,6 +76,12 @@ export class SqlTextCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class SqlTextDescriptor extends CodecDescriptorImpl { @@ -106,6 +120,12 @@ export class SqlIntCodec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + encodePsl(value: number): PslLiteral { + return encodeNumberPsl(value); + } + decodePsl(literal: PslLiteral): number { + return Number(decodeWholeNumberPsl(this.id, literal)); + } } export class SqlIntDescriptor extends CodecDescriptorImpl { @@ -144,6 +164,21 @@ export class SqlFloatCodec extends CodecImpl< decodeJson(json: JsonValue): number { return sqlFloatDecodeJson(json); } + encodePsl(value: number): PslLiteral { + if (!Number.isFinite(value)) { + throw structuredError( + 'RUNTIME.ENCODE_FAILED', + `${this.id} application value must be a finite number, got ${value}`, + { meta: { codec: this.id } }, + ); + } + return encodeNumberPsl(value); + } + decodePsl(literal: PslLiteral): number { + const value = decodeNumberPsl(this.id, literal); + if (!Number.isFinite(value)) throw pslLiteralReadsError(this.id, 'a finite number', literal); + return value; + } } export class SqlFloatDescriptor extends CodecDescriptorImpl { @@ -182,6 +217,12 @@ export class SqlCharCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class SqlCharDescriptor extends CodecDescriptorImpl { @@ -223,6 +264,12 @@ export class SqlVarcharCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class SqlVarcharDescriptor extends CodecDescriptorImpl { diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs-psl.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs-psl.test.ts new file mode 100644 index 000000000000..4dcce31af719 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs-psl.test.ts @@ -0,0 +1,48 @@ +import type { Codec } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { + sqlCharDescriptor, + sqlFloatDescriptor, + sqlIntDescriptor, + sqlTextDescriptor, + sqlVarcharDescriptor, +} from '../../src/ast/sql-codecs'; + +const instanceCtx = { name: '' }; +const strings = ['hello', 'a"b\\c', '']; + +const table: readonly [string, Codec, readonly unknown[]][] = [ + ['sql/text@1', sqlTextDescriptor.factory()(instanceCtx), strings], + ['sql/char@1', sqlCharDescriptor.factory({})(instanceCtx), strings], + ['sql/varchar@1', sqlVarcharDescriptor.factory({})(instanceCtx), strings], + ['sql/int@1', sqlIntDescriptor.factory()(instanceCtx), [0, 42, -7]], + ['sql/float@1', sqlFloatDescriptor.factory()(instanceCtx), [1.5, -0.25, 1e21, 1e-7]], +]; + +const cases = table.flatMap(([id, codec, values]) => + values.map((value): [string, unknown, Codec] => [id, value, codec]), +); + +describe('SQL base codecs', () => { + it.each(cases)('%s round-trips %s through its PSL literal', (_id, value, codec) => { + expect(codec.decodePsl(codec.encodePsl(value))).toEqual(value); + }); + + it.each(table)('%s rejects a boolean literal with a message naming it', (id, codec) => { + expect(() => codec.decodePsl({ kind: 'boolean', text: 'true' })).toThrow(id); + }); + + it('sql/int@1 rejects 1.5', () => { + const [, int] = table[3]!; + expect(() => int.decodePsl({ kind: 'number', text: '1.5' })).toThrow( + 'sql/int@1 reads a whole number literal; got a number 1.5', + ); + }); + + it('sql/float@1 refuses "NaN" in both spellings', () => { + const [, float] = table[4]!; + expect(() => float.decodePsl({ kind: 'string', text: 'NaN' })).toThrow('sql/float@1'); + expect(() => float.decodePsl({ kind: 'number', text: 'NaN' })).toThrow('sql/float@1'); + expect(() => float.encodePsl(Number.NaN)).toThrow('sql/float@1'); + }); +}); 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 96488cabe24f..1f37bb0f3db0 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 @@ -2,7 +2,12 @@ * Test-only helper that constructs a SQL-family `Codec` instance from author-side encode/decode functions. Replaces the legacy public `mkCodec()` factory (deleted under TML-2357); tests that need a stub codec for behavioural assertions instantiate one through this helper rather than going through `descriptor.factory(...)`. */ import type { JsonValue } from '@internal/contract/types'; -import type { CodecTrait } from '@internal/framework-components/codec'; +import { + type CodecTrait, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, +} from '@internal/framework-components/codec'; import type { Codec, SqlCodecCallContext } from '../../src/ast/codec-types'; type JsonRoundTripConfig = [TInput] extends [JsonValue] @@ -36,6 +41,20 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const encodeJson = (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue; + const decodeJson = (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput; + const encodePsl = (value: TInput): PslLiteral => { + const json = encodeJson(value); + if (typeof json === 'string') return { kind: 'string', text: json }; + if (typeof json === 'number') return { kind: 'number', text: String(json) }; + if (typeof json === 'boolean') return { kind: 'boolean', text: String(json) }; + return encodeJsonTextPsl(json); + }; + const decodePsl = (literal: PslLiteral): TInput => { + if (literal.kind === 'number') return decodeJson(Number(literal.text)); + if (literal.kind === 'boolean') return decodeJson(literal.text === 'true'); + return decodeJson(decodeJsonTextPsl(config.typeId, literal)); + }; return { id: config.typeId, encode: (value, ctx) => { @@ -52,7 +71,9 @@ export function defineTestCodec< return Promise.reject(error); } }, - encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, - decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + encodeJson, + decodeJson, + encodePsl, + decodePsl, } as Codec; } diff --git a/packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts b/packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts index da58e828835c..97f7232b7591 100644 --- a/packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts +++ b/packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts @@ -19,6 +19,9 @@ import { CodecImpl, type CodecInstanceContext, type CodecTrait, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; import type { StandardSchemaV1 } from '@standard-schema/spec'; @@ -65,6 +68,12 @@ class TestVectorCodec extends CodecImpl<'test/vector@1', readonly ['equality'], decodeJson(json: JsonValue): number[] { return json as number[]; } + encodePsl(value: number[]): PslLiteral { + return encodeJsonTextPsl(this.encodeJson(value)); + } + decodePsl(literal: PslLiteral): number[] { + return this.decodeJson(decodeJsonTextPsl(this.id, literal)); + } } class TestVectorDescriptor extends CodecDescriptorImpl { 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 5e3b2ffa74b5..111956130d02 100644 --- a/packages/2-sql/5-runtime/test/codec-integrity.test.ts +++ b/packages/2-sql/5-runtime/test/codec-integrity.test.ts @@ -22,6 +22,8 @@ describe('createExecutionContext — column codec integrity', () => { decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; } 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 32db1ea6342b..8a1085089643 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 @@ -35,6 +35,8 @@ describe('buildContractCodecRegistry — per-column codec instance context', () decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; instances.push({ ctx, codec }); return codec; @@ -146,6 +148,8 @@ describe('buildContractCodecRegistry — forCodecRef content-keyed cache', () => decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; return Object.assign({}, codec, { meta: { length: params.length, ctxName: ctx.name }, @@ -394,6 +398,8 @@ describe('buildContractCodecRegistry — forColumn delegates to forCodecRef', () decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; 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 931966916b6a..779a3be16885 100644 --- a/packages/2-sql/5-runtime/test/test-codec.ts +++ b/packages/2-sql/5-runtime/test/test-codec.ts @@ -4,7 +4,12 @@ * The body is identical in spirit to the retired `mkCodec`: promise-lift sync author functions onto the framework-required `Promise<…>` boundary, default `encodeJson`/`decodeJson` to identity when `TInput` is JSON-safe, fail loudly otherwise. */ import type { JsonValue } from '@internal/contract/types'; -import type { CodecTrait } from '@internal/framework-components/codec'; +import { + type CodecTrait, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, +} from '@internal/framework-components/codec'; import type { Codec, SqlCodecCallContext } from '@internal/sql-relational-core/ast'; type JsonRoundTripConfig = [TInput] extends [JsonValue] @@ -38,6 +43,20 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const encodeJson = (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue; + const decodeJson = (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput; + const encodePsl = (value: TInput): PslLiteral => { + const json = encodeJson(value); + if (typeof json === 'string') return { kind: 'string', text: json }; + if (typeof json === 'number') return { kind: 'number', text: String(json) }; + if (typeof json === 'boolean') return { kind: 'boolean', text: String(json) }; + return encodeJsonTextPsl(json); + }; + const decodePsl = (literal: PslLiteral): TInput => { + if (literal.kind === 'number') return decodeJson(Number(literal.text)); + if (literal.kind === 'boolean') return decodeJson(literal.text === 'true'); + return decodeJson(decodeJsonTextPsl(config.typeId, literal)); + }; return { id: config.typeId, encode: (value, ctx) => { @@ -54,7 +73,9 @@ export function defineTestCodec< return Promise.reject(error); } }, - encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, - decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + encodeJson, + decodeJson, + encodePsl, + decodePsl, } as Codec; } 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 5372576b0cc1..d154995a9ec5 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,4 +1,6 @@ -import type { ColumnDefault } from '@internal/contract/types'; +import type { ColumnDefault, ColumnDefaultLiteralInputValue } from '@internal/contract/types'; +import type { Codec } from '@internal/framework-components/codec'; +import { formatPslLiteral } from './psl-literal-format'; const DEFAULT_FUNCTION_ATTRIBUTES: Readonly> = { 'autoincrement()': '@default(autoincrement())', @@ -10,15 +12,34 @@ export interface DefaultMappingOptions { readonly fallbackFunctionAttribute?: ((expression: string) => string | undefined) | undefined; } +/** A literal default prints through the column codec, so the codec is required. */ +export interface LiteralDefaultMappingOptions extends DefaultMappingOptions { + readonly codec: Codec; +} + export type DefaultMappingResult = { readonly attribute: string } | { readonly comment: string }; +type FunctionColumnDefault = Extract; + export function mapDefault( - columnDefault: ColumnDefault, + columnDefault: FunctionColumnDefault, options?: DefaultMappingOptions, +): DefaultMappingResult; +export function mapDefault( + columnDefault: ColumnDefault, + options: LiteralDefaultMappingOptions, +): DefaultMappingResult; +export function mapDefault( + columnDefault: ColumnDefault, + options?: DefaultMappingOptions | LiteralDefaultMappingOptions, ): DefaultMappingResult { switch (columnDefault.kind) { - case 'literal': - return { attribute: `@default(${formatLiteralValue(columnDefault.value)})` }; + case 'literal': { + if (options === undefined || !('codec' in options)) { + throw new TypeError('mapDefault: a literal default prints through its column codec'); + } + return { attribute: `@default(${formatLiteral(columnDefault.value, options.codec)})` }; + } case 'function': { const attribute = options?.functionAttributes?.[columnDefault.expression] ?? @@ -31,26 +52,11 @@ 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); +/** The contract holds the JSON form, so the codec reads it back before writing its PSL form; a `Date` is the one authored value JSON has no notation for and is already the codec's own value. A list column prints one literal per element. Any error the codec raises propagates. */ +function formatLiteral(value: ColumnDefaultLiteralInputValue, codec: Codec): string { + const literal = (element: ColumnDefaultLiteralInputValue) => + formatPslLiteral( + codec.encodePsl(element instanceof Date ? element : codec.decodeJson(element)), + ); + return Array.isArray(value) ? `[${value.map(literal).join(', ')}]` : literal(value); } diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/psl-literal-format.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/psl-literal-format.ts new file mode 100644 index 000000000000..71fb0fd09d5c --- /dev/null +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/psl-literal-format.ts @@ -0,0 +1,15 @@ +import type { PslLiteral } from '@internal/framework-components/codec'; + +/** The characters a PSL string literal cannot hold as written. */ +export function escapePslString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r'); +} + +/** Writes a codec's PSL literal as schema source: a string inside its quotes with escapes, a number or boolean as its text. */ +export function formatPslLiteral(literal: PslLiteral): string { + return literal.kind === 'string' ? `"${escapePslString(literal.text)}"` : literal.text; +} diff --git a/packages/2-sql/9-family/src/exports/psl-infer.ts b/packages/2-sql/9-family/src/exports/psl-infer.ts index 591b4c495b24..17977907dcb0 100644 --- a/packages/2-sql/9-family/src/exports/psl-infer.ts +++ b/packages/2-sql/9-family/src/exports/psl-infer.ts @@ -12,6 +12,7 @@ export type { DefaultMappingOptions, DefaultMappingResult, + LiteralDefaultMappingOptions, } from '../core/psl-contract-infer/default-mapping'; export { mapDefault } from '../core/psl-contract-infer/default-mapping'; export { @@ -31,6 +32,10 @@ export type { PslTypeResolution, RelationField, } from '../core/psl-contract-infer/printer-config'; +export { + escapePslString, + formatPslLiteral, +} from '../core/psl-contract-infer/psl-literal-format'; export type { InferredRelations } from '../core/psl-contract-infer/relation-inference'; export { buildChildRelationField, diff --git a/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts b/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts index 368f1818e226..16655cf8aef7 100644 --- a/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts +++ b/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts @@ -60,6 +60,8 @@ const pgTextCodec: Codec = { if (typeof json !== 'string') throw new Error(`expected string, got ${typeof json}`); return json; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; const pgIntCodec: Codec = { @@ -71,6 +73,8 @@ const pgIntCodec: Codec = { if (typeof json !== 'number') throw new Error(`expected number, got ${typeof json}`); return json; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; const testCodecLookup: CodecLookup = { 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 1a89871f45a1..e1a1be6e3064 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 @@ -1,8 +1,20 @@ +import { + type Codec, + decodeBooleanPsl, + decodeJsonTextPsl, + decodeNumberPsl, + decodeStringPsl, + encodeBooleanPsl, + encodeJsonTextPsl, + encodeNumberPsl, + encodeStringPsl, +} from '@internal/framework-components/codec'; import { describe, expect, it } from 'vitest'; import { type DefaultMappingOptions, mapDefault, } from '../../src/core/psl-contract-infer/default-mapping'; +import { formatPslLiteral } from '../../src/core/psl-contract-infer/psl-literal-format'; // Inline dialect-mapping fixture (the Postgres maps now live in the target); // these cases exercise the neutral `mapDefault` with an injected mapping. @@ -11,6 +23,44 @@ const injectedMapping: DefaultMappingOptions = { fallbackFunctionAttribute: (expression) => `@default(dbgenerated(${JSON.stringify(expression)}))`, }; +function codec( + id: string, + members: Pick & Partial>, +): Codec { + return { + id, + encode: async (value: unknown) => value, + decode: async (wire: unknown) => wire, + encodeJson: (value) => value as never, + decodeJson: (json) => json as never, + ...members, + }; +} + +const text = codec('test/text@1', { + encodePsl: (value) => encodeStringPsl(value as string), + decodePsl: (literal) => decodeStringPsl('test/text@1', literal), +}); +const number = codec('test/number@1', { + encodePsl: (value) => encodeNumberPsl(value as number), + decodePsl: (literal) => decodeNumberPsl('test/number@1', literal), +}); +const boolean = codec('test/bool@1', { + encodePsl: (value) => encodeBooleanPsl(value as boolean), + decodePsl: (literal) => decodeBooleanPsl('test/bool@1', literal), +}); +const json = codec('test/json@1', { + encodePsl: (value) => encodeJsonTextPsl(value as never), + decodePsl: (literal) => decodeJsonTextPsl('test/json@1', literal), +}); +const refusing = codec('test/refusing@1', { + decodeJson: () => { + throw new Error('test/refusing@1 reads nothing'); + }, + encodePsl: (value) => encodeStringPsl(value as string), + decodePsl: (literal) => decodeStringPsl('test/refusing@1', literal), +}); + describe('mapDefault', () => { it('maps autoincrement()', () => { expect(mapDefault({ kind: 'function', expression: 'autoincrement()' })).toEqual({ @@ -38,69 +88,70 @@ describe('mapDefault', () => { }); }); - it('maps boolean true', () => { - expect(mapDefault({ kind: 'literal', value: true })).toEqual({ - attribute: '@default(true)', - }); - }); - - it('maps boolean false', () => { - expect(mapDefault({ kind: 'literal', value: false })).toEqual({ - attribute: '@default(false)', + it('unrecognized function becomes comment', () => { + expect(mapDefault({ kind: 'function', expression: 'custom_func()' })).toEqual({ + comment: '// Raw default: custom_func()', }); }); - it('maps number', () => { - expect(mapDefault({ kind: 'literal', value: 42 })).toEqual({ - attribute: '@default(42)', + it('treats Postgres-specific functions as raw defaults without injected mapping', () => { + expect(mapDefault({ kind: 'function', expression: 'gen_random_uuid()' })).toEqual({ + comment: '// Raw default: gen_random_uuid()', }); }); - it('maps string', () => { - expect(mapDefault({ kind: 'literal', value: 'hello' })).toEqual({ - attribute: '@default("hello")', + describe('literal defaults print through the column codec', () => { + it('prints a boolean', () => { + expect(mapDefault({ kind: 'literal', value: true }, { codec: boolean })).toEqual({ + attribute: '@default(true)', + }); }); - }); - it('maps string with quotes', () => { - expect(mapDefault({ kind: 'literal', value: 'he said "hi"' })).toEqual({ - attribute: '@default("he said \\"hi\\"")', + it('prints a number', () => { + expect(mapDefault({ kind: 'literal', value: 42 }, { codec: number })).toEqual({ + attribute: '@default(42)', + }); }); - }); - 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('escapes backslash, quotes, newline, and carriage return and keeps a tab as written', () => { + expect( + mapDefault({ kind: 'literal', value: 'line 1\nline 2\t"quoted"' }, { codec: text }), + ).toEqual({ attribute: '@default("line 1\\nline 2\t\\"quoted\\"")' }); }); - }); - it('unrecognized function becomes comment', () => { - expect(mapDefault({ kind: 'function', expression: 'custom_func()' })).toEqual({ - comment: '// Raw default: custom_func()', + it('prints a JSON document as a string holding JSON text', () => { + expect(mapDefault({ kind: 'literal', value: { a: 1 } }, { codec: json })).toEqual({ + attribute: '@default("{\\"a\\":1}")', + }); }); - }); - it('treats Postgres-specific functions as raw defaults without injected mapping', () => { - expect(mapDefault({ kind: 'function', expression: 'gen_random_uuid()' })).toEqual({ - comment: '// Raw default: gen_random_uuid()', + it('prints a list one element at a time', () => { + expect(mapDefault({ kind: 'literal', value: [1, 2] }, { codec: number })).toEqual({ + attribute: '@default([1, 2])', + }); }); - }); - it('maps null literal', () => { - expect(mapDefault({ kind: 'literal', value: null })).toEqual({ - attribute: '@default(null)', + it('lets an error the codec raises propagate', () => { + expect(() => mapDefault({ kind: 'literal', value: 'x' }, { codec: refusing })).toThrow( + 'test/refusing@1 reads nothing', + ); }); }); +}); - it('maps large number literal', () => { - expect(mapDefault({ kind: 'literal', value: 9007199254740991 })).toEqual({ - attribute: '@default(9007199254740991)', - }); +describe('formatPslLiteral', () => { + it.each([ + ['plain', '"plain"'], + ['back\\slash', '"back\\\\slash"'], + ['say "hi"', '"say \\"hi\\""'], + ['line\nbreak', '"line\\nbreak"'], + ['carriage\rreturn', '"carriage\\rreturn"'], + ])('writes the string %j as %s', (text, expected) => { + expect(formatPslLiteral({ kind: 'string', text })).toBe(expected); }); - it('stringifies unsupported literal defaults', () => { - expect(mapDefault({ kind: 'literal', value: { nested: ['value'] } })).toEqual({ - attribute: '@default("{\\"nested\\":[\\"value\\"]}")', - }); + it('writes a number and a boolean as their text', () => { + expect(formatPslLiteral({ kind: 'number', text: '9007199254740993' })).toBe('9007199254740993'); + expect(formatPslLiteral({ kind: 'boolean', text: 'false' })).toBe('false'); }); }); 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 ae36f015d0f0..01bed79a64e0 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 @@ -19,6 +19,9 @@ import { type ColumnHelperFor, type ColumnSpec, column, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, } from '@internal/framework-components/codec'; import { isRuntimeError, runtimeError } from '@internal/framework-components/runtime'; import type { ProjectionExpr } from '@internal/sql-relational-core/ast'; @@ -202,6 +205,14 @@ export class ArktypeJsonCodecClass extends CodecImpl< decodeJson(json: JsonValue): TInferred { return validateSchema(this.schema, json); } + + encodePsl(value: TInferred): PslLiteral { + return encodeJsonTextPsl(this.encodeJson(value)); + } + + decodePsl(literal: PslLiteral): TInferred { + return this.decodeJson(decodeJsonTextPsl(this.id, literal)); + } } const arktypeJsonParamsSchema = type({ diff --git a/packages/3-extensions/arktype-json/test/psl-literals.test.ts b/packages/3-extensions/arktype-json/test/psl-literals.test.ts new file mode 100644 index 000000000000..28c12b5bf037 --- /dev/null +++ b/packages/3-extensions/arktype-json/test/psl-literals.test.ts @@ -0,0 +1,22 @@ +import { type } from 'arktype'; +import { describe, expect, it } from 'vitest'; +import { arktypeJsonColumn } from '../src/core/arktype-json-codec'; + +const product = type({ name: 'string', price: 'number' }); +const codec = arktypeJsonColumn(product).codecFactory({ name: '' }); + +describe('pg/arktype-json@1 PSL literal', () => { + it('round-trips a value through JSON text', () => { + const value = { name: 'pen', price: 1.5 }; + expect(codec.encodePsl(value)).toEqual({ kind: 'string', text: '{"name":"pen","price":1.5}' }); + expect(codec.decodePsl({ kind: 'string', text: '{"name":"pen","price":1.5}' })).toEqual(value); + }); + + it('rejects a number literal naming the codec', () => { + expect(() => codec.decodePsl({ kind: 'number', text: '1' })).toThrow(codec.id); + }); + + it('validates the parsed JSON against the schema', () => { + expect(() => codec.decodePsl({ kind: 'string', text: '{"name":1}' })).toThrow(); + }); +}); diff --git a/packages/3-extensions/mongo/test/mongo.enum.e2e.test.ts b/packages/3-extensions/mongo/test/mongo.enum.e2e.test.ts index d22a62d1b3b9..763d1c700218 100644 --- a/packages/3-extensions/mongo/test/mongo.enum.e2e.test.ts +++ b/packages/3-extensions/mongo/test/mongo.enum.e2e.test.ts @@ -59,6 +59,8 @@ const codecLookup: CodecLookup = { decode: async (w: unknown) => w, encodeJson: (v: unknown) => v, decodeJson: (j: unknown) => j, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, } as ReturnType; }, targetTypesFor: (id: string) => mongoTargetTypes[id], diff --git a/packages/3-extensions/pgvector/src/core/codecs.ts b/packages/3-extensions/pgvector/src/core/codecs.ts index 0a43e3e58d07..a54809f259e4 100644 --- a/packages/3-extensions/pgvector/src/core/codecs.ts +++ b/packages/3-extensions/pgvector/src/core/codecs.ts @@ -19,6 +19,9 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, } from '@internal/framework-components/codec'; import type { ExtractCodecTypes, ProjectionExpr } from '@internal/sql-relational-core/ast'; import { CastExpr, FunctionCallExpr } from '@internal/sql-relational-core/ast'; @@ -149,6 +152,14 @@ export class PgVectorCodec extends CodecImpl< this.assertVector(value, 'RUNTIME.DECODE_FAILED'); return value; } + + encodePsl(value: number[]): PslLiteral { + return encodeJsonTextPsl(this.encodeJson(value)); + } + + decodePsl(literal: PslLiteral): number[] { + return this.decodeJson(decodeJsonTextPsl(this.id, literal)); + } } /** diff --git a/packages/3-extensions/pgvector/test/psl-literals.test.ts b/packages/3-extensions/pgvector/test/psl-literals.test.ts new file mode 100644 index 000000000000..88b2405edde0 --- /dev/null +++ b/packages/3-extensions/pgvector/test/psl-literals.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { pgVectorDescriptor } from '../src/core/codecs'; + +const codec = pgVectorDescriptor.factory({ length: 3 })({ name: '' }); + +describe('pg/vector@1 PSL literal', () => { + it('round-trips a vector through JSON text', () => { + expect(codec.encodePsl([0.1, 0.2, 0.3])).toEqual({ kind: 'string', text: '[0.1,0.2,0.3]' }); + expect(codec.decodePsl({ kind: 'string', text: '[0.1,0.2,0.3]' })).toEqual([0.1, 0.2, 0.3]); + }); + + it('rejects a number literal naming the codec', () => { + expect(() => codec.decodePsl({ kind: 'number', text: '1' })).toThrow('pg/vector@1'); + }); + + it('rejects JSON of the wrong shape through decodeJson', () => { + expect(() => codec.decodePsl({ kind: 'string', text: '{"a":1}' })).toThrow(); + }); +}); diff --git a/packages/3-extensions/postgis/src/core/codecs.ts b/packages/3-extensions/postgis/src/core/codecs.ts index 5dd23059972a..92c36853e808 100644 --- a/packages/3-extensions/postgis/src/core/codecs.ts +++ b/packages/3-extensions/postgis/src/core/codecs.ts @@ -40,6 +40,9 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + decodeStringPsl, + encodeStringPsl, + type PslLiteral, } from '@internal/framework-components/codec'; import type { ExtractCodecTypes, ProjectionExpr } from '@internal/sql-relational-core/ast'; import { @@ -127,7 +130,7 @@ export class PostgisGeometryCodec extends CodecImpl< return decodeEWKBHex(wire); } - encodeJson(value: Geometry): JsonValue { + encodeJson(value: Geometry): string { assertGeometry(value); return encodeEWKBHex(value); } @@ -142,6 +145,14 @@ export class PostgisGeometryCodec extends CodecImpl< } return decodeEWKBHex(json); } + + encodePsl(value: Geometry): PslLiteral { + return encodeStringPsl(this.encodeJson(value)); + } + + decodePsl(literal: PslLiteral): Geometry { + return this.decodeJson(decodeStringPsl(this.id, literal)); + } } export class PostgisGeometryDescriptor extends PostgresCodecDescriptor { diff --git a/packages/3-extensions/postgis/test/psl-literals.test.ts b/packages/3-extensions/postgis/test/psl-literals.test.ts new file mode 100644 index 000000000000..d0e45b6884f3 --- /dev/null +++ b/packages/3-extensions/postgis/test/psl-literals.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { postgisGeometryDescriptor } from '../src/core/codecs'; +import type { Geometry } from '../src/core/geojson'; + +const codec = postgisGeometryDescriptor.factory({ srid: 4326 })({ name: '' }); +const point: Geometry = { type: 'Point', coordinates: [1, 2], srid: 4326 }; + +describe('pg/geometry@1 PSL literal', () => { + it('round-trips a geometry through its HEXEWKB string', () => { + const literal = codec.encodePsl(point); + expect(literal.kind).toBe('string'); + expect(literal.text).toBe(codec.encodeJson(point)); + expect(codec.decodePsl(literal)).toEqual(point); + }); + + it('rejects a number literal naming the codec', () => { + expect(() => codec.decodePsl({ kind: 'number', text: '1' })).toThrow('pg/geometry@1'); + }); +}); 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 133f67a28832..e035b76e09a2 100644 --- a/packages/3-extensions/sql-orm-client/test/test-codec.ts +++ b/packages/3-extensions/sql-orm-client/test/test-codec.ts @@ -2,7 +2,12 @@ * Test-only helper that constructs a SQL-family `Codec` instance from author-side encode/decode functions. Replaces the legacy public `mkCodec()` factory (deleted under TML-2357); tests that need a stub codec for behavioural assertions instantiate one through this helper rather than going through `descriptor.factory(...)`. */ import type { JsonValue } from '@internal/contract/types'; -import type { CodecTrait } from '@internal/framework-components/codec'; +import { + type CodecTrait, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, +} from '@internal/framework-components/codec'; import type { Codec, SqlCodecCallContext } from '@internal/sql-relational-core/ast'; type JsonRoundTripConfig = [TInput] extends [JsonValue] @@ -36,6 +41,20 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const encodeJson = (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue; + const decodeJson = (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput; + const encodePsl = (value: TInput): PslLiteral => { + const json = encodeJson(value); + if (typeof json === 'string') return { kind: 'string', text: json }; + if (typeof json === 'number') return { kind: 'number', text: String(json) }; + if (typeof json === 'boolean') return { kind: 'boolean', text: String(json) }; + return encodeJsonTextPsl(json); + }; + const decodePsl = (literal: PslLiteral): TInput => { + if (literal.kind === 'number') return decodeJson(Number(literal.text)); + if (literal.kind === 'boolean') return decodeJson(literal.text === 'true'); + return decodeJson(decodeJsonTextPsl(config.typeId, literal)); + }; return { id: config.typeId, encode: (value, ctx) => { @@ -52,7 +71,9 @@ export function defineTestCodec< return Promise.reject(error); } }, - encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, - decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + encodeJson, + decodeJson, + encodePsl, + decodePsl, } as Codec; } diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-runner.polymorphism.integration.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-runner.polymorphism.integration.test.ts index fcde3bf17655..6fc0d8085e8b 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-runner.polymorphism.integration.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-runner.polymorphism.integration.test.ts @@ -76,6 +76,8 @@ const mongoCodecLookup: CodecLookup = { decode: async (w: unknown) => w, encodeJson: (v: unknown) => v, decodeJson: (j: unknown) => j, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, } as ReturnType; }, targetTypesFor: (id: string) => mongoTargetTypes[id], diff --git a/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts b/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts index 11d2d27b4903..cafb2229754a 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts @@ -1,5 +1,18 @@ +import type { JsonValue } from '@internal/contract/types'; import type { CodecDescriptor, CodecTrait } from '@internal/framework-components/codec'; -import { renderTsLiteral, voidParamsSchema } from '@internal/framework-components/codec'; +import { + decodeBooleanPsl, + decodeJsonTextPsl, + decodeNumberPsl, + decodeStringPsl, + decodeWholeNumberPsl, + encodeBooleanPsl, + encodeJsonTextPsl, + encodeNumberPsl, + encodeStringPsl, + renderTsLiteral, + voidParamsSchema, +} from '@internal/framework-components/codec'; import { type MongoCodec, type MongoCodecRegistry, @@ -23,51 +36,76 @@ export const mongoObjectIdCodec = mongoCodec({ typeId: MONGO_OBJECTID_CODEC_ID, decode: (wire: ObjectId) => wire.toHexString(), encode: (value: string) => new ObjectId(value), + encodePsl: encodeStringPsl, + decodePsl: (literal) => decodeStringPsl(MONGO_OBJECTID_CODEC_ID, literal), }); export const mongoStringCodec = mongoCodec({ typeId: MONGO_STRING_CODEC_ID, decode: (wire: string) => wire, encode: (value: string) => value, + encodePsl: encodeStringPsl, + decodePsl: (literal) => decodeStringPsl(MONGO_STRING_CODEC_ID, literal), }); export const mongoDoubleCodec = mongoCodec({ typeId: MONGO_DOUBLE_CODEC_ID, decode: (wire: number) => wire, encode: (value: number) => value, + encodePsl: encodeNumberPsl, + decodePsl: (literal) => decodeNumberPsl(MONGO_DOUBLE_CODEC_ID, literal), }); export const mongoInt32Codec = mongoCodec({ typeId: MONGO_INT32_CODEC_ID, decode: (wire: number) => wire, encode: (value: number) => value, + encodePsl: encodeNumberPsl, + decodePsl: (literal) => Number(decodeWholeNumberPsl(MONGO_INT32_CODEC_ID, literal)), }); export const mongoBooleanCodec = mongoCodec({ typeId: MONGO_BOOLEAN_CODEC_ID, decode: (wire: boolean) => wire, encode: (value: boolean) => value, + encodePsl: encodeBooleanPsl, + decodePsl: (literal) => decodeBooleanPsl(MONGO_BOOLEAN_CODEC_ID, literal), }); +const mongoDateDecodeJson = (json: JsonValue): Date => { + if (typeof json !== 'string') { + throw mongoAdapterError('RUNTIME.DECODE_FAILED', 'expected ISO date string', { + meta: { codecId: MONGO_DATE_CODEC_ID, received: typeof json }, + }); + } + return new Date(json); +}; + export const mongoDateCodec = mongoCodec({ typeId: MONGO_DATE_CODEC_ID, decode: (wire: Date) => wire, encode: (value: Date) => value, encodeJson: (value: Date) => value.toISOString(), - decodeJson: (json) => { - if (typeof json !== 'string') { - throw mongoAdapterError('RUNTIME.DECODE_FAILED', 'expected ISO date string', { - meta: { codecId: MONGO_DATE_CODEC_ID, received: typeof json }, - }); - } - return new Date(json); - }, + decodeJson: mongoDateDecodeJson, + encodePsl: (value) => encodeStringPsl(value.toISOString()), + decodePsl: (literal) => mongoDateDecodeJson(decodeStringPsl(MONGO_DATE_CODEC_ID, literal)), }); +const mongoVectorFromJson = (json: JsonValue): readonly number[] => { + if (!Array.isArray(json) || !json.every((element) => typeof element === 'number')) { + throw mongoAdapterError('RUNTIME.DECODE_FAILED', 'expected an array of numbers', { + meta: { codecId: MONGO_VECTOR_CODEC_ID, received: typeof json }, + }); + } + return json; +}; + export const mongoVectorCodec = mongoCodec({ typeId: MONGO_VECTOR_CODEC_ID, decode: (wire: readonly number[]) => wire, encode: (value: readonly number[]) => value, + encodePsl: (value) => encodeJsonTextPsl([...value]), + decodePsl: (literal) => mongoVectorFromJson(decodeJsonTextPsl(MONGO_VECTOR_CODEC_ID, literal)), }); /** diff --git a/packages/3-mongo-target/2-mongo-adapter/test/lowering.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/lowering.test.ts index e96c504fa55f..ad8b86a879b1 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/lowering.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/lowering.test.ts @@ -154,6 +154,8 @@ describe('lowerFilter', () => { typeId: 'test/uppercase@1', decode: (wire: string) => wire, encode: (value: string) => value.toUpperCase(), + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); @@ -170,6 +172,8 @@ describe('lowerFilter', () => { typeId: 'test/uppercase@1', decode: (wire: string) => wire, encode: (value: string) => value.toUpperCase(), + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); @@ -195,6 +199,8 @@ describe('lowerFilter', () => { typeId: 'test/uppercase@1', decode: (wire: string) => wire, encode: (value: string) => value.toUpperCase(), + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); diff --git a/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter-ctx.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter-ctx.test.ts index 23508f2f8cf7..26dd41d83776 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter-ctx.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter-ctx.test.ts @@ -31,6 +31,8 @@ function recordingRegistry(observed: (CodecCallContext | undefined)[]) { observed.push(ctx); return v; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); return registry; @@ -201,6 +203,8 @@ describe('MongoAdapter — CodecCallContext threading', () => { callCount += 1; return v; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); return reg; diff --git a/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter.test.ts index 3c990199f42f..a232b052ce0d 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter.test.ts @@ -381,6 +381,8 @@ describe('MongoAdapter with codec registry', () => { typeId: 'test/uppercase@1', decode: (wire: string) => wire.toLowerCase(), encode: (value: string) => value.toUpperCase(), + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); function registryWithUppercase() { diff --git a/packages/3-mongo-target/2-mongo-adapter/test/psl-literals.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/psl-literals.test.ts new file mode 100644 index 000000000000..680833421cb1 --- /dev/null +++ b/packages/3-mongo-target/2-mongo-adapter/test/psl-literals.test.ts @@ -0,0 +1,42 @@ +import type { Codec, PslLiteral } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { mongoStandardCodecs } from '../src/core/codecs'; + +const samples: Record = { + 'mongo/objectId@1': ['507f1f77bcf86cd799439011'], + 'mongo/string@1': ['hello', 'a"b\\c', ''], + 'mongo/double@1': [1.5, -0.25, 1e21], + 'mongo/int32@1': [0, 42, -7], + 'mongo/bool@1': [true, false], + 'mongo/date@1': [new Date('2026-01-02T03:04:05.123Z')], + 'mongo/vector@1': [[0.1, 0.2], []], +}; + +const codecs = mongoStandardCodecs.map((codec): [string, Codec] => [codec.id, codec]); + +const cases = codecs.flatMap(([id, codec]) => + (samples[id] ?? []).map((value): [string, unknown, Codec] => [id, value, codec]), +); + +describe('every standard Mongo codec', () => { + it('has at least one sample in the table', () => { + expect(new Set(codecs.map(([id]) => id))).toEqual(new Set(Object.keys(samples))); + }); + + it.each(cases)('%s round-trips %s through its PSL literal', (_id, value, codec) => { + expect(codec.decodePsl(codec.encodePsl(value))).toEqual(value); + }); + + it.each(codecs)('%s rejects a wrong-kind literal with a message naming it', (id, codec) => { + const wrong: PslLiteral = + id === 'mongo/bool@1' ? { kind: 'number', text: '1' } : { kind: 'boolean', text: 'true' }; + expect(() => codec.decodePsl(wrong)).toThrow(id); + }); + + it('mongo/int32@1 rejects 1.5', () => { + const [, int32] = codecs.find(([id]) => id === 'mongo/int32@1')!; + expect(() => int32.decodePsl({ kind: 'number', text: '1.5' })).toThrow( + 'mongo/int32@1 reads a whole number literal; got a number 1.5', + ); + }); +}); diff --git a/packages/3-mongo-target/2-mongo-adapter/test/resolve-value-ctx.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/resolve-value-ctx.test.ts index 206572a1181a..25980fd03ded 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/resolve-value-ctx.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/resolve-value-ctx.test.ts @@ -30,6 +30,8 @@ describe('resolveValue — CodecCallContext threading', () => { observed.push(ctx); return v; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); @@ -50,6 +52,8 @@ describe('resolveValue — CodecCallContext threading', () => { observed.push(ctx); return v; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); @@ -86,6 +90,8 @@ describe('resolveValue — CodecCallContext threading', () => { receivedValue = v; return v; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); @@ -110,6 +116,8 @@ describe('resolveValue — CodecCallContext threading', () => { callCount += 1; return v; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); @@ -138,6 +146,8 @@ describe('resolveValue — CodecCallContext threading', () => { typeId: 'test/blocking@1', decode: (w: string) => w, encode: (v: string) => release.promise.then((suffix) => `${v}:${suffix}`), + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); @@ -170,6 +180,8 @@ describe('resolveValue — CodecCallContext threading', () => { encode: () => { throw cause; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); @@ -191,6 +203,8 @@ describe('resolveValue — CodecCallContext threading', () => { typeId: 'test/level-blocker@1', decode: (w: string) => w, encode: (v: string) => blockingLeaf.promise.then((s) => `${v}:${s}`), + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); @@ -223,6 +237,8 @@ describe('resolveValue — CodecCallContext threading', () => { typeId: 'test/array-blocker@1', decode: (w: string) => w, encode: (v: string) => blockingLeaf.promise.then((s) => `${v}:${s}`), + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }), ); diff --git a/packages/3-mongo-target/2-mongo-adapter/test/resolve-value.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/resolve-value.test.ts index ea4570a9a43c..861ee1edc154 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/resolve-value.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/resolve-value.test.ts @@ -14,6 +14,8 @@ const uppercaseCodec = mongoCodec({ typeId: 'test/uppercase@1', decode: (wire: string) => wire.toLowerCase(), encode: (value: string) => value.toUpperCase(), + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); function testRegistry() { @@ -108,6 +110,8 @@ describe('resolveValue', () => { callOrder.push('encode-a-start'); return dA.promise.then((suffix) => `${value}:${suffix}`); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const asyncBCodec = mongoCodec({ typeId: 'test/async-b@1', @@ -116,6 +120,8 @@ describe('resolveValue', () => { callOrder.push('encode-b-start'); return dB.promise.then((suffix) => `${value}:${suffix}`); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const registry = newMongoCodecRegistry(); @@ -154,6 +160,8 @@ describe('resolveValue', () => { if (value === 'one') return d1.promise; return d2.promise; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const registry = newMongoCodecRegistry(); @@ -192,6 +200,8 @@ describe('resolveValue', () => { encode: async (_v: string) => { throw new Error('kms-key-resolution-failed'); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const registry = newMongoCodecRegistry(); registry.register(failingCodec); @@ -216,6 +226,8 @@ describe('resolveValue', () => { encode: async (_v: string) => { throw new Error('boom'); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const registry = newMongoCodecRegistry(); registry.register(failingCodec); @@ -239,6 +251,8 @@ describe('resolveValue', () => { encode: async (_v: string) => { throw new Error('boom'); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const registry = newMongoCodecRegistry(); registry.register(failingCodec); @@ -260,6 +274,8 @@ describe('resolveValue', () => { err.code = 'RUNTIME.ENCODE_FAILED'; throw err; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const registry = newMongoCodecRegistry(); registry.register(innerCodec); @@ -281,6 +297,8 @@ describe('resolveValue', () => { encode: async (_v: string) => { throw envelope; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const registry = newMongoCodecRegistry(); registry.register(innerCodec); @@ -298,6 +316,8 @@ describe('resolveValue', () => { encode: async (_v: string) => { throw new Error('plain failure'); }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }); const registry = newMongoCodecRegistry(); registry.register(innerCodec); 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 2acc9fa153c6..f394ce4fb112 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 @@ -148,6 +148,24 @@ export const pgInt8Decode = (wire: string | number | bigint): bigint => export const pgUnboundedIntDecode = (wire: string | number | bigint): bigint => decimalIntegerDecode('pg/unboundedint@1', wire); +const NON_FINITE_TEXT = /^(?:NaN|-?Infinity)$/; + +/** Neither JSON nor a SQL number literal has a form for `NaN` or the infinities; PostgreSQL reads and writes them as the text `NaN`, `Infinity`, `-Infinity`, so the float codecs carry them as that text on the wire and in JSON. */ +export const pgFloatEncode = (value: number): string | number => + Number.isFinite(value) ? value : String(value); + +export const pgFloatEncodeJson = (value: number): JsonValue => pgFloatEncode(value); + +export const pgFloatDecodeJson = (codecId: string, json: JsonValue): number => { + if (typeof json === 'number') return json; + if (typeof json === 'string' && NON_FINITE_TEXT.test(json)) return Number(json); + throw postgresError( + 'RUNTIME.DECODE_FAILED', + `${codecId} database JSON value must be a number, or the string NaN, Infinity or -Infinity`, + { meta: { codecId, received: typeof json } }, + ); +}; + const MIN_SAFE_INTEGER_BIGINT = BigInt(Number.MIN_SAFE_INTEGER); const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); @@ -373,7 +391,7 @@ export const pgIntervalFromIso = (text: string): PgInterval => intervalFieldsOf( /** Renders the application value as its canonical ISO-8601 duration. */ export const pgIntervalToIso = (value: PgInterval): string => formatIsoDuration(value); -export const pgIntervalEncodeJson = (value: PgInterval): JsonValue => formatIsoDuration(value); +export const pgIntervalEncodeJson = (value: PgInterval): string => formatIsoDuration(value); export const pgIntervalDecodeJson = (json: JsonValue): PgInterval => { if (typeof json !== 'string') { @@ -411,7 +429,7 @@ export const pgIntervalDecode = (wire: string | Record): PgInte const BASE64_TEXT = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; -export const pgByteaEncodeJson = (value: Uint8Array): JsonValue => +export const pgByteaEncodeJson = (value: Uint8Array): string => Buffer.from(value).toString('base64'); export const pgByteaDecodeJson = (value: JsonValue): Uint8Array => { 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 b236ec1f1d2f..1766f8f93db1 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codecs.ts @@ -19,6 +19,18 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + decodeBooleanPsl, + decodeFloatPsl, + decodeJsonTextPsl, + decodeStringPsl, + decodeWholeNumberPsl, + encodeBooleanPsl, + encodeFloatPsl, + encodeJsonTextPsl, + encodeNumberPsl, + encodeStringPsl, + type PslLiteral, + pslLiteralReadsError, renderTsLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; @@ -55,6 +67,9 @@ import { pgByteaDecodeJson, pgByteaDecodeWire, pgByteaEncodeJson, + pgFloatDecodeJson, + pgFloatEncode, + pgFloatEncodeJson, pgInt8Decode, pgInt8NumberDecode, pgInt8NumberDecodeJson, @@ -176,6 +191,32 @@ const CANONICAL_NUMERIC_TEXT = /^(?:-?\d+(?:\.\d+)?|NaN|-?Infinity)$/; const isCanonicalNumericText = (value: string): boolean => CANONICAL_NUMERIC_TEXT.test(value); +const NON_FINITE_NUMERIC_TEXT = /^(?:NaN|-?Infinity)$/; +const DECIMAL_NUMERAL = /^(-?)0*(\d+)(\.\d+)?$/; +const NUMERIC_READS = + 'a number literal, or a string holding a decimal, "NaN", "Infinity", "-Infinity"'; + +/** Leading zeros and the sign of zero never change a decimal. Trailing zeros are kept, because a column without a scale keeps them. */ +const canonicalDecimalText = (text: string): string => { + const numeral = DECIMAL_NUMERAL.exec(text); + if (numeral === null) return text; + const [, sign = '', whole = '', fraction = ''] = numeral; + const digits = `${whole}${fraction}`; + return /^[0.]+$/.test(digits) ? digits : `${sign}${digits}`; +}; + +/** A finite decimal is written as a number literal; `NaN` and the infinities as the quoted string, which PSL has no number token for. */ +const pgNumericEncodePsl = (text: string): PslLiteral => + NON_FINITE_NUMERIC_TEXT.test(text) ? { kind: 'string', text } : { kind: 'number', text }; + +/** A number literal, as written; or a string holding the same text, which is how `contract infer` printed a decimal before the printer moved onto the codec and how schemas written then still read. */ +const pgNumericDecodePsl = (codecId: string, literal: PslLiteral): string => { + if (literal.kind === 'boolean') throw pslLiteralReadsError(codecId, NUMERIC_READS, literal); + const text = canonicalDecimalText(literal.text); + if (!isCanonicalNumericText(text)) throw pslLiteralReadsError(codecId, NUMERIC_READS, literal); + return text; +}; + const identityJsonProjection = (expression: ProjectionExpr): ProjectionExpr => expression; const decodePostgresNumberWire = (wire: string | number): number => @@ -338,6 +379,12 @@ export class PgTextCodec extends CodecImpl< json, ); } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class PgTextDescriptor extends PostgresCodecDescriptor { @@ -404,6 +451,12 @@ export class PgEnumCodec extends CodecImpl< 'text codec: a native-enum member value is stored as its wire string form' >(json); } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export type PgEnumParams = { readonly typeName: string }; @@ -538,6 +591,12 @@ export class PgTextArrayCodec extends CodecImpl< decodeJson(json: JsonValue): readonly string[] { return Array.isArray(json) ? json.map((entry) => String(entry)) : []; } + encodePsl(value: readonly string[]): PslLiteral { + return encodeJsonTextPsl(this.encodeJson(value)); + } + decodePsl(literal: PslLiteral): readonly string[] { + return this.decodeJson(decodeJsonTextPsl(this.id, literal)); + } } export class PgTextArrayDescriptor extends PostgresCodecDescriptor { @@ -578,6 +637,12 @@ export class PgInt4Codec extends CodecImpl< json, ); } + encodePsl(value: number): PslLiteral { + return encodeNumberPsl(value); + } + decodePsl(literal: PslLiteral): number { + return Number(decodeWholeNumberPsl(this.id, literal)); + } } export class PgInt4Descriptor extends PostgresCodecDescriptor { @@ -627,6 +692,12 @@ export class PgInt2Codec extends CodecImpl< json, ); } + encodePsl(value: number): PslLiteral { + return encodeNumberPsl(value); + } + decodePsl(literal: PslLiteral): number { + return Number(decodeWholeNumberPsl(this.id, literal)); + } } export class PgInt2Descriptor extends PostgresCodecDescriptor { @@ -687,6 +758,12 @@ export class PgInt8Codec extends CodecImpl< } return pgInt8Decode(json); } + encodePsl(value: bigint): PslLiteral { + return { kind: 'number', text: pgBigintEncodeJson(this.id, value) }; + } + decodePsl(literal: PslLiteral): bigint { + return pgInt8Decode(decodeWholeNumberPsl(this.id, literal)); + } } export class PgInt8Descriptor extends PostgresCodecDescriptor { @@ -743,6 +820,12 @@ export class PgInt8NumberCodec extends CodecImpl< decodeJson(json: JsonValue): number { return pgInt8NumberDecodeJson(json); } + encodePsl(value: number): PslLiteral { + return encodeNumberPsl(pgInt8NumberEncodeJson(value)); + } + decodePsl(literal: PslLiteral): number { + return pgInt8NumberDecode(decodeWholeNumberPsl(this.id, literal)); + } } export class PgInt8NumberDescriptor extends PostgresCodecDescriptor { @@ -778,19 +861,23 @@ export class PgFloat4Codec extends CodecImpl< string | number, number > { - async encode(value: number, _ctx: CodecCallContext): Promise { - return value; + async encode(value: number, _ctx: CodecCallContext): Promise { + return pgFloatEncode(value); } async decode(wire: string | number, _ctx: CodecCallContext): Promise { return decodePostgresNumberWire(wire); } encodeJson(value: number): JsonValue { - return value; + return pgFloatEncodeJson(value); } decodeJson(json: JsonValue): number { - return blindCast( - json, - ); + return pgFloatDecodeJson(this.id, json); + } + encodePsl(value: number): PslLiteral { + return encodeFloatPsl(value); + } + decodePsl(literal: PslLiteral): number { + return decodeFloatPsl(this.id, literal); } } @@ -827,19 +914,23 @@ export class PgFloat8Codec extends CodecImpl< string | number, number > { - async encode(value: number, _ctx: CodecCallContext): Promise { - return value; + async encode(value: number, _ctx: CodecCallContext): Promise { + return pgFloatEncode(value); } async decode(wire: string | number, _ctx: CodecCallContext): Promise { return decodePostgresNumberWire(wire); } encodeJson(value: number): JsonValue { - return value; + return pgFloatEncodeJson(value); } decodeJson(json: JsonValue): number { - return blindCast( - json, - ); + return pgFloatDecodeJson(this.id, json); + } + encodePsl(value: number): PslLiteral { + return encodeFloatPsl(value); + } + decodePsl(literal: PslLiteral): number { + return decodeFloatPsl(this.id, literal); } } @@ -888,6 +979,12 @@ export class PgBoolCodec extends CodecImpl< decodeJson(json: JsonValue): boolean { return blindCast(json); } + encodePsl(value: boolean): PslLiteral { + return encodeBooleanPsl(value); + } + decodePsl(literal: PslLiteral): boolean { + return decodeBooleanPsl(this.id, literal); + } } export class PgBoolDescriptor extends PostgresCodecDescriptor { @@ -929,7 +1026,7 @@ export class PgNumericCodec extends CodecImpl< async decode(wire: string | number, _ctx: CodecCallContext): Promise { return pgNumericDecode(wire); } - encodeJson(value: string): JsonValue { + encodeJson(value: string): string { if (!isCanonicalNumericText(value)) { throw postgresError( 'RUNTIME.ENCODE_FAILED', @@ -949,6 +1046,12 @@ export class PgNumericCodec extends CodecImpl< } return json; } + encodePsl(value: string): PslLiteral { + return pgNumericEncodePsl(this.encodeJson(value)); + } + decodePsl(literal: PslLiteral): string { + return pgNumericDecodePsl(this.id, literal); + } } export class PgNumericDescriptor extends PostgresCodecDescriptor { @@ -1009,6 +1112,12 @@ export class PgUnboundedIntCodec extends CodecImpl< } return pgUnboundedIntDecode(json); } + encodePsl(value: bigint): PslLiteral { + return { kind: 'number', text: pgBigintEncodeJson(this.id, value) }; + } + decodePsl(literal: PslLiteral): bigint { + return pgUnboundedIntDecode(decodeWholeNumberPsl(this.id, literal)); + } } export class PgUnboundedIntDescriptor extends PostgresCodecDescriptor { @@ -1063,6 +1172,12 @@ export class PgTimetzCodec extends CodecImpl< json, ); } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class PgTimetzDescriptor extends PostgresCodecDescriptor { @@ -1113,6 +1228,12 @@ export class PgBitCodec extends CodecImpl< json, ); } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class PgBitDescriptor extends PostgresCodecDescriptor { @@ -1162,6 +1283,12 @@ export class PgVarbitCodec extends CodecImpl< json, ); } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class PgVarbitDescriptor extends PostgresCodecDescriptor { @@ -1209,6 +1336,12 @@ export class PgByteaCodec extends CodecImpl< decodeJson(json: JsonValue): Uint8Array { return pgByteaDecodeJson(json); } + encodePsl(value: Uint8Array): PslLiteral { + return encodeStringPsl(pgByteaEncodeJson(value)); + } + decodePsl(literal: PslLiteral): Uint8Array { + return pgByteaDecodeJson(decodeStringPsl(this.id, literal)); + } } export class PgByteaDescriptor extends PostgresCodecDescriptor { @@ -1255,6 +1388,12 @@ export class PgUuidCodec extends CodecImpl< decodeJson(json: JsonValue): string { return blindCast(json); } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class PgUuidDescriptor extends PostgresCodecDescriptor { @@ -1301,6 +1440,12 @@ export class PgInetCodec extends CodecImpl< decodeJson(json: JsonValue): string { return blindCast(json); } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class PgInetDescriptor extends PostgresCodecDescriptor { @@ -1367,6 +1512,12 @@ export class PgIntervalCodec extends CodecImpl< decodeJson(json: JsonValue): PgInterval { return pgIntervalDecodeJson(json); } + encodePsl(value: PgInterval): PslLiteral { + return encodeStringPsl(pgIntervalEncodeJson(value)); + } + decodePsl(literal: PslLiteral): PgInterval { + return pgIntervalDecodeJson(decodeStringPsl(this.id, literal)); + } } export class PgIntervalDescriptor extends PostgresCodecDescriptor { @@ -1415,6 +1566,12 @@ export class PgJsonCodec extends CodecImpl< decodeJson(json: JsonValue): JsonValue { return json; } + encodePsl(value: JsonValue): PslLiteral { + return encodeJsonTextPsl(value); + } + decodePsl(literal: PslLiteral): JsonValue { + return decodeJsonTextPsl(this.id, literal); + } } export class PgJsonDescriptor extends PostgresCodecDescriptor { @@ -1459,6 +1616,12 @@ export class PgJsonbCodec extends CodecImpl< decodeJson(json: JsonValue): JsonValue { return json; } + encodePsl(value: JsonValue): PslLiteral { + return encodeJsonTextPsl(value); + } + decodePsl(literal: PslLiteral): JsonValue { + return decodeJsonTextPsl(this.id, literal); + } } export class PgJsonbDescriptor extends PostgresCodecDescriptor { diff --git a/packages/3-targets/3-targets/postgres/src/core/date-codecs.ts b/packages/3-targets/3-targets/postgres/src/core/date-codecs.ts index a981fa4a851c..67a2efe34e4d 100644 --- a/packages/3-targets/3-targets/postgres/src/core/date-codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/date-codecs.ts @@ -6,6 +6,9 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + decodeStringPsl, + encodeStringPsl, + type PslLiteral, } from '@internal/framework-components/codec'; import { CastExpr, type ProjectionExpr } from '@internal/sql-relational-core/ast'; import type { StandardSchemaV1 } from '@standard-schema/spec'; @@ -114,6 +117,12 @@ export class PgTimestamptzDateCodec extends CodecImpl< decodeJson(json: JsonValue): Date { return decodeDate(json); } + encodePsl(value: Date): PslLiteral { + return encodeStringPsl(encodeDate(value)); + } + decodePsl(literal: PslLiteral): Date { + return decodeDate(decodeStringPsl(this.id, literal)); + } } export class PgTimestamptzDateDescriptor extends PostgresCodecDescriptor { diff --git a/packages/3-targets/3-targets/postgres/src/core/prisma7-binding.ts b/packages/3-targets/3-targets/postgres/src/core/prisma7-binding.ts index c5db572701e2..3b04f4247540 100644 --- a/packages/3-targets/3-targets/postgres/src/core/prisma7-binding.ts +++ b/packages/3-targets/3-targets/postgres/src/core/prisma7-binding.ts @@ -63,7 +63,6 @@ export const prisma7PostgresBinding = { readonly nativeType: string; readonly typeParams?: Readonly> | undefined; }) => { - if (nativeType === 'json' || nativeType === 'jsonb') return { kind: 'json' } as const; if (nativeType === 'bytea') { return { kind: 'sqlExpression', diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-default-codec.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-default-codec.ts new file mode 100644 index 000000000000..08cbb4d0d906 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-default-codec.ts @@ -0,0 +1,42 @@ +import { type Codec, materializeCodec } from '@internal/framework-components/codec'; +import { PG_TEXT_CODEC_ID } from '../codec-ids'; +import { postgresCodecDescriptorRegistry } from '../registry'; + +/** The codec `contract emit` binds to each PSL type name the type map prints, so a default prints in the form emit reads. An enum column's members are strings either way, so it reads through the text codec. */ +const CODEC_ID_BY_PSL_TYPE: ReadonlyMap = new Map([ + ['String', 'pg/text@1'], + ['Boolean', 'pg/bool@1'], + ['Int', 'pg/int4@1'], + ['SmallInt', 'pg/int2@1'], + ['BigInt', 'pg/int8@1'], + ['Float', 'pg/float8@1'], + ['Real', 'pg/float4@1'], + ['Numeric', 'pg/numeric@1'], + ['Timestamp', 'pg/timestamp-temporal@1'], + ['Timestamptz', 'pg/timestamptz-temporal@1'], + ['Date', 'pg/date-temporal@1'], + ['Time', 'pg/time-temporal@1'], + ['Timetz', 'pg/timetz@1'], + ['Json', 'pg/json@1'], + ['Jsonb', 'pg/jsonb@1'], + ['Bytes', 'pg/bytea@1'], + ['Uuid', 'pg/uuid@1'], + ['Inet', 'pg/inet@1'], + ['VarChar', 'sql/varchar@1'], + ['Char', 'sql/char@1'], +]); + +const codecs = new Map(); + +/** The codec a column's literal default prints through, or `undefined` for a PSL type no codec is bound to. Every Postgres codec is parameter-stateless, so one instance per codec id serves every column. */ +export function defaultCodecFor(pslTypeName: string, isEnum: boolean): Codec | undefined { + const codecId = isEnum ? PG_TEXT_CODEC_ID : CODEC_ID_BY_PSL_TYPE.get(pslTypeName); + if (codecId === undefined) return undefined; + const cached = codecs.get(codecId); + if (cached !== undefined) return cached; + const descriptor = postgresCodecDescriptorRegistry.descriptorFor(codecId); + if (descriptor === undefined) return undefined; + const codec = materializeCodec(descriptor, { codecId }, { name: `` }); + codecs.set(codecId, codec); + return codec; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts index b439ebffba22..44b85da754fd 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts @@ -1,4 +1,4 @@ -import { toEnumMemberName, toEnumName } from '@internal/family-sql/psl-infer'; +import { escapePslString, toEnumMemberName, toEnumName } from '@internal/family-sql/psl-infer'; import type { PslExtensionBlock, PslExtensionBlockParamValue, @@ -8,7 +8,7 @@ import { createUniqueFieldName, type TopLevelNameResult, } from './infer-names'; -import { escapePslString, SYNTHETIC_SPAN } from './psl-literals'; +import { SYNTHETIC_SPAN } from './psl-literals'; export const PSL_SCALAR_TYPE_NAMES = new Set([ 'String', diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts index 108f809ee6d5..e72629f14153 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts @@ -1,3 +1,4 @@ +import { escapePslString } from '@internal/family-sql/psl-infer'; import type { PslAttributeArgument, PslModelAttribute, @@ -5,7 +6,7 @@ import type { import { computeIndexContentHash, parseWireName } from '@internal/sql-schema-ir/naming'; import type { SqlCheckConstraintIR, SqlIndexIR } from '@internal/sql-schema-ir/types'; import { assertDefined } from '@internal/utils/assertions'; -import { buildAttribute, escapePslString, namedArg, positionalArg } from './psl-literals'; +import { buildAttribute, namedArg, positionalArg } from './psl-literals'; export function buildModelConstraintAttribute( name: 'id' | 'unique', diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts index f28c379c6d8a..72b9b34a2522 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts @@ -5,7 +5,13 @@ import type { PslTypeMap, RelationField, } from '@internal/family-sql/psl-infer'; -import { mapDefault, toFieldName, toModelName } from '@internal/family-sql/psl-infer'; +import { + escapePslString, + mapDefault, + toFieldName, + toModelName, +} from '@internal/family-sql/psl-infer'; +import type { Codec } from '@internal/framework-components/codec'; import type { PslAttributeArgument, PslField, @@ -22,6 +28,7 @@ import { import type { SqlColumnIR, SqlTableIR } from '@internal/sql-schema-ir/types'; import { ifDefined } from '@internal/utils/defined'; import { postgresRenderCheckExpressions } from '../check-expressions'; +import { defaultCodecFor } from './infer-default-codec'; import { buildDanglingForeignKeyWarning, type DanglingForeignKeyInfo } from './infer-foreign-keys'; import { buildCheckAttribute, @@ -37,15 +44,10 @@ import { buildAttribute, buildMapAttribute, buildSimpleConstraintFieldAttribute, - escapePslString, - formatPslListLiteralValue, - formatPslValue, namedArg, - type PslDefaultValueFormat, parseColumnDefault, parseDefaultAttributeString, positionalArg, - pslDefaultValueFormat, SYNTHETIC_SPAN, } from './psl-literals'; @@ -283,7 +285,7 @@ function buildScalarField( const defaultAttribute = inferDefaultAttribute( column, - enumPslName === undefined ? pslDefaultValueFormat(resolution.pslType.name) : formatPslValue, + defaultCodecFor(resolution.pslType.name, enumPslName !== undefined), defaultMapping, rawDefaultParser, ); @@ -338,13 +340,14 @@ function buildScalarField( } /** - * A literal default prints as the PSL literal its codec accepts. A literal that has no such PSL - * literal prints as `dbgenerated(...)` with the expression Postgres reported: `contract emit` - * accepts that on a scalar column and rejects it at the field on a list column. + * A literal default prints through the column codec: the codec reads the JSON form the introspection + * parser produced and writes its PSL literal. A value the codec does not read prints as + * `dbgenerated(...)` with the expression Postgres reported: `contract emit` accepts that on a + * scalar column and rejects it at the field on a list column. */ function inferDefaultAttribute( column: SqlColumnIR, - valueFormat: PslDefaultValueFormat, + codec: Codec | undefined, defaultMapping: DefaultMappingOptions | undefined, rawDefaultParser: PslPrinterOptions['parseRawDefault'], ): string | undefined { @@ -364,9 +367,8 @@ function inferDefaultAttribute( // A list column's literal default prints from `resolvedDefault`: the raw // SQL text read against the element type only yields a function, which // the interpreter rejects on a list column. - const { value } = column.resolvedDefault; - return Array.isArray(value) - ? literalOrRawAttribute(formatPslListLiteralValue(value, valueFormat), column, defaultMapping) + return Array.isArray(column.resolvedDefault.value) + ? literalOrRawAttribute(column.resolvedDefault, codec, column, defaultMapping) : undefined; } const parsed = parseColumnDefault(column.default, column.nativeType, rawDefaultParser); @@ -374,27 +376,42 @@ function inferDefaultAttribute( return undefined; } if (parsed.kind === 'literal') { - return literalOrRawAttribute(valueFormat(parsed.value), column, defaultMapping); + return literalOrRawAttribute(parsed, codec, column, defaultMapping); } return mappedAttribute(parsed, defaultMapping); } function literalOrRawAttribute( - literal: string | undefined, + columnDefault: ColumnDefault, + codec: Codec | undefined, column: SqlColumnIR, defaultMapping: DefaultMappingOptions | undefined, ): string | undefined { - if (literal !== undefined) { - return `@default(${literal})`; - } + const literal = + codec === undefined ? undefined : codecAttribute(columnDefault, codec, defaultMapping); + if (literal !== undefined) return literal; return typeof column.default === 'string' ? mappedAttribute({ kind: 'function', expression: column.default }, defaultMapping) : undefined; } +/** `undefined` when the codec does not read the value, so the raw expression prints instead. */ +function codecAttribute( + columnDefault: ColumnDefault, + codec: Codec, + defaultMapping: DefaultMappingOptions | undefined, +): string | undefined { + try { + const result = mapDefault(columnDefault, { ...defaultMapping, codec }); + return 'attribute' in result ? result.attribute : undefined; + } catch { + return undefined; + } +} + /** A default the mapping can only describe in a comment is dropped: a field AST node has no comment. */ function mappedAttribute( - columnDefault: ColumnDefault, + columnDefault: Extract, defaultMapping: DefaultMappingOptions | undefined, ): string | undefined { const result = mapDefault(columnDefault, defaultMapping); diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts index 96aac42320ce..01fd4b75c84e 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts @@ -1,8 +1,9 @@ +import { escapePslString } from '@internal/family-sql/psl-infer'; import type { PslExtensionBlock } from '@internal/framework-components/psl-ast'; import { parseWireName } from '@internal/sql-schema-ir/naming'; import { assertDefined } from '@internal/utils/assertions'; import type { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; -import { escapePslString, SYNTHETIC_SPAN } from './psl-literals'; +import { SYNTHETIC_SPAN } from './psl-literals'; const POLICY_OPERATION_KEYWORD = { select: 'policy_select', diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/psl-literals.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/psl-literals.ts index faf60a4d3f59..4a0a9b18f77a 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/psl-literals.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/psl-literals.ts @@ -1,5 +1,5 @@ import { type ColumnDefault, isColumnDefault } from '@internal/contract/types'; -import type { PslPrinterOptions } from '@internal/family-sql/psl-infer'; +import { escapePslString, type PslPrinterOptions } from '@internal/family-sql/psl-infer'; import type { PslAttribute, PslAttributeArgument, @@ -58,112 +58,6 @@ export function namedArg(name: string, value: string): PslAttributeArgument { return { kind: 'named', name, value, span: SYNTHETIC_SPAN }; } -export function escapePslString(value: string): string { - return value - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r'); -} - -/** - * Prints one default value as the PSL literal its field's codec accepts at `contract emit`, or - * returns `undefined` when that codec accepts no PSL literal for the value. - */ -export type PslDefaultValueFormat = (value: unknown) => string | undefined; - -const INTEGER_TEXT = /^-?\d+$/; -const SPECIAL_VALUE_TEXT = /^(?:NaN|-?Infinity)$/; -const DECIMAL_TEXT = /^(?:-?\d+(?:\.\d+)?|NaN|-?Infinity)$/; - -/** PSL has no exponent syntax, so the decimal point moves to where the exponent puts it. */ -function plainNumeral(value: number): string { - const [coefficient = '', exponent] = String(value).split('e'); - if (exponent === undefined) return coefficient; - const sign = coefficient.startsWith('-') ? '-' : ''; - const [whole = '', fraction = ''] = coefficient.slice(sign.length).split('.'); - const digits = `${whole}${fraction}`; - const point = whole.length + Number(exponent); - if (point <= 0) return `${sign}0.${'0'.repeat(-point)}${digits}`; - if (point >= digits.length) return `${sign}${digits}${'0'.repeat(point - digits.length)}`; - return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`; -} - -export const formatPslValue: PslDefaultValueFormat = (value) => { - if (typeof value === 'string') return `"${escapePslString(value)}"`; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - return undefined; -}; - -const formatNumber: PslDefaultValueFormat = (value) => - typeof value === 'number' && Number.isFinite(value) ? plainNumeral(value) : undefined; - -/** PSL has no number for `NaN` or `Infinity`; the float codecs pass their quoted text through. */ -const formatFloat: PslDefaultValueFormat = (value) => - typeof value === 'string' && SPECIAL_VALUE_TEXT.test(value) ? `"${value}"` : formatNumber(value); - -/** - * `pg/int8@1` reads a PSL number from the text written, so every digit of an `int8` survives. A - * PSL string is not a `bigint`. A JavaScript number past the safe integer range is already rounded. - */ -const formatInteger: PslDefaultValueFormat = (value) => { - if (typeof value === 'string') return INTEGER_TEXT.test(value) ? value : undefined; - return typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : undefined; -}; - -/** - * `pg/numeric@1` stores decimal text, `NaN` or `Infinity`, and reads a PSL string as that text. A - * PSL number would also keep every digit, but has no spelling for `NaN` or `Infinity`. - */ -const formatDecimalText: PslDefaultValueFormat = (value) => { - const text = typeof value === 'number' && Number.isFinite(value) ? plainNumeral(value) : value; - return typeof text === 'string' && DECIMAL_TEXT.test(text) ? `"${text}"` : undefined; -}; - -/** - * The codecs of `Date`, `Time`, `Timestamp` and `Timestamptz` encode Temporal values, which no PSL - * literal is. A JSON codec reads a PSL string as a JSON string, not as JSON text, so a JSON default - * keeps its raw expression. - */ -const noLiteral: PslDefaultValueFormat = () => undefined; - -const DEFAULT_VALUE_FORMATS: ReadonlyMap = new Map([ - ['Int', formatNumber], - ['SmallInt', formatNumber], - ['Float', formatFloat], - ['Real', formatFloat], - ['BigInt', formatInteger], - ['Numeric', formatDecimalText], - ['Date', noLiteral], - ['Time', noLiteral], - ['Timestamp', noLiteral], - ['Timestamptz', noLiteral], - ['Json', noLiteral], - ['Jsonb', noLiteral], -]); - -/** The default value format for a field of a PSL type the Postgres type map resolves. */ -export function pslDefaultValueFormat(typeName: string): PslDefaultValueFormat { - return DEFAULT_VALUE_FORMATS.get(typeName) ?? formatPslValue; -} - -/** - * Formats a resolved list default as PSL literal-list syntax (`[1, 2]`, `["a"]`, `[]`), or returns - * `undefined` when any element has no literal, such as `null` or a value `format` refuses. - */ -export function formatPslListLiteralValue( - elements: readonly unknown[], - format: PslDefaultValueFormat, -): string | undefined { - const parts: string[] = []; - for (const element of elements) { - const part = format(element); - if (part === undefined) return undefined; - parts.push(part); - } - return `[${parts.join(', ')}]`; -} - /** * Resolves a `SqlColumnIR.default` value into a normalized {@link ColumnDefault}. * diff --git a/packages/3-targets/3-targets/postgres/src/core/temporal-codecs.ts b/packages/3-targets/3-targets/postgres/src/core/temporal-codecs.ts index 42b8bf307102..fe182517e1a2 100644 --- a/packages/3-targets/3-targets/postgres/src/core/temporal-codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/temporal-codecs.ts @@ -6,6 +6,9 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + decodeStringPsl, + encodeStringPsl, + type PslLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; import { CastExpr, type ProjectionExpr } from '@internal/sql-relational-core/ast'; @@ -54,6 +57,12 @@ export class PgDateTemporalCodec extends CodecImpl< blindCast(json), ); } + encodePsl(value: Temporal.PlainDate): PslLiteral { + return encodeStringPsl(pgDateTemporalEncode(value)); + } + decodePsl(literal: PslLiteral): Temporal.PlainDate { + return pgDateTemporalDecode(decodeStringPsl(this.id, literal)); + } } export class PgDateTemporalDescriptor extends PostgresCodecDescriptor { @@ -102,6 +111,12 @@ export class PgTimestampTemporalCodec extends CodecImpl< ), ); } + encodePsl(value: Temporal.PlainDateTime): PslLiteral { + return encodeStringPsl(pgTimestampTemporalEncode(value)); + } + decodePsl(literal: PslLiteral): Temporal.PlainDateTime { + return pgTimestampTemporalDecode(decodeStringPsl(this.id, literal)); + } } export class PgTimestampTemporalDescriptor extends PostgresCodecDescriptor { @@ -158,6 +173,12 @@ export class PgTimestamptzTemporalCodec extends CodecImpl< ), ); } + encodePsl(value: Temporal.Instant): PslLiteral { + return encodeStringPsl(pgTimestamptzTemporalEncode(value)); + } + decodePsl(literal: PslLiteral): Temporal.Instant { + return pgTimestamptzTemporalDecode(decodeStringPsl(this.id, literal)); + } } export class PgTimestamptzTemporalDescriptor extends PostgresCodecDescriptor { @@ -212,6 +233,12 @@ export class PgTimeTemporalCodec extends CodecImpl< blindCast(json), ); } + encodePsl(value: Temporal.PlainTime): PslLiteral { + return encodeStringPsl(pgTimeTemporalEncode(value)); + } + decodePsl(literal: PslLiteral): Temporal.PlainTime { + return pgTimeTemporalDecode(decodeStringPsl(this.id, literal)); + } } export class PgTimeTemporalDescriptor extends PostgresCodecDescriptor { diff --git a/packages/3-targets/3-targets/postgres/src/core/temporal-string-codecs.ts b/packages/3-targets/3-targets/postgres/src/core/temporal-string-codecs.ts index 243d6ff5e68e..79206cb72c87 100644 --- a/packages/3-targets/3-targets/postgres/src/core/temporal-string-codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/temporal-string-codecs.ts @@ -6,6 +6,9 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + decodeStringPsl, + encodeStringPsl, + type PslLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; import { CastExpr, type ProjectionExpr } from '@internal/sql-relational-core/ast'; @@ -46,6 +49,12 @@ export class PgDateStringCodec extends CodecImpl< json, ); } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class PgDateStringDescriptor extends PostgresCodecDescriptor { @@ -93,6 +102,12 @@ export class PgTimestampStringCodec extends CodecImpl< 'timestamp-string columns serialize to JSON as their wire string form' >(json); } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class PgTimestampStringDescriptor extends PostgresCodecDescriptor { @@ -151,6 +166,12 @@ export class PgTimestamptzStringCodec extends CodecImpl< 'timestamptz-string columns serialize to JSON as their wire string form' >(json); } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class PgTimestamptzStringDescriptor extends PostgresCodecDescriptor { @@ -208,6 +229,12 @@ export class PgTimeStringCodec extends CodecImpl< json, ); } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class PgTimeStringDescriptor extends PostgresCodecDescriptor { 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 6648393db0d0..abbc3fa65cd3 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 @@ -115,6 +115,21 @@ describe('codecs-class', () => { expect(await codec.encode(Math.E, callCtx)).toBe(Math.E); expect(await codec.decode(Math.E, callCtx)).toBe(Math.E); }); + it.each([ + [Number.NaN, 'NaN'], + [Number.POSITIVE_INFINITY, 'Infinity'], + [Number.NEGATIVE_INFINITY, '-Infinity'], + ])('writes %s as the text %s that Postgres reads back', async (value, text) => { + expect(await codec.encode(value, callCtx)).toBe(text); + expect(await codec.decode(text, callCtx)).toBe(value); + }); + }); + + describe('pg/float4@1 non-finite values', () => { + const codec = pgFloat4Descriptor.factory()(instanceCtx); + it('writes NaN as the text NaN', async () => { + expect(await codec.encode(Number.NaN, callCtx)).toBe('NaN'); + }); }); describe('pg/bool@1', () => { diff --git a/packages/3-targets/3-targets/postgres/test/postgres-codec-descriptor.test-d.ts b/packages/3-targets/3-targets/postgres/test/postgres-codec-descriptor.test-d.ts index 3e06c5a15365..2cdf31c28a42 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-codec-descriptor.test-d.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-codec-descriptor.test-d.ts @@ -6,6 +6,9 @@ import { CodecImpl, type CodecInstanceContext, type CodecTrait, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, } from '@internal/framework-components/codec'; import { FunctionCallExpr, type ProjectionExpr } from '@internal/sql-relational-core/ast'; import type { StandardSchemaV1 } from '@standard-schema/spec'; @@ -56,6 +59,12 @@ class VectorCodec extends CodecImpl< decodeJson(json: JsonValue): ReadonlyArray { return json as unknown as ReadonlyArray; } + encodePsl(value: ReadonlyArray): PslLiteral { + return encodeJsonTextPsl(this.encodeJson(value)); + } + decodePsl(literal: PslLiteral): ReadonlyArray { + return this.decodeJson(decodeJsonTextPsl(this.id, literal)); + } } class GenericVectorDescriptor extends CodecDescriptorImpl { diff --git a/packages/3-targets/3-targets/postgres/test/postgres-codec-descriptor.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-codec-descriptor.test.ts index 6385d73c407b..74f0adfabdd9 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-codec-descriptor.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-codec-descriptor.test.ts @@ -6,6 +6,9 @@ import { CodecImpl, type CodecInstanceContext, type CodecRef, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, } from '@internal/framework-components/codec'; import { CaseExpr, @@ -84,6 +87,12 @@ class VectorCodec extends CodecImpl< } return json.map(Number); } + encodePsl(value: ReadonlyArray): PslLiteral { + return encodeJsonTextPsl(this.encodeJson(value)); + } + decodePsl(literal: PslLiteral): ReadonlyArray { + return this.decodeJson(decodeJsonTextPsl(this.id, literal)); + } } class GenericVectorDescriptor extends CodecDescriptorImpl { diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-adoption.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-adoption.test.ts index 20e220d1d639..c1b261b92335 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-adoption.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-adoption.test.ts @@ -8,7 +8,12 @@ */ import { type Contract, coreHash, profileHash } from '@internal/contract/types'; import type { SqlDescribedContractSpace } from '@internal/family-sql/control'; -import type { Codec, CodecLookup } from '@internal/framework-components/codec'; +import { + type Codec, + type CodecLookup, + decodeStringPsl, + encodeStringPsl, +} from '@internal/framework-components/codec'; import { assembleAuthoringContributions } from '@internal/framework-components/control'; import { UNSPECIFIED_PSL_NAMESPACE_ID } from '@internal/framework-components/psl-ast'; import { buildSymbolTable } from '@internal/psl-parser'; @@ -186,6 +191,8 @@ const pgEnumCodec = { decode: () => Promise.reject(new Error('unused')), encodeJson: (value) => value, decodeJson: (json) => json, + encodePsl: (value) => encodeStringPsl(value as string), + decodePsl: (literal) => decodeStringPsl(PG_ENUM_CODEC_ID, literal), } as Codec; const codecLookup: CodecLookup = { diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts index 3401e8b4c665..564a507c1581 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts @@ -392,7 +392,7 @@ describe('printPsl', () => { `); }); - it('preserves raw Postgres defaults via dbgenerated attributes', () => { + it('preserves raw Postgres function defaults via dbgenerated attributes, and a jsonb literal through its codec', () => { const schemaIR = new SqlSchemaIR({ tables: { data: { @@ -433,7 +433,7 @@ describe('printPsl', () => { model Data { id Int @id computed String @default(dbgenerated("my_custom_func()")) - payload Jsonb @default(dbgenerated("'{}'::jsonb")) + payload Jsonb @default("{}") touchedAt Timestamptz @default(dbgenerated("clock_timestamp()")) @map("touched_at") @@map("data") diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.literal-defaults.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.literal-defaults.test.ts index 3eede5c1f679..b7be2ed00a1e 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.literal-defaults.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.literal-defaults.test.ts @@ -79,11 +79,11 @@ describe('printPsl literal defaults', () => { negFloat Float @default(-1.5) tinyFloat Float @default(0.0000001) negReal Real @default(-2.5) - negDecimal Numeric(65, 30) @default("-0.5") - longDecimal Numeric(65, 30) @default("12345678901234567890.123456789") - tinyDecimal Numeric(65, 30) @default("0.000000000000000001") - scaleDecimal Numeric(65, 30) @default("1.50") - scaledDecimal Numeric(10, 2) @default("-1.25") + negDecimal Numeric(65, 30) @default(-0.5) + longDecimal Numeric(65, 30) @default(12345678901234567890.123456789) + tinyDecimal Numeric(65, 30) @default(0.000000000000000001) + scaleDecimal Numeric(65, 30) @default(1.50) + scaledDecimal Numeric(10, 2) @default(-1.25) safeBigInt BigInt @default(5) negSafeBigInt BigInt @default(-5) negBigInt BigInt @default(-9007199254740993) @@ -95,11 +95,12 @@ describe('printPsl literal defaults', () => { `); }); - it('prints a default that has no PSL literal as dbgenerated with the expression Postgres printed', () => { + it('prints a default the codec reads as its literal, and one it does not as dbgenerated with the expression Postgres printed', () => { const output = printTable('raw_defaults', [ introspected('stamp', 'timestamp(3)', "'2024-01-01 00:00:00'::timestamp without time zone"), introspected('day', 'date', "'2024-01-01'::date"), introspected('jsonNull', 'jsonb', "'null'::jsonb", { nullable: true }), + introspected('jsonObject', 'jsonb', '\'{"a": 1}\'::jsonb'), introspected('textNull', 'character varying(32)', 'NULL::character varying', { nullable: true, }), @@ -110,11 +111,12 @@ describe('printPsl literal defaults', () => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. model RawDefaults { - id Int @id - stamp Timestamp(3) @default(dbgenerated("'2024-01-01 00:00:00'::timestamp without time zone")) - day Date @default(dbgenerated("'2024-01-01'::date")) - jsonNull Jsonb? @default(dbgenerated("'null'::jsonb")) - textNull VarChar(32)? @default(dbgenerated("NULL::character varying")) + id Int @id + stamp Timestamp(3) @default("2024-01-01T00:00:00") + day Date @default("2024-01-01") + jsonNull Jsonb? @default("null") + jsonObject Jsonb @default("{\\"a\\":1}") + textNull VarChar(32)? @default(dbgenerated("NULL::character varying")) @@map("raw_defaults") } @@ -209,8 +211,8 @@ describe('printPsl literal defaults', () => { emptyBigInts BigInt[]? @default([]) @noCheck(elementNotNull) hugeBigInts BigInt[]? @default([9007199254740993, -9007199254740993]) @noCheck(elementNotNull) negFloats Float[]? @default([-1.5, 2]) @noCheck(elementNotNull) - longDecimals Numeric(65, 30)[]? @default(["12345678901234567890.123456789", "0.000000000000000001"]) @noCheck(elementNotNull) - scaledDecimals Numeric(10, 2)[]? @default(["-1.25", "2"]) @noCheck(elementNotNull) + longDecimals Numeric(65, 30)[]? @default([12345678901234567890.123456789, 0.000000000000000001]) @noCheck(elementNotNull) + scaledDecimals Numeric(10, 2)[]? @default([-1.25, 2]) @noCheck(elementNotNull) emptyVarchars VarChar(32)[]? @default([]) @noCheck(elementNotNull) @@map("list_defaults") @@ -219,7 +221,7 @@ describe('printPsl literal defaults', () => { `); }); - it('prints a default with an element that has no PSL literal as dbgenerated with the expression Postgres printed', () => { + it('prints a temporal list default as the literal its codec accepts', () => { const output = printTable('raw_list_defaults', [ introspected( 'timestamps', @@ -235,7 +237,7 @@ describe('printPsl literal defaults', () => { model RawListDefaults { id Int @id - timestamps Timestamp(3)[]? @default(dbgenerated("ARRAY['2024-01-01 00:00:00'::timestamp(3) without time zone]")) @noCheck(elementNotNull) + timestamps Timestamp(3)[]? @default(["2024-01-01T00:00:00"]) @noCheck(elementNotNull) @@map("raw_list_defaults") } diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts index 58cf7d9b8991..72e34f74d81a 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts @@ -18,7 +18,12 @@ import { type AuthoringTypeNamespace, collectScalarTypeConstructors, } from '@internal/framework-components/authoring'; -import type { Codec, CodecLookup } from '@internal/framework-components/codec'; +import { + type Codec, + type CodecLookup, + decodeStringPsl, + encodeStringPsl, +} from '@internal/framework-components/codec'; import { assembleAuthoringContributions } from '@internal/framework-components/control'; import type { PslDocumentAst, @@ -84,6 +89,8 @@ const textCodec: Codec = { if (typeof json !== 'string') throw new Error(`expected string, got ${typeof json}`); return json; }, + encodePsl: (value) => encodeStringPsl(value as string), + decodePsl: (literal) => decodeStringPsl('pg/text@1', literal), }; const codecLookup: CodecLookup = { diff --git a/packages/3-targets/3-targets/postgres/test/psl-literals.test.ts b/packages/3-targets/3-targets/postgres/test/psl-literals.test.ts new file mode 100644 index 000000000000..9174004775be --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-literals.test.ts @@ -0,0 +1,233 @@ +import type { Codec, PslLiteral } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { codecDescriptors } from '../src/core/codecs'; + +const instanceCtx = { name: '' }; + +const strings = ['hello', 'a"b\\c', '']; +const integers = [0, 42, -7]; +const bigints = [0n, 9007199254740993n, -5n]; +const nonFinite = [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]; + +const samples: Record = { + 'sql/char@1': strings, + 'sql/varchar@1': strings, + 'sql/text@1': strings, + 'sql/int@1': integers, + 'sql/float@1': [1.5, -0.25, 1e21, 1e-7], + 'pg/text@1': strings, + 'pg/enum@1': ['active'], + 'pg/char@1': strings, + 'pg/varchar@1': strings, + 'pg/int@1': integers, + 'pg/float@1': [1.5, -0.25], + 'pg/int4@1': integers, + 'pg/int2@1': integers, + 'pg/int8@1': bigints, + 'pg/int8number@1': [...integers, Number.MAX_SAFE_INTEGER], + 'pg/float4@1': [1.5, -0.25, ...nonFinite], + 'pg/float8@1': [1.5, -0.25, 1e21, 1e-7, ...nonFinite], + 'pg/numeric@1': [ + '1.50', + '0', + '-7.5', + '123456789012345678901234567890.5', + 'NaN', + 'Infinity', + '-Infinity', + ], + 'pg/unboundedint@1': bigints, + 'pg/date-temporal@1': [Temporal.PlainDate.from('2026-01-02')], + 'pg/timestamp-temporal@1': [Temporal.PlainDateTime.from('2026-01-02T03:04:05.999999999')], + 'pg/timestamptz-temporal@1': [Temporal.Instant.from('2026-01-02T03:04:05.123456789Z')], + 'pg/time-temporal@1': [Temporal.PlainTime.from('03:04:05.123456')], + 'pg/date-string@1': ['2026-01-02'], + 'pg/timestamp-string@1': ['2026-01-02 03:04:05'], + 'pg/timestamptz-string@1': ['2026-01-02 03:04:05+00'], + 'pg/timestamptz-date@1': [new Date('2026-01-02T03:04:05.123Z')], + 'pg/time-string@1': ['03:04:05'], + 'pg/timetz@1': ['03:04:05+02:00'], + 'pg/bool@1': [true, false], + 'pg/bit@1': ['0101'], + 'pg/varbit@1': ['00001111'], + 'pg/bytea@1': [new Uint8Array([0xde, 0xad, 0xbe, 0xef]), new Uint8Array(0)], + 'pg/uuid@1': ['123e4567-e89b-12d3-a456-426614174000'], + 'pg/inet@1': ['192.168.0.1/24'], + 'pg/interval@1': [{ months: 13, days: 2, micros: 3_000_000n }], + 'pg/json@1': [{ a: 1 }, [1, 'x'], null, 'str', 3, true], + 'pg/jsonb@1': [{ a: 1 }, [1, 'x'], null, 'str', 3, true], + 'pg/text-array@1': [['a', 'b'], []], +}; + +function isTemporal(value: unknown): value is { toString(): string } { + return typeof value === 'object' && value !== null && 'equals' in value; +} + +function expectSame(actual: unknown, expected: unknown): void { + if (isTemporal(expected)) { + expect(String(actual)).toBe(String(expected)); + return; + } + expect(actual).toEqual(expected); +} + +const anyParams = { typeName: 'Status' } as never; + +const codecs = codecDescriptors.map((descriptor): [string, Codec] => [ + descriptor.codecId, + descriptor.factory(anyParams)(instanceCtx), +]); + +function codecFor(id: string): Codec { + const found = codecs.find(([codecId]) => codecId === id); + if (found === undefined) throw new Error(`no codec ${id}`); + return found[1]; +} + +const cases = codecs.flatMap(([id, codec]) => + (samples[id] ?? []).map((value): [string, unknown, Codec] => [id, value, codec]), +); + +describe('every registered Postgres codec', () => { + it('has at least one sample in the table', () => { + expect(new Set(codecs.map(([id]) => id))).toEqual(new Set(Object.keys(samples))); + }); + + it.each(cases)('%s round-trips %s through its PSL literal', (_id, value, codec) => { + expectSame(codec.decodePsl(codec.encodePsl(value)), value); + }); + + it.each(codecs)('%s rejects a wrong-kind literal with a message naming it', (id, codec) => { + const wrong: PslLiteral = + id === 'pg/bool@1' ? { kind: 'number', text: '1' } : { kind: 'boolean', text: 'true' }; + expect(() => codec.decodePsl(wrong)).toThrow(id); + }); +}); + +describe('integer codecs', () => { + it.each(['pg/int4@1', 'pg/int2@1', 'pg/int8number@1', 'pg/int@1', 'sql/int@1'])( + '%s rejects 1.5', + (id) => { + expect(() => codecFor(id).decodePsl({ kind: 'number', text: '1.5' })).toThrow( + `${id} reads a whole number literal; got a number 1.5`, + ); + }, + ); + + it.each(['pg/int8@1', 'pg/unboundedint@1'])('%s reads every digit of a big integer', (id) => { + expect(codecFor(id).decodePsl({ kind: 'number', text: '9007199254740993' })).toBe( + 9007199254740993n, + ); + expect(codecFor(id).encodePsl(9007199254740993n)).toEqual({ + kind: 'number', + text: '9007199254740993', + }); + }); + + it.each(['pg/int8@1', 'pg/unboundedint@1'])('%s rejects 1.5', (id) => { + expect(() => codecFor(id).decodePsl({ kind: 'number', text: '1.5' })).toThrow( + `${id} reads a whole number literal; got a number 1.5`, + ); + }); + + it('pg/int8number@1 rejects a whole number outside the safe range', () => { + expect(() => + codecFor('pg/int8number@1').decodePsl({ kind: 'number', text: '9007199254740993' }), + ).toThrow('pg/int8number@1'); + }); +}); + +describe('pg/numeric@1', () => { + const numeric = codecFor('pg/numeric@1'); + + it.each([ + ['1.50', '1.50'], + ['007', '7'], + ['-0', '0'], + ['00.10', '0.10'], + ['-007.50', '-7.50'], + ['-0.00', '0.00'], + ['NaN', 'NaN'], + ['Infinity', 'Infinity'], + ['-Infinity', '-Infinity'], + ])('reads the number literal %s as %s', (text, expected) => { + expect(numeric.decodePsl({ kind: 'number', text })).toBe(expected); + }); + + it.each(['NaN', 'Infinity', '-Infinity'])('reads the string literal "%s"', (text) => { + expect(numeric.decodePsl({ kind: 'string', text })).toBe(text); + expect(numeric.encodePsl(text)).toEqual({ kind: 'string', text }); + }); + + it('prints a finite decimal as a number literal', () => { + expect(numeric.encodePsl('1.50')).toEqual({ kind: 'number', text: '1.50' }); + }); + + it.each([ + ['1.50', '1.50'], + ['007', '7'], + ['-0.00', '0.00'], + ])('reads the string literal "%s" holding a decimal as %s', (text, expected) => { + expect(numeric.decodePsl({ kind: 'string', text })).toBe(expected); + }); + + it('rejects a string that holds neither a decimal nor a special value', () => { + expect(() => numeric.decodePsl({ kind: 'string', text: 'abc' })).toThrow( + 'pg/numeric@1 reads a number literal, or a string holding a decimal, "NaN", "Infinity", "-Infinity"; got a string "abc"', + ); + expect(() => numeric.decodePsl({ kind: 'string', text: '1e5' })).toThrow('pg/numeric@1'); + }); +}); + +describe('float codecs', () => { + it.each(['pg/float4@1', 'pg/float8@1'])('%s reads "NaN" and NaN and prints "NaN"', (id) => { + const codec = codecFor(id); + expect(codec.decodePsl({ kind: 'string', text: 'NaN' })).toBeNaN(); + expect(codec.decodePsl({ kind: 'number', text: 'NaN' })).toBeNaN(); + expect(codec.encodePsl(Number.NaN)).toEqual({ kind: 'string', text: 'NaN' }); + expect(codec.encodePsl(1.5)).toEqual({ kind: 'number', text: '1.5' }); + }); + + it.each(['pg/float4@1', 'pg/float8@1'])('%s stores a non-finite value as a JSON string', (id) => { + const codec = codecFor(id); + expect(codec.encodeJson(Number.NaN)).toBe('NaN'); + expect(codec.encodeJson(Number.POSITIVE_INFINITY)).toBe('Infinity'); + expect(codec.encodeJson(Number.NEGATIVE_INFINITY)).toBe('-Infinity'); + expect(codec.encodeJson(1.5)).toBe(1.5); + expect(codec.decodeJson('NaN')).toBeNaN(); + expect(codec.decodeJson('Infinity')).toBe(Number.POSITIVE_INFINITY); + expect(codec.decodeJson('-Infinity')).toBe(Number.NEGATIVE_INFINITY); + expect(codec.decodeJson(1.5)).toBe(1.5); + expect(() => codec.decodeJson('1.5')).toThrow(id); + }); + + it.each(['pg/float@1', 'sql/float@1'])('%s refuses "NaN"', (id) => { + expect(() => codecFor(id).decodePsl({ kind: 'string', text: 'NaN' })).toThrow(id); + expect(() => codecFor(id).decodePsl({ kind: 'number', text: 'NaN' })).toThrow(id); + }); +}); + +describe('document codecs', () => { + it('pg/jsonb@1 prints an object as JSON text', () => { + expect(codecFor('pg/jsonb@1').encodePsl({ a: 1 })).toEqual({ kind: 'string', text: '{"a":1}' }); + }); + + it('pg/json@1 rejects a string that is not JSON', () => { + expect(() => codecFor('pg/json@1').decodePsl({ kind: 'string', text: '{' })).toThrow( + 'pg/json@1 reads a string literal holding JSON text', + ); + }); + + it('pg/bytea@1 prints base64 text', () => { + expect(codecFor('pg/bytea@1').encodePsl(new Uint8Array([0xde, 0xad, 0xbe, 0xef]))).toEqual({ + kind: 'string', + text: '3q2+7w==', + }); + }); + + it('pg/interval@1 prints an ISO duration', () => { + expect( + codecFor('pg/interval@1').encodePsl({ months: 13, days: 2, micros: 3_000_000n }), + ).toEqual({ kind: 'string', text: 'P1Y1M2DT3S' }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-native-enum-authoring.test.ts b/packages/3-targets/3-targets/postgres/test/psl-native-enum-authoring.test.ts index 78dfd546e8eb..9ee9e5f92f28 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-native-enum-authoring.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-native-enum-authoring.test.ts @@ -14,7 +14,12 @@ */ import sqlFamilyPack from '@internal/family-sql/pack'; -import type { Codec, CodecLookup } from '@internal/framework-components/codec'; +import { + type Codec, + type CodecLookup, + decodeStringPsl, + encodeStringPsl, +} from '@internal/framework-components/codec'; import { assembleAuthoringContributions } from '@internal/framework-components/control'; import { buildSymbolTable } from '@internal/psl-parser'; import { parse } from '@internal/psl-parser/syntax'; @@ -410,6 +415,8 @@ describe('native_enum coexists with a PSL enum block in the same namespace', () if (typeof json !== 'string') throw new Error(`expected string, got ${typeof json}`); return json; }, + encodePsl: (value) => encodeStringPsl(value as string), + decodePsl: (literal) => decodeStringPsl('pg/text@1', literal), }; const enumTestCodecLookup: CodecLookup = { diff --git a/packages/3-targets/3-targets/postgres/test/psl-pg-enum-column.test.ts b/packages/3-targets/3-targets/postgres/test/psl-pg-enum-column.test.ts index 5540195b298e..67491e613b1d 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-pg-enum-column.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-pg-enum-column.test.ts @@ -12,7 +12,12 @@ * 3. Nullable variant (`pg.enum(E)?`). */ -import type { Codec, CodecLookup } from '@internal/framework-components/codec'; +import { + type Codec, + type CodecLookup, + decodeStringPsl, + encodeStringPsl, +} from '@internal/framework-components/codec'; import { assembleAuthoringContributions } from '@internal/framework-components/control'; import { buildSymbolTable } from '@internal/psl-parser'; import { parse } from '@internal/psl-parser/syntax'; @@ -43,6 +48,8 @@ const pgEnumCodec = { decode: () => Promise.reject(new Error('unused')), encodeJson: (value) => value, decodeJson: (json) => json, + encodePsl: (value) => encodeStringPsl(value as string), + decodePsl: (literal) => decodeStringPsl(PG_ENUM_CODEC_ID, literal), } as Codec; const codecLookup: CodecLookup = { 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 4ac2d0303760..d52b218baac3 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/codecs.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/codecs.ts @@ -18,6 +18,15 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + decodeJsonTextPsl, + decodeNumberPsl, + decodeStringPsl, + decodeWholeNumberPsl, + encodeJsonTextPsl, + encodeNumberPsl, + encodeStringPsl, + type PslLiteral, + pslLiteralReadsError, renderTsLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; @@ -273,6 +282,12 @@ export class SqliteTextCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } export class SqliteTextDescriptor extends SqliteCodecDescriptor { @@ -314,6 +329,12 @@ export class SqliteIntegerCodec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + encodePsl(value: number): PslLiteral { + return encodeNumberPsl(value); + } + decodePsl(literal: PslLiteral): number { + return Number(decodeWholeNumberPsl(this.id, literal)); + } } export class SqliteIntegerDescriptor extends SqliteCodecDescriptor { @@ -364,6 +385,14 @@ export class SqliteRealCodec extends CodecImpl< } return finiteReal(json, 'RUNTIME.DECODE_FAILED'); } + encodePsl(value: number): PslLiteral { + return encodeNumberPsl(finiteReal(value, 'RUNTIME.ENCODE_FAILED')); + } + decodePsl(literal: PslLiteral): number { + const value = decodeNumberPsl(this.id, literal); + if (!Number.isFinite(value)) throw pslLiteralReadsError(this.id, 'a finite number', literal); + return value; + } } export class SqliteRealDescriptor extends SqliteCodecDescriptor { @@ -399,7 +428,7 @@ export class SqliteBlobCodec extends CodecImpl< async decode(wire: Uint8Array, _ctx: CodecCallContext): Promise { return wire; } - encodeJson(value: Uint8Array): JsonValue { + encodeJson(value: Uint8Array): string { return Buffer.from(value).toString('hex').toUpperCase(); } decodeJson(json: JsonValue): Uint8Array { @@ -412,6 +441,12 @@ export class SqliteBlobCodec extends CodecImpl< } return new Uint8Array(Buffer.from(json, 'hex')); } + encodePsl(value: Uint8Array): PslLiteral { + return encodeStringPsl(this.encodeJson(value)); + } + decodePsl(literal: PslLiteral): Uint8Array { + return this.decodeJson(decodeStringPsl(this.id, literal)); + } } export class SqliteBlobDescriptor extends SqliteCodecDescriptor { @@ -459,7 +494,7 @@ export class SqliteDatetimeCodec extends CodecImpl< async decode(wire: string, _ctx: CodecCallContext): Promise { return this.parseDate(wire); } - encodeJson(value: Date): JsonValue { + encodeJson(value: Date): string { return value.toISOString(); } decodeJson(json: JsonValue): Date { @@ -472,6 +507,12 @@ export class SqliteDatetimeCodec extends CodecImpl< } return this.parseDate(json); } + encodePsl(value: Date): PslLiteral { + return encodeStringPsl(this.encodeJson(value)); + } + decodePsl(literal: PslLiteral): Date { + return this.decodeJson(decodeStringPsl(this.id, literal)); + } } export class SqliteDatetimeDescriptor extends SqliteCodecDescriptor { @@ -513,6 +554,12 @@ export class SqliteJsonCodec extends CodecImpl< decodeJson(json: JsonValue): JsonValue { return json; } + encodePsl(value: JsonValue): PslLiteral { + return encodeJsonTextPsl(value); + } + decodePsl(literal: PslLiteral): JsonValue { + return decodeJsonTextPsl(this.id, literal); + } } export class SqliteJsonDescriptor extends SqliteCodecDescriptor { @@ -571,7 +618,7 @@ export class SqliteBigintCodec extends CodecImpl< } return BigInt(wire); } - encodeJson(value: bigint): JsonValue { + encodeJson(value: bigint): string { return bigintEncodeJson(SQLITE_BIGINT_CODEC_ID, value); } decodeJson(json: JsonValue): bigint { @@ -584,6 +631,12 @@ export class SqliteBigintCodec extends CodecImpl< } return BigInt(json); } + encodePsl(value: bigint): PslLiteral { + return { kind: 'number', text: this.encodeJson(value) }; + } + decodePsl(literal: PslLiteral): bigint { + return BigInt(decodeWholeNumberPsl(this.id, literal)); + } } export class SqliteBigintDescriptor extends SqliteCodecDescriptor { @@ -641,7 +694,7 @@ export class SqliteBigintNumberCodec extends CodecImpl< } return safeIntegerFromBigint(BigInt(wire)); } - encodeJson(value: number): JsonValue { + encodeJson(value: number): number { return encodableSafeInteger(value); } decodeJson(json: JsonValue): number { @@ -654,6 +707,12 @@ export class SqliteBigintNumberCodec extends CodecImpl< } return safeIntegerNumber(json, 'RUNTIME.DECODE_FAILED'); } + encodePsl(value: number): PslLiteral { + return encodeNumberPsl(this.encodeJson(value)); + } + decodePsl(literal: PslLiteral): number { + return safeIntegerFromBigint(BigInt(decodeWholeNumberPsl(this.id, literal))); + } } export class SqliteBigintNumberDescriptor extends SqliteCodecDescriptor { diff --git a/packages/3-targets/3-targets/sqlite/test/psl-literals.test.ts b/packages/3-targets/3-targets/sqlite/test/psl-literals.test.ts new file mode 100644 index 000000000000..44c730b17344 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/psl-literals.test.ts @@ -0,0 +1,113 @@ +import type { Codec, PslLiteral } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { codecDescriptors } from '../src/core/codecs'; + +const instanceCtx = { name: '' }; +const strings = ['hello', 'a"b\\c', '']; +const integers = [0, 42, -7]; + +const samples: Record = { + 'sql/char@1': strings, + 'sql/varchar@1': strings, + 'sql/int@1': integers, + 'sql/float@1': [1.5, -0.25, 1e21], + 'sqlite/text@1': strings, + 'sqlite/integer@1': integers, + 'sqlite/real@1': [1.5, -0.25, 1e21, 1e-7], + 'sqlite/blob@1': [new Uint8Array([0xde, 0xad, 0xbe, 0xef]), new Uint8Array(0)], + 'sqlite/datetime@1': [new Date('2026-01-02T03:04:05.123Z')], + 'sqlite/json@1': [{ a: 1 }, [1, 'x'], null, 'str', 3, true], + 'sqlite/bigint@1': [0n, 9007199254740993n, -5n], + 'sqlite/bigintnumber@1': [...integers, Number.MAX_SAFE_INTEGER], +}; + +const codecs = codecDescriptors.map((descriptor): [string, Codec] => [ + descriptor.codecId, + descriptor.factory({} as never)(instanceCtx), +]); + +function codecFor(id: string): Codec { + const found = codecs.find(([codecId]) => codecId === id); + if (found === undefined) throw new Error(`no codec ${id}`); + return found[1]; +} + +const cases = codecs.flatMap(([id, codec]) => + (samples[id] ?? []).map((value): [string, unknown, Codec] => [id, value, codec]), +); + +describe('every registered SQLite codec', () => { + it('has at least one sample in the table', () => { + expect(new Set(codecs.map(([id]) => id))).toEqual(new Set(Object.keys(samples))); + }); + + it.each(cases)('%s round-trips %s through its PSL literal', (_id, value, codec) => { + expect(codec.decodePsl(codec.encodePsl(value))).toEqual(value); + }); + + it.each(codecs)('%s rejects a wrong-kind literal with a message naming it', (id, codec) => { + const wrong: PslLiteral = { kind: 'boolean', text: 'true' }; + expect(() => codec.decodePsl(wrong)).toThrow(id); + }); +}); + +describe('integer codecs', () => { + it.each(['sqlite/integer@1', 'sqlite/bigintnumber@1', 'sqlite/bigint@1', 'sql/int@1'])( + '%s rejects 1.5', + (id) => { + expect(() => codecFor(id).decodePsl({ kind: 'number', text: '1.5' })).toThrow( + `${id} reads a whole number literal; got a number 1.5`, + ); + }, + ); + + it('sqlite/bigint@1 reads every digit of a big integer', () => { + expect( + codecFor('sqlite/bigint@1').decodePsl({ kind: 'number', text: '9007199254740993' }), + ).toBe(9007199254740993n); + expect(codecFor('sqlite/bigint@1').encodePsl(9007199254740993n)).toEqual({ + kind: 'number', + text: '9007199254740993', + }); + }); + + it('sqlite/bigintnumber@1 rejects a whole number outside the safe range', () => { + expect(() => + codecFor('sqlite/bigintnumber@1').decodePsl({ kind: 'number', text: '9007199254740993' }), + ).toThrow('sqlite/bigintnumber@1'); + }); +}); + +describe('sqlite/real@1', () => { + const real = codecFor('sqlite/real@1'); + + it('refuses "NaN" in both forms', () => { + expect(() => real.decodePsl({ kind: 'string', text: 'NaN' })).toThrow('sqlite/real@1'); + expect(() => real.decodePsl({ kind: 'number', text: 'NaN' })).toThrow('sqlite/real@1'); + expect(() => real.encodePsl(Number.NaN)).toThrow('sqlite/real@1'); + }); +}); + +describe('sqlite/datetime@1', () => { + it('prints a Date as its ISO string', () => { + expect(codecFor('sqlite/datetime@1').encodePsl(new Date('2026-01-02T03:04:05.123Z'))).toEqual({ + kind: 'string', + text: '2026-01-02T03:04:05.123Z', + }); + }); + + it('rejects a string that is not a date', () => { + expect(() => codecFor('sqlite/datetime@1').decodePsl({ kind: 'string', text: 'x' })).toThrow( + 'sqlite/datetime@1', + ); + }); +}); + +describe('sqlite/blob@1', () => { + it('prints uppercase hex text', () => { + expect(codecFor('sqlite/blob@1').encodePsl(new Uint8Array([0xde, 0xad]))).toEqual({ + kind: 'string', + text: 'DEAD', + }); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/sqlite-codec-descriptor.test-d.ts b/packages/3-targets/3-targets/sqlite/test/sqlite-codec-descriptor.test-d.ts index a3481cbf7ce9..a969cd6c296c 100644 --- a/packages/3-targets/3-targets/sqlite/test/sqlite-codec-descriptor.test-d.ts +++ b/packages/3-targets/3-targets/sqlite/test/sqlite-codec-descriptor.test-d.ts @@ -6,6 +6,9 @@ import { CodecImpl, type CodecInstanceContext, type CodecTrait, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, } from '@internal/framework-components/codec'; import { FunctionCallExpr, type ProjectionExpr } from '@internal/sql-relational-core/ast'; import type { StandardSchemaV1 } from '@standard-schema/spec'; @@ -56,6 +59,14 @@ class VectorCodec extends CodecImpl< decodeJson(json: JsonValue): ReadonlyArray { return json as unknown as ReadonlyArray; } + + encodePsl(value: ReadonlyArray): PslLiteral { + return encodeJsonTextPsl(this.encodeJson(value)); + } + + decodePsl(literal: PslLiteral): ReadonlyArray { + return this.decodeJson(decodeJsonTextPsl(this.id, literal)); + } } class GenericVectorDescriptor extends CodecDescriptorImpl { diff --git a/packages/3-targets/3-targets/sqlite/test/sqlite-codec-descriptor.test.ts b/packages/3-targets/3-targets/sqlite/test/sqlite-codec-descriptor.test.ts index 175082895bb6..7b8127f71e81 100644 --- a/packages/3-targets/3-targets/sqlite/test/sqlite-codec-descriptor.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/sqlite-codec-descriptor.test.ts @@ -6,6 +6,9 @@ import { CodecImpl, type CodecInstanceContext, type CodecRef, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, } from '@internal/framework-components/codec'; import { ColumnRef, @@ -75,6 +78,14 @@ class VectorCodec extends CodecImpl< } return json.map(Number); } + + encodePsl(value: ReadonlyArray): PslLiteral { + return encodeJsonTextPsl(this.encodeJson(value)); + } + + decodePsl(literal: PslLiteral): ReadonlyArray { + return this.decodeJson(decodeJsonTextPsl(this.id, literal)); + } } class GenericVectorDescriptor extends CodecDescriptorImpl { diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/data-transform.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/data-transform.test.ts index f9661ef939ac..34342c1bd7d6 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/data-transform.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/data-transform.test.ts @@ -222,6 +222,8 @@ const transformingCodec: Codec = { decode: async (wire: unknown) => wire, encodeJson: (v) => v as never, decodeJson: (v) => v as never, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; const transformingCodecDescriptor: AnyCodecDescriptor = { diff --git a/packages/3-targets/6-adapters/postgres/test/psl-interpreter-literal-defaults.test.ts b/packages/3-targets/6-adapters/postgres/test/psl-interpreter-literal-defaults.test.ts new file mode 100644 index 000000000000..6172cf91353f --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/psl-interpreter-literal-defaults.test.ts @@ -0,0 +1,181 @@ +/** + * The Prisma 8 interpreter reads every literal default through the column codec's `decodePsl`. These cases need the real Postgres codecs (`pg/numeric@1` canonicalises leading zeros; `pg/int8@1` keeps every digit) and the real PSL scalar type names, so they live here rather than in `contract-psl`, whose unit tests do not depend on a target pack. + */ +import { collectScalarTypeConstructors } from '@internal/framework-components/authoring'; +import { assembleAuthoringContributions } from '@internal/framework-components/control'; +import { buildSymbolTable } from '@internal/psl-parser'; +import { parse } from '@internal/psl-parser/syntax'; +import { interpretPslDocumentToSqlContract } from '@internal/sql-contract-psl'; +import postgresTargetDescriptor from '@internal/target-postgres/control'; +import { type PostgresSchema, postgresCreateNamespace } from '@internal/target-postgres/types'; +import { describe, expect, it } from 'vitest'; +import { createPostgresBuiltinCodecLookup } from '../src/core/codec-lookup'; +import { postgresAuthoringTypes } from '../src/core/control-mutation-defaults'; +import postgresAdapterDescriptor from '../src/exports/control'; + +const assembled = assembleAuthoringContributions([ + postgresTargetDescriptor, + postgresAdapterDescriptor, +]); + +function columnDefaults(source: string) { + const { document, sourceFile } = parse(source); + const { table: symbolTable } = buildSymbolTable({ + document, + sourceFile, + pslBlockDescriptors: assembled.pslBlockDescriptors, + }); + const result = interpretPslDocumentToSqlContract({ + symbolTable, + sourceFile, + sourceId: 'schema.prisma', + capabilities: { sql: { scalarList: true } }, + target: { + kind: 'target' as const, + familyId: 'sql' as const, + targetId: 'postgres' as const, + id: 'postgres', + version: postgresTargetDescriptor.version, + capabilities: {}, + defaultNamespaceId: 'public', + }, + scalarColumnDescriptors: collectScalarTypeConstructors(postgresAuthoringTypes), + authoringContributions: assembled, + composedExtensionContracts: new Map(), + createNamespace: postgresCreateNamespace, + codecLookup: createPostgresBuiltinCodecLookup(), + }); + if (!result.ok) return { columns: {}, diagnostics: result.failure.diagnostics }; + const namespace = result.value.storage.namespaces['public'] as PostgresSchema; + const table = namespace.table['N']; + const columns = Object.fromEntries( + Object.entries(table?.columns ?? {}).flatMap(([name, column]) => + column.default === undefined ? [] : [[name, column.default]], + ), + ); + return { columns, diagnostics: [] }; +} + +describe('literal defaults through the real Postgres codecs', () => { + it('lower a decimal to the text written, without leading zeros or the sign of zero', () => { + expect( + columnDefaults(`types { + Price = Numeric(10, 2) +} + +model N { + id Int @id + long Decimal @default(12345678901234567890.123456789) + tiny Decimal @default(0.000000000000000001) + negative Decimal @default(-1.25) + whole Decimal @default(10) + bareTrailingZeros Decimal @default(1.50) + scaledTrailingZeros Price @default(1.50) + notANumber Decimal @default(NaN) + quotedNotANumber Decimal @default("NaN") + negativeZero Decimal @default(-0) + leadingZeros Decimal @default(007) + leadingZeroFraction Decimal @default(00.10) + negativeLeadingZero Decimal @default(-007.50) + scaledNegativeZero Price @default(-0.00) +}`), + ).toEqual({ + columns: { + long: { kind: 'literal', value: '12345678901234567890.123456789' }, + tiny: { kind: 'literal', value: '0.000000000000000001' }, + negative: { kind: 'literal', value: '-1.25' }, + whole: { kind: 'literal', value: '10' }, + bareTrailingZeros: { kind: 'literal', value: '1.50' }, + scaledTrailingZeros: { kind: 'literal', value: '1.50' }, + notANumber: { kind: 'literal', value: 'NaN' }, + quotedNotANumber: { kind: 'literal', value: 'NaN' }, + negativeZero: { kind: 'literal', value: '0' }, + leadingZeros: { kind: 'literal', value: '7' }, + leadingZeroFraction: { kind: 'literal', value: '0.10' }, + negativeLeadingZero: { kind: 'literal', value: '-7.50' }, + scaledNegativeZero: { kind: 'literal', value: '0.00' }, + }, + diagnostics: [], + }); + }); + + it('lower every digit written on a big integer column', () => { + expect( + columnDefaults(`model N { + id Int @id + big BigInt @default(9007199254740993) + smallest BigInt @default(-9223372036854775808) + safe BigInt @default(42) +}`), + ).toEqual({ + columns: { + big: { kind: 'literal', value: '9007199254740993' }, + smallest: { kind: 'literal', value: '-9223372036854775808' }, + safe: { kind: 'literal', value: '42' }, + }, + diagnostics: [], + }); + }); + + it('lower each list element from its text', () => { + expect( + columnDefaults(`model N { + id Int @id + decimals Decimal[] @default([12345678901234567890.123456789, 1.50, -0, 007]) + bigs BigInt[] @default([9007199254740993, -1]) + ints Int[] @default([1, -2]) +}`), + ).toEqual({ + columns: { + decimals: { kind: 'literal', value: ['12345678901234567890.123456789', '1.50', '0', '7'] }, + bigs: { kind: 'literal', value: ['9007199254740993', '-1'] }, + ints: { kind: 'literal', value: [1, -2] }, + }, + diagnostics: [], + }); + }); + + it('keep numbers on columns whose codec reads a JSON number, and NaN as its JSON string', () => { + expect( + columnDefaults(`model N { + id Int @id + count Int @default(-5) + ratio Float @default(1.50) + nan Float @default("NaN") + json Jsonb @default("{\\"a\\":1}") + none Json @default("null") +}`), + ).toEqual({ + columns: { + count: { kind: 'literal', value: -5 }, + ratio: { kind: 'literal', value: 1.5 }, + nan: { kind: 'literal', value: 'NaN' }, + json: { kind: 'literal', value: { a: 1 } }, + none: { kind: 'literal', value: null }, + }, + diagnostics: [], + }); + }); + + it('reject a number on a column whose codec reads text', () => { + expect( + columnDefaults(`model N { + id Int @id + payload Bytes @default(1234) +}`), + ).toEqual({ + columns: {}, + diagnostics: [ + { + code: 'PSL_INVALID_DEFAULT_LITERAL', + message: + 'Field "N.payload": @default(1234) is not a value of pg/bytea@1: pg/bytea@1 reads a string literal; got a number 1234', + sourceId: 'schema.prisma', + span: expect.objectContaining({ + start: expect.objectContaining({ line: 3, column: 17 }), + }), + }, + ], + }); + }); +}); 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 133f67a28832..e035b76e09a2 100644 --- a/packages/3-targets/6-adapters/postgres/test/test-codec.ts +++ b/packages/3-targets/6-adapters/postgres/test/test-codec.ts @@ -2,7 +2,12 @@ * Test-only helper that constructs a SQL-family `Codec` instance from author-side encode/decode functions. Replaces the legacy public `mkCodec()` factory (deleted under TML-2357); tests that need a stub codec for behavioural assertions instantiate one through this helper rather than going through `descriptor.factory(...)`. */ import type { JsonValue } from '@internal/contract/types'; -import type { CodecTrait } from '@internal/framework-components/codec'; +import { + type CodecTrait, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, +} from '@internal/framework-components/codec'; import type { Codec, SqlCodecCallContext } from '@internal/sql-relational-core/ast'; type JsonRoundTripConfig = [TInput] extends [JsonValue] @@ -36,6 +41,20 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const encodeJson = (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue; + const decodeJson = (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput; + const encodePsl = (value: TInput): PslLiteral => { + const json = encodeJson(value); + if (typeof json === 'string') return { kind: 'string', text: json }; + if (typeof json === 'number') return { kind: 'number', text: String(json) }; + if (typeof json === 'boolean') return { kind: 'boolean', text: String(json) }; + return encodeJsonTextPsl(json); + }; + const decodePsl = (literal: PslLiteral): TInput => { + if (literal.kind === 'number') return decodeJson(Number(literal.text)); + if (literal.kind === 'boolean') return decodeJson(literal.text === 'true'); + return decodeJson(decodeJsonTextPsl(config.typeId, literal)); + }; return { id: config.typeId, encode: (value, ctx) => { @@ -52,7 +71,9 @@ export function defineTestCodec< return Promise.reject(error); } }, - encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, - decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + encodeJson, + decodeJson, + encodePsl, + decodePsl, } as Codec; } diff --git a/packages/3-targets/6-adapters/sqlite/test/lower-to-execute-request.test.ts b/packages/3-targets/6-adapters/sqlite/test/lower-to-execute-request.test.ts index 0a62b69ee6bc..74f0b7260138 100644 --- a/packages/3-targets/6-adapters/sqlite/test/lower-to-execute-request.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/lower-to-execute-request.test.ts @@ -35,6 +35,8 @@ const transformingCodec = { decode: async (wire: unknown) => wire, encodeJson: (value: unknown) => value, decodeJson: (json: unknown) => json, + encodePsl: (value: unknown) => ({ kind: 'string' as const, text: String(value) }), + decodePsl: (literal: { text: string }) => literal.text as never, } as unknown as Codec; const transformingDescriptor: AnySqliteCodecDescriptor = { @@ -218,6 +220,8 @@ const transformingQueryCodec: Codec = { decode: async (wire: unknown) => wire, encodeJson: (v) => v as never, decodeJson: (v) => v as never, + encodePsl: (value: unknown) => ({ kind: 'string' as const, text: String(value) }), + decodePsl: (literal: { text: string }) => literal.text as never, }; const testRegistry: ContractCodecRegistry = { @@ -287,6 +291,8 @@ class ExtTransformDescriptor extends CodecDescriptorImpl { decode: async (wire: unknown) => wire, encodeJson: (v: unknown) => v as never, decodeJson: (v: unknown) => v as never, + encodePsl: (value: unknown) => ({ kind: 'string' as const, text: String(value) }), + decodePsl: (literal: { text: string }) => literal.text as never, }) as unknown as Codec; } } diff --git a/packages/3-targets/6-adapters/sqlite/test/sqlite-codec-registry-composition.test.ts b/packages/3-targets/6-adapters/sqlite/test/sqlite-codec-registry-composition.test.ts index ee663751f6fa..27ed54f5707f 100644 --- a/packages/3-targets/6-adapters/sqlite/test/sqlite-codec-registry-composition.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/sqlite-codec-registry-composition.test.ts @@ -6,6 +6,9 @@ import { CodecDescriptorImpl, CodecImpl, type CodecInstanceContext, + decodeStringPsl, + encodeStringPsl, + type PslLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; import type { ControlExtensionDescriptor } from '@internal/framework-components/control'; @@ -77,6 +80,14 @@ class TestCodec extends CodecImpl } return json; } + + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } class TestGenericDescriptor extends CodecDescriptorImpl { diff --git a/projects/remove-dbgenerated/slices/b-codec-psl-literals/plan.md b/projects/remove-dbgenerated/slices/b-codec-psl-literals/plan.md new file mode 100644 index 000000000000..aa71374baa71 --- /dev/null +++ b/projects/remove-dbgenerated/slices/b-codec-psl-literals/plan.md @@ -0,0 +1,126 @@ +# Plan: Slice B — Codec-owned PSL literals + +Spec: [`spec.md`](spec.md). Branch: `remove-dbgenerated-codec-psl-literals`, cut from `remove-dbgenerated-plan` rebased onto `main` so the project artifacts travel with the slice. + +## At a glance + +Eight sequential dispatches. The first adds the framework interface and the parser combinator. The next two make every codec in the repository implement the new members until the root typecheck is green. Then the three consumers move onto the codec, one dispatch each: the Prisma 8 interpreter, the Prisma 7 source, the infer printer. The last two dispatches add the journey tests and run every slice gate, then land the ADR amendment and docs. + +## Decisions made while grounding the spec (recorded for the reviewer and for slice C) + +These were raised to the operator before dispatch 1. Each is reversible; none changes an interface slice C relies on. + +1. **Mongo codecs come from a factory, not classes.** `mongoCodec({...})` in `packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts` returns object literals typed as `Codec`. The factory config gains required `encodePsl` and `decodePsl` members, so every Mongo codec declares its PSL form explicitly, matching the spec's "no implicit PSL form" reason for having no base-class default. +2. **Float `NaN`, `Infinity`, `-Infinity`.** The tokenizer already reads these as number tokens. `pg/float4@1` and `pg/float8@1` gain: `decodePsl` accepts the number literal and the three quoted strings and returns the JS number; `encodePsl` writes the quoted string; `encodeJson` writes the string for a non-finite number and `decodeJson` reads it back as a number. The contract JSON form (`"NaN"` as a string) is unchanged. `sqlite/real@1` and `sql/float@1` already refuse non-finite values in JSON and refuse them in PSL too. `pg/numeric@1` keeps printing `"NaN"` quoted, as infer does today. +3. **Infer-time literal the codec cannot read.** `mapDefault`'s literal arm calls the codec and lets a thrown error propagate. The Postgres printer catches it and falls back to the raw expression through the function mapping, which prints `dbgenerated(...)` until slice C replaces that fallback. +4. **`printer-config.ts` carries no formatter option.** The Postgres printer selects the formatter table by PSL type name inside `infer-model-blocks.ts`. That selection is replaced by resolving the column's codec from the Postgres codec descriptor registry using the column's native type and type params. +5. **Test doubles.** About forty object literals in test files implement `Codec`. Each gains the two members. Test files are exempt from the bare-cast rule. + +## Dispatches + +### Dispatch 1: Framework interface and parser combinator + +- **Outcome:** `Codec` requires `encodePsl` and `decodePsl`, `CodecImpl` declares them abstract, `PslLiteral` and the shared helper module exist, and `literal()` parses a PSL string, number, or boolean into a `PslLiteral`. +- **Builds on:** Nothing. Spec sections B1, B2, B4. +- **Hands to:** The interface every codec dispatch implements and the combinator the interpreter dispatch uses. +- **Focus:** Tests first: `PslLiteral` type test; a `CodecImpl` subclass without the methods fails to compile (`test-d`); `literal()` accepts each literal kind, resolves string escapes, keeps number text verbatim, and rejects anything else with `Expected a string, number, or boolean literal`. Write `psl-literal-helpers.ts` with the shared pairs the spec names (identity-string, JSON-text, JS-number, boolean) plus tests. Update the `codec.ts` header comment to six methods. Add `'literal'` to `ArgTypeKind` and `LiteralArgType` to `attribute-spec/types.ts`; export `literal` and `PslLiteral` where `numLiteral` and `NumLiteral` are exported. Do not touch any codec class or any consumer. The root typecheck will be red after this dispatch; the two touched packages must be green. + +### Dispatch 2: Postgres target and relational-core codecs + +- **Outcome:** Every codec class in the Postgres target (`codecs.ts`, `temporal-codecs.ts`, `temporal-string-codecs.ts`, `date-codecs.ts`) and in `relational-core/src/ast/sql-codecs.ts` implements the two members by the B3 rule, and a table test per pack proves `decodePsl(encodePsl(v))` round-trips and a wrong-kind literal throws a message naming the codec. +- **Builds on:** Dispatch 1's interface and helpers. +- **Hands to:** Thirty-five codecs with PSL forms; the pattern the remaining packs copy. +- **Focus:** Tests first. Apply the one rule; named exceptions only for floats (decision 2 above), `pg/int8@1`, `pg/numeric@1`, `pg/unbounded-int@1` (digits read and printed as text, never through `Number()`), and `pg/int4@1`/`pg/int2@1`/`sql/int@1` (reject `1.5` with a message such as `pg/int4@1 reads a whole number literal; got 1.5`). `pg/numeric@1` canonicalises leading zeros and the sign of zero the way the deleted `number-literal-default.ts` does today, because the existing interpreter tests expect `007` → `7` and `-0` → `0`. If a codec's JSON form is a string but its PSL form must be a number, or the reverse, stop and report. Also update every test double in the two touched packages so their own typecheck is green. + +### Dispatch 3: Remaining codecs, Mongo factory, and every test double + +- **Outcome:** SQLite, pgvector, postgis, arktype-json, and the Mongo adapter codecs implement the two members; the `mongoCodec` factory requires them in its config; every object literal in the repository that implements `Codec` has them; the root `pnpm typecheck` is green. +- **Builds on:** Dispatch 2's pattern and helpers. +- **Hands to:** A repository where every codec has a PSL form, ready for the consumers. +- **Focus:** Tests first per pack (SQLite target, pgvector, postgis, arktype-json, Mongo codec package). Grep for `decodeJson` across `packages/**/test` and `test/**` to find every double; use the shared helpers so each double is two lines. Do not touch the interpreter, the printer, or the Prisma 7 source. + +### Dispatch 4: Interpreter reads literals through the codec + +- **Outcome:** `@default` literal arms are `literal()` and `list(literal())`; `psl-column-resolution.ts` decodes each literal with the column codec's `decodePsl` and reports `PSL_INVALID_DEFAULT_LITERAL` with the codec's message; `number-literal-default.ts`, its test, its `resolution` export, and the `numeric` trait check are deleted. +- **Builds on:** Dispatch 1's combinator; dispatch 3's green typecheck. +- **Hands to:** A Prisma 8 interpreter with no type-specific literal code; the `contract-prisma7` package is red on the deleted `numberLiteralDefault` import until dispatch 5. +- **Focus:** Tests first in `contract-psl/test/interpreter.defaults.test.ts`, every case the spec lists, each asserting the whole default object. `interpreter.number-defaults.test.ts` is rewritten to the codec rule (`Bytes @default(1234)` is now `PSL_INVALID_DEFAULT_LITERAL`). The fixture `codecLookup` must return codecs with `decodePsl`; use the real Postgres codec instances where the fixture already resolves Postgres codec ids. `DefaultArgValue` becomes `PslLiteral | PslLiteral[] | TypedFuncCall`. Do not edit the function arms or the enum arms. Slice A adds a tagged-literal arm after the function arms in the same function; keep the edit to the literal arms so the rebase is mechanical. + +### Dispatch 5: Prisma 7 source reads literals through the codec + +- **Outcome:** `Prisma7LiteralDefaultForm` has no `json` member; `defaults.ts` builds a `PslLiteral` from the expression, calls the codec's `decodePsl`, and turns a throw into `PSL.PRISMA7_UNKNOWN_DEFAULT` with the spec's message; `scalarValue`, `elementValue`, `numberValue`, `rejectedNumberReason`, `WHOLE_NUMBER_SCALARS`, `WHOLE_NUMBER_TEXT` are gone; the JSON-null diagnostic still fires on a `Json` field. +- **Builds on:** Dispatch 4's deleted export. +- **Hands to:** A green `contract-prisma7` package with the codec's messages in its fixtures. +- **Focus:** Tests first: `defaults` fixture with updated messages; `jsonLiteral Json @default("{\"a\":1}")` lowers through the codec; `integer-default-not-whole-number` expects the codec's message; `json-null-default` unchanged. The `sqlExpression` form stays for `bytea` and the temporal types. Grep gate: `kind: 'json'` returns nothing in `contract-prisma7` and `prisma7-binding.ts`. + +### Dispatch 6: Printer prints literals through the codec + +- **Outcome:** `mapDefault(columnDefault, { codec })` prints a literal as `formatPslLiteral(codec.encodePsl(codec.decodeJson(value)))`, per element for a list; `formatPslLiteral` and `escapePslString` live in the family; the formatter table in `psl-literals.ts` and `formatLiteralValue`/`quoteString`/`escapeString` in `default-mapping.ts` are deleted; `infer-model-blocks.ts` resolves the column's codec and falls back to the raw expression when the codec throws. +- **Builds on:** Dispatch 3's codecs. +- **Hands to:** A printer with no per-type formatter; `contract infer` prints every literal the codec can read in the form the interpreter reads. +- **Focus:** Tests first in `9-family/test/psl-contract-infer/default-mapping.test.ts` and `postgres/test/psl-infer/print-psl/print-psl.literal-defaults.test.ts` and `print-psl.defaults-and-types.test.ts`: every spec case printed back to its source text; `pg/float8@1` `NaN` prints `"NaN"`; `pg/int8@1` beyond 2^53 prints every digit; a jsonb object prints `"{\"a\":1}"`; `'null'::jsonb` now prints `@default("null")`; temporal literals still fall back to `dbgenerated(...)`. Grep gate: `PslDefaultValueFormat|formatLiteralValue` returns nothing under `packages`. `mapDefault`'s `codec` option is required for the literal arm; existing callers that only map function defaults are updated. + +### Dispatch 7: Journeys and slice gates + +- **Outcome:** The jsonb case in `infer-roundtrip-fidelity.e2e.test.ts` asserts emit succeeds without a workaround and infer prints `@default("{}")`; a new integration test emits the spec's Outcome schema, runs `db init`, verifies clean with `--schema-only --strict`, and reads a row whose defaults come back with decoded types; every slice gate is green. +- **Builds on:** Dispatches 4, 5, 6. +- **Hands to:** A branch whose tip passes `pnpm typecheck`, `pnpm test:packages`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm fixtures:check`, `pnpm lint:deps`, and the grep gates. +- **Focus:** Use the `:agent` command variants and read the log files. `pnpm fixtures:check` must show every existing `contract.json` byte-identical. Any red test outside the slice's surfaces is reported, not fixed. + +### Dispatch 8: ADR amendment and docs + +- **Outcome:** ADR 184 has the "Amendment — PSL literal methods live on `Codec`" section; the ADR index summary line is updated; `docs/reference/codec-authoring-guide.md` lists six methods with a JSON-valued and a string-valued example; `contract-psl/README.md` has the literal-defaults paragraph; `docs/reference/error-reference.md` has `PSL_INVALID_DEFAULT_LITERAL` and the updated Prisma 7 messages; `pnpm lint:docs` is green. +- **Builds on:** Dispatch 7's final shapes. +- **Hands to:** A PR-ready branch. +- **Focus:** Plain language, no hard-wrapped prose, ADR examples that match the code. Point the DDL note at `deferred.md` item 3. + +## Dispatch-INVEST check + +| Dispatch | Independent handoff | One coherent outcome | Binary verification | +|---|---|---|---| +| 1 | Interface and combinator usable before any codec adopts them. | One framework surface. | Two packages green; `test-d` proves omission fails to compile. | +| 2 | Postgres and relational-core codecs complete on their own. | One mechanical adoption with named exceptions. | Per-pack round-trip table tests. | +| 3 | The rest of the repository compiles. | One "every codec has a PSL form" outcome. | Root typecheck green. | +| 4 | Interpreter reads through the codec. | One consumer. | Spec's interpreter cases green. | +| 5 | Prisma 7 source reads through the codec. | One consumer. | Fixture tests green; grep gate empty. | +| 6 | Printer prints through the codec. | One consumer. | Printer cases green; grep gate empty. | +| 7 | End-to-end proof. | One gate outcome. | All commands green. | +| 8 | Docs match the code. | One documentation outcome. | `lint:docs` green. | + +## Validation gates + +### Per-dispatch baseline + +- Tests before implementation. Each named test is red before the change that turns it green. +- After changing exported types, run that package's `pnpm build` before typechecking downstream packages. +- Run each touched package's `pnpm --filter test` and `pnpm --filter typecheck`. +- Stage files explicitly; commit with `git commit -s --trailer "Signed-off-by: Will Madden "` and the `Co-Authored-By: Claude Fable 5.1 ` line. Do not push. +- No bare `as` in production code; no `any`; no comments that restate code. + +### Dispatch-specific gates + +- **D1:** `@internal/framework-components` and `@internal/psl-parser` test and typecheck. +- **D2:** `@internal/target-postgres` and `@internal/sql-relational-core` build, test, typecheck. +- **D3:** root `pnpm typecheck:agent` green; each touched pack's tests. +- **D4:** `@internal/sql-contract-psl` test and typecheck; grep `numberLiteralDefault` returns only the `contract-prisma7` import. +- **D5:** `@internal/sql-contract-prisma7` test and typecheck; grep `kind: 'json'` empty in the two named paths. +- **D6:** `@internal/family-sql` and `@internal/target-postgres` test and typecheck; grep `PslDefaultValueFormat|formatLiteralValue` empty under `packages`. +- **D7:** `pnpm typecheck:agent`, `pnpm test:packages:agent`, `pnpm test:integration:agent`, `pnpm test:e2e:agent`, `pnpm fixtures:check:agent`, `pnpm lint:deps:agent`. +- **D8:** `pnpm lint:docs`. + +## Open items + +- Slice A adds a tagged-literal arm after the function arms in `scalarDefaultArms` and its own `DefaultArgValue` member. Whichever slice merges second resolves that one function by hand to `[literal(), ...funcArms, taggedLiteral(tags)]` / `[list(literal()), ...funcArms, taggedLiteral(tags)]`. +- Slice C deletes the `dbgenerated(...)` fallback the printer keeps in dispatch 6 and the `// Raw default:` comment result. + +## Retro (2026-09-16, slice B delivered as PR #30324) + +Trigger: spec gaps recorded in the PR body. No halt condition fired; the shared-file edit stayed inside `scalarDefaultArms`. + +- **The spec's per-codec inventory was written from class names, not from how codecs are built.** Mongo codecs come from a factory, and about forty test doubles implement `Codec` as object literals. Lesson: when a spec makes an interface member required, grep for every implementer (`decodeJson` across `src` and `test`), not only for `extends CodecImpl`, before sizing the slice. +- **A codec's JSON form and its PSL form can disagree with the spec's grouping.** Three codecs moved rule: postgis (hex string, not a JSON document), interval (ISO duration string), and numeric (also reads a quoted decimal because older schemas and the old printer used it). Lesson: derive the rule table from `encodeJson` return types in code, and let the implementer report each reassignment rather than pre-listing groups in the spec. +- **Changing what a value decodes to reaches the wire.** Making `"NaN"` decode to a real NaN broke the DDL renderer until the float codecs' `encode` learned to write the text. Lesson: a spec that changes `decodeJson` behaviour must name every consumer of the decoded value (DDL rendering, runtime encode), not only the PSL path. +- **Printer codec resolution keys on the printed PSL type name.** Several codecs share one native type, so the plan's "resolve by native type" could not pick the codec emit binds. Lesson: when a printer must pick a codec, key on whatever the reader will resolve from. +- **Docs rule versus spec instruction.** The spec asked the ADR to link into `projects/`; the always-apply doc-maintenance rule forbids it. Lesson: a slice spec's docs section should be checked against `.agents/rules/doc-maintenance.mdc` at planning time. +- **Process.** Reviewer and implementer ran in parallel on different dispatches, which cut wall time without a conflict; the slip of spawning a second implementer for dispatch 2 cost one re-read of the codebase and nothing else. +- **Local gates missed two CI-only checks.** `pnpm lint:agent` does not run `lint:throws` (the bare-throw ratchet) or `check:upgrade-coverage --mode pr` (a diff under `packages/3-extensions/` needs a pending upgrade-instruction fragment). Both failed on CI and were fixed after the PR opened. Lesson: a slice gate list must include every step CI's Lint job runs, and any slice that changes a public interface extension authors implement must plan the upgrade fragment as a dispatch item. diff --git a/test/integration/test/cli-journeys/codec-psl-literal-defaults.e2e.test.ts b/test/integration/test/cli-journeys/codec-psl-literal-defaults.e2e.test.ts new file mode 100644 index 000000000000..d92d4cfb24f4 --- /dev/null +++ b/test/integration/test/cli-journeys/codec-psl-literal-defaults.e2e.test.ts @@ -0,0 +1,181 @@ +/** + * Journey: every literal `@default` is read by the column codec's `decodePsl`, + * stored in the contract in the codec's JSON form, created in the database by + * `db init`, verified clean by strict `db verify`, and read back through the + * client as the codec's own value type. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Contract } from '@prisma/orm-postgres/contract/types'; +import type { SqlStorage } from '@prisma/orm-postgres/family-contract/types'; +import postgres from '@prisma/orm-postgres/runtime'; +import { withClient } from '@repo/test-utils'; +import stripAnsi from 'strip-ansi'; +import { describe, expect, it } from 'vitest'; +import { withTempDir } from '../utils/cli-test-helpers'; +import { + type JourneyContext, + parseJsonOutput, + runContractEmit, + runDbInit, + runDbVerify, + setupJourney, + timeouts, + useDevDatabase, +} from '../utils/journey-test-helpers'; + +const SCHEMA = `// use prisma-8 + +model T { + id Int @id @default(autoincrement()) + meta Jsonb @default("{}") + items Json @default("[1, 2]") + big BigInt @default(9007199254740993) + price Decimal @default(1.50) + ratio Float @default("NaN") + name String @default("x") + flag Boolean @default(true) + scores Int[] @default([1, 2]) +} +`; + +interface SchemaVerifyResult { + readonly schema: { readonly issues: readonly unknown[] }; +} + +interface EmittedColumn { + readonly default?: { readonly kind: string; readonly value: unknown }; +} + +interface EmittedTable { + readonly name?: string; + readonly columns: Record; +} + +function output(result: { stdout: string; stderr: string }): string { + return `${stripAnsi(result.stderr)}\n${stripAnsi(result.stdout)}`; +} + +function readContractJson(ctx: JourneyContext): unknown { + return JSON.parse(readFileSync(join(ctx.testDir, 'contract.json'), 'utf-8')); +} + +/** The one table the schema declares, under whichever key the interpreter stores it. */ +function emittedTable(contractJson: unknown): { + readonly key: string; + readonly table: EmittedTable; +} { + const tables = ( + contractJson as { + storage: { namespaces: { public: { entries: { table: Record } } } }; + } + ).storage.namespaces.public.entries.table; + const [entry, ...rest] = Object.entries(tables); + if (entry === undefined || rest.length > 0) + throw new Error(`expected one table, got ${Object.keys(tables)}`); + return { key: entry[0], table: entry[1] }; +} + +async function rows(result: AsyncIterable): Promise { + const out: unknown[] = []; + for await (const row of result) out.push(row); + return out; +} + +withTempDir(({ createTempDir }) => { + describe('Journey: codec-owned PSL literal defaults', () => { + const db = useDevDatabase(); + + it( + 'emits, initialises, verifies clean, and reads the defaults back with their decoded types', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + writeFileSync(join(ctx.testDir, 'contract.prisma'), SCHEMA, 'utf-8'); + + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `contract emit\n${output(emit)}`).toBe(0); + + const contractJson = readContractJson(ctx); + const { key, table } = emittedTable(contractJson); + const columns = table.columns; + expect({ + meta: columns['meta']?.default, + items: columns['items']?.default, + big: columns['big']?.default, + price: columns['price']?.default, + ratio: columns['ratio']?.default, + name: columns['name']?.default, + flag: columns['flag']?.default, + scores: columns['scores']?.default, + }).toEqual({ + meta: { kind: 'literal', value: {} }, + items: { kind: 'literal', value: [1, 2] }, + big: { kind: 'literal', value: '9007199254740993' }, + price: { kind: 'literal', value: '1.50' }, + ratio: { kind: 'literal', value: 'NaN' }, + name: { kind: 'literal', value: 'x' }, + flag: { kind: 'literal', value: true }, + scores: { kind: 'literal', value: [1, 2] }, + }); + + const init = await runDbInit(ctx); + expect(init.exitCode, `db init\n${output(init)}`).toBe(0); + + const verify = await runDbVerify(ctx, ['--schema-only', '--strict', '--json']); + expect( + parseJsonOutput(verify).schema.issues, + `db verify\n${output(verify)}`, + ).toEqual([]); + + const tables = await withClient(db.connectionString, async (client) => { + const result = await client.query<{ table_name: string }>( + "select table_name from information_schema.tables where table_schema = 'public'", + ); + return result.rows.map((row) => row.table_name); + }); + expect(tables, 'db init created the table under the contract key').toContain(key); + await withClient(db.connectionString, (client) => + client.query(`insert into "${key}" default values`), + ); + + const client = postgres>({ contractJson, url: db.connectionString }); + const runtime = await client.connect(); + try { + const sqlNamespace = ( + client.sql as unknown as { + readonly public: Record; + } + ).public; + const sqlTable = sqlNamespace[key]; + expect( + sqlTable, + `the client exposes ${key}; it has ${Object.keys(sqlNamespace)}`, + ).toBeDefined(); + const plan = sqlTable + ?.select('id', 'meta', 'items', 'big', 'price', 'ratio', 'name', 'flag', 'scores') + .build(); + expect(await rows(runtime.query(plan as never))).toEqual([ + { + id: 1, + meta: {}, + items: [1, 2], + big: 9007199254740993n, + price: '1.50', + ratio: Number.NaN, + name: 'x', + flag: true, + scores: [1, 2], + }, + ]); + } finally { + await runtime.close(); + } + }, + timeouts.spinUpPpgDev, + ); + }); +}); diff --git a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts index 7676172f542a..77d1fea04a5d 100644 --- a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts +++ b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts @@ -331,19 +331,23 @@ withTempDir(({ createTempDir }) => { const infer = await runContractInfer(ctx); expect(infer.exitCode, `contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); - // Fix the one remaining unrelated emit-blocker (1:1 back-relation) so - // emit succeeds and verify can run. The gin/hash indexes and the tags - // list default are left exactly as infer printed them — postgres now - // registers those access methods and infer now prints a literal-list - // default (TML-3037), so they emit and round-trip clean against the - // live gin/hash indexes and the live tags default, proving those - // fixes too. Only the jsonb default on Users.metadata is left broken, - // which is what this test is for. - const reduced = fixOneToOneBackRelation(readContractPsl(ctx)); - writeContractPsl(ctx, reduced); + const psl = readContractPsl(ctx); + expect( + psl, + 'Users.metadata carries its jsonb default as the JSON text its codec reads', + ).toMatch(/metadata\s+Jsonb\s+@default\("\{\}"\)/); + + // The jsonb default is left exactly as infer printed it; the one + // remaining unrelated emit-blocker (1:1 back-relation) is fixed so + // emit and verify can run. + writeContractPsl(ctx, fixOneToOneBackRelation(psl)); const emit = await runContractEmit(ctx); - expect(emit.exitCode, `contract emit\n${stripAnsi(emit.stderr)}`).toBe(0); + expect( + emit.exitCode, + 'contract emit should accept @default("{}") on Users.metadata; ' + + `instead got:\n${stripAnsi(emit.stderr)}\n${stripAnsi(emit.stdout)}`, + ).toBe(0); await expectVerifiesCleanAfterPull(ctx, 'Users.metadata'); }, diff --git a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.prisma7-defaults.e2e.test.ts b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.prisma7-defaults.e2e.test.ts index 655c68100546..88d464f3c6bb 100644 --- a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.prisma7-defaults.e2e.test.ts +++ b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.prisma7-defaults.e2e.test.ts @@ -147,15 +147,6 @@ interface SchemaVerifyResult { readonly schema: { readonly issues: readonly VerifyIssue[] }; } -interface SourceLoadError { - readonly meta?: { - readonly diagnostics?: readonly { - readonly code: string; - readonly span?: { readonly start: { readonly line: number } }; - }[]; - }; -} - function readContractPsl(ctx: JourneyContext): string { return readFileSync(join(ctx.testDir, 'contract.prisma'), 'utf-8'); } @@ -210,9 +201,9 @@ withTempDir(({ createTempDir }) => { emptyBigInts BigInt[]? @default([]) @noCheck(elementNotNull) hugeBigInts BigInt[]? @default([9007199254740993, -9007199254740993]) @noCheck(elementNotNull) negFloats Float[]? @default([-1.5, 2]) @noCheck(elementNotNull) - negDecimals Numeric(65, 30)[]? @default(["-1.5", "2"]) @noCheck(elementNotNull) - longDecimals Numeric(65, 30)[]? @default(["12345678901234567890.123456789", "0.000000000000000001"]) @noCheck(elementNotNull) - scaledDecimals Numeric(10, 2)[]? @default(["-1.25", "2"]) @noCheck(elementNotNull) + negDecimals Numeric(65, 30)[]? @default([-1.5, 2]) @noCheck(elementNotNull) + longDecimals Numeric(65, 30)[]? @default([12345678901234567890.123456789, 0.000000000000000001]) @noCheck(elementNotNull) + scaledDecimals Numeric(10, 2)[]? @default([-1.25, 2]) @noCheck(elementNotNull) emptyVarchars VarChar(32)[]? @default([]) @noCheck(elementNotNull) @@map("list_defaults") @@ -225,17 +216,17 @@ withTempDir(({ createTempDir }) => { negFloat Float @default(-1.5) tinyFloat Float @default(0.0000001) negReal Real @default(-2.5) - negDecimal Numeric(65, 30) @default("-0.5") - longDecimal Numeric(65, 30) @default("12345678901234567890.123456789") - tinyDecimal Numeric(65, 30) @default("0.000000000000000001") - scaleDecimal Numeric(65, 30) @default("1.50") - wholeDecimal Numeric(65, 30) @default("10") - scaledDecimal Numeric(10, 2) @default("-1.25") + negDecimal Numeric(65, 30) @default(-0.5) + longDecimal Numeric(65, 30) @default(12345678901234567890.123456789) + tinyDecimal Numeric(65, 30) @default(0.000000000000000001) + scaleDecimal Numeric(65, 30) @default(1.50) + wholeDecimal Numeric(65, 30) @default(10) + scaledDecimal Numeric(10, 2) @default(-1.25) negSafeBigInt BigInt @default(-5) negBigInt BigInt @default(-9007199254740993) hugeBigInt BigInt @default(9007199254740993) - stamp Timestamp(3) @default(dbgenerated("'2024-01-01 00:00:00'::timestamp without time zone")) - jsonNull Jsonb? @default(dbgenerated("'null'::jsonb")) + stamp Timestamp(3) @default("2024-01-01T00:00:00") + jsonNull Jsonb? @default("null") @@map("number_defaults") } @@ -310,13 +301,13 @@ withTempDir(({ createTempDir }) => { ); }); - describe('given list defaults with an element that has no PSL literal', () => { + describe('given a list default of timestamps', () => { const db = useDevDatabase({ onReady: (cs) => withClient(cs, (client) => client.query(RAW_LIST_DEFAULTS_SQL)), }); it( - 'infer prints them as dbgenerated, which emit rejects at each field', + 'infer prints each element as the literal its codec accepts, and the schema emits and verifies clean', async () => { const ctx = setupJourney({ connectionString: db.connectionString, @@ -330,21 +321,21 @@ withTempDir(({ createTempDir }) => { model RawListDefaults { id Int @id(map: "raw_list_defaults_pkey") - timestamps Timestamp(3)[]? @default(dbgenerated("ARRAY['2024-01-01 00:00:00'::timestamp(3) without time zone]")) @noCheck(elementNotNull) + timestamps Timestamp(3)[]? @default(["2024-01-01T00:00:00"]) @noCheck(elementNotNull) @@map("raw_list_defaults") } " `); - const emit = await runContractEmit(ctx, ['--json']); - expect(emit.exitCode, `contract emit\n${output(emit)}`).toBe(2); - expect(parseJsonOutput(emit).meta?.diagnostics).toEqual([ - expect.objectContaining({ - code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', - span: expect.objectContaining({ start: expect.objectContaining({ line: 6 }) }), - }), - ]); + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `contract emit\n${output(emit)}`).toBe(0); + + const verify = await runDbVerify(ctx, ['--schema-only', '--strict', '--json']); + expect( + parseJsonOutput(verify).schema.issues, + `db verify\n${output(verify)}`, + ).toEqual([]); }, timeouts.spinUpPpgDev, ); diff --git a/test/integration/test/cross-package/test-codec.ts b/test/integration/test/cross-package/test-codec.ts index 133f67a28832..e035b76e09a2 100644 --- a/test/integration/test/cross-package/test-codec.ts +++ b/test/integration/test/cross-package/test-codec.ts @@ -2,7 +2,12 @@ * Test-only helper that constructs a SQL-family `Codec` instance from author-side encode/decode functions. Replaces the legacy public `mkCodec()` factory (deleted under TML-2357); tests that need a stub codec for behavioural assertions instantiate one through this helper rather than going through `descriptor.factory(...)`. */ import type { JsonValue } from '@internal/contract/types'; -import type { CodecTrait } from '@internal/framework-components/codec'; +import { + type CodecTrait, + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, +} from '@internal/framework-components/codec'; import type { Codec, SqlCodecCallContext } from '@internal/sql-relational-core/ast'; type JsonRoundTripConfig = [TInput] extends [JsonValue] @@ -36,6 +41,20 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const encodeJson = (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue; + const decodeJson = (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput; + const encodePsl = (value: TInput): PslLiteral => { + const json = encodeJson(value); + if (typeof json === 'string') return { kind: 'string', text: json }; + if (typeof json === 'number') return { kind: 'number', text: String(json) }; + if (typeof json === 'boolean') return { kind: 'boolean', text: String(json) }; + return encodeJsonTextPsl(json); + }; + const decodePsl = (literal: PslLiteral): TInput => { + if (literal.kind === 'number') return decodeJson(Number(literal.text)); + if (literal.kind === 'boolean') return decodeJson(literal.text === 'true'); + return decodeJson(decodeJsonTextPsl(config.typeId, literal)); + }; return { id: config.typeId, encode: (value, ctx) => { @@ -52,7 +71,9 @@ export function defineTestCodec< return Promise.reject(error); } }, - encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, - decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + encodeJson, + decodeJson, + encodePsl, + decodePsl, } as Codec; } diff --git a/test/integration/test/mongo/interpreter.enum.test.ts b/test/integration/test/mongo/interpreter.enum.test.ts index 41c645b4ca30..fa73d4633ef4 100644 --- a/test/integration/test/mongo/interpreter.enum.test.ts +++ b/test/integration/test/mongo/interpreter.enum.test.ts @@ -47,6 +47,8 @@ const mongoCodecLookup: CodecLookup = { throw new Error(`expected string, got ${typeof j}`); return j; }, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, } as ReturnType; }, targetTypesFor: (id: string) => mongoTargetTypes[id], diff --git a/test/integration/test/mongo/migration-psl-authoring.test.ts b/test/integration/test/mongo/migration-psl-authoring.test.ts index 0a8f38d52120..822250acad9f 100644 --- a/test/integration/test/mongo/migration-psl-authoring.test.ts +++ b/test/integration/test/mongo/migration-psl-authoring.test.ts @@ -51,6 +51,8 @@ const mongoCodecLookup: CodecLookup = { decode: async (v: unknown) => v, encodeJson: (v: unknown) => v as JsonValue, decodeJson: (v: JsonValue) => v, + encodePsl: (value) => ({ kind: 'string', text: String(value) }), + decodePsl: (literal) => literal.text as never, }; }, targetTypesFor(id: string) { diff --git a/test/integration/test/number-defaults/psl-number-defaults.integration.test.ts b/test/integration/test/number-defaults/psl-number-defaults.integration.test.ts index c82f185e716f..9f9a2ab68042 100644 --- a/test/integration/test/number-defaults/psl-number-defaults.integration.test.ts +++ b/test/integration/test/number-defaults/psl-number-defaults.integration.test.ts @@ -213,15 +213,34 @@ describe('PSL number defaults keep every digit', () => { }); describe('PSL number defaults on codecs that do not hold numbers', () => { - it('fail emit on a Postgres bytea column, as before', async () => { - await expect( - authorSqlContractFromPsl('model Payload {\n id Int @id\n data Bytes @default(1234)\n}'), - ).rejects.toThrow('The first argument must be of type string'); + it('are reported at the @default on a Postgres bytea column, in the codec words', async () => { + const result = await authorSqlContractFromPsl( + 'model Payload {\n id Int @id\n data Bytes @default(1234)\n}', + ); + expect(result.ok).toBe(false); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_DEFAULT_LITERAL', + message: + 'Field "Payload.data": @default(1234) is not a value of pg/bytea@1: pg/bytea@1 reads a string literal; got a number 1234', + span: expect.objectContaining({ start: expect.objectContaining({ line: 3, column: 14 }) }), + }), + ]); }); - it('fail emit on a SQLite datetime column, as before', async () => { - await expect( - authorSqliteContractFromPsl('model Event {\n id Int @id\n at DateTime @default(0)\n}'), - ).rejects.toThrow('toISOString is not a function'); + it('are reported at the @default on a SQLite datetime column, in the codec words', async () => { + const result = await authorSqliteContractFromPsl( + 'model Event {\n id Int @id\n at DateTime @default(0)\n}', + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_DEFAULT_LITERAL', + message: + 'Field "Event.at": @default(0) is not a value of sqlite/datetime@1: sqlite/datetime@1 reads a string literal; got a number 0', + span: expect.objectContaining({ start: expect.objectContaining({ line: 3, column: 15 }) }), + }), + ]); }); }); diff --git a/test/integration/test/sql-orm-client/include-codecs.test.ts b/test/integration/test/sql-orm-client/include-codecs.test.ts index 9f5d8a87008a..b87ed46e49ec 100644 --- a/test/integration/test/sql-orm-client/include-codecs.test.ts +++ b/test/integration/test/sql-orm-client/include-codecs.test.ts @@ -12,6 +12,9 @@ import { CodecImpl, type CodecInstanceContext, type ColumnTypeDescriptor, + decodeStringPsl, + encodeStringPsl, + type PslLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; import { defineContract, field, model, rel } from '@internal/postgres/contract-builder'; @@ -57,6 +60,14 @@ class IncludedTextCodec extends CodecImpl< } return `decoded-json:${json}`; } + + encodePsl(value: string): PslLiteral { + return encodeStringPsl(value); + } + + decodePsl(literal: PslLiteral): string { + return decodeStringPsl(this.id, literal); + } } class IncludedTextDescriptor extends CodecDescriptorImpl { diff --git a/upgrade-instructions/pending/codec-psl-literals/extension/instructions.md b/upgrade-instructions/pending/codec-psl-literals/extension/instructions.md new file mode 100644 index 000000000000..6a4248b6cc0c --- /dev/null +++ b/upgrade-instructions/pending/codec-psl-literals/extension/instructions.md @@ -0,0 +1,46 @@ +--- +changes: + - id: codec-encode-psl-decode-psl + summary: | + `Codec` gains two required members, `encodePsl(value): PslLiteral` and `decodePsl(literal: PslLiteral): TInput`, + abstract on `CodecImpl` and required in the `mongoCodec({...})` config. Every codec class and every + `mongoCodec` call must implement them, using the shared pairs exported from + `@internal/framework-components/codec`; a class without them no longer compiles. + detection: + glob: "**/*.{ts,mts,cts}" + matches: + - 'extends\s+CodecImpl\s*<' + - 'mongoCodec\s*\(\s*\{' +--- + +## `codec-encode-psl-decode-psl` + +A codec now owns the PSL literal that denotes its values in schema source (`@default(...)`). `PslLiteral` is `{ kind: 'string' | 'number' | 'boolean', text }` with the fence removed and escapes resolved; a number's digits arrive as written. Add the two members to every class that `extends CodecImpl<...>` and to every `mongoCodec({...})` config, choosing the pair by the codec's JSON form: + +- JSON form is a string and the value is that string: `encodeStringPsl(value)` / `decodeStringPsl(this.id, literal)`. +- JSON form is a string but the value is not (bytes, a Temporal or Date value, a HEXEWKB geometry, an ISO duration): `encodeStringPsl(this.encodeJson(value))` / `this.decodeJson(decodeStringPsl(this.id, literal))`. Declare `encodeJson`'s return type as `string` so the first call typechecks. +- JSON form is a number and the value is a JavaScript number: `encodeNumberPsl(value)` / `decodeNumberPsl(this.id, literal)`; an integer codec reads `Number(decodeWholeNumberPsl(this.id, literal))` so a fraction is refused. +- JSON form is a boolean: `encodeBooleanPsl(value)` / `decodeBooleanPsl(this.id, literal)`. +- JSON form is an object, array, or null: `encodeJsonTextPsl(this.encodeJson(value))` / `this.decodeJson(decodeJsonTextPsl(this.id, literal))`. + +```ts +import { + decodeJsonTextPsl, + encodeJsonTextPsl, + type PslLiteral, +} from '@internal/framework-components/codec'; + +class PgVectorCodec extends CodecImpl<'pg/vector@1', readonly ['equality'], string, number[]> { + // ... encode, decode, encodeJson, decodeJson unchanged + + encodePsl(value: number[]): PslLiteral { + return encodeJsonTextPsl(this.encodeJson(value)); + } + + decodePsl(literal: PslLiteral): number[] { + return this.decodeJson(decodeJsonTextPsl(this.id, literal)); + } +} +``` + +For a Mongo codec, pass the same pair as `encodePsl` and `decodePsl` members of the `mongoCodec({...})` config; there is no default. Each decode helper takes the codec id so its error names the codec, and the PSL interpreter reports that message as `PSL_INVALID_DEFAULT_LITERAL` at the `@default` attribute.