diff --git a/docs/architecture docs/ADR-INDEX.md b/docs/architecture docs/ADR-INDEX.md index 09753bd10f8b..689cedafe482 100644 --- a/docs/architecture docs/ADR-INDEX.md +++ b/docs/architecture docs/ADR-INDEX.md @@ -36,6 +36,7 @@ This document provides a comprehensive index of all Architectural Decision Recor | 249 | Central attribute-spec registry | Registers every model-level and field-level PSL attribute of both families in one place, keyed by level and name, as a spec *factory* over a framework-owned construction-time context (`AttributeSpecContext` / `FieldAttributeSpecContext`) — the uniform signature is what lets the language server invoke the same factories the interpreters run. `assembleAttributeSpecs` merges family built-ins with [ADR 236](adrs/ADR%20236%20-%20Target-contributed%20model%20attributes.md)'s model-attribute descriptors into frozen plain records and restores the factory types core erases (they return `AttributeSpec`, because `refine` makes `Out` contravariant and `unknown` would reject every spec that refines). Registry keys drive unknown-attribute diagnostics at field and model level in both families; block attributes are declared on `AuthoringPslBlockDescriptor.attributes` and parsed by the kit. | [ADR 249 - Central attribute-spec registry.md](adrs/ADR%20249%20-%20Central%20attribute-spec%20registry.md) | | 250 | Models and views are emitted from the contract | `contract.d.ts` gains a `Models` namespace (one member per model, `_`, plus `_Any` for each polymorphic base) and a type-only `models` constant; relations are typed as the related member with a phantom `RelationKeys` key so `Scalars` names a default fetch's row and `Shape` names an application data structure derived from the model, and both ORM collections carry `_row` so `ResultType` names any query's result. To-one nullability is a family hook (SQL reads foreign keys and column nullability; Mongo takes the nullable default); name collisions and non-identifier names are emitter errors | [ADR 250 - Models and views are emitted from the contract.md](adrs/ADR%20250%20-%20Models%20and%20views%20are%20emitted%20from%20the%20contract.md) | | 253 | PSL red-root source ownership | A PSL parse registers the actual returned red document root with a named `SourceFile`; post-parse diagnostics resolve filenames from the node's owning root through `PslSources`, with no unnamed parse mode or singleton fallback. Output diagnostics may still serialize a `sourceId`, but it originates from `SourceFile.filename`; file-read errors and Prisma7 compatibility plumbing are explicit exceptions. | [ADR 253 - PSL red-root source ownership.md](adrs/ADR%20253%20-%20PSL%20red-root%20source%20ownership.md) | +| 254 | Literal types for column defaults | Every literal column default has a literal type (`string`, `boolean`, the whole-number types by size `i8`–`i64`, `bigint`, `decimal`, `float`, `json`, or a list of those), so a codec descriptor names the types it accepts and gains no methods; a written number's type comes from its own size and precision, never from the column. A codec converts between a named type's value shape and its own stored form inside `decodeJson`. PSL writes a literal as a plain scalar or as an ADR 129 tagged literal (`` json`...` ``); `sql` writes a raw SQL expression instead. Codecs never see PSL syntax. Replaces the PSL half of ADR 184 | [ADR 254 - Literal types for column defaults.md](adrs/ADR%20254%20-%20Literal%20types%20for%20column%20defaults.md) | ## Query System 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..4ad515d82378 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 @@ -2,6 +2,8 @@ > **Retrospective note.** This ADR's examples use the `defineCodec({...})` factory. That factory was the canonical codec-author surface at the time; it was later retired in favor of class-based authoring: concrete codecs extend `CodecImpl`, descriptors extend `CodecDescriptorImpl`, and per-codec column helpers tie helpers to descriptors with `satisfies`. The ADR's *decision* — that codecs own both wire and JSON-safe representations through `encode` / `decode` + `encodeJson` / `decodeJson` — is unchanged; only the authoring shape has moved on. See [ADR 208 — Higher-order codecs for parameterized types](ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) and the [Codec authoring guide](../../reference/codec-authoring-guide.md) for the current shape. +> **PSL half: see [ADR 254 — Literal types for column defaults](ADR%20254%20-%20Literal%20types%20for%20column%20defaults.md).** The `PslLiteralCodec` interface sketched below is replaced there: codec descriptors name the literal types they are compatible with, codecs gain no methods, and codecs never receive PSL syntax. The JSON half of this ADR is unaffected. + ## At a glance A column with `codecId: "pg/timestamptz@1"` has a default value of `new Date('2024-01-15')` — a JavaScript `Date`. This value has to survive a round-trip through `contract.json`, but `Date` has no JSON representation. The codec handles it: diff --git a/docs/architecture docs/adrs/ADR 254 - Literal types for column defaults.md b/docs/architecture docs/adrs/ADR 254 - Literal types for column defaults.md new file mode 100644 index 000000000000..f85af0274b0d --- /dev/null +++ b/docs/architecture docs/adrs/ADR 254 - Literal types for column defaults.md @@ -0,0 +1,218 @@ +# ADR 254 — Literal types for column defaults + +Status: **Accepted** + +## Decision + +Every literal column default has a **literal type**. A codec declares the literal types its columns are compatible with, by name and nothing more. PSL is one way of writing a literal of a given type, and a codec never sees PSL syntax. + +```prisma +model Account { + id Int @id + name String @default("anonymous") + balance BigInt @default(9007199254740993) + price Decimal @default(1.50) + active Boolean @default(true) + meta Jsonb @default(json`{ "plan": "free", "seats": 1 }`) + expires DateTime @default(sql`now() + interval '3 days'`) +} +``` + +Reading that model: + +- `"anonymous"`, `9007199254740993`, `1.50`, and `true` are plain PSL scalars. They write a `string` literal, an `i64` literal (the smallest whole-number type that holds those digits), a `decimal` literal, and a `boolean` literal. +- `` json`...` `` is a tagged literal, the syntax [ADR 129](ADR%20129%20-%20Template-Tagged%20Literals%20for%20Extensions.md) defines: a tag followed by a string. The tag `json` names the literal type, and the string holds the JSON document. In backticks it needs no escaping and may span several lines. +- `` sql`now() + interval '3 days'` `` is also a tagged literal, but `sql` does not name a literal type. It writes a raw SQL expression, which becomes a function default the database evaluates. No codec is consulted. + +Each literal type fixes the value shape it produces, and a codec that stores a different shape converts inside the `decodeJson` it already has, so a codec needs no new methods. Writing `` @default(json`{}`) `` on an `Int` column is an error: `pg/int4@1 is not compatible with a json literal; it accepts i8, i16, i32 literals`. + +This ADR replaces the PSL half of [ADR 184](ADR%20184%20-%20Codec-owned%20value%20serialization.md), which sketched `encodePsl` and `decodePsl` methods on codecs. The JSON half of ADR 184 is unchanged. + +## Why literal types + +### A codec cannot be asked "is this literal yours?" without a type to answer about + +In SQL a codec can represent almost anything: a number, a document, a geometry, a vector, a timestamp. When a schema writes a default, something has to decide whether the written value suits the column. If the only information available is the characters the author typed, the codec has to try to read them and report failure by throwing. That makes compatibility a side effect of decoding, and the error the author sees is whatever the codec happened to throw. + +A literal type turns the question into a lookup. The literal says what kind of value it is. The codec says which kinds it accepts. A mismatch is reported before anything is decoded, with a message that names the codec and the literal types it accepts. + +### A codec should not depend on PSL + +Codecs live below every authoring surface. PSL is one surface; the schema language of earlier Prisma versions, read by the contract source in [ADR 252](ADR%20252%20-%20An%20earlier%20Prisma%20version's%20schema%20is%20a%20contract%20source.md), is another. If a codec received PSL text, or the PSL parser's idea of what a token is, every codec would be coupled to PSL's quoting, escaping, and tokenizer rules. A change to PSL syntax would become a change to every codec. + +With literal types, each authoring surface maps its own syntax to literals, and codecs only ever receive a literal of a type they declared. PSL can gain new tags without any codec changing. + +### The literal types are cut where the stored representations are cut + +The contract stores a literal default in the column codec's JSON form ([ADR 184](ADR%20184%20-%20Codec-owned%20value%20serialization.md)). Codecs that hold numbers do not share one JSON form: + +| Column | Codec | Written | Stored in `contract.json` | +|---|---|---|---| +| `Int` | `pg/int4@1` | `42` | `42` | +| `BigInt` | `pg/int8@1` | `9007199254740993` | `"9007199254740993"` | +| `Decimal` | `pg/numeric@1` | `1.50` | `"1.50"` | + +A single `number` literal type would have to be converted per codec, which puts conversion code on every numeric codec. Instead the literal types are cut where those representations are cut, and the whole-number types are cut again by width — `i8`, `i16`, `i32`, `i64`, then `bigint` — so that a number too large for its column is a type the column does not accept rather than a value it fails to decode. The declaration is then a list of names, and the rules for whole numbers, decimal canonicalisation, and digit preservation are written once, in the literal type, rather than once per codec. + +## The literal types + +A literal type defines what its value is, how a written literal is read into that value, and how a stored value is written back. It is defined in the framework, so a codec descriptor in any family can name it. + +| Literal type | Written as | Value it produces | Named by | +|---|---|---|---| +| `string` | A string scalar | The text, with escapes resolved | Text, uuid, inet, bit and varbit, bytes as base64, geometry as hex, intervals, timestamps and dates as their text form | +| `boolean` | `true` / `false` | `true` or `false` | Boolean codecs | +| `i8` | A whole number in [-128, 127] | A JSON number | Every integer codec | +| `i16` | A whole number in [-32768, 32767] not already `i8` | A JSON number | `pg/int2@1` and wider | +| `i32` | A whole number in the signed 32-bit range not already smaller | A JSON number | `pg/int4@1`, `pg/int@1`, `sql/int@1` and wider | +| `i64` | A whole number in the signed 64-bit range not already smaller | The digits as text, because a JSON number rounds past 2^53 | `pg/int8@1`, `pg/int8number@1`, `sqlite/integer@1`, `sqlite/bigint@1`, `sqlite/bigintnumber@1` and wider | +| `bigint` | Any larger whole number | The digits as text | `pg/unboundedint@1`, and every codec over `numeric` or a float | +| `decimal` | A number with a fraction | Decimal text. Trailing zeros are kept, leading zeros and the sign of zero are removed | `pg/numeric@1`, `pg/float4@1`, `pg/float8@1`, `pg/float@1`, `sql/float@1`, `sqlite/real@1` | +| `float` | `NaN`, `Infinity`, `-Infinity` | That text | `pg/numeric@1`, `pg/float4@1`, `pg/float8@1` | +| `json` | A `json` tag body | The parsed JSON value | `pg/json@1`, `pg/jsonb@1`, `sqlite/json@1`, `arktype/json@1` | + +A declaration may also name **a list of element types**, `{ list: [...] }`, which is how a column that is not a list takes a PSL list: `pg/vector@1` names a list of the whole-number and `decimal` types, so a vector column takes `@default([0.1, 0.2, 0.3])`. + +Two rules keep the values faithful. A number is never converted to a JavaScript number unless its literal type says so, because converting `9007199254740993` rounds it and converting `1.50` drops the trailing zero a `numeric` column keeps. And a `json` literal's body is parsed as JSON once, by the literal type, so codecs receive the JSON value rather than text they must parse. + +## Writing a literal in PSL + +PSL has two ways to write a literal, and both produce the same literal. + +**Plain scalars** write `string`, `boolean`, and the numeric literal types, and need no tag. + +**Tagged literals** write any literal type, including ones with no plain scalar. A tagged literal is a tag followed by a string in any of PSL's three quote characters; its escapes and the canonical body (line endings normalised, the common indentation removed) are those of [ADR 129](ADR%20129%20-%20Template-Tagged%20Literals%20for%20Extensions.md). Tags are registered in `ControlMutationDefaults.defaultLiteralTagRegistry`, whose entry for a tag says which literal type it writes, and they follow ADR 129's prefixing rules. Each SQL target registers the `json` tag, with no prefixed alias. A tag that no pack in the contract's stack registers is `PSL_UNKNOWN_DEFAULT_LITERAL_TAG`, and the message lists the registered tags. + +The `sql` tag is the one tag that does not write a literal type. Its body is a SQL expression, not a value of the column's type, so it lowers to a function default (`{ kind: 'function', expression }`) on any column, and no codec compatibility applies. + +The syntax tree keeps exactly what the author wrote. The formatter and the language server work from the tree; only the interpreter turns scalars and tagged literals into literals. + +## Codecs declare compatible literal types + +A codec descriptor names the literal types its columns are compatible with. This is static metadata, next to `traits` and `targetTypes` on `CodecDescriptor`: it depends only on the codec id, never on a particular column's parameters. It carries no functions — a declaration is a list of names, and turning a named type's value into the codec's own form is work `decodeJson` already does. + +```ts +class PgJsonbDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes = ['json'] as const; +} + +class PgInt4Descriptor extends PostgresCodecDescriptor { + override readonly literalTypes = integerLiteralTypesUpTo('i32'); +} + +class PgVectorDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes = [ + { list: [...integerLiteralTypesUpTo('i64'), 'bigint', 'decimal'] }, + ] as const; +} +``` + +`integerLiteralTypesUpTo(name)` gives the chain from `i8` up to and including `name`, so a descriptor does not spell it out. + +The declaration is optional. A codec that names no literal type accepts no literal defaults, and its columns take raw SQL defaults only. + +**A codec converts between the shapes it names and its own stored form, inside its existing `decodeJson`.** The literal type fixes the shape of the value it produces, and a codec that stores a different shape is the one that knows how to convert: `pg/int8@1` stores digit text and names `i8` to `i64`, so its `decodeJson` accepts a whole JSON number as well as the text. No codec gains a method, and no contract source branches per codec. + +The codec instance keeps the checks that depend on column parameters. The interpreter builds the codec from the descriptor with the column's own `typeParams` and passes the literal type's value to `decodeJson`, so a `vector(3)` column given two elements is refused there, with the vector codec's own message. + +## Reading a default + +Each step has one owner. + +1. **PSL parser.** Parses the `@default(...)` argument into a scalar or a tagged literal node, recording the source span. +2. **Interpreter.** Turns the node into a written literal, independent of the source language: the text of a string, the digits of a number as written, a boolean, the body of a tag, or a list of those. A `sql` tagged literal becomes a function default and stops here. +3. **Literal types.** Reading the written literal gives its type and its value together: a number's type comes from its own size and precision, a tag's from the type its tag names. Text no literal type reads — a number with an exponent, a `json` body that is not a JSON document — is refused here. +4. **Compatibility.** If the literal's type is not one the column's codec declares, the interpreter reports an error naming the codec, the literal's type, and the types the codec accepts. Nothing has been decoded. +5. **Codec instance.** `decodeJson` converts the literal type's value into the codec's own form and checks it against the column. A value it refuses is reported with the codec's message. +6. **Contract.** The default is stored as `{ kind: 'literal', value }` in the codec's JSON form, like every literal default. + +Other text-based contract sources follow the same steps from their own syntax. The reader for the earlier Prisma schema language ([ADR 252](ADR%20252%20-%20An%20earlier%20Prisma%20version's%20schema%20is%20a%20contract%20source.md)) turns that language's defaults into literals of a type and checks them against the same declarations. The TypeScript contract builder is not a text source: `.default(value)` passes a value of the codec's own type, and TypeScript's types do the compatibility check. + +## Printing a default + +`contract infer` runs the steps in reverse for each introspected literal default: + +1. The target reads the database's default into the codec's JSON form. +2. The printer takes the literal type the column's codec declares and asks it to write that value. +3. A literal type with a plain scalar prints as that scalar. Any other prints as a tagged literal with the tag that writes it. A `json` body always uses the backtick fence, escaping backslashes and backticks: a quote-fenced tagged literal resolves the full PSL string escapes, so switching fences would change what a body containing `\n` reads back as. +4. **The printer passes what it wrote back through the column's codec's `decodeJson`** before printing it. A literal type says what a value is written as, not that the codec accepts every value of that shape — the temporal codecs name `string` but refuse `infinity`, which PostgreSQL stores and reports verbatim. +5. When the codec names no literal type, the literal type cannot write the value, or the codec does not read it back, the printer writes the database's expression as a raw SQL default instead. Infer never drops a default. + +A printed schema therefore reads back to the same contract, because printing and reading pass through the same literal type. + +The printer needs the codec bound to each PSL type name it prints. That binding lives in the adapter's authoring type namespaces, which sit above the target package the printer is in, so the target restates it for the type names it prints and a test in the adapter fails if the two disagree — the same shape as any other restated invariant, with the check that keeps it honest. + +## Responsibilities + +| Layer | Owns | +|---|---| +| PSL parser | Scalars and tagged literal nodes, spans, canonicalisation of tagged bodies | +| Tag registry | Which literal type each tag writes; which tag writes raw SQL | +| Literal types | The value each literal holds, reading a written literal into it, and writing a stored value back | +| Interpreter and other text sources | Mapping their syntax to written literals; wording the refusals in their own diagnostics | +| Codec descriptor | The names of the compatible literal types | +| Codec instance | `decodeJson`: converting each named type's value shape into the codec's own form, and the checks that depend on column parameters | +| Contract | The JSON form, unchanged from [ADR 184](ADR%20184%20-%20Codec-owned%20value%20serialization.md) | + +## How a plain scalar picks its literal type + +**Settled: from the number itself, never from the column.** + +A written number's literal type comes from its own size and precision. `42` is an `i8` on every column, `100000000000000099` an `i64`, `1.50` a `decimal`, `NaN` a `float`. The whole-number types are cut by width — `i8`, `i16`, `i32`, `i64`, then `bigint` for anything larger — and a number takes the smallest type that holds it, decided by comparing its digits as a `BigInt`, so no classification passes through a JavaScript number. + +One syntax then names one literal type, the compatibility check is a lookup with no trial decoding, and a value too large for its column is reported as an incompatibility before anything is decoded: `Int @default(100000000000000099)` says `pg/int4@1 is not compatible with an i64 literal; it accepts i8, i16, i32 literals`. + +This is why a codec names a *chain* of types rather than one: `pg/int4@1` names `i8` to `i32`, `pg/int8@1` names `i8` to `i64`. The cost is that a codec whose stored shape differs from a named type's shape converts between them, which the section above makes its job. + +## Settled details + +- **The declaration is optional**, and Mongo codecs name nothing, because no Mongo contract source reads defaults from text. +- **A JSON column is not compatible with a `string` literal.** `Jsonb @default("{}")` is an error that asks for `` json`{}` ``. The reader for the earlier Prisma schema language turns that language's quoted JSON into a `json` literal itself. +- **A decimal column is not compatible with a `string` literal.** `Decimal @default("1.50")` is an error, and the default is written `1.50`. +- **`NaN`, `Infinity`, and `-Infinity` are `float` literals**, because the PSL tokenizer reads them as numbers, and they are written and printed bare — a quoted `"NaN"` is a `string` literal, which no numeric codec accepts. The integer literal types refuse them. +- **`sqlite/real@1`, `sql/float@1` and `pg/float@1` do not name `float`**, because their `decodeJson` refuses non-finite values. `Real @default(NaN)` on SQLite is therefore an incompatibility reported at the attribute, not a decode failure at emit. +- **JSON null is a value.** `` Json @default(json`null`) `` stores JSON null. +- **Enum columns are unchanged.** Their default is a bare member name, and enum codecs name no literal types. +- **List columns keep PSL's list syntax.** Each element is a literal checked against the element codec's declaration, so `` Jsonb[] @default([json`{}`, json`[]`]) `` is valid and `Int[] @default([1, "x"])` is refused, naming the second element. +- **A list literal on a scalar column needs a `{ list }` declaration.** `` Jsonb @default([1, 2]) `` is an incompatibility, because `pg/jsonb@1` names only `json`; the JSON array is written `` json`[1, 2]` ``. A vector column accepts one because `pg/vector@1` names a list of element types. +- **A diagnostic inside a list names the failing element in its message** (`Field "N.scores" at element 2: ...`) and is reported at the `@default(...)` attribute, because the attribute-spec layer carries no span for a string, number or boolean argument. +- **Diagnostics.** A literal whose type the codec does not declare is `PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE`. Text a literal type cannot read, and a value a codec refuses, are `PSL_INVALID_DEFAULT_LITERAL` with the reason. A `json` body that is not valid JSON is `PSL_INVALID_JSON_LITERAL`. Each diagnostic points at the literal. + +## Alternatives considered + +### Codec methods that receive the parser's classification + +Codecs gain `encodePsl(value)` and `decodePsl(literal)`, where the literal is `{ kind: 'string' | 'number' | 'boolean', text }` produced by the PSL parser. This is close to the interface ADR 184 sketched. + +Rejected. The input to every codec becomes the PSL tokenizer's view of the source, which couples codecs to PSL. A JSON document can only arrive as a string with its quotes escaped. And there is still no compatibility check: a codec discovers that a literal is not for it by failing to decode it. + +### One `number` literal type, converted per codec + +A single `number` literal type carries the digits as text, and each numeric codec declares a read function to its own JSON form and a write function back. + +Rejected. Every numeric codec carries conversion code that duplicates what its `decodeJson` already does, and the rules for whole numbers, decimal canonicalisation, and digit preservation are written once per codec instead of once per literal type. + +### Codecs receive the raw argument text + +Whatever is written between `@default(` and `)` goes to the column codec as text, and the codec parses it. + +Rejected. The parser would need an unparsed argument form that exists for `@default` alone, and the formatter and language server would need to understand it. Every codec would reimplement PSL quoting and escaping. And a bare word such as `ACTIVE` could be an enum member or a string the codec reads, with nothing to tell them apart. + +### Try each JSON form until the codec accepts one + +A number literal is offered to the codec as a JSON number first, then as text, and the first form `decodeJson` accepts wins. + +Rejected. Compatibility is again discovered by failure, a value that two forms both decode is ambiguous, and the error the author sees comes from the last attempt rather than from the actual mismatch. + +### A separate registry of PSL converters keyed by codec id + +The PSL conversions live in a registry beside the codecs, as ADR 184's `PslLiteralCodec` interface suggested. + +Rejected. The codec descriptor is already the codec-id-keyed home for a codec's static metadata. A second registry would hold the same kind of information in a second place and could drift from the codecs it describes. + +## Related + +- [ADR 184 — Codec-owned value serialization](ADR%20184%20-%20Codec-owned%20value%20serialization.md): codecs own the JSON form of values. Its JSON half stands; this ADR replaces its PSL half. +- [ADR 129 — Tagged literals carry raw SQL and other pack-owned text in PSL](ADR%20129%20-%20Template-Tagged%20Literals%20for%20Extensions.md): the tagged literal syntax, the canonical body, and tag registration. This ADR adds that a tag writes either a literal of a literal type or, for `sql`, a raw SQL expression. +- [ADR 252 — An earlier Prisma version's schema is a contract source](ADR%20252%20-%20An%20earlier%20Prisma%20version's%20schema%20is%20a%20contract%20source.md): a second text contract source that maps its own syntax to literals. +- [ADR 167 — Typed default literal pipeline and extensibility](ADR%20167%20-%20Typed%20default%20literal%20pipeline%20and%20extensibility.md): historical context for typed literal defaults. diff --git a/docs/reference/codec-authoring-guide.md b/docs/reference/codec-authoring-guide.md index 3208932121ac..a657f8926347 100644 --- a/docs/reference/codec-authoring-guide.md +++ b/docs/reference/codec-authoring-guide.md @@ -377,6 +377,44 @@ The descriptor is the only place a target's behaviour for a codec is declared. ` An identity `jsonProjection` is a claim, not a placeholder: it says this codec's stored form *is* its canonical JSON, as it is for `pg/text@1` and `pg/int4@1`. Write one only when that holds. A codec whose stored form cannot survive JSON — a wide integer, a byte string, a value whose text depends on a session setting — needs a projection that converts it, because the renderer will ask and then use the answer. +## Literal defaults a codec accepts + +A written `@default` literal has a type of its own, decided by what was written rather than by the column: `string`, `boolean`, the whole-number types by size — `i8`, `i16`, `i32`, `i64`, `bigint` — `decimal`, `float` for `NaN` and the infinities, and `json`. A descriptor names the ones its columns take in `literalTypes`, and the contract source checks the written literal's type against that list before anything is decoded, so a value too large for the column is refused with a diagnostic instead of a decode failure. + +```ts +class PgInt4Descriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = + integerLiteralTypesUpTo('i32'); + // … +} +``` + +`integerLiteralTypesUpTo(name)` gives the chain from `i8` up to and including `name`, so a descriptor does not spell it out. A declaration may also name a list of element types, which is how a column that is not a list takes a PSL list: + +```ts +class PgVectorDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = [ + { list: [...integerLiteralTypesUpTo('i64'), 'bigint', 'decimal'] }, + ]; + // … +} +``` + +Each literal type fixes the shape of the value it produces: `i8`, `i16` and `i32` give a JSON number, `i64` and `bigint` give digit text (a JSON number rounds past 2^53), `decimal` gives decimal text with its trailing zeros, `float` gives the word, and `json` gives the parsed document. **A codec's `decodeJson` must accept the value shape of every type it names**, in addition to its own JSON form. `pg/int8@1` stores digit text and names `i8` to `i64`, so its `decodeJson` takes a whole JSON number as well as the text: + +```ts +decodeJson(json: JsonValue): bigint { + if (typeof json !== 'string' && typeof json !== 'number') { + throw postgresError(/* … */); + } + return pgInt8Decode(json); +} +``` + +Converting between those shapes is the codec's job, not the interpreter's — there is no per-type code and no per-codec branch in any contract source. A codec that names nothing accepts no literal default at all; its columns take only a `` sql`...` `` default. `contract infer` runs the same declaration backwards to choose the literal it prints, and checks that what it wrote reads back through `decodeJson` before printing it. + +See [ADR 254](../architecture%20docs/adrs/ADR%20254%20-%20Literal%20types%20for%20column%20defaults.md). + ## `satisfies` discipline The framework exports two helper-shape constraints: diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 7d0df3a2ff85..11f26016cac4 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -541,7 +541,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, or one the column's codec does not accept. The message is `Field ".": @default `, where the reason is one of: `holds literal, which does not accept; it accepts .` for a literal of the wrong type, and `... at element , ...` when it is one element of a list; `holds text that this contract source does not read: ` for a `Json` default whose text is not a JSON document; `holds a value that does not read: ` for a literal the codec declares but refuses; plus the reasons that do not involve the value's type — an unknown function, an enum member on a non-enum field or a non-member, and `dbgenerated()` with no expression on a required field. Use a literal of a type the column accepts, 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 @@ -567,6 +567,18 @@ A backtick string appears somewhere other than after a tag, for example `` @map( A `@default` tagged literal uses a tag no pack in the stack registered: `Unknown literal tag "". Known tags: .` Every SQL target registers `sql`; Postgres also registers `pg.sql` and SQLite `sqlite.sql`. Reported at the literal when the default is lowered. +### PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE + +A `@default` literal has a type the column's codec does not accept: `Field ".": is not compatible with literal; it accepts `. A written literal has a type of its own — a number's comes from its size and precision, so `42` is an `i8` and `100000000000000099` an `i64` — and a codec names the types it takes. Inside a list literal the message names the element: `Field "." at element 2: ...`. A codec that names none reads `it accepts no literal defaults`, and takes only a `` sql`...` `` default. Reported at the `@default` attribute. See [ADR 254](../architecture%20docs/adrs/ADR%20254%20-%20Literal%20types%20for%20column%20defaults.md). + +### PSL_INVALID_DEFAULT_LITERAL + +A `@default` literal the column's codec accepts by type but refuses to decode, such as a `pgvector.Vector(3)` column given two elements: `Field ".": `, or ` at element ` when it is one element of a list. Also reported for a literal no contract source can write, such as a list inside a list: `A list literal cannot contain another list.` Reported at the `@default` attribute. + +### PSL_INVALID_JSON_LITERAL + +A `` @default(json`...`) `` body is not a JSON document: `Field ".": `. Reported at the `@default` attribute. + ### PSL_TAGGED_LITERAL_NUL A tagged literal's body contains a NUL character: `Tagged literals must not contain NUL characters.` Reported at the literal when the default is lowered. 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..ba8a78191cc6 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 @@ -26,6 +26,26 @@ export type { ColumnTypeDescriptor, } from '../shared/column-spec'; export { column } from '../shared/column-spec'; +export { jsonDefaultLiteralTagEntry } from '../shared/json-default-literal-tag'; +export type { + Literal, + LiteralRefusal, + LiteralTypeDeclaration, + LiteralTypeName, + ReadLiteralResult, + ScalarLiteral, + WrittenLiteral, +} from '../shared/literal-types'; +export { + describeDeclarations, + integerLiteralTypesUpTo, + isCompatible, + isNonFiniteText, + isNumeralText, + readLiteral, +} from '../shared/literal-types'; +export type { WrittenLiteralText } from '../shared/literal-types-write'; +export { escapePslString, writeLiteral } from '../shared/literal-types-write'; export { renderTsLiteral } from '../shared/render-ts-literal'; export { CONTRACT_CODEC_DESCRIPTOR_MISSING, diff --git a/packages/1-framework/1-core/framework-components/src/exports/control.ts b/packages/1-framework/1-core/framework-components/src/exports/control.ts index a9edd69c8fcc..417298a75dbe 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/control.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/control.ts @@ -128,7 +128,9 @@ export type { export { dispositionForCategory } from '../control/verifier-disposition'; export type { ControlDefaultLiteralTagEntry, + ControlDefaultLiteralTagLoweringEntry, ControlDefaultLiteralTagRegistry, + ControlDefaultLiteralTagTypeEntry, ControlDefaultRegistries, ControlMutationDefaultEntry, ControlMutationDefaultRegistry, @@ -142,6 +144,7 @@ export type { TaggedLiteralValue, TypedDefaultFunctionCall, } from '../shared/mutation-default-types'; +export { isDefaultLiteralTagLoweringEntry } from '../shared/mutation-default-types'; export type { TaggedLiteralCanonicalization } from '../shared/tagged-literal'; export { canonicalizeTaggedLiteralBody, diff --git a/packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts b/packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts index 1746b2bc2aba..57aa72f9d95a 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts @@ -12,6 +12,7 @@ import type { JsonValue } from '@internal/contract/types'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { Codec } from './codec'; import { type CodecInstanceContext, type CodecTrait, voidParamsSchema } from './codec-types'; +import type { LiteralTypeDeclaration } from './literal-types'; /** * Unified codec descriptor. Every codec in the framework registers through this shape — non-parameterized codecs use `P = void` and a constant factory that returns the same shared codec instance for every column; parameterized codecs use a non-empty `P` and a curried higher-order factory that returns a per-instance codec. @@ -31,6 +32,8 @@ export interface CodecDescriptor

{ readonly traits: readonly CodecTrait[]; /** Database-native type names this codec handles (e.g. `['timestamptz']`). */ readonly targetTypes: readonly string[]; + /** The literal types this codec's columns accept as a `@default(...)` literal, by name. A codec that names none accepts no literal default. The codec's `decodeJson` accepts the value shape of every type named here in addition to its own JSON form. ADR 254. */ + readonly literalTypes?: readonly LiteralTypeDeclaration[] | undefined; /** Standard Schema validator for the factory's params. Validates JSON-sourced params at the contract boundary (PSL → IR; `contract.json` → runtime). For non-parameterized codecs (`P = void`), the schema validates `void`/`undefined` — the framework supplies no params at the call boundary. */ readonly paramsSchema: StandardSchemaV1

; /** Whether this descriptor is parameterized — i.e. its `paramsSchema` is something other than the singleton `voidParamsSchema`. Consumers that need to gate column-aware dispatch read this directly rather than threading a free-floating `(codecId) => boolean` callback. */ @@ -74,6 +77,9 @@ export abstract class CodecDescriptorImpl implements CodecDescri abstract readonly traits: readonly CodecTrait[]; abstract readonly targetTypes: readonly string[]; + /** Optional literal types this codec's columns accept. See {@link CodecDescriptor.literalTypes}. */ + readonly literalTypes?: readonly LiteralTypeDeclaration[] | undefined; + abstract readonly paramsSchema: StandardSchemaV1; /** Boolean derived from `paramsSchema`: `true` whenever the schema is not the singleton `voidParamsSchema`. */ diff --git a/packages/1-framework/1-core/framework-components/src/shared/json-default-literal-tag.ts b/packages/1-framework/1-core/framework-components/src/shared/json-default-literal-tag.ts new file mode 100644 index 000000000000..96d7e1b512ab --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/shared/json-default-literal-tag.ts @@ -0,0 +1,13 @@ +import type { ControlDefaultLiteralTagTypeEntry } from './mutation-default-types'; + +/** + * The `` json`...` `` default literal every target that stores JSON registers. The body is read as + * a JSON document and checked against the column's codec like any other literal. + */ +export function jsonDefaultLiteralTagEntry(): ControlDefaultLiteralTagTypeEntry { + return { + usage: 'json`...`', + documentation: "Reads the body as a JSON document and stores it as the column's default.", + literalType: 'json', + }; +} diff --git a/packages/1-framework/1-core/framework-components/src/shared/literal-types-write.ts b/packages/1-framework/1-core/framework-components/src/shared/literal-types-write.ts new file mode 100644 index 000000000000..309ed76c4439 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/shared/literal-types-write.ts @@ -0,0 +1,134 @@ +/** + * Writing a stored value back as the literal a contract source wrote. The inverse of + * {@link readLiteral}: a codec's declarations are tried in order and the first type that accepts + * the value produces the literal's source text. + * + * ADR 254. + */ + +import type { JsonValue } from '@internal/contract/types'; +import { + classifyNumberText, + type LiteralTypeDeclaration, + type LiteralTypeName, +} from './literal-types'; + +/** + * One literal as source text. `text` is the complete literal, including the tag and its fence when + * the type is written as a tagged literal; `tag` names that type so a caller can tell the two apart + * without re-parsing. + */ +export interface WrittenLiteralText { + readonly text: string; + readonly tag?: LiteralTypeName; +} + +/** 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)}`; +} + +/** A string as PSL source writes it, with the escapes its string decoder resolves. */ +export function escapePslString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r'); +} + +/** The text of a number-shaped value as a contract source would have written it. */ +function numeralText(value: JsonValue): string | undefined { + if (typeof value === 'number') return plainNumeral(value); + return typeof value === 'string' ? value : undefined; +} + +function writeNumber(value: JsonValue, type: LiteralTypeName): string | undefined { + const text = numeralText(value); + if (text === undefined) return undefined; + const literal = classifyNumberText(text); + if (literal === undefined || literal.type !== type) return undefined; + return String(literal.value); +} + +/** + * A json body inside a backtick fence, which resolves `` \` `` and `\\` and nothing else — so a + * `\n` in the JSON text survives as the two characters JSON wrote. A quote fence would resolve the + * full PSL string escapes and change what the body reads back as, so it is never used. + */ +function writeJsonTag(value: JsonValue): WrittenLiteralText { + const body = JSON.stringify(value).replace(/\\/g, '\\\\').replace(/`/g, '\\`'); + return { text: `json\`${body}\``, tag: 'json' }; +} + +function writeScalar(value: JsonValue, type: LiteralTypeName): WrittenLiteralText | undefined { + switch (type) { + case 'string': + return typeof value === 'string' ? { text: `"${escapePslString(value)}"` } : undefined; + case 'boolean': + return typeof value === 'boolean' ? { text: String(value) } : undefined; + case 'json': + return writeJsonTag(value); + case 'i8': + case 'i16': + case 'i32': + case 'i64': + case 'bigint': + case 'decimal': + case 'float': { + const text = writeNumber(value, type); + return text === undefined ? undefined : { text }; + } + } +} + +function writeList( + value: JsonValue, + elementTypes: readonly LiteralTypeName[], +): WrittenLiteralText | undefined { + if (!Array.isArray(value)) return undefined; + const parts: string[] = []; + for (const element of value) { + const written = firstWritten(element, elementTypes); + if (written === undefined) return undefined; + parts.push(written.text); + } + return { text: `[${parts.join(', ')}]` }; +} + +function firstWritten( + value: JsonValue, + types: readonly LiteralTypeName[], +): WrittenLiteralText | undefined { + for (const type of types) { + const written = writeScalar(value, type); + if (written !== undefined) return written; + } + return undefined; +} + +/** + * The literal source text for a stored value, taking the first of `declarations` that accepts it, + * or `undefined` when none does. + */ +export function writeLiteral( + value: JsonValue, + declarations: readonly LiteralTypeDeclaration[], +): WrittenLiteralText | undefined { + for (const declaration of declarations) { + const written = + typeof declaration === 'string' + ? writeScalar(value, declaration) + : writeList(value, declaration.list); + if (written !== undefined) return written; + } + return undefined; +} diff --git a/packages/1-framework/1-core/framework-components/src/shared/literal-types.ts b/packages/1-framework/1-core/framework-components/src/shared/literal-types.ts new file mode 100644 index 000000000000..2a2e455b4c67 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/shared/literal-types.ts @@ -0,0 +1,219 @@ +/** + * Literal types for column defaults: the vocabulary a contract source classifies a written default + * into, and a codec descriptor declares through `literalTypes`. + * + * A written number's type comes from its own size and precision, never from the column it is + * written on: `42` is an `i8` everywhere, `100000000000000099` an `i64`, `1.50` a `decimal`. The + * compatibility check is then a lookup by name, and a value too large for a column is reported as + * an incompatibility before anything is decoded. + * + * ADR 254. + */ + +import type { JsonValue } from '@internal/contract/types'; + +export type LiteralTypeName = + | 'string' + | 'boolean' + | 'i8' + | 'i16' + | 'i32' + | 'i64' + | 'bigint' + | 'decimal' + | 'float' + | 'json'; + +/** What a codec accepts: a type by name, or a list whose elements are any of the named types. */ +export type LiteralTypeDeclaration = + | LiteralTypeName + | { readonly list: readonly LiteralTypeName[] }; + +/** A literal whose type is a single name: everything a `list` element can be. */ +export type ScalarLiteral = { readonly type: LiteralTypeName; readonly value: JsonValue }; + +/** + * A classified literal. A list literal's `type.list` holds the types of its elements in first-seen + * order, so an empty list has an empty list of element types and is compatible with every list + * declaration. + */ +export type Literal = + | ScalarLiteral + | { + readonly type: { readonly list: readonly LiteralTypeName[] }; + readonly value: readonly JsonValue[]; + }; + +/** A literal as a contract source wrote it, with the source's own escapes already resolved. */ +export type WrittenLiteral = + | { readonly kind: 'string'; readonly text: string } + | { readonly kind: 'number'; readonly text: string } + | { readonly kind: 'boolean'; readonly value: boolean } + | { readonly kind: 'json'; readonly text: string } + | { readonly kind: 'list'; readonly elements: readonly WrittenLiteral[] }; + +export interface LiteralRefusal { + readonly ok: false; + readonly reason: 'invalid-json' | 'invalid-number'; + readonly message: string; + /** Which element of a list literal was refused; `undefined` when the literal is not a list. */ + readonly elementIndex: number | undefined; +} + +export type ReadLiteralResult = { readonly ok: true; readonly literal: Literal } | LiteralRefusal; + +const INTEGER_TEXT = /^-?\d+$/; +const DECIMAL_TEXT = /^-?\d+\.\d+$/; +const FLOAT_WORDS: ReadonlySet = new Set(['NaN', 'Infinity', '-Infinity']); + +/** + * Whether `text` is a whole number or a decimal as a contract source writes one. A codec whose + * stored shape differs from a literal type's uses this to recognise the text a `decimal`, `i64` or + * `bigint` literal default carries, instead of restating the accepted syntax. + */ +export function isNumeralText(text: string): boolean { + return INTEGER_TEXT.test(text) || DECIMAL_TEXT.test(text); +} + +/** Whether `text` is one of the three words a `float` literal is written as. */ +export function isNonFiniteText(text: string): boolean { + return FLOAT_WORDS.has(text); +} + +const INTEGER_LIMITS = [ + { name: 'i8', min: -128n, max: 127n }, + { name: 'i16', min: -32768n, max: 32767n }, + { name: 'i32', min: -2147483648n, max: 2147483647n }, + { name: 'i64', min: -9223372036854775808n, max: 9223372036854775807n }, +] as const satisfies readonly { name: LiteralTypeName; min: bigint; max: bigint }[]; + +/** The integer types whose value is an exact JSON number; `i64` and `bigint` carry digit text. */ +const NUMBER_VALUED_INTEGERS: ReadonlySet = new Set(['i8', 'i16', 'i32']); + +const INTEGER_CHAIN = [...INTEGER_LIMITS.map(({ name }) => name), 'bigint'] as const; + +/** + * The chain of integer literal types from `i8` up to and including `name`, so a codec descriptor + * naming `i8` to `i64` does not spell the chain out. + */ +export function integerLiteralTypesUpTo( + name: (typeof INTEGER_CHAIN)[number], +): readonly LiteralTypeName[] { + return INTEGER_CHAIN.slice(0, INTEGER_CHAIN.indexOf(name) + 1); +} + +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}`; +} + +/** The literal type of a number written as `text`, or `undefined` when PSL cannot write it. */ +export function classifyNumberText(text: string): ScalarLiteral | undefined { + if (FLOAT_WORDS.has(text)) return { type: 'float', value: text }; + if (DECIMAL_TEXT.test(text)) return { type: 'decimal', value: canonicalDecimalText(text) }; + if (!INTEGER_TEXT.test(text)) return undefined; + const digits = BigInt(text); + const limit = INTEGER_LIMITS.find(({ min, max }) => digits >= min && digits <= max); + const name: LiteralTypeName = limit?.name ?? 'bigint'; + return { + type: name, + value: NUMBER_VALUED_INTEGERS.has(name) ? Number(digits) : digits.toString(), + }; +} + +type ReadScalarResult = { readonly ok: true; readonly literal: ScalarLiteral } | LiteralRefusal; + +export function readLiteral(written: WrittenLiteral): ReadLiteralResult { + return written.kind === 'list' ? readList(written.elements) : readScalar(written); +} + +function readScalar(written: Exclude): ReadScalarResult { + switch (written.kind) { + case 'string': + return { ok: true, literal: { type: 'string', value: written.text } }; + case 'boolean': + return { ok: true, literal: { type: 'boolean', value: written.value } }; + case 'number': { + const literal = classifyNumberText(written.text); + return literal === undefined + ? { + ok: false, + reason: 'invalid-number', + message: `"${written.text}" is not a number literal.`, + elementIndex: undefined, + } + : { ok: true, literal }; + } + case 'json': + return readJson(written.text); + } +} + +function readJson(text: string): ReadScalarResult { + try { + return { ok: true, literal: { type: 'json', value: JSON.parse(text) } }; + } catch (error) { + return { + ok: false, + reason: 'invalid-json', + message: error instanceof Error ? error.message : String(error), + elementIndex: undefined, + }; + } +} + +function readList(elements: readonly WrittenLiteral[]): ReadLiteralResult { + const types: LiteralTypeName[] = []; + const values: JsonValue[] = []; + for (const [elementIndex, element] of elements.entries()) { + if (element.kind === 'list') { + return { + ok: false, + reason: 'invalid-number', + message: 'A list literal cannot contain another list.', + elementIndex, + }; + } + const read = readScalar(element); + if (!read.ok) return { ...read, elementIndex }; + if (!types.includes(read.literal.type)) types.push(read.literal.type); + values.push(read.literal.value); + } + return { ok: true, literal: { type: { list: types }, value: values } }; +} + +/** Whether a codec declaring `declarations` accepts `literal`. */ +export function isCompatible( + literal: Literal, + declarations: readonly LiteralTypeDeclaration[], +): boolean { + const literalType = literal.type; + if (typeof literalType === 'string') { + return declarations.includes(literalType); + } + return declarations.some( + (declaration) => + typeof declaration !== 'string' && + literalType.list.every((element) => declaration.list.includes(element)), + ); +} + +/** What a codec accepts, for a diagnostic: `i8, i16, i32 literals`, `a list of i8 literals`. */ +export function describeDeclarations(declarations: readonly LiteralTypeDeclaration[]): string { + const scalars = declarations.filter((declaration) => typeof declaration === 'string'); + const lists = declarations.filter((declaration) => typeof declaration !== 'string'); + const parts = [ + ...(scalars.length > 0 ? [`${scalars.join(', ')} literals`] : []), + ...lists.map((declaration) => `a list of ${declaration.list.join(', ')} literals`), + ]; + return parts.length === 0 ? 'no literal defaults' : parts.join(' and '); +} diff --git a/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts b/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts index 27188e1a7c43..42892c2c1137 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts @@ -3,6 +3,7 @@ import type { ExecutionMutationDefaultPhases, ExecutionMutationDefaultValue, } from '@internal/contract/types'; +import type { LiteralTypeName } from './literal-types'; interface SourcePosition { readonly offset: number; @@ -88,17 +89,37 @@ export interface TaggedLiteralValue { readonly span: SourceSpan; } -export interface ControlDefaultLiteralTagEntry { +interface ControlDefaultLiteralTagDescription { /** How the tag is written, for messages: `` sql`...` ``. */ readonly usage: string; /** What the literal does, shown as signature help. */ readonly documentation: string; +} + +/** A tag whose body the family lowers itself, into a storage default or an execution default. */ +export interface ControlDefaultLiteralTagLoweringEntry extends ControlDefaultLiteralTagDescription { readonly lower: (input: { readonly literal: TaggedLiteralValue; readonly context: DefaultFunctionLoweringContext; }) => LoweredDefaultResult; } +/** A tag whose body is a literal of one type, checked against the column's codec like any other literal. */ +export interface ControlDefaultLiteralTagTypeEntry extends ControlDefaultLiteralTagDescription { + readonly literalType: LiteralTypeName; +} + +export type ControlDefaultLiteralTagEntry = + | ControlDefaultLiteralTagLoweringEntry + | ControlDefaultLiteralTagTypeEntry; + +/** Which of the two kinds of tag entry this is; the only place the discriminating key is named. */ +export function isDefaultLiteralTagLoweringEntry( + entry: ControlDefaultLiteralTagEntry, +): entry is ControlDefaultLiteralTagLoweringEntry { + return 'lower' in entry; +} + export type ControlDefaultLiteralTagRegistry = ReadonlyMap; export interface ControlMutationDefaults { 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..9cd863e4bbc2 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,7 @@ import { type ColumnHelperForStrict, type ColumnSpec, column, + type LiteralTypeDeclaration, voidParamsSchema, } from '../src/exports/codec'; @@ -211,3 +212,27 @@ test('AnyCodecDescriptor stores parameterized + non-parameterized descriptors wi reg.set(vectorFixtureDescriptor.codecId, vectorFixtureDescriptor); expectTypeOf().toMatchTypeOf>(); }); + +test('literalTypes is optional and typed as the declaration union', () => { + expectTypeOf['literalTypes']>().toEqualTypeOf< + readonly LiteralTypeDeclaration[] | undefined + >(); + expectTypeOf().not.toBeAny(); + + class DeclaringDescriptor extends Int4FixtureDescriptor { + override readonly literalTypes = ['i8', 'i16', { list: ['i8'] }] as const; + } + const declaring: CodecDescriptor = new DeclaringDescriptor(); + expectTypeOf(declaring.literalTypes).toEqualTypeOf< + readonly LiteralTypeDeclaration[] | undefined + >(); + + class UndeclaredDescriptor extends Int4FixtureDescriptor {} + new UndeclaredDescriptor() satisfies CodecDescriptor; + + class WrongDeclaration extends Int4FixtureDescriptor { + // @ts-expect-error -- "int" is not a literal type name + override readonly literalTypes = ['int'] as const; + } + expectTypeOf().not.toBeAny(); +}); diff --git a/packages/1-framework/1-core/framework-components/test/default-literal-tag-entry.test.ts b/packages/1-framework/1-core/framework-components/test/default-literal-tag-entry.test.ts new file mode 100644 index 000000000000..50d5861b6d15 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/default-literal-tag-entry.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { jsonDefaultLiteralTagEntry } from '../src/exports/codec'; +import { + type ControlDefaultLiteralTagEntry, + isDefaultLiteralTagLoweringEntry, +} from '../src/exports/control'; + +const loweringEntry: ControlDefaultLiteralTagEntry = { + usage: 'sql`...`', + documentation: 'Raw SQL.', + lower: () => ({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'now()' } }, + }), +}; + +describe('isDefaultLiteralTagLoweringEntry', () => { + it('accepts an entry that lowers its own body', () => { + expect(isDefaultLiteralTagLoweringEntry(loweringEntry)).toBe(true); + }); + + it('refuses an entry that names a literal type', () => { + expect(isDefaultLiteralTagLoweringEntry(jsonDefaultLiteralTagEntry())).toBe(false); + }); +}); + +describe('jsonDefaultLiteralTagEntry', () => { + it('names the json literal type', () => { + expect(jsonDefaultLiteralTagEntry()).toEqual({ + usage: 'json`...`', + documentation: "Reads the body as a JSON document and stores it as the column's default.", + literalType: 'json', + }); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/literal-types-write.test.ts b/packages/1-framework/1-core/framework-components/test/literal-types-write.test.ts new file mode 100644 index 000000000000..1a5cf0b4f5f3 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/literal-types-write.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { integerLiteralTypesUpTo, type LiteralTypeDeclaration } from '../src/shared/literal-types'; +import { writeLiteral } from '../src/shared/literal-types-write'; +import { resolvePslBacktickEscapes } from '../src/shared/tagged-literal'; + +const integers = integerLiteralTypesUpTo('i64'); + +describe('writeLiteral', () => { + describe('each type writes its own value', () => { + it.each([ + ['a string', 'anonymous', ['string'], '"anonymous"'], + ['a string with quotes and backslashes', 'a"b\\c', ['string'], '"a\\"b\\\\c"'], + ['a string with a newline', 'a\nb', ['string'], '"a\\nb"'], + ['true', true, ['boolean'], 'true'], + ['false', false, ['boolean'], 'false'], + ['an i8 number', 42, ['i8'], '42'], + ['an i16 number', 1000, integers, '1000'], + ['an i64 as digit text', '9007199254740993', integers, '9007199254740993'], + ['a bigint as digit text', '9223372036854775808', ['bigint'], '9223372036854775808'], + ['a decimal as text', '1.50', ['decimal'], '1.50'], + ['NaN unquoted', 'NaN', ['float'], 'NaN'], + ['Infinity unquoted', 'Infinity', ['float'], 'Infinity'], + ['-Infinity unquoted', '-Infinity', ['float'], '-Infinity'], + ] as [string, never, readonly LiteralTypeDeclaration[], string][])( + '%s', + (_name, value, declarations, text) => { + expect(writeLiteral(value, declarations)).toEqual({ text }); + }, + ); + }); + + it('writes a fractional number through the decimal declaration', () => { + expect(writeLiteral(1.5, ['i8', 'i16', 'i32', 'decimal'])).toEqual({ text: '1.5' }); + }); + + it('writes digit text through the first integer type that holds it', () => { + expect(writeLiteral('42', integers)).toEqual({ text: '42' }); + }); + + it('writes a number past the exponent threshold without an exponent', () => { + expect(writeLiteral(1e21, ['bigint'])).toEqual({ text: '1000000000000000000000' }); + }); + + it('writes a non-finite number unquoted', () => { + expect(writeLiteral(Number.NaN, ['float'])).toEqual({ text: 'NaN' }); + }); + + describe('json', () => { + it('writes a json tag with a backtick fence', () => { + expect(writeLiteral({ plan: 'free', seats: 1 }, ['json'])).toEqual({ + text: 'json`{"plan":"free","seats":1}`', + tag: 'json', + }); + }); + + it('writes json null', () => { + expect(writeLiteral(null, ['json'])).toEqual({ text: 'json`null`', tag: 'json' }); + }); + + it('escapes a backslash inside the backtick fence', () => { + expect(writeLiteral({ a: '\\' }, ['json'])).toEqual({ + text: 'json`{"a":"\\\\\\\\"}`', + tag: 'json', + }); + }); + + it('escapes a backtick inside the backtick fence', () => { + expect(writeLiteral({ a: '`' }, ['json'])).toEqual({ + text: 'json`{"a":"\\`"}`', + tag: 'json', + }); + }); + + it.each([ + ['a backtick', { a: '`' }], + ['a backslash', { a: '\\' }], + ['a backslash before a backtick', { a: '\\`' }], + ['an escape sequence a quote fence would resolve', { a: 'x\ny' }], + ])('round-trips %s through the fence escapes', (_name, value) => { + const written = writeLiteral(value, ['json']); + if (written === undefined) throw new Error('expected a json literal'); + const body = written.text.slice('json`'.length, -1); + expect(JSON.parse(resolvePslBacktickEscapes(body))).toEqual(value); + }); + }); + + describe('lists', () => { + it('writes each element against the declaration element types', () => { + expect(writeLiteral([0.1, 0.2], [{ list: ['i8', 'decimal'] }])).toEqual({ + text: '[0.1, 0.2]', + }); + }); + + it('writes an empty list', () => { + expect(writeLiteral([], [{ list: ['i8'] }])).toEqual({ text: '[]' }); + }); + + it('refuses a list with an element no element type writes', () => { + expect(writeLiteral([1, 'x'], [{ list: ['i8'] }])).toBeUndefined(); + }); + + it('refuses a list against scalar declarations only', () => { + expect(writeLiteral([1], ['i8'])).toBeUndefined(); + }); + }); + + it('tries the declarations in order and takes the first that writes', () => { + expect(writeLiteral('42', ['string', ...integers])).toEqual({ text: '"42"' }); + expect(writeLiteral('42', [...integers, 'string'])).toEqual({ text: '42' }); + }); + + it.each([ + ['a string against integer declarations', 'nonsense', integers], + ['a boolean against string declarations', true, ['string'] as const], + ['a number against no declarations', 1, [] as const], + ['a fractional number against integer declarations', 1.5, integers], + ['a non-finite number against decimal declarations', Number.NaN, ['decimal'] as const], + ] as [string, never, readonly LiteralTypeDeclaration[]][])( + 'returns undefined for %s', + (_name, value, declarations) => { + expect(writeLiteral(value, declarations)).toBeUndefined(); + }, + ); +}); diff --git a/packages/1-framework/1-core/framework-components/test/literal-types.test.ts b/packages/1-framework/1-core/framework-components/test/literal-types.test.ts new file mode 100644 index 000000000000..f0deb0257209 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/literal-types.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest'; +import { + describeDeclarations, + integerLiteralTypesUpTo, + isCompatible, + isNonFiniteText, + isNumeralText, + type Literal, + type LiteralTypeDeclaration, + readLiteral, + type WrittenLiteral, +} from '../src/shared/literal-types'; + +const number = (text: string): WrittenLiteral => ({ kind: 'number', text }); + +function readOk(written: WrittenLiteral): Literal { + const result = readLiteral(written); + if (!result.ok) throw new Error(`expected a literal, got ${result.reason}: ${result.message}`); + return result.literal; +} + +describe('readLiteral', () => { + describe('whole numbers', () => { + it.each([ + ['zero', '0', 'i8', 0], + ['the sign of zero dropped', '-0', 'i8', 0], + ['leading zeros dropped', '007', 'i8', 7], + ['the largest i8', '127', 'i8', 127], + ['the smallest i8', '-128', 'i8', -128], + ['one past the largest i8', '128', 'i16', 128], + ['one past the smallest i8', '-129', 'i16', -129], + ['the largest i16', '32767', 'i16', 32767], + ['one past the largest i16', '32768', 'i32', 32768], + ['the smallest i16', '-32768', 'i16', -32768], + ['one past the smallest i16', '-32769', 'i32', -32769], + ['the largest i32', '2147483647', 'i32', 2147483647], + ['one past the largest i32', '2147483648', 'i64', '2147483648'], + ['the smallest i32', '-2147483648', 'i32', -2147483648], + ['one past the smallest i32', '-2147483649', 'i64', '-2147483649'], + ['past the safe integer range', '9007199254740993', 'i64', '9007199254740993'], + ['the largest i64', '9223372036854775807', 'i64', '9223372036854775807'], + ['the smallest i64', '-9223372036854775808', 'i64', '-9223372036854775808'], + ['one past the largest i64', '9223372036854775808', 'bigint', '9223372036854775808'], + ['one past the smallest i64', '-9223372036854775809', 'bigint', '-9223372036854775809'], + [ + 'leading zeros dropped from a bigint', + '009223372036854775808', + 'bigint', + '9223372036854775808', + ], + ])('%s', (_name, text, type, value) => { + expect(readOk(number(text))).toEqual({ type, value }); + }); + }); + + describe('decimals', () => { + it.each([ + ['trailing zeros kept', '1.50', '1.50'], + ['leading zeros dropped', '007.50', '7.50'], + ['the sign of zero dropped', '-0.0', '0.0'], + ['a negative decimal keeps its sign', '-007.50', '-7.50'], + ['a large decimal is kept as text', '9223372036854775808.5', '9223372036854775808.5'], + ])('%s', (_name, text, value) => { + expect(readOk(number(text))).toEqual({ type: 'decimal', value }); + }); + }); + + it.each(['NaN', 'Infinity', '-Infinity'])('%s is a float literal', (text) => { + expect(readOk(number(text))).toEqual({ type: 'float', value: text }); + }); + + it.each(['1e3', '+1', '1.', '', 'nonsense'])('refuses the number text %o', (text) => { + expect(readLiteral(number(text))).toEqual({ + ok: false, + reason: 'invalid-number', + message: expect.stringContaining(text), + elementIndex: undefined, + }); + }); + + it('reads a string', () => { + expect(readOk({ kind: 'string', text: 'anonymous' })).toEqual({ + type: 'string', + value: 'anonymous', + }); + }); + + it.each([true, false])('reads the boolean %s', (value) => { + expect(readOk({ kind: 'boolean', value })).toEqual({ type: 'boolean', value }); + }); + + describe('json', () => { + it.each([ + ['an object', '{ "plan": "free", "seats": 1 }', { plan: 'free', seats: 1 }], + ['an array', '[1, 2]', [1, 2]], + ['null', 'null', null], + ['a string', '"a"', 'a'], + ])('reads %s', (_name, text, value) => { + expect(readOk({ kind: 'json', text })).toEqual({ type: 'json', value }); + }); + + it('refuses text that is not json, with the parser message', () => { + const result = readLiteral({ kind: 'json', text: '{ plan }' }); + expect(result).toMatchObject({ ok: false, reason: 'invalid-json' }); + expect(result.ok ? '' : result.message).not.toBe(''); + }); + }); + + describe('lists', () => { + it('reads the element types in first-seen order', () => { + expect( + readOk({ kind: 'list', elements: [number('1'), number('2'), number('40000')] }), + ).toEqual({ type: { list: ['i8', 'i32'] }, value: [1, 2, 40000] }); + }); + + it('reads an empty list', () => { + expect(readOk({ kind: 'list', elements: [] })).toEqual({ type: { list: [] }, value: [] }); + }); + + it('reports the refusal of an element at that element', () => { + expect(readLiteral({ kind: 'list', elements: [number('1'), number('1e3')] })).toEqual({ + ok: false, + reason: 'invalid-number', + message: expect.stringContaining('1e3'), + elementIndex: 1, + }); + }); + + it('refuses a nested list at that element', () => { + expect( + readLiteral({ kind: 'list', elements: [number('1'), { kind: 'list', elements: [] }] }), + ).toEqual({ + ok: false, + reason: 'invalid-number', + message: 'A list literal cannot contain another list.', + elementIndex: 1, + }); + }); + }); +}); + +describe('isCompatible', () => { + const scalars: readonly LiteralTypeDeclaration[] = ['i8', 'i16', 'i32']; + const lists: readonly LiteralTypeDeclaration[] = [{ list: ['i8', 'decimal'] }]; + + it('accepts a scalar named by the declarations', () => { + expect(isCompatible(readOk(number('42')), scalars)).toBe(true); + }); + + it('refuses a scalar the declarations do not name', () => { + expect(isCompatible(readOk(number('42000000000')), scalars)).toBe(false); + }); + + it('refuses every literal against no declarations', () => { + expect(isCompatible(readOk(number('42')), [])).toBe(false); + }); + + it('accepts a list whose element types the list declaration names', () => { + expect( + isCompatible(readOk({ kind: 'list', elements: [number('1'), number('1.5')] }), lists), + ).toBe(true); + }); + + it('refuses a list with an element type outside the list declaration', () => { + expect( + isCompatible( + readOk({ kind: 'list', elements: [number('1'), { kind: 'string', text: 'x' }] }), + lists, + ), + ).toBe(false); + }); + + it('refuses a list against scalar declarations only', () => { + expect(isCompatible(readOk({ kind: 'list', elements: [number('1')] }), scalars)).toBe(false); + }); + + it('refuses a scalar against a list declaration only', () => { + expect(isCompatible(readOk(number('1')), lists)).toBe(false); + }); +}); + +describe('describeDeclarations', () => { + it.each([ + ['no literal defaults', []], + ['i8, i16, i32 literals', ['i8', 'i16', 'i32']], + ['a list of i8, decimal literals', [{ list: ['i8', 'decimal'] }]], + ['string literals and a list of i8 literals', ['string', { list: ['i8'] }]], + ] as const)('reads %o', (expected, declarations) => { + expect(describeDeclarations(declarations)).toBe(expected); + }); +}); + +describe('integerLiteralTypesUpTo', () => { + it.each([ + ['i8', ['i8']], + ['i32', ['i8', 'i16', 'i32']], + ['i64', ['i8', 'i16', 'i32', 'i64']], + ['bigint', ['i8', 'i16', 'i32', 'i64', 'bigint']], + ] as const)('%s', (name, expected) => { + expect(integerLiteralTypesUpTo(name)).toEqual(expected); + }); +}); + +describe('isNumeralText', () => { + it.each(['0', '-0', '007', '9223372036854775808', '1.50', '-007.50'])( + 'accepts %o, which classifies as a literal type', + (text) => { + expect(isNumeralText(text)).toBe(true); + expect(readLiteral(number(text)).ok).toBe(true); + }, + ); + + it.each(['NaN', 'Infinity', '-Infinity', '1e3', '+1', '1.', '', 'nonsense', '1,5'])( + 'refuses %o', + (text) => { + expect(isNumeralText(text)).toBe(false); + }, + ); +}); + +describe('isNonFiniteText', () => { + it.each(['NaN', 'Infinity', '-Infinity'])( + 'accepts %o, which reads as a float literal', + (text) => { + expect(isNonFiniteText(text)).toBe(true); + expect(readOk(number(text))).toEqual({ type: 'float', value: text }); + }, + ); + + it.each(['nan', 'infinity', '42', '1.5', ''])('refuses %o', (text) => { + expect(isNonFiniteText(text)).toBe(false); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts index de145c49a332..0f890586cb4f 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts @@ -7,6 +7,8 @@ import { leafDiagnostic } from './diagnostic'; export interface ListOptions { readonly allowEmpty?: boolean; readonly unique?: boolean; + /** How the list reads in an "expected one of" message. Defaults to the element label plus `[]`, which is unreadable when the element label is itself a list of alternatives. */ + readonly label?: string; } export function list( @@ -17,7 +19,7 @@ export function list( const unique = opts?.unique ?? false; return { kind: 'list', - label: `${of.label}[]`, + label: opts?.label ?? `${of.label}[]`, of, allowEmpty, unique, diff --git a/packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts index a87451f841be..79788c2b21ca 100644 --- a/packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts @@ -1204,16 +1204,17 @@ describe('providePslCompletionItems', () => { insertTextFormat: item.insertTextFormat, })); const postgresTags = postgres.createPostgresDefaultLiteralTagRegistry(); - const documentation = postgresTags.get('sql')?.documentation; + const documentationOf = (registry: ControlDefaultLiteralTagRegistry, tag: string) => + registry.get(tag)?.documentation; const value = (label: string) => ({ label, detail: 'PSL argument value', newText: label, insertTextFormat: undefined, }); - const tag = (label: string, snippet: boolean) => ({ + const tag = (registry: ControlDefaultLiteralTagRegistry, label: string, snippet: boolean) => ({ label, - detail: documentation, + detail: documentationOf(registry, label), newText: snippet ? `${label}\`$1\`` : label, insertTextFormat: snippet ? InsertTextFormat.Snippet : undefined, }); @@ -1221,21 +1222,28 @@ describe('providePslCompletionItems', () => { expect(complete(postgresTags, true)).toEqual([ value('true'), value('false'), - tag('sql', true), - tag('pg.sql', true), + tag(postgresTags, 'sql', true), + tag(postgresTags, 'pg.sql', true), + tag(postgresTags, 'json', true), ]); - expect(complete(sqlite.createSqliteDefaultLiteralTagRegistry(), true)).toEqual([ + const sqliteTags = sqlite.createSqliteDefaultLiteralTagRegistry(); + expect(complete(sqliteTags, true)).toEqual([ value('true'), value('false'), - tag('sql', true), - tag('sqlite.sql', true), + tag(sqliteTags, 'sql', true), + tag(sqliteTags, 'sqlite.sql', true), + tag(sqliteTags, 'json', true), ]); expect(complete(postgresTags, false)).toEqual([ value('true'), value('false'), - tag('sql', false), - tag('pg.sql', false), + tag(postgresTags, 'sql', false), + tag(postgresTags, 'pg.sql', false), + tag(postgresTags, 'json', false), ]); + + // Each tag carries the text of the tag it names, not every registered tag's text. + expect(documentationOf(postgresTags, 'json')).not.toBe(documentationOf(postgresTags, 'sql')); }, 5_000); it('uses distinct local and referenced fields through actual SQL relation specs', async () => { 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..367b1ee74559 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,10 @@ 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, + readLiteral, + type WrittenLiteral, +} 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 +17,15 @@ import { printSyntax, StringLiteralExprAst, } from '@internal/psl-parser/syntax'; -import { numberLiteralDefault } from '@internal/sql-contract-psl/resolution'; +import { + describeLiteralType, + type LiteralDefaultRefusal, + readLiteralDefault, +} from '@internal/sql-contract-psl/resolution'; import type { AuthoredColumnDefault, AuthoredColumnDefaultLiteralValue, } from '@internal/sql-contract-ts/contract-builder'; -import { blindCast } from '@internal/utils/casts'; import { prisma7Diagnostic } from './diagnostics'; import type { Prisma7LiteralDefaultForm } from './target-binding'; @@ -120,45 +127,107 @@ export function lowerPrisma7Default( return { storage: { kind: 'literal', value: scalar }, onCreate: undefined }; } +/** + * The stored value of a literal default: an enum member resolves through the enum's members, and + * every other literal is classified into a literal type and handed to the column's codec, the same + * path PSL takes. + */ function scalarValue( expression: ExpressionAst, input: LowerPrisma7DefaultInput, unknown: (reason: string, span: PslSpan) => undefined, ): AuthoredColumnDefaultLiteralValue | undefined { const span = input.attribute.span; - const isJson = input.literalForm?.kind === 'json'; - 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.`, - input.sourceId, - span, - ), - ); - return undefined; - }; + const enumValue = enumMemberValue(expression, input); + if (enumValue !== undefined) return enumValue; + const array = ArrayLiteralAst.cast(expression.syntax); - if (array !== undefined) { + const elements = array === undefined ? undefined : [...array.elements()]; + if (elements?.some((element) => enumMemberValue(element, input) !== undefined) === true) { const values: AuthoredColumnDefaultLiteralValue[] = []; - for (const element of array.elements()) { - const value = elementValue(element, input); + for (const element of elements) { + const value = enumMemberValue(element, input); if (value === undefined) { - return unknown( - rejectedNumberReason(element, input) ?? 'lists may only hold literals or enum members.', - span, - ); + return unknown('lists may only hold literals or enum members.', span); } 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 written = writtenLiteralFor(expression, elements, input); + if (written === undefined) return unreadableValue(expression, input, unknown); + + const jsonNull = jsonNullDefault(written, expression, input); + if (jsonNull !== undefined) return jsonNull; + + const read = readLiteralDefault({ + written, + isList: input.field.list, + column: { codecId: input.codecId }, + codecLookup: input.codecLookup, + fieldPath: `${input.modelName}.${input.field.name}`, + }); + return read.ok ? read.value : unknown(refusalReason(read.refusal), span); +} + +/** The storage value of an enum member name, when the field is typed by a Prisma 7 enum. */ +function enumMemberValue( + expression: ExpressionAst, + input: LowerPrisma7DefaultInput, +): string | undefined { + const member = IdentifierAst.cast(expression.syntax)?.name(); + return member === undefined ? undefined : input.enumMembers?.get(member); +} + +function writtenLiteralFor( + expression: ExpressionAst, + elements: readonly ExpressionAst[] | undefined, + input: LowerPrisma7DefaultInput, +): WrittenLiteral | undefined { + if (elements === undefined) return writtenLiteral(expression, input); + const written: WrittenLiteral[] = []; + for (const element of elements) { + const elementLiteral = writtenLiteral(element, input); + if (elementLiteral === undefined) return undefined; + written.push(elementLiteral); + } + return { kind: 'list', elements: written }; +} + +/** + * The JSON value null, which the contract cannot tell apart from SQL NULL, reported before the + * column's codec sees the literal. + */ +function jsonNullDefault( + written: WrittenLiteral, + expression: ExpressionAst, + input: LowerPrisma7DefaultInput, +): undefined { + if (input.literalForm?.kind !== 'json') return undefined; + const read = readLiteral(written); + if (!read.ok) return undefined; + const value = read.literal.value; + const isNull = Array.isArray(value) ? value.includes(null) : value === null; + if (!isNull) return undefined; + input.diagnostics.push( + prisma7Diagnostic( + 'PSL.PRISMA7_JSON_NULL_DEFAULT_UNSUPPORTED', + `Field "${input.modelName}.${input.field.name}": @default(${printSyntax(expression.syntax).trim()}) ${written.kind === 'list' ? 'holds' : 'is'} 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, + input.attribute.span, + ), + ); + return undefined; +} + +/** A `@default(...)` this contract source cannot read as a literal at all. */ +function unreadableValue( + expression: ExpressionAst, + input: LowerPrisma7DefaultInput, + unknown: (reason: string, span: PslSpan) => undefined, +): undefined { + const span = input.attribute.span; const identifier = IdentifierAst.cast(expression.syntax)?.name(); if (identifier !== undefined) { return unknown( @@ -168,6 +237,9 @@ function scalarValue( span, ); } + if (ArrayLiteralAst.cast(expression.syntax) !== undefined) { + return unknown('lists may only hold literals or enum members.', span); + } return unknown('holds a value this contract source does not read.', span); } @@ -193,61 +265,32 @@ 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( +/** The written literal a Prisma 7 expression is, or `undefined` when it is not a literal at all. */ +function writtenLiteral( 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; +): WrittenLiteral | undefined { + const text = StringLiteralExprAst.cast(expression.syntax)?.value(); + if (text !== undefined) { + return input.literalForm?.kind === 'json' ? { kind: 'json', text } : { kind: 'string', text }; } - return numberLiteralDefault(text, input.codecId, input.codecLookup); + const number = NumberLiteralExprAst.cast(expression.syntax)?.token()?.text; + if (number !== undefined) return { kind: 'number', text: number }; + const boolean = BooleanLiteralExprAst.cast(expression.syntax)?.value(); + return boolean === undefined ? undefined : { kind: 'boolean', value: boolean }; } -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; +/** Why the column refused the literal, as a phrase following `@default `. */ +function refusalReason(refusal: LiteralDefaultRefusal): string { + const at = refusal.elementIndex === undefined ? '' : ` at element ${refusal.elementIndex + 1}`; + switch (refusal.kind) { + case 'unreadable': + return `holds text${at} that this contract source does not read: ${refusal.message}`; + case 'incompatible': + return `holds ${describeLiteralType(refusal.literalType)}${at}, which ${refusal.codecId} does not accept; it accepts ${refusal.accepts}.`; + case 'undecodable': + return `holds a value${at} that ${refusal.codecId} does not read: ${refusal.message}`; } - return BooleanLiteralExprAst.cast(expression.syntax)?.value(); } function lowerFunction( 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..bbc2dc35f806 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,19 +132,63 @@ describe('Decimal and BigInt number defaults', () => { }); }); +async function diagnosticsOf(caseName: string, file: string) { + const schemaPath = join(fixturesDir, caseName, file); + const result = await prisma7Contract(schemaPath, { + binding: prisma7PostgresBinding, + }).source.load(postgresSourceContext([schemaPath])); + return result.ok ? [] : result.failure.diagnostics.map((diagnostic) => diagnostic.message); +} + 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'); - const result = await prisma7Contract(schemaPath, { - binding: prisma7PostgresBinding, - }).source.load(postgresSourceContext([schemaPath])); - 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.', + it('are rejected, naming the literal type and what the column accepts', async () => { + expect(await diagnosticsOf('number-default-spellings', 'other-types.prisma')).toEqual([ + 'Field "OtherTypes.name": @default holds an i8 literal, which pg/text@1 does not accept; it accepts string literals.', + 'Field "OtherTypes.payload": @default holds an i16 literal, which pg/bytea@1 does not accept; it accepts string literals.', + 'Field "OtherTypes.at": @default holds an i8 literal, which pg/timestamp-temporal@1 does not accept; it accepts string literals.', + 'Field "OtherTypes.flag": @default holds an i8 literal, which pg/bool@1 does not accept; it accepts boolean literals.', + ]); + }); +}); + +describe('Number defaults too large for the column', () => { + it('are rejected before anything is decoded, naming the literal type', async () => { + expect(await diagnosticsOf('number-default-spellings', 'out-of-range.prisma')).toEqual([ + 'Field "OutOfRange.count": @default holds an i64 literal, which pg/int4@1 does not accept; it accepts i8, i16, i32 literals.', + 'Field "OutOfRange.small": @default holds an i32 literal, which pg/int2@1 does not accept; it accepts i8, i16 literals.', + 'Field "OutOfRange.ints": @default holds an i64 literal at element 2, which pg/int4@1 does not accept; it accepts i8, i16, i32 literals.', + ]); + }); +}); + +describe('Json defaults whose text is not a JSON document', () => { + it('are rejected, carrying the JSON parser message', async () => { + expect(await diagnosticsOf('number-default-spellings', 'unreadable-json.prisma')).toEqual([ + expect.stringMatching( + /^Field "UnreadableJson\.broken": @default holds text that this contract source does not read: /, + ), + expect.stringMatching( + /^Field "UnreadableJson\.list": @default holds text at element 2 that this contract source does not read: /, + ), ]); }); }); + +describe('Json, Decimal, BigInt and Float literal defaults', () => { + it('lower through the column codec', async () => { + const { columns } = await loadFixtureTable('defaults', 'Defaults'); + expect( + Object.fromEntries( + ['jsonLiteral', 'decimalLiteral', 'bigIntLiteral', 'floatLiteral', 'intLiteral'].map( + (column) => [column, columns[column]?.['default']], + ), + ), + ).toEqual({ + jsonLiteral: { kind: 'literal', value: { a: 1 } }, + decimalLiteral: { kind: 'literal', value: '12.34' }, + bigIntLiteral: { kind: 'literal', value: '9007199254740993' }, + floatLiteral: { kind: 'literal', value: 1.5 }, + intLiteral: { kind: 'literal', value: 42 }, + }); + }); +}); 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..7b51364dd15a 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 holds a decimal literal, which pg/int8@1 does not accept; it accepts i8, i16, i32, i64 literals." }, { "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 holds a decimal literal at element 2, which pg/int8@1 does not accept; it accepts i8, i16, i32, i64 literals." }, { "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 holds a decimal literal, which pg/int4@1 does not accept; it accepts i8, i16, i32 literals." }, { "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 holds a decimal literal, which pg/int2@1 does not accept; it accepts i8, i16 literals." }, { "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 holds a decimal literal at element 2, which pg/int4@1 does not accept; it accepts i8, i16, i32 literals." } ] diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/number-default-spellings/out-of-range.prisma b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/number-default-spellings/out-of-range.prisma new file mode 100644 index 000000000000..553bbe6f4e1d --- /dev/null +++ b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/number-default-spellings/out-of-range.prisma @@ -0,0 +1,10 @@ +datasource db { + provider = "postgresql" +} + +model OutOfRange { + id Int @id + count Int @default(100000000000000099) + small Int @default(100000) @db.SmallInt + ints Int[] @default([1, 100000000000000099]) +} diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/number-default-spellings/unreadable-json.prisma b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/number-default-spellings/unreadable-json.prisma new file mode 100644 index 000000000000..04044084098c --- /dev/null +++ b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/number-default-spellings/unreadable-json.prisma @@ -0,0 +1,9 @@ +datasource db { + provider = "postgresql" +} + +model UnreadableJson { + id Int @id + broken Json @default("{ nope") + list Json[] @default(["{}", "nope"]) +} diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts index e1f0a2df4079..2dea4c33a637 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts @@ -10,15 +10,29 @@ import { postgresSourceContext } from './support'; const postgres = { binding: prisma7PostgresBinding }; +/** + * A lookup whose text codec encodes every default as JSON null, so the contract the reader builds + * fails the checks `contract emit` runs. The column's codec is built from its descriptor, so the + * descriptor's factory is what has to hand back the broken codec. + */ function withTextDefaultsEncodedAsNull(lookup: CodecLookup): CodecLookup { + const breakCodec = (codec: T): T => + Object.assign(Object.create(Object.getPrototypeOf(codec)), codec, { encodeJson: () => null }); const get = (id: string) => { const codec = lookup.get(id); - if (id !== 'pg/text@1' || codec === undefined) return codec; - return Object.assign(Object.create(Object.getPrototypeOf(codec)), codec, { - encodeJson: () => null, + return id !== 'pg/text@1' || codec === undefined ? codec : breakCodec(codec); + }; + const descriptorFor = (id: string) => { + const descriptor = lookup.descriptorFor?.(id); + if (id !== 'pg/text@1' || descriptor === undefined) return descriptor; + return Object.assign(Object.create(Object.getPrototypeOf(descriptor)), descriptor, { + factory: (params: never) => (ctx: never) => breakCodec(descriptor.factory(params)(ctx)), }); }; - return Object.assign(Object.create(Object.getPrototypeOf(lookup)), lookup, { get }); + return Object.assign(Object.create(Object.getPrototypeOf(lookup)), lookup, { + get, + descriptorFor, + }); } function scratchDir(name: string): string { diff --git a/packages/2-sql/2-authoring/contract-psl/README.md b/packages/2-sql/2-authoring/contract-psl/README.md index ece7f77dc462..90779a4bb289 100644 --- a/packages/2-sql/2-authoring/contract-psl/README.md +++ b/packages/2-sql/2-authoring/contract-psl/README.md @@ -60,6 +60,7 @@ Unsupported PSL constructs in v1 (strict errors): Supported `@default(...)` surface in v1 when composed contributors provide handlers: - Storage defaults: `autoincrement()`, `now()`, literals, `dbgenerated("...")` +- Literal defaults have a type of their own, decided by what is written rather than by the column: a string, `true`/`false`, a whole number typed by its size (`i8`, `i16`, `i32`, `i64`, then `bigint`), a number with a fraction (`decimal`, keeping its trailing zeros), `NaN`/`Infinity`/`-Infinity` (`float`), a JSON document written `` @default(json`{ "plan": "free" }`) ``, and a list of any of those. The column's codec names which types it accepts, so `Int @default(100000000000000099)` is refused before anything is decoded: `PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE`. A `json` body that is not a JSON document is `PSL_INVALID_JSON_LITERAL`, and a literal the codec accepts by type but refuses to decode — a `pgvector.Vector(3)` given two elements — is `PSL_INVALID_DEFAULT_LITERAL`. A codec that names no literal type takes only a `` sql`...` `` default. See [ADR 254](../../../../docs/architecture%20docs/adrs/ADR%20254%20-%20Literal%20types%20for%20column%20defaults.md). - Raw SQL defaults as a tagged literal: a tag followed by a string literal, as in `` @default(sql`(now() + interval '7 days')`) `` or, for a body with backticks, `@default(sql"(now() + '00:03:00'::interval)")`. The string may use any quote style, and whitespace, newlines, or comments may sit between the tag and the string. A backtick string may span lines and resolves only `` \` `` and `\\`, so `${` is ordinary text and needs no escape; `"` and `'` strings resolve the usual escapes. A backtick string is only valid after a tag; anywhere else it is `PSL_BACKTICK_STRING_REQUIRES_TAG`. A body that is exactly `now()` or `autoincrement()` (`` sql`now()` ``) is refused: write the named form, `@default(now())`. Any other body, including `NOW()` and `gen_random_uuid()`, is used as written. The body is canonicalized (line endings, a blank first and last line, common indentation) and used verbatim as the default expression. `sql` is registered by every SQL target; `pg.sql` (Postgres) and `sqlite.sql` (SQLite) are target-prefixed spellings of the same tag. An unregistered tag is `PSL_UNKNOWN_DEFAULT_LITERAL_TAG`, reported when the default is lowered. - Execution defaults: `uuid()`, `uuid(4)`, `uuid(7)`, `cuid(2)`, `ulid()`, `nanoid()`, `nanoid(<2-255>)` - Explicitly unsupported in v1: `cuid()` (diagnostic suggests `cuid(2)`) 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..723e0689e3a2 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,11 @@ export { buildEntityTypesByDiscriminator } from '../interpreter'; -export { numberLiteralDefault } from '../number-literal-default'; +export { + describeLiteralType, + type LiteralDefaultColumn, + type LiteralDefaultRefusal, + type ReadLiteralDefaultResult, + readLiteralDefault, +} from '../literal-default'; export { type ColumnDescriptor, type ResolveFieldTypeResult, diff --git a/packages/2-sql/2-authoring/contract-psl/src/literal-default.ts b/packages/2-sql/2-authoring/contract-psl/src/literal-default.ts new file mode 100644 index 000000000000..1daf7b9a8c36 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/src/literal-default.ts @@ -0,0 +1,286 @@ +/** + * Reading a `@default(...)` literal: a written literal is classified into a literal type, the type + * is checked against the column's codec by membership, and the codec's `decodeJson` converts the + * value. No per-type code and no per-codec branch live here. + * + * ADR 254. + */ + +import type { JsonValue } from '@internal/contract/types'; +import { + type CodecLookup, + describeDeclarations, + isCompatible, + type Literal, + type LiteralTypeDeclaration, + type LiteralTypeName, + materializeCodec, + readLiteral, + type WrittenLiteral, +} from '@internal/framework-components/codec'; +import type { ContributedPslDiagnosticCode } from '@internal/framework-components/psl-ast'; +import type { AuthoredColumnDefaultLiteralValue } from '@internal/sql-contract-ts/contract-builder'; +import { blindCast } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; +import { InternalError } from '@internal/utils/internal-error'; + +/** A `` json`...` `` body that is not a JSON document. */ +export const PSL_INVALID_JSON_LITERAL: ContributedPslDiagnosticCode = 'PSL_INVALID_JSON_LITERAL'; + +/** A literal the column's codec declares it accepts but refuses to decode, and a literal no contract source can write. */ +export const PSL_INVALID_DEFAULT_LITERAL: ContributedPslDiagnosticCode = + 'PSL_INVALID_DEFAULT_LITERAL'; + +/** A literal whose type the column's codec does not accept. */ +export const PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE: ContributedPslDiagnosticCode = + 'PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE'; + +export interface LiteralDefaultColumn { + readonly codecId: string; + readonly typeParams?: Record | undefined; +} + +/** The column's `typeParams` as the codec reference carries them, so `vector(3)` checks its length. */ +function codecRefTypeParams( + typeParams: Record | undefined, +): JsonValue | undefined { + return typeParams === undefined + ? undefined + : blindCast( + typeParams, + ); +} + +export type LiteralDefaultResult = + | { readonly ok: true; readonly value: AuthoredColumnDefaultLiteralValue } + | { readonly ok: false; readonly code: string; readonly message: string }; + +/** + * Why a literal default was refused, in parts, so each contract source words its own diagnostic: + * PSL says `pg/int4@1 is not compatible with a decimal literal`, the Prisma 7 reader says what a + * Prisma 7 user needs to hear, and neither restates the other's phrasing. + */ +export type LiteralDefaultRefusal = { + /** Which element of a list literal the refusal is about; `undefined` when it is about the whole literal. */ + readonly elementIndex: number | undefined; +} & ( + | { + readonly kind: 'unreadable'; + readonly reason: 'invalid-json' | 'invalid-number'; + readonly message: string; + } + | { + readonly kind: 'incompatible'; + readonly codecId: string; + readonly literalType: string; + /** What the codec accepts instead, as {@link describeDeclarations} words it. */ + readonly accepts: string; + } + | { readonly kind: 'undecodable'; readonly codecId: string; readonly message: string } +); + +export type ReadLiteralDefaultResult = + | { readonly ok: true; readonly value: AuthoredColumnDefaultLiteralValue } + | { readonly ok: false; readonly refusal: LiteralDefaultRefusal }; + +/** The written literal a tagged literal's body is, given the literal type its tag names. */ +export function writtenLiteralForTagBody( + literalType: LiteralTypeName, + text: string, +): WrittenLiteral { + switch (literalType) { + case 'json': + return { kind: 'json', text }; + case 'string': + return { kind: 'string', text }; + case 'boolean': + return { kind: 'boolean', value: text === 'true' }; + case 'i8': + case 'i16': + case 'i32': + case 'i64': + case 'bigint': + case 'decimal': + case 'float': + return { kind: 'number', text }; + } +} + +const REFUSAL_CODES = { + 'invalid-json': PSL_INVALID_JSON_LITERAL, + 'invalid-number': PSL_INVALID_DEFAULT_LITERAL, +} as const; + +/** Where in a list literal a diagnostic is about, for a message: ` at element 2`. */ +function at(elementIndex: number | undefined): string { + return elementIndex === undefined ? '' : ` at element ${elementIndex + 1}`; +} + +const VOWEL = /^[aeiou]/; + +/** A literal type in a diagnostic, with its article: `a decimal literal`, `an i64 literal`. */ +export function describeLiteralType(literalType: string): string { + return `${VOWEL.test(literalType) ? 'an' : 'a'} ${literalType} literal`; +} + +/** How a literal's type reads in a diagnostic: `bigint`, `string`, or `list` for a list literal. */ +function literalTypeName(literal: Literal): string { + return typeof literal.type === 'string' ? literal.type : 'list'; +} + +function scalarDeclarations( + declarations: readonly LiteralTypeDeclaration[], +): readonly LiteralTypeDeclaration[] { + return declarations.filter((declaration) => typeof declaration === 'string'); +} + +/** + * Reads one `@default(...)` literal for a column, refusing in parts so each contract source words + * its own diagnostic. `isList` selects the check: a list column's elements are each checked and + * decoded against the element codec's scalar declarations, while a scalar column's literal is + * checked whole — so a codec declaring `{ list: [...] }` takes a PSL list on a column that is not a + * list. + */ +export function readLiteralDefault(input: { + readonly written: WrittenLiteral; + readonly isList: boolean; + readonly column: LiteralDefaultColumn; + readonly codecLookup: CodecLookup | undefined; + readonly fieldPath: string; +}): ReadLiteralDefaultResult { + const read = readLiteral(input.written); + if (!read.ok) { + return { + ok: false, + refusal: { + kind: 'unreadable', + reason: read.reason, + message: read.message, + elementIndex: read.elementIndex, + }, + }; + } + + const descriptorFor = input.codecLookup?.descriptorFor; + if (descriptorFor === undefined) { + throw new InternalError( + `Field "${input.fieldPath}": the codec lookup resolving column codecs exposes no descriptorFor, but the column was resolved from a codec descriptor.`, + ); + } + const descriptor = descriptorFor(input.column.codecId); + if (descriptor === undefined) { + throw new InternalError( + `Field "${input.fieldPath}": no codec descriptor is registered for "${input.column.codecId}", but the column was resolved from one.`, + ); + } + + const declared = descriptor.literalTypes ?? []; + const declarations = input.isList ? scalarDeclarations(declared) : declared; + const incompatible = ( + literalType: string, + elementIndex: number | undefined, + ): ReadLiteralDefaultResult => ({ + ok: false, + refusal: { + kind: 'incompatible', + codecId: input.column.codecId, + literalType, + accepts: describeDeclarations(declarations), + elementIndex, + }, + }); + + const typeParams = codecRefTypeParams(input.column.typeParams); + const codec = materializeCodec( + descriptor, + { codecId: input.column.codecId, ...ifDefined('typeParams', typeParams) }, + { name: input.fieldPath }, + ); + const decode = (value: JsonValue, elementIndex: number | undefined): ReadLiteralDefaultResult => { + try { + return { + ok: true, + value: blindCast< + AuthoredColumnDefaultLiteralValue, + 'a codec decodes its own JSON form into the application value the contract builder accepts' + >(codec.decodeJson(value)), + }; + } catch (error) { + return { + ok: false, + refusal: { + kind: 'undecodable', + codecId: input.column.codecId, + message: error instanceof Error ? error.message : String(error), + elementIndex, + }, + }; + } + }; + + if (!input.isList) { + if (!isCompatible(read.literal, declarations)) { + return incompatible(literalTypeName(read.literal), undefined); + } + return decode(blindCast(read.literal.value), undefined); + } + + if (input.written.kind !== 'list') { + throw new InternalError( + `Field "${input.fieldPath}": a list column's default was read as a ${input.written.kind} literal rather than a list.`, + ); + } + + const decoded: AuthoredColumnDefaultLiteralValue[] = []; + for (const [elementIndex, written] of input.written.elements.entries()) { + // Each element is read on its own so its own type and position are both in hand; the whole-list + // read above has already refused anything unreadable. + const element = readLiteral(written); + if (!element.ok || typeof element.literal.type !== 'string') { + throw new InternalError( + `Field "${input.fieldPath}": element ${elementIndex + 1} read differently on its own than as part of the list literal.`, + ); + } + if (!isCompatible(element.literal, declarations)) { + return incompatible(element.literal.type, elementIndex); + } + const result = decode(element.literal.value, elementIndex); + if (!result.ok) return result; + decoded.push(result.value); + } + return { ok: true, value: decoded }; +} + +/** {@link readLiteralDefault} worded as a PSL diagnostic's code and message; the caller adds the provenance. */ +export function lowerLiteralDefault(input: { + readonly written: WrittenLiteral; + readonly isList: boolean; + readonly column: LiteralDefaultColumn; + readonly codecLookup: CodecLookup | undefined; + readonly fieldPath: string; +}): LiteralDefaultResult { + const read = readLiteralDefault(input); + if (read.ok) return read; + const { refusal } = read; + const where = `Field "${input.fieldPath}"${at(refusal.elementIndex)}`; + switch (refusal.kind) { + case 'unreadable': + return { + ok: false, + code: REFUSAL_CODES[refusal.reason], + message: `${where}: ${refusal.message}`, + }; + case 'incompatible': + return { + ok: false, + code: PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE, + message: `${where}: ${refusal.codecId} is not compatible with ${describeLiteralType(refusal.literalType)}; it accepts ${refusal.accepts}`, + }; + case 'undecodable': + return { + ok: false, + code: PSL_INVALID_DEFAULT_LITERAL, + message: `${where}: ${refusal.message}`, + }; + } +} 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 46bc3c3527c1..28076121ea1b 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 @@ -21,12 +21,17 @@ import { isAuthoringTypeConstructorDescriptor, validateAuthoringHelperArguments, } from '@internal/framework-components/authoring'; -import type { AnyCodecDescriptor, CodecLookup } from '@internal/framework-components/codec'; +import type { + AnyCodecDescriptor, + CodecLookup, + WrittenLiteral, +} from '@internal/framework-components/codec'; import { type ControlDefaultLiteralTagRegistry, type ControlMutationDefaultRegistry, type DefaultFunctionLoweringContext, describeTaggedLiteralFailure, + isDefaultLiteralTagLoweringEntry, type MutationDefaultGeneratorDescriptor, } from '@internal/framework-components/control'; import type { @@ -44,17 +49,18 @@ import { type PslDiagnosticCollector, } from '@internal/psl-parser'; import type { PslSources } from '@internal/psl-parser/syntax'; -import type { - AuthoredColumnDefault, - AuthoredColumnDefaultLiteralValue, -} from '@internal/sql-contract-ts/contract-builder'; +import type { AuthoredColumnDefault } from '@internal/sql-contract-ts/contract-builder'; import { InternalError } from '@internal/utils/internal-error'; import { contractError } from './contract-errors'; import { type LoweredPslDefaultResult, lowerDefaultFunctionWithRegistry, } from './default-function-registry'; -import { numberLiteralDefault } from './number-literal-default'; +import { + lowerLiteralDefault, + PSL_INVALID_DEFAULT_LITERAL, + writtenLiteralForTagBody, +} from './literal-default'; import { mapPslHelperArgs } from './psl-authoring-arguments'; import { @@ -707,12 +713,17 @@ const TAGGED_LITERAL_CANONICALIZATION_CODES = { 'too-large': 'PSL_TAGGED_LITERAL_TOO_LARGE', } as const; +/** A tag naming a literal type yields the written literal its body is; every other tag lowers itself. */ +type TaggedLiteralLowering = + | LoweredPslDefaultResult + | { readonly ok: true; readonly written: WrittenLiteral }; + function lowerTaggedLiteral( literal: ParsedTaggedLiteral, registry: ControlDefaultLiteralTagRegistry, context: DefaultFunctionLoweringContext, source: DiagnosticSource, -): LoweredPslDefaultResult { +): TaggedLiteralLowering { const reject = (code: string, message: string): LoweredPslDefaultResult => ({ ok: false, kind: 'owned', @@ -736,6 +747,12 @@ function lowerTaggedLiteral( describeTaggedLiteralFailure(canonicalization.reason), ); } + if (!isDefaultLiteralTagLoweringEntry(entry)) { + return { + ok: true, + written: writtenLiteralForTagBody(entry.literalType, canonicalization.body), + }; + } const result = entry.lower({ literal: { tag: literal.tag, body: canonicalization.body, span: literal.span }, context, @@ -784,82 +801,129 @@ 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 context: DefaultFunctionLoweringContext = { - sourceId: input.sources.sourceFileFor(node.syntax).filename, - modelName: input.modelName, - fieldName: input.fieldName, - columnCodecId: input.columnDescriptor.codecId, - }; - const lowered = - 'tag' in value - ? lowerTaggedLiteral(value, input.defaultLiteralTagRegistry, context, source) - : lowerDefaultFunctionWithRegistry({ - call: value, - registry: input.defaultFunctionRegistry, - context, - source, - }); - + const context: DefaultFunctionLoweringContext = { + sourceId: input.sources.sourceFileFor(node.syntax).filename, + modelName: input.modelName, + fieldName: input.fieldName, + columnCodecId: input.columnDescriptor.codecId, + }; + const readAsLiteral = (written: WrittenLiteral) => { + const lowered = lowerLiteralDefault({ + written, + isList: input.field.list, + column: input.columnDescriptor, + codecLookup: input.codecLookup, + fieldPath: `${input.modelName}.${input.fieldName}`, + }); if (!lowered.ok) { - if (lowered.kind === 'owned') input.diagnostics.push(lowered.diagnostic); - else input.diagnostics.pushExternal(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.`, - ...source.at(value.span), + code: lowered.code, + message: lowered.message, + ...source.at(), }); return {}; } + return { defaultValue: { kind: 'literal' as const, value: lowered.value } }; + }; - // 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) { + const writtenElement = ( + element: string | boolean | NumLiteral | ParsedTaggedLiteral, + ): WrittenLiteral | { readonly ok: false } => { + if (typeof element === 'string') return { kind: 'string', text: element }; + if (typeof element === 'boolean') return { kind: 'boolean', value: element }; + if ('text' in element) return { kind: 'number', text: element.text }; + const lowered = lowerTaggedLiteral(element, input.defaultLiteralTagRegistry, context, source); + if (!lowered.ok) { + if (lowered.kind === 'owned') input.diagnostics.push(lowered.diagnostic); + else input.diagnostics.pushExternal(lowered.diagnostic); + return { ok: false }; + } + if (!('written' in lowered)) { 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.`, - ...source.at(value.span), + code: PSL_INVALID_DEFAULT_LITERAL, + message: `Literal tag "${element.tag}" produces a default of its own and cannot be an element of a list literal.`, + ...source.at(element.span), }); - return {}; + return { ok: false }; } + return lowered.written; + }; - 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}".`, - ...source.at(value.span), - }); - return {}; + if (Array.isArray(value)) { + const elements: WrittenLiteral[] = []; + for (const element of value) { + const written = writtenElement(element); + if ('ok' in written) return {}; + elements.push(written); } + return readAsLiteral({ kind: 'list', elements }); + } + + // A column bound to a value set (`pg.enum(Ref)`) takes a member name, which is checked against the + // value set rather than read as a literal; its codec accepts no literal default at all. + if (input.columnDescriptor.valueSet !== undefined && typeof value === 'string') { + return { defaultValue: { kind: 'literal', value } }; + } + + if (typeof value === 'string') return readAsLiteral({ kind: 'string', text: value }); + if (typeof value === 'boolean') return readAsLiteral({ kind: 'boolean', value }); + + if ('text' in value) { + return readAsLiteral({ kind: 'number', text: value.text }); + } + + const lowered = + 'tag' in value + ? lowerTaggedLiteral(value, input.defaultLiteralTagRegistry, context, source) + : lowerDefaultFunctionWithRegistry({ + call: value, + registry: input.defaultFunctionRegistry, + context, + source, + }); + + if (!lowered.ok) { + if (lowered.kind === 'owned') input.diagnostics.push(lowered.diagnostic); + else input.diagnostics.pushExternal(lowered.diagnostic); + return {}; + } + + if ('written' in lowered) return readAsLiteral(lowered.written); + + 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.`, + ...source.at(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.`, + ...source.at(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}".`, + ...source.at(value.span), + }); + return {}; } - return { defaultValue: { kind: 'literal', value } }; + return { executionDefaults: { onCreate: lowered.value.generated } }; } 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 d24523b8793c..aa56408da61b 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 @@ -171,33 +171,27 @@ const mapFieldSpec = fieldAttribute('map', { refine: validateMappedName, }); -type DefaultArgValue = - | string - | NumLiteral - | boolean - | (string | NumLiteral | boolean)[] - | TypedFuncCall - | ParsedTaggedLiteral; +type DefaultLiteralElement = string | NumLiteral | boolean | ParsedTaggedLiteral; + +type DefaultArgValue = DefaultLiteralElement | DefaultLiteralElement[] | TypedFuncCall; function scalarDefaultArms( isList: boolean, registries: ControlDefaultRegistries, ): readonly [ArgType, ...ArgType[]] { - const literal = () => oneOf(str(), numLiteral(), bool()); - const tagEntries = [...registries.defaultLiteralTagRegistry]; - const tagArms = - tagEntries.length > 0 - ? [ - taggedLiteral( - tagEntries.map(([tag]) => tag), - { - documentation: [...new Set(tagEntries.map(([, entry]) => entry.documentation))].join( - ' ', - ), - }, - ), - ] - : []; + // One arm per distinct documentation, so each tag's completion and signature help carries the + // text of the tag it names rather than every registered tag's text run together. + const tagsByDocumentation = new Map(); + for (const [tag, entry] of registries.defaultLiteralTagRegistry) { + const tags = tagsByDocumentation.get(entry.documentation); + if (tags === undefined) tagsByDocumentation.set(entry.documentation, [tag]); + else tags.push(tag); + } + const tagArms = () => + [...tagsByDocumentation].map(([documentation, tags]) => taggedLiteral(tags, { documentation })); + // A list element may itself be a tagged literal, so `Jsonb[] @default([json`{}`])` parses. + const literal = () => oneOf(str(), numLiteral(), bool(), ...tagArms()); + const listArm = () => list(literal(), { label: `list of (${literal().label})` }); const funcArms = [...registries.defaultFunctionRegistry.entries()].map(([name, entry]) => funcCall( name, @@ -207,9 +201,11 @@ function scalarDefaultArms( >(entry.signature), ), ); + // A scalar column takes a list literal too: a codec such as `pg/vector@1` declares a list of + // element types, and its value is written as a PSL list on a column that is not a list. return isList - ? [list(literal()), ...funcArms, ...tagArms] - : [str(), numLiteral(), bool(), ...funcArms, ...tagArms]; + ? [listArm(), ...funcArms, ...tagArms()] + : [str(), numLiteral(), bool(), ...funcArms, ...tagArms(), listArm()]; } function noEnumMember(): RejectingArgType { diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixture-codec-descriptors.ts b/packages/2-sql/2-authoring/contract-psl/test/fixture-codec-descriptors.ts new file mode 100644 index 000000000000..de063efd4e08 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/fixture-codec-descriptors.ts @@ -0,0 +1,205 @@ +/** + * Codec descriptors for the interpreter fixtures. A literal default resolves through the column's + * codec descriptor, so the fixture lookup carries one per codec: what it accepts as a literal and + * how it decodes one, mirroring the real Postgres codecs closely enough for the interpreter's + * literal path. `test/integration` covers the real packs. + */ + +import type { JsonValue } from '@internal/contract/types'; +import { + type AnyCodecDescriptor, + type CodecLookup, + type CodecTrait, + integerLiteralTypesUpTo, + isNonFiniteText, + isNumeralText, + type LiteralTypeDeclaration, + voidParamsSchema, +} from '@internal/framework-components/codec'; +import { blindCast } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; + +const targetTypesByCodecId: Record = { + 'pg/text@1': ['text'], + 'pg/int@1': ['int4'], + 'pg/bool@1': ['bool'], + 'pg/int4@1': ['int4'], + 'pg/int8@1': ['int8'], + 'pg/float8@1': ['float8'], + 'pg/numeric@1': ['numeric'], + 'pg/timestamptz-temporal@1': ['timestamptz'], + 'pg/jsonb@1': ['jsonb'], + 'pg/bytea@1': ['bytea'], + 'sql/char@1': ['character'], + 'sql/varchar@1': ['character varying'], + 'pg/int2@1': ['int2'], + 'pg/float4@1': ['float4'], + 'pg/timestamp-temporal@1': ['timestamp'], + 'pg/date-temporal@1': ['date'], + 'pg/time-temporal@1': ['time'], + 'pg/timetz@1': ['timetz'], + 'pg/json@1': ['json'], + 'pg/vector@1': ['vector'], +}; + +const wholeNumbers = integerLiteralTypesUpTo('i64'); + +/** + * What each fixture codec accepts as a literal default and how it decodes one. Mirrors the real + * Postgres codecs closely enough for the interpreter's literal path; `test/integration` covers the + * real packs. + */ +const fixtureCodecs: Readonly< + Record< + string, + { + readonly traits: readonly CodecTrait[]; + readonly literalTypes?: readonly LiteralTypeDeclaration[]; + readonly encodeJson?: (value: unknown) => JsonValue; + readonly decodeJson: (json: JsonValue, typeParams: Record) => unknown; + } + > +> = (() => { + const asText = (json: JsonValue): string => { + if (typeof json !== 'string') throw new Error('value must be text'); + return json; + }; + const asNumber = (json: JsonValue): number => { + if (typeof json === 'number') return json; + if (typeof json === 'string' && (isNumeralText(json) || isNonFiniteText(json))) { + return Number(json); + } + throw new Error('value must be a number'); + }; + const text = { + traits: ['equality', 'order', 'textual'] as const, + literalTypes: ['string'] as const, + decodeJson: asText, + }; + const wholeNumber = (accepts: readonly LiteralTypeDeclaration[]) => ({ + traits: ['equality', 'order', 'numeric'] as const, + literalTypes: accepts, + decodeJson: (json: JsonValue): number => { + const value = asNumber(json); + if (!Number.isInteger(value)) throw new Error('value must be a whole number'); + return value; + }, + }); + const json = { + traits: ['equality'] as const, + literalTypes: ['json'] as const, + decodeJson: (value: JsonValue) => value, + }; + return { + 'pg/text@1': text, + 'sql/char@1': text, + 'sql/varchar@1': text, + 'pg/bytea@1': { + traits: ['equality'] as const, + literalTypes: ['string'] as const, + decodeJson: asText, + }, + 'pg/timestamptz-temporal@1': text, + 'pg/timestamp-temporal@1': text, + 'pg/date-temporal@1': text, + 'pg/time-temporal@1': text, + 'pg/timetz@1': text, + 'pg/bool@1': { + traits: ['equality', 'boolean'] as const, + literalTypes: ['boolean'] as const, + decodeJson: (value: JsonValue) => { + if (typeof value !== 'boolean') throw new Error('value must be a boolean'); + return value; + }, + }, + 'pg/int2@1': wholeNumber(integerLiteralTypesUpTo('i16')), + 'pg/int4@1': wholeNumber(integerLiteralTypesUpTo('i32')), + 'pg/int@1': wholeNumber(integerLiteralTypesUpTo('i32')), + 'pg/int8@1': { + traits: ['equality', 'order', 'numeric'] as const, + literalTypes: wholeNumbers, + encodeJson: (value: unknown) => String(value), + decodeJson: (value: JsonValue) => BigInt(typeof value === 'number' ? value : asText(value)), + }, + 'pg/numeric@1': { + traits: ['equality', 'order', 'numeric'] as const, + literalTypes: [...wholeNumbers, 'bigint', 'decimal', 'float'] as const, + decodeJson: (value: JsonValue) => (typeof value === 'number' ? String(value) : asText(value)), + }, + 'pg/float4@1': { + traits: ['equality', 'order', 'numeric'] as const, + literalTypes: [...wholeNumbers, 'bigint', 'decimal', 'float'] as const, + decodeJson: asNumber, + }, + 'pg/float8@1': { + traits: ['equality', 'order', 'numeric'] as const, + literalTypes: [...wholeNumbers, 'bigint', 'decimal', 'float'] as const, + decodeJson: asNumber, + }, + 'pg/json@1': json, + 'pg/jsonb@1': json, + 'pg/vector@1': { + traits: ['equality'] as const, + literalTypes: [{ list: [...wholeNumbers, 'bigint', 'decimal'] }] as const, + decodeJson: (value: JsonValue, typeParams: Record) => { + if (!Array.isArray(value)) throw new Error('Vector value must be an array of numbers'); + const elements = value.map(asNumber); + if (elements.length !== typeParams['length']) { + throw new Error( + `Vector length mismatch: expected ${String(typeParams['length'])}, got ${elements.length}`, + ); + } + return elements; + }, + }, + }; +})(); + +/** Passes `typeParams` through: the fixture vector type constructor already validates its length. */ +const vectorParamsSchema: AnyCodecDescriptor['paramsSchema'] = { + '~standard': { + version: 1, + vendor: 'contract-psl-fixtures', + validate: (value: unknown) => ({ value }), + }, +}; + +/** A descriptor for a fixture codec, parameterized only for `pg/vector@1`, whose length the codec checks. */ +function fixtureDescriptor(codecId: string): AnyCodecDescriptor | undefined { + const codec = fixtureCodecs[codecId]; + if (codec === undefined) return undefined; + const parameterized = codecId === 'pg/vector@1'; + return { + codecId, + traits: codec.traits, + targetTypes: targetTypesByCodecId[codecId] ?? [], + ...ifDefined('literalTypes', codec.literalTypes), + paramsSchema: parameterized ? vectorParamsSchema : voidParamsSchema, + isParameterized: parameterized, + factory: (params: unknown) => () => ({ + id: codecId, + encode: async (value: unknown) => + blindCast(value), + decode: async (wire: unknown) => wire, + encodeJson: (value: unknown) => + codec.encodeJson === undefined + ? blindCast(value) + : codec.encodeJson(value), + decodeJson: (value: JsonValue) => + codec.decodeJson( + value, + blindCast, 'the fixture vector schema passes typeParams through'>( + params ?? {}, + ), + ), + }), + }; +} + +export const postgresCodecLookup: CodecLookup = { + // A representative instance, built with no params — the same shape the control stack builds. + get: (id: string) => fixtureDescriptor(id)?.factory({})({ name: id }), + descriptorFor: fixtureDescriptor, + targetTypesFor: (id: string) => targetTypesByCodecId[id], + renderOutputTypeFor: () => undefined, +}; 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 893494b7768f..9b466878184b 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -18,10 +18,11 @@ import { type PslExtensionBlock, resolveEnumCodecId, } from '@internal/framework-components/authoring'; -import type { CodecLookup } from '@internal/framework-components/codec'; +import { jsonDefaultLiteralTagEntry } from '@internal/framework-components/codec'; import type { ExtensionPackRef, TargetPackRef } from '@internal/framework-components/components'; import type { ControlDefaultLiteralTagEntry, + ControlDefaultLiteralTagLoweringEntry, ControlMutationDefaultEntry, ControlMutationDefaults, DefaultFunctionLoweringContext, @@ -44,6 +45,7 @@ import { checkSqlDefaultBody, reservedSqlDefaultBody } from '@internal/sql-contr import { type EnumTypeHandle, enumType } from '@internal/sql-contract-ts/contract-builder'; import { blindCast } from '@internal/utils/casts'; import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; +import { postgresCodecLookup } from './fixture-codec-descriptors'; function testEnumFactory( block: PslExtensionBlock, @@ -544,37 +546,7 @@ export const sqliteScalarColumnDescriptors = collectScalarTypeConstructors( sqliteScalarAuthoringTypes, ); -const targetTypesByCodecId: Record = { - 'pg/text@1': ['text'], - 'pg/int@1': ['int4'], - 'pg/bool@1': ['bool'], - 'pg/int4@1': ['int4'], - 'pg/int8@1': ['int8'], - 'pg/float8@1': ['float8'], - 'pg/numeric@1': ['numeric'], - 'pg/timestamptz-temporal@1': ['timestamptz'], - 'pg/jsonb@1': ['jsonb'], - 'pg/bytea@1': ['bytea'], - 'sql/char@1': ['character'], - 'sql/varchar@1': ['character varying'], - 'pg/int2@1': ['int2'], - 'pg/float4@1': ['float4'], - 'pg/timestamp-temporal@1': ['timestamp'], - 'pg/date-temporal@1': ['date'], - 'pg/time-temporal@1': ['time'], - 'pg/timetz@1': ['timetz'], - 'pg/json@1': ['json'], - 'pg/vector@1': ['vector'], -}; - -export const postgresCodecLookup: CodecLookup = { - get: (id: string) => { - if (!targetTypesByCodecId[id]) return undefined; - return { id } as ReturnType; - }, - targetTypesFor: (id: string) => targetTypesByCodecId[id], - renderOutputTypeFor: () => undefined, -}; +export { postgresCodecLookup } from './fixture-codec-descriptors'; export function createPostgresTestContext( overrides?: Partial, @@ -645,7 +617,7 @@ const dbgeneratedSig: FuncCallSig = { }; // Mirrors the SQL family's `sqlDefaultLiteralTagEntry`; the authoring layer's tests cannot import the family. -function sqlLiteralTagEntry(usage: string): ControlDefaultLiteralTagEntry { +function sqlLiteralTagEntry(usage: string): ControlDefaultLiteralTagLoweringEntry { return { usage, documentation: "Uses the SQL in the string, verbatim, as the column's default expression.", @@ -777,6 +749,7 @@ export function createBuiltinLikeControlMutationDefaults(): ControlMutationDefau defaultLiteralTagRegistry: new Map([ ['sql', sqlLiteralTagEntry('sql`...`')], ['pg.sql', sqlLiteralTagEntry('pg.sql`...`')], + ['json', jsonDefaultLiteralTagEntry()], ]), generatorDescriptors: [ { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter-defaults-support.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter-defaults-support.ts index 02b07445a8b2..3536c8439ae8 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter-defaults-support.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter-defaults-support.ts @@ -5,6 +5,7 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, postgresNativeScalarTypeDescriptors, postgresTarget, temporalCodecPresetMirrors, @@ -32,6 +33,8 @@ export const interpretPslDocumentToSqlContract = ( input; return interpretPslDocumentToSqlContractInternal({ target: postgresTarget, + // Literal defaults resolve through the column's codec descriptor, as they do in a real stack. + codecLookup: postgresCodecLookup, scalarColumnDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.literal-types.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.literal-types.test.ts new file mode 100644 index 000000000000..9a530b63181f --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.literal-types.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; +import { interpretPslDocumentToSqlContract } from '../src/interpreter'; +import { + createBuiltinLikeControlMutationDefaults, + pgvectorAuthoringContributions, + postgresCodecLookup, + postgresNativeScalarTypeDescriptors, + postgresTarget, + symbolTableInputFromParseArgs, +} from './fixtures'; +import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contract-storage'; +import { unboundTables } from './unbound-tables'; + +function interpret(schema: string, codecLookup = postgresCodecLookup) { + const document = symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }); + return interpretPslDocumentToSqlContract({ + ...document, + target: postgresTarget, + scalarColumnDescriptors: postgresNativeScalarTypeDescriptors, + authoringContributions: pgvectorAuthoringContributions, + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, + controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), + codecLookup, + }); +} + +function columnDefaults(schema: string) { + const result = interpret(schema); + if (!result.ok) throw new Error(JSON.stringify(result.failure.diagnostics)); + const table = unboundTables(sqlStorageFromSuccessfulSqlInterpretation(result.value))['N']; + return Object.fromEntries( + Object.entries(table?.columns ?? {}).flatMap(([name, column]) => + column.default === undefined ? [] : [[name, column.default]], + ), + ); +} + +function diagnostics(schema: string) { + const result = interpret(schema); + expect(result.ok).toBe(false); + return result.ok ? [] : result.failure.diagnostics; +} + +const model = (fields: string) => `model N {\n id Int @id\n${fields}\n}\n`; + +describe('literal defaults the codec accepts', () => { + it('reads every literal form in the outcome schema', () => { + expect( + columnDefaults( + model(` name String @default("anonymous") + small SmallInt @default(100) + count Int @default(100000) + balance BigInt @default(100000000000000099) + price Decimal @default(1.50) + ratio Float @default(NaN) + active Boolean @default(true) + meta Jsonb @default(json\`{ "plan": "free", "seats": 1 }\`) + scores Int[] @default([1, 2]) + docs Jsonb[] @default([json\`{}\`, json\`[]\`]) + embed pgvector.Vector(3) @default([0.1, 0.2, 0.3]) + expires DateTime @default(sql\`now() + interval '3 days'\`)`), + ), + ).toEqual({ + name: { kind: 'literal', value: 'anonymous' }, + small: { kind: 'literal', value: 100 }, + count: { kind: 'literal', value: 100000 }, + balance: { kind: 'literal', value: '100000000000000099' }, + price: { kind: 'literal', value: '1.50' }, + ratio: { kind: 'literal', value: Number.NaN }, + active: { kind: 'literal', value: true }, + meta: { kind: 'literal', value: { plan: 'free', seats: 1 } }, + scores: { kind: 'literal', value: [1, 2] }, + docs: { kind: 'literal', value: [{}, []] }, + embed: { kind: 'literal', value: [0.1, 0.2, 0.3] }, + expires: { kind: 'function', expression: "now() + interval '3 days'" }, + }); + }); + + it.each([ + ['a whole number on a float column', 'ratio Float @default(1)', 'ratio', 1], + ['a whole number on a decimal column', 'price Decimal @default(42)', 'price', '42'], + ['a whole number on a bigint column', 'balance BigInt @default(42)', 'balance', '42'], + ['a decimal keeping its trailing zeros', 'price Decimal @default(1.50)', 'price', '1.50'], + ['leading zeros dropped', 'price Decimal @default(007.50)', 'price', '7.50'], + ['the sign of zero dropped', 'price Decimal @default(-0.0)', 'price', '0.0'], + [ + 'Infinity on a float column', + 'ratio Float @default(Infinity)', + 'ratio', + Number.POSITIVE_INFINITY, + ], + ['a json null', 'meta Jsonb @default(json`null`)', 'meta', null], + ])('reads %s', (_name, field, column, expected) => { + expect(columnDefaults(model(` ${field}`))[column]).toEqual({ + kind: 'literal', + value: expected, + }); + }); + + it('emits a bigint default as the decimal text its codec encodes', () => { + expect(columnDefaults(model(' balance BigInt @default(9007199254740993)'))['balance']).toEqual( + { + kind: 'literal', + value: '9007199254740993', + }, + ); + }); +}); + +describe('literal defaults the codec refuses', () => { + it.each([ + [ + 'a bigint literal on an int column', + 'count Int @default(100000000000000099)', + 'N.count": pg/int4@1 is not compatible with an i64 literal; it accepts i8, i16, i32 literals', + ], + [ + 'a decimal literal on an int column', + 'count Int @default(1.5)', + 'N.count": pg/int4@1 is not compatible with a decimal literal; it accepts i8, i16, i32 literals', + ], + [ + 'a string literal on a jsonb column', + 'meta Jsonb @default("{}")', + 'N.meta": pg/jsonb@1 is not compatible with a string literal; it accepts json literals', + ], + [ + 'a string literal on a decimal column', + 'price Decimal @default("1.50")', + 'N.price": pg/numeric@1 is not compatible with a string literal;', + ], + [ + 'a string literal on an int column', + 'count Int @default("1")', + 'N.count": pg/int4@1 is not compatible with a string literal;', + ], + [ + 'a json literal on an int column', + 'count Int @default(json`1`)', + 'N.count": pg/int4@1 is not compatible with a json literal;', + ], + [ + 'a list literal on a column whose codec names no list', + 'count Int @default([1, 2])', + 'N.count": pg/int4@1 is not compatible with a list literal;', + ], + [ + 'a string element in a list of ints', + 'scores Int[] @default([1, "x"])', + 'N.scores" at element 2: pg/int4@1 is not compatible with a string literal; it accepts i8, i16, i32 literals', + ], + [ + 'a list literal on a jsonb column', + 'meta Jsonb @default([1, 2])', + 'N.meta": pg/jsonb@1 is not compatible with a list literal; it accepts json literals', + ], + ])('refuses %s as incompatible', (_name, field, message) => { + expect(diagnostics(model(` ${field}`))).toEqual([ + expect.objectContaining({ + code: 'PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE', + message: expect.stringContaining(message), + sourceId: 'schema.prisma', + span: expect.objectContaining({ start: expect.objectContaining({ line: 3 }) }), + }), + ]); + }); + + it('refuses a json body that is not a JSON document', () => { + expect(diagnostics(model(' meta Jsonb @default(json`{ plan }`)'))).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_JSON_LITERAL', + message: expect.stringContaining('N.meta'), + }), + ]); + }); + + it('refuses a vector whose length does not match the column, with the codec message', () => { + expect(diagnostics(model(' embed pgvector.Vector(3) @default([1, 2])'))).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_DEFAULT_LITERAL', + message: expect.stringContaining('Vector length mismatch: expected 3, got 2'), + }), + ]); + }); + + it('refuses a non-finite literal on a codec that names no float', () => { + expect(diagnostics(model(' count Int @default(NaN)'))).toEqual([ + expect.objectContaining({ + code: 'PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE', + message: expect.stringContaining( + 'pg/int4@1 is not compatible with a float literal; it accepts i8, i16, i32 literals', + ), + }), + ]); + }); + + it('refuses a number on a column whose codec names only string', () => { + expect(diagnostics(model(' payload Bytes @default(1234)'))).toEqual([ + expect.objectContaining({ + code: 'PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE', + message: expect.stringContaining( + 'pg/bytea@1 is not compatible with an i16 literal; it accepts string literals', + ), + }), + ]); + }); +}); + +describe('the codec lookup the column was resolved from', () => { + it('raises an internal error when it exposes no descriptorFor', () => { + const { descriptorFor: _descriptorFor, ...withoutDescriptors } = postgresCodecLookup; + expect(() => interpret(model(' count Int @default(1)'), withoutDescriptors)).toThrow( + 'exposes no descriptorFor', + ); + }); + + it('raises an internal error when the column codec has no descriptor', () => { + expect(() => + interpret(model(' count Int @default(1)'), { + ...postgresCodecLookup, + descriptorFor: () => undefined, + }), + ).toThrow('no codec descriptor is registered for "pg/int4@1"'); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts index edcba34d1e00..76f5a4430461 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts @@ -3,6 +3,7 @@ import { createTestSqlNamespace } from '../../../1-core/contract/test/test-suppo import { interpretPslDocumentToSqlContract as interpretPslDocumentToSqlContractInternal } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, postgresNativeScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -18,6 +19,7 @@ describe('interpretPslDocumentToSqlContract tagged literal defaults', () => { }); return interpretPslDocumentToSqlContractInternal({ target: postgresTarget, + codecLookup: postgresCodecLookup, scalarColumnDescriptors: postgresNativeScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, @@ -98,7 +100,7 @@ describe('interpretPslDocumentToSqlContract tagged literal defaults', () => { { code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', message: - 'Expected one of: string | number | boolean | autoincrement() | now() | uuid() | cuid() | ulid() | nanoid() | dbgenerated() | sql`...`', + 'Expected one of: string | number | boolean | autoincrement() | now() | uuid() | cuid() | ulid() | nanoid() | dbgenerated() | sql`...` | json`...` | list of (string | number | boolean | sql`...` | json`...`)', sourceId: 'schema.prisma', span: lineThreeSpan(21, 'gen_random_uuid()'.length), }, @@ -109,7 +111,7 @@ describe('interpretPslDocumentToSqlContract tagged literal defaults', () => { expect(diagnostics('v String @default(sqlite.sql`x`)')).toEqual([ { code: 'PSL_UNKNOWN_DEFAULT_LITERAL_TAG', - message: 'Unknown literal tag "sqlite.sql". Known tags: sql, pg.sql.', + message: 'Unknown literal tag "sqlite.sql". Known tags: sql, pg.sql, json.', sourceId: 'schema.prisma', span: lineThreeSpan(21, 'sqlite.sql`x`'.length), }, @@ -183,4 +185,54 @@ describe('interpretPslDocumentToSqlContract tagged literal defaults', () => { expect.objectContaining({ code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED' }), ]); }); + + describe('the json tag', () => { + it('reads a JSON document as the column default', () => { + expect(columnDefault('v Jsonb @default(json`{ "plan": "free" }`)', 'v')).toEqual({ + kind: 'literal', + value: { plan: 'free' }, + }); + }); + + it('reads json`null` as JSON null', () => { + expect(columnDefault('v Jsonb @default(json`null`)', 'v')).toEqual({ + kind: 'literal', + value: null, + }); + }); + + it('reads a json tag inside a list on a jsonb list column', () => { + expect(columnDefault('v Jsonb[] @default([json`{}`, json`[1]`])', 'v')).toEqual({ + kind: 'literal', + value: [{}, [1]], + }); + }); + + it('refuses a body that is not a JSON document', () => { + expect(diagnostics('v Jsonb @default(json`{ plan }`)')).toEqual([ + expect.objectContaining({ code: 'PSL_INVALID_JSON_LITERAL' }), + ]); + }); + + it('refuses a json literal on a column whose codec does not accept one', () => { + expect(diagnostics('v Int @default(json`1`)')).toEqual([ + expect.objectContaining({ + code: 'PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE', + message: expect.stringContaining('is not compatible with a json literal'), + }), + ]); + }); + }); + + it('refuses a lowering tag as an element of a list literal, at the element', () => { + expect(diagnostics('v Jsonb[] @default([json`{}`, sql`md5(x)`])')).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_DEFAULT_LITERAL', + message: + 'Literal tag "sql" produces a default of its own and cannot be an element of a list literal.', + sourceId: 'schema.prisma', + span: lineThreeSpan(33, 11), + }), + ]); + }); }); 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 abcd3e6d7c0f..3ea6ad216705 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, @@ -19,6 +20,7 @@ import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contr const baseInput = { target: postgresTarget, + codecLookup: postgresCodecLookup, scalarColumnDescriptors: postgresNativeScalarTypeDescriptors, authoringContributions: { type: postgresScalarAuthoringTypes }, composedExtensionContracts: new Map(), 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 5656e9eb4a39..67ddd47181ae 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 @@ -16,6 +16,7 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, @@ -79,6 +80,7 @@ const testCodecLookup: CodecLookup = { get(id: string): Codec | undefined { return codecsById[id]; }, + descriptorFor: (id: string) => postgresCodecLookup.descriptorFor?.(id), targetTypesFor(id: string): readonly string[] | undefined { return targetTypesById[id]; }, 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/sql-attribute-specs.test.ts b/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts index 9b6cb9b8a391..6834f7a97760 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 @@ -249,7 +249,12 @@ describe('sqlAttributeSpecs.field.default', () => { 'funcCall', 'funcCall', 'funcCall', + // One tagged-literal arm per distinct tag documentation: the sql tags, then json. 'taggedLiteral', + 'taggedLiteral', + // A codec such as `pg/vector@1` declares a list of element types, so a scalar column takes a + // list literal too; the codec's declaration decides whether one is accepted. + 'list', ]); const uuid = value.alternatives.find( (alt): alt is FuncCallMetadata => @@ -298,12 +303,18 @@ describe('sqlAttributeSpecs.field.default', () => { .filter((alt) => alt.kind === 'funcCall') .map((alt) => (alt as FuncCallMetadata).name), ).toEqual(['autoincrement', 'now', 'uuid', 'cuid', 'ulid', 'nanoid', 'dbgenerated']); - expect(value.alternatives.at(-1)).toMatchObject({ - kind: 'taggedLiteral', - label: 'sql`...`', - tags: ['sql', 'pg.sql'], - documentation: "Uses the SQL in the string, verbatim, as the column's default expression.", - }); + expect(value.alternatives.filter((alt) => alt.kind === 'taggedLiteral')).toMatchObject([ + { + label: 'sql`...`', + tags: ['sql', 'pg.sql'], + documentation: "Uses the SQL in the string, verbatim, as the column's default expression.", + }, + { + label: 'json`...`', + tags: ['json'], + documentation: "Reads the body as a JSON document and stores it as the column's default.", + }, + ]); }); it('exposes enum default alternatives and empty-enum rejection metadata', () => { @@ -413,16 +424,13 @@ model Post { }); }); - it('accepts a list literal on a list field and rejects a list on a scalar field', () => { + it('accepts a list literal on a list field and on a scalar field, where the codec decides', () => { expect( interpretDefault('model Post {\n id Int @id\n tags String[] @default(["a"])\n}\n', 'tags'), ).toEqual({ value: { value: ['a'] }, diagnostics: [] }); - const rejected = interpretDefault( - 'model Post {\n id Int @id\n tag String @default(["a"])\n}\n', - 'tag', - ); - expect(rejected.value).toBeUndefined(); - expect(rejected.diagnostics).toHaveLength(1); + expect( + interpretDefault('model Post {\n id Int @id\n tag String @default(["a"])\n}\n', 'tag'), + ).toEqual({ value: { value: ['a'] }, diagnostics: [] }); }); it('accepts a registered default function and rejects an unregistered one', () => { diff --git a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts index 5447eed1255e..69f1cbf312b9 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts @@ -38,7 +38,12 @@ import { flushAuthoringWarnings, isAuthoringEntityTypeDescriptor, } from '@internal/framework-components/authoring'; -import type { CodecLookup, ColumnTypeDescriptor } from '@internal/framework-components/codec'; +import { + type Codec, + type CodecLookup, + type ColumnTypeDescriptor, + materializeCodec, +} from '@internal/framework-components/codec'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { lowerAuthoredCheck } from '@internal/sql-contract/authored-check-naming'; import { sqlContractCanonicalizationHooks } from '@internal/sql-contract/canonicalization-hooks'; @@ -93,8 +98,35 @@ type DomainFieldRef = | { readonly kind: 'scalar'; readonly many?: boolean } | { readonly kind: 'valueObject'; readonly name: string; readonly many?: boolean }; -function encodeViaCodec(value: unknown, codecId: string, codecLookup?: CodecLookup): JsonValue { - const codec = codecLookup?.get(codecId); +/** + * The codec that encodes one column's default. Built with the column's own `typeParams`, because a + * parameterized codec answers for its params when it encodes — `pg/vector@1` checks the length its + * column declares — and the lookup's representative instance carries none. Only a column has params; + * every other encode site takes the representative instance. + */ +function columnCodec( + codecId: string, + typeParams: Record | undefined, + codecLookup?: CodecLookup, +): Codec | undefined { + const descriptor = codecLookup?.descriptorFor?.(codecId); + if (descriptor === undefined) return codecLookup?.get(codecId); + return materializeCodec( + descriptor, + { + codecId, + ...ifDefined( + 'typeParams', + typeParams === undefined + ? undefined + : blindCast(typeParams), + ), + }, + { name: codecId }, + ); +} + +function encodeViaCodec(value: unknown, codec: Codec | undefined): JsonValue { if (codec) { return codec.encodeJson(value); } @@ -106,8 +138,7 @@ function encodeViaCodec(value: unknown, codecId: string, codecLookup?: CodecLook function encodeColumnDefault( defaultInput: AuthoredColumnDefault, - codecId: string, - codecLookup?: CodecLookup, + codec: Codec | undefined, many = false, ): ColumnDefault { if (defaultInput.kind === 'function') { @@ -122,12 +153,12 @@ function encodeColumnDefault( } return { kind: 'literal', - value: defaultInput.value.map((element) => encodeViaCodec(element, codecId, codecLookup)), + value: defaultInput.value.map((element) => encodeViaCodec(element, codec)), }; } return { kind: 'literal', - value: encodeViaCodec(defaultInput.value, codecId, codecLookup), + value: encodeViaCodec(defaultInput.value, codec), }; } @@ -338,7 +369,9 @@ function checkMemberValues( handle: EnumTypeHandle, codecLookup: CodecLookup | undefined, ): readonly (string | number)[] { - const encoded = handle.values.map((value) => encodeViaCodec(value, handle.codecId, codecLookup)); + const encoded = handle.values.map((value) => + encodeViaCodec(value, codecLookup?.get(handle.codecId)), + ); const values: (string | number)[] = []; for (const value of encoded) { if (typeof value !== 'string' && !(typeof value === 'number' && Number.isFinite(value))) { @@ -672,7 +705,7 @@ function buildStorageColumn( if (isValueObjectField(field)) { const encodedDefault = field.default !== undefined - ? encodeColumnDefault(field.default, JSONB_CODEC_ID, codecLookup) + ? encodeColumnDefault(field.default, codecLookup?.get(JSONB_CODEC_ID)) : undefined; return { @@ -686,7 +719,11 @@ function buildStorageColumn( const codecId = field.descriptor.codecId; const encodedDefault = field.default !== undefined - ? encodeColumnDefault(field.default, codecId, codecLookup, field.many === true) + ? encodeColumnDefault( + field.default, + columnCodec(codecId, field.descriptor.typeParams, codecLookup), + field.many === true, + ) : undefined; // `storageValueSetRef` (derived from an `enumTypeHandle`) takes precedence @@ -1485,7 +1522,7 @@ export function buildSqlContractFromDefinition( codecId: handle.codecId, members: handle.enumMembers.map((m) => ({ name: m.name, - value: encodeViaCodec(m.value, handle.codecId, codecLookup), + value: encodeViaCodec(m.value, codecLookup?.get(handle.codecId)), })), }; @@ -1496,7 +1533,7 @@ export function buildSqlContractFromDefinition( } storageSlot[enumName] = { kind: 'valueSet', - values: handle.values.map((v) => encodeViaCodec(v, handle.codecId, codecLookup)), + values: handle.values.map((v) => encodeViaCodec(v, codecLookup?.get(handle.codecId))), }; } 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..a499aa678474 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 @@ -1,4 +1,4 @@ -import type { CodecLookup } from '@internal/framework-components/codec'; +import type { AnyCodecDescriptor, CodecLookup } from '@internal/framework-components/codec'; import type { TargetPackRef } from '@internal/framework-components/components'; import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; @@ -242,6 +242,75 @@ describe('shared contract definition lowering', () => { }); }); + it("encodes a parameterized column's default through a codec built with the column's typeParams", () => { + // A codec whose encoding answers for its params, as `pg/vector@1` does: it refuses a value + // whose length is not the length the column declares. + const descriptor = { + codecId: 'test/vector@1', + traits: ['equality'], + targetTypes: ['vector'], + isParameterized: true, + paramsSchema: { + '~standard': { version: 1, vendor: 'test', validate: (value: unknown) => ({ value }) }, + }, + factory: (params: { readonly length: number }) => () => ({ + id: 'test/vector@1', + encode: async (value: unknown) => value, + decode: async (wire: unknown) => wire, + encodeJson: (value: unknown) => { + if (!Array.isArray(value) || value.length !== params.length) { + throw new Error(`length mismatch: expected ${params.length}, got ${String(value)}`); + } + return [...value]; + }, + decodeJson: (json: unknown) => json, + }), + } as unknown as AnyCodecDescriptor; + + const codecLookup: CodecLookup = { + // The representative instance carries no params, as the control stack's does, so a build that + // used it in place of the column's own codec refuses every value. + get: (id) => + id === 'test/vector@1' ? descriptor.factory({ length: 0 })({ name: id }) : undefined, + descriptorFor: (id) => (id === 'test/vector@1' ? descriptor : undefined), + targetTypesFor: (id) => (id === 'test/vector@1' ? ['vector'] : undefined), + renderOutputTypeFor: () => undefined, + }; + + const contract = buildSqlContractFromDefinition( + { + warnings: undefined, + target: postgresTargetPack, + createNamespace: createTestSqlNamespace, + models: [ + { + modelName: 'Document', + tableName: 'document', + fields: [ + { + fieldName: 'embedding', + columnName: 'embedding', + descriptor: { + codecId: 'test/vector@1', + nativeType: 'vector', + typeParams: { length: 3 }, + }, + nullable: false, + default: { kind: 'literal', value: [0.5, 0.25, 0.125] }, + }, + ], + }, + ], + }, + codecLookup, + ); + + expect(unboundTables(contract.storage)['document']?.columns['embedding']?.default).toEqual({ + kind: 'literal', + value: [0.5, 0.25, 0.125], + }); + }); + it('builds phase-specific execution defaults', () => { const contract = buildSqlContractFromDefinition({ warnings: undefined, diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts index 9550c5839c4b..29695d1ee908 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts @@ -5,6 +5,7 @@ */ import type { JsonValue } from '@internal/contract/types'; +import { isNumeralText } from '@internal/framework-components/codec'; import { structuredError } from '@internal/utils/structured-error'; export const SQL_CHAR_CODEC_ID = 'sql/char@1' as const; @@ -66,7 +67,9 @@ export const sqlFloatEncodeJson = (value: number): JsonValue => { return value; }; +/** Also reads the numeral text a `decimal` or whole-number literal default carries; a non-finite value stays refused. */ export const sqlFloatDecodeJson = (json: JsonValue): number => { + if (typeof json === 'string' && isNumeralText(json)) return Number(json); if (typeof json !== 'number' || !Number.isFinite(json)) { throw structuredError( 'RUNTIME.DECODE_FAILED', 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..12172338f7c1 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,6 +18,8 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + integerLiteralTypesUpTo, + type LiteralTypeDeclaration, voidParamsSchema, } from '@internal/framework-components/codec'; import type { StandardSchemaV1 } from '@standard-schema/spec'; @@ -71,6 +73,7 @@ export class SqlTextCodec extends CodecImpl< } export class SqlTextDescriptor extends CodecDescriptorImpl { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; override readonly codecId = SQL_TEXT_CODEC_ID; override readonly traits = ['equality', 'order', 'textual'] as const; override readonly targetTypes = ['text'] as const; @@ -109,6 +112,8 @@ export class SqlIntCodec extends CodecImpl< } export class SqlIntDescriptor extends CodecDescriptorImpl { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = + integerLiteralTypesUpTo('i32'); override readonly codecId = SQL_INT_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['int'] as const; @@ -147,6 +152,11 @@ export class SqlFloatCodec extends CodecImpl< } export class SqlFloatDescriptor extends CodecDescriptorImpl { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = [ + ...integerLiteralTypesUpTo('i64'), + 'bigint', + 'decimal', + ]; override readonly codecId = SQL_FLOAT_CODEC_ID; override readonly traits = ['equality', 'order', 'numeric'] as const; override readonly targetTypes = ['float'] as const; @@ -185,6 +195,7 @@ export class SqlCharCodec extends CodecImpl< } export class SqlCharDescriptor extends CodecDescriptorImpl { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; override readonly codecId = SQL_CHAR_CODEC_ID; override readonly traits = ['equality', 'order', 'textual'] as const; override readonly targetTypes = ['char'] as const; @@ -226,6 +237,7 @@ export class SqlVarcharCodec extends CodecImpl< } export class SqlVarcharDescriptor extends CodecDescriptorImpl { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; override readonly codecId = SQL_VARCHAR_CODEC_ID; override readonly traits = ['equality', 'order', 'textual'] as const; override readonly targetTypes = ['varchar'] as const; diff --git a/packages/2-sql/4-lanes/relational-core/test/literal-default-coercion.test.ts b/packages/2-sql/4-lanes/relational-core/test/literal-default-coercion.test.ts new file mode 100644 index 000000000000..50ec3f597b1b --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/literal-default-coercion.test.ts @@ -0,0 +1,36 @@ +import type { CodecInstanceContext } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { sqlFloatDescriptor } from '../src/ast/sql-codecs'; + +const ctx: CodecInstanceContext = { name: 'literal-defaults' }; + +describe('sql/float@1 decodeJson', () => { + const codec = sqlFloatDescriptor.factory()(ctx); + + it('reads a JSON number', () => { + expect(codec.decodeJson(1.5)).toBe(1.5); + }); + + it.each([ + ['digit text', '42', 42], + ['decimal text', '1.5', 1.5], + ['negative decimal text', '-1.50', -1.5], + ])('reads %s', (_name, json, expected) => { + expect(codec.decodeJson(json)).toBe(expected); + }); + + it.each([['NaN'], ['Infinity'], ['-Infinity'], ['nonsense'], ['']])( + 'refuses the text %o', + (json) => { + expect(() => codec.decodeJson(json)).toThrow(); + }, + ); + + it.each([ + ['a boolean', true], + ['null', null], + ['a non-finite number', Number.NaN], + ])('refuses %s', (_name, json) => { + expect(() => codec.decodeJson(json)).toThrow(); + }); +}); diff --git a/packages/2-sql/4-lanes/relational-core/test/literal-type-inventory.test.ts b/packages/2-sql/4-lanes/relational-core/test/literal-type-inventory.test.ts new file mode 100644 index 000000000000..ea40e07cfa70 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/literal-type-inventory.test.ts @@ -0,0 +1,42 @@ +import { + type AnyCodecDescriptor, + integerLiteralTypesUpTo, + type LiteralTypeDeclaration, +} from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import * as sqlCodecs from '../src/ast/sql-codecs'; + +const string = ['string'] as const; + +const EXPECTED: Readonly> = { + 'sql/text@1': string, + 'sql/char@1': string, + 'sql/varchar@1': string, + 'sql/int@1': integerLiteralTypesUpTo('i32'), + 'sql/float@1': [...integerLiteralTypesUpTo('i64'), 'bigint', 'decimal'], +}; + +const isDescriptor = (value: unknown): value is AnyCodecDescriptor => + typeof value === 'object' && + value !== null && + 'codecId' in value && + typeof value.codecId === 'string' && + 'traits' in value && + 'factory' in value; + +const moduleExports: readonly unknown[] = Object.values(sqlCodecs); +const descriptors = moduleExports.filter(isDescriptor); + +describe('relational-core literal type inventory', () => { + it('registers codecs to check', () => { + expect(descriptors.length).toBeGreaterThan(0); + }); + + it('declares the literal types of every registered codec', () => { + expect( + Object.fromEntries( + descriptors.map((descriptor) => [descriptor.codecId, descriptor.literalTypes ?? []]), + ), + ).toEqual(EXPECTED); + }); +}); 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..d109b9c8ccd0 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 { LiteralTypeDeclaration } from '@internal/framework-components/codec'; +import { writeLiteral } from '@internal/framework-components/codec'; const DEFAULT_FUNCTION_ATTRIBUTES: Readonly> = { 'autoincrement()': '@default(autoincrement())', @@ -8,6 +10,17 @@ const DEFAULT_FUNCTION_ATTRIBUTES: Readonly> = { export interface DefaultMappingOptions { readonly functionAttributes?: Readonly>; readonly fallbackFunctionAttribute?: ((expression: string) => string | undefined) | undefined; + /** + * What the column's codec accepts as a literal default. A value none of these types writes has no + * PSL literal, and the caller falls back to the raw database default. + */ + readonly literalTypes?: readonly LiteralTypeDeclaration[]; + /** + * Whether the column is a list, whose elements are each written against the element codec's + * scalar declarations. A scalar column whose codec declares `{ list: [...] }` — `pg/vector@1` — + * writes its list through {@link writeLiteral} instead. + */ + readonly list?: boolean; } export type DefaultMappingResult = { readonly attribute: string } | { readonly comment: string }; @@ -17,8 +30,16 @@ export function mapDefault( options?: DefaultMappingOptions, ): DefaultMappingResult { switch (columnDefault.kind) { - case 'literal': - return { attribute: `@default(${formatLiteralValue(columnDefault.value)})` }; + case 'literal': { + const text = writeDefaultLiteral( + columnDefault.value, + options?.literalTypes ?? [], + options?.list === true, + ); + return text === undefined + ? { comment: `// Literal default: ${JSON.stringify(columnDefault.value)}` } + : { attribute: `@default(${text})` }; + } case 'function': { const attribute = options?.functionAttributes?.[columnDefault.expression] ?? @@ -31,26 +52,20 @@ 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 writeDefaultLiteral( + value: ColumnDefaultLiteralInputValue, + declarations: readonly LiteralTypeDeclaration[], + list: boolean, +): string | undefined { + if (value instanceof Date) return undefined; + if (!list) return writeLiteral(value, declarations)?.text; + if (!Array.isArray(value)) return undefined; + const scalars = declarations.filter((declaration) => typeof declaration === 'string'); + const parts: string[] = []; + for (const element of value) { + const written = writeLiteral(element, scalars); + if (written === undefined) return undefined; + parts.push(written.text); } -} - -function quoteString(str: string): string { - return `"${escapeString(str)}"`; -} - -function escapeString(str: string): string { - return JSON.stringify(str).slice(1, -1); + return `[${parts.join(', ')}]`; } diff --git a/packages/2-sql/9-family/src/core/sql-default-literal-tag.ts b/packages/2-sql/9-family/src/core/sql-default-literal-tag.ts index 63d656720769..2219f89c9487 100644 --- a/packages/2-sql/9-family/src/core/sql-default-literal-tag.ts +++ b/packages/2-sql/9-family/src/core/sql-default-literal-tag.ts @@ -1,5 +1,5 @@ import type { - ControlDefaultLiteralTagEntry, + ControlDefaultLiteralTagLoweringEntry, LoweredDefaultResult, } from '@internal/framework-components/control'; import type { ContributedPslDiagnosticCode } from '@internal/framework-components/psl-ast'; @@ -11,7 +11,7 @@ export const PSL_INVALID_DEFAULT_SQL: ContributedPslDiagnosticCode = 'PSL_INVALI /** * The `` sql`...` `` default literal every SQL target registers: the canonical body becomes the expression verbatim. A body that is exactly `now()` or `autoincrement()` is refused so the named form is written instead. */ -export function sqlDefaultLiteralTagEntry(usage: string): ControlDefaultLiteralTagEntry { +export function sqlDefaultLiteralTagEntry(usage: string): ControlDefaultLiteralTagLoweringEntry { return { usage, documentation: "Uses the SQL in the string, verbatim, as the column's default expression.", 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..d7bc297061ff 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,3 +1,4 @@ +import { integerLiteralTypesUpTo } from '@internal/framework-components/codec'; import { describe, expect, it } from 'vitest'; import { type DefaultMappingOptions, @@ -11,7 +12,10 @@ const injectedMapping: DefaultMappingOptions = { fallbackFunctionAttribute: (expression) => `@default(dbgenerated(${JSON.stringify(expression)}))`, }; -describe('mapDefault', () => { +const wholeNumbers = integerLiteralTypesUpTo('i64'); +type Declarations = NonNullable; + +describe('mapDefault function defaults', () => { it('maps autoincrement()', () => { expect(mapDefault({ kind: 'function', expression: 'autoincrement()' })).toEqual({ attribute: '@default(autoincrement())', @@ -38,42 +42,6 @@ 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('maps number', () => { - expect(mapDefault({ kind: 'literal', value: 42 })).toEqual({ - attribute: '@default(42)', - }); - }); - - it('maps string', () => { - expect(mapDefault({ kind: 'literal', value: 'hello' })).toEqual({ - attribute: '@default("hello")', - }); - }); - - it('maps string with quotes', () => { - expect(mapDefault({ kind: 'literal', value: 'he said "hi"' })).toEqual({ - attribute: '@default("he said \\"hi\\"")', - }); - }); - - it('escapes control characters in string defaults', () => { - expect(mapDefault({ kind: 'literal', value: 'line 1\nline 2\t"quoted"' })).toEqual({ - attribute: '@default("line 1\\nline 2\\t\\"quoted\\"")', - }); - }); - it('unrecognized function becomes comment', () => { expect(mapDefault({ kind: 'function', expression: 'custom_func()' })).toEqual({ comment: '// Raw default: custom_func()', @@ -85,22 +53,75 @@ describe('mapDefault', () => { comment: '// Raw default: gen_random_uuid()', }); }); +}); - it('maps null literal', () => { - expect(mapDefault({ kind: 'literal', value: null })).toEqual({ - attribute: '@default(null)', - }); +describe('mapDefault literal defaults', () => { + it.each([ + ['a string', 'anonymous', ['string'], '@default("anonymous")'], + ['a string with quotes', 'he said "hi"', ['string'], '@default("he said \\"hi\\"")'], + ['a string with a newline', 'line 1\nline 2', ['string'], '@default("line 1\\nline 2")'], + ['true', true, ['boolean'], '@default(true)'], + ['false', false, ['boolean'], '@default(false)'], + ['a small whole number', 100, wholeNumbers, '@default(100)'], + ['digit text past 2^53', '100000000000000099', wholeNumbers, '@default(100000000000000099)'], + ['decimal text keeping its trailing zero', '1.50', ['decimal'], '@default(1.50)'], + ['NaN unquoted', 'NaN', ['float'], '@default(NaN)'], + [ + 'a JSON document as a json tag', + { plan: 'free', seats: 1 }, + ['json'], + '@default(json`{"plan":"free","seats":1}`)', + ], + ['a JSON array as a json tag', [1, 2], ['json'], '@default(json`[1,2]`)'], + [ + 'a list against a list declaration', + [0.1, 0.2, 0.3], + [{ list: ['decimal'] }], + '@default([0.1, 0.2, 0.3])', + ], + ] as [string, never, Declarations, string][])( + 'writes %s', + (_name, value, literalTypes, attribute) => { + expect(mapDefault({ kind: 'literal', value }, { literalTypes })).toEqual({ attribute }); + }, + ); + + it('writes a list column element by element against the scalar declarations', () => { + expect( + mapDefault({ kind: 'literal', value: [1, 2] }, { literalTypes: wholeNumbers, list: true }), + ).toEqual({ attribute: '@default([1, 2])' }); }); - it('maps large number literal', () => { - expect(mapDefault({ kind: 'literal', value: 9007199254740991 })).toEqual({ - attribute: '@default(9007199254740991)', - }); + it('writes an empty list column default', () => { + expect( + mapDefault({ kind: 'literal', value: [] }, { literalTypes: ['string'], list: true }), + ).toEqual({ attribute: '@default([])' }); }); - it('stringifies unsupported literal defaults', () => { - expect(mapDefault({ kind: 'literal', value: { nested: ['value'] } })).toEqual({ - attribute: '@default("{\\"nested\\":[\\"value\\"]}")', + it('writes a list of json tags on a json list column', () => { + expect( + mapDefault({ kind: 'literal', value: [{}, []] }, { literalTypes: ['json'], list: true }), + ).toEqual({ attribute: '@default([json`{}`, json`[]`])' }); + }); + + it.each([ + ['a codec that names no literal type', 'anonymous', []], + ['a value no named type writes', { a: 1 }, ['string']], + ['a list element no named type writes', ['a', 1], ['string']], + ['a list value on a codec naming only scalars', [1, 2], wholeNumbers], + ] as [string, never, Declarations][])( + 'describes %s in a comment, so the caller falls back', + (_name, value, literalTypes) => { + const isList = _name.includes('list element'); + expect(mapDefault({ kind: 'literal', value }, { literalTypes, list: isList })).toEqual({ + comment: `// Literal default: ${JSON.stringify(value)}`, + }); + }, + ); + + it('describes a literal in a comment when no literal types are given at all', () => { + expect(mapDefault({ kind: 'literal', value: 'hello' })).toEqual({ + comment: '// Literal default: "hello"', }); }); }); diff --git a/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts b/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts index acf9abf39ce4..ffbb5af2e214 100644 --- a/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts +++ b/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts @@ -1,3 +1,4 @@ +import { isDefaultLiteralTagLoweringEntry } from '@internal/framework-components/control'; import { checkSqlDefaultBody } from '@internal/sql-contract/validators'; import { describe, expect, it } from 'vitest'; import { createBuiltinLikeControlMutationDefaults } from '../../2-authoring/contract-psl/test/fixtures'; @@ -117,8 +118,12 @@ describe('sqlDefaultLiteralTagEntry', () => { }); describe('the contract-psl fixture registry mirrors the family entry', () => { - const fixtureEntry = + const registered = createBuiltinLikeControlMutationDefaults().defaultLiteralTagRegistry.get('sql'); + if (registered === undefined || !isDefaultLiteralTagLoweringEntry(registered)) { + throw new Error('the fixture registry does not register `sql` as a lowering tag'); + } + const fixtureEntry = registered; const familyEntry = sqlDefaultLiteralTagEntry('sql`...`'); it.each([ @@ -130,7 +135,7 @@ describe('the contract-psl fixture registry mirrors the family entry', () => { ["'no select here'"], [''], ])('lowers %j the same way', (body) => { - expect(fixtureEntry?.lower({ literal: { tag: 'sql', body, span }, context })).toEqual( + expect(fixtureEntry.lower({ literal: { tag: 'sql', body, span }, context })).toEqual( familyEntry.lower({ literal: { tag: 'sql', body, span }, context }), ); }); 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..234408c24083 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,7 @@ import { type ColumnHelperFor, type ColumnSpec, column, + type LiteralTypeDeclaration, } from '@internal/framework-components/codec'; import { isRuntimeError, runtimeError } from '@internal/framework-components/runtime'; import type { ProjectionExpr } from '@internal/sql-relational-core/ast'; @@ -210,6 +211,7 @@ const arktypeJsonParamsSchema = type({ }) satisfies StandardSchemaV1; export class ArktypeJsonDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['json']; protected override nativeType(): string { return ARKTYPE_JSON_NATIVE_TYPE; } diff --git a/packages/3-extensions/arktype-json/test/literal-type-inventory.test.ts b/packages/3-extensions/arktype-json/test/literal-type-inventory.test.ts new file mode 100644 index 000000000000..f984e4a63a6a --- /dev/null +++ b/packages/3-extensions/arktype-json/test/literal-type-inventory.test.ts @@ -0,0 +1,21 @@ +import type { LiteralTypeDeclaration } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { codecDescriptors } from '../src/core/arktype-json-codec'; + +const EXPECTED: Readonly> = { + 'arktype/json@1': ['json'], +}; + +describe('arktype-json literal type inventory', () => { + it('registers codecs to check', () => { + expect(codecDescriptors.length).toBeGreaterThan(0); + }); + + it('declares the literal types of every registered codec', () => { + expect( + Object.fromEntries( + codecDescriptors.map((descriptor) => [descriptor.codecId, descriptor.literalTypes ?? []]), + ), + ).toEqual(EXPECTED); + }); +}); diff --git a/packages/3-extensions/pgvector/src/core/codecs.ts b/packages/3-extensions/pgvector/src/core/codecs.ts index 0a43e3e58d07..ace4f0df4647 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, + integerLiteralTypesUpTo, + isNumeralText, + type LiteralTypeDeclaration, } from '@internal/framework-components/codec'; import type { ExtractCodecTypes, ProjectionExpr } from '@internal/sql-relational-core/ast'; import { CastExpr, FunctionCallExpr } from '@internal/sql-relational-core/ast'; @@ -145,7 +148,9 @@ export class PgVectorCodec extends CodecImpl< meta: { codecId: VECTOR_CODEC_ID }, }); } - const value = [...json]; + const value = json.map((element) => + typeof element === 'string' && isNumeralText(element) ? Number(element) : element, + ); this.assertVector(value, 'RUNTIME.DECODE_FAILED'); return value; } @@ -172,6 +177,9 @@ const jsonArrayFromVectorElements = (expression: ProjectionExpr): ProjectionExpr ]); export class PgVectorDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = [ + { list: [...integerLiteralTypesUpTo('i64'), 'bigint', 'decimal'] }, + ]; protected override nativeType(): string { return PG_VECTOR_NATIVE_TYPE; } diff --git a/packages/3-extensions/pgvector/test/literal-default-coercion.test.ts b/packages/3-extensions/pgvector/test/literal-default-coercion.test.ts new file mode 100644 index 000000000000..08eb95a42322 --- /dev/null +++ b/packages/3-extensions/pgvector/test/literal-default-coercion.test.ts @@ -0,0 +1,29 @@ +import type { CodecInstanceContext } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { pgVectorDescriptor } from '../src/core/codecs'; + +const ctx: CodecInstanceContext = { name: 'literal-defaults' }; + +describe('pg/vector@1 decodeJson', () => { + const codec = pgVectorDescriptor.factory({ length: 3 })(ctx); + + it('reads an array of JSON numbers', () => { + expect(codec.decodeJson([0.1, 0.2, 0.3])).toEqual([0.1, 0.2, 0.3]); + }); + + it('reads digit and decimal text elements', () => { + expect(codec.decodeJson(['1', '0.5', '-2.25'])).toEqual([1, 0.5, -2.25]); + }); + + it('refuses an element that is not a numeral', () => { + expect(() => codec.decodeJson([1, 2, 'x'])).toThrow(); + }); + + it('refuses a non-finite element', () => { + expect(() => codec.decodeJson([1, 2, 'NaN'])).toThrow(); + }); + + it('refuses the wrong length', () => { + expect(() => codec.decodeJson(['1', '2'])).toThrow('Vector length mismatch'); + }); +}); diff --git a/packages/3-extensions/pgvector/test/literal-type-inventory.test.ts b/packages/3-extensions/pgvector/test/literal-type-inventory.test.ts new file mode 100644 index 000000000000..a5362ba54411 --- /dev/null +++ b/packages/3-extensions/pgvector/test/literal-type-inventory.test.ts @@ -0,0 +1,21 @@ +import type { LiteralTypeDeclaration } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { codecDescriptors } from '../src/core/codecs'; + +const EXPECTED: Readonly> = { + 'pg/vector@1': [{ list: ['i8', 'i16', 'i32', 'i64', 'bigint', 'decimal'] }], +}; + +describe('pgvector literal type inventory', () => { + it('registers codecs to check', () => { + expect(codecDescriptors.length).toBeGreaterThan(0); + }); + + it('declares the literal types of every registered codec', () => { + expect( + Object.fromEntries( + codecDescriptors.map((descriptor) => [descriptor.codecId, descriptor.literalTypes ?? []]), + ), + ).toEqual(EXPECTED); + }); +}); diff --git a/packages/3-extensions/postgis/src/core/codecs.ts b/packages/3-extensions/postgis/src/core/codecs.ts index 5dd23059972a..bea410d8d528 100644 --- a/packages/3-extensions/postgis/src/core/codecs.ts +++ b/packages/3-extensions/postgis/src/core/codecs.ts @@ -40,6 +40,7 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + type LiteralTypeDeclaration, } from '@internal/framework-components/codec'; import type { ExtractCodecTypes, ProjectionExpr } from '@internal/sql-relational-core/ast'; import { @@ -145,6 +146,7 @@ export class PostgisGeometryCodec extends CodecImpl< } export class PostgisGeometryDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return POSTGIS_GEOMETRY_NATIVE_TYPE; } diff --git a/packages/3-extensions/postgis/test/literal-type-inventory.test.ts b/packages/3-extensions/postgis/test/literal-type-inventory.test.ts new file mode 100644 index 000000000000..fac8e0ed2381 --- /dev/null +++ b/packages/3-extensions/postgis/test/literal-type-inventory.test.ts @@ -0,0 +1,21 @@ +import type { LiteralTypeDeclaration } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { codecDescriptors } from '../src/core/codecs'; + +const EXPECTED: Readonly> = { + 'pg/geometry@1': ['string'], +}; + +describe('postgis literal type inventory', () => { + it('registers codecs to check', () => { + expect(codecDescriptors.length).toBeGreaterThan(0); + }); + + it('declares the literal types of every registered codec', () => { + expect( + Object.fromEntries( + codecDescriptors.map((descriptor) => [descriptor.codecId, descriptor.literalTypes ?? []]), + ), + ).toEqual(EXPECTED); + }); +}); diff --git a/packages/3-mongo-target/2-mongo-adapter/test/literal-type-inventory.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/literal-type-inventory.test.ts new file mode 100644 index 000000000000..2767656bb3db --- /dev/null +++ b/packages/3-mongo-target/2-mongo-adapter/test/literal-type-inventory.test.ts @@ -0,0 +1,31 @@ +import type { LiteralTypeDeclaration } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { mongoCodecDescriptors } from '../src/core/codecs'; + +/** Mongo reads no literal default: a Mongo default is authored in TypeScript, not in PSL. */ +const EXPECTED: Readonly> = { + 'mongo/objectId@1': [], + 'mongo/string@1': [], + 'mongo/double@1': [], + 'mongo/int32@1': [], + 'mongo/bool@1': [], + 'mongo/date@1': [], + 'mongo/vector@1': [], +}; + +describe('mongo literal type inventory', () => { + it('registers codecs to check', () => { + expect(mongoCodecDescriptors.length).toBeGreaterThan(0); + }); + + it('declares the literal types of every registered codec', () => { + expect( + Object.fromEntries( + mongoCodecDescriptors.map((descriptor) => [ + descriptor.codecId, + descriptor.literalTypes ?? [], + ]), + ), + ).toEqual(EXPECTED); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-descriptor.ts b/packages/3-targets/3-targets/postgres/src/core/codec-descriptor.ts index 8dc73eb539e7..d8fee29c6d6b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codec-descriptor.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codec-descriptor.ts @@ -7,6 +7,7 @@ import { type CodecInstanceContext, type CodecRef, type CodecTrait, + type LiteralTypeDeclaration, validateCodecTypeParams, } from '@internal/framework-components/codec'; import { @@ -122,6 +123,7 @@ class PostgresCodecDescriptorAdapter extends Postg override readonly codecId: string; override readonly traits: readonly CodecTrait[]; override readonly targetTypes: readonly string[]; + override readonly literalTypes: readonly LiteralTypeDeclaration[] | undefined; override readonly paramsSchema: D['paramsSchema']; override readonly renderOutputType?: (params: DescriptorParams) => string | undefined; override readonly renderInputType?: (params: DescriptorParams) => string | undefined; @@ -142,6 +144,8 @@ class PostgresCodecDescriptorAdapter extends Postg this.traits = descriptor.traits; this.targetTypes = descriptor.targetTypes; this.paramsSchema = descriptor.paramsSchema; + + this.literalTypes = descriptor.literalTypes; this.factory = (params) => descriptor.factory(params); const renderOutputType = descriptor.renderOutputType; 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..33a51bc084ed 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 @@ -9,6 +9,7 @@ */ import type { JsonValue } from '@internal/contract/types'; +import { isNonFiniteText, isNumeralText } from '@internal/framework-components/codec'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type as arktype } from 'arktype'; import { postgresError } from './errors'; @@ -148,6 +149,29 @@ export const pgInt8Decode = (wire: string | number | bigint): bigint => export const pgUnboundedIntDecode = (wire: string | number | bigint): bigint => decimalIntegerDecode('pg/unboundedint@1', wire); +/** + * 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); + +/** Also reads the numeral text a `decimal` or whole-number literal default carries. */ +export const pgFloatDecodeJson = (codecId: string, json: JsonValue): number => { + if (typeof json === 'number') return json; + if (typeof json === 'string' && (isNonFiniteText(json) || isNumeralText(json))) { + return Number(json); + } + throw postgresError( + 'RUNTIME.DECODE_FAILED', + `${codecId} database JSON value must be a number, decimal text, or the text 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); @@ -195,11 +219,13 @@ export const pgInt8NumberDecode = (wire: string | number | bigint): number => { return Number(value); }; +/** Also reads the digit text an `i64` literal default carries, which is refused past the safe integer range. */ export const pgInt8NumberDecodeJson = (json: JsonValue): number => { + if (typeof json === 'string') return pgInt8NumberDecode(json); if (typeof json !== 'number') { throw postgresError( 'RUNTIME.DECODE_FAILED', - 'pg/int8number@1 database JSON value must be a number', + 'pg/int8number@1 database JSON value must be a number or decimal text', { meta: { codecId: 'pg/int8number@1', received: typeof json } }, ); } 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..757c620458be 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,8 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + integerLiteralTypesUpTo, + type LiteralTypeDeclaration, renderTsLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; @@ -55,6 +57,9 @@ import { pgByteaDecodeJson, pgByteaDecodeWire, pgByteaEncodeJson, + pgFloatDecodeJson, + pgFloatEncode, + pgFloatEncodeJson, pgInt8Decode, pgInt8NumberDecode, pgInt8NumberDecodeJson, @@ -341,6 +346,7 @@ export class PgTextCodec extends CodecImpl< } export class PgTextDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_TEXT_NATIVE_TYPE; } @@ -581,6 +587,8 @@ export class PgInt4Codec extends CodecImpl< } export class PgInt4Descriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = + integerLiteralTypesUpTo('i32'); protected override nativeType(): string { return PG_INT4_NATIVE_TYPE; } @@ -630,6 +638,8 @@ export class PgInt2Codec extends CodecImpl< } export class PgInt2Descriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = + integerLiteralTypesUpTo('i16'); protected override nativeType(): string { return PG_INT2_NATIVE_TYPE; } @@ -678,10 +688,10 @@ export class PgInt8Codec extends CodecImpl< return pgBigintEncodeJson(PG_INT8_CODEC_ID, value); } decodeJson(json: JsonValue): bigint { - if (typeof json !== 'string') { + if (typeof json !== 'string' && typeof json !== 'number') { throw postgresError( 'RUNTIME.DECODE_FAILED', - 'pg/int8@1 database JSON value must be a decimal string', + 'pg/int8@1 database JSON value must be a decimal string or a whole number', { meta: { codecId: PG_INT8_CODEC_ID, received: typeof json } }, ); } @@ -690,6 +700,8 @@ export class PgInt8Codec extends CodecImpl< } export class PgInt8Descriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = + integerLiteralTypesUpTo('i64'); protected override nativeType(): string { return PG_INT8_NATIVE_TYPE; } @@ -746,6 +758,8 @@ export class PgInt8NumberCodec extends CodecImpl< } export class PgInt8NumberDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = + integerLiteralTypesUpTo('i64'); protected override nativeType(): string { return PG_INT8_NATIVE_TYPE; } @@ -778,23 +792,27 @@ 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(PG_FLOAT4_CODEC_ID, json); } } export class PgFloat4Descriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = [ + ...integerLiteralTypesUpTo('i64'), + 'bigint', + 'decimal', + 'float', + ]; protected override nativeType(): string { return PG_FLOAT4_NATIVE_TYPE; } @@ -827,23 +845,27 @@ 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(PG_FLOAT8_CODEC_ID, json); } } export class PgFloat8Descriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = [ + ...integerLiteralTypesUpTo('i64'), + 'bigint', + 'decimal', + 'float', + ]; protected override nativeType(): string { return PG_FLOAT8_NATIVE_TYPE; } @@ -891,6 +913,7 @@ export class PgBoolCodec extends CodecImpl< } export class PgBoolDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['boolean']; protected override nativeType(): string { return PG_BOOL_NATIVE_TYPE; } @@ -940,10 +963,11 @@ export class PgNumericCodec extends CodecImpl< return value; } decodeJson(json: JsonValue): string { + if (typeof json === 'number') return pgNumericDecode(json); if (typeof json !== 'string') { throw postgresError( 'RUNTIME.DECODE_FAILED', - 'pg/numeric@1 database JSON value must be a decimal string', + 'pg/numeric@1 database JSON value must be a decimal string or a number', { meta: { codecId: PG_NUMERIC_CODEC_ID, received: typeof json } }, ); } @@ -952,6 +976,12 @@ export class PgNumericCodec extends CodecImpl< } export class PgNumericDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = [ + ...integerLiteralTypesUpTo('i64'), + 'bigint', + 'decimal', + 'float', + ]; protected override nativeType(): string { return PG_NUMERIC_NATIVE_TYPE; } @@ -1000,10 +1030,10 @@ export class PgUnboundedIntCodec extends CodecImpl< return pgBigintEncodeJson(PG_UNBOUNDED_INT_CODEC_ID, value); } decodeJson(json: JsonValue): bigint { - if (typeof json !== 'string') { + if (typeof json !== 'string' && typeof json !== 'number') { throw postgresError( 'RUNTIME.DECODE_FAILED', - 'pg/unboundedint@1 database JSON value must be a decimal string', + 'pg/unboundedint@1 database JSON value must be a decimal string or a whole number', { meta: { codecId: PG_UNBOUNDED_INT_CODEC_ID, received: typeof json } }, ); } @@ -1012,6 +1042,10 @@ export class PgUnboundedIntCodec extends CodecImpl< } export class PgUnboundedIntDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = [ + ...integerLiteralTypesUpTo('i64'), + 'bigint', + ]; protected override nativeType(): string { return PG_NUMERIC_NATIVE_TYPE; } @@ -1066,6 +1100,7 @@ export class PgTimetzCodec extends CodecImpl< } export class PgTimetzDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_TIMETZ_NATIVE_TYPE; } @@ -1116,6 +1151,7 @@ export class PgBitCodec extends CodecImpl< } export class PgBitDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_BIT_NATIVE_TYPE; } @@ -1165,6 +1201,7 @@ export class PgVarbitCodec extends CodecImpl< } export class PgVarbitDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_VARBIT_NATIVE_TYPE; } @@ -1212,6 +1249,7 @@ export class PgByteaCodec extends CodecImpl< } export class PgByteaDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_BYTEA_NATIVE_TYPE; } @@ -1258,6 +1296,7 @@ export class PgUuidCodec extends CodecImpl< } export class PgUuidDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_UUID_NATIVE_TYPE; } @@ -1304,6 +1343,7 @@ export class PgInetCodec extends CodecImpl< } export class PgInetDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_INET_NATIVE_TYPE; } @@ -1370,6 +1410,7 @@ export class PgIntervalCodec extends CodecImpl< } export class PgIntervalDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_INTERVAL_NATIVE_TYPE; } @@ -1418,6 +1459,7 @@ export class PgJsonCodec extends CodecImpl< } export class PgJsonDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['json']; protected override nativeType(): string { return PG_JSON_NATIVE_TYPE; } @@ -1462,6 +1504,7 @@ export class PgJsonbCodec extends CodecImpl< } export class PgJsonbDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['json']; protected override nativeType(): string { return PG_JSONB_NATIVE_TYPE; } @@ -1510,6 +1553,7 @@ export class PgFloatCodec extends SqlFloatCodec { } export class PgCharDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_CHAR_NATIVE_TYPE; } @@ -1539,6 +1583,7 @@ export const pgCharColumn = (params: LengthParams = {}) => pgCharColumn satisfies ColumnHelperFor; export class PgVarcharDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_VARCHAR_NATIVE_TYPE; } @@ -1573,6 +1618,8 @@ export const pgVarcharColumn = (params: LengthParams = {}) => pgVarcharColumn satisfies ColumnHelperFor; export class PgIntDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = + integerLiteralTypesUpTo('i32'); protected override nativeType(): string { return PG_INT_NATIVE_TYPE; } @@ -1599,6 +1646,11 @@ export const pgIntColumn = () => pgIntColumn satisfies ColumnHelperFor; export class PgFloatDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = [ + ...integerLiteralTypesUpTo('i64'), + 'bigint', + 'decimal', + ]; protected override nativeType(): string { return PG_FLOAT_NATIVE_TYPE; } 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..03c8dec6a7d4 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,7 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + type LiteralTypeDeclaration, } from '@internal/framework-components/codec'; import { CastExpr, type ProjectionExpr } from '@internal/sql-relational-core/ast'; import type { StandardSchemaV1 } from '@standard-schema/spec'; @@ -117,6 +118,7 @@ export class PgTimestamptzDateCodec extends CodecImpl< } export class PgTimestamptzDateDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_TIMESTAMPTZ_NATIVE_TYPE; } diff --git a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts index 5895be280017..fc8fa2945c88 100644 --- a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts +++ b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts @@ -1,4 +1,5 @@ import type { ColumnDefault, JsonValue } from '@internal/contract/types'; +import { blindCast } from '@internal/utils/casts'; /** * Pre-compiled regex patterns for performance. @@ -219,7 +220,7 @@ function parseArrayLiteralBody( if (token.quoted) { // A quoted token is always a string — `"NULL"`, `"true"`, `"1"` are the // literal text, never the keyword/number. - result.push(token.value); + result.push(textElementValue(token.value, elementType)); continue; } const el = token.value.trim(); @@ -287,7 +288,19 @@ function parseConstructorElement(element: string, elementType: string): JsonValu if (FALSE_PATTERN.test(element)) return false; const token = readLiteralToken(element); if (token === undefined) return undefined; - return token.kind === 'number' ? numberValue(token.numeral, elementType) : token.text; + return token.kind === 'number' + ? numberValue(token.numeral, elementType) + : textElementValue(token.text, elementType); +} + +/** A `json`/`jsonb` element's text is a JSON document, as it is on a scalar column of the same type. */ +function textElementValue(text: string, elementType: string): JsonValue { + if (elementType !== 'json' && elementType !== 'jsonb') return text; + try { + return blindCast(JSON.parse(text)); + } catch { + return text; + } } function parseArrayConstructor( 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..27b22d7b74a6 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-default-codec.ts @@ -0,0 +1,98 @@ +/** + * What a printed column's codec accepts as a literal default. + * + * `contract emit` binds a codec to each PSL type constructor the printer names, so a default has to + * be written in the form that codec reads back. The binding itself lives in the adapter's authoring + * type namespaces, which sit above this package; the table below restates it for the type names the + * printer emits, and `adapter-postgres/test/printed-type-codecs.test.ts` fails if the two disagree + * or if the printer gains a type name this table does not cover. + */ + +import type { ColumnDefaultLiteralInputValue, JsonValue } from '@internal/contract/types'; +import { + type Codec, + type LiteralTypeDeclaration, + materializeCodec, +} from '@internal/framework-components/codec'; +import { blindCast } from '@internal/utils/casts'; +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. */ +export const CODEC_ID_BY_PRINTED_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'], +]); + +/** + * The literal types a column of `pslTypeName` accepts. An enum column's default is a member name, + * which is a string either way, so it reads through the text codec. + */ +export function literalTypesForPrintedType( + pslTypeName: string, + isEnum: boolean, +): readonly LiteralTypeDeclaration[] { + const codecId = isEnum ? PG_TEXT_CODEC_ID : CODEC_ID_BY_PRINTED_TYPE.get(pslTypeName); + if (codecId === undefined) return []; + return postgresCodecDescriptorRegistry.descriptorFor(codecId)?.literalTypes ?? []; +} + +const codecs = new Map(); + +/** One instance per codec id: every codec in the table above decodes JSON the same way for any params. */ +function printedTypeCodec(pslTypeName: string, isEnum: boolean): Codec | undefined { + const codecId = isEnum ? PG_TEXT_CODEC_ID : CODEC_ID_BY_PRINTED_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; +} + +/** + * Whether the column's codec reads the value back. + * + * A literal type says what a value is written as, not that this codec accepts every value of that + * shape: the temporal codecs name `string` but refuse `infinity`, which PostgreSQL stores and + * reports verbatim. A default the codec refuses has no PSL literal, so the raw expression prints + * instead of a schema `contract emit` would reject. + */ +export function printedDefaultReadsBack( + value: ColumnDefaultLiteralInputValue, + pslTypeName: string, + isEnum: boolean, + isList: boolean, +): boolean { + const codec = printedTypeCodec(pslTypeName, isEnum); + if (codec === undefined) return false; + const values = isList && Array.isArray(value) ? value : [value]; + return values.every((element) => { + try { + codec.decodeJson(blindCast(element)); + return true; + } catch { + return false; + } + }); +} 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..fd85065e0375 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,5 @@ import { toEnumMemberName, toEnumName } from '@internal/family-sql/psl-infer'; +import { escapePslString } from '@internal/framework-components/codec'; import type { PslExtensionBlock, PslExtensionBlockParamValue, @@ -8,7 +9,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..67aa4ba116ba 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/framework-components/codec'; 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..13e5de7fbada 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 @@ -1,4 +1,4 @@ -import type { ColumnDefault } from '@internal/contract/types'; +import type { ColumnDefault, ColumnDefaultLiteralInputValue } from '@internal/contract/types'; import type { DefaultMappingOptions, PslPrinterOptions, @@ -6,6 +6,7 @@ import type { RelationField, } from '@internal/family-sql/psl-infer'; import { mapDefault, toFieldName, toModelName } from '@internal/family-sql/psl-infer'; +import { escapePslString } from '@internal/framework-components/codec'; import type { PslAttributeArgument, PslField, @@ -22,6 +23,7 @@ import { import type { SqlColumnIR, SqlTableIR } from '@internal/sql-schema-ir/types'; import { ifDefined } from '@internal/utils/defined'; import { postgresRenderCheckExpressions } from '../check-expressions'; +import { literalTypesForPrintedType, printedDefaultReadsBack } from './infer-default-codec'; import { buildDanglingForeignKeyWarning, type DanglingForeignKeyInfo } from './infer-foreign-keys'; import { buildCheckAttribute, @@ -37,15 +39,10 @@ import { buildAttribute, buildMapAttribute, buildSimpleConstraintFieldAttribute, - escapePslString, - formatPslListLiteralValue, - formatPslValue, namedArg, - type PslDefaultValueFormat, parseColumnDefault, parseDefaultAttributeString, positionalArg, - pslDefaultValueFormat, SYNTHETIC_SPAN, } from './psl-literals'; @@ -281,11 +278,17 @@ function buildScalarField( attributes.push(buildSimpleConstraintFieldAttribute('id', singlePkConstraintName)); } + const isEnumColumn = enumPslName !== undefined; const defaultAttribute = inferDefaultAttribute( column, - enumPslName === undefined ? pslDefaultValueFormat(resolution.pslType.name) : formatPslValue, - defaultMapping, rawDefaultParser, + { + ...defaultMapping, + literalTypes: literalTypesForPrintedType(resolution.pslType.name, isEnumColumn), + list: column.many === true, + }, + (value) => + printedDefaultReadsBack(value, resolution.pslType.name, isEnumColumn, column.many === true), ); if (defaultAttribute !== undefined) { attributes.push(parseDefaultAttributeString(defaultAttribute)); @@ -344,9 +347,9 @@ function buildScalarField( */ function inferDefaultAttribute( column: SqlColumnIR, - valueFormat: PslDefaultValueFormat, - defaultMapping: DefaultMappingOptions | undefined, rawDefaultParser: PslPrinterOptions['parseRawDefault'], + defaultMapping: DefaultMappingOptions, + readsBack: (value: ColumnDefaultLiteralInputValue) => boolean, ): string | undefined { if ( column.default === 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, column, defaultMapping, readsBack) : undefined; } const parsed = parseColumnDefault(column.default, column.nativeType, rawDefaultParser); @@ -374,19 +376,26 @@ function inferDefaultAttribute( return undefined; } if (parsed.kind === 'literal') { - return literalOrRawAttribute(valueFormat(parsed.value), column, defaultMapping); + return literalOrRawAttribute(parsed, column, defaultMapping, readsBack); } return mappedAttribute(parsed, defaultMapping); } +/** + * A literal no named literal type writes, or that the column's codec does not read back, has no PSL + * literal, so the raw database default prints instead. + */ function literalOrRawAttribute( - literal: string | undefined, + columnDefault: ColumnDefault, column: SqlColumnIR, - defaultMapping: DefaultMappingOptions | undefined, + defaultMapping: DefaultMappingOptions, + readsBack: (value: ColumnDefaultLiteralInputValue) => boolean, ): string | undefined { - if (literal !== undefined) { - return `@default(${literal})`; - } + const result = + columnDefault.kind === 'literal' && !readsBack(columnDefault.value) + ? { comment: '' } + : mapDefault(columnDefault, defaultMapping); + if ('attribute' in result) return result.attribute; return typeof column.default === 'string' ? mappedAttribute({ kind: 'function', expression: column.default }, defaultMapping) : undefined; 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..74db58e7d485 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/framework-components/codec'; 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/postgres-type-map.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts index 769786748f4e..e417a617d008 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts @@ -52,6 +52,17 @@ const PARAMETERIZED_NATIVE_TYPES: Record = { timetz: 'Timetz', }; +/** + * Every PSL type name this map prints for a column whose native type it recognises. A column's + * literal default has to be written in the form the codec bound to its type name reads back, so + * `infer-default-codec.ts` names a codec for each of these. + */ +export const PRINTED_PSL_TYPE_NAMES: ReadonlySet = new Set([ + ...Object.values(POSTGRES_TO_PSL), + ...Object.values(PRESERVED_NATIVE_TYPES), + ...Object.values(PARAMETERIZED_NATIVE_TYPES), +]); + const PARAMETERIZED_TYPE_PATTERN = /^(.+?)\((.+)\)$/; function getOwnMappingValue(map: Record, key: string): string | undefined { 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..1ae53d61be05 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,6 @@ import { type ColumnDefault, isColumnDefault } from '@internal/contract/types'; import type { PslPrinterOptions } from '@internal/family-sql/psl-infer'; +import { escapePslString } from '@internal/framework-components/codec'; import type { PslAttribute, PslAttributeArgument, @@ -58,112 +59,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..49eabf0c2e6f 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,7 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + type LiteralTypeDeclaration, voidParamsSchema, } from '@internal/framework-components/codec'; import { CastExpr, type ProjectionExpr } from '@internal/sql-relational-core/ast'; @@ -57,6 +58,7 @@ export class PgDateTemporalCodec extends CodecImpl< } export class PgDateTemporalDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_DATE_NATIVE_TYPE; } @@ -105,6 +107,7 @@ export class PgTimestampTemporalCodec extends CodecImpl< } export class PgTimestampTemporalDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_TIMESTAMP_NATIVE_TYPE; } @@ -161,6 +164,7 @@ export class PgTimestamptzTemporalCodec extends CodecImpl< } export class PgTimestamptzTemporalDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_TIMESTAMPTZ_NATIVE_TYPE; } @@ -215,6 +219,7 @@ export class PgTimeTemporalCodec extends CodecImpl< } export class PgTimeTemporalDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_TIME_NATIVE_TYPE; } 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..46c24effe0a6 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,7 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + type LiteralTypeDeclaration, voidParamsSchema, } from '@internal/framework-components/codec'; import { CastExpr, type ProjectionExpr } from '@internal/sql-relational-core/ast'; @@ -49,6 +50,7 @@ export class PgDateStringCodec extends CodecImpl< } export class PgDateStringDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_DATE_NATIVE_TYPE; } @@ -96,6 +98,7 @@ export class PgTimestampStringCodec extends CodecImpl< } export class PgTimestampStringDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_TIMESTAMP_NATIVE_TYPE; } @@ -154,6 +157,7 @@ export class PgTimestamptzStringCodec extends CodecImpl< } export class PgTimestamptzStringDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_TIMESTAMPTZ_NATIVE_TYPE; } @@ -211,6 +215,7 @@ export class PgTimeStringCodec extends CodecImpl< } export class PgTimeStringDescriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override nativeType(): string { return PG_TIME_NATIVE_TYPE; } diff --git a/packages/3-targets/3-targets/postgres/test/codecs.test.ts b/packages/3-targets/3-targets/postgres/test/codecs.test.ts index 46bf77950f0f..ad1b6012e8f6 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs.test.ts @@ -395,9 +395,13 @@ describe('adapter-postgres codecs', () => { expect(codec.decodeJson('9007199254740993')).toBe(9007199254740993n); }); - it('rejects a JSON number, which has already lost digits', () => { - expect(() => codec.decodeJson(42)).toThrow( - 'pg/int8@1 database JSON value must be a decimal string', + it('reads the JSON number of a whole-number literal default', () => { + expect(codec.decodeJson(42)).toBe(42n); + }); + + it('rejects a JSON number past the safe integer range, which has already lost digits', () => { + expect(() => codec.decodeJson(9007199254740992)).toThrow( + 'pg/int8@1 wire number must be an integer within the safe integer range', ); }); diff --git a/packages/3-targets/3-targets/postgres/test/integer-representation-codecs.test.ts b/packages/3-targets/3-targets/postgres/test/integer-representation-codecs.test.ts index ddb789d70885..a80ef722b69b 100644 --- a/packages/3-targets/3-targets/postgres/test/integer-representation-codecs.test.ts +++ b/packages/3-targets/3-targets/postgres/test/integer-representation-codecs.test.ts @@ -116,9 +116,19 @@ describe('pg/int8number@1', () => { expect(codec.decodeJson(-9007199254740991)).toBe(-9007199254740991); }); - it('rejects a JSON string', () => { - expect(() => codec.decodeJson('42')).toThrow( - 'pg/int8number@1 database JSON value must be a number', + it('reads the digit text of a whole-number literal default', () => { + expect(codec.decodeJson('42')).toBe(42); + }); + + it('rejects digit text past the safe integer range', () => { + expect(() => codec.decodeJson('9007199254740992')).toThrow( + 'pg/int8number@1 value must be an integer within the safe integer range', + ); + }); + + it('rejects a JSON string that is not a decimal integer', () => { + expect(() => codec.decodeJson('1.5')).toThrow( + 'pg/int8number@1 value must be a decimal integer', ); }); @@ -312,9 +322,13 @@ describe('pg/unboundedint@1', () => { expect(codec.decodeJson('18446744073709551617')).toBe(18446744073709551617n); }); - it('rejects a JSON number, which has already lost digits', () => { - expect(() => codec.decodeJson(42)).toThrow( - 'pg/unboundedint@1 database JSON value must be a decimal string', + it('reads the JSON number of a whole-number literal default', () => { + expect(codec.decodeJson(42)).toBe(42n); + }); + + it('rejects a JSON number past the safe integer range, which has already lost digits', () => { + expect(() => codec.decodeJson(9007199254740992)).toThrow( + 'pg/unboundedint@1 wire number must be an integer within the safe integer range', ); }); diff --git a/packages/3-targets/3-targets/postgres/test/literal-default-coercion.test.ts b/packages/3-targets/3-targets/postgres/test/literal-default-coercion.test.ts new file mode 100644 index 000000000000..3dd79a788d1a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/literal-default-coercion.test.ts @@ -0,0 +1,162 @@ +import type { CodecInstanceContext } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { + pgFloat4Descriptor, + pgFloat8Descriptor, + pgFloatDescriptor, + pgInt8Descriptor, + pgInt8NumberDescriptor, + pgNumericDescriptor, + pgUnboundedIntDescriptor, +} from '../src/core/codecs'; + +const ctx: CodecInstanceContext = { name: 'literal-defaults' }; + +describe('pg/int8@1 decodeJson', () => { + const codec = pgInt8Descriptor.factory()(ctx); + + it('reads the digit text of an i64 literal', () => { + expect(codec.decodeJson('9007199254740993')).toBe(9007199254740993n); + }); + + it('reads the JSON number of a small whole-number literal', () => { + expect(codec.decodeJson(42)).toBe(42n); + }); + + it.each([ + ['a fractional number', 1.5], + ['a number past the safe integer range', 9007199254740992], + ['text that is not a decimal integer', '1.5'], + ['a boolean', true], + ['null', null], + ])('refuses %s', (_name, json) => { + expect(() => codec.decodeJson(json)).toThrow(); + }); +}); + +describe('pg/unboundedint@1 decodeJson', () => { + const codec = pgUnboundedIntDescriptor.factory()(ctx); + + it('reads digit text past the int8 range', () => { + expect(codec.decodeJson('9223372036854775808')).toBe(9223372036854775808n); + }); + + it('reads a JSON number', () => { + expect(codec.decodeJson(-7)).toBe(-7n); + }); + + it.each([ + ['a fractional number', 1.5], + ['a number past the safe integer range', 9007199254740992], + ['decimal text', '1.5'], + ])('refuses %s', (_name, json) => { + expect(() => codec.decodeJson(json)).toThrow(); + }); +}); + +describe('pg/int8number@1 decodeJson', () => { + const codec = pgInt8NumberDescriptor.factory()(ctx); + + it('reads a JSON number', () => { + expect(codec.decodeJson(42)).toBe(42); + }); + + it('reads digit text within the safe integer range', () => { + expect(codec.decodeJson('9007199254740991')).toBe(9007199254740991); + }); + + it('refuses digit text past the safe integer range, naming the limit', () => { + expect(() => codec.decodeJson('9007199254740992')).toThrow( + 'pg/int8number@1 value must be an integer within the safe integer range', + ); + }); + + it.each([ + ['decimal text', '1.5'], + ['a fractional number', 1.5], + ])('refuses %s', (_name, json) => { + expect(() => codec.decodeJson(json)).toThrow(); + }); +}); + +describe('pg/numeric@1 decodeJson', () => { + const codec = pgNumericDescriptor.factory({})(ctx); + + it('reads decimal text as written', () => { + expect(codec.decodeJson('1.50')).toBe('1.50'); + }); + + it.each([ + ['a whole JSON number', 42, '42'], + ['a fractional JSON number', 1.5, '1.5'], + ])('reads %s as canonical decimal text', (_name, json, expected) => { + expect(codec.decodeJson(json)).toBe(expected); + }); + + it.each([ + ['a boolean', true], + ['null', null], + ])('refuses %s', (_name, json) => { + expect(() => codec.decodeJson(json)).toThrow(); + }); +}); + +describe.each([ + ['pg/float4@1', pgFloat4Descriptor], + ['pg/float8@1', pgFloat8Descriptor], +])('%s non-finite values and literal shapes', (_id, descriptor) => { + const codec = descriptor.factory()(ctx); + + it.each([ + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['-Infinity', Number.NEGATIVE_INFINITY], + ])('round-trips %s through encodeJson and decodeJson', (text, value) => { + expect(codec.encodeJson(value)).toBe(text); + expect(codec.decodeJson(codec.encodeJson(value))).toBe(value); + }); + + it.each([ + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['-Infinity', Number.NEGATIVE_INFINITY], + ])('round-trips %s through encode and decode', async (text, value) => { + expect(await codec.encode(value, {})).toBe(text); + expect(await codec.decode(text, {})).toBe(value); + }); + + it('keeps a finite value as a JSON number', () => { + expect(codec.encodeJson(1.5)).toBe(1.5); + expect(codec.decodeJson(1.5)).toBe(1.5); + }); + + it.each([ + ['digit text', '42', 42], + ['decimal text', '1.5', 1.5], + ])('reads %s', (_name, json, expected) => { + expect(codec.decodeJson(json)).toBe(expected); + }); + + it.each([ + ['text that is not a numeral', 'nonsense'], + ['a boolean', true], + ['null', null], + ])('refuses %s', (_name, json) => { + expect(() => codec.decodeJson(json)).toThrow(); + }); +}); + +describe('pg/float@1 decodeJson', () => { + const codec = pgFloatDescriptor.factory()(ctx); + + it.each([ + ['digit text', '42', 42], + ['decimal text', '1.5', 1.5], + ])('reads %s', (_name, json, expected) => { + expect(codec.decodeJson(json)).toBe(expected); + }); + + it.each([['NaN'], ['Infinity'], ['-Infinity']])('refuses the non-finite word %s', (json) => { + expect(() => codec.decodeJson(json)).toThrow(); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/literal-type-inventory.test.ts b/packages/3-targets/3-targets/postgres/test/literal-type-inventory.test.ts new file mode 100644 index 000000000000..780416fb7015 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/literal-type-inventory.test.ts @@ -0,0 +1,67 @@ +import { + integerLiteralTypesUpTo, + type LiteralTypeDeclaration, +} from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { codecDescriptors } from '../src/core/codecs'; + +const string = ['string'] as const; +const wholeNumbers = integerLiteralTypesUpTo('i64'); +const exactDecimals = [...wholeNumbers, 'bigint', 'decimal'] as const; +const everyNumber = [...exactDecimals, 'float'] as const; + +const EXPECTED: Readonly> = { + 'sql/char@1': string, + 'sql/varchar@1': string, + 'sql/text@1': string, + 'sql/int@1': integerLiteralTypesUpTo('i32'), + 'sql/float@1': exactDecimals, + 'pg/text@1': string, + 'pg/char@1': string, + 'pg/varchar@1': string, + 'pg/uuid@1': string, + 'pg/inet@1': string, + 'pg/bit@1': string, + 'pg/varbit@1': string, + 'pg/timetz@1': string, + 'pg/interval@1': string, + 'pg/bytea@1': string, + 'pg/date-string@1': string, + 'pg/time-string@1': string, + 'pg/timestamp-string@1': string, + 'pg/timestamptz-string@1': string, + 'pg/date-temporal@1': string, + 'pg/time-temporal@1': string, + 'pg/timestamp-temporal@1': string, + 'pg/timestamptz-temporal@1': string, + 'pg/timestamptz-date@1': string, + 'pg/bool@1': ['boolean'], + 'pg/int2@1': integerLiteralTypesUpTo('i16'), + 'pg/int4@1': integerLiteralTypesUpTo('i32'), + 'pg/int@1': integerLiteralTypesUpTo('i32'), + 'pg/int8@1': wholeNumbers, + 'pg/int8number@1': wholeNumbers, + 'pg/unboundedint@1': [...wholeNumbers, 'bigint'], + 'pg/float@1': exactDecimals, + 'pg/float4@1': everyNumber, + 'pg/float8@1': everyNumber, + 'pg/numeric@1': everyNumber, + 'pg/json@1': ['json'], + 'pg/jsonb@1': ['json'], + 'pg/enum@1': [], + 'pg/text-array@1': [], +}; + +describe('postgres literal type inventory', () => { + it('registers codecs to check', () => { + expect(codecDescriptors.length).toBeGreaterThan(0); + }); + + it('declares the literal types of every registered codec', () => { + expect( + Object.fromEntries( + codecDescriptors.map((descriptor) => [descriptor.codecId, descriptor.literalTypes ?? []]), + ), + ).toEqual(EXPECTED); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl.round-trip.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl.round-trip.test.ts new file mode 100644 index 000000000000..0100d73b5ea5 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl.round-trip.test.ts @@ -0,0 +1,209 @@ +/** + * What `contract infer` prints, `contract emit` reads back to the value the database reported. + * + * The printer chooses a PSL literal from the column codec's declared literal types; this parses the + * printed schema with the real PSL parser and interprets it through the real codec descriptors, so + * a literal that prints but does not read back fails here rather than in a user's terminal. + */ + +import { + type AuthoringTypeNamespace, + collectScalarTypeConstructors, +} from '@internal/framework-components/authoring'; +import { type CodecLookup, jsonDefaultLiteralTagEntry } 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'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { interpretPslDocumentToSqlContract } from '@internal/sql-contract-psl'; +import { type SqlColumnIRInput, SqlSchemaIR } from '@internal/sql-schema-ir/types'; +import { ifDefined } from '@internal/utils/defined'; +import { describe, expect, it } from 'vitest'; +import { + postgresAuthoringEntityTypes, + postgresAuthoringPslBlockDescriptors, +} from '../../src/core/authoring'; +import { parsePostgresDefault } from '../../src/core/default-normalizer'; +import { type PostgresSchema, postgresCreateNamespace } from '../../src/core/postgres-schema'; +import { CODEC_ID_BY_PRINTED_TYPE } from '../../src/core/psl-infer/infer-default-codec'; +import { postgresCodecRegistry } from '../../src/core/registry'; +import { printPslFromFlat } from './fixtures'; + +/** The type constructors the printed schema names, bound to the codec `contract emit` resolves. */ +const authoringTypes = { + String: { kind: 'typeConstructor', output: { codecId: 'pg/text@1', nativeType: 'text' } }, + Boolean: { kind: 'typeConstructor', output: { codecId: 'pg/bool@1', nativeType: 'bool' } }, + Int: { kind: 'typeConstructor', output: { codecId: 'pg/int4@1', nativeType: 'int4' } }, + SmallInt: { kind: 'typeConstructor', output: { codecId: 'pg/int2@1', nativeType: 'int2' } }, + BigInt: { kind: 'typeConstructor', output: { codecId: 'pg/int8@1', nativeType: 'int8' } }, + Float: { kind: 'typeConstructor', output: { codecId: 'pg/float8@1', nativeType: 'float8' } }, + Jsonb: { kind: 'typeConstructor', output: { codecId: 'pg/jsonb@1', nativeType: 'jsonb' } }, + Timestamp: { + kind: 'typeConstructor', + args: [{ kind: 'number', name: 'precision', integer: true, minimum: 0, optional: true }], + output: { + codecId: 'pg/timestamp-temporal@1', + nativeType: 'timestamp', + typeParams: { precision: { kind: 'arg', index: 0 } }, + }, + }, + Numeric: { + kind: 'typeConstructor', + args: [ + { kind: 'number', name: 'precision', integer: true, minimum: 1, optional: true }, + { kind: 'number', name: 'scale', integer: true, minimum: 0, optional: true }, + ], + output: { + codecId: 'pg/numeric@1', + nativeType: 'numeric', + typeParams: { precision: { kind: 'arg', index: 0 }, scale: { kind: 'arg', index: 1 } }, + }, + }, +} as const satisfies AuthoringTypeNamespace; + +const assembled = assembleAuthoringContributions([ + { + authoring: { + entityTypes: postgresAuthoringEntityTypes, + type: authoringTypes, + pslBlockDescriptors: postgresAuthoringPslBlockDescriptors, + }, + }, +]); + +const target = { + kind: 'target' as const, + familyId: 'sql' as const, + targetId: 'postgres' as const, + id: 'postgres', + version: '0.0.1', + capabilities: {}, + defaultNamespaceId: 'public', + authoring: { type: authoringTypes }, +}; + +const codecLookup: CodecLookup = { + get: (id) => postgresCodecRegistry.descriptorFor(id)?.factory({})({ name: id }), + descriptorFor: (id) => postgresCodecRegistry.descriptorFor(id), + targetTypesFor: (id) => postgresCodecRegistry.descriptorFor(id)?.targetTypes, + renderOutputTypeFor: () => undefined, +}; + +function introspected( + name: string, + nativeType: string, + rawDefault: string, + shape: { readonly many?: true } = {}, +): SqlColumnIRInput { + const resolvedNativeType = shape.many ? `${nativeType}[]` : nativeType; + return { + name, + nativeType, + nullable: shape.many === true, + default: rawDefault, + ...ifDefined('many', shape.many), + resolvedNativeType, + ...ifDefined('resolvedDefault', parsePostgresDefault(rawDefault, resolvedNativeType)), + }; +} + +/** The `default` each column carries after printing the schema and interpreting what was printed. */ +function roundTrippedDefaults(columns: readonly SqlColumnIRInput[]) { + const printed = printPslFromFlat( + new SqlSchemaIR({ + tables: { + account: { + name: 'account', + columns: Object.fromEntries( + [{ name: 'id', nativeType: 'int4', nullable: false }, ...columns].map((column) => [ + column.name, + column, + ]), + ), + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }), + ); + const { document, sources } = parse(printed, 'schema.prisma'); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: assembled.pslBlockDescriptors, + }); + const emitted = interpretPslDocumentToSqlContract({ + document, + symbolTable, + sources, + capabilities: { sql: { scalarList: true } }, + target, + scalarColumnDescriptors: collectScalarTypeConstructors(authoringTypes), + authoringContributions: assembled, + composedExtensionContracts: new Map(), + createNamespace: postgresCreateNamespace, + codecLookup, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + defaultLiteralTagRegistry: new Map([['json', jsonDefaultLiteralTagEntry()]]), + generatorDescriptors: [], + }, + }); + if (!emitted.ok) { + throw new Error(`${printed}\n\n${JSON.stringify(emitted.failure.diagnostics, null, 2)}`); + } + const storage = emitted.value.storage as SqlStorage; + const namespace = storage.namespaces['public'] as PostgresSchema; + return Object.fromEntries( + Object.entries(namespace.entries.table?.['account']?.columns ?? {}).flatMap(([name, column]) => + column.default === undefined ? [] : [[name, column.default]], + ), + ); +} + +describe('a printed default reads back as the value the database reported', () => { + it('round-trips every literal form the printer writes', () => { + expect( + roundTrippedDefaults([ + introspected('name', 'text', "'anonymous'::text"), + introspected( + 'quoted', + 'text', + `'he said "hi" \\ over +two lines é'::text`, + ), + introspected('small', 'int2', "'100'::integer"), + introspected('count', 'int4', "'100000'::integer"), + introspected('balance', 'int8', "'100000000000000099'::bigint"), + introspected('price', 'numeric(10,2)', '1.50'), + introspected('ratio', 'float8', "'NaN'::numeric"), + introspected('active', 'bool', 'true'), + introspected('meta', 'jsonb', `'{"plan": "free", "seats": 1}'::jsonb`), + introspected('stamp', 'timestamp(3)', "'2024-01-01 00:00:00'::timestamp(3)"), + introspected('scores', 'int4', "'{1,2}'::integer[]", { many: true }), + introspected('docs', 'jsonb', `ARRAY['{}'::jsonb, '[]'::jsonb]`, { many: true }), + ]), + ).toEqual({ + name: { kind: 'literal', value: 'anonymous' }, + quoted: { kind: 'literal', value: 'he said "hi" \\ over\ntwo lines é' }, + small: { kind: 'literal', value: 100 }, + count: { kind: 'literal', value: 100000 }, + balance: { kind: 'literal', value: '100000000000000099' }, + price: { kind: 'literal', value: '1.50' }, + ratio: { kind: 'literal', value: 'NaN' }, + active: { kind: 'literal', value: true }, + meta: { kind: 'literal', value: { plan: 'free', seats: 1 } }, + stamp: { kind: 'literal', value: '2024-01-01T00:00:00' }, + scores: { kind: 'literal', value: [1, 2] }, + docs: { kind: 'literal', value: [{}, []] }, + }); + }); + + it('prints a type constructor for every printed type name the round trip covers', () => { + expect( + Object.keys(authoringTypes).filter((name) => !CODEC_ID_BY_PRINTED_TYPE.has(name)), + ).toEqual([]); + }); +}); 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..860f8880d75e 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 @@ -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(json\`{}\`) 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..8190be0038d8 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) @@ -111,9 +111,9 @@ describe('printPsl literal defaults', () => { 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")) + stamp Timestamp(3) @default("2024-01-01 00:00:00") + day Date @default("2024-01-01") + jsonNull Jsonb? @default(json\`null\`) textNull VarChar(32)? @default(dbgenerated("NULL::character varying")) @@map("raw_defaults") @@ -139,10 +139,10 @@ describe('printPsl literal defaults', () => { model SpecialValueDefaults { id Int @id - floatNaN Float @default("NaN") - floatNegInf Float @default("-Infinity") - realNaN Real @default("NaN") - decimalNaN Numeric @default("NaN") + floatNaN Float @default(NaN) + floatNegInf Float @default(-Infinity) + realNaN Real @default(NaN) + decimalNaN Numeric @default(NaN) timeWithZone Timetz @default("12:34:56+00") @@map("special_value_defaults") @@ -209,8 +209,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") @@ -235,7 +235,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-01 00:00:00"]) @noCheck(elementNotNull) @@map("raw_list_defaults") } diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.literal-types.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.literal-types.test.ts new file mode 100644 index 000000000000..ec4917d84de4 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.literal-types.test.ts @@ -0,0 +1,162 @@ +import { type SqlColumnIRInput, SqlSchemaIR } from '@internal/sql-schema-ir/types'; +import { ifDefined } from '@internal/utils/defined'; +import { describe, expect, it } from 'vitest'; +import { parsePostgresDefault } from '../../../src/core/default-normalizer'; +import { + CODEC_ID_BY_PRINTED_TYPE, + literalTypesForPrintedType, +} from '../../../src/core/psl-infer/infer-default-codec'; +import { PRINTED_PSL_TYPE_NAMES } from '../../../src/core/psl-infer/postgres-type-map'; +import { printPslFromFlat } from '../fixtures'; + +function introspected( + name: string, + nativeType: string, + rawDefault: string, + shape: { readonly many?: true } = {}, +): SqlColumnIRInput { + const resolvedNativeType = shape.many ? `${nativeType}[]` : nativeType; + return { + name, + nativeType, + nullable: shape.many === true, + default: rawDefault, + ...ifDefined('many', shape.many), + resolvedNativeType, + ...ifDefined('resolvedDefault', parsePostgresDefault(rawDefault, resolvedNativeType)), + }; +} + +/** The `@default(...)` each column prints, keyed by field name. */ +function printedDefaults(columns: readonly SqlColumnIRInput[]): Record { + const output = printPslFromFlat( + new SqlSchemaIR({ + tables: { + account: { + name: 'account', + columns: Object.fromEntries( + [{ name: 'id', nativeType: 'int4', nullable: false }, ...columns].map((column) => [ + column.name, + column, + ]), + ), + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }), + ); + return Object.fromEntries( + output + .split('\n') + .flatMap((line) => { + const match = /^\s+(\w+)\s.*?(@default\(.*?\))(?:\s+@|\s*$)/.exec(line); + return match?.[1] === undefined || match[2] === undefined ? [] : [[match[1], match[2]]]; + }) + .filter(([name]) => name !== 'id'), + ); +} + +describe('printPsl writes each default as the literal its codec reads back', () => { + it('prints every literal form the outcome schema writes', () => { + expect( + printedDefaults([ + introspected('name', 'text', "'anonymous'::text"), + introspected('small', 'int2', "'100'::integer"), + introspected('count', 'int4', "'100000'::integer"), + introspected('balance', 'int8', "'100000000000000099'::bigint"), + introspected('price', 'numeric(10,2)', '1.50'), + introspected('ratio', 'float8', "'NaN'::numeric"), + introspected('active', 'bool', 'true'), + introspected('meta', 'jsonb', `'{"plan": "free", "seats": 1}'::jsonb`), + introspected('scores', 'int4', "'{1,2}'::integer[]", { many: true }), + ]), + ).toEqual({ + name: '@default("anonymous")', + small: '@default(100)', + count: '@default(100000)', + balance: '@default(100000000000000099)', + price: '@default(1.50)', + ratio: '@default(NaN)', + active: '@default(true)', + meta: '@default(json`{"plan":"free","seats":1}`)', + scores: '@default([1, 2])', + }); + }); + + it('prints every digit of an int8 past the safe integer range', () => { + expect(printedDefaults([introspected('big', 'int8', "'9007199254740993'::bigint")])).toEqual({ + big: '@default(9007199254740993)', + }); + }); + + it.each([ + ['NaN', "'NaN'::numeric", '@default(NaN)'], + ['Infinity', "'Infinity'::numeric", '@default(Infinity)'], + ['-Infinity', "'-Infinity'::numeric", '@default(-Infinity)'], + ])('prints the float8 %s unquoted', (_name, rawDefault, expected) => { + expect(printedDefaults([introspected('ratio', 'float8', rawDefault)])).toEqual({ + ratio: expected, + }); + }); + + it('prints a list of json documents as json tags', () => { + expect( + printedDefaults([ + introspected('docs', 'jsonb', `ARRAY['{}'::jsonb, '[]'::jsonb]`, { many: true }), + ]), + ).toEqual({ docs: '@default([json`{}`, json`[]`])' }); + }); + + it.each([ + ['infinity', "'infinity'::timestamp without time zone"], + ['-infinity', "'-infinity'::timestamp without time zone"], + ])( + 'falls back to the raw expression for the temporal sentinel %s, which its codec refuses', + (_name, rawDefault) => { + const printed = printedDefaults([introspected('stamp', 'timestamp', rawDefault)])['stamp']; + expect(printed).toMatch(/^@default\(dbgenerated\(/); + }, + ); + + it('prints an ordinary temporal default as the string its codec reads', () => { + expect( + printedDefaults([ + introspected('stamp', 'timestamp', "'2024-01-01 00:00:00'::timestamp without time zone"), + ]), + ).toEqual({ stamp: '@default("2024-01-01 00:00:00")' }); + }); + + it('falls back to the raw expression for a codec that names no literal type', () => { + expect(printedDefaults([introspected('area', 'geometry', "'POINT(0 0)'::geometry")])).toEqual( + {}, + ); + }); +}); + +describe('the codec bound to each printed type name', () => { + it('covers every PSL type name the type map prints', () => { + expect(PRINTED_PSL_TYPE_NAMES.size).toBeGreaterThan(0); + expect( + [...PRINTED_PSL_TYPE_NAMES].filter((name) => !CODEC_ID_BY_PRINTED_TYPE.has(name)), + ).toEqual([]); + }); + + it('names a registered codec that declares literal types for every printed type', () => { + expect( + [...CODEC_ID_BY_PRINTED_TYPE.keys()].filter( + (typeName) => literalTypesForPrintedType(typeName, false).length === 0, + ), + ).toEqual([]); + }); + + it('reads an enum column through the text codec, whose members are strings', () => { + expect(literalTypesForPrintedType('SomeEnum', true)).toEqual(['string']); + }); + + it('names nothing for a type no codec is bound to', () => { + expect(literalTypesForPrintedType('Unsupported', false)).toEqual([]); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/src/core/codec-descriptor.ts b/packages/3-targets/3-targets/sqlite/src/core/codec-descriptor.ts index ffcce6b97760..c528d26cd604 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/codec-descriptor.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/codec-descriptor.ts @@ -7,6 +7,7 @@ import { type CodecInstanceContext, type CodecRef, type CodecTrait, + type LiteralTypeDeclaration, validateCodecTypeParams, } from '@internal/framework-components/codec'; import type { ProjectionExpr } from '@internal/sql-relational-core/ast'; @@ -71,6 +72,7 @@ class SqliteCodecDescriptorAdapter extends SqliteC override readonly codecId: string; override readonly traits: readonly CodecTrait[]; override readonly targetTypes: readonly string[]; + override readonly literalTypes: readonly LiteralTypeDeclaration[] | undefined; override readonly paramsSchema: D['paramsSchema']; override readonly renderOutputType?: (params: DescriptorParams) => string | undefined; override readonly renderInputType?: (params: DescriptorParams) => string | undefined; @@ -89,6 +91,8 @@ class SqliteCodecDescriptorAdapter extends SqliteC this.targetTypes = descriptor.targetTypes; this.paramsSchema = descriptor.paramsSchema; + this.literalTypes = descriptor.literalTypes; + const renderOutputType = descriptor.renderOutputType; if (renderOutputType !== undefined) { this.renderOutputType = (params) => renderOutputType.call(descriptor, params); 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..1fcedaa33c9d 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,9 @@ import { type ColumnHelperFor, type ColumnHelperForStrict, column, + integerLiteralTypesUpTo, + isNumeralText, + type LiteralTypeDeclaration, renderTsLiteral, voidParamsSchema, } from '@internal/framework-components/codec'; @@ -143,6 +146,27 @@ const UPPERCASE_HEX = /^(?:[0-9A-F]{2})*$/; * `9.0e+999`, which reads back as `Infinity` rather than failing. A real is * therefore carried only where it is finite. */ +/** A whole number within the range a JS number holds exactly, read from a JSON number or the digit text an `i64` literal default carries. */ +const safeWholeNumber = (codecId: string, json: JsonValue): number => { + const text = typeof json === 'number' ? String(json) : json; + if (typeof text !== 'string' || !DECIMAL_INTEGER.test(text)) { + throw sqliteError( + 'RUNTIME.DECODE_FAILED', + `${codecId} database JSON value must be a whole number or decimal text`, + { meta: { codecId, received: typeof json } }, + ); + } + const value = BigInt(text); + if (value < MIN_SAFE_INTEGER_BIGINT || value > MAX_SAFE_INTEGER_BIGINT) { + throw sqliteError( + 'RUNTIME.DECODE_FAILED', + `${codecId} value must be an integer within the safe integer range, got ${text}`, + { meta: { codecId, received: text } }, + ); + } + return Number(value); +}; + const finiteReal = (value: number, code: 'RUNTIME.ENCODE_FAILED' | 'RUNTIME.DECODE_FAILED') => { if (!Number.isFinite(value)) { throw sqliteError(code, 'sqlite/real@1 value must be a finite number', { @@ -276,6 +300,7 @@ export class SqliteTextCodec extends CodecImpl< } export class SqliteTextDescriptor extends SqliteCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override jsonProjection(expression: ProjectionExpr): ProjectionExpr { return expression; } @@ -312,11 +337,13 @@ export class SqliteIntegerCodec extends CodecImpl< return value; } decodeJson(json: JsonValue): number { - return json as number; + return safeWholeNumber(SQLITE_INTEGER_CODEC_ID, json); } } export class SqliteIntegerDescriptor extends SqliteCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = + integerLiteralTypesUpTo('i64'); protected override jsonProjection(expression: ProjectionExpr): ProjectionExpr { return expression; } @@ -353,10 +380,11 @@ export class SqliteRealCodec extends CodecImpl< return finiteReal(value, 'RUNTIME.ENCODE_FAILED'); } decodeJson(json: JsonValue): number { + if (typeof json === 'string' && isNumeralText(json)) return Number(json); if (typeof json !== 'number') { throw sqliteError( 'RUNTIME.DECODE_FAILED', - 'sqlite/real@1 database JSON value must be a number', + 'sqlite/real@1 database JSON value must be a number or decimal text', { meta: { codecId: SQLITE_REAL_CODEC_ID, received: typeof json }, }, @@ -367,6 +395,11 @@ export class SqliteRealCodec extends CodecImpl< } export class SqliteRealDescriptor extends SqliteCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = [ + ...integerLiteralTypesUpTo('i64'), + 'bigint', + 'decimal', + ]; protected override jsonProjection(expression: ProjectionExpr): ProjectionExpr { return expression; } @@ -415,6 +448,7 @@ export class SqliteBlobCodec extends CodecImpl< } export class SqliteBlobDescriptor extends SqliteCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override jsonProjection(expression: ProjectionExpr): ProjectionExpr { return hexJsonProjection(expression); } @@ -475,6 +509,7 @@ export class SqliteDatetimeCodec extends CodecImpl< } export class SqliteDatetimeDescriptor extends SqliteCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['string']; protected override jsonProjection(expression: ProjectionExpr): ProjectionExpr { return expression; } @@ -516,6 +551,7 @@ export class SqliteJsonCodec extends CodecImpl< } export class SqliteJsonDescriptor extends SqliteCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = ['json']; protected override jsonProjection(expression: ProjectionExpr): ProjectionExpr { return jsonDocumentRetag(expression); } @@ -575,10 +611,11 @@ export class SqliteBigintCodec extends CodecImpl< return bigintEncodeJson(SQLITE_BIGINT_CODEC_ID, value); } decodeJson(json: JsonValue): bigint { + if (typeof json === 'number') return BigInt(safeWholeNumber(SQLITE_BIGINT_CODEC_ID, json)); if (typeof json !== 'string' || !DECIMAL_INTEGER.test(json)) { throw sqliteError( 'RUNTIME.DECODE_FAILED', - 'sqlite/bigint@1 database JSON value must be a decimal string', + 'sqlite/bigint@1 database JSON value must be a decimal string or a whole number', { meta: { codecId: SQLITE_BIGINT_CODEC_ID, received: typeof json } }, ); } @@ -587,6 +624,8 @@ export class SqliteBigintCodec extends CodecImpl< } export class SqliteBigintDescriptor extends SqliteCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = + integerLiteralTypesUpTo('i64'); protected override jsonProjection(expression: ProjectionExpr): ProjectionExpr { return decimalTextJsonProjection(expression); } @@ -645,10 +684,11 @@ export class SqliteBigintNumberCodec extends CodecImpl< return encodableSafeInteger(value); } decodeJson(json: JsonValue): number { + if (typeof json === 'string') return safeWholeNumber(SQLITE_BIGINT_NUMBER_CODEC_ID, json); if (typeof json !== 'number') { throw sqliteError( 'RUNTIME.DECODE_FAILED', - 'sqlite/bigintnumber@1 database JSON value must be a number', + 'sqlite/bigintnumber@1 database JSON value must be a number or decimal text', { meta: { codecId: SQLITE_BIGINT_NUMBER_CODEC_ID, received: typeof json } }, ); } @@ -657,6 +697,8 @@ export class SqliteBigintNumberCodec extends CodecImpl< } export class SqliteBigintNumberDescriptor extends SqliteCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = + integerLiteralTypesUpTo('i64'); protected override jsonProjection(expression: ProjectionExpr): ProjectionExpr { return integerJsonProjection(expression); } diff --git a/packages/3-targets/3-targets/sqlite/test/codecs.test.ts b/packages/3-targets/3-targets/sqlite/test/codecs.test.ts index 59bd8dadcbcc..4a61c0fc2589 100644 --- a/packages/3-targets/3-targets/sqlite/test/codecs.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/codecs.test.ts @@ -11,9 +11,13 @@ describe('SQLite codec JSON representations', () => { expect(bigintCodec.decodeJson('9223372036854775807')).toBe(9223372036854775807n); }); - it('rejects a JSON number, which has already lost digits', () => { - expect(() => bigintCodec.decodeJson(42)).toThrow( - 'sqlite/bigint@1 database JSON value must be a decimal string', + it('reads the JSON number of a whole-number literal default', () => { + expect(bigintCodec.decodeJson(42)).toBe(42n); + }); + + it('rejects a JSON number past the safe integer range, which has already lost digits', () => { + expect(() => bigintCodec.decodeJson(9007199254740992)).toThrow( + 'sqlite/bigint@1 value must be an integer within the safe integer range', ); }); diff --git a/packages/3-targets/3-targets/sqlite/test/integer-representation-codecs.test.ts b/packages/3-targets/3-targets/sqlite/test/integer-representation-codecs.test.ts index 19776ec39ce7..5ed2a59bc8c4 100644 --- a/packages/3-targets/3-targets/sqlite/test/integer-representation-codecs.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/integer-representation-codecs.test.ts @@ -184,9 +184,19 @@ describe('sqlite/bigintnumber@1', () => { expect(codec.decodeJson(-9007199254740991)).toBe(-9007199254740991); }); - it('rejects a JSON string', () => { - expect(() => codec.decodeJson('42')).toThrow( - 'sqlite/bigintnumber@1 database JSON value must be a number', + it('reads the digit text of a whole-number literal default', () => { + expect(codec.decodeJson('42')).toBe(42); + }); + + it('rejects digit text past the safe integer range', () => { + expect(() => codec.decodeJson('9007199254740992')).toThrow( + 'sqlite/bigintnumber@1 value must be an integer within the safe integer range', + ); + }); + + it('rejects a JSON string that is not a decimal integer', () => { + expect(() => codec.decodeJson('1.5')).toThrow( + 'sqlite/bigintnumber@1 database JSON value must be a whole number or decimal text', ); }); diff --git a/packages/3-targets/3-targets/sqlite/test/literal-default-coercion.test.ts b/packages/3-targets/3-targets/sqlite/test/literal-default-coercion.test.ts new file mode 100644 index 000000000000..a58b46bf3c51 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/literal-default-coercion.test.ts @@ -0,0 +1,93 @@ +import type { CodecInstanceContext } from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { + sqliteBigintDescriptor, + sqliteBigintNumberDescriptor, + sqliteIntegerDescriptor, + sqliteRealDescriptor, +} from '../src/core/codecs'; + +const ctx: CodecInstanceContext = { name: 'literal-defaults' }; + +describe('sqlite/bigint@1 decodeJson', () => { + const codec = sqliteBigintDescriptor.factory()(ctx); + + it('reads digit text', () => { + expect(codec.decodeJson('9007199254740993')).toBe(9007199254740993n); + }); + + it('reads a JSON number', () => { + expect(codec.decodeJson(42)).toBe(42n); + }); + + it.each([ + ['a fractional number', 1.5], + ['a number past the safe integer range', 9007199254740992], + ['decimal text', '1.5'], + ['a boolean', true], + ])('refuses %s', (_name, json) => { + expect(() => codec.decodeJson(json)).toThrow(); + }); +}); + +describe('sqlite/bigintnumber@1 decodeJson', () => { + const codec = sqliteBigintNumberDescriptor.factory()(ctx); + + it('reads a JSON number', () => { + expect(codec.decodeJson(42)).toBe(42); + }); + + it('reads digit text within the safe integer range', () => { + expect(codec.decodeJson('9007199254740991')).toBe(9007199254740991); + }); + + it('refuses digit text past the safe integer range, naming the limit', () => { + expect(() => codec.decodeJson('9007199254740992')).toThrow('safe integer range'); + }); +}); + +describe('sqlite/integer@1 decodeJson', () => { + const codec = sqliteIntegerDescriptor.factory()(ctx); + + it('reads a JSON number', () => { + expect(codec.decodeJson(42)).toBe(42); + }); + + it('reads digit text within the safe integer range', () => { + expect(codec.decodeJson('9007199254740991')).toBe(9007199254740991); + }); + + it.each([ + ['a number past the safe integer range', 9007199254740992], + ['digit text past the safe integer range', '9007199254740992'], + ])('refuses %s, naming the limit', (_name, json) => { + expect(() => codec.decodeJson(json)).toThrow('safe integer range'); + }); + + it.each([ + ['a fractional number', 1.5], + ['decimal text', '1.5'], + ['a boolean', true], + ])('refuses %s', (_name, json) => { + expect(() => codec.decodeJson(json)).toThrow(); + }); +}); + +describe('sqlite/real@1 decodeJson', () => { + const codec = sqliteRealDescriptor.factory()(ctx); + + it.each([ + ['digit text', '42', 42], + ['decimal text', '1.5', 1.5], + ])('reads %s', (_name, json, expected) => { + expect(codec.decodeJson(json)).toBe(expected); + }); + + it('reads a JSON number', () => { + expect(codec.decodeJson(1.5)).toBe(1.5); + }); + + it.each([['NaN'], ['Infinity'], ['-Infinity']])('refuses the non-finite word %s', (json) => { + expect(() => codec.decodeJson(json)).toThrow(); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/literal-type-inventory.test.ts b/packages/3-targets/3-targets/sqlite/test/literal-type-inventory.test.ts new file mode 100644 index 000000000000..f10d1e83fff5 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/literal-type-inventory.test.ts @@ -0,0 +1,38 @@ +import { + integerLiteralTypesUpTo, + type LiteralTypeDeclaration, +} from '@internal/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { codecDescriptors } from '../src/core/codecs'; + +const string = ['string'] as const; +const wholeNumbers = integerLiteralTypesUpTo('i64'); + +const EXPECTED: Readonly> = { + 'sql/char@1': string, + 'sql/varchar@1': string, + 'sql/int@1': integerLiteralTypesUpTo('i32'), + 'sql/float@1': [...wholeNumbers, 'bigint', 'decimal'], + 'sqlite/text@1': string, + 'sqlite/blob@1': string, + 'sqlite/datetime@1': string, + 'sqlite/integer@1': wholeNumbers, + 'sqlite/bigint@1': wholeNumbers, + 'sqlite/bigintnumber@1': wholeNumbers, + 'sqlite/real@1': [...wholeNumbers, 'bigint', 'decimal'], + 'sqlite/json@1': ['json'], +}; + +describe('sqlite literal type inventory', () => { + it('registers codecs to check', () => { + expect(codecDescriptors.length).toBeGreaterThan(0); + }); + + it('declares the literal types of every registered codec', () => { + expect( + Object.fromEntries( + codecDescriptors.map((descriptor) => [descriptor.codecId, descriptor.literalTypes ?? []]), + ), + ).toEqual(EXPECTED); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/structured-errors.test.ts b/packages/3-targets/3-targets/sqlite/test/structured-errors.test.ts index ab5438561eb0..e6d6eb5a0699 100644 --- a/packages/3-targets/3-targets/sqlite/test/structured-errors.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/structured-errors.test.ts @@ -51,13 +51,13 @@ describe('structured error codes', () => { }); }); - it('bigint codec decode of a number raises RUNTIME.DECODE_FAILED', () => { + it('bigint codec decode of a boolean raises RUNTIME.DECODE_FAILED', () => { const bigintCodec = sqliteBigintDescriptor.factory()({ name: 'test' }); - const error = capture(() => bigintCodec.decodeJson(42)); + const error = capture(() => bigintCodec.decodeJson(true)); expect(isStructuredError(error)).toBe(true); expect(error).toMatchObject({ code: 'RUNTIME.DECODE_FAILED', - message: 'sqlite/bigint@1 database JSON value must be a decimal string', + message: 'sqlite/bigint@1 database JSON value must be a decimal string or a whole number', }); }); diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index 13d0d4a2ffa0..2c7d87e01eea 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -11,6 +11,7 @@ import { import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import { parseContractMarkerRow } from '@internal/family-sql/verify'; import type { CodecLookup } from '@internal/framework-components/codec'; +import { materializeCodec } from '@internal/framework-components/codec'; import { APP_SPACE_ID, type SchemaNodeRef } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { ledgerOriginFromStored } from '@internal/migration-tools/ledger-origin'; @@ -1863,7 +1864,14 @@ async function pgRenderDdlColumnDefault( return `DEFAULT ${renderDefaultLiteral(def.value, { many: true, nativeType })}`; } if (codecRef !== undefined) { - const codec = codecLookup.get(codecRef.codecId); + // Built with the column's own `typeParams`: a parameterized codec answers for them when it + // reads a default back — `pg/vector@1` checks the length its column declares — and the lookup's + // representative instance carries none. + const descriptor = codecLookup.descriptorFor?.(codecRef.codecId); + const codec = + descriptor === undefined + ? codecLookup.get(codecRef.codecId) + : materializeCodec(descriptor, codecRef, { name: codecRef.codecId }); if (codec !== undefined) { // A literal default reaches here either as the canonical JSON a // contract stores or as the value an authoring surface built, and only diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts index 1ee52d7e25eb..03ce8923fa62 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts @@ -4,6 +4,7 @@ import { timestampNowControlDescriptor, } from '@internal/family-sql/control'; import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import { jsonDefaultLiteralTagEntry } from '@internal/framework-components/codec'; import type { ControlDefaultLiteralTagEntry, ControlMutationDefaultEntry, @@ -426,9 +427,10 @@ export function createPostgresDefaultLiteralTagRegistry(): ReadonlyMap< string, ControlDefaultLiteralTagEntry > { - return new Map([ + return new Map([ ['sql', sqlDefaultLiteralTagEntry('sql`...`')], ['pg.sql', sqlDefaultLiteralTagEntry('pg.sql`...`')], + ['json', jsonDefaultLiteralTagEntry()], ]); } diff --git a/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts b/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts index cd4b1c5bdcc8..b2f5d6c73c2b 100644 --- a/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts @@ -4,6 +4,8 @@ import { instantiateAuthoringTypeConstructor, validateAuthoringHelperArguments, } from '@internal/framework-components/authoring'; +import { jsonDefaultLiteralTagEntry } from '@internal/framework-components/codec'; +import { isDefaultLiteralTagLoweringEntry } from '@internal/framework-components/control'; import { describe, expect, it } from 'vitest'; import { createPostgresBuiltinCodecLookup } from '../src/core/codec-lookup'; import { @@ -406,15 +408,28 @@ describe('postgresNativeAuthoringTypes', () => { describe('createPostgresDefaultLiteralTagRegistry', () => { const tagRegistry = createPostgresDefaultLiteralTagRegistry(); + const loweringTag = (tag: string) => { + const entry = tagRegistry.get(tag); + if (entry === undefined || !isDefaultLiteralTagLoweringEntry(entry)) { + throw new Error(`the registry does not register "${tag}" as a lowering tag`); + } + return entry; + }; - it('registers sql and pg.sql, in that order', () => { - expect([...tagRegistry.keys()]).toEqual(['sql', 'pg.sql']); + it('registers sql, pg.sql and json, in that order', () => { + expect([...tagRegistry.keys()]).toEqual(['sql', 'pg.sql', 'json']); expect(tagRegistry.get('sql')?.usage).toBe('sql`...`'); expect(tagRegistry.get('pg.sql')?.usage).toBe('pg.sql`...`'); + expect(tagRegistry.get('json')?.usage).toBe('json`...`'); + }); + + it('registers json as a literal of type json, with no prefixed alias', () => { + expect(tagRegistry.get('json')).toEqual(jsonDefaultLiteralTagEntry()); + expect(tagRegistry.get('pg.json')).toBeUndefined(); }); it('lowers a body verbatim as a function default', () => { - const result = tagRegistry.get('pg.sql')!.lower({ + const result = loweringTag('pg.sql').lower({ literal: { tag: 'pg.sql', body: "'{}'::jsonb", span: stubSpan }, context: stubContext, }); @@ -428,14 +443,14 @@ describe('createPostgresDefaultLiteralTagRegistry', () => { const registries = postgresAdapterDescriptor.controlMutationDefaults; if (registries === undefined) throw new Error('the adapter descriptor declares mutation defaults'); - expect([...registries.defaultLiteralTagRegistry.keys()]).toEqual(['sql', 'pg.sql']); + expect([...registries.defaultLiteralTagRegistry.keys()]).toEqual(['sql', 'pg.sql', 'json']); }); it.each([ ['sql', 'now'], ['pg.sql', 'autoincrement'], ])('refuses %s`%s()`, which is a Prisma default function', (tag, name) => { - const result = tagRegistry.get(tag)!.lower({ + const result = loweringTag(tag).lower({ literal: { tag, body: `${name}()`, span: stubSpan }, context: stubContext, }); @@ -449,7 +464,7 @@ describe('createPostgresDefaultLiteralTagRegistry', () => { }); it('lowers sql`gen_random_uuid()` verbatim', () => { - const result = tagRegistry.get('sql')!.lower({ + const result = loweringTag('sql').lower({ literal: { tag: 'sql', body: 'gen_random_uuid()', span: stubSpan }, context: stubContext, }); @@ -463,7 +478,7 @@ describe('createPostgresDefaultLiteralTagRegistry', () => { }); it("accepts sql`now() + interval '1 day'`", () => { - const result = tagRegistry.get('sql')!.lower({ + const result = loweringTag('sql').lower({ literal: { tag: 'sql', body: "now() + interval '1 day'", span: stubSpan }, context: stubContext, }); diff --git a/packages/3-targets/6-adapters/postgres/test/ddl-add-column-lowering.test.ts b/packages/3-targets/6-adapters/postgres/test/ddl-add-column-lowering.test.ts index 26fdbd79229c..a6f923ed15fa 100644 --- a/packages/3-targets/6-adapters/postgres/test/ddl-add-column-lowering.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/ddl-add-column-lowering.test.ts @@ -11,6 +11,7 @@ */ import { col, fn, lit } from '@internal/sql-relational-core/contract-free'; +import type { AnyPostgresCodecDescriptor } from '@internal/target-postgres/codec-descriptor'; import { addColumnAction, alterTable } from '@internal/target-postgres/contract-free'; import { PostgresAlterTable } from '@internal/target-postgres/ddl'; import { describe, expect, it } from 'vitest'; @@ -139,6 +140,70 @@ describe('PostgresAlterTable ADD COLUMN lowering', () => { ); }); + describe('a parameterized column whose codec answers for its params', () => { + // A codec whose wire form depends on the length its column declares, as `pg/vector@1` does. + const descriptor = { + codecId: 'test/vector@1', + traits: ['equality'], + targetTypes: ['vector'], + isParameterized: true, + paramsSchema: { + '~standard': { version: 1, vendor: 'test', validate: (value: unknown) => ({ value }) }, + }, + factory: (params: { readonly length: number }) => () => ({ + id: 'test/vector@1', + encode: async (value: readonly number[]) => `[${value.join(',')}]`, + decode: async (wire: unknown) => wire, + encodeJson: (value: unknown) => value, + decodeJson: (json: unknown) => { + if (!Array.isArray(json) || json.length !== params.length) { + throw new Error(`length mismatch: expected ${params.length}, got ${String(json)}`); + } + return [...json]; + }, + }), + } as unknown as AnyPostgresCodecDescriptor; + + const builtin = createPostgresBuiltinCodecLookup(); + const withVector = new PostgresControlAdapter({ + ...builtin, + // The representative instance carries no params, as the control stack's does. + get: (id: string) => + id === 'test/vector@1' ? descriptor.factory({})({ name: id }) : builtin.get(id), + descriptorFor: (id: string): AnyPostgresCodecDescriptor | undefined => + id === 'test/vector@1' + ? descriptor + : (builtin.descriptorFor(id) as AnyPostgresCodecDescriptor | undefined), + }); + + const vectorColumn = (length: number) => + alterTable({ + schema: 's', + table: 't', + actions: [ + addColumnAction( + col('embedding', 'vector', { + default: lit([0.5, 0.25, 0.125]), + codecRef: { codecId: 'test/vector@1', typeParams: { length } }, + }), + ), + ], + }); + + it("renders the default through a codec built with the column's typeParams", async () => { + const lowered = await withVector.lowerToExecuteRequest(vectorColumn(3)); + expect(lowered.sql).toBe( + `ALTER TABLE "s"."t" ADD COLUMN "embedding" vector DEFAULT '[0.5,0.25,0.125]'::vector`, + ); + }); + + it('refuses a default whose length is not the length the column declares', async () => { + await expect(withVector.lowerToExecuteRequest(vectorColumn(2))).rejects.toThrow( + 'length mismatch: expected 2', + ); + }); + }); + it('params array is always empty for DDL', async () => { const ast = alterTable({ schema: 's', diff --git a/packages/3-targets/6-adapters/postgres/test/printed-type-codecs.test.ts b/packages/3-targets/6-adapters/postgres/test/printed-type-codecs.test.ts new file mode 100644 index 000000000000..b241a3731f8d --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/printed-type-codecs.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { CODEC_ID_BY_PRINTED_TYPE } from '../../../3-targets/postgres/src/core/psl-infer/infer-default-codec'; +import { + postgresNativeAuthoringTypes, + postgresScalarAuthoringTypes, +} from '../src/core/control-mutation-defaults'; + +/** + * `contract infer` writes a default in the form the codec `contract emit` binds to the printed type + * name reads back. The printer restates that binding for the type names it prints, because the + * authoring namespaces that own it sit above the target package; this fails if the two disagree. + */ +const emitCodecIdByTypeName: ReadonlyMap = new Map( + [ + ...Object.entries(postgresScalarAuthoringTypes), + ...Object.entries(postgresNativeAuthoringTypes), + ].map(([typeName, typeConstructor]) => [typeName, typeConstructor.output.codecId]), +); + +describe('the codec bound to each printed PSL type name', () => { + it('has a binding to compare against', () => { + expect(emitCodecIdByTypeName.size).toBeGreaterThan(0); + expect(CODEC_ID_BY_PRINTED_TYPE.size).toBeGreaterThan(0); + }); + + it('agrees with the type constructor contract emit resolves', () => { + expect( + [...CODEC_ID_BY_PRINTED_TYPE].map(([typeName, codecId]) => ({ typeName, codecId })), + ).toEqual( + [...CODEC_ID_BY_PRINTED_TYPE.keys()].map((typeName) => ({ + typeName, + codecId: emitCodecIdByTypeName.get(typeName), + })), + ); + }); +}); diff --git a/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts b/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts index dff3f0136187..7d097ce4a523 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts @@ -4,6 +4,7 @@ import { timestampNowControlDescriptor, } from '@internal/family-sql/control'; import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import { jsonDefaultLiteralTagEntry } from '@internal/framework-components/codec'; import type { ControlDefaultLiteralTagEntry, ControlMutationDefaultEntry, @@ -272,9 +273,10 @@ export function createSqliteDefaultLiteralTagRegistry(): ReadonlyMap< string, ControlDefaultLiteralTagEntry > { - return new Map([ + return new Map([ ['sql', sqlDefaultLiteralTagEntry('sql`...`')], ['sqlite.sql', sqlDefaultLiteralTagEntry('sqlite.sql`...`')], + ['json', jsonDefaultLiteralTagEntry()], ]); } diff --git a/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts b/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts index 32928891c7f4..55596b13ea70 100644 --- a/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts @@ -1,4 +1,6 @@ import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import { jsonDefaultLiteralTagEntry } from '@internal/framework-components/codec'; +import { isDefaultLiteralTagLoweringEntry } from '@internal/framework-components/control'; import { describe, expect, it } from 'vitest'; import { createSqliteBuiltinCodecLookup } from '../src/core/codec-lookup'; import { @@ -82,14 +84,27 @@ describe('createSqliteDefaultFunctionRegistry — dbgenerated canonicalization', describe('createSqliteDefaultLiteralTagRegistry', () => { const tagRegistry = createSqliteDefaultLiteralTagRegistry(); + const loweringTag = (tag: string) => { + const entry = tagRegistry.get(tag); + if (entry === undefined || !isDefaultLiteralTagLoweringEntry(entry)) { + throw new Error(`the registry does not register "${tag}" as a lowering tag`); + } + return entry; + }; - it('registers sql and sqlite.sql, in that order', () => { - expect([...tagRegistry.keys()]).toEqual(['sql', 'sqlite.sql']); + it('registers sql, sqlite.sql and json, in that order', () => { + expect([...tagRegistry.keys()]).toEqual(['sql', 'sqlite.sql', 'json']); expect(tagRegistry.get('sqlite.sql')?.usage).toBe('sqlite.sql`...`'); + expect(tagRegistry.get('json')?.usage).toBe('json`...`'); + }); + + it('registers json as a literal of type json, with no prefixed alias', () => { + expect(tagRegistry.get('json')).toEqual(jsonDefaultLiteralTagEntry()); + expect(tagRegistry.get('sqlite.json')).toBeUndefined(); }); it('lowers sql`CURRENT_TIMESTAMP` verbatim, with no rewrite to now()', () => { - const result = tagRegistry.get('sql')!.lower({ + const result = loweringTag('sql').lower({ literal: { tag: 'sql', body: 'CURRENT_TIMESTAMP', span: stubSpan }, context: stubContext, }); @@ -106,14 +121,14 @@ describe('createSqliteDefaultLiteralTagRegistry', () => { const registries = sqliteAdapterDescriptor.controlMutationDefaults; if (registries === undefined) throw new Error('the adapter descriptor declares mutation defaults'); - expect([...registries.defaultLiteralTagRegistry.keys()]).toEqual(['sql', 'sqlite.sql']); + expect([...registries.defaultLiteralTagRegistry.keys()]).toEqual(['sql', 'sqlite.sql', 'json']); }); it.each([ ['sql', 'now'], ['sqlite.sql', 'autoincrement'], ])('refuses %s`%s()`, which is a Prisma default function', (tag, name) => { - const result = tagRegistry.get(tag)!.lower({ + const result = loweringTag(tag).lower({ literal: { tag, body: `${name}()`, span: stubSpan }, context: stubContext, }); @@ -127,7 +142,7 @@ describe('createSqliteDefaultLiteralTagRegistry', () => { }); it("accepts sql`now() + interval '1 day'`", () => { - const result = tagRegistry.get('sql')!.lower({ + const result = loweringTag('sql').lower({ literal: { tag: 'sql', body: "now() + interval '1 day'", span: stubSpan }, context: stubContext, }); diff --git a/projects/remove-dbgenerated/plan.md b/projects/remove-dbgenerated/plan.md index 0e387ac14a7d..17cf4efe36e6 100644 --- a/projects/remove-dbgenerated/plan.md +++ b/projects/remove-dbgenerated/plan.md @@ -7,7 +7,7 @@ Spec: [`spec.md`](spec.md). Deferred items: [`deferred.md`](deferred.md). **Line | Slice | Folder | Branch | Depends on | Owner | Linear | |---|---|---|---|---|---| | A — The `sql` tagged literal for raw SQL defaults | [`slices/a-sql-default-literal/`](slices/a-sql-default-literal/spec.md) | `remove-dbgenerated-sql-literal` | nothing | agent 1 | TBD | -| B — Codec-owned PSL literals (ADR 184, PSL half) | [`slices/b-codec-psl-literals/`](slices/b-codec-psl-literals/spec.md) | `remove-dbgenerated-codec-psl-literals` | nothing | agent 2 | TBD | +| B — Literal types that codecs are compatible with (ADR 184, PSL half) | [`slices/b-codec-psl-literals/`](slices/b-codec-psl-literals/spec.md) | `remove-dbgenerated-codec-psl-literals` | A merged | agent 2 | TBD | | C — Delete `dbgenerated`, regenerate Supabase, upgrade instruction | [`slices/c-remove-dbgenerated/`](slices/c-remove-dbgenerated/spec.md) | `remove-dbgenerated-delete` | A and B merged | agent 1 | TBD | Each slice is one PR against `main`. Slice plans (dispatch decomposition) are written at build time at `slices//plan.md` by the slice's implementer following the slice spec; the specs are complete enough that the plan is a sequencing document, not a design document. @@ -15,10 +15,11 @@ Each slice is one PR against `main`. Slice plans (dispatch decomposition) are wr ## Sequencing ``` -main ──┬── A (parallel) ──┐ - └── B (parallel) ──┴── C +main ── A ── B ── C ``` +- *Amended 2026-09-17.* B now builds on slice A's tag registry and tagged-literal node, so B follows A. B's first PR implemented the withdrawn `encodePsl`/`decodePsl` design and is reworked on top of A after A merges. + - A and B start together from the same `main` commit (`f3574a34a7` or later). - A and B share one file: `packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts`, function `scalarDefaultArms`. A appends a `taggedLiteral(...)` arm after the function arms. B replaces `str(), numLiteral(), bool()` with `literal()`. Whichever merges second rebases and resolves that one function by hand; the result is `[literal(), ...funcArms, taggedLiteral(tags)]` for scalars and `[list(literal()), ...funcArms, taggedLiteral(tags)]` for lists. Both also touch `DefaultArgValue` in the same file, each adding its own union member. - A and B both add a file under `psl-parser/src/attribute-spec/combinators/` and a member to `ArgTypeKind` in `attribute-spec/types.ts`; distinct names, so the rebase is mechanical. @@ -29,7 +30,7 @@ main ──┬── A (parallel) ──┐ - Agent 2 (slice B) owns the `Codec` interface change and every codec class. Agent 1 (slice A) does not add or change codec members. - Agent 1 (slice A) owns the tokenizer, parser node, tag registry, TypeScript `sql` tag and helpers, `gen_random_uuid()`, and the SQLite verify-side resolver. Agent 2 does not touch those. -- Interfaces slice C relies on, which A and B must ship exactly as specified: `ControlMutationDefaults.defaultLiteralTagRegistry` (A5), `TaggedLiteralValue` (A4), `Codec.encodePsl` / `Codec.decodePsl` and `PslLiteral` (B1, B2), `mapDefault(columnDefault, { codec })` and `formatPslLiteral` (B6). A change to any of these names or shapes is reported to the orchestrator before it lands. +- Interfaces slice C relies on, which A and B must ship exactly as specified: `ControlMutationDefaults.defaultLiteralTagRegistry` (A5), `TaggedLiteralValue` (A4), the codec descriptor's compatible literal types declaration and the tag registry's mapping from tag to literal type (slice B spec, rewrite pending), `mapDefault(columnDefault, { codec })` (B6). A change to any of these names or shapes is reported to the orchestrator before it lands. - Both agents record any question the spec does not answer in their PR body under "Spec gaps" and stop on the halt conditions their spec lists. They do not choose an alternative. ## Validation gates per slice diff --git a/projects/remove-dbgenerated/slices/b-codec-psl-literals/brief.md b/projects/remove-dbgenerated/slices/b-codec-psl-literals/brief.md new file mode 100644 index 000000000000..4d34011257c6 --- /dev/null +++ b/projects/remove-dbgenerated/slices/b-codec-psl-literals/brief.md @@ -0,0 +1,233 @@ +# Implementation brief — literal types for column defaults + +You are implementing ADR 254. This brief is self-contained: it assumes you have not seen the discussion that produced the design, and it tells you everything you need to decide nothing for yourself. Where something is not settled, it says so and tells you to stop and ask rather than choose. + +## 1. One decision is blocked and must be discussed before you write code for it + +**A plain number scalar in a schema does not name a literal type of its own.** Under ADR 254, `42` written on an `Int` column is an `int` literal, on a `BigInt` column a `bigint` literal, and on a `Decimal` column a `decimal` literal. Which one it is depends on what the column's codec declares. That consequence is deliberate but unsettled, and Will has asked to discuss it before it is built. + +Three answers are open, and ADR 254 records all three: accept it as written; give each numeric literal type its own tag so a written literal always names its own type; or return to a single `number` literal type whose conversion each codec owns. + +**What this means for you:** + +- Raise the question with Will before you implement anything in section 6.4 or the numeric part of section 6.3. Ask directly, state the three options, and wait for an answer. Do not pick one, and do not proceed on an assumption. +- Everything else in this brief is settled and you can build it while you wait, in the order section 7 gives. +- Once Will answers, write the answer into the slice specification (section 5) before you write the code that depends on it. + +## 2. What this repository is, in the terms this brief uses + +Prisma 8 is a data layer whose single source of truth is a **contract**: a JSON file, `contract.json`, that describes every table, column, index and constraint of a database. Nothing generates runtime code from it; tools read it. + +Four ideas matter here. + +**Authoring surfaces.** A contract is written in one of several ways. The main one is **PSL**, the Prisma Schema Language, the `.prisma` file people write. A second is the TypeScript contract builder. A third is the schema file of an earlier Prisma version, which Prisma 8 reads directly. Every one of these is called a **contract source**, and each turns its own syntax into the same contract. + +**Codecs.** A **codec** owns one database type. It converts between the JavaScript value an application uses, the wire form the database driver exchanges, and a JSON form used inside `contract.json`. Its four methods are `encode`, `decode`, `encodeJson` and `decodeJson`. Every codec has a **codec id** such as `pg/int4@1`. A **codec descriptor** holds the codec's static metadata, keyed by codec id: `traits`, `targetTypes`, a params schema, and the factory that makes codec instances. The descriptor is where this work adds a declaration. + +**Column defaults.** A column's default in the contract is one of two shapes. `{ kind: 'literal', value }` holds a value, stored in that column's codec JSON form. `{ kind: 'function', expression }` holds SQL text the database evaluates. Nothing in this work changes those two shapes. + +**Tagged literals.** PSL can write text that Prisma does not parse, as a **tag** followed by a string: `` sql`now()` ``. The tag says which pack owns the text. This already exists and is described in ADR 129. + +## 3. What you are building, in one paragraph + +Every literal column default gains a **literal type**: `string`, `boolean`, `int`, `float`, `bigint`, `decimal`, or `json`. Each literal type is defined once in the framework and produces exactly the value shape that the codecs naming it already accept in `decodeJson`. Each codec descriptor names the literal types its columns are compatible with, as a list of names carrying no functions. A contract source turns its own syntax into a literal of a type, the interpreter checks the type against the column's codec and reports a precise error when they do not match, and the literal type produces the value that goes through `decodeJson` into the contract. Printing a schema runs the same path backwards. No codec gains a method, and no per-type code remains in the interpreter or the printer. + +## 4. Read these first + +Everything listed is committed. Fetch before you start. + +**On `main`:** + +- `docs/architecture docs/adrs/ADR 129 - Template-Tagged Literals for Extensions.md`. The tagged literal syntax, the canonical body, and how tags are registered. +- `docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md`. Why codecs own the JSON form of values. Its PSL half is what ADR 254 replaces. +- `docs/architecture docs/adrs/ADR 252 - An earlier Prisma version's schema is a contract source.md`. The second text contract source you must keep working. +- `docs/reference/codec-authoring-guide.md`. How a codec and its descriptor are written. +- `projects/remove-dbgenerated/spec.md` and `projects/remove-dbgenerated/plan.md`. The project this slice belongs to. Its purpose is removing `@default(dbgenerated("..."))`, a construct that put raw SQL into a default as an unnamed string. Slice A replaced it with the `sql` tagged literal and is merged. Your slice is B. Slice C deletes `dbgenerated` and is not yours. +- `CLAUDE.md` at the repository root, and the rules it points at under `.agents/rules/`. + +**On branch `remove-dbgenerated-adr-253`, which is pull request 30334 and may have merged into `main` by the time you read this. Check `main` first; if the file is not there, fetch the branch:** + +- `docs/architecture docs/adrs/ADR 254 - Literal types for column defaults.md`. **The design you are implementing. It is authoritative. Where this brief and ADR 254 disagree, ADR 254 wins, and you tell Will about the disagreement.** If review changed the ADR, follow the changed ADR. +- The same branch amends `projects/remove-dbgenerated/spec.md` decisions D9 and D10 to match ADR 254. + +**On branch `remove-dbgenerated-codec-psl-literals`, whose pull request 30324 is closed.** This branch holds an earlier, withdrawn attempt at the same slice, built against a design where codecs gained `encodePsl` and `decodePsl` methods. That design is dead. Five pieces of it are worth reusing and are listed in section 8. Do not merge or rebase this branch; take the pieces by hand. + +## 5. Your first deliverable: rewrite the slice specification + +`projects/remove-dbgenerated/slices/b-codec-psl-literals/spec.md` currently describes the withdrawn design and carries a banner saying so. Rewrite it to describe the work in this brief, in the same shape as the sibling file `projects/remove-dbgenerated/slices/a-sql-default-literal/spec.md`: outcome, design sections, the tests that must exist, definition of done, halt conditions. Keep it a slice-level document. Do not restate ADR 254's rationale; point at it. + +Commit the rewritten spec before you write implementation code, so the two are reviewable apart. + +## 6. The design, in full + +### 6.1 Literal types live in the framework + +Add literal types to `packages/1-framework/1-core/framework-components`, alongside the codec surface in `src/shared/`, exported through `src/exports/codec.ts`. There are seven, and no others in this slice. + +| Literal type | The value it produces | Reading rules | Writing rules | +|---|---|---|---| +| `string` | The text | Escapes are already resolved by the source | Print as the source's string syntax | +| `boolean` | `true` or `false` | | | +| `int` | A JSON number, whole | Refuse text that is not a whole number, and refuse `NaN` and the infinities | Print the digits | +| `float` | A JSON number, or the text `NaN`, `Infinity`, or `-Infinity` | Accept a number, or one of those three words | Print a finite number plainly with no exponent; print the three special values as a quoted string | +| `bigint` | The digits as text | Refuse text that is not a whole number | Print the digits | +| `decimal` | Decimal text | Keep trailing zeros. Remove leading zeros and the sign of zero, so `007` reads as `7`, `-0` as `0`, and `-007.50` as `-7.50`. Accept `NaN`, `Infinity` and `-Infinity` | Print a finite decimal plainly with no exponent; print the three special values as a quoted string | +| `json` | A JSON value | Parse the body as JSON once | Print `JSON.stringify` of the value | + +The canonicalisation rules for `decimal` already exist on `main` in `packages/2-sql/2-authoring/contract-psl/src/number-literal-default.ts`, in `canonicalDecimalText`. Move that logic into the `decimal` literal type and delete that file, its export from `packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts`, and its test. + +A literal type must never convert a number through a JavaScript number except where the table says it produces a JSON number. `bigint` and `decimal` carry digits as text end to end. + +### 6.2 Codec descriptors name their literal types + +Add an optional member to `CodecDescriptor` in `packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts`, and to `CodecDescriptorImpl`, naming the literal types that codec's columns are compatible with. It carries names only and no functions. A codec that names none accepts no literal defaults; its columns can still take a `sql` default. + +The complete inventory follows. Every codec id in the repository appears exactly once. Implement it exactly. + +**Postgres target, `packages/3-targets/3-targets/postgres/src/core/`:** + +| Codec ids | Literal type | +|---|---| +| `pg/text@1`, `pg/char@1`, `pg/varchar@1`, `pg/uuid@1`, `pg/inet@1`, `pg/bit@1`, `pg/varbit@1`, `pg/timetz@1`, `pg/interval@1`, `pg/bytea@1`, `pg/enum@1`, `pg/date-string@1`, `pg/time-string@1`, `pg/timestamp-string@1`, `pg/timestamptz-string@1`, `pg/date-temporal@1`, `pg/time-temporal@1`, `pg/timestamp-temporal@1`, `pg/timestamptz-temporal@1`, `pg/timestamptz-date@1` | `string` | +| `pg/int4@1`, `pg/int2@1`, `pg/int8number@1`, `pg/int@1` | `int` | +| `pg/float4@1`, `pg/float8@1`, `pg/float@1` | `float` | +| `pg/int8@1`, `pg/unboundedint@1` | `bigint` | +| `pg/numeric@1` | `decimal` | +| `pg/json@1`, `pg/jsonb@1` | `json` | +| `pg/bool@1` | `boolean` | +| `pg/text-array@1` | none. It is internal to list handling and no column authored in a schema uses it | + +`pg/enum@1` names `string` because its JSON form is the member's storage string. This does not change how enum defaults are written; see section 6.6. + +**SQLite target, `packages/3-targets/3-targets/sqlite/src/core/codecs.ts`:** `sqlite/text@1`, `sqlite/blob@1` and `sqlite/datetime@1` name `string`. `sqlite/integer@1` and `sqlite/bigintnumber@1` name `int`. `sqlite/real@1` names `float`. `sqlite/bigint@1` names `bigint`. `sqlite/json@1` names `json`. + +**SQL base codecs, `packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts`:** `sql/text@1`, `sql/char@1` and `sql/varchar@1` name `string`. `sql/int@1` names `int`. `sql/float@1` names `float`. + +**Extensions:** `pg/vector@1` and `arktype/json@1` name `json`. `pg/geometry@1` names `string`, because its JSON form is hexadecimal text. + +**Mongo:** `mongo/string@1`, `mongo/bool@1`, `mongo/date@1`, `mongo/double@1`, `mongo/int32@1`, `mongo/vector@1`, `mongo/array@1` and `mongo/document@1` name nothing and do not change. No Mongo contract source reads a default from text. + +`sqlite/real@1` and `sql/float@1` refuse `NaN` and the infinities inside `decodeJson` already. Leave that. The `float` literal type carries those values and the codec rejects them, which produces the right error on those targets. + +### 6.3 The PSL interpreter reads literals through literal types + +Files: `packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts`, `psl-column-resolution.ts`. + +The `@default(...)` argument today has separate arms for a string, a number, a boolean, a list, a function call, an enum member, and, since slice A, a tagged literal. Replace the string, number and boolean arms with one arm that yields the written scalar and its syntax kind, keep the list arm wrapping it, and leave the function-call, enum-member and tagged-literal arms alone. + +Resolution order for a default that is a literal: + +1. A tagged literal takes the literal type its tag writes, from the tag registry. The `sql` tag is not a literal type and keeps slice A's behaviour of lowering to a raw SQL default. +2. A plain scalar takes its literal type from the column's codec declaration. **A string scalar takes `string` and a boolean scalar takes `boolean`, both unambiguous. A number scalar is the blocked decision in section 1: do not implement it until Will has answered.** +3. If the literal's type is not one the column's codec names, report `PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE` at the literal, with a message naming the codec and the types it accepts, for example `Field "Account.meta": pg/int4@1 is not compatible with a json literal; it accepts int literals`. +4. Ask the literal type to read the written text. Text it refuses is `PSL_INVALID_DEFAULT_LITERAL` at the literal, with the reason. A `json` body that is not valid JSON is `PSL_INVALID_JSON_LITERAL`. +5. Pass the value to the column codec's `decodeJson`. A thrown error becomes `PSL_INVALID_DEFAULT_LITERAL` at the literal, carrying the codec's message. +6. Store the default as it is stored today. + +A missing codec in the lookup is an internal error, not a diagnostic: the lookup that resolved the column must carry its codec. + +### 6.4 The `json` tag + +Each SQL target registers a `json` tag in the registry slice A added, `ControlMutationDefaults.defaultLiteralTagRegistry`. Register it with no prefixed alias: `json` only, on Postgres and on SQLite. The registry entry says which literal type the tag writes. A tag no pack registers keeps slice A's diagnostic, `PSL_UNKNOWN_DEFAULT_LITERAL_TAG`. + +### 6.5 The earlier Prisma version's schema reader + +Files under `packages/2-sql/2-authoring/contract-prisma7/src/`, principally `defaults.ts` and `target-binding.ts`, plus `packages/3-targets/3-targets/postgres/src/core/prisma7-binding.ts`. + +That reader has its own per-type handling for defaults, including a rule that `Int` and `BigInt` defaults must be whole numbers, and a path that parses quoted JSON text. Replace both with the same route as section 6.3: map the written syntax to a literal of a type, check it against the codec's declaration, read it with the literal type, pass it to `decodeJson`. Its diagnostics keep their existing code, `PSL.PRISMA7_UNKNOWN_DEFAULT`, with the reason from the literal type or the codec. + +Two behaviours of that reader stay exactly as they are: + +- A `Bytes` or `DateTime` default is carried as a raw SQL expression rather than a value, because verification cannot yet compare those as typed values. This is decision D11 in the project spec. +- Its diagnostic for a JSON default of `null`, `PSL.PRISMA7_JSON_NULL_DEFAULT_UNSUPPORTED`, stays and keeps firing. + +That language writes a JSON default as a quoted string, such as `@default("{\"a\":1}")`. The reader turns that quoted string into a `json` literal itself. Prisma 8's own PSL does not accept that form; see section 6.7. + +### 6.6 The printer + +Files: `packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts` and the Postgres printer under `packages/3-targets/3-targets/postgres/src/core/psl-infer/`. + +`contract infer` reads a live database and prints a schema. For a literal default it must now take the literal type the column's codec names, ask that type to write the stored value, and print the result: as a plain scalar where the literal type has one, otherwise as a tagged literal with the tag that writes it. When the codec names no literal type, or the literal type cannot write the value, keep whatever the printer does on `main` for a default it cannot express. Slice C changes that fallback; you do not. + +Delete the per-type formatter table in the Postgres printer, `PslDefaultValueFormat`, `pslDefaultValueFormat`, `formatPslValue`, `formatPslListLiteralValue` and the helpers they use, and the equivalent literal formatting in the family's `default-mapping.ts`. + +### 6.7 Behaviour that changes for people who already wrote schemas + +Three forms that work today become errors. Each needs an entry in the app-author upgrade instructions; section 9 says where. + +- A JSON column with a quoted JSON string, `Jsonb @default("{}")`. It becomes `` Jsonb @default(json`{}`) ``. +- A decimal column with a quoted decimal, `Decimal @default("1.50")`. It becomes `Decimal @default(1.50)`. +- Any other quoted value on a column whose codec names a non-`string` literal type. + +Two forms are unchanged and must stay working: an enum column's default is a bare member name, such as `@default(ACTIVE)`; and a list column keeps PSL's list syntax, `Int[] @default([1, 2])`, where each element is checked against the element codec's declaration, so `` Jsonb[] @default([json`{}`, json`[]`]) `` is valid and `Int[] @default([1, "x"])` is refused at its second element. + +`` Json @default(json`null`) `` is allowed and stores JSON null. + +### 6.8 What you must not change + +- The contract format. Every `contract.json` already in the repository must come out byte-identical. `pnpm fixtures:check` is the proof. +- The `Codec` interface. No codec gains a method in this slice. +- The TypeScript contract builder's `.default(value)`, which passes a value of the codec's own type and is checked by TypeScript. +- Anything about `dbgenerated`, which slice C removes. +- The DDL rendering half of ADR 184, which stays future work. + +## 7. Order of work + +1. Rewrite the slice specification (section 5). +2. Literal types in the framework, with their own tests (section 6.1). +3. The descriptor member and the full inventory of declarations, with a test per pack asserting every registered codec's declaration (section 6.2). +4. The `json` tag on both targets (section 6.4). +5. The interpreter, for the `string`, `boolean` and `json` literal types only (section 6.3, steps 1 and 3 to 6). +6. The printer for the same types (section 6.6). +7. The earlier Prisma version's reader (section 6.5). +8. **Stop. The numeric literal types need the blocked decision from section 1.** If it is answered by now, implement them across the interpreter, the printer and the reader, and update the slice specification first. +9. Documentation and upgrade instructions (section 9). +10. Full gates and the pull request (sections 10 and 11). + +Steps 5 to 7 leave the repository in a state where numeric defaults do not yet work, so the branch is not ready to merge until step 8 lands. Do not open the pull request before then. + +## 8. What to reuse from the closed branch + +Branch `remove-dbgenerated-codec-psl-literals`. Take these by hand; ignore everything else on it, especially anything adding `encodePsl`, `decodePsl`, a `PslLiteral` type, or a `literal()` parser combinator. + +- **The end-to-end test** at `test/integration/test/cli-journeys/codec-psl-literal-defaults.e2e.test.ts`, which emits a schema using every literal kind, runs `db init`, verifies the database, and reads a row back through the client. Adapt its schema to the new syntax, in particular the JSON column. +- **The JSON round-trip case** in `test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts`, which asserts that a `jsonb` default survives infer, emit and verify without a workaround. +- **The float fix** in `packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts`: `pg/float4@1` and `pg/float8@1` carry `NaN` and the infinities as text in their JSON form and on the wire, and read them back as numbers. The `float` literal type depends on this. +- **The decimal canonicalisation behaviour** for `pg/numeric@1`, as test cases. The logic itself comes from `main` as section 6.1 says. +- **The Postgres printer's codec lookup**, `packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-default-codec.ts`, which finds the codec that `contract emit` binds to a printed PSL type name. The printer needs a codec and this is how it gets one. Note the reason recorded there: several codecs share one database type, so the printed type name, not the database type, picks the codec. + +One trap from that branch: when the `@default` argument arms change, the language server's completion for `@default(` loses its `true` and `false` suggestions unless the new arm offers them. There is a test for it in `packages/1-framework/3-tooling/language-server`. + +## 9. Documentation to update in the same pull request + +- `docs/reference/error-reference.md`: add `PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE`, `PSL_INVALID_DEFAULT_LITERAL` and `PSL_INVALID_JSON_LITERAL`, in the form its neighbours use, and update the earlier-version reader's message wording where it changed. +- `docs/reference/codec-authoring-guide.md`: a codec descriptor names its literal types; show one example. +- `packages/2-sql/2-authoring/contract-psl/README.md`: one paragraph on how a literal default is written and what the column's codec accepts. +- Upgrade instructions for the three broken forms in section 6.7, following the `record-upgrade-instructions` skill in `skills-contrib/record-upgrade-instructions/`. Both audiences need one: app authors, whose schemas change; and extension authors, whose codec descriptors should name literal types. The repository check for this is `pnpm check:upgrade-coverage --mode pr`. +- If ADR 254 has merged and your implementation diverges from it in any way, update the ADR in this pull request and say so in the description. + +## 10. How to work + +- Follow the Drive process: invoke the `drive-process` skill and run the slice with one implementer and one reviewer, resumed across dispatches. +- Tests before implementation, as `CLAUDE.md` requires. Every named test must fail before the change that makes it pass. +- Use `pnpm`, never `npm` or `npx`. Use the shell's Node; do not switch versions. +- Run slow commands through their `:agent` variants and read the log file each prints, per `.agents/rules/running-tests.mdc`. +- No `any`, no bare `as` casts in production code, no comments that restate the code, and test names that omit "should". The rules under `.agents/rules/` are not optional. +- Stage files explicitly and sign off every commit, per `.agents/rules/git-staging.mdc`. Do not push until the work is ready for review. + +## 11. Definition of done + +- `pnpm typecheck`, `pnpm test:packages`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm lint`, `pnpm lint:deps`, `pnpm lint:docs` and `pnpm fixtures:check` all green, the last with no contract file changed. +- `pnpm lint:throws` and `pnpm check:upgrade-coverage --mode pr` green. Neither runs under `pnpm lint`, and continuous integration fails without them. +- `git grep -n "numberLiteralDefault\|PslDefaultValueFormat\|formatPslValue\|formatPslListLiteralValue" -- packages` returns nothing. +- A test per pack asserts that every codec it registers declares the literal types this brief's inventory gives it, so a codec added later without a declaration fails. +- The end-to-end test from section 8 passes against a real database, covering every literal type. +- One pull request against `main`, with a description that follows the `create-pr` skill in `skills-contrib/create-pr/`. This project has no Linear ticket, so omit the ticket prefix from the title and say so in the checklist. + +## 12. Stop and ask Will if + +- The blocked decision in section 1 is still unanswered when you reach step 8. +- A codec's JSON form turns out not to match the literal type this brief assigns it, so the declaration would need a conversion function after all. That would reopen the ADR's central claim. +- Making a codec declaration work would require changing what a `contract.json` stores. +- A contract source other than PSL and the earlier version's reader turns out to read literal defaults. +- The rewritten slice specification would need a design decision this brief does not give you. 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..db01ffdc2859 --- /dev/null +++ b/projects/remove-dbgenerated/slices/b-codec-psl-literals/plan.md @@ -0,0 +1,19 @@ +# Slice B plan — dispatch sequence + +Spec: [`spec.md`](spec.md). One implementer and one reviewer, resumed across every dispatch. Review artifact: `wip/reviews/code-review.md` (gitignored). Every dispatch: tests first and red before the change; commits staged explicitly and signed off; `pnpm typecheck` at the root plus the package-scoped test commands named in the gate, run through their `:agent` variants where they exist and read from the log file. + +| # | Dispatch | Outcome (true when it lands) | Builds on | Hands to | Gate | +|---|---|---|---|---|---| +| 1 | Literal types in the framework | `literal-types.ts` exists with `readLiteral`, `isCompatible`, `describeDeclarations`, `writeLiteral`, `integerLiteralTypesUpTo`, `jsonDefaultLiteralTagEntry`; `CodecDescriptor.literalTypes` and the tag-entry union exist; nothing consumes them yet (spec B1, B3 types) | spec | 2, 3 | framework-components typecheck + tests | +| 2 | Inventory and coercion | every production codec declares `literalTypes` per spec B2; seven per-pack inventory tests; `decodeJson` coercion in the codecs B2 lists, with the float4/float8 non-finite fix | 1 | 3, 5 | typecheck; postgres, sqlite, relational-core, pgvector, postgis, arktype-json, mongo-adapter tests; `pnpm fixtures:check` | +| 3 | The interpreter and the `json` tag | both adapters and the fixture registry register `json`; PSL reads every Outcome form and reports every error form (spec B4); `number-literal-default.ts` deleted; language-server completion test unchanged and green | 1, 2 | 4, 5, 6 | typecheck; contract-psl, language-server, adapter tests; `pnpm fixtures:check`; `pnpm test:packages` | +| 4 | The Prisma 7 reader | spec B5; `contract-prisma7` tests green with the new messages | 3 | 6 | typecheck; contract-prisma7 tests | +| 5 | The printer | spec B6; the deleted formatter names are gone; postgres print tests green | 2, 3 | 6 | typecheck; 9-family and postgres psl-infer tests; grep in DoD | +| 6 | Journeys | e2e test, roundtrip-fidelity jsonb case, parity pair, integration number-defaults test (spec B9 and Tests § Journeys); `pnpm test:integration` and `pnpm test:e2e` green | 3, 4, 5 | 7 | `pnpm test:packages`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm fixtures:check` | +| 7 | Docs, upgrade instructions, ADR amendment | spec B8 and B10; every DoD command green | 6 | PR | every command in the spec's Definition of done | + +Open items: +- Element-level spans for list-literal diagnostics need the parser argument types to carry spans; handed to the editor-tooling brief (project D14), not this slice. +- The TypeScript contract builder's `.default()` cannot take a `bigint` or a non-finite number; PSL is the only surface for `BigInt` defaults beyond 2^53 and `Float @default(NaN)`. Follow-up outside this slice. +- `useDevDatabase` in `test/integration` passes its timeout as `beforeAll`'s third argument, which vitest ignores, so journeys flake at 5 s on a loaded machine. Follow-up outside this slice. +- The Prisma 7 fixture updater (`UPDATE_PRISMA7_FIXTURES=1`) writes golden JSON in a different format from the committed files. Follow-up outside this slice. diff --git a/projects/remove-dbgenerated/slices/b-codec-psl-literals/spec.md b/projects/remove-dbgenerated/slices/b-codec-psl-literals/spec.md index c7c9e03f2feb..4c2fed71f080 100644 --- a/projects/remove-dbgenerated/slices/b-codec-psl-literals/spec.md +++ b/projects/remove-dbgenerated/slices/b-codec-psl-literals/spec.md @@ -1,181 +1,289 @@ -# Slice B — Codec-owned PSL literals (the PSL half of ADR 184) +# Slice B — Literal types for column defaults -**Project:** [Remove `dbgenerated`](../../spec.md). **Linear:** not yet created. **Branch:** `remove-dbgenerated-codec-psl-literals` off `main`. **Shape:** one PR. **Runs in parallel with:** [slice A](../a-sql-default-literal/spec.md). **Touches nothing slice A touches** except the `@default` argument arms in `sql-attribute-specs.ts`, where each slice adds its own arm. +**Project:** [Remove `dbgenerated`](../../spec.md). **Design:** [ADR 254](../../../../docs/architecture%20docs/adrs/ADR%20254%20-%20Literal%20types%20for%20column%20defaults.md), amended by this slice as B10 records. **Linear:** not yet created. **Branch:** `worktree/literal-types-column-defaults-852235` off `main`. **Shape:** one PR. **Depends on:** slice A, merged. **Input:** [`brief.md`](brief.md), which is unvalidated design input from another agent; every claim in it was verified against the code before this spec was written, and the corrections are listed under "Corrections to the brief". ## Outcome -Every typed literal default is read from PSL and printed back to PSL by the column's codec. There is no type-specific code in the interpreter, the printer, or the Prisma 7 source for numbers, JSON, or anything else. Concretely, after this slice: +Every literal column default has a literal type. A codec descriptor names the literal types its columns accept, as names only. A contract source turns its syntax into a literal of a type, the interpreter checks that type against the column's codec by membership, the codec's `decodeJson` converts the literal's value, and the printer runs the same path backwards. No codec gains a method. No per-type code remains in the interpreter or the printer. + +After this slice, all of the following are true: ```prisma -model T { - 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]) +model Account { + id Int @id + name String @default("anonymous") + small SmallInt @default(100) + count Int @default(100000) + balance BigInt @default(100000000000000099) + price Decimal @default(1.50) + ratio Float @default(NaN) + active Boolean @default(true) + meta Jsonb @default(json`{ "plan": "free", "seats": 1 }`) + scores Int[] @default([1, 2]) + docs Jsonb[] @default([json`{}`, json`[]`]) + embed pgvector.Vector(3) @default([0.1, 0.2, 0.3]) + expires DateTime @default(sql`(now() + '3 days'::interval)`) } ``` -emits a contract in which `meta` holds the JSON object `{}`, `big` holds every digit, `price` holds `1.50` with its trailing zero, and `contract infer` prints each of them back in exactly that form. +Every one of those emits, migrates onto a dev database, verifies clean, reads back through the client with its decoded type, and `contract infer` prints them back in the same forms. These are errors, each pointing at the literal: + +```prisma +count Int @default(100000000000000099) // pg/int4@1 is not compatible with a bigint literal; it accepts i8, i16, i32 literals +count Int @default(1.5) // pg/int4@1 is not compatible with a decimal literal; ... +meta Jsonb @default("{}") // pg/jsonb@1 is not compatible with a string literal; it accepts json literals +price Decimal @default("1.50") // pg/numeric@1 is not compatible with a string literal; ... +meta Jsonb @default(json`{ plan }`) // PSL_INVALID_JSON_LITERAL +embed pgvector.Vector(3) @default([1, 2]) // PSL_INVALID_DEFAULT_LITERAL, with the vector codec's length message +scores Int[] @default([1, "x"]) // incompatible, reported at the second element +``` + +## Decisions from the shaping discussion (2026-09-18) + +These settle ADR 254's open question and are written into the ADR by B10. + +1. **A written number's literal type comes from its own size and precision, never from the column.** The literal types for numbers are `i8`, `i16`, `i32`, `i64`, `bigint`, `decimal` and `float`. A number gets the smallest type that holds it. `42` is `i8` on every column; `100000000000000099` is `i64`; `1.50` is `decimal`; `NaN` is `float`. Reason: one syntax then names one type, the compatibility check is a lookup with no trial decoding, and a size error is reported as an incompatibility before anything is decoded. +2. **A codec names every type it accepts, and coercion between those types' value shapes is the codec's job, inside its existing `decodeJson`.** `pg/int8@1` names `i8` to `i64`, so its `decodeJson` accepts a JSON number as well as the digit text it stores. No new codec method; the declaration stays a list of names. Reason: the value shape a literal type produces is fixed by the type, and the codecs that store a different shape are the ones that know how to convert it. +3. **A vector default is a list, not a JSON document.** The brief gave `pg/vector@1` the `json` type because `json` was the only type producing an array. That matches on storage shape, which is the mistake `Jsonb @default("{}")` makes. Instead a declaration may name a list of element types, `{ list: [...] }`, and a PSL list on a non-list column writes a list literal. Serhii asked for `@default([1, 2, 3])` on a vector column and this gives it. This shape was proposed in the discussion and not objected to; it is open to review in the PR. + +## Amendments made during the build + +These supersede the sections below where they differ. + +- **`writeLiteral` returns the complete literal source.** `text` is the whole written literal, including the tag and fence for `json` (`` json`{"a":1}` ``); `tag` is kept so a caller can tell a tagged literal apart. The printer prints `@default()`. (Dispatch 1.) +- **No quote-fence fallback when printing `json`.** A quote-fenced tagged literal resolves the full PSL string escapes, while a backtick fence resolves only `` \` `` and `\\`, so switching fences changes what a JSON body containing `\n` reads back as. The printer always uses the backtick fence and escapes backticks and backslashes. (Dispatch 1.) +- **A nested list is refused with reason `invalid-number`** and the message "A list literal cannot contain another list."; it maps to `PSL_INVALID_DEFAULT_LITERAL`. A list literal's `type.list` is the element types in first-seen order, deduplicated, so an empty list is compatible with every `{ list }` declaration. `describeDeclarations` joins scalar and list parts with " and ". `integerLiteralTypesUpTo('bigint')` is allowed. (Dispatch 1.) +- **`writeLiteral` writes a list literal itself** against a `{ list }` declaration (a scalar column such as `vector(3)`); the printer writes a list column's elements one by one against the element codec's scalar declarations. Two different paths. (Dispatch 1, for dispatch 5.) + +- **`readLiteral` refuses with `{ ok: false; reason; message; elementIndex }`**, where `elementIndex` is a required key typed `number | undefined` and names the failing element of a list literal so the interpreter can report at that element's span. The tag-entry union has a type predicate, `isDefaultLiteralTagLoweringEntry`, exported from `exports/control.ts`; `jsonDefaultLiteralTagEntry` and `LiteralTypeName` are exported from `exports/codec.ts` only. (Dispatch 1, round 2.) +- **`CodecDescriptorImpl.literalTypes` stays `readonly`**, typed `readonly LiteralTypeDeclaration[] | undefined` so the target adapters can forward a wrapped descriptor's declaration. (Dispatch 2 review.) + +- **`Jsonb @default([1, 2])` is an incompatibility, not a JSON array.** A PSL list reads as a list literal, and `pg/jsonb@1` names only `json`, so the membership rule refuses it; the JSON array is written `` json`[1, 2]` ``. The Tests section's "harmless consequence" line is withdrawn. (Dispatch 3.) +- **`100000000000000099` is an `i64` literal**, so the Outcome's first error reads `pg/int4@1 is not compatible with an i64 literal; it accepts i8, i16, i32 literals`. Messages choose the article ("an i64", "a string"). (Dispatch 3.) +- **Diagnostics inside a list are reported at the `@default(...)` attribute span** and name the failing element in the message (`Field "N.scores" at element 2: ...`), because the attribute-spec layer carries no span for string, number and boolean arguments. Reporting at the element's own span needs the parser's argument types to carry spans and is handed to the editor-tooling brief (project decision D14). (Dispatch 3.) +- **A column bound to a value set (`pg.enum(Ref)`) keeps the member-name path**: a string default on such a column is checked against the value set and never against `literalTypes`. (Dispatch 3.) +- **A lowering tag (`sql`) inside a list literal is `PSL_INVALID_DEFAULT_LITERAL`** with a message saying the tag produces a default of its own. (Dispatch 3.) +- **The Prisma 7 reader keeps a private copy of the old number-through-codec helper until dispatch 4 replaces it** with B5. (Dispatch 3.) + +- **The printer falls back when the codec would refuse what it wrote.** Before printing a literal, the Postgres printer passes the written value back through the column codec's `decodeJson`; a refusal (for example a temporal `infinity` sentinel, which `decodeTemporalText` rejects) takes the raw-default fallback as on `main`. Infer never prints a schema that emit cannot read. (Dispatch 5 review.) +- **The Postgres printer restates the type-name-to-codec binding** in `psl-infer/infer-default-codec.ts`, because the emit-side binding lives in the adapter, which depends on the target. Two tests keep it honest: one in the adapter asserts entry-by-entry agreement with the authoring type tables, one in the target asserts every printed type name is covered. (Dispatch 5.) +- **Temporal defaults print as string literals** (`Date @default("2024-01-01")`) rather than `dbgenerated`; verify compares them through `parseTemporal` on both sides and the planner renders them quoted, so the round trip holds. The upgrade instructions mention the changed infer output. (Dispatch 5.) + +- **A column's codec is materialised with the column's `typeParams` wherever a default passes through it**: the interpreter (B4), the contract builder's `encodeJson` re-encode, and the Postgres DDL renderer. The last two used the param-less representative and so could not encode or render a `vector(3)` default. (Dispatch 6.) +- **The e2e journey's raw SQL default is written in the form Postgres reports** (`(now() + '3 days'::interval)`), because strict verification compares raw expressions; the Outcome snippet is updated. Pre-existing raw-SQL behaviour, not a literal-type matter. (Dispatch 6.) +- **The TypeScript builder cannot express a `BigInt` default beyond 2^53 or a non-finite `Float` default**, so those two forms are covered by the e2e journey and not by the parity pair. Recorded as a follow-up in the plan's open items. (Dispatch 6.) + +- **A contract that stores digit text for a `sqlite/integer@1` default now renders `DEFAULT 0` rather than `DEFAULT '0'`**, because the SQLite DDL renderer decodes through the codec before rendering and the codec now reads digit text as a number. Authoring a string on an integer column was never legitimate; the upgrade instructions record the change. (Dispatch 6 review.) + +## Corrections to the brief + +Verified against the code on 2026-09-18. Where the brief and this spec differ, this spec wins. + +- **`NaN`, `Infinity` and `-Infinity` print unquoted.** The PSL tokenizer reads them as number tokens (`tokenizer.ts`, `KEYWORD_NUMBERS`). The brief said to print them as a quoted string, which would read back as a `string` literal and be refused by every float codec. +- **`pg/enum@1` names no literal type.** Enum defaults are bare member names and never reach the codec (`enumDefaultArms` in `sql-attribute-specs.ts`), so naming `string` would be inert and would contradict ADR 254. The brief said `string`. +- **Mongo has seven codecs, not nine.** `mongo/array@1` and `mongo/document@1` do not exist. The seven (`mongo/objectId@1`, `mongo/string@1`, `mongo/double@1`, `mongo/int32@1`, `mongo/bool@1`, `mongo/date@1`, `mongo/vector@1`) name nothing, as the brief said. +- **`pg/float4@1` and `pg/float8@1` do no validation in `decodeJson` on `main`** (a blind cast), and their `encodeJson` turns `NaN` into JSON `null`. The closed branch's float fix is needed and is B2's job. +- **`sqlite/real@1`, `sql/float@1` and `pg/float@1` refuse non-finite values in `decodeJson`.** Confirmed. They therefore do not name `float`, so `Real @default(NaN)` on SQLite is an incompatibility diagnostic rather than a decode failure. The brief had them name `float`. +- **No allowlist exists for contributed diagnostic codes.** `ContributedPslDiagnosticCode` is the open type `` `PSL_${string}` ``; the new codes are declared as constants in `contract-psl` and need no framework edit. +- **`number-literal-default.ts` has no test file of its own.** Its behaviour is covered by `contract-psl/test/interpreter.number-defaults.test.ts` and `test/integration/test/number-defaults/psl-number-defaults.integration.test.ts`, both of which change in this slice. +- **The `@default` argument arms do not need merging.** `str()`, `numLiteral()` and `bool()` already yield the written scalar with its syntax kind (`string`, `{ text }`, `boolean`). Keeping them leaves the language server's `true`/`false` completion untouched. The brief said to replace them with one arm. +- **`build-contract.ts` re-encodes every literal default through `encodeJson`** (`encodeViaCodec`), so the interpreter stores the decoded value and the contract receives the canonical JSON form. The brief's step 6 ("store the default as it is stored today") is right; this records why the contract stays canonical. +- **`pg/text-array@1` is contract-free only** (`contract-free/columns.ts`); no PSL type binds it. Confirmed. +- **`CodecLookup.get(codecId)` cannot give a length-aware vector codec**; `control-stack.ts` builds the representative with empty params. The interpreter materialises the column's codec from the descriptor and the column's `typeParams` (B4). +- **The closed branch's float fix lives in `codecs.ts` and `codec-helpers.ts`**, not `codec-helpers.ts` alone. ## Design -### B1. The `PslLiteral` type +### B1. Literal types in the framework -File: [`packages/1-framework/1-core/framework-components/src/shared/codec-types.ts`](../../../../packages/1-framework/1-core/framework-components/src/shared/codec-types.ts). +File: new `packages/1-framework/1-core/framework-components/src/shared/literal-types.ts`, exported through `src/exports/codec.ts`. ```ts -/** 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; -} -``` +export type LiteralTypeName = + | 'string' | 'boolean' | 'i8' | 'i16' | 'i32' | 'i64' | 'bigint' | 'decimal' | 'float' | 'json'; -Nothing converts `text` to a JavaScript number before a codec sees it. +export type LiteralTypeDeclaration = LiteralTypeName | { readonly list: readonly LiteralTypeName[] }; -### B2. The `Codec` interface +export type Literal = + | { readonly type: LiteralTypeName; readonly value: JsonValue } + | { readonly type: { readonly list: readonly LiteralTypeName[] }; readonly value: readonly JsonValue[] }; +``` -File: [`packages/1-framework/1-core/framework-components/src/shared/codec.ts`](../../../../packages/1-framework/1-core/framework-components/src/shared/codec.ts). +A written literal, independent of the source language: ```ts -export interface Codec<...> { - // existing: encode, decode, encodeJson, decodeJson - /** 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; -} - -export abstract class CodecImpl<...> { - abstract encodePsl(value: TInput): PslLiteral; - abstract decodePsl(literal: PslLiteral): TInput; -} +export type WrittenLiteral = + | { readonly kind: 'string'; readonly text: string } // escapes already resolved by the source + | { readonly kind: 'number'; readonly text: string } // digits exactly as written + | { readonly kind: 'boolean'; readonly value: boolean } + | { readonly kind: 'json'; readonly text: string } // the body of a json tag, or Prisma 7's quoted JSON + | { readonly kind: 'list'; readonly elements: readonly WrittenLiteral[] }; ``` -- Both members are required. There is no base-class default. A codec that omits them fails to compile. -- `decodePsl` throws an ordinary `Error` whose message says what the codec accepts, for example `pg/int4@1 reads a number literal; got a string`. Callers turn the message into a diagnostic. -- Update the file's header comment, which lists the four conversion methods, to list six and say when each pair runs (PSL: schema reading and schema printing). +Functions: -### B3. The PSL form of each codec +- `readLiteral(written): { ok: true; literal: Literal } | { ok: false; reason: 'invalid-json' | 'invalid-number'; message: string }`. Classifies and produces the value. A `list` reads each element; a nested list is `invalid-number`-style refusal with its own message (PSL cannot write one anyway). +- `isCompatible(literal, declarations: readonly LiteralTypeDeclaration[]): boolean`. A scalar literal's type must appear by name. A list literal needs a `{ list }` declaration whose element names include every element's type. +- `describeDeclarations(declarations): string` for messages: `i8, i16, i32 literals`, `a list of i8, ... literals`, or `no literal defaults`. +- `writeLiteral(value: JsonValue, declarations): { text: string; tag?: LiteralTypeName } | undefined`. Tries the declarations in order; the first type whose `write` accepts the value wins. Used by the printer (B6). -One rule, applied to every codec: +The types, their value shapes, and the rules: -- If `encodeJson(value)` is a JSON string, the PSL form is `{ kind: 'string', text: }`, and `decodePsl` accepts a string literal and returns `decodeJson(text)`. -- If `encodeJson(value)` is a JSON number, the PSL form is `{ kind: 'number', text }` where `text` is the exact decimal text of the value with no exponent, and `decodePsl` accepts a number literal and reads its text without passing through `Number()` unless the codec's own type is a JavaScript number. -- If `encodeJson(value)` is a JSON boolean, the PSL form is `{ kind: 'boolean', text }`, and `decodePsl` accepts a boolean literal. -- If `encodeJson(value)` is a JSON object, array, or null, the PSL form is `{ kind: 'string', text: JSON.stringify(encodeJson(value)) }`, and `decodePsl` accepts a string literal, parses it as JSON, and returns `decodeJson(parsed)`. This covers the JSON codecs, the arktype-json codec, pgvector, and postgis. +| Literal type | Written as | Value produced | Reading rules | Writing rules | +|---|---|---|---|---| +| `string` | a string scalar | the text | | `"..."` with PSL escapes | +| `boolean` | `true` / `false` | the boolean | | `true` / `false` | +| `i8` | whole number in [-128, 127] | JSON number | leading zeros and the sign of zero dropped | digits | +| `i16` | whole number in [-32768, 32767] not `i8` | JSON number | same | digits | +| `i32` | whole number in [-2^31, 2^31-1] not smaller | JSON number | same | digits | +| `i64` | whole number in [-2^63, 2^63-1] not smaller | digit text | same; text because a JSON number rounds past 2^53 | digits | +| `bigint` | any larger whole number | digit text | same | digits | +| `decimal` | a number with a fraction | decimal text | trailing zeros kept; leading zeros and the sign of zero dropped, so `007.50` is `7.50` and `-0.0` is `0.0` (the `canonicalDecimalText` logic from `number-literal-default.ts`, moved here) | the text | +| `float` | `NaN`, `Infinity`, `-Infinity` | that text | | that text, unquoted | +| `json` | a `json` tag body | the parsed JSON value | parsed once; a parse failure is `invalid-json` with the parser's message | `JSON.stringify(value)` inside a `json` tag, backtick fence, switching to the quote fence when the text contains a backtick | -Named exceptions to the rule, each already how PSL is written today: +Classification is by the number's text alone using `BigInt` comparison; no literal type converts through a JavaScript number except `i8`, `i16` and `i32`, whose values are exact. The tokenizer admits no exponent and no leading `+`, so the number regexes are `^-?\d+$` and `^-?\d+\.\d+$` plus the three words. -- Float codecs (`pg/float4@1`, `pg/float8@1`, and SQLite's real codec): `NaN`, `Infinity`, and `-Infinity` are written as string literals `"NaN"`, `"Infinity"`, `"-Infinity"`, because PSL has no number token for them. `decodePsl` accepts both a number literal and one of those three strings. -- Integer codecs whose JavaScript type is `bigint` or a decimal string (`pg/int8@1`, `pg/numeric@1`, the decimal codecs): `decodePsl` reads the digits from `text` directly; `encodePsl` prints them directly. The number never touches a JavaScript `number`. -- Codecs whose JSON form is a string but whose PSL form must be a number (none known). If one is found, stop and report. +`write` for a numeric type accepts a JSON number or numeric text, classifies it, and prints it only when the classification is that type. A finite JSON number prints plainly with no exponent (the existing `plainNumeral` logic from the Postgres printer moves here). So a stored `pg/int8@1` text `"42"` prints `42` through `i8`, and a stored `pg/float8@1` number `1.5` prints `1.5` through `decimal`. -Per-codec inventory the implementer must complete (grep `extends CodecImpl`; the abstract members make omissions compile errors, so the list is checked by the typecheck): +Provide `integerLiteralTypesUpTo(name)` returning the chain `['i8', ...]` up to and including `name`, so descriptors do not spell the chain out. -- `packages/3-targets/3-targets/postgres/src/core/codecs.ts` (21 classes) -- `packages/3-targets/3-targets/postgres/src/core/temporal-codecs.ts` (4) -- `packages/3-targets/3-targets/postgres/src/core/temporal-string-codecs.ts` (4) -- `packages/3-targets/3-targets/postgres/src/core/date-codecs.ts` (1) -- `packages/3-targets/3-targets/sqlite/src/core/codecs.ts` (8) -- `packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts` (5) -- `packages/3-extensions/pgvector/src/core/codecs.ts` (1) -- `packages/3-extensions/postgis/src/core/codecs.ts` (1) -- `packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts` (1) -- `packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts` (all classes; nothing in Mongo authoring calls them yet) -- Any alias or higher-order codec class the typecheck reports. +### B2. Codec descriptors name their literal types; codecs coerce -Shared helpers are allowed and expected: for instance one `stringPslCodec()` mixin-style pair of functions the identity-string codecs share, one `jsonTextPsl` pair the object-valued codecs share. Put family-shared helpers in `packages/1-framework/1-core/framework-components/src/shared/psl-literal-helpers.ts`. Do not put a default on `CodecImpl`. +Add `readonly literalTypes?: readonly LiteralTypeDeclaration[]` to `CodecDescriptor` and `CodecDescriptorImpl` (`codec-descriptor.ts`). Optional; a codec that names none accepts no literal defaults. Mongo's `mongoCodec({...})` factory does not gain the option. -### B4. The `literal()` combinator +The inventory. Every production codec appears exactly once. -File: new `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/literal.ts`; types in `attribute-spec/types.ts`. +| Codec ids | `literalTypes` | +|---|---| +| `pg/text@1`, `pg/char@1`, `pg/varchar@1`, `pg/uuid@1`, `pg/inet@1`, `pg/bit@1`, `pg/varbit@1`, `pg/timetz@1`, `pg/interval@1`, `pg/bytea@1`, `pg/date-string@1`, `pg/time-string@1`, `pg/timestamp-string@1`, `pg/timestamptz-string@1`, `pg/date-temporal@1`, `pg/time-temporal@1`, `pg/timestamp-temporal@1`, `pg/timestamptz-temporal@1`, `pg/timestamptz-date@1`, `sqlite/text@1`, `sqlite/blob@1`, `sqlite/datetime@1`, `sql/text@1`, `sql/char@1`, `sql/varchar@1`, `pg/geometry@1` | `['string']` | +| `pg/bool@1` | `['boolean']` | +| `pg/int2@1` | `i8` to `i16` | +| `pg/int4@1`, `pg/int@1`, `sql/int@1` | `i8` to `i32` | +| `pg/int8@1`, `pg/int8number@1`, `sqlite/integer@1`, `sqlite/bigint@1`, `sqlite/bigintnumber@1` | `i8` to `i64` | +| `pg/unboundedint@1` | `i8` to `i64`, `bigint` | +| `pg/float@1`, `sql/float@1`, `sqlite/real@1` | `i8` to `i64`, `bigint`, `decimal` | +| `pg/float4@1`, `pg/float8@1`, `pg/numeric@1` | `i8` to `i64`, `bigint`, `decimal`, `float` | +| `pg/json@1`, `pg/jsonb@1`, `sqlite/json@1`, `arktype/json@1` | `['json']` | +| `pg/vector@1` | `[{ list: ['i8', 'i16', 'i32', 'i64', 'bigint', 'decimal'] }]` | +| `pg/enum@1`, `pg/text-array@1`, the seven Mongo codecs | none | -```ts -export function literal(): LiteralArgType; // parses to PslLiteral -``` +Coercion. Each codec's `decodeJson` accepts the value shape of every type it names, in addition to its own JSON form, and refuses the rest with its existing error code: + +- `pg/int8@1`, `sqlite/bigint@1`, `pg/unboundedint@1`: a whole JSON number as well as digit text. +- `pg/int8number@1`, `sqlite/bigintnumber@1`, `sqlite/integer@1`: digit text as well as a number; text past `Number.MAX_SAFE_INTEGER` is refused with a message naming the codec's limit. `sqlite/integer@1` gains that check for numbers too, since it has none today. +- `pg/numeric@1`: a JSON number, stored as its canonical decimal text. +- `pg/float4@1`, `pg/float8@1`: digit or decimal text, and the three non-finite words. Their JSON form for a non-finite value becomes the word as text, and `encode`/`decode` carry it on the wire the same way (the closed branch's float fix, `pgFloatEncode`, `pgFloatEncodeJson`, `pgFloatDecodeJson`, without its `encodePsl`/`decodePsl`). +- `pg/float@1`, `sql/float@1`, `sqlite/real@1`: digit or decimal text; non-finite still refused. +- `pg/vector@1`: elements may be digit or decimal text. +- Every other codec is unchanged. + +Per pack, one test asserts the full inventory: it walks the pack's registered descriptors and compares each `codecId` to `literalTypes` against a table, and fails on a codec missing from the table. Packs: Postgres target, SQLite target, relational-core, pgvector, postgis, arktype-json, Mongo adapter. + +### B3. The `json` tag + +`ControlDefaultLiteralTagEntry` in `mutation-default-types.ts` becomes a union: the existing lowering entry (`usage`, `documentation`, `lower`) for `sql`, and a literal-type entry (`usage`, `documentation`, `literalType: LiteralTypeName`) for `json`. The framework exports `jsonDefaultLiteralTagEntry()` from the same place as the literal types. Postgres (`6-adapters/postgres/src/core/control-mutation-defaults.ts`) and SQLite register `json` with no prefixed alias. The `contract-psl` fixture registry gains it. Assembly is unchanged. + +### B4. The PSL interpreter + +Files: `contract-psl/src/sql-attribute-specs.ts`, `psl-column-resolution.ts`, `psl-field-resolution.ts` as needed. -- `ArgTypeKind` gains `'literal'`. Label `literal`. -- A `StringLiteralExprAst` yields `{ kind: 'string', text: literal.value() }` (escapes resolved by the existing `value()`). A `NumberLiteralExprAst` yields `{ kind: 'number', text: token.text }`. A boolean literal yields `{ kind: 'boolean', text }`. Anything else: `Expected a string, number, or boolean literal`. -- `numLiteral()` stays for its other consumers (the Prisma 7 source uses it for attribute arguments); `@default` no longer uses it. +Arms. `scalarDefaultArms` keeps `str()`, `numLiteral()`, `bool()`, the function arms and the tag arm. Two changes: the non-list case also gains `list(literal())` so a scalar column can take a list literal; and the list element `oneOf` gains the tag arm, so `Jsonb[] @default([json`{}`])` parses. Enum arms are unchanged. -### B5. Interpreter +Resolution of a literal default, in `lowerDefaultForField`: -Files: [`sql-attribute-specs.ts`](../../../../packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts), [`psl-column-resolution.ts`](../../../../packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts), [`number-literal-default.ts`](../../../../packages/2-sql/2-authoring/contract-psl/src/number-literal-default.ts). +1. A tagged literal: look up the registry entry. A lowering entry lowers as today (`sql`). A literal-type entry yields `{ kind: 'json', text: body }` as the written literal (the only literal-type tag today; the code is generic over `literalType`). +2. A string, number, boolean or list value yields the matching `WrittenLiteral`; a list element that is a tagged literal is handled as in step 1 within the list. +3. `readLiteral`. `invalid-json` is `PSL_INVALID_JSON_LITERAL`; any other refusal is `PSL_INVALID_DEFAULT_LITERAL`. Both at the literal's span (the element's span inside a list). +4. The descriptor is `codecLookup.descriptorFor(codecId)`; absent `descriptorFor` or a missing descriptor is an `InternalError`, because the column was resolved from it. For a list column the element literals are checked one by one against the element codec's scalar declarations (as today); for a scalar column the whole literal is checked. Incompatible is `PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE`: `Field "Account.count": pg/int4@1 is not compatible with a bigint literal; it accepts i8, i16, i32 literals`. A list literal on a scalar column whose codec names no list: `... is not compatible with a list literal; ...`. +5. The codec instance is `materializeCodec(descriptor, { codecId, typeParams: columnDescriptor.typeParams }, ctx)` so a `vector(3)` column checks its length. `decodeJson` on the value (per element for a list column). A throw is `PSL_INVALID_DEFAULT_LITERAL` carrying the codec's message. +6. Store the decoded value as today; `build-contract.ts` re-encodes it through `encodeJson`. -- `scalarDefaultArms`: the literal arms `str(), numLiteral(), bool()` become one `literal()` arm; the list case becomes `list(literal())`. The function arms are unchanged. (Slice A appends a tagged-literal arm after the function arms; the two edits do not overlap.) -- `DefaultArgValue` becomes `PslLiteral | PslLiteral[] | TypedFuncCall` (slice A adds its own member). -- `psl-column-resolution.ts`: for a `PslLiteral` or a list of them, look up the column codec with `codecLookup.get(codecId)`. The codec must exist; a missing codec is an `InternalError` (the codec lookup is already required to build the column). Call `codec.decodePsl(literal)` for the value or for each element. A thrown error becomes diagnostic `PSL_INVALID_DEFAULT_LITERAL` at the attribute's span with message `Field ".": @default() is not a value of : `. The decoded value goes into `{ kind: 'literal', value }` exactly where today's value goes; encoding to JSON for the contract happens where it happens today (`encodeColumnDefault` in `contract-ts/src/build-contract.ts` calls `encodeJson`). -- Delete `number-literal-default.ts` and its test. Delete the `numeric` trait check that gated it. Remove `numberLiteralDefault` from `contract-psl`'s `resolution` export; its one external consumer (the Prisma 7 source) is rewritten in B7. -- The enum-member arm is unchanged: members lower to their storage value as today. +Delete `number-literal-default.ts` and its export from `exports/resolution.ts`. The three diagnostic codes are constants in `contract-psl`. -### B6. Printer +### B5. The Prisma 7 reader -Files: [`9-family/src/core/psl-contract-infer/default-mapping.ts`](../../../../packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts), [`postgres/src/core/psl-infer/psl-literals.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/psl-literals.ts), [`infer-model-blocks.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts), `printer-config.ts`. +Files: `contract-prisma7/src/defaults.ts`, `target-binding.ts`; `postgres/src/core/prisma7-binding.ts`. -- `mapDefault(columnDefault, options)` gains a required `codec: Codec` in its options for the literal arm. The literal arm prints `formatPslLiteral(codec.encodePsl(codec.decodeJson(value)))`; for a list column, each element, joined as `[a, b]`. The contract holds values in JSON form, so `decodeJson` runs first to get the codec's own type. -- New family function `formatPslLiteral(literal: PslLiteral): string`: `string` → `"` + escaped text + `"` using the existing `escapePslString` rules (moved to the family if it lives in the target today); `number` and `boolean` → `text`. -- Delete `formatLiteralValue`, `quoteString`, `escapeString` from `default-mapping.ts`. -- Delete the per-codec formatter table in `psl-literals.ts` (`PslDefaultValueFormat`, `formatPslValue`, `formatNumber`, `formatFloat`, `formatInteger`, `plainNumeral`, and the table that maps codec IDs to them) and the `printer-config.ts` option that carries it. `infer-model-blocks.ts` passes the column's codec to `mapDefault` instead. -- The list-literal printing path in `infer-model-blocks.ts` (the one that prints `@default([...])` from `resolvedDefault`) uses the same `formatPslLiteral` per element. +The reader builds a `WrittenLiteral` from its own syntax: a string literal is `string`, except that when the binding's `literalDefaultForm` is `json` it is `{ kind: 'json', text }`; a number is `number`; a boolean is `boolean`; a list is `list`. Then steps 3 to 6 of B4 with the same helpers (shared through the `contract-psl` resolution export, where `lowerPrisma7Default` already imports from). Diagnostics keep the code `PSL.PRISMA7_UNKNOWN_DEFAULT`, with the reason text from the literal type, the incompatibility message, or the codec. -### B7. Prisma 7 source +Deleted: `WHOLE_NUMBER_SCALARS`, `WHOLE_NUMBER_TEXT`, `rejectedNumberReason`, `numberValue`, and the `JSON.parse` in `elementValue`. Unchanged: the `sqlExpression` form for `Bytes` and `DateTime` (project decision D11), and `PSL.PRISMA7_JSON_NULL_DEFAULT_UNSUPPORTED`, which fires before the literal is read. -Files: [`contract-prisma7/src/defaults.ts`](../../../../packages/2-sql/2-authoring/contract-prisma7/src/defaults.ts), [`contract-prisma7/src/target-binding.ts`](../../../../packages/2-sql/2-authoring/contract-prisma7/src/target-binding.ts), [`postgres/src/core/prisma7-binding.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/prisma7-binding.ts). +### B6. The printer -- `Prisma7LiteralDefaultForm` loses the `{ kind: 'json' }` member. `literalDefaultForm` in the Postgres binding no longer returns it for `json`/`jsonb`; those columns take the codec path like every other column. The `sqlExpression` member stays for `bytea` and the temporal types (project spec D11). -- `scalarValue`, `elementValue`, `numberValue`, `rejectedNumberReason`, `WHOLE_NUMBER_SCALARS`, and `WHOLE_NUMBER_TEXT` are replaced by: build a `PslLiteral` from the expression (string → `{ kind: 'string', text: value() }`; number → `{ kind: 'number', text: token.text }`; boolean → boolean), call `codecLookup.get(codecId).decodePsl(literal)`, and turn a thrown error into `PSL.PRISMA7_UNKNOWN_DEFAULT` with message `Field ".": @default() is not a value of : `. The enum-member path (identifier → `enumMembers.get`) is unchanged. The `PRISMA7_JSON_NULL_DEFAULT_UNSUPPORTED` diagnostic stays and fires when the decoded value is `null` on a JSON-typed column. -- The Prisma 7 rule that `Int` and `BigInt` defaults must be whole numbers is now the codec's rule: `pg/int4@1` and `pg/int8@1` `decodePsl` reject `1.5`. The fixture expectations that quote the old message text are updated to the codec's message. +Files: `9-family/src/core/psl-contract-infer/default-mapping.ts`; `postgres/src/core/psl-infer/`. -### B8. ADR 184 amendment +`mapDefault(columnDefault, options)` gains `options.literalTypes: readonly LiteralTypeDeclaration[]`. For a literal default it calls `writeLiteral(value, literalTypes)` and prints `@default()`, or `` @default(``) `` when a tag is returned, or for a list value on a list column `@default([])`. When `writeLiteral` returns `undefined`, the result is what `main` does for an inexpressible default (the function fallback, `dbgenerated` until slice C). `formatLiteralValue` is deleted. This is the `mapDefault(columnDefault, { codec })` seam the project plan lists for slice C, with `literalTypes` in place of `codec`; the plan is updated. -Add a section "Amendment — PSL literal methods live on `Codec`" to [ADR 184](../../../../docs/architecture%20docs/adrs/ADR%20184%20-%20Codec-owned%20value%20serialization.md): `encodePsl` and `decodePsl` are required members of the `Codec` interface and abstract on `CodecImpl`; the `PslLiteralCodec` interface sketched in the ADR is not a separate entity and never was, it is the consumer's view of the same codec (dependency inversion); the "single interface with all boundaries" alternative is no longer rejected for PSL; the `PslLiteral` shape and the one rule from B3 with its named exceptions; DDL methods remain future work with a pointer to [`deferred.md`](../../deferred.md) item 3. Update the `docs/reference/codec-authoring-guide.md` to list six methods and show a JSON-valued and a string-valued example. Update the ADR index summary line. +The Postgres printer needs the descriptor for each printed column. It resolves the printed PSL type name to a descriptor through the same type resolution `contract emit` uses, so the two cannot disagree; if that resolution cannot be called from infer, the closed branch's `infer-default-codec.ts` map is acceptable only with a test that asserts it agrees with the emit-side type map for every printed type name. Enum columns keep their member-name path. Deleted: `PslDefaultValueFormat`, `pslDefaultValueFormat`, `formatPslValue`, `formatPslListLiteralValue`, `formatNumber`, `formatFloat`, `formatInteger`, `formatDecimalText`, `noLiteral`, `DEFAULT_VALUE_FORMATS`. -### B9. Docs +### B7. Behaviour that changes for existing schemas -- `contract-psl/README.md`: one paragraph on literal defaults: the written form is whatever the column's codec accepts; JSON columns take a string holding JSON text; numbers are read exactly as written. -- `docs/reference/error-reference.md`: add `PSL_INVALID_DEFAULT_LITERAL`; update the Prisma 7 messages that changed. +- `Jsonb @default("{}")` becomes `` Jsonb @default(json`{}`) ``. +- `Decimal @default("1.50")` becomes `Decimal @default(1.50)`. +- `Float @default("NaN")` becomes `Float @default(NaN)`. +- Any quoted value on a column whose codec does not name `string`. + +Unchanged: enum member defaults; list syntax on list columns; `` Json @default(json`null`) `` stores JSON null. + +### B8. Docs and upgrade instructions + +- `docs/reference/error-reference.md`: `PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE`, `PSL_INVALID_DEFAULT_LITERAL`, `PSL_INVALID_JSON_LITERAL`, in the neighbours' form; the Prisma 7 message wording where it changed. +- `docs/reference/codec-authoring-guide.md`: a section on `literalTypes` with one scalar and one list example, and the coercion rule. +- `contract-psl/README.md`: one paragraph replacing the word "literals" in the `@default` list. +- `upgrade-instructions/pending/literal-types-column-defaults/app/instructions.md` (the three forms in B7, with detection on `*.prisma`) and `.../extension/instructions.md` (descriptors name `literalTypes`; `decodeJson` accepts every named shape), per the `record-upgrade-instructions` skill. + +### B9. Reused from the closed branch + +By hand, from `origin/remove-dbgenerated-codec-psl-literals`: the e2e test `test/integration/test/cli-journeys/codec-psl-literal-defaults.e2e.test.ts`, with its schema rewritten to the Outcome forms and a vector column added; the jsonb case in `infer-roundtrip-fidelity.e2e.test.ts`; the float fix (B2); the decimal canonicalisation cases as tests. Nothing that adds `encodePsl`, `decodePsl`, `PslLiteral`, or the `literal()` combinator. + +### B10. ADR 254 amendment + +ADR 254 is on the unmerged branch of PR 30334 and merged into this branch. This PR edits it: the open question is closed with decisions 1 to 3 above; the literal-types table gains the numeric types by size; "Codecs declare compatible literal types" gains the list declaration and the coercion rule; the enum and non-finite float details in "Settled details" are corrected. The project spec's D9 and D10 gain a one-line amendment note pointing here, and the plan's slice C seam is updated (B6). ## Tests (written first; each named test must fail before its implementation lands) -Framework (`framework-components/test`): -- `PslLiteral` type test; `CodecImpl` subclass without the methods fails to compile (`test-d`). +Framework (`framework-components/test/literal-types.test.ts`): +- classification table: `0`, `-0`, `007` → `i8` value `7`; `127`/`128` boundary; `32767`/`32768`; `2147483647`/`2147483648`; `9007199254740993` → `i64` text; `9223372036854775807`/`9223372036854775808` → `i64`/`bigint`; `1.50` → `decimal` `"1.50"`; `-007.50` → `"-7.50"`; `-0.0` → `"0.0"`; `NaN`, `Infinity`, `-Infinity` → `float`. +- `json`: object, array, `null`, invalid text → `invalid-json` with a message. +- `isCompatible`: scalar in and out of a declaration; list against `{ list }`; list against scalars only; nested list refused. +- `writeLiteral`: each type round-trips its own value; number `1.5` against `i8..i32, decimal` → `1.5`; text `"42"` against `i8..` → `42`; `1e21`-magnitude number prints without exponent; non-finite prints unquoted; JSON value prints as a `json` tag, quote fence when the text has a backtick; `undefined` when nothing matches. +- `describeDeclarations` wording. + +Descriptor (`framework-components/test/codec.types.test-d.ts`): `literalTypes` is optional and typed as the declaration union. + +Per pack inventory test (B2), seven files, one per pack. + +Codec coercion (`postgres/test`, `sqlite/test`, `relational-core/test`, `pgvector/test`): for each codec in B2's coercion list, `decodeJson` accepts each named shape and refuses the rest; float4/float8 non-finite round-trip through `encodeJson`/`decodeJson` and `encode`/`decode`; `sqlite/integer@1` refuses `2**53 + 1`. -Per pack, a table test that for every codec the pack registers, `decodePsl(encodePsl(v))` equals `v` for at least one sample value per codec, and `decodePsl` of a wrong-kind literal throws with a message naming the codec. Packs: Postgres target, SQLite target, relational-core, pgvector, postgis, arktype-json, Mongo codec package. +Registry (`6-adapters/postgres/test/control-mutation-defaults.test.ts`, SQLite equivalent): tag registry holds `sql`, `pg.sql` (or `sqlite.sql`) and `json`; `json` names literal type `json`. -Interpreter (`contract-psl/test/interpreter.defaults.test.ts`), each case asserting the whole default object: -- `Jsonb @default("{}")` → literal `{}` (object); `Json @default("[1, 2]")` → `[1, 2]`; `Json @default("null")` → literal `null` (allowed in Prisma 8 authoring); `BigInt @default(9007199254740993)` → exact; `Decimal @default(1.50)` → `"1.50"`; `Float @default("NaN")`; `Float @default(1.5)`; `Int @default(1.5)` → `PSL_INVALID_DEFAULT_LITERAL` with the codec's message; `Int @default("1")` → `PSL_INVALID_DEFAULT_LITERAL`; `String @default("a\"b")` → `a"b`; `Boolean @default(true)`; `Int[] @default([1, 2])`; `Int[] @default([1, "x"])` → diagnostic naming the element. -- The existing `preserves raw dbgenerated defaults for timestamp and json columns` test is unchanged (slice C rewrites it). +Interpreter (`contract-psl/test/interpreter.defaults.literal-types.test.ts`, replacing `interpreter.number-defaults.test.ts`), whole default object asserted: +- every Outcome column above; each error case above with its code and span; `Int @default("1")` incompatible; `Float @default(1)` → `1`; `Real @default(NaN)` on a codec without `float` → incompatible; `BigInt @default(42)` → the decoded bigint, and the emitted contract holds `"42"`; `Decimal @default(42)` → `"42"`; `Jsonb @default([1, 2])` → JSON array (harmless consequence of the list arm; recorded); vector length mismatch → `PSL_INVALID_DEFAULT_LITERAL` with the codec's message; missing `descriptorFor` → `InternalError`. +- `interpreter.defaults.tagged-literal.test.ts`: `json` tag cases incl. `json\`null\``, invalid JSON, `json` on an `Int` column → incompatible, `json` inside a list on `Jsonb[]`. +- language server `completion-provider.test.ts:766` stays green unchanged. -Printer (`9-family/test/psl-contract-infer/default-mapping.test.ts`, `postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts`): -- every case above printed back to the same source text; a `pg/float8@1` value `NaN` prints `"NaN"`; a `pg/int8@1` value beyond 2^53 prints every digit; a jsonb object prints `"{\"a\":1}"` with escapes. +Prisma 7 (`contract-prisma7/test/defaults.test.ts`): `Int @default(1.5)` and `Int @default(100000000000000099)` → `PRISMA7_UNKNOWN_DEFAULT` with the incompatibility reason; `Json @default("{\"a\":1}")` lowers through the codec; `Decimal @default(1.5)` → `"1.5"`; `json-null-default` fixture unchanged; `Bytes`/`DateTime` unchanged. -Prisma 7 source (`contract-prisma7/test`): -- existing `defaults` fixture green with updated messages; `jsonLiteral Json @default("{\"a\":1}")` lowers through the codec; `Int @default(1.5)` rejected with the codec's message; `json-null-default` fixture unchanged. +Printer (`9-family/test/psl-contract-infer/default-mapping.test.ts`, `postgres/test/psl-infer/print-psl/*`): every Outcome column printed back to the same text; `pg/int8@1` beyond 2^53 prints every digit; `pg/float8@1` `NaN` prints `NaN`; jsonb object prints as a `json` tag; vector prints as a list; a codec naming nothing falls back as on `main`; the type-name resolution agrees with emit for every printed type. -Journeys: -- `test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts`: the jsonb default case now asserts that emit succeeds without the workaround and that infer prints `@default("{}")`; delete the comment that says it is "left broken". -- New integration test: the Outcome schema emits, `db init` succeeds, `db verify --schema-only --strict` reports nothing, and a row read through the client returns the defaults with their decoded types. +Journeys: the e2e test from B9 against a real database; the `infer-roundtrip-fidelity` jsonb case; `test/integration/test/number-defaults/psl-number-defaults.integration.test.ts` updated to descriptors with `literalTypes`; a parity pair `test/integration/test/authoring/parity/default-literal-types/` whose PSL and TypeScript emit identical contracts. ## Definition of done -- All tests above green; `pnpm test:packages`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm fixtures:check`, `pnpm lint:deps`, `pnpm lint:docs`, root typecheck green. -- `git grep -n "numberLiteralDefault\|PslDefaultValueFormat\|formatLiteralValue" -- packages` returns nothing. -- `git grep -n "kind: 'json'" -- packages/2-sql/2-authoring/contract-prisma7 packages/3-targets/3-targets/postgres/src/core/prisma7-binding.ts` returns nothing. -- Every existing fixture's `contract.json` is byte-identical (`pnpm fixtures:check`); the JSON form of values does not change. -- ADR 184 amendment and codec guide update merged with the PR. +- All tests above green; `pnpm typecheck`, `pnpm test:packages`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm lint`, `pnpm lint:deps`, `pnpm lint:docs`, `pnpm lint:throws`, `pnpm fixtures:check` (no contract file changed), `pnpm check:upgrade-coverage --mode pr` green. +- `git grep -n "numberLiteralDefault\|PslDefaultValueFormat\|formatPslValue\|formatPslListLiteralValue\|formatLiteralValue\|encodePsl\|decodePsl" -- packages` returns nothing. +- Seven per-pack inventory tests exist and fail on an undeclared codec. +- ADR 254, the project spec D9/D10 note, and the plan's B6 seam updated in the PR. +- One PR against `main`, description per the `create-pr` skill, no Linear prefix, and the checklist says why. ## Halt conditions -- A codec's JSON form is a string but its natural PSL form must be a number, or the reverse. Report the codec; do not add a per-codec branch outside that codec. -- A consumer other than the interpreter, the printer, and the Prisma 7 source depends on the deleted formatter table. Report it. -- The Mongo codec package cannot depend on `PslLiteral` without a layering violation. Report; do not duplicate the type. +- A codec's `decodeJson` cannot accept a named shape without changing what `contract.json` stores. Report; do not add a per-codec branch in the interpreter. +- The Postgres printer cannot reach the emit-side type resolution and the hand map cannot be tested against it. Report the seam. +- A contract source other than PSL and the Prisma 7 reader reads literal defaults. Report it. +- The `{ list }` declaration cannot express what a codec needs (for example a fixed element type per position). Report; do not add functions to the declaration. ## Repository rules that apply -`CLAUDE.md`; `.agents/rules/running-tests.mdc`; `.agents/rules/git-staging.mdc`; `.agents/rules/no-bare-casts.mdc`; `.agents/rules/contract-default-values.mdc`; `.agents/rules/storage-type-hooks.mdc`; `.agents/rules/prefer-assertions-over-defensive-checks.mdc`; `.agents/rules/omit-should-in-tests.mdc`; `docs/reference/codec-authoring-guide.md`. +`CLAUDE.md`; `.agents/rules/running-tests.mdc`; `.agents/rules/git-staging.mdc`; `.agents/rules/no-bare-casts.mdc`; `.agents/rules/contract-default-values.mdc`; `.agents/rules/storage-type-hooks.mdc`; `.agents/rules/prefer-assertions-over-defensive-checks.mdc`; `.agents/rules/omit-should-in-tests.mdc`; `.agents/rules/non-vacuous-verification.mdc`; the `psl-ast-layers` and `no-bare-casts` skills; `docs/reference/codec-authoring-guide.md`. diff --git a/projects/remove-dbgenerated/spec.md b/projects/remove-dbgenerated/spec.md index 6a1ac95e7532..d420dbc626de 100644 --- a/projects/remove-dbgenerated/spec.md +++ b/projects/remove-dbgenerated/spec.md @@ -7,7 +7,7 @@ `@default(dbgenerated("..."))` lets a schema put arbitrary SQL into a column default as an unnamed string. ADR 167 accepted it as a stopgap while typed default literals were unfinished. It was never meant to ship in Prisma 8. This project removes it and builds the two things it was standing in for: 1. A designed way to write a raw SQL default: the ADR 129 tagged literal, written `@default(sql\`...\`)` or `@default(sql"...")` in PSL, and `.default(sql\`...\`)` in TypeScript. -2. The codec-owned PSL literal layer that ADR 184 decided and nobody built, so that every typed literal default (JSON, big integers, decimals, timestamps, and so on) is read from PSL and printed back to PSL by the column's codec, with no special cases in the interpreter or the printer. +2. Typed literal defaults checked and converted by the column's codec: every literal default has a literal type (`string`, `number`, `boolean`, `json`), each codec declares the literal types it is compatible with, PSL writes a literal of a type either as a plain PSL scalar or with a tag, and the interpreter and printer have no per-type code. When both exist, `dbgenerated` is deleted everywhere, the shipped Supabase contract is regenerated without it, and users get an upgrade instruction. @@ -56,13 +56,21 @@ The SQL family contract builder exports `sql` (template tag), `now()`, and `auto Nobody can tell from a function's name whether it returns a value of the column's type, for a list column or for any other column. That is the author's responsibility and the database reports the error if it is wrong. So `tags String[] @default(sql`'{}'::text[]`)` and `tags DateTime[] @default(now())` both lower. Two defaults are still refused on a list column: client-side generators (`uuid()`, `cuid()`, `ulid()`, `nanoid()`), which generate one value, and `autoincrement()` (`PSL_LIST_AUTOINCREMENT_UNSUPPORTED`), which is a Prisma marker for a sequence-backed scalar column rather than SQL and would otherwise be rendered as a scalar `SERIAL` column with no error from the database. -### D9. Codecs own the PSL form of every literal +### D9. Every literal default has a literal type; PSL writes it as a scalar or with a tag -`encodePsl` and `decodePsl` become required members of the framework `Codec` interface and abstract members of `CodecImpl`. Every codec class in the repository implements them, including Mongo codecs. The interpreter passes a literal's normalised content and kind to the column codec's `decodePsl` and stores the result through `encodeJson` as today. The printer calls `encodePsl` and writes the result. The numbers-only shortcut in the interpreter, the per-codec formatter table in the Postgres infer printer, and the Prisma 7 source's JSON literal handling are all deleted. The full interface, the rule for what each codec's PSL form is, and the list of codecs are in [slice B](slices/b-codec-psl-literals/spec.md). ADR 184 is amended to say this was always the design and the interface is a consumer interface satisfied by the pack's codecs, not a separate entity. +*Amended 2026-09-17; replaces the 2026-09-16 D9. Amended again in slice B — see the ADR 254 amendment: the numeric types are cut by size (`i8`, `i16`, `i32`, `i64`, `bigint`) and a written number's type comes from its own size and precision, never from the column. Full design: ADR 254.* A literal default has a literal type, and the literal type says what the value is, independent of how PSL writes it. There are seven: `string`, `boolean`, `int`, `float`, `bigint`, `decimal`, and `json`. Each one is defined in the framework, and each produces the value shape the codecs that name it already accept in `decodeJson`, so `int` gives a whole JSON number, `bigint` gives the digits as text, `decimal` gives decimal text with its trailing zeros, and `json` gives a JSON value. -### D10. The codec receives normalised content, not source text with fences +PSL writes a literal of a given type in one of two ways. The plain PSL scalars write `string`, `boolean`, and the numeric literal types. A tag writes a literal of the type the tag names, as in `` @default(json`{ "a": 1 }`) ``; the tag registry entry says which literal type a tag writes, and each SQL target registers `json` with no prefixed alias. Both ways lower to the same literal. The syntax tree keeps what was written, for the formatter and the language server. -`decodePsl` receives `{ kind, text }` where `kind` is `string`, `number`, or `boolean`, and `text` is the literal's content with the quotes removed and escape sequences resolved for a string, the digits exactly as written for a number, and `true` or `false` for a boolean. The codec never sees the fence. A number is never converted to a JavaScript number before the codec sees it. `encodePsl` returns the same shape and the printer adds quotes and escapes. +The `sql` tag is different: it writes a raw SQL expression, not a value of the column's type. It lowers to a raw SQL default as D2 describes, and no codec check applies to it. + +**Open, to discuss with the operator before implementation:** a plain number scalar names no literal type of its own, so `42` is an `int` literal on an `Int` column, a `bigint` literal on a `BigInt` column, and a `decimal` literal on a `Decimal` column, decided by what the column's codec declares. Whether to accept that, to give each numeric literal type its own tag, or to keep one `number` literal type that each codec converts, is not settled. The numeric literal types are not implemented until it is. ADR 254 records the three options. + +### D10. Codecs declare the literal types they are compatible with + +*Amended 2026-09-17; replaces the 2026-09-16 D10. Amended again in slice B — see the ADR 254 amendment: a declaration may name a list of element types (`{ list: [...] }`), and a codec converts between a named type's value shape and its own stored form inside `decodeJson`. Full design: ADR 254.* A codec descriptor names the literal types a column of that codec is compatible with, as static metadata beside `traits` and `targetTypes`. The declaration carries names only: the literal type produces the value the codec's existing `decodeJson` accepts, so no codec gains a method and no conversion code is written per codec. The declaration is optional, and a codec that names no literal type accepts no literal defaults. + +A default whose literal type the column's codec does not name is a diagnostic at the literal that names the codec and its compatible literal types, for example `pg/int4@1 is not compatible with a json literal; it accepts int literals`. The interpreter then passes the literal type's value to `decodeJson`, so a value the codec refuses, such as a vector of the wrong length, is reported with the codec's own message. The contract stores the JSON form as it does today (D1). ### D11. The DDL side of ADR 184 is out of scope @@ -70,7 +78,7 @@ Nobody can tell from a function's name whether it returns a value of the column' ### D12. `contract infer` prints the new forms and never a gap -When a Postgres default is a named function, infer prints the named function. When the column codec can read it as a literal, infer prints the literal through `encodePsl`. Otherwise infer prints `@default(sql\`\`)`, switching to the double-quote fence when the expression contains a backtick. Infer never emits a comment in place of a default and never stops on one. The family printer's `// Raw default:` comment fallback is deleted. +When a Postgres default is a named function, infer prints the named function. When the column's codec names a literal type that can write the value, infer prints it as a literal of that type: as a plain PSL scalar where the type has one, otherwise with the type's tag. Otherwise infer prints `@default(sql\`\`)`, switching to the double-quote fence when the expression contains a backtick. Infer never emits a comment in place of a default and never stops on one. The family printer's `// Raw default:` comment fallback is deleted. ### D13. The Prisma 7 source maps `dbgenerated` directly @@ -87,7 +95,7 @@ Slice A implements completion of registered tags inside `@default(`, because the - Migrating index expressions, check constraint bodies, and RLS predicates from plain strings to tagged literals (deferred). - `encodeDdl` and `decodeDdl` (deferred, D11). - New named storage default functions. -- Any change to Mongo authoring. Mongo codecs implement the new methods; nothing calls them yet. +- Any change to Mongo authoring or Mongo codecs. Nothing reads a Mongo default from PSL. ## Cross-cutting requirements @@ -101,21 +109,21 @@ Slice A implements completion of registered tags inside `@default(`, because the ## Contract-impact -Entities affected: `ColumnDefault` (unchanged shape, new producers). `Codec` interface (two new required members, slice B). `ControlMutationDefaults` (a new tag registry beside the function registry, slice A). No migration of stored contracts. +Entities affected: `ColumnDefault` (unchanged shape, new producers). `Codec` interface (unchanged). Codec descriptors (a new declaration naming compatible literal types, slice B). Literal types are defined in the framework and the tag registry says which one each tag writes (slice B). `ControlMutationDefaults` (a new tag registry beside the function registry, slice A). No migration of stored contracts. ## Adapter-impact - Postgres adapter: registry gains the tag registry; loses `dbgenerated`. - SQLite adapter: registry gains the tag registry; loses `dbgenerated` and the `NOW_SYNONYMS` rewrite. -- Postgres target: infer prints the new forms; codecs implement PSL methods. -- SQLite target: verify-side default resolution hook; codecs implement PSL methods. -- Mongo: codecs implement PSL methods; nothing else. -- Extensions: pgvector, postgis, arktype-json codecs implement PSL methods. Supabase contract regenerated. +- Postgres target: infer prints the new forms; codec descriptors declare their compatible literal types. +- SQLite target: verify-side default resolution hook; codec descriptors declare their compatible literal types. +- Extensions: pgvector, postgis, arktype-json codec descriptors declare their compatible literal types. Supabase contract regenerated. ## ADR pointers - ADR 129 — amended in slice A: two fences, tag registration rule (D3), canonicalization applies to both fences, the `TaggedLiteral` node's fields as built. -- ADR 184 — amended in slice B: `encodePsl` and `decodePsl` are required members of `Codec` (D9, D10); the "single interface" alternative is not rejected for PSL; DDL methods remain future work. +- ADR 184 — its PSL half is replaced by ADR 254: a codec descriptor names the literal types it is compatible with, and gains no methods (D9, D10). DDL methods remain future work. +- ADR 129 — also amended in slice B: a tag writes a literal of a literal type; `sql` writes a raw SQL expression (D9). - ADR 167 — note added in slice C: the `dbgenerated(...)` stopgap is removed and what replaced it. ## Definition of done (project) @@ -150,6 +158,10 @@ Conclusions from the shaping discussion on 2026-09-16, with reasons, assumptions **Normalised content to the codec, no pre-parsing.** Why: converting a number literal to a JavaScript number before the codec sees it loses precision for big integers and decimals. Alternative rejected: passing source text with fences (the codec has no business with PSL quoting). +**Codecs declare the literal types they are compatible with; tags are how PSL writes a literal of a type (2026-09-17).** Why: it answers how to check a default literal against a codec whose SQL type can be anything. The check is a lookup, not a trial decode. The codec deals only in literal types and never sees PSL syntax, so the PSL scalars and the tags are two ways of writing the same thing. This replaces the 2026-09-16 entries "Codec interface, not a separate registry" and "Normalised content to the codec, no pre-parsing". Alternatives rejected: `encodePsl` and `decodePsl` on the codec, taking a string, number, or boolean kind (built in slice B's first PR and withdrawn, because it made the parser's classification the codec's input); passing the raw argument text to the codec (needs an unparsed argument form for `@default` alone). + +**The literal types are cut where the stored representations are cut (2026-09-18).** Why: codecs that hold numbers store different JSON forms, `42` for `pg/int4@1`, `"9007199254740993"` for `pg/int8@1`, and `"1.50"` for `pg/numeric@1`, and existing contract JSON must not change. Separate `int`, `bigint`, and `decimal` literal types each produce one of those forms, so the codec declaration is a list of names and no codec carries conversion code. Alternative rejected: one `number` literal type with a read and a write function per codec, which duplicates what each codec's `decodeJson` already does. The consequence that a plain number scalar names no literal type of its own is open, and recorded in D9. + **DDL side out of scope.** Why: no strong reason to do it now; the Prisma 7 source's workaround for bytes and timestamps is contained and recorded. **Infer prints, never stops.** Why: infer's job is to describe the database faithfully; a user adopting a database should not be halted on a default they then have to add by hand. Alternative rejected: reporting a gap and stopping. diff --git a/test/e2e/framework/test/sqlite/migrations/additive.test.ts b/test/e2e/framework/test/sqlite/migrations/additive.test.ts index 6274cbeed3f6..f3840bb66c85 100644 --- a/test/e2e/framework/test/sqlite/migrations/additive.test.ts +++ b/test/e2e/framework/test/sqlite/migrations/additive.test.ts @@ -47,8 +47,8 @@ describe('SQLite Migration E2E - From empty schema', () => { fields: { id: int.id(), label: text.default('untitled'), - priority: field.column(integerColumn).default('0'), - isActive: field.column(integerColumn).default('1').column('is_active'), + priority: field.column(integerColumn).default(0), + isActive: field.column(integerColumn).default(1).column('is_active'), createdAt: text.default(now()).column('created_at'), }, }), diff --git a/test/integration/test/authoring/parity/default-literal-types/contract.ts b/test/integration/test/authoring/parity/default-literal-types/contract.ts new file mode 100644 index 000000000000..b63d8c65bc0c --- /dev/null +++ b/test/integration/test/authoring/parity/default-literal-types/contract.ts @@ -0,0 +1,29 @@ +import { + boolColumn, + float8Column, + int2Column, + int4Column, + jsonbColumn, + numericColumn, + textColumn, +} from '@internal/adapter-postgres/column-types'; +import { autoincrement, defineContract, field, model } from '@internal/postgres/contract-builder'; + +export const contract = defineContract({ + models: { + T: model('T', { + fields: { + id: field.column(int4Column).default(autoincrement()).id(), + name: field.column(textColumn).default('anonymous'), + small: field.column(int2Column).default(100), + count: field.column(int4Column).default(100000), + price: field.column(numericColumn(10, 2)).default('1.50'), + ratio: field.column(float8Column).default(1.5), + active: field.column(boolColumn).default(true), + meta: field.column(jsonbColumn).default({ plan: 'free', seats: 1 }), + scores: field.column(int4Column).many().default([1, 2]), + docs: field.column(jsonbColumn).many().default([{}, []]), + }, + }).sql({ table: 't' }), + }, +}); diff --git a/test/integration/test/authoring/parity/default-literal-types/expected.contract.json b/test/integration/test/authoring/parity/default-literal-types/expected.contract.json new file mode 100644 index 000000000000..e03d3d36bab4 --- /dev/null +++ b/test/integration/test/authoring/parity/default-literal-types/expected.contract.json @@ -0,0 +1,295 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", + "roots": { + "t": { + "model": "T", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "T": { + "fields": { + "active": { + "nullable": false, + "type": { + "codecId": "pg/bool@1", + "kind": "scalar" + } + }, + "count": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "docs": { + "many": true, + "nullable": false, + "type": { + "codecId": "pg/jsonb@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "meta": { + "nullable": false, + "type": { + "codecId": "pg/jsonb@1", + "kind": "scalar" + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "price": { + "nullable": false, + "type": { + "codecId": "pg/numeric@1", + "kind": "scalar", + "typeParams": { + "precision": 10, + "scale": 2 + } + } + }, + "ratio": { + "nullable": false, + "type": { + "codecId": "pg/float8@1", + "kind": "scalar" + } + }, + "scores": { + "many": true, + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "small": { + "nullable": false, + "type": { + "codecId": "pg/int2@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "active": { + "column": "active" + }, + "count": { + "column": "count" + }, + "docs": { + "column": "docs" + }, + "id": { + "column": "id" + }, + "meta": { + "column": "meta" + }, + "name": { + "column": "name" + }, + "price": { + "column": "price" + }, + "ratio": { + "column": "ratio" + }, + "scores": { + "column": "scores" + }, + "small": { + "column": "small" + } + }, + "namespaceId": "public", + "table": "t" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "t": { + "checks": [ + { + "expression": "array_position(\"docs\", NULL) IS NULL", + "name": "t_docs_elem_not_null_3ecbeb56", + "prefix": "t_docs_elem_not_null" + }, + { + "expression": "array_position(\"scores\", NULL) IS NULL", + "name": "t_scores_elem_not_null_0d0de0bd", + "prefix": "t_scores_elem_not_null" + } + ], + "columns": { + "active": { + "codecId": "pg/bool@1", + "default": { + "kind": "literal", + "value": true + }, + "nativeType": "bool", + "nullable": false + }, + "count": { + "codecId": "pg/int4@1", + "default": { + "kind": "literal", + "value": 100000 + }, + "nativeType": "int4", + "nullable": false + }, + "docs": { + "codecId": "pg/jsonb@1", + "default": { + "kind": "literal", + "value": [{}, []] + }, + "many": true, + "nativeType": "jsonb", + "nullable": false + }, + "id": { + "codecId": "pg/int4@1", + "default": { + "expression": "autoincrement()", + "kind": "function" + }, + "nativeType": "int4", + "nullable": false + }, + "meta": { + "codecId": "pg/jsonb@1", + "default": { + "kind": "literal", + "value": { + "plan": "free", + "seats": 1 + } + }, + "nativeType": "jsonb", + "nullable": false + }, + "name": { + "codecId": "pg/text@1", + "default": { + "kind": "literal", + "value": "anonymous" + }, + "nativeType": "text", + "nullable": false + }, + "price": { + "codecId": "pg/numeric@1", + "default": { + "kind": "literal", + "value": "1.50" + }, + "nativeType": "numeric", + "nullable": false, + "typeParams": { + "precision": 10, + "scale": 2 + } + }, + "ratio": { + "codecId": "pg/float8@1", + "default": { + "kind": "literal", + "value": 1.5 + }, + "nativeType": "float8", + "nullable": false + }, + "scores": { + "codecId": "pg/int4@1", + "default": { + "kind": "literal", + "value": [1, 2] + }, + "many": true, + "nativeType": "int4", + "nullable": false + }, + "small": { + "codecId": "pg/int2@1", + "default": { + "kind": "literal", + "value": 100 + }, + "nativeType": "int2", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [] + } + } + }, + "id": "public" + } + }, + "storageHash": "c91c217ab456dda315f745cf463fbb656b2af3bc48d42ea9ee3aba401f74c34e" + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "checkConstraint": true, + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true, + "scalarList": true + } + }, + "extensions": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma contract emit\".", + "regenerate": "To regenerate, run: prisma contract emit" + } +} diff --git a/test/integration/test/authoring/parity/default-literal-types/packs.ts b/test/integration/test/authoring/parity/default-literal-types/packs.ts new file mode 100644 index 000000000000..95c501cfb5d8 --- /dev/null +++ b/test/integration/test/authoring/parity/default-literal-types/packs.ts @@ -0,0 +1 @@ +export const extensions = [] as const; diff --git a/test/integration/test/authoring/parity/default-literal-types/schema.prisma b/test/integration/test/authoring/parity/default-literal-types/schema.prisma new file mode 100644 index 000000000000..ec459228ec62 --- /dev/null +++ b/test/integration/test/authoring/parity/default-literal-types/schema.prisma @@ -0,0 +1,16 @@ +// use prisma-8 + +model T { + id Int @id @default(autoincrement()) + name String @default("anonymous") + small SmallInt @default(100) + count Int @default(100000) + price Numeric(10, 2) @default(1.50) + ratio Float @default(1.5) + active Boolean @default(true) + meta Jsonb @default(json`{ "plan": "free", "seats": 1 }`) + scores Int[] @default([1, 2]) + docs Jsonb[] @default([json`{}`, json`[]`]) + + @@map("t") +} diff --git a/test/integration/test/authoring/psl.pgvector-literal-default.test.ts b/test/integration/test/authoring/psl.pgvector-literal-default.test.ts new file mode 100644 index 000000000000..9bed13f6fbda --- /dev/null +++ b/test/integration/test/authoring/psl.pgvector-literal-default.test.ts @@ -0,0 +1,172 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import postgresAdapter from '@internal/adapter-postgres/control'; +import { createControlClient, enrichContract } from '@internal/cli/control-api'; +import postgresDriver from '@internal/driver-postgres/control'; +import pgvector from '@internal/extension-pgvector/control'; +import sql from '@internal/family-sql/control'; +import { createControlStack } from '@internal/framework-components/control'; +import { materialiseMigrationPackage } from '@internal/migration-tools/io'; +import { emitContractSpaceArtifacts } from '@internal/migration-tools/spaces'; +import { sqlContractCanonicalizationHooks } from '@internal/sql-contract/canonicalization-hooks'; +import { sqlEmission } from '@internal/sql-contract-emitter'; +import { prismaContract } from '@internal/sql-contract-psl/provider'; +import postgres from '@internal/target-postgres/control'; +import postgresPackRef from '@internal/target-postgres/pack'; +import { postgresCreateNamespace } from '@internal/target-postgres/types'; +import { timeouts, withClient, withDevDatabase } from '@repo/test-utils'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { emit } from '../../utils/emit'; +import { createIntegrationTestDir } from '../utils/cli-test-helpers'; + +/** + * Materialise pgvector's pinned contract-space artifacts under + * `/migrations/pgvector/...` so the per-space db init + * flow (sub-spec § 6) can read its head ref + baseline migration. + * + * Db init requires a `migrationsDir` whenever any extension publishes + * a contract space because the apply path reads the user repo, not the + * descriptor. + */ +async function materialisePgvectorPinnedArtifacts(projectRoot: string): Promise { + const migrationsDir = join(projectRoot, 'migrations'); + mkdirSync(migrationsDir, { recursive: true }); + const space = pgvector.contractSpace; + if (!space) { + throw new Error('pgvector descriptor must declare a contractSpace'); + } + const baseline = space.migrations[0]; + if (!baseline) { + throw new Error('pgvector contract-space must ship at least one baseline migration'); + } + await emitContractSpaceArtifacts(migrationsDir, 'pgvector', { + contract: space.contractJson, + contractDts: '// rendered .d.ts for pgvector contract space\nexport interface Contract {}\n', + headRef: { hash: space.headRef.hash, invariants: [...space.headRef.invariants] }, + }); + await materialiseMigrationPackage(join(migrationsDir, 'pgvector'), baseline); + return migrationsDir; +} + +describe( + 'authoring: a pgvector literal default', + () => { + const originalCwd = process.cwd(); + const frameworkComponents = [postgres, postgresAdapter, pgvector] as const; + let testDir: string; + + const stack = createControlStack({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresDriver, + extensions: [pgvector], + }); + + beforeEach(() => { + testDir = createIntegrationTestDir(); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it( + 'is stored as the vector the codec decodes, created by dbInit, and read back from the column', + async () => { + const schemaPath = join(testDir, 'schema.prisma'); + writeFileSync( + schemaPath, + `model Document { + id Int @id @default(autoincrement()) + embedding pgvector.Vector(3) @default([0.5, 0.25, 0.125]) +} +`, + 'utf-8', + ); + process.chdir(testDir); + + const pslResult = await prismaContract('./schema.prisma', { + target: postgresPackRef, + createNamespace: postgresCreateNamespace, + }).source.load({ + composedExtensions: [pgvector.id], + composedExtensionContracts: new Map(), + authoringContributions: stack.authoringContributions, + codecLookup: stack.codecLookup, + controlMutationDefaults: stack.controlMutationDefaults, + resolvedInputs: [schemaPath], + capabilities: stack.capabilities, + }); + if (!pslResult.ok) { + throw new Error(JSON.stringify(pslResult.failure.diagnostics, null, 2)); + } + + const emitted = await emit( + enrichContract(pslResult.value, frameworkComponents), + stack, + sqlEmission, + sqlContractCanonicalizationHooks, + ); + const emittedContract = JSON.parse(emitted.contractJson) as Record; + const columns = ( + emittedContract as unknown as { + storage: { + namespaces: { + public: { + entries: { + table: { + Document: { columns: Record }; + }; + }; + }; + }; + }; + } + ).storage.namespaces.public.entries.table.Document.columns; + expect(columns['embedding']?.default).toEqual({ + kind: 'literal', + value: [0.5, 0.25, 0.125], + }); + + const migrationsDir = await materialisePgvectorPinnedArtifacts(testDir); + + await withDevDatabase(async ({ connectionString }) => { + const client = createControlClient({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresDriver, + extensions: [pgvector], + }); + try { + await client.connect(connectionString); + const apply = await client.dbInit({ + contract: emittedContract, + mode: 'apply', + migrationsDir, + }); + if (!apply.ok) { + throw new Error(`dbInit apply failed: ${JSON.stringify(apply.failure, null, 2)}`); + } + } finally { + await client.close(); + } + + await withClient(connectionString, async (raw) => { + await raw.query('INSERT INTO "Document" DEFAULT VALUES'); + const read = await raw.query<{ embedding: string }>( + 'SELECT "embedding"::text FROM "Document"', + ); + expect(read.rows.map((row) => row.embedding)).toEqual(['[0.5,0.25,0.125]']); + }); + }); + }, + timeouts.spinUpPpgDev, + ); + }, + timeouts.spinUpPpgDev, +); 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..e5dc246a9617 --- /dev/null +++ b/test/integration/test/cli-journeys/codec-psl-literal-defaults.e2e.test.ts @@ -0,0 +1,197 @@ +/** + * Journey: every literal `@default` is classified into a literal type, checked against the column's + * codec, 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 Account { + id Int @id @default(autoincrement()) + name String @default("anonymous") + small SmallInt @default(100) + count Int @default(100000) + balance BigInt @default(100000000000000099) + price Decimal @default(1.50) + ratio Float @default(NaN) + active Boolean @default(true) + meta Jsonb @default(json\`{ "plan": "free", "seats": 1 }\`) + scores Int[] @default([1, 2]) + docs Jsonb[] @default([json\`{}\`, json\`[]\`]) + expires DateTime @default(sql\`(now() + '3 days'::interval)\`) +} +`; + +interface SchemaVerifyResult { + readonly schema: { readonly issues: readonly unknown[] }; +} + +interface EmittedColumn { + readonly default?: { readonly kind: string; readonly value?: unknown }; +} + +interface EmittedTable { + 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: literal types for column 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({ + name: columns['name']?.default, + small: columns['small']?.default, + count: columns['count']?.default, + balance: columns['balance']?.default, + price: columns['price']?.default, + ratio: columns['ratio']?.default, + active: columns['active']?.default, + meta: columns['meta']?.default, + scores: columns['scores']?.default, + docs: columns['docs']?.default, + expires: columns['expires']?.default, + }).toEqual({ + name: { kind: 'literal', value: 'anonymous' }, + small: { kind: 'literal', value: 100 }, + count: { kind: 'literal', value: 100000 }, + balance: { kind: 'literal', value: '100000000000000099' }, + price: { kind: 'literal', value: '1.50' }, + ratio: { kind: 'literal', value: 'NaN' }, + active: { kind: 'literal', value: true }, + meta: { kind: 'literal', value: { plan: 'free', seats: 1 } }, + scores: { kind: 'literal', value: [1, 2] }, + docs: { kind: 'literal', value: [{}, []] }, + expires: { kind: 'function', expression: "(now() + '3 days'::interval)" }, + }); + + 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([]); + + 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', + 'name', + 'small', + 'count', + 'balance', + 'price', + 'ratio', + 'active', + 'meta', + 'scores', + 'docs', + ) + .build(); + expect(await rows(runtime.query(plan as never))).toEqual([ + { + id: 1, + name: 'anonymous', + small: 100, + count: 100000, + balance: 100000000000000099n, + price: '1.50', + ratio: Number.NaN, + active: true, + meta: { plan: 'free', seats: 1 }, + scores: [1, 2], + docs: [{}, []], + }, + ]); + } 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..ff0b2be396c1 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 @@ -320,7 +320,7 @@ withTempDir(({ createTempDir }) => { ); it( - 'a jsonb literal default round-trips clean through db verify --schema-only', + 'a jsonb, numeric and temporal literal default round-trip clean through db verify --schema-only', async () => { const ctx: JourneyContext = setupJourney({ connectionString: db.connectionString, @@ -331,21 +331,23 @@ withTempDir(({ createTempDir }) => { const infer = await runContractInfer(ctx); expect(infer.exitCode, `contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); + // Each default prints as the literal its codec reads back: a jsonb + // document as a `json` tag, a numeric keeping the trailing zero it was + // stored with, and a timestamp as the text its codec parses. + const printed = readContractPsl(ctx); + expect(printed).toContain('@default(json`{}`)'); + expect(printed).toContain('@default(1.50)'); + expect(printed).toContain('@default("2024-01-01 00:00:00")'); + // 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); + // emit succeeds and verify can run. Everything else is left exactly as + // infer printed it. + writeContractPsl(ctx, fixOneToOneBackRelation(printed)); const emit = await runContractEmit(ctx); expect(emit.exitCode, `contract emit\n${stripAnsi(emit.stderr)}`).toBe(0); - await expectVerifiesCleanAfterPull(ctx, 'Users.metadata'); + await expectVerifiesCleanAfterPull(ctx, 'Users.metadata, Users.fee, Users.joinedAt'); }, timeouts.spinUpPpgDev, ); 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 687ee911bf85..c99a6d0ef631 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 @@ -201,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") @@ -216,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-01 00:00:00") + jsonNull Jsonb? @default(json\`null\`) @@map("number_defaults") } @@ -234,10 +234,10 @@ withTempDir(({ createTempDir }) => { model SqlDefaults { id Int @id(map: "sql_defaults_pkey") textNull VarChar(32)? @default(dbgenerated("NULL::character varying")) - floatNaN Float @default("NaN") - floatNegInf Float @default("-Infinity") - realNaN Real @default("NaN") - decimalNaN Numeric @default("NaN") + floatNaN Float @default(NaN) + floatNegInf Float @default(-Infinity) + realNaN Real @default(NaN) + decimalNaN Numeric @default(NaN) timeWithZone Timetz @default("12:34:56+00") @@map("sql_defaults") @@ -301,13 +301,13 @@ withTempDir(({ createTempDir }) => { ); }); - describe('given list defaults with an element that has no PSL literal', () => { + describe('given a temporal list default', () => { const db = useDevDatabase({ onReady: (cs) => withClient(cs, (client) => client.query(RAW_LIST_DEFAULTS_SQL)), }); it( - 'infer prints them as dbgenerated, which emit accepts: a list column takes any storage default', + 'infer prints each element as the string its codec reads, which emit accepts', async () => { const ctx = setupJourney({ connectionString: db.connectionString, @@ -321,7 +321,7 @@ 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-01 00:00:00"]) @noCheck(elementNotNull) @@map("raw_list_defaults") } diff --git a/test/integration/test/cli-journeys/infer-roundtrip-fidelity/harness.ts b/test/integration/test/cli-journeys/infer-roundtrip-fidelity/harness.ts index 6c84de608bb6..b2b579c316cc 100644 --- a/test/integration/test/cli-journeys/infer-roundtrip-fidelity/harness.ts +++ b/test/integration/test/cli-journeys/infer-roundtrip-fidelity/harness.ts @@ -18,7 +18,9 @@ export const SEED_SQL = ` birth_date date, tags text[] NOT NULL DEFAULT '{}'::text[], labels text[] DEFAULT '{}'::text[], - metadata jsonb NOT NULL DEFAULT '{}'::jsonb + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + fee numeric(10,2) NOT NULL DEFAULT 1.50, + joined_at timestamp(3) NOT NULL DEFAULT '2024-01-01 00:00:00' ); CREATE INDEX users_metadata_gin_idx ON users USING gin (metadata); CREATE INDEX users_email_lower_idx ON users (lower(email)); 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..23f9122d4af7 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 @@ -212,16 +212,33 @@ 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 () => { +describe('PSL number defaults on codecs that accept no number literal', () => { + it('report the incompatibility on a Postgres bytea column', 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'); + ).resolves.toMatchObject({ + ok: false, + diagnostics: [ + expect.objectContaining({ + code: 'PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE', + message: + 'Field "Payload.data": pg/bytea@1 is not compatible with an i16 literal; it accepts string literals', + }), + ], + }); }); - 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('report the incompatibility on a SQLite datetime column', async () => { + const result = await authorSqliteContractFromPsl( + 'model Event {\n id Int @id\n at DateTime @default(0)\n}', + ); + expect(result.ok).toBe(false); + expect(result.ok ? [] : result.failure.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE', + message: + 'Field "Event.at": sqlite/datetime@1 is not compatible with an i8 literal; it accepts string literals', + }), + ]); }); }); diff --git a/upgrade-instructions/pending/literal-types-column-defaults/app/instructions.md b/upgrade-instructions/pending/literal-types-column-defaults/app/instructions.md new file mode 100644 index 000000000000..3eb29f140b54 --- /dev/null +++ b/upgrade-instructions/pending/literal-types-column-defaults/app/instructions.md @@ -0,0 +1,120 @@ +--- +changes: + - id: json-column-default-is-a-json-tag + summary: | + A `Json` or `Jsonb` column's literal default is written ``@default(json`{ "a": 1 }`)``. + A quoted string is now refused: a JSON column accepts a `json` literal, not a `string` one. + detection: + glob: "**/*.prisma" + matches: + - '(Json|Jsonb)(\[\])?\??\s+@default\("' + - id: decimal-and-float-defaults-are-numbers + summary: | + A `Decimal`, `Numeric` or `Float` column's literal default is written as a number, not as a + quoted string: `@default(1.50)`, `@default(NaN)`, `@default(-Infinity)`. + detection: + glob: "**/*.prisma" + matches: + - '(Decimal|Numeric(\([^)]*\))?|Float|Real)(\[\])?\??\s+@default\("' + - id: a-quoted-default-needs-a-column-that-takes-text + summary: | + Every literal `@default` is now checked against the column's codec by type. A quoted value on + a column whose codec does not accept a `string` literal is refused with + `PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE`, and a number too large for its column with the same + code instead of a decode failure. + detection: + glob: "**/*.prisma" + contains: + - "@default(" + - id: infer-prints-literals-where-it-printed-dbgenerated + summary: | + `prisma contract infer` now prints a temporal, numeric or JSON column default as the literal + its codec reads back, where it printed `dbgenerated("...")` or dropped the default before. + Re-run infer and review the diff before emitting. + detection: + glob: "**/*.prisma" + contains: + - "dbgenerated(" + - id: a-number-column-default-authored-as-text-now-stores-the-number + summary: | + A number-typed column whose default was authored as quoted text — `.default('0')` on a SQLite + `integer` column — now renders `DEFAULT 0` rather than `DEFAULT '0'`. Author the number. + detection: + glob: "**/*.{ts,mts,cts}" + matches: + - "\\.default\\(['\"]-?\\d+(\\.\\d+)?['\"]\\)" +--- + +## `json-column-default-is-a-json-tag` + +A column default is now a literal of a type, and the column's codec names the types it accepts. `pg/json@1`, `pg/jsonb@1`, `sqlite/json@1` and `arktype/json@1` accept a `json` literal, which is written as a tagged literal: + +| Before | After | +| --- | --- | +| `meta Jsonb @default("{}")` | ``meta Jsonb @default(json`{}`)`` | +| `meta Jsonb @default("{\"plan\":\"free\"}")` | ``meta Jsonb @default(json`{ "plan": "free" }`)`` | +| `docs Jsonb[] @default(["{}"])` | ``docs Jsonb[] @default([json`{}`])`` | + +The body inside the tag is the JSON document itself, so it needs none of the escaping a PSL string needed. A backtick body resolves `` \` `` and `\\` and nothing else, so `` json`{ "plan": "free" }` `` needs no escaping at all. + +A backslash has to survive twice — the backtick fence, then JSON — so a JSON string that needs one backslash is written with four: + +| In the schema | After the fence | JSON reads | +| --- | --- | --- | +| ``json`{ "re": "\\\\d+" }` `` | `{ "re": "\\d+" }` | the string `\d+` | + +Two backslashes are not enough: the fence turns them into one, and `\d` is not a JSON escape, so the body is refused with `PSL_INVALID_JSON_LITERAL` — as is any other body that is not a JSON document. + +`` @default(json`null`) `` stores the JSON value null, as `@default("null")` did. + +## `decimal-and-float-defaults-are-numbers` + +A number's literal type comes from what is written, so a quoted value is a `string` literal, which no numeric codec accepts: + +| Before | After | +| --- | --- | +| `price Decimal @default("1.50")` | `price Decimal @default(1.50)` | +| `ratio Float @default("NaN")` | `ratio Float @default(NaN)` | +| `ratio Float @default("-Infinity")` | `ratio Float @default(-Infinity)` | +| `prices Decimal[] @default(["1.50", "2"])` | `prices Decimal[] @default([1.50, 2])` | + +Trailing zeros are kept (`1.50` stays `1.50`), and leading zeros and the sign of zero are dropped (`007.50` is `7.50`, `-0.0` is `0.0`) — the same values these defaults have had. `NaN`, `Infinity` and `-Infinity` are written bare; they are number tokens in PSL, not identifiers. + +`Real` on SQLite and `Float` on a column whose codec refuses non-finite values (`sqlite/real@1`, `sql/float@1`, `pg/float@1`) do not accept `NaN` at all; that is now `PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE` rather than a decode failure at emit. + +## `a-quoted-default-needs-a-column-that-takes-text` + +Every literal default is classified into a type — `string`, `boolean`, `i8`/`i16`/`i32`/`i64`/`bigint` by the number's size, `decimal`, `float`, `json`, or a list of those — and checked against the column's codec before anything is decoded. Two families of schema that used to emit now fail at `contract emit`: + +```text +count Int @default("1") // pg/int4@1 is not compatible with a string literal +count Int @default(100000000000000099) // ... with an i64 literal; it accepts i8, i16, i32 literals +count Int @default(1.5) // ... with a decimal literal +payload Bytes @default(1234) // pg/bytea@1 ... it accepts string literals +``` + +The message names the column, the codec, the literal's type and what the codec accepts, so the fix is to write a literal of an accepted type, or to widen the column. A column whose codec accepts no literal default at all — `pg/enum@1` (write the member name), `pg/text-array@1`, every Mongo codec — reads `it accepts no literal defaults`; give it a `` sql`...` `` default instead. + +## `infer-prints-literals-where-it-printed-dbgenerated` + +`prisma contract infer` chooses the literal from the same declaration, so a default it used to print as a raw expression now prints as a literal: + +| Column | Before | After | +| --- | --- | --- | +| `jsonb DEFAULT '{}'::jsonb` | `@default(dbgenerated("'{}'::jsonb"))` | ``@default(json`{}`)`` | +| `timestamp(3) DEFAULT '2024-01-01 00:00:00'` | `@default(dbgenerated("'2024-01-01 00:00:00'::timestamp without time zone"))` | `@default("2024-01-01 00:00:00")` | +| `numeric(10,2) DEFAULT 1.50` | `@default("1.50")` | `@default(1.50)` | +| `float8 DEFAULT 'NaN'` | `@default("NaN")` | `@default(NaN)` | + +The printed schema emits and verifies clean against the same database, so the change is in the text, not in the contract. Re-run `prisma contract infer` and commit the new text; a default whose value the codec cannot read back — `timestamp DEFAULT 'infinity'` — still prints as `dbgenerated(...)`. + +## `a-number-column-default-authored-as-text-now-stores-the-number` + +A codec now reads the value shape of every literal type it accepts, so `sqlite/integer@1` reads the digit text `'0'` as the number `0`. A contract that authored a number column's default as a quoted string therefore renders `DEFAULT 0` where it rendered `DEFAULT '0'`, and a schema diff over DDL text will show it. Author the number: + +```diff +-priority: field.column(integerColumn).default('0'), ++priority: field.column(integerColumn).default(0), +``` + +Re-emit and run `prisma db verify --schema-only` against an existing database: if it reports the column's default, apply the change with `prisma db update` or a migration. diff --git a/upgrade-instructions/pending/literal-types-column-defaults/extension/instructions.md b/upgrade-instructions/pending/literal-types-column-defaults/extension/instructions.md new file mode 100644 index 000000000000..62670cb7425f --- /dev/null +++ b/upgrade-instructions/pending/literal-types-column-defaults/extension/instructions.md @@ -0,0 +1,150 @@ +--- +changes: + - id: codec-descriptors-declare-literal-types + summary: | + A codec descriptor declares `literalTypes`: the literal types its columns accept as a + `@default`. A descriptor that declares none accepts no literal default at all, so a column + typed by it takes only a ``sql`...` `` default. + detection: + glob: "**/*.{ts,mts,cts}" + contains: + - "CodecDescriptor" + - id: decode-json-accepts-every-named-shape + summary: | + Each literal type fixes the shape of the value it produces, so a codec's `decodeJson` must + accept the shape of every type its descriptor names, in addition to its own JSON form. + detection: + glob: "**/*.{ts,mts,cts}" + matches: + - '\bdecodeJson\(' + - id: default-literal-tag-entry-is-a-union + summary: | + `ControlDefaultLiteralTagEntry` is a union: the lowering entry it was, and a new entry that + names the literal type its body is read as. Narrow with `isDefaultLiteralTagLoweringEntry` + before reaching for `lower`. + detection: + glob: "**/*.{ts,mts,cts}" + contains: + - "ControlDefaultLiteralTagEntry" + - id: map-default-takes-literal-types + summary: | + `mapDefault(columnDefault, options)` writes a literal default through the column codec's + declared literal types; a target printer passes `literalTypes` and `list` per column. + detection: + glob: "**/*.{ts,mts,cts}" + matches: + - '\bmapDefault\(' + - id: psl-string-escaper-moved-to-the-framework + summary: | + `escapePslString` is exported from `@internal/framework-components/codec`, beside + `writeLiteral`, so a printed literal and the parser's decoder share one definition. + detection: + glob: "**/*.{ts,mts,cts}" + contains: + - "escapePslString" +--- + +## `codec-descriptors-declare-literal-types` + +`CodecDescriptor` gained an optional `literalTypes`. Declare the types a column of this codec accepts as a `@default` literal: + +```ts +import { + integerLiteralTypesUpTo, + type LiteralTypeDeclaration, +} from '@internal/framework-components/codec'; + +class MyInt4Descriptor extends PostgresCodecDescriptor { + override readonly literalTypes: readonly LiteralTypeDeclaration[] = + integerLiteralTypesUpTo('i32'); + // … +} +``` + +The ten type names are `string`, `boolean`, `i8`, `i16`, `i32`, `i64`, `bigint`, `decimal`, `float` and `json`. A written number's type comes from its own size and precision, never from the column, so `42` is an `i8` on every column and `100000000000000099` an `i64`; naming a chain is what makes a too-large value an incompatibility rather than a decode failure. `integerLiteralTypesUpTo(name)` gives the chain from `i8` up to and including `name`. + +A declaration may also name a list of element types, which lets a column that is not a list take a PSL list — this is how a vector column takes `@default([0.1, 0.2, 0.3])`: + +```ts +override readonly literalTypes: readonly LiteralTypeDeclaration[] = [ + { list: [...integerLiteralTypesUpTo('i64'), 'bigint', 'decimal'] }, +]; +``` + +Declaring nothing is a decision, not an omission: the column then accepts no literal default, and a `@default` on it is refused with `PSL_DEFAULT_LITERAL_TYPE_INCOMPATIBLE` naming `no literal defaults`. + +A descriptor that adapts another (`postgresCodec(...)`, `sqliteCodec(...)`) forwards the wrapped descriptor's declaration; a hand-written adapter must copy `literalTypes` across as it copies `traits` and `targetTypes`. + +## `decode-json-accepts-every-named-shape` + +Each literal type fixes the shape of the value it produces: `i8`, `i16` and `i32` give a JSON number; `i64` and `bigint` give digit text, because a JSON number rounds past 2^53; `decimal` gives decimal text with its trailing zeros; `float` gives the word `NaN`, `Infinity` or `-Infinity`; `string` gives the text; `boolean` a boolean; `json` the parsed document. Converting between those shapes and the codec's own stored form is the codec's job — no contract source branches per codec. + +So a codec whose stored form differs from a named type's shape widens `decodeJson`. `pg/int8@1` stores digit text and names `i8` to `i64`, so it takes a whole JSON number as well: + +```diff + decodeJson(json: JsonValue): bigint { +- if (typeof json !== 'string') { ++ if (typeof json !== 'string' && typeof json !== 'number') { + throw myError('RUNTIME.DECODE_FAILED', 'value must be a decimal string or a whole number'); + } + return decodeInt8(json); + } +``` + +The mirror case is a number-valued codec that names `i64`: it must read digit text, and refuse text past `Number.MAX_SAFE_INTEGER` with a message naming that limit rather than rounding. `isNumeralText` and `isNonFiniteText`, exported from `@internal/framework-components/codec`, recognise the text a numeric literal carries; use them instead of a local regex so the codec and the classifier cannot drift. + +A codec that validates in `decodeJson` still may: a literal the codec refuses is reported as `PSL_INVALID_DEFAULT_LITERAL` carrying the codec's own message. `contract infer` calls `decodeJson` on what it is about to write and falls back to the raw expression when it throws, so a value the codec cannot read is never printed as a literal. + +## `default-literal-tag-entry-is-a-union` + +`ControlDefaultLiteralTagEntry` is now: + +```ts +type ControlDefaultLiteralTagEntry = + | ControlDefaultLiteralTagLoweringEntry // usage, documentation, lower(...) + | ControlDefaultLiteralTagTypeEntry; // usage, documentation, literalType +``` + +A lowering entry is unchanged: it turns its body into a default itself, as `` sql`...` `` does. A literal-type entry names the literal type its body is read as, and the body then goes through the column's codec like any other literal — `jsonDefaultLiteralTagEntry()`, exported from `@internal/framework-components/codec`, is the `` json`...` `` tag that Postgres and SQLite register. + +Code that reads `entry.lower` must narrow first: + +```diff +-const lowered = entry.lower({ literal, context }); ++if (!isDefaultLiteralTagLoweringEntry(entry)) return readAsLiteral(entry.literalType, body); ++const lowered = entry.lower({ literal, context }); +``` + +`isDefaultLiteralTagLoweringEntry` is exported from `@internal/framework-components/control`; it is the only place the discriminating key is named. A registry built as `new Map([...])` with both kinds of entry needs its type argument spelled out: `new Map([...])`. + +Tags are grouped by their `documentation` when the `@default` attribute spec is built, so each tag's completion and signature help carries its own text. Give a new tag a description of its own rather than reusing another tag's. + +## `map-default-takes-literal-types` + +`mapDefault` (`@internal/family-sql/psl-infer`) no longer guesses a PSL form from the JavaScript type of the stored value. `DefaultMappingOptions` gained: + +- `literalTypes` — what the column's codec accepts, the same declaration the descriptor carries; +- `list` — whether the column is a list, whose elements are each written against the declaration's scalar types. + +A target printer builds those per column and passes them with the rest of the mapping: + +```ts +const result = mapDefault(columnDefault, { + ...defaultMapping, + literalTypes: literalTypesForPrintedColumn(column), + list: column.many === true, +}); +``` + +A literal no named type writes now comes back as `{ comment }` rather than an attribute, which is the signal to fall back to the raw database default. `formatLiteralValue` is gone, and with it the per-PSL-type formatter table a target printer used to supply (`PslDefaultValueFormat`, `formatPslValue`, `formatPslListLiteralValue`); delete those and read the declaration instead. + +## `psl-string-escaper-moved-to-the-framework` + +A printed `string` default and a hand-written PSL string must escape the same way, or what a printer writes does not parse back. `escapePslString` is exported from `@internal/framework-components/codec`, beside `writeLiteral`, and is the one definition. Delete a local copy and import it: + +```diff +-function escapePslString(value: string): string { +- return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +-} ++import { escapePslString } from '@internal/framework-components/codec'; +```