diff --git a/apps/telemetry-backend/src/prisma/contract.prisma b/apps/telemetry-backend/src/prisma/contract.prisma index c1da9b437032..2b3ee799173a 100644 --- a/apps/telemetry-backend/src/prisma/contract.prisma +++ b/apps/telemetry-backend/src/prisma/contract.prisma @@ -2,7 +2,7 @@ model TelemetryEvent { id BigInt @id @default(autoincrement()) - ingestedAt DateTime @default(now()) + ingestedAt DateTime(as: temporal) @default(now()) installationId String version String command String diff --git a/docs/Serverless Deployment Guide.md b/docs/Serverless Deployment Guide.md index 6085b16bd461..0a8d8c21a69c 100644 --- a/docs/Serverless Deployment Guide.md +++ b/docs/Serverless Deployment Guide.md @@ -110,7 +110,7 @@ This goes in `.env`, not `.dev.vars`. `.dev.vars` is for runtime worker secrets; A serverless client at module scope; a connection from `connect` in each request; SQL, ORM and transactions on that connection; cursor streaming. The full file is `examples/prisma-8-cloudflare-worker/src/worker.ts`. -The samples use extensionless relative imports and `./contract.d`, which need `moduleResolution: "bundler"` in `tsconfig.json`; Worker projects built with wrangler use that setting, and so does the example. On a runtime without a built-in `Temporal`, which today includes Cloudflare Workers at the example's compatibility date and Node.js 24, reading a `DateTime` column needs a Temporal polyfill: add `temporal-polyfill` and import `temporal-polyfill/full/global` before the first query, as the example does at the top of `worker.ts` and `seed.ts`. +The samples use extensionless relative imports and `./contract.d`, which need `moduleResolution: "bundler"` in `tsconfig.json`; Worker projects built with wrangler use that setting, and so does the example. On a runtime without a built-in `Temporal`, which today includes Cloudflare Workers at the example's compatibility date and Node.js 24, reading a column whose type reads `Temporal` values, such as `TimestamptzTemporal` or `DateTime(as: temporal)`, needs a Temporal polyfill: add `temporal-polyfill` and import `temporal-polyfill/full/global` before the first query, as the example does at the top of `worker.ts` and `seed.ts`. #### Module scope diff --git a/docs/architecture docs/adrs/ADR 246 - Option arguments and select templates for authoring helpers.md b/docs/architecture docs/adrs/ADR 246 - Option arguments and select templates for authoring helpers.md index 1af857e37daf..f149a346b8e4 100644 --- a/docs/architecture docs/adrs/ADR 246 - Option arguments and select templates for authoring helpers.md +++ b/docs/architecture docs/adrs/ADR 246 - Option arguments and select templates for authoring helpers.md @@ -1,6 +1,6 @@ # ADR 246 — Option arguments and select templates for authoring helpers -**Status:** Superseded 2026-10-22 by [ADR 275](ADR%20275%20-%20Client%20generators%20are%20field%20attributes%2C%20and%20a%20type%20alias%20is%20expanded%20into%20each%20column.md), except its two omission rules. Field presets, the `option` argument kind and the `select` template node were removed. The two omission rules, no empty `typeParams` and no empty `executionDefaults`, stand as canonicalization rules in [Data Contract](../subsystems/1.%20Data%20Contract.md#deterministic-emission). The content below is preserved as a historical record. +**Status:** Superseded 2026-10-22 by [ADR 275](ADR%20275%20-%20Client%20generators%20are%20field%20attributes%2C%20and%20a%20type%20alias%20is%20expanded%20into%20each%20column.md), except its two omission rules. Field presets, the `option` argument kind and the `select` template node were removed; [ADR 279](ADR%20279%20-%20Convenience%20types%20choose%20an%20explicit%20type%20from%20their%20arguments.md) gives a type choice its own `option` arguments, which only a type choice takes. The two omission rules, no empty `typeParams` and no empty `executionDefaults`, stand as canonicalization rules in [Data Contract](../subsystems/1.%20Data%20Contract.md#deterministic-emission). The content below is preserved as a historical record. ## At a glance diff --git a/docs/architecture docs/adrs/ADR 252 - An earlier Prisma version's schema is a contract source.md b/docs/architecture docs/adrs/ADR 252 - An earlier Prisma version's schema is a contract source.md index dbc122aa3821..3956b8c9617a 100644 --- a/docs/architecture docs/adrs/ADR 252 - An earlier Prisma version's schema is a contract source.md +++ b/docs/architecture docs/adrs/ADR 252 - An earlier Prisma version's schema is a contract source.md @@ -112,7 +112,7 @@ flowchart LR **Rules of the language live in the family authoring package.** `@internal/sql-contract-prisma7` holds everything that is true of the Prisma 7 language for the SQL family: blocks and attributes, relation pairing, junction tables, defaults, and the diagnostics. It knows nothing about a particular database and depends on no Prisma 7 package. -**Facts about the database come from the target through one binding.** `Prisma7TargetBinding` is the complete list of what a target must answer: the target pack and namespace factory, the datasource providers it accepts, the type map of what the earlier version creates for each scalar and native-type attribute, the native enum entity kind, the index types, the identifier byte limit, the junction relation field names, the "now" generator for each column codec, and how a literal default is read for a column whose codec does not take the written value. The Postgres target exports one instance. +**Facts about the database come from the target through one binding.** `Prisma7TargetBinding` is the complete list of what a target must answer: the target pack and namespace factory, the datasource providers it accepts, the type map of what the earlier version creates for each scalar and native-type attribute, the native enum entity kind, the index types, the identifier byte limit, the junction relation field names, the "now" generator for each column codec, how a literal default is read for a column whose codec does not take the written value, and the SQL expression of a default whose value the column's type holds but its codec cannot hold exactly. The Postgres target exports one instance. **The facade publishes one function** that wires the two together: diff --git a/docs/architecture docs/adrs/ADR 254 - Data types and casts.md b/docs/architecture docs/adrs/ADR 254 - Data types and casts.md index 198c38afc85a..ff3cfdf6801a 100644 --- a/docs/architecture docs/adrs/ADR 254 - Data types and casts.md +++ b/docs/architecture docs/adrs/ADR 254 - Data types and casts.md @@ -1,6 +1,6 @@ # ADR 254 — Data types and casts -Status: **Accepted.** Implemented for the Postgres, SQLite and MongoDB targets and their extensions. Amended 2026-10-20: a type-constructor argument that is a value names a data type and is admitted by the cast rule (see "Columns and type constructors"). Amended 2026-10-22: MongoDB values are written and cast as SQL values are, and enum members and discriminator values are read by one framework function in both families (see "Enum members and discriminator values"). +Status: **Accepted.** Implemented for the Postgres, SQLite and MongoDB targets and their extensions. Amended 2026-10-20: a type-constructor argument that is a value names a data type and is admitted by the cast rule (see "Columns and type constructors"). Amended 2026-10-22: MongoDB values are written and cast as SQL values are, and enum members and discriminator values are read by one framework function in both families (see "Enum members and discriminator values"). Amended by [ADR 279](ADR%20279%20-%20Convenience%20types%20choose%20an%20explicit%20type%20from%20their%20arguments.md) on 2026-10-22: a type constructor still names one codec, and a second kind of type declaration, a type choice, resolves to a type constructor from its arguments, so a column always stores the codec and parameters of a type constructor. ## At a glance @@ -103,7 +103,7 @@ A codec has four methods: A codec is built with its descriptor, its data type and the column's parameters of that type, and `toDataTypeValue` ends with `dataTypeValueOf(json)`, which calls `DataType.valueFrom` with those parameters. A Mongo codec is the same: its `toDataTypeValue` returns a `DataTypeValue`. The wire value is what the driver exchanges with the database, and it is not always text: a Postgres `bytea` codec writes a `Uint8Array`, a SQLite integer codec reads a number, a Mongo codec writes a BSON value. `fromWire` and `toWire` take the per-call context of [ADR 207](ADR%20207%20-%20Codec%20call%20context%20per-query%20AbortSignal%20and%20column%20metadata.md) and are asynchronous as [ADR 204](ADR%20204%20-%20Single-Path%20Async%20Codec%20Runtime.md) describes. A codec never reads `contract.json` and never sees PSL text. -Codecs of one type differ only in the application value. `pg/int8@1` holds a `bigint` and `pg/int8number@1` a `number`; `pg/timestamptz-date@1`, `pg/timestamptz-temporal@1` and `pg/timestamptz-string@1` hold a `Date`, a `Temporal.Instant` and text. A codec refuses in `fromDataTypeValue` a value its application value cannot hold exactly: `pg/int8number@1` refuses digit text past 2^53, and the `Temporal` and `Date` codecs refuse `infinity`, which only the text codecs hold. A limit one target's storage has belongs to that target's codec: SQLite cannot store `NaN`, so `sqlite/real@1`, and `sql/float@1` as SQLite adapts it, refuse it in `toWire` and in both value methods, and the SQLite driver refuses a `NaN` parameter no codec encoded, such as one in raw SQL. +Codecs of one type differ only in the application value. `pg/int8@1` holds a `bigint` and `pg/int8number@1` a `number`; `pg/timestamptz-date@1`, `pg/timestamptz-temporal@1` and `pg/timestamptz-string@1` hold a `Date`, a `Temporal.Instant` and text. A codec refuses in `fromDataTypeValue` a value its application value cannot hold exactly: `pg/int8number@1` refuses digit text past 2^53, and the `Temporal` and `Date` codecs refuse `infinity`, which only the text codecs hold. The `Date` codecs also refuse digits below a millisecond, which the text and `Temporal` codecs hold. A limit one target's storage has belongs to that target's codec: SQLite cannot store `NaN`, so `sqlite/real@1`, and `sql/float@1` as SQLite adapts it, refuse it in `toWire` and in both value methods, and the SQLite driver refuses a `NaN` parameter no codec encoded, such as one in raw SQL. A limit of the type itself is the type's, as "Values" describes. A codec checks only parameters it keeps for itself: `arktype/json@1` validates the document against its schema in `fromDataTypeValue` and `fromWire`. @@ -229,9 +229,9 @@ Every consumer of a stored default reads it through the column's data type and c - **Loading a contract.** When the CLI loads a contract, the SQL family's `deserializeContract` reads every literal default through its column's type and refuses a value the type does not hold, with `CONTRACT.VALIDATION_FAILED` naming the column. No later step meets an invalid stored value. - **Writing a default into DDL.** The planner hands the column's value to its codec, `fromDataTypeValue` then `toWire`, and the adapter writes the wire value as a SQL literal of the column's type. The `DEFAULT` clause therefore holds the text the codec writes for every row: on SQLite a default and a row the application wrote for the same instant are one text, and on Postgres a year before 1 is written with ` BC`. The codec's methods are asynchronous, so this runs where DDL is lowered, which is asynchronous too. - **Reading a default the database reports.** Postgres reports a default as a literal with a cast, `'2024-01-01 00:00:00+00'::timestamp with time zone`; SQLite reports the literal its `DEFAULT` clause holds. The target takes the literal's body out of that text, which is what its driver would hand `fromWire` for a row of the column, and introspection reads it with the column's codec, `fromWire` then `toDataTypeValue`. Introspection is asynchronous and receives the contract, so this runs there, and the tree it builds holds the value ([ADR 235](ADR%20235%20-%20The%20schema%20differ%20walks%20two%20derived%20schema%20IRs.md): each side of a diff is built in the form it is compared in). A default the database holds as an expression, such as `now()`, is compared as expression text. A contract default written as a `sql` expression that holds only a literal, such as `'hello'::bytea`, has its literal read the same way, so the two sides compare as values; text is compared only where no codec can read a literal, and then case and spaces inside quotes count. A literal the codec cannot read, such as a value written outside Prisma that the application value cannot hold, is reported as a difference naming the column and the codec's message. -- **Comparing defaults.** `db verify` and the planner compare two values of the column's type for equality. A `Date` default is a value of its column's type like any other: the database's default is read through the column's codec, and the comparison never goes through `toISOString()`. When no database is in reach, both sides come from contracts and no codec is needed. +- **Comparing defaults.** `db verify` and the planner compare two values of the column's type for equality. A `Date` default is a value of its column's type like any other: the database's default is read through the column's codec, and the comparison never goes through `toISOString()`. On a column whose codec reads a `Date`, both sides are read as the application reads them, so a difference below a millisecond is not reported. When no database is in reach, both sides come from contracts and no codec is needed. - **Rows the database returns as JSON.** An `include` or an aggregated child row set comes back as JSON. Each column enters it through its codec's JSON projection ([codec authoring guide](../../reference/codec-authoring-guide.md#rows-the-database-returns-as-json)), and the runtime reads it with `fromWire`, the same method that reads an ordinary row. A projection puts into the JSON a value `fromWire` reads exactly, giving the same application value as an ordinary row. On Postgres a type whose value would enter the JSON in another form than its row's text is cast to text, `CAST(x AS text)`, including `json`, `jsonb` and vectors: a document embedded as JSON would come back already parsed, so `fromWire` would parse the string document `"42"` a second time, and a vector widened to double precision would read `0.10000000149011612` where a row reads `0.1`. A type whose JSON value `fromWire` already reads, such as a number, a boolean, or text like a uuid or an enum label, enters as the column itself, and its `fromWire` reads both forms; `pg/char` enters with its padding removed. The runtime has one way to read what the database returns, and the stored form never has to match the database's spelling. -- **Inferring a contract.** `contract infer` has no contract, so it reads a reported default with the codec of the type constructor it prints for the column, `fromWire` of the literal's body then `toDataTypeValue`, and an enum column's through `pg/text@1`. It prints the value as "Printing" describes. +- **Inferring a contract.** `contract infer` has no contract, so it reads a reported default with the codec of the type constructor it prints for the column, `fromWire` of the literal's body then `toDataTypeValue`, and an enum column's through `pg/text@1`. It prints a reported literal only when the codec holds it exactly, and otherwise prints the raw expression. Where the column's data type reads the body's text, infer asks the codec's `fromDataTypeValue`, as `contract emit` asks of a written default, so a `Date` codec's refusal of digits below a millisecond reaches infer from the codec. A JSON number keeps its own check, because the JSON codec parses the document's text into JavaScript numbers when it reads the body, so a number such as `12345678901234567890` is rounded before any value reaches `fromDataTypeValue`. It prints the value as "Printing" describes. ## How a data type names its database type @@ -300,7 +300,7 @@ SQLite stores text, integers, reals and blobs, so its target declares `sqlite/te A type is stored as its own database type only when the database can record it. Declaring a SQLite column `DATETIME` or `JSON` would let introspection tell the types apart, but `JSON` gives the column numeric affinity, and SQLite would then store the document `"42"` as the integer 42. -**Comparing column types.** Two columns have the same type when they are stored as the same data type, with equal parameters in its normal form, and are both lists or both not. The rule is the same whether one column comes from the database or both come from contracts, as when the migration planner compares two contracts or the contract validator checks a many-to-many join table; without the stack's data types, the validator compares parameters as written. The database side's type is the one that claims what the database reports: by its texts, or for an enum by the kind of the type, read from the catalog with its schema and name, so the result does not depend on the search path. A column whose reported type no data type in the stack claims never equals another, and its mismatch names the reported type: `The database column is "point", which no data type in the configured stack declares.`, or for a SQLite column declared with no type, `The database column has no declared type.` After `ALTER COLUMN TYPE`, the Postgres planner checks the new type the same way: a type with texts by comparing `format_type` with its catalog text, an enum by identity, `atttypid = to_regtype(...)`, or the element type's `typarray` for a list. A column in the unbound namespace whose enum type lives outside `public` does not compare equal; this is a known limit. +**Comparing column types.** Two columns have the same type when they are stored as the same data type, with equal parameters in its normal form, and are both lists or both not. The rule is the same whether one column comes from the database or both come from contracts, as when the migration planner compares two contracts or the contract validator checks a many-to-many join table; without the stack's data types, the validator compares parameters as written. The database side's type is the one that claims what the database reports: by its texts, or for an enum by the kind of the type, read from the catalog with its schema and name, so the result does not depend on the search path. A column whose reported type no data type in the stack claims never equals another, and its mismatch names the reported type: `The database column is "point", which no data type in the configured stack declares.`, or for a SQLite column declared with no type, `The database column has no declared type.` After `ALTER COLUMN TYPE`, the Postgres planner checks the new type the same way: a type with texts by comparing `format_type` with its catalog text for the type as written (a bare `timestamptz` is `timestamp with time zone`, though its normal form counts the omitted precision as 6), an enum by identity, `atttypid = to_regtype(...)`, or the element type's `typarray` for a list. A column in the unbound namespace whose enum type lives outside `public` does not compare equal; this is a known limit. ## Columns and type constructors diff --git a/docs/architecture docs/adrs/ADR 257 - Scalar types are named after the target on every surface.md b/docs/architecture docs/adrs/ADR 257 - Scalar types are named after the target on every surface.md index 700371d9d39a..864dfbcb1bd4 100644 --- a/docs/architecture docs/adrs/ADR 257 - Scalar types are named after the target on every surface.md +++ b/docs/architecture docs/adrs/ADR 257 - Scalar types are named after the target on every surface.md @@ -2,6 +2,8 @@ Status: **Accepted** +**Amended by [ADR 279](ADR%20279%20-%20Convenience%20types%20choose%20an%20explicit%20type%20from%20their%20arguments.md) (2026-10-22):** where a stored type has several codecs, every one of them carries a suffix naming its kind of value, not only the second (`TimestamptzTemporal`, `TimestamptzJsDate`, `TimestamptzString`). The unsuffixed name of the concept, such as `DateTime`, `Date`, `Time` and `Duration` on PostgreSQL, belongs to a convenience type, a type choice that becomes one of the suffixed types. + ## Decision A scalar type's name says what the database stores, in the database's own vocabulary. Each type has one token, written exactly as its codec id writes it (`objectId` keeps its inner capital), and the three surfaces a user writes derive from it: the codec id is `/@`, the PSL name is the token with its first letter uppercased, and the TypeScript builder helper is `field.()`. Where a codec id already carries a name, the codec id is the source of the token, and no codec id is renamed to fit a surface name. diff --git a/docs/architecture docs/adrs/ADR 275 - Client generators are field attributes, and a type alias is expanded into each column.md b/docs/architecture docs/adrs/ADR 275 - Client generators are field attributes, and a type alias is expanded into each column.md index e35c813b3f59..a5f8a4cf12d1 100644 --- a/docs/architecture docs/adrs/ADR 275 - Client generators are field attributes, and a type alias is expanded into each column.md +++ b/docs/architecture docs/adrs/ADR 275 - Client generators are field attributes, and a type alias is expanded into each column.md @@ -5,6 +5,7 @@ **Builds on:** [ADR 158 — Execution mutation defaults](ADR%20158%20-%20Execution%20mutation%20defaults.md), [ADR 241 — Scalar types use the authoring type-constructor channel](ADR%20241%20-%20Scalar%20types%20use%20the%20authoring%20type-constructor%20channel.md), [ADR 269 — PSL mixins](ADR%20269%20-%20PSL%20mixins.md) **Supersedes:** the field-preset half of [ADR 170 — Pack-provided type constructors and field presets](ADR%20170%20-%20Pack-provided%20type%20constructors%20and%20field%20presets.md); [ADR 246 — Option arguments and select templates for authoring helpers](ADR%20246%20-%20Option%20arguments%20and%20select%20templates%20for%20authoring%20helpers.md), except its two omission rules **Amends:** [ADR 104](ADR%20104%20-%20PSL%20extension%20namespacing%20%26%20syntax.md), [ADR 158](ADR%20158%20-%20Execution%20mutation%20defaults.md), [ADR 169 — Declared applicability for mutation default generators](ADR%20169%20-%20Declared%20applicability%20for%20mutation%20default%20generators.md), [ADR 208 — Higher-order codecs for parameterized types](ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md), [ADR 212 — AST-bound codec resolution](ADR%20212%20-%20AST-bound%20codec%20resolution.md), [ADR 231](ADR%20231%20-%20Declarative%20attribute%20specifications.md), [ADR 241](ADR%20241%20-%20Scalar%20types%20use%20the%20authoring%20type-constructor%20channel.md), [ADR 249](ADR%20249%20-%20Central%20attribute-spec%20registry.md), [ADR 254](ADR%20254%20-%20Data%20types%20and%20casts.md) +**Amended by [ADR 279](ADR%20279%20-%20Convenience%20types%20choose%20an%20explicit%20type%20from%20their%20arguments.md) (2026-10-22):** the contributed `temporal.*` aliases are built on the convenience type `DateTime`, and their `String` and `JsDate` variants are not needed, because `now()` picks its generator from the field's final codec and a user writes `DateTime(as: string) @onCreate(now())` for another kind of value. **Amended 2026-10-21:** the TypeScript `types` key is renamed `databaseTypes` and registers database types only; a contributed alias is used as `field.column(type.temporal.UpdatedAt())`; a contributed alias carries no database default; Mongo's contributed aliases are on `Date`; the Mongo TypeScript builder gets no contributed aliases yet. Each change is marked "Amended 2026-10-21" below. **Amended 2026-10-22:** every SQL target gains the type constructor `sql.Char(n)`, because SQLite had no other way to write the `sql/char@1` column of the identifier presets; a preset call is refused with its exact rewrite, in PSL and, at run time, in TypeScript; the `number` and `object` argument kinds and an argument reference's `path` and `default` go with `option` and `select`; `@prisma/orm-postgres/contract-builder` and `@prisma/orm-sqlite/contract-builder` export `type`. Each change is marked "Amended 2026-10-22" below. **Amended 2026-10-23 by [ADR 310 — Each generated identifier has a type alias for its column](ADR%20310%20-%20Each%20generated%20identifier%20has%20a%20type%20alias%20for%20its%20column.md):** each generated identifier format gets a type alias for its column, such as `Ulid` for `Char(26)`. The change is marked "Amended 2026-10-23" below. @@ -79,6 +80,8 @@ For Postgres, `now` maps each date and time codec to its generator. That map exi | `pg/date-temporal@1`, `pg/date-string@1` | `plainDateNow`, `dateStringNow` | none | | `pg/time-temporal@1`, `pg/time-string@1`, `pg/timetz@1` | `plainTimeNow`, `timeStringNow`, `timetzNow` | `precision` | +(Amended 2026-10-23: every timestamp codec also passes `precision`, and its generator rounds down to it, so the client holds the value PostgreSQL stores instead of one PostgreSQL rounds up: the string and `Date` codecs, `pg/timestamptz-string@1`, `pg/timestamp-string@1`, `pg/timestamptz-date@1` and `pg/timestamp-date@1`, and the `Temporal` codecs, `pg/timestamptz-temporal@1`, `pg/timestamp-temporal@1` and `pg/timestamp-instant@1`. Only the date codecs, which have no precision, pass none.) + SQLite maps `sqlite/datetime@1` to `timestampNow`. Mongo maps `mongo/date@1` to `timestampNow`. `uuid`, `cuid`, `ulid`, `nanoid` and `ksuid` (new; a `ksuid` generator exists but no function names it) map every text-like codec of the target to their generators. The lists must be complete. Today the applicability lists name only `pg/text@1`, `sql/char@1` and `pg/uuid@1`, so `uuid()` on a SQLite `String` (`sqlite/text@1`) or a Postgres `VarChar` (`sql/varchar@1`) is refused; that is a defect this registry fixes. @@ -126,7 +129,7 @@ What this changes elsewhere: A pack's `type` namespace gains a type alias descriptor (new). Its output names a type constructor with fixed arguments and a generator function for each phase. The PSL interpreter and the TypeScript builder expand it exactly like a user alias. (Amended 2026-10-21: a contributed alias carries a type and generators only, and no database default. No contributed alias needs one, and a default would be a SQL concept on a framework descriptor that Mongo could not use. A database default can be added to the descriptor later, when a pack needs one, with its own check at assembly. Assembly checks the alias's fixed type parameters against its codec's parameter schema, and resolves its generator calls for the field length those parameters give, so a broken pack is refused with `CONTRACT.PACK_CONTRIBUTION_INVALID` before any schema is read.) -The Postgres target contributes `temporal.CreatedAt` and `temporal.UpdatedAt` on `TimestamptzTemporal`, with `String` and `JsDate` variants on the string and `Date` codecs (all new names). SQLite contributes `temporal.CreatedAt` and `temporal.UpdatedAt` on `DateTime`; Mongo the same on its `DateTime`. (Amended 2026-10-21: Mongo's aliases are on `Date`, because Mongo's `DateTime` constructor is deprecated. Both name the codec `mongo/date@1`, so the stored column is the same. The Postgres variants are named `temporal.CreatedAtString`, `temporal.UpdatedAtString`, `temporal.CreatedAtJsDate` and `temporal.UpdatedAtJsDate`.) Contributed aliases take no parameters, so a column with a precision is written with the attributes: +The Postgres target contributes `temporal.CreatedAt` and `temporal.UpdatedAt` on `TimestamptzTemporal`, with `String` and `JsDate` variants on the string and `Date` codecs (all new names). SQLite contributes `temporal.CreatedAt` and `temporal.UpdatedAt` on `DateTime`; Mongo the same on its `DateTime`. (Amended 2026-10-21: Mongo's aliases are on `Date`, because Mongo's `DateTime` constructor is deprecated. Both name the codec `mongo/date@1`, so the stored column is the same. The Postgres variants are named `temporal.CreatedAtString`, `temporal.UpdatedAtString`, `temporal.CreatedAtJsDate` and `temporal.UpdatedAtJsDate`.) (Amended by ADR 279: the Postgres aliases are built on the convenience type `DateTime`, and the variants are removed.) Contributed aliases take no parameters, so a column with a precision is written with the attributes: ```prisma model Event { @@ -193,12 +196,12 @@ The Prisma 7 reader builds the contract directly and writes no aliases. It reads - **Upgrade.** The upgrade instructions ship a script that rewrites stored contracts and migration snapshots into the expanded form and refreshes their hashes, as the removal of `@noCheck` did. Users then re-emit and run `db sign` against every database. The change has no operations; since ADR 309, `migration plan` writes such a migration with no operations, and `db migrate` applies it by moving the marker to the new hash. The instructions also give the PSL and TypeScript rewrites for presets. - **Breaking for extension authors.** A default function can no longer lower to a client generator: `ControlMutationDefaultEntry.lower` returns a database default only. An extension that generated a value through a default function, such as `slugid()`, contributes a generator function with a binding for each codec it fills instead. `printArguments` and `applicableCodecIds` are removed; a binding declares the codecs it fills and how `contract print` writes each generator. - **Diagnostics.** The `PSL_PRESET_*` diagnostics and the refusal of preset-only generators in `@default` are removed. `@default(uuid())` gets a refusal that names `@onCreate(uuid())`. (Amended 2026-10-22.) A preset call is `PSL_UNRESOLVED_REFERENCE` with its rewrite. Mongo's `PSL_PRESET_ON_VARIANT_FIELD` and `PSL_PRESET_CONFLICT`, which also fire for `@onCreate` and `@onUpdate`, are renamed `PSL_GENERATOR_ON_VARIANT_FIELD` and `PSL_GENERATOR_CONFLICT`. -- **Less code.** The `fieldPreset` descriptor kind, `instantiateAuthoringFieldPreset`, the `authoring.field` namespace, the `option`, `number` and `object` argument kinds, the `select` template node, the `path` and `default` of an argument reference, the PSL text parsers only those kinds used, the temporal preset builders, `field.generated(...)` with the `@internal/ids` spec helpers, and the preset print search are removed (amended 2026-10-22). Only the `value` argument kind is left. The language server loses its field-preset completion and hover branches. +- **Less code.** The `fieldPreset` descriptor kind, `instantiateAuthoringFieldPreset`, the `authoring.field` namespace, the `option`, `number` and `object` argument kinds, the `select` template node, the `path` and `default` of an argument reference, the PSL text parsers only those kinds used, the temporal preset builders, `field.generated(...)` with the `@internal/ids` spec helpers, and the preset print search are removed (amended 2026-10-22). Only the `value` argument kind is left. The language server loses its field-preset completion and hover branches. (Amended by [ADR 279](ADR%20279%20-%20Convenience%20types%20choose%20an%20explicit%20type%20from%20their%20arguments.md): a type choice declares its own `option` arguments, written as bare names; only a type choice takes them, and a type constructor still takes only `value` arguments.) ## ADRs this changes - **ADR 170** — its field-preset half is superseded: the preset examples, the preset parts of "Why we need this" and "What we are doing", "What can a preset do?" and "How this fits TS and PSL together". Type constructors, names, namespaces and short names stand. -- **ADR 246** — superseded. The `option` argument kind and the `select` template exist only for temporal presets and are removed. Its two omission rules (no empty `executionDefaults`, no empty `typeParams`) stand as canonicalization rules. +- **ADR 246** — superseded. The `option` argument kind and the `select` template exist only for temporal presets and are removed. Its two omission rules (no empty `executionDefaults`, no empty `typeParams`) stand as canonicalization rules. (Amended by [ADR 279](ADR%20279%20-%20Convenience%20types%20choose%20an%20explicit%20type%20from%20their%20arguments.md): a type choice declares its own `option` arguments, written as bare names; only a type choice takes them, and a type constructor still takes only `value` arguments.) - **ADR 169 (Declared applicability for mutation default generators)** — §1 is amended: applicability is still keyed by codec id, but it is declared on the generator function entry, not on the generator. The §3 note and the `@default(uuid(7))` example are superseded by `@onCreate`. - **ADR 158** — §1's "onUpdate is future work" is done; §3's compatibility check is the codec map above; §4's precedence now covers `@default` with `@onCreate` in PSL. - **ADR 104** — "Named storage type for reuse" and "Enforceable coupling" are amended: a named type is expanded at authoring, and checks run on each expanded column. diff --git a/docs/architecture docs/adrs/ADR 279 - Convenience types choose an explicit type from their arguments.md b/docs/architecture docs/adrs/ADR 279 - Convenience types choose an explicit type from their arguments.md index 0dc2859b2a2f..233c78c0c599 100644 --- a/docs/architecture docs/adrs/ADR 279 - Convenience types choose an explicit type from their arguments.md +++ b/docs/architecture docs/adrs/ADR 279 - Convenience types choose an explicit type from their arguments.md @@ -113,7 +113,7 @@ Each place that needs a field's codec resolves the field's call first, including ### What the tools write `contract infer` reads a database and `contract print` reads a contract, and neither knows which kind of type the user wrote. Both write a convenience call wherever one gives the column's explicit type, and the explicit type otherwise: -1. For each database type, one explicit type is marked `inferred`: the one its convenience default gives. Infer and the Prisma 7 schema reader pick that type for a column. +1. For each database type, one explicit type is marked `inferred`: the one its convenience default gives. Infer and the Prisma 7 schema reader pick that type for a column. If that type cannot read the column's own default, such as `infinity` for a `Date`, infer picks the first row, in the order the convenience type declares its rows, with the same storage whose type can, so it does not write a field whose default fails to read where some row reads it. The rows' order is therefore infer's order of preference. Infer reads only the default, not the rows, so a column whose rows hold such a value is still written with the default type. 2. To write the call for an explicit type, the tools find the row that names it, build the arguments from the row, and drop those equal to the defaults. 3. They resolve the call again and keep it only if it gives the same codec and parameters. @@ -186,7 +186,7 @@ The PostgreSQL target ships two type aliases (ADR 275), in the namespace `tempor - A type constructor still names exactly one codec (ADR 254). A type choice is a second kind of type declaration, which resolves to a type constructor. - Every explicit type of a database type with several codecs carries a suffix for its value (ADR 257); bare concept names belong to convenience types. - The `temporal.*` aliases (ADR 275) are built on `DateTime` and need no variants for other values. -- The editor's hover lists a convenience type's options and the explicit type each combination gives. +- The editor's hover on a convenience call names the explicit type that call is, such as `DateTime(as: temporal)` is exactly `TimestamptzTemporal`, and lists the explicit type each combination gives. ## Not decided here @@ -213,4 +213,4 @@ The PostgreSQL target ships two type aliases (ADR 275), in the namespace `tempor **A Prisma-owned value for calendar dates, times and durations, or a branded string.** A class is one more date API, does not survive JSON, and duplicates Temporal. A branded string forces every write through a helper or a cast and catches nothing the codec doesn't. -**Infer the string types for columns a `Date` cannot hold exactly.** Nothing is lost, but the schema would differ from what a user would write for the same model; infer takes the default. +**Infer the string types for columns whose values have more digits than a `Date` holds.** Nothing is lost, but the schema would differ from what a user would write for the same model; infer takes the default, which reads those values with the extra digits dropped. A default the type cannot read at all is different: the row would fail to read, so infer picks a type that reads it. diff --git a/docs/glossary.md b/docs/glossary.md index d569c81c4cee..2390e59b894e 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -213,6 +213,14 @@ A specific feature that a database may or may not support (e.g., `RETURNING` cla Handles the translation between JavaScript values and database values. When you read a timestamp from the database, a codec converts it to a JavaScript `Date`; when you write it back, the codec converts it the other way. Extensions provide codecs for specialized types like vectors or geometries. +### Convenience type + +A type, in PSL or the TypeScript builder (`type.DateTime(...)`), named for what a field means in the application, such as `DateTime`, `Date`, `Time` or `Duration`, that becomes exactly one explicit type. Optional arguments choose which, with defaults. The contract stores only the explicit type. See ADR 279. + +### Explicit type + +A type, in PSL or the TypeScript builder, that fixes one codec, so it fixes both the column type and the value the application reads. It is named after the database, with a suffix for the kind of value when a column type has several codecs: `TimestamptzTemporal`, `TimestamptzJsDate`, `DateString`. See ADR 257 and ADR 279. + ### Marker A small record stored in the database that tracks which contract the database is currently migrated to. Before running queries or migrations, Prisma 8 checks that the marker matches the contract the application is carrying. This catches situations where the database and application have drifted out of sync — for example, if a migration was run but the application wasn't redeployed. @@ -227,6 +235,10 @@ Where a database object's name comes from. An object is **wire-named** when Pris Naming mode is independent of [control policy](architecture%20docs/adrs/ADR%20224%20-%20Control%20Policy%20—%20framework-locked%20vocabulary%20and%20family-owned%20dispatch.md), which answers a different question: whether Prisma 8 may write to the object at all. +### Type choice + +The declaration a pack contributes for a convenience type: its option arguments with their values and defaults, the arguments it passes on, and a table from each allowed combination of option values to the name of an explicit type constructor. See ADR 279. + ### Wire Name The name Prisma 8 derives for an object it names itself: your prefix, an underscore, and eight hex characters of a hash over the object's content — `user_email_idx_46df9cad`. Because the suffix is content-addressed, an unchanged definition always produces the same name, and a name match is a content match. Renaming the prefix while leaving the definition alone keeps the suffix, which is how a rename is recognized as a rename rather than a drop and a create. See [ADR 234](architecture%20docs/adrs/ADR%20234%20-%20Content-addressed%20wire%20names%20for%20Postgres-normalized%20objects.md). diff --git a/docs/reference/codec-authoring-guide.md b/docs/reference/codec-authoring-guide.md index 587e695ef9ce..b16b22114720 100644 --- a/docs/reference/codec-authoring-guide.md +++ b/docs/reference/codec-authoring-guide.md @@ -592,7 +592,9 @@ A codec's `paramsSchema` is its data type's `params`, referenced and never resta A codec with parameters of its own sets `paramsSchema` to `dataType.params.and(ownKeys)`, or to its own keys alone when the data type declares no `params`, as `arktype/json@1` does. `postgresCodec(...)` and `sqliteCodec(...)` take the adapted codec's params schema from the data type they are given. -A type constructor maps its arguments onto the parameters and names only the codec; the data type follows from the codec. Every argument of a type constructor is a value argument, `{ kind: 'value', dataType }`, and assembly refuses any other kind with `CONTRACT.PACK_CONTRIBUTION_INVALID`. A value argument is a value of the named data type, read and cast as an attribute argument is (ADR 254), so `Vector(1536)` reads `1536` as a `pg/int2` and casts it to `pg/int4`, and `Vector("1536")` is refused. On Postgres every length, precision, scale and SRID is a `pg/int4`, the type of a typmod. A value argument carries no bounds, because the parameter's schema checks the value it gives. Assembly refuses a value argument whose data type no component registers (`CONTRACT.DATA_TYPE_UNREGISTERED`) or that no contract source can write (`CONTRACT.DATA_TYPE_NOT_WRITABLE`). In the TypeScript builder, the pack that registers a data type declares the TypeScript value an argument of it takes as `__dataTypeArguments` in its type, such as `{ 'pg/int4': { argument: number; parameter: number } }`: `argument` is what an author passes, and `parameter` is the type parameter the data type's reader gives for it, and `contract print` writes each argument as a literal of its data type. `inferred: true` marks the constructor written for a column of the data type when no schema names one: `contract infer` and the Prisma 7 reader write it. `contract print` writes it when it produces the column's codec, and otherwise the first constructor in the stack that does, so a `timestamptz` column with the `Temporal` codec prints `TimestamptzTemporal`, which the PostgreSQL target lists before its second name `DateTime`. It is called with the fewest arguments that give the column's parameters in normal form, so a `numeric(10,0)` column is written `Numeric(10)`. The Prisma 7 reader builds a contract rather than printing PSL, so it passes every parameter the Prisma 7 column gives. At most one constructor per data type carries the mark. A data type with no marked constructor, such as `pg/tsquery`, is still recognised, but `contract infer` cannot write a column of it and fails with `CONTRACT.INFER_COLUMN_TYPE_UNCLAIMED`. +A type constructor maps its arguments onto the parameters and names only the codec; the data type follows from the codec. Every argument of a type constructor is a value argument, `{ kind: 'value', dataType }`, and assembly refuses any other kind with `CONTRACT.PACK_CONTRIBUTION_INVALID`. A value argument is a value of the named data type, read and cast as an attribute argument is (ADR 254), so `Vector(1536)` reads `1536` as a `pg/int2` and casts it to `pg/int4`, and `Vector("1536")` is refused. On Postgres every length, precision, scale and SRID is a `pg/int4`, the type of a typmod. A value argument carries no bounds, because the parameter's schema checks the value it gives. Assembly refuses a value argument whose data type no component registers (`CONTRACT.DATA_TYPE_UNREGISTERED`) or that no contract source can write (`CONTRACT.DATA_TYPE_NOT_WRITABLE`). In the TypeScript builder, the pack that registers a data type declares the TypeScript value an argument of it takes as `__dataTypeArguments` in its type, such as `{ 'pg/int4': { argument: number; parameter: number } }`: `argument` is what an author passes, and `parameter` is the type parameter the data type's reader gives for it, and `contract print` writes each argument as a literal of its data type. `inferred: true` marks the constructor written for a column of the data type when no schema names one: `contract infer` and the Prisma 7 reader write it. `contract print` writes it when it produces the column's codec, and otherwise the first constructor in the stack that does, so a `timestamptz` column with the `Temporal` codec prints `TimestamptzTemporal`. It is called with the fewest arguments that give the column's parameters in normal form, so a `numeric(10,0)` column is written `Numeric(10)`. The Prisma 7 reader builds a contract rather than printing PSL, so it passes every parameter the Prisma 7 column gives. At most one constructor per data type carries the mark. A data type with no marked constructor, such as `pg/tsquery`, is still recognised, but `contract infer` cannot write a column of it and fails with `CONTRACT.INFER_COLUMN_TYPE_UNCLAIMED`. + +A type choice (`kind: 'typeChoice'`) is a second kind of type declaration, for a type named after what a field means rather than a column type ([ADR 279](../architecture%20docs/adrs/ADR%20279%20-%20Convenience%20types%20choose%20an%20explicit%20type%20from%20their%20arguments.md)). It names no codec. It declares option arguments, `{ kind: 'option', name, values, default, optional: true }`, written as bare names (the option kind exists only for type choice arguments), and value arguments it passes on by name, such as `precision`; and `choices`, a table whose rows map one value of each option to the name of a type constructor the same component contributes at the same level of the namespace, with `defaults` for passed-on arguments the call leaves out. A call resolves to its row's constructor, called with the passed-on arguments, so the contract records only that constructor's codec and parameters. A combination with no row, and a passed-on argument the chosen constructor does not take, are refused at the argument. Every argument is optional, so a type choice is also written bare, as its default row. Assembly refuses a table that names a constructor the component does not contribute beside the choice, a value or default an option does not list, defaults with no row, a row default for an argument the choice does not pass on, a row whose constructor requires an argument that the row gives no default and the choice does not pass on, and a passed-on argument of another data type than its constructor's. The order of the rows matters in one place: when the default type of a column cannot read the column's own default, `contract infer` takes the first row with the same storage whose type can. A type choice is never marked `inferred`. Where a call of a type choice becomes the constructor that `contract print` or `contract infer` writes for a column, they write the type choice call instead, by name, with only the arguments that differ from its defaults, and only when that call gives the same column; otherwise they write the constructor. In the TypeScript builder its `type.*` helper takes the same arguments as one object keyed by name, such as `type.DateTime({ as: 'string', precision: 6 })`, and is typed as one overload per row. ```ts import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 6dacb2ef1808..bdc4d1114278 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -243,7 +243,7 @@ The migration-file CLI (`prisma migration`) received a flag it does not recognis ### CONTRACT.ARGUMENT_INVALID -A builder or helper on the contract-authoring surface is called with a bad argument: a composed authoring helper receives too many arguments or a malformed trailing options object, `field.sql({ id })` / `field.sql({ unique })` is used without a matching inline `.id(...)` / `.unique(...)` declaration, `model("Name", ...)` is called without a model definition, a nanoid ID generator is given a size outside 2–255, a TypeScript `type.*` type constructor receives an argument its data type's parameter schema refuses (meta: `helperPath`, `argumentIndex`; see `CONTRACT.TYPE_PARAMS_INVALID` for how PSL and the contract build report the same bound), or an authored index combines its cross-field parameters invalidly (fields and an expression together or neither, an expression without `name:`/`map:`, or `map:` combined with `name:`). Also raised by the Mongo TypeScript builder when two variants of a polymorphic base declare the same discriminator value (meta: `modelName`, `value`, `variants`, `reason: 'duplicate-discriminator-value'`). Also raised by the Mongo TypeScript builder for `.mongo({ field })` on a field of a value object, whose fields are stored under their own names (meta: `valueObjectName`, `fieldName`, `reason: 'value-object-field-stored-name'`). Also raised by the Mongo TypeScript builder for a model field stored as `__proto__`, which JavaScript reads as the prototype of an object rather than as a key (meta: `modelName`, `fieldName`, `reason: 'stored-name-unsupported'`); the Mongo PSL interpreter reports the same case at the field as `PSL_STORED_NAME_UNSUPPORTED`, and reading `contract.json` back refuses a field or embed relation stored as `__proto__`. Also raised when a contract targets SQLite and declares an expression or partial index: SQLite's namespace construction rejects `expression:`/`where:` because the target does not support them. Also raised when a column with a literal default has type parameters that its codec does not accept beyond its data type's parameters, which the build checks first with `CONTRACT.TYPE_PARAMS_INVALID` (meta: `modelName`, `fieldName`, `codecId`, `reason: 'type-params-invalid'`; the codec's error is the `cause`). Also raised by a SQL contract build for a relation's `index: ""` that names nothing on its table, matches more than one index, unique constraint or primary key of its table, by the `name` or `map` the source gave it or an index's stored name, names an object whose first columns are not the foreign key's columns, or names an index of a type whose body is rendered from its options rather than its columns, such as Postgres's `fullText` (meta: `reason: 'foreign-key-index-unresolved'`, `namespaceId`, `tableName`, `columns`, `index`); a PSL source reports it as `PSL_INVALID_RELATION_ATTRIBUTE` at the relation. Also raised when two identical indexes of one table are both named with `name:`, whose wire names the planner could not tell apart (meta: `tableName`, `indexes`). Also raised by `requireSqlExpression` when a TypeScript builder field that takes raw SQL (`index` `where` or `expression`, `check` `expression`, `fullTextIndex` `where`, a policy's `using` or `withCheck`) holds something other than a `sql` value at run time, which only JavaScript that is not type-checked can pass (an object that carries the `sql` value marker but whose `text` is not a string counts as something else): ``Index "post_user_active" where must be a sql`...` value.`` (meta: `what`, which names the object and the field: `Index "" where`, `Index "" expression`, `Check "" expression`, `Full-text index "" where`, `Policy "" using`, `Policy "" withCheck`; an index or check with no `name` or `map` is named by its model, as in `Index on "" where`, and a full-text index with neither by its fields in order, as in `Full-text index on fields "", "" where`). Also raised by `sqlTextOf` when a migration-file function that takes a SQL fragment gets something that is neither a string nor a `sql` value: ``createIndex "post_user_active" where must be a string or a sql`...` value.`` (meta: `what`, which names the function, the object and the argument: `fn expression`, `checkExpression "" expression`, `addCheckConstraint "" expression`, `createIndex "" expression`, `createIndex "" where`, `createRlsPolicy "" using`, `createRlsPolicy "" withCheck`, `alterColumnType ""."" using`, `addColumnWithTemporaryDefault "
"."" temporaryDefault`, and on SQLite `addColumn "
"."" default`, `recreateTable "
"."" default` and `recreateTable "
" copyExpressions.`); this one is raised when the migration file runs, not while the contract is built. Also raised by `replaceStoredValue` and `removeStoredValue` in a Postgres or SQLite `migration.ts` for a value that is not a string or a finite number, such as `null`, which matches no row: `replaceStoredValue({ table: "task", column: "state" }): from must be a string or a number; got null` (meta: `what`, the function and the argument, as in `replaceStoredValue from`, `replaceStoredValue to` or `removeStoredValue value`), raised when the migration file runs. Raised too when the call's `codecRef` names a codec that writes the value as neither a string nor a number: `Codec writes the stored value as neither a string nor a number, which a migration cannot compare` (meta: `what: 'stored value'`, `codecId`). Otherwise raised while authoring/building the contract, before emit. Payload: varies per site. The MongoDB contract builder also raises it for a polymorphism rule every source shares, worded for TypeScript (meta: `modelName`, and `reason`): a discriminator that is optional, a list or not text (`discriminator-optional`, `discriminator-list`, `discriminator-not-text`, with `fieldName`); a variant's value the discriminator field cannot hold, or that is not a member of the field's enum (`discriminator-value-unreadable`, `discriminator-value-not-a-member`, with `baseModel` and `fieldName`); a model with both a discriminator and a base (`discriminator-and-base`); a discriminator no model names as its base (`discriminator-without-variants`); a listed variant no model declares (`listed-variant-missing`, with `variant`); and a base that has no discriminator, that the contract does not declare, or that names no value for the variant (`base-without-discriminator`, `base-unknown`, `variant-without-value`, with `baseModel`); and a variant field that restates a base field with a different stored name, type, nullability or list shape (`restated-base-field-differs`, with `fieldName`, `baseModel` and `differences`), which Mongo PSL reports as `PSL_VARIANT_FIELD_DIFFERS_FROM_BASE` at the field. Mongo PSL reports the same rules with its own codes, at the `@@discriminator` or `@@base` attribute. `mongoContract(path, options)` raises it when `options.target` is missing (`reason: 'target-pack-missing'`, with `schemaPath`); pass `{ target: mongoTargetPack }`, or name the schema in `defineConfig({ contract })` from `@prisma/orm-mongo/config`. +A builder or helper on the contract-authoring surface is called with a bad argument: a composed authoring helper receives too many arguments or a malformed trailing options object, `field.sql({ id })` / `field.sql({ unique })` is used without a matching inline `.id(...)` / `.unique(...)` declaration, `model("Name", ...)` is called without a model definition, a nanoid ID generator is given a size outside 2–255, a TypeScript `type.*` type constructor receives an argument its data type's parameter schema refuses (meta: `helperPath`, `argumentIndex`; see `CONTRACT.TYPE_PARAMS_INVALID` for how PSL and the contract build report the same bound), or an authored index combines its cross-field parameters invalidly (fields and an expression together or neither, an expression without `name:`/`map:`, or `map:` combined with `name:`). A TypeScript `type.*` helper of a type choice, such as PostgreSQL's `type.DateTime`, takes its arguments as one object keyed by name, and raises it when given anything else (` takes its arguments as one object with the keys , such as ({
"."" using`, `addColumnWithTemporaryDefault "
"."" temporaryDefault`, and on SQLite `addColumn "
"."" default`, `recreateTable "
"."" default` and `recreateTable "
" copyExpressions.`); this one is raised when the migration file runs, not while the contract is built. Also raised by `replaceStoredValue` and `removeStoredValue` in a Postgres or SQLite `migration.ts` for a value that is not a string or a finite number, such as `null`, which matches no row: `replaceStoredValue({ table: "task", column: "state" }): from must be a string or a number; got null` (meta: `what`, the function and the argument, as in `replaceStoredValue from`, `replaceStoredValue to` or `removeStoredValue value`), raised when the migration file runs. Raised too when the call's `codecRef` names a codec that writes the value as neither a string nor a number: `Codec writes the stored value as neither a string nor a number, which a migration cannot compare` (meta: `what: 'stored value'`, `codecId`). Otherwise raised while authoring/building the contract, before emit. Payload: varies per site. The MongoDB contract builder also raises it for a polymorphism rule every source shares, worded for TypeScript (meta: `modelName`, and `reason`): a discriminator that is optional, a list or not text (`discriminator-optional`, `discriminator-list`, `discriminator-not-text`, with `fieldName`); a variant's value the discriminator field cannot hold, or that is not a member of the field's enum (`discriminator-value-unreadable`, `discriminator-value-not-a-member`, with `baseModel` and `fieldName`); a model with both a discriminator and a base (`discriminator-and-base`); a discriminator no model names as its base (`discriminator-without-variants`); a listed variant no model declares (`listed-variant-missing`, with `variant`); and a base that has no discriminator, that the contract does not declare, or that names no value for the variant (`base-without-discriminator`, `base-unknown`, `variant-without-value`, with `baseModel`); and a variant field that restates a base field with a different stored name, type, nullability or list shape (`restated-base-field-differs`, with `fieldName`, `baseModel` and `differences`), which Mongo PSL reports as `PSL_VARIANT_FIELD_DIFFERS_FROM_BASE` at the field. Mongo PSL reports the same rules with its own codes, at the `@@discriminator` or `@@base` attribute. `mongoContract(path, options)` raises it when `options.target` is missing (`reason: 'target-pack-missing'`, with `schemaPath`); pass `{ target: mongoTargetPack }`, or name the schema in `defineConfig({ contract })` from `@prisma/orm-mongo/config`. ### CONTRACT.AGGREGATE_DESCRIPTOR_AMBIGUOUS @@ -551,6 +551,8 @@ Namespaces are declared (contract-level list or a model-level `namespace`) on a A composed pack's contribution is malformed or collides with another contribution; this is the extension-author-facing bucket. Covers: entity types colliding with reserved helper keys, duplicate entity kinds or index-type registrations, a registered entity kind with no `lowerEntityHandles` lowering, an invalid `indexTypes` shape, entries-slot collisions between a model attribute and a block entry kind, a model attribute that lowers to a malformed index, bad authoring-helper paths, a codec registered with an entity-ref arg but no `columnFromEntity` hook, and print-time contribution mismatches (a block keyword with no PSL block descriptor, or a descriptor whose discriminator disagrees with the block's kind). Raised during contract authoring/lowering and PSL printing. +A contributed type choice (`kind: 'typeChoice'`, such as PostgreSQL's `DateTime`) is refused when the stack is assembled, by the control stack and by the TypeScript builders, when its table cannot be resolved: `Type choice "" contributed by "" chooses "", which is not a type constructor "" registers beside it.`, for a row naming a constructor no component or only another component contributes, a row whose combination or an option default the option does not list, two rows for one combination (`… has two rows for as: ; each combination chooses one type.`), a combination of defaults with no row (`… has no row for its defaults as: , so it cannot be written bare.`), a parameter default for an argument it does not pass on or that the chosen constructor does not take (`… gives a default for "" to "", which takes no argument of that name.`), a passed-on argument of another data type than the chosen constructor takes, or a row whose chosen constructor requires an argument the row gives no default for, since a type choice is also written bare (`… has a row for as: that chooses "", which requires "", and gives it no default.`). Payload: `packId`, `contribution`. + A contributed type alias (`kind: 'typeAlias'`, such as `temporal.CreatedAt`) is refused when the stack is assembled, by the control stack and by the TypeScript builders, so PSL and TypeScript refuse the same packs. Its type params may not reference an argument, because a type alias takes none: `Invalid authoring type alias "" contributed by descriptor "". output.type.typeParams. references an argument, but a type alias takes none. Write the value itself.` Each generator call must resolve for the alias's codec: `Type alias "" calls now() on create, which does not apply to its codec "".`, `…, which its codec "" refuses: `, or `…, but no component declares the generator function "".` Payload: `packId`, `contribution`, `reason`, `keyword`, `paramName`, `codecId`. The Mongo `defineContract` builder raises it for a model field, a value object field or an enum whose codec represents a data type that is not a MongoDB data type (`reason: 'data-type-not-mongo'`): the pack declared the data type without `mongoDataType`, so its BSON type is unknown and the contract cannot describe the field. The Mongo PSL interpreter reports the same case at the field as `PSL_UNSUPPORTED_FIELD_TYPE`. Payload (meta): `modelName`, `valueObjectName` or `enumName`, `fieldName` for a field, `codecId`, `dataType`, `reason`. @@ -1392,8 +1394,8 @@ A raw-SQL tagged template interpolated a JS value whose type cannot be auto-infe A value that only a `Temporal` implementation can produce or read was needed in a process that has neither a global `Temporal` nor the fallback `Temporal` of the Postgres target. Two paths raise it, and they carry different metadata: -- A Temporal-backed codec (`pg/date-temporal@1`, `pg/timestamp-temporal@1`, `pg/timestamptz-temporal@1`, `pg/time-temporal@1`) decoding a value. Payload: `codecId`, `operation` (`'decode'`). Encoding a `Temporal` value needs no implementation and does not raise this error. -- The `instantNow`, `plainDateTimeNow`, `plainDateNow` or `plainTimeNow` mutation-default generator producing a value, for `temporal.CreatedAt`, `temporal.UpdatedAt`, or for a `TimestamptzTemporal`, `TimestampTemporal`, `TimestampInstant`, `DateTemporal`, `Date`, `TimeTemporal` or `Time` field with `@onCreate(now())` or `@onUpdate(now())`. No codec is involved. Payload: `generatorId`. +- A Temporal-backed codec (`pg/date-temporal@1`, `pg/time-temporal@1`, `pg/timestamp-temporal@1`, `pg/timestamptz-temporal@1`, `pg/timestamp-instant@1`) decoding a value. These are the codecs of `DateTemporal`, `TimeTemporal`, `TimestampTemporal`, `TimestamptzTemporal` and `TimestampInstant`, which the convenience types choose with `as: temporal`. Payload: `codecId`, `operation` (`'decode'`). Encoding a `Temporal` value needs no implementation and does not raise this error. +- The `instantNow`, `plainDateTimeNow`, `plainDateNow` or `plainTimeNow` mutation-default generator producing a value, for `@onCreate(now())` or `@onUpdate(now())` on a `TimestamptzTemporal`, `TimestampTemporal`, `TimestampInstant`, `DateTemporal` or `TimeTemporal` field, or on a convenience type that chooses one with `as: temporal`. No codec is involved. Payload: `generatorId`. The check is lazy: registering the target, validating a contract, building a runtime or a client, resolving a descriptor and constructing a codec instance all succeed without `Temporal`. Only producing or interpreting a value fails, including the first read of a `db.enums` enum whose codec is a Temporal codec. @@ -1401,7 +1403,7 @@ It is raised on **reads**, because the check is the first thing a Temporal codec This error never occurs in the control plane. The CLI commands, `node migration.ts`, the Vite plugin, the language server and the programmatic control client all load `prisma.config.ts` or the control descriptors, and so load the target's control entry. The target's control entry (`@internal/target-postgres/control`) sets the fallback `Temporal`, from `temporal-polyfill`, when it is loaded. It does not set `globalThis.Temporal`. `temporal-polyfill` is a required peer dependency of `@prisma/orm-postgres` and `@prisma/orm-target-postgres`. npm, pnpm and bun install it automatically; a project that uses Yarn adds it to its own dependencies. When it is not installed, loading the control entry fails because Node.js cannot find the package `temporal-polyfill`, not with this error. The fallback is held once per process, and every Postgres codec in the process uses it. An application process that loads no control-plane code and has no global `Temporal` gets `RUNTIME.TEMPORAL_UNAVAILABLE`. An application that loads control-plane code in its own process, such as server code under `vite dev` with the Prisma Vite plugin or a script that calls the control client, decodes dates without its own `Temporal`, and then fails in production. An application that uses the Temporal codecs must load its own `Temporal`. -In the application, install a global implementation before any query runs (`import 'temporal-polyfill/full/global'`), or author the column with its `*String` type (`DateString`, `TimestampString(p)`, `TimestamptzString(p)`, `TimeString(p)`) to read and write ISO 8601 text, which needs no Temporal at all. +In the application, install a global implementation before any query runs (`import 'temporal-polyfill/full/global'`), or write the column as a convenience type at its default, which needs no Temporal at all: `DateTime` reads a JavaScript `Date`, and `Date`, `Time` and `Duration` read ISO 8601 strings. `temporal.CreatedAt` and `temporal.UpdatedAt` are `DateTime` and need none either. ### RUNTIME.TRANSACTION_ALREADY_OPEN diff --git a/docs/reference/scalar-types.md b/docs/reference/scalar-types.md index 0736a23e538a..802cf075e0c0 100644 --- a/docs/reference/scalar-types.md +++ b/docs/reference/scalar-types.md @@ -65,6 +65,26 @@ One limitation to know about: These are the current names. The Postgres and SQLite rename project will change several of them to the target's own type names and update this table. +The date and time types have two layers ([ADR 279](../architecture%20docs/adrs/ADR%20279%20-%20Convenience%20types%20choose%20an%20explicit%20type%20from%20their%20arguments.md)). Each explicit type in the table below names one column type and one kind of value. The convenience types `DateTime`, `Date`, `Time` and `Duration` are named for what a field means, and each becomes one explicit type, chosen by its optional `as` and `storage` arguments: `as` is the value the application reads, and `storage` is the database type that stores it; `precision` is passed on to the explicit type. The contract stores only the explicit type, so `DateTime(as: temporal)` and `TimestamptzTemporal` emit the same contract. + +| Convenience type | `as` | `storage` | Becomes | +| --- | --- | --- | --- | +| `DateTime(as?, storage?, precision?)` | `jsDate` (default) | `timestamptz` (default) | `TimestamptzJsDate`, precision 3 unless written | +| | `temporal` | `timestamptz` | `TimestamptzTemporal` | +| | `string` | `timestamptz` | `TimestamptzString` | +| | `jsDate` | `timestamp` | `TimestampJsDate`, precision 3 unless written | +| | `temporal` | `timestamp` | `TimestampInstant` | +| | `string` | `timestamp` | `TimestampString` | +| `Date(as?, storage?)` | `string` (default) | `date` (default) | `DateString` | +| | `temporal` | `date` | `DateTemporal` | +| `Time(as?, storage?, precision?)` | `string` (default) | `time` (default) | `TimeString` | +| | `temporal` | `time` | `TimeTemporal` | +| | `string` | `timetz` | `Timetz` | +| `Duration(as?, storage?, precision?)` | `string` (default) | `interval` (default) | `IntervalString` | +| | `parts` | `interval` | `IntervalParts` | + +The arguments are written as bare names, by name or in this order: `DateTime(as: string, precision: 6)`, `DateTime(temporal, timestamp)`. A combination the table does not list, such as `Time(as: temporal, storage: timetz)` or `Date(as: jsDate)`, is refused at the argument that makes it invalid, naming the values it accepts; so is a `precision` the chosen type does not take. No default needs a global `Temporal`. In a TypeScript contract the helpers take the same arguments as one object, `type.DateTime({ as: 'string', precision: 6 })`, and are typed as the explicit type they choose; a combination the table does not list, or an argument the chosen type does not take, does not compile. An options object kept in a variable needs `as const`, so each option keeps its literal value: `const options = { as: 'string' } as const; type.DateTime(options)`. + | PSL name | TS helper | Codec id | Storage type | Application type | | --- | --- | --- | --- | --- | | `String` | `field.column(type.String())` | `pg/text@1` | `text` | `string` | @@ -79,16 +99,16 @@ These are the current names. The Postgres and SQLite rename project will change | `Real` | — | `pg/float4@1` | `float4` | `number` | | `Float` | `field.column(type.Float())` | `pg/float8@1` | `float8` | `number` | | `Decimal`, `Numeric(p?, s?)` | `field.column(type.Decimal())` | `pg/numeric@1` | `numeric` | `string` (decimal text) | -| `DateTime`, `TimestamptzTemporal(p?)` | `field.column(type.DateTime())` | `pg/timestamptz-temporal@1` | `timestamptz` | `Temporal.Instant` | +| `TimestamptzTemporal(p?)` | `field.column(type.TimestamptzTemporal())` | `pg/timestamptz-temporal@1` | `timestamptz` | `Temporal.Instant` | | `TimestamptzJsDate(p?)` | — | `pg/timestamptz-date@1` | `timestamptz` | `Date` | | `TimestamptzString(p?)` | — | `pg/timestamptz-string@1` | `timestamptz` | `string` (ISO 8601) | | `TimestampTemporal(p?)` | — | `pg/timestamp-temporal@1` | `timestamp` | `Temporal.PlainDateTime` | | `TimestampString(p?)` | — | `pg/timestamp-string@1` | `timestamp` | `string` (ISO 8601) | | `TimestampJsDate(p?)` | — | `pg/timestamp-date@1` | `timestamp` | `Date`, the stored date and time read as UTC | | `TimestampInstant(p?)` | — | `pg/timestamp-instant@1` | `timestamp` | `Temporal.Instant`, the stored date and time read as UTC | -| `Date`, `DateTemporal` | — | `pg/date-temporal@1` | `date` | `Temporal.PlainDate` | +| `DateTemporal` | — | `pg/date-temporal@1` | `date` | `Temporal.PlainDate` | | `DateString` | — | `pg/date-string@1` | `date` | `string` (ISO 8601) | -| `Time(p?)`, `TimeTemporal(p?)` | — | `pg/time-temporal@1` | `time` | `Temporal.PlainTime` | +| `TimeTemporal(p?)` | — | `pg/time-temporal@1` | `time` | `Temporal.PlainTime` | | `TimeString(p?)` | — | `pg/time-string@1` | `time` | `string` (ISO 8601) | | `Timetz(p?)` | — | `pg/timetz@1` | `timetz` | `string` (ISO 8601) | | `Uuid` | `field.column(type.Uuid())` | `pg/uuid@1` | `uuid` | `string` | @@ -102,11 +122,20 @@ These are the current names. The Postgres and SQLite rename project will change | `Bytes` | `field.column(type.Bytes())` | `pg/bytea@1` | `bytea` | `Uint8Array` | | `pg.enum(Name)` | — | `pg/enum@1` | the native enum type | `string` | -`TimestampJsDate` and `TimestampInstant` read the date and time a `timestamp` column stores as UTC, and write a value's UTC date and time with no offset, so a value the application writes reads back as the same instant whatever the time zone of the server, the session or the application. A database default is different: PostgreSQL fills a `timestamp` column's `@default(now())` in the session's time zone, so on a server whose `TimeZone` is not UTC that value reads back shifted by the zone's offset. `@onCreate(now())` writes the client's time in UTC and reads back unshifted. A `Date` holds milliseconds, so on a `TimestampJsDate` column with a precision above 3 a read drops the extra digits, and an equality filter with a value read back misses a row stored with microseconds. Use `TimestampInstant` or `TimestampString` to keep them. They refuse `infinity`, `-infinity` and a value after `+275760-09-13T00:00:00`, which no `Date` or `Temporal.Instant` holds, and name `TimestampString`, which holds them. `IntervalString` reads every `IntervalStyle` and writes an ISO 8601 duration; it refuses any other text on write. +`TimestampJsDate` and `TimestampInstant` read the date and time a `timestamp` column stores as UTC, and write a value's UTC date and time with no offset, so a value the application writes reads back as the same instant whatever the time zone of the server, the session or the application. A database default is different: PostgreSQL fills a `timestamp` column's `@default(now())` in the session's time zone, so on a server whose `TimeZone` is not UTC that value reads back shifted by the zone's offset. `@onCreate(now())` writes the client's time in UTC and reads back unshifted. On a column read as a `Date` with a precision below 3, `now()` is rounded down to that precision, so the client holds the value the column stores. A `Date` holds milliseconds, so on a `TimestampJsDate` column with a precision above 3 a read drops the extra digits, and an equality filter with a value read back misses a row stored with microseconds. Use `TimestampInstant` or `TimestampString` to keep them. They refuse `infinity`, `-infinity` and a value after `+275760-09-13T00:00:00`, which no `Date` or `Temporal.Instant` holds, and name `TimestampString`, which holds them. `IntervalString` reads every `IntervalStyle` and writes an ISO 8601 duration; it refuses any other text on write. A literal default of a date, time, interval or bytes type is written with the tag of its type, as in `@default(timestamptz`2024-01-01T00:00:00Z`)` or `@default(bytea`\x0102`)`; a quoted string is refused with a message that shows the tag. It is stored in the one form its data type stores, whichever codec the column uses and however the default was written, and a default with more fraction digits or more scale than its column holds is refused rather than rounded; [ADR 254](../architecture%20docs/adrs/ADR%20254%20-%20Data%20types%20and%20casts.md#date-and-time-types) states each form. -The tools that write a schema for you use the text date and time types. In a PSL schema, `prisma orm init` writes `createdAt TimestamptzString @default(now())` and `updatedAt temporal.UpdatedAtString`. In a TypeScript contract, it writes `field.column(type.temporal.CreatedAtString())` and `field.column(type.temporal.UpdatedAtString())`. All of these are `TimestamptzString` columns. `prisma contract infer` and `prisma7Schema(...)` write `TimestampString(p)`, `TimestamptzString(p)`, `DateString` and `TimeString(p)`. Five types read and write `Temporal` values: `TimestamptzTemporal` (also written `DateTime`), `TimestampTemporal`, `TimestampInstant`, `DateTemporal` (also written `Date`) and `TimeTemporal` (also written `Time`). Their TypeScript types come from `temporal-spec`, so a project needs no global `Temporal` declarations to type them. An application that uses them needs a global `Temporal`: on a runtime without one, such as Node.js 24, install it, for example with `import 'temporal-polyfill/full/global'`. +The tools that write a schema for you write the convenience types, with only the arguments that differ from the defaults, and the explicit type only where no convenience call becomes it, such as `TimestampTemporal` for a `timestamp` read as `Temporal.PlainDateTime`. + +- `prisma orm init` writes `createdAt DateTime @default(now()) @onCreate(now())` and `updatedAt temporal.UpdatedAt` in a PSL schema, and `field.column(type.DateTime()).default(now()).onCreate(gen.now())` and `field.column(type.temporal.UpdatedAt())` in a TypeScript contract. +- `prisma contract infer` takes the default path for each column: a `timestamptz(3)` column is `DateTime`, a `timestamp(3)` column `DateTime(storage: timestamp)`, a `date` column `Date`, a `time(0)` column `Time(precision: 0)`, a `timetz` column `Time(storage: timetz)` and an `interval` column `Duration`. A column with no declared precision holds microseconds, as precision 6 does, so a `timestamptz` column is `DateTime(precision: 6)`; `db verify` and the migration planner count an omitted precision on a date or time column as 6. A `Date` holds milliseconds, so a timestamp column with more digits reads with them dropped, and a timestamp default with digits below a millisecond is written as its raw `sql` expression. To keep the digits, change the field to `DateTime(as: temporal)` or `DateTime(as: string)`. When the default-path type cannot read the column's own default as a row at all, as a `Date` cannot read `infinity` or `-infinity`, infer writes the first row, in the order the convenience type declares its rows, of the same storage whose type can, which for those values is `as: string`: a `timestamp(3)` column with `DEFAULT 'infinity'` is ``DateTime(as: string, storage: timestamp, precision: 3) @default(timestamp`infinity`)``. Infer reads only the default, not the rows: a row that stores `infinity` or `-infinity` in a column read as a `Date` fails to read with `RUNTIME.DECODE_FAILED`, so add `as: string` to such a field and keep its other arguments, such as `DateTime(as: string, storage: timestamp, precision: 3)` for a `timestamp(3)` column; `as: temporal` cannot hold those values either. +- `prisma contract print` writes each column as the convenience call that becomes its explicit type exactly, such as `DateTime(as: temporal)` for `TimestamptzTemporal`, and otherwise the explicit type. +- `prisma7Schema(...)` reads Prisma 7's `DateTime` as `TimestampJsDate(3)`, a `timestamp(3)` column read as a `Date` as Prisma 7 read it, printed `DateTime(storage: timestamp)`, and `@db.Timestamptz(p)` as `TimestamptzJsDate(p)`. + +On a column read as a `Date` (`TimestamptzJsDate`, `TimestampJsDate`, and `DateTime` at its default), a default with digits below a millisecond is refused: write it with a string or `Temporal` type, or as a `sql` expression. A TypeScript contract cannot write one, because its `.default()` takes a `Date`. `db verify` compares a default on such a column as the application reads it, so it does not see a difference smaller than a millisecond. + +`temporal.CreatedAt` is `DateTime @onCreate(now())` and `temporal.UpdatedAt` is `DateTime @onCreate(now()) @onUpdate(now())`. Five types read and write `Temporal` values: `TimestamptzTemporal`, `TimestampTemporal`, `TimestampInstant`, `DateTemporal` and `TimeTemporal`, which the convenience types choose with `as: temporal`. Their TypeScript types come from `temporal-spec`, so a project needs no global `Temporal` declarations to type them. An application that uses them needs a global `Temporal`: on a runtime without one, such as Node.js 24, install it, for example with `import 'temporal-polyfill/full/global'`. A text date or time type reads and writes ISO 8601, the form its data type stores: `TimestamptzString` reads `2024-01-01T00:00:00Z`, the instant in UTC, whatever the session's time zone; `TimestampString` reads `2024-01-01T12:34:56.5`; `DateString` reads `2024-01-01`; `TimeString` reads `12:34:56`; and `Timetz` reads `12:34:56+02:00`, or `12:34:56Z` for a zero offset. A year before 1 or after 9999 is a sign and six digits, as in `-000043-03-15` for 44 BC. `infinity` and `-infinity` read as those words. A value PostgreSQL holds beyond the range of the `Temporal` and `Date` types reads in the same shape: the time `24:00:00`, and a date with a seven-digit year, such as `+5874897-12-31`. A value written in another form PostgreSQL reads is stored as the value it names and reads back in ISO 8601: `2024-01-01 01:00:00+01` written to a `TimestamptzString` column reads back as `2024-01-01T00:00:00Z`; a literal default or enum member must be written in the stored form. @@ -144,7 +173,7 @@ The PSL name on each target for a concept, and the Prisma 6/7 name a migrating s | double | `Float` | `Float` | `Float` | `Double` | | decimal | `Decimal` | `Decimal`, `Numeric(p?, s?)` | `Decimal` (stored as text) | `Decimal128` | | boolean | `Boolean` | `Boolean` | — | `Bool` | -| timestamp | `DateTime` | `DateTime`, `TimestamptzTemporal(p?)` | `DateTime` | `Date` | +| timestamp | `DateTime` | `DateTime` (a `TimestamptzJsDate(3)`) | `DateTime` | `Date` | | text | `String` | `String` | `String` | `String` | | bytes | `Bytes` | `Bytes` | `Bytes` | `Binary` | | JSON | `Json` | `Json`, `Jsonb` | `Json` | `Json` | @@ -153,4 +182,4 @@ The PSL name on each target for a concept, and the Prisma 6/7 name a migrating s A Prisma 6 MongoDB schema read with `prisma6Schema` may keep its native types. Each native type Prisma 6.19 accepts gives the field the MongoDB type of what Prisma 6 stores: `Int @db.Int` is `Int32`, `Int @db.Long` is `Int64Number` like a plain `Int`, `BigInt @db.Long` is `Int64`, `Bytes @db.ObjectId` is `ObjectId`, and `@db.String`, `@db.Bool`, `@db.Double`, `@db.Date`, `@db.BinData` and `@db.Json` are the same type as the plain field. `DateTime @db.Timestamp` stores a BSON timestamp, which no MongoDB scalar type holds, so `prisma6Schema` refuses it. -A Prisma 7 PostgreSQL schema read with `prisma7Schema`, or a database Prisma 7 created and `contract infer` reads, gets `TimestampString(3)` for `DateTime`, and `TimestampString(p)`, `TimestamptzString(p)`, `DateString` and `TimeString(p)` for `@db.Timestamp`, `@db.Timestamptz`, `@db.Date` and `@db.Time`. The PostgreSQL cell above is what a new Prisma 8 schema writes for the concept. +A Prisma 7 PostgreSQL schema read with `prisma7Schema`, or a database Prisma 7 created and `contract infer` reads, gets `TimestampJsDate(3)` for `DateTime`, written `DateTime(storage: timestamp)`, and `TimestampJsDate(p)`, `TimestamptzJsDate(p)`, `DateString`, `TimeString(p)` and `Timetz(p)` for `@db.Timestamp`, `@db.Timestamptz`, `@db.Date`, `@db.Time` and `@db.Timetz`, so a `DateTime` field still reads a JavaScript `Date`. The PostgreSQL cell above is what a new Prisma 8 schema writes for the concept. diff --git a/examples/prisma-8-cloudflare-worker/src/prisma/contract.prisma b/examples/prisma-8-cloudflare-worker/src/prisma/contract.prisma index af383df7f55d..109b7b4aa493 100644 --- a/examples/prisma-8-cloudflare-worker/src/prisma/contract.prisma +++ b/examples/prisma-8-cloudflare-worker/src/prisma/contract.prisma @@ -17,7 +17,7 @@ model User { id String @id @onCreate(uuid()) email String displayName String - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) kind user_type address Address? posts Post[] @@ -30,7 +30,7 @@ model Post { id String @id @onCreate(uuid()) title String userId String - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) user User @relation(fields: [userId], references: [id]) @@ -44,7 +44,7 @@ model Task { status String @default("open") type String userId String - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) user User @relation(fields: [userId], references: [id]) diff --git a/examples/prisma-8-demo/migrations/app/20260422T0720_initial/contract.prisma b/examples/prisma-8-demo/migrations/app/20260422T0720_initial/contract.prisma index c9993fb036b1..526dc39d7964 100644 --- a/examples/prisma-8-demo/migrations/app/20260422T0720_initial/contract.prisma +++ b/examples/prisma-8-demo/migrations/app/20260422T0720_initial/contract.prisma @@ -28,7 +28,7 @@ model User { id Uuid @id @onCreate(uuid()) email String displayName String - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) kind user_type address Address? posts Post[] @@ -42,7 +42,7 @@ model Post { title String userId Uuid priority Priority @default(Low) - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) embedding Embedding1536? user User @relation(fields: [userId], references: [id]) @@ -78,7 +78,7 @@ model Task { status String @default("open") type String userId Uuid - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) user User @relation(fields: [userId], references: [id]) diff --git a/examples/prisma-8-demo/migrations/app/20260810T1108_add_post_engagement_counters/contract.prisma b/examples/prisma-8-demo/migrations/app/20260810T1108_add_post_engagement_counters/contract.prisma index 9ecc57adb666..14c27660787a 100644 --- a/examples/prisma-8-demo/migrations/app/20260810T1108_add_post_engagement_counters/contract.prisma +++ b/examples/prisma-8-demo/migrations/app/20260810T1108_add_post_engagement_counters/contract.prisma @@ -28,7 +28,7 @@ model User { id Uuid @id @onCreate(uuid()) email String displayName String - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) kind user_type address Address? posts Post[] @@ -42,7 +42,7 @@ model Post { title String userId Uuid priority Priority @default(Low) - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) embedding Embedding1536? // Three engagement counters, one per integer representation. They are @@ -94,7 +94,7 @@ model Task { status String @default("open") type String userId Uuid - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) user User @relation(fields: [userId], references: [id]) diff --git a/examples/prisma-8-demo/migrations/app/20260917T0818_add_post_expires_at/contract.prisma b/examples/prisma-8-demo/migrations/app/20260917T0818_add_post_expires_at/contract.prisma index b03d39bd5c99..d0d97622dbe5 100644 --- a/examples/prisma-8-demo/migrations/app/20260917T0818_add_post_expires_at/contract.prisma +++ b/examples/prisma-8-demo/migrations/app/20260917T0818_add_post_expires_at/contract.prisma @@ -28,7 +28,7 @@ model User { id Uuid @id @onCreate(uuid()) email String displayName String - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) kind user_type address Address? posts Post[] @@ -42,8 +42,8 @@ model Post { title String userId Uuid priority Priority @default(Low) - createdAt DateTime @default(now()) - expiresAt DateTime @default(sql`(now() + '7 days'::interval)`) + createdAt DateTime(as: temporal) @default(now()) + expiresAt DateTime(as: temporal) @default(sql`(now() + '7 days'::interval)`) embedding Embedding1536? // Three engagement counters, one per integer representation. They are @@ -95,7 +95,7 @@ model Task { status String @default("open") type String userId Uuid - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) user User @relation(fields: [userId], references: [id]) diff --git a/examples/prisma-8-demo/migrations/app/20260922T1218_add_post_title_search/contract.prisma b/examples/prisma-8-demo/migrations/app/20260922T1218_add_post_title_search/contract.prisma index cff400263429..cd7a0815797a 100644 --- a/examples/prisma-8-demo/migrations/app/20260922T1218_add_post_title_search/contract.prisma +++ b/examples/prisma-8-demo/migrations/app/20260922T1218_add_post_title_search/contract.prisma @@ -28,7 +28,7 @@ model User { id Uuid @id @onCreate(uuid()) email String displayName String - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) kind user_type address Address? posts Post[] @@ -42,8 +42,8 @@ model Post { title String userId Uuid priority Priority @default(Low) - createdAt DateTime @default(now()) - expiresAt DateTime @default(sql`(now() + '7 days'::interval)`) + createdAt DateTime(as: temporal) @default(now()) + expiresAt DateTime(as: temporal) @default(sql`(now() + '7 days'::interval)`) embedding Embedding1536? // Three engagement counters, one per integer representation. They are @@ -96,7 +96,7 @@ model Task { status String @default("open") type String userId Uuid - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) user User @relation(fields: [userId], references: [id]) diff --git a/examples/prisma-8-demo/migrations/app/20261009T1354_add_post_body_search/contract.prisma b/examples/prisma-8-demo/migrations/app/20261009T1354_add_post_body_search/contract.prisma index 875756472ef7..26cdbfd3a008 100644 --- a/examples/prisma-8-demo/migrations/app/20261009T1354_add_post_body_search/contract.prisma +++ b/examples/prisma-8-demo/migrations/app/20261009T1354_add_post_body_search/contract.prisma @@ -28,7 +28,7 @@ model User { id Uuid @id @onCreate(uuid()) email String displayName String - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) kind user_type address Address? posts Post[] @@ -43,8 +43,8 @@ model Post { body String? userId Uuid priority Priority @default(Low) - createdAt DateTime @default(now()) - expiresAt DateTime @default(sql`(now() + '7 days'::interval)`) + createdAt DateTime(as: temporal) @default(now()) + expiresAt DateTime(as: temporal) @default(sql`(now() + '7 days'::interval)`) embedding Embedding1536? // Three engagement counters, one per integer representation. They are @@ -100,7 +100,7 @@ model Task { status String @default("open") type String userId Uuid - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) user User @relation(fields: [userId], references: [id]) diff --git a/examples/prisma-8-demo/prisma/contract.ts b/examples/prisma-8-demo/prisma/contract.ts index 494d21ae6fe9..209543b1460a 100644 --- a/examples/prisma-8-demo/prisma/contract.ts +++ b/examples/prisma-8-demo/prisma/contract.ts @@ -32,8 +32,8 @@ export const contract = defineContract( fields: { id: field.column(type.Char(36)).id().onCreate(gen.uuid()), email: field.column(type.String()), - createdAt: field.column(type.temporal.CreatedAt()), - updatedAt: field.column(type.temporal.UpdatedAt()), + createdAt: field.column(timestamptzTemporalColumn).onCreate(gen.now()), + updatedAt: field.column(timestamptzTemporalColumn).onCreate(gen.now()).onUpdate(gen.now()), kind: field.namedType(UserEnum), address: field.column(type.Jsonb()).optional(), }, @@ -46,11 +46,11 @@ export const contract = defineContract( body: field.column(type.String()).optional(), userId: field.column(type.Char(36)), priority: field.namedType(Priority).default(Priority.members.Low), - createdAt: field.column(type.temporal.CreatedAt()), + createdAt: field.column(timestamptzTemporalColumn).onCreate(gen.now()), expiresAt: field .column(timestamptzTemporalColumn) .default(sql`(now() + '7 days'::interval)`), - updatedAt: field.column(type.temporal.UpdatedAt()), + updatedAt: field.column(timestamptzTemporalColumn).onCreate(gen.now()).onUpdate(gen.now()), embedding: Embedding1536.optional(), }, }); diff --git a/examples/prisma-8-demo/src/orm-client/fragments.ts b/examples/prisma-8-demo/src/orm-client/fragments.ts index f5ac1b1dccaf..eb8b1d281b37 100644 --- a/examples/prisma-8-demo/src/orm-client/fragments.ts +++ b/examples/prisma-8-demo/src/orm-client/fragments.ts @@ -7,7 +7,7 @@ import { db } from '../prisma/db'; * A scope for any model with a `createdAt` timestamp, such as `User`, `Post` and `Task`: rows created since `since`. */ export function createdSince(since: Temporal.Instant) { - return db.orm.fragment({ createdAt: field.column(type.TimestamptzTemporal()) }, (rows) => + return db.orm.fragment({ createdAt: field.column(type.DateTime({ as: 'temporal' })) }, (rows) => rows.where((row) => row.createdAt.gte(since)), ); } diff --git a/examples/prisma-8-demo/src/prisma/contract.prisma b/examples/prisma-8-demo/src/prisma/contract.prisma index 437af5685bf5..93465e9bafe2 100644 --- a/examples/prisma-8-demo/src/prisma/contract.prisma +++ b/examples/prisma-8-demo/src/prisma/contract.prisma @@ -28,7 +28,7 @@ model User { id Uuid @id @onCreate(uuid()) email String displayName String - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) kind user_type address Address? posts Post[] @@ -43,8 +43,8 @@ model Post { body String? userId Uuid priority Priority @default(Low) - createdAt DateTime @default(now()) - expiresAt DateTime @default(sql`(now() + '7 days'::interval)`) + createdAt DateTime(as: temporal) @default(now()) + expiresAt DateTime(as: temporal) @default(sql`(now() + '7 days'::interval)`) embedding Embedding1536? // Three engagement counters, one per integer representation. They are @@ -100,7 +100,7 @@ model Task { status String @default("open") type String userId Uuid - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) user User @relation(fields: [userId], references: [id]) diff --git a/examples/prisma-8-demo/test/fixtures/declaration-library.ts b/examples/prisma-8-demo/test/fixtures/declaration-library.ts index ea51d0756c36..07701eeb6198 100644 --- a/examples/prisma-8-demo/test/fixtures/declaration-library.ts +++ b/examples/prisma-8-demo/test/fixtures/declaration-library.ts @@ -45,7 +45,7 @@ export const labelled = (labels: readonly string[]) => ); export const unexpired = (now: Temporal.Instant) => - client.fragment({ expiresAt: field.column(type.TimestamptzTemporal()) }, (rows) => + client.fragment({ expiresAt: field.column(type.DateTime({ as: 'temporal' })) }, (rows) => rows.where((row) => row.expiresAt.gt(now)).orderBy((row) => row.expiresAt.asc()), ); diff --git a/examples/prisma7-adoption/README.md b/examples/prisma7-adoption/README.md index 4a68ad5ba0f7..98c82234bf6e 100644 --- a/examples/prisma7-adoption/README.md +++ b/examples/prisma7-adoption/README.md @@ -110,7 +110,7 @@ A Prisma 7 schema that uses `@ignore`, `@@ignore` or `Unsupported("...")` fields - The guide's `prisma7.config.ts` sets `datasource.url` to `process.env["DATABASE_URL"]`, which is `string | undefined`; under `exactOptionalPropertyTypes` that does not type-check, so this example adds the `datasource` block only when the variable is set. `prisma7 generate` runs without a database either way. - Prisma 7 rejects `url` inside the `datasource` block; the URL lives only in `prisma7.config.ts` (Prisma 7) and `prisma.config.ts` (Prisma 8), both reading the same `DATABASE_URL` from `.env`. - Where Prisma 7 named a primary key or foreign key differently from Prisma 8, the contract states Prisma 7's name: a `map` on `@id`, `@@id` or `@relation`, a junction's primary key such as `_PostToTag_AB_pkey`, and a foreign key name Prisma 7 cut to 63 bytes. `db verify` does not compare these names, but migrations drop and rename constraints by them. -- Prisma 8 reads a `DateTime` column as the text PostgreSQL prints, such as `2026-09-14 10:00:00.123` (UTC, as Prisma 7 wrote it), not as a JavaScript `Date`. `@db.Timestamptz`, `@db.Date` and `@db.Time` columns read as text too, so the application needs no `Temporal`. +- Prisma 8 reads a `DateTime` column as a JavaScript `Date`, as Prisma 7 did: `prisma7Schema` reads it as `DateTime(storage: timestamp)`, a `timestamp(3)` column whose value is stored as UTC. A `@db.Timestamptz` column also reads as a `Date`; `@db.Date` and `@db.Time` columns read as ISO 8601 strings, such as `2026-09-14` and `10:00:00`. The application needs no `Temporal`. - `pnpm sign` creates `migrations/` (a snapshot of the signed contract and the `db` ref). It is Prisma 8's record of what was signed and is committed here; in phase 4 the first `migration plan` builds the baseline from it. - The Prisma 8 CLI prints JSON only with `--json`. Without it, a pipe, a file or an agent receives the command's tab-separated data lines on stdout, and the human output goes to stderr. diff --git a/examples/prisma7-adoption/generated/prisma8/contract.d.ts b/examples/prisma7-adoption/generated/prisma8/contract.d.ts index 54ca7df0688b..1fa1b3e6f73c 100644 --- a/examples/prisma7-adoption/generated/prisma8/contract.d.ts +++ b/examples/prisma7-adoption/generated/prisma8/contract.d.ts @@ -23,9 +23,9 @@ import type { } from '@prisma/orm-postgres/contract/types'; export type StorageHash = - StorageHashBase<'9268d6eef6e976bf77ca87083b15cd8eb247c9bf7fe4d2929f56a23bfa36d9ec'>; + StorageHashBase<'97963644c7d9359d5e31879ed1c6bfdc39b666d0fd1d0f152525b055d3c574c3'>; export type ExecutionHash = - ExecutionHashBase<'8552d69a879d0f7c4482b9b7a2e9932db9ea33b3421ed778e87bf04b17076b10'>; + ExecutionHashBase<'9a5883a46a0f8f232598bc988951650620bebba27bb6d25c01513cb5e3fd94b9'>; export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; @@ -538,7 +538,7 @@ type ContractBase = Omit< columns: { readonly createdAt: { readonly dataType: 'pg/timestamp'; - readonly codecId: 'pg/timestamp-string@1'; + readonly codecId: 'pg/timestamp-date@1'; readonly nullable: false; readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; readonly typeParams: { readonly precision: 3 }; @@ -579,7 +579,7 @@ type ContractBase = Omit< }; readonly updatedAt: { readonly dataType: 'pg/timestamp'; - readonly codecId: 'pg/timestamp-string@1'; + readonly codecId: 'pg/timestamp-date@1'; readonly nullable: false; readonly typeParams: { readonly precision: 3 }; readonly many: false; @@ -774,7 +774,7 @@ type ContractBase = Omit< readonly nullable: false; readonly type: { readonly kind: 'scalar'; - readonly codecId: 'pg/timestamp-string@1'; + readonly codecId: 'pg/timestamp-date@1'; readonly typeParams: { readonly precision: 3 }; }; }; @@ -802,7 +802,7 @@ type ContractBase = Omit< readonly nullable: false; readonly type: { readonly kind: 'scalar'; - readonly codecId: 'pg/timestamp-string@1'; + readonly codecId: 'pg/timestamp-date@1'; readonly typeParams: { readonly precision: 3 }; }; }; @@ -868,7 +868,7 @@ type ContractBase = Omit< readonly defaults: readonly [ { readonly onCreate: { - readonly id: 'timestampStringNow'; + readonly id: 'timestampNow'; readonly kind: 'generator'; readonly params: { readonly precision: 3 }; }; @@ -880,12 +880,12 @@ type ContractBase = Omit< }, { readonly onCreate: { - readonly id: 'timestampStringNow'; + readonly id: 'timestampNow'; readonly kind: 'generator'; readonly params: { readonly precision: 3 }; }; readonly onUpdate: { - readonly id: 'timestampStringNow'; + readonly id: 'timestampNow'; readonly kind: 'generator'; readonly params: { readonly precision: 3 }; }; diff --git a/examples/prisma7-adoption/generated/prisma8/contract.json b/examples/prisma7-adoption/generated/prisma8/contract.json index 479758e006e3..3799b1f0b9fa 100644 --- a/examples/prisma7-adoption/generated/prisma8/contract.json +++ b/examples/prisma7-adoption/generated/prisma8/contract.json @@ -270,7 +270,7 @@ "createdAt": { "nullable": false, "type": { - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "kind": "scalar", "typeParams": { "precision": 3 @@ -311,7 +311,7 @@ "updatedAt": { "nullable": false, "type": { - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "kind": "scalar", "typeParams": { "precision": 3 @@ -490,7 +490,7 @@ "User": { "columns": { "createdAt": { - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "dataType": "pg/timestamp", "default": { "expression": "now()", @@ -539,7 +539,7 @@ } }, "updatedAt": { - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "dataType": "pg/timestamp", "nullable": false, "typeParams": { @@ -654,15 +654,15 @@ "kind": "postgres-schema" } }, - "storageHash": "9268d6eef6e976bf77ca87083b15cd8eb247c9bf7fe4d2929f56a23bfa36d9ec" + "storageHash": "97963644c7d9359d5e31879ed1c6bfdc39b666d0fd1d0f152525b055d3c574c3" }, "execution": { - "executionHash": "8552d69a879d0f7c4482b9b7a2e9932db9ea33b3421ed778e87bf04b17076b10", + "executionHash": "9a5883a46a0f8f232598bc988951650620bebba27bb6d25c01513cb5e3fd94b9", "mutations": { "defaults": [ { "onCreate": { - "id": "timestampStringNow", + "id": "timestampNow", "kind": "generator", "params": { "precision": 3 @@ -676,14 +676,14 @@ }, { "onCreate": { - "id": "timestampStringNow", + "id": "timestampNow", "kind": "generator", "params": { "precision": 3 } }, "onUpdate": { - "id": "timestampStringNow", + "id": "timestampNow", "kind": "generator", "params": { "precision": 3 diff --git a/examples/prisma7-adoption/migrations/app/refs/db.json b/examples/prisma7-adoption/migrations/app/refs/db.json index e6cba861b2b1..fce4f374d9d3 100644 --- a/examples/prisma7-adoption/migrations/app/refs/db.json +++ b/examples/prisma7-adoption/migrations/app/refs/db.json @@ -1,4 +1,4 @@ { - "hash": "cee288aa57be618663c6c6118ece25e6ae2049027a7337e8e04c1742faedfff3", + "hash": "97963644c7d9359d5e31879ed1c6bfdc39b666d0fd1d0f152525b055d3c574c3", "invariants": [] } diff --git a/examples/prisma7-adoption/migrations/snapshots/cee288aa57be618663c6c6118ece25e6ae2049027a7337e8e04c1742faedfff3/contract.d.ts b/examples/prisma7-adoption/migrations/snapshots/97963644c7d9359d5e31879ed1c6bfdc39b666d0fd1d0f152525b055d3c574c3/contract.d.ts similarity index 85% rename from examples/prisma7-adoption/migrations/snapshots/cee288aa57be618663c6c6118ece25e6ae2049027a7337e8e04c1742faedfff3/contract.d.ts rename to examples/prisma7-adoption/migrations/snapshots/97963644c7d9359d5e31879ed1c6bfdc39b666d0fd1d0f152525b055d3c574c3/contract.d.ts index 21be02e58726..7841e9099d4b 100644 --- a/examples/prisma7-adoption/migrations/snapshots/cee288aa57be618663c6c6118ece25e6ae2049027a7337e8e04c1742faedfff3/contract.d.ts +++ b/examples/prisma7-adoption/migrations/snapshots/97963644c7d9359d5e31879ed1c6bfdc39b666d0fd1d0f152525b055d3c574c3/contract.d.ts @@ -3,11 +3,12 @@ // To regenerate, run: prisma contract emit import type { CodecTypes as PgTypes, - TimestampString, + ParameterizedOutputTypesForTypeParams as PgParameterizedOutputTypes, } from '@prisma/orm-postgres/target/codec-types'; import type { QueryOperationTypes as PgTargetQueryOps } from '@prisma/orm-postgres/target/operation-types'; import type { + ColumnTypesFromStorage, ContractWithTypeMaps, RelationKeys, TypeMaps as TypeMapsType, @@ -15,15 +16,16 @@ import type { import type { Contract as ContractType, ExecutionHashBase, + FieldTypesFromDomain, NamespaceId, ProfileHashBase, StorageHashBase, } from '@prisma/orm-postgres/contract/types'; export type StorageHash = - StorageHashBase<'cee288aa57be618663c6c6118ece25e6ae2049027a7337e8e04c1742faedfff3'>; + StorageHashBase<'97963644c7d9359d5e31879ed1c6bfdc39b666d0fd1d0f152525b055d3c574c3'>; export type ExecutionHash = - ExecutionHashBase<'76ee8948067fff39e1b787eafdf2c9319ebe5e0f5029f71536b4f58b92d7790e'>; + ExecutionHashBase<'380511653781da301aa7612ce64f0ac7b74bedf70c783143bab5ae56d5295037'>; export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; @@ -41,6 +43,10 @@ export type AggregateTypes = { readonly 'pg/int4@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; readonly 'pg/int8@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; readonly 'pg/int8number@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/interval-string@1': { + readonly output: 'pg/interval-string@1'; + readonly nullable: true; + }; readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; readonly 'pg/time-string@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; @@ -90,6 +96,10 @@ export type AggregateTypes = { readonly 'pg/int4@1': { readonly output: 'pg/int4@1'; readonly nullable: true }; readonly 'pg/int8@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; readonly 'pg/int8number@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/interval-string@1': { + readonly output: 'pg/interval-string@1'; + readonly nullable: true; + }; readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; readonly 'pg/text-array@1': { readonly output: 'pg/text-array@1'; readonly nullable: true }; @@ -99,6 +109,14 @@ export type AggregateTypes = { readonly output: 'pg/time-temporal@1'; readonly nullable: true; }; + readonly 'pg/timestamp-date@1': { + readonly output: 'pg/timestamp-date@1'; + readonly nullable: true; + }; + readonly 'pg/timestamp-instant@1': { + readonly output: 'pg/timestamp-instant@1'; + readonly nullable: true; + }; readonly 'pg/timestamp-string@1': { readonly output: 'pg/timestamp-string@1'; readonly nullable: true; @@ -150,6 +168,10 @@ export type AggregateTypes = { readonly 'pg/int4@1': { readonly output: 'pg/int4@1'; readonly nullable: true }; readonly 'pg/int8@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; readonly 'pg/int8number@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/interval-string@1': { + readonly output: 'pg/interval-string@1'; + readonly nullable: true; + }; readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; readonly 'pg/text-array@1': { readonly output: 'pg/text-array@1'; readonly nullable: true }; @@ -159,6 +181,14 @@ export type AggregateTypes = { readonly output: 'pg/time-temporal@1'; readonly nullable: true; }; + readonly 'pg/timestamp-date@1': { + readonly output: 'pg/timestamp-date@1'; + readonly nullable: true; + }; + readonly 'pg/timestamp-instant@1': { + readonly output: 'pg/timestamp-instant@1'; + readonly nullable: true; + }; readonly 'pg/timestamp-string@1': { readonly output: 'pg/timestamp-string@1'; readonly nullable: true; @@ -202,6 +232,10 @@ export type AggregateTypes = { readonly 'pg/int4@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; readonly 'pg/int8@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; readonly 'pg/int8number@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/interval-string@1': { + readonly output: 'pg/interval-string@1'; + readonly nullable: true; + }; readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; readonly 'pg/time-string@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; @@ -234,152 +268,85 @@ type DefaultLiteralValue = CodecId extends keyo ? Encoded : CodecTypes[CodecId]['json'] : Encoded; +type ParameterizedOutputTypeFunctions = PgParameterizedOutputTypes; -export type FieldOutputTypes = { - readonly public: { - readonly Post: { - readonly authorId: CodecTypes['pg/int4@1']['output']; - readonly content: CodecTypes['pg/text@1']['output'] | null; - readonly id: CodecTypes['pg/int4@1']['output']; - readonly published: CodecTypes['pg/bool@1']['output']; - readonly title: CodecTypes['pg/text@1']['output']; - readonly viewCount: CodecTypes['pg/int4@1']['output']; - }; - readonly PostToTag: { - readonly A: CodecTypes['pg/int4@1']['output']; - readonly B: CodecTypes['pg/int4@1']['output']; - }; - readonly Tag: { - readonly id: CodecTypes['pg/int4@1']['output']; - readonly name: CodecTypes['pg/text@1']['output']; - }; - readonly User: { - readonly createdAt: TimestampString<3>; - readonly email: CodecTypes['pg/text@1']['output']; - readonly id: CodecTypes['pg/int4@1']['output']; - readonly name: CodecTypes['pg/text@1']['output'] | null; - readonly role: 'USER' | 'ADMIN'; - readonly updatedAt: TimestampString<3>; - }; - }; -}; -export type FieldInputTypes = { +type FieldTypeOverrides = { readonly public: { - readonly Post: { - readonly authorId: CodecTypes['pg/int4@1']['input']; - readonly content: CodecTypes['pg/text@1']['input'] | null; - readonly id: CodecTypes['pg/int4@1']['input']; - readonly published: CodecTypes['pg/bool@1']['input']; - readonly title: CodecTypes['pg/text@1']['input']; - readonly viewCount: CodecTypes['pg/int4@1']['input']; - }; - readonly PostToTag: { - readonly A: CodecTypes['pg/int4@1']['input']; - readonly B: CodecTypes['pg/int4@1']['input']; - }; - readonly Tag: { - readonly id: CodecTypes['pg/int4@1']['input']; - readonly name: CodecTypes['pg/text@1']['input']; - }; - readonly User: { - readonly createdAt: CodecTypes['pg/timestamp-string@1']['input']; - readonly email: CodecTypes['pg/text@1']['input']; - readonly id: CodecTypes['pg/int4@1']['input']; - readonly name: CodecTypes['pg/text@1']['input'] | null; - readonly role: 'USER' | 'ADMIN'; - readonly updatedAt: CodecTypes['pg/timestamp-string@1']['input']; - }; - }; -}; -export type StorageColumnTypes = { - readonly public: { - readonly _PostToTag: { - readonly A: CodecTypes['pg/int4@1']['output']; - readonly B: CodecTypes['pg/int4@1']['output']; - }; - readonly Post: { - readonly authorId: CodecTypes['pg/int4@1']['output']; - readonly content: CodecTypes['pg/text@1']['output'] | null; - readonly id: CodecTypes['pg/int4@1']['output']; - readonly published: CodecTypes['pg/bool@1']['output']; - readonly title: CodecTypes['pg/text@1']['output']; - readonly viewCount: CodecTypes['pg/int4@1']['output']; - }; - readonly Tag: { - readonly id: CodecTypes['pg/int4@1']['output']; - readonly name: CodecTypes['pg/text@1']['output']; - }; - readonly User: { - readonly createdAt: TimestampString<3>; - readonly email: CodecTypes['pg/text@1']['output']; - readonly id: CodecTypes['pg/int4@1']['output']; - readonly name: CodecTypes['pg/text@1']['output'] | null; - readonly role: 'USER' | 'ADMIN'; - readonly updatedAt: TimestampString<3>; + readonly models: { + readonly User: { + readonly role: { readonly output: 'USER' | 'ADMIN'; readonly input: 'USER' | 'ADMIN' }; + }; }; }; }; -export type StorageColumnInputTypes = { +export type FieldOutputTypes = FieldTypesFromDomain< + ContractBase['domain']['namespaces'], + CodecTypes, + ParameterizedOutputTypeFunctions, + FieldTypeOverrides, + 'output' +>; +export type FieldInputTypes = FieldTypesFromDomain< + ContractBase['domain']['namespaces'], + CodecTypes, + ParameterizedOutputTypeFunctions, + FieldTypeOverrides, + 'input' +>; +type ColumnTypeOverrides = { readonly public: { - readonly _PostToTag: { - readonly A: CodecTypes['pg/int4@1']['input']; - readonly B: CodecTypes['pg/int4@1']['input']; - }; - readonly Post: { - readonly authorId: CodecTypes['pg/int4@1']['input']; - readonly content: CodecTypes['pg/text@1']['input'] | null; - readonly id: CodecTypes['pg/int4@1']['input']; - readonly published: CodecTypes['pg/bool@1']['input']; - readonly title: CodecTypes['pg/text@1']['input']; - readonly viewCount: CodecTypes['pg/int4@1']['input']; - }; - readonly Tag: { - readonly id: CodecTypes['pg/int4@1']['input']; - readonly name: CodecTypes['pg/text@1']['input']; - }; readonly User: { - readonly createdAt: CodecTypes['pg/timestamp-string@1']['input']; - readonly email: CodecTypes['pg/text@1']['input']; - readonly id: CodecTypes['pg/int4@1']['input']; - readonly name: CodecTypes['pg/text@1']['input'] | null; - readonly role: 'USER' | 'ADMIN'; - readonly updatedAt: CodecTypes['pg/timestamp-string@1']['input']; + readonly role: { readonly output: 'USER' | 'ADMIN'; readonly input: 'USER' | 'ADMIN' }; }; }; }; +export type StorageColumnTypes = ColumnTypesFromStorage< + ContractBase['storage'], + CodecTypes, + ParameterizedOutputTypeFunctions, + ColumnTypeOverrides, + 'output' +>; +export type StorageColumnInputTypes = ColumnTypesFromStorage< + ContractBase['storage'], + CodecTypes, + ParameterizedOutputTypeFunctions, + ColumnTypeOverrides, + 'input' +>; export namespace Models { export type public_Post = { - authorId: CodecTypes['pg/int4@1']['output']; - content: CodecTypes['pg/text@1']['output'] | null; - id: CodecTypes['pg/int4@1']['output']; - published: CodecTypes['pg/bool@1']['output']; - title: CodecTypes['pg/text@1']['output']; - viewCount: CodecTypes['pg/int4@1']['output']; + authorId: FieldOutputTypes['public']['Post']['authorId']; + content: FieldOutputTypes['public']['Post']['content']; + id: FieldOutputTypes['public']['Post']['id']; + published: FieldOutputTypes['public']['Post']['published']; + title: FieldOutputTypes['public']['Post']['title']; + viewCount: FieldOutputTypes['public']['Post']['viewCount']; author: public_User; tags: public_Tag[]; readonly [RelationKeys]?: 'author' | 'tags'; }; export type public_PostToTag = { - A: CodecTypes['pg/int4@1']['output']; - B: CodecTypes['pg/int4@1']['output']; + A: FieldOutputTypes['public']['PostToTag']['A']; + B: FieldOutputTypes['public']['PostToTag']['B']; post: public_Post; tag: public_Tag; readonly [RelationKeys]?: 'post' | 'tag'; }; export type public_Tag = { - id: CodecTypes['pg/int4@1']['output']; - name: CodecTypes['pg/text@1']['output']; + id: FieldOutputTypes['public']['Tag']['id']; + name: FieldOutputTypes['public']['Tag']['name']; posts: public_Post[]; readonly [RelationKeys]?: 'posts'; }; export type public_User = { - createdAt: TimestampString<3>; - email: CodecTypes['pg/text@1']['output']; - id: CodecTypes['pg/int4@1']['output']; - name: CodecTypes['pg/text@1']['output'] | null; - role: 'USER' | 'ADMIN'; - updatedAt: TimestampString<3>; + createdAt: FieldOutputTypes['public']['User']['createdAt']; + email: FieldOutputTypes['public']['User']['email']; + id: FieldOutputTypes['public']['User']['id']; + name: FieldOutputTypes['public']['User']['name']; + role: FieldOutputTypes['public']['User']['role']; + updatedAt: FieldOutputTypes['public']['User']['updatedAt']; posts: public_Post[]; readonly [RelationKeys]?: 'posts'; }; @@ -569,7 +536,7 @@ type ContractBase = Omit< columns: { readonly createdAt: { readonly dataType: 'pg/timestamp'; - readonly codecId: 'pg/timestamp-string@1'; + readonly codecId: 'pg/timestamp-date@1'; readonly nullable: false; readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; readonly typeParams: { readonly precision: 3 }; @@ -610,7 +577,7 @@ type ContractBase = Omit< }; readonly updatedAt: { readonly dataType: 'pg/timestamp'; - readonly codecId: 'pg/timestamp-string@1'; + readonly codecId: 'pg/timestamp-date@1'; readonly nullable: false; readonly typeParams: { readonly precision: 3 }; readonly many: false; @@ -805,7 +772,7 @@ type ContractBase = Omit< readonly nullable: false; readonly type: { readonly kind: 'scalar'; - readonly codecId: 'pg/timestamp-string@1'; + readonly codecId: 'pg/timestamp-date@1'; readonly typeParams: { readonly precision: 3 }; }; }; @@ -833,7 +800,7 @@ type ContractBase = Omit< readonly nullable: false; readonly type: { readonly kind: 'scalar'; - readonly codecId: 'pg/timestamp-string@1'; + readonly codecId: 'pg/timestamp-date@1'; readonly typeParams: { readonly precision: 3 }; }; }; @@ -897,6 +864,14 @@ type ContractBase = Omit< readonly executionHash: ExecutionHash; readonly mutations: { readonly defaults: readonly [ + { + readonly onCreate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; + readonly ref: { + readonly entry: 'User'; + readonly field: 'createdAt'; + readonly namespace: 'public'; + }; + }, { readonly onCreate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; readonly onUpdate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; diff --git a/examples/prisma7-adoption/migrations/snapshots/97963644c7d9359d5e31879ed1c6bfdc39b666d0fd1d0f152525b055d3c574c3/contract.json b/examples/prisma7-adoption/migrations/snapshots/97963644c7d9359d5e31879ed1c6bfdc39b666d0fd1d0f152525b055d3c574c3/contract.json new file mode 100644 index 000000000000..f0914e7639c8 --- /dev/null +++ b/examples/prisma7-adoption/migrations/snapshots/97963644c7d9359d5e31879ed1c6bfdc39b666d0fd1d0f152525b055d3c574c3/contract.json @@ -0,0 +1 @@ +{"_generated":{"message":"This file is automatically generated by \"prisma contract emit\".","regenerate":"To regenerate, run: prisma contract emit","warning":"⚠️ GENERATED FILE - DO NOT EDIT"},"capabilities":{"postgres":{"distinctOn":true,"forKeyShare":true,"forNoKeyUpdate":true,"jsonAgg":true,"lateral":true,"limit":true,"orderBy":true,"returning":true},"sql":{"checkConstraint":true,"defaultInInsert":true,"enums":true,"forShare":true,"forUpdate":true,"insertOnConflictSkip":true,"insertOnConflictWithoutTarget":true,"lateral":true,"lockNowait":true,"lockOf":true,"lockSkipLocked":true,"returning":true,"scalarList":true}},"domain":{"namespaces":{"public":{"models":{"Post":{"fields":{"authorId":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}},"content":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"id":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}},"published":{"nullable":false,"type":{"codecId":"pg/bool@1","kind":"scalar"}},"title":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"viewCount":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}}},"relations":{"author":{"cardinality":"N:1","nullable":false,"on":{"localFields":["authorId"],"targetFields":["id"]},"to":{"model":"User","namespace":"public"}},"tags":{"cardinality":"N:M","on":{"localFields":["id"],"targetFields":["A"]},"through":{"childColumns":["B"],"namespaceId":"public","parentColumns":["A"],"table":"_PostToTag","targetColumns":["id"]},"to":{"model":"Tag","namespace":"public"}}},"storage":{"fields":{"authorId":{"column":"authorId"},"content":{"column":"content"},"id":{"column":"id"},"published":{"column":"published"},"title":{"column":"title"},"viewCount":{"column":"viewCount"}},"namespaceId":"public","table":"Post"}},"PostToTag":{"fields":{"A":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}},"B":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}}},"relations":{"post":{"cardinality":"N:1","nullable":false,"on":{"localFields":["A"],"targetFields":["id"]},"to":{"model":"Post","namespace":"public"}},"tag":{"cardinality":"N:1","nullable":false,"on":{"localFields":["B"],"targetFields":["id"]},"to":{"model":"Tag","namespace":"public"}}},"storage":{"fields":{"A":{"column":"A"},"B":{"column":"B"}},"namespaceId":"public","table":"_PostToTag"}},"Tag":{"fields":{"id":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}},"name":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}}},"relations":{"posts":{"cardinality":"N:M","on":{"localFields":["id"],"targetFields":["B"]},"through":{"childColumns":["A"],"namespaceId":"public","parentColumns":["B"],"table":"_PostToTag","targetColumns":["id"]},"to":{"model":"Post","namespace":"public"}}},"storage":{"fields":{"id":{"column":"id"},"name":{"column":"name"}},"namespaceId":"public","table":"Tag"}},"User":{"fields":{"createdAt":{"nullable":false,"type":{"codecId":"pg/timestamp-date@1","kind":"scalar","typeParams":{"precision":3}}},"email":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"id":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}},"name":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"role":{"nullable":false,"type":{"codecId":"pg/enum@1","kind":"scalar","typeParams":{"typeName":"Role"}}},"updatedAt":{"nullable":false,"type":{"codecId":"pg/timestamp-date@1","kind":"scalar","typeParams":{"precision":3}}}},"relations":{"posts":{"cardinality":"1:N","on":{"localFields":["id"],"targetFields":["authorId"]},"to":{"model":"Post","namespace":"public"}}},"storage":{"fields":{"createdAt":{"column":"createdAt"},"email":{"column":"email"},"id":{"column":"id"},"name":{"column":"name"},"role":{"column":"role"},"updatedAt":{"column":"updatedAt"}},"namespaceId":"public","table":"User"}}}}}},"execution":{"executionHash":"380511653781da301aa7612ce64f0ac7b74bedf70c783143bab5ae56d5295037","mutations":{"defaults":[{"onCreate":{"id":"timestampNow","kind":"generator"},"ref":{"entry":"User","field":"createdAt","namespace":"public"}},{"onCreate":{"id":"timestampNow","kind":"generator"},"onUpdate":{"id":"timestampNow","kind":"generator"},"ref":{"entry":"User","field":"updatedAt","namespace":"public"}}]}},"extensions":{},"meta":{},"profileHash":"3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2","roots":{"Post":{"model":"Post","namespace":"public"},"Tag":{"model":"Tag","namespace":"public"},"User":{"model":"User","namespace":"public"},"_PostToTag":{"model":"PostToTag","namespace":"public"}},"schemaVersion":"1","storage":{"namespaces":{"public":{"entries":{"native_enum":{"Role":{"kind":"postgres-enum","members":["USER","ADMIN"],"typeName":"Role"}},"table":{"Post":{"columns":{"authorId":{"codecId":"pg/int4@1","dataType":"pg/int4","nullable":false},"content":{"codecId":"pg/text@1","dataType":"pg/text","nullable":true},"id":{"codecId":"pg/int4@1","dataType":"pg/int4","default":{"expression":"autoincrement()","kind":"function"},"nullable":false},"published":{"codecId":"pg/bool@1","dataType":"pg/bool","default":{"kind":"literal","value":false},"nullable":false},"title":{"codecId":"pg/text@1","dataType":"pg/text","nullable":false},"viewCount":{"codecId":"pg/int4@1","dataType":"pg/int4","default":{"kind":"literal","value":0},"nullable":false}},"foreignKeys":[{"onDelete":"restrict","onUpdate":"cascade","source":{"columns":["authorId"],"namespaceId":"public","tableName":"Post"},"target":{"columns":["id"],"namespaceId":"public","tableName":"User"}}],"indexes":[],"primaryKey":{"columns":["id"]},"uniques":[]},"Tag":{"columns":{"id":{"codecId":"pg/int4@1","dataType":"pg/int4","default":{"expression":"autoincrement()","kind":"function"},"nullable":false},"name":{"codecId":"pg/text@1","dataType":"pg/text","nullable":false}},"foreignKeys":[],"indexes":[{"columns":["name"],"name":"Tag_name_key","unique":true}],"primaryKey":{"columns":["id"]},"uniques":[]},"User":{"columns":{"createdAt":{"codecId":"pg/timestamp-date@1","dataType":"pg/timestamp","default":{"expression":"now()","kind":"function"},"nullable":false,"typeParams":{"precision":3}},"email":{"codecId":"pg/text@1","dataType":"pg/text","nullable":false},"id":{"codecId":"pg/int4@1","dataType":"pg/int4","default":{"expression":"autoincrement()","kind":"function"},"nullable":false},"name":{"codecId":"pg/text@1","dataType":"pg/text","nullable":true},"role":{"codecId":"pg/enum@1","dataType":"pg/enum","default":{"kind":"literal","value":"USER"},"nullable":false,"typeParams":{"typeName":"Role"},"valueSet":{"entityKind":"valueSet","entityName":"Role","namespaceId":"public","plane":"storage"}},"updatedAt":{"codecId":"pg/timestamp-date@1","dataType":"pg/timestamp","nullable":false,"typeParams":{"precision":3}}},"foreignKeys":[],"indexes":[{"columns":["email"],"name":"User_email_key","unique":true}],"primaryKey":{"columns":["id"]},"uniques":[]},"_PostToTag":{"columns":{"A":{"codecId":"pg/int4@1","dataType":"pg/int4","nullable":false},"B":{"codecId":"pg/int4@1","dataType":"pg/int4","nullable":false}},"foreignKeys":[{"index":{"primaryKey":true},"onDelete":"cascade","onUpdate":"cascade","source":{"columns":["A"],"namespaceId":"public","tableName":"_PostToTag"},"target":{"columns":["id"],"namespaceId":"public","tableName":"Post"}},{"index":{"name":"_PostToTag_B_index"},"onDelete":"cascade","onUpdate":"cascade","source":{"columns":["B"],"namespaceId":"public","tableName":"_PostToTag"},"target":{"columns":["id"],"namespaceId":"public","tableName":"Tag"}}],"indexes":[{"columns":["B"],"name":"_PostToTag_B_index","unique":false}],"primaryKey":{"columns":["A","B"],"name":"_PostToTag_AB_pkey"},"uniques":[]}},"valueSet":{"Role":{"kind":"valueSet","values":["USER","ADMIN"]}}},"id":"public","kind":"postgres-schema"}},"storageHash":"97963644c7d9359d5e31879ed1c6bfdc39b666d0fd1d0f152525b055d3c574c3"},"target":"postgres","targetFamily":"sql"} diff --git a/examples/prisma7-adoption/migrations/snapshots/cee288aa57be618663c6c6118ece25e6ae2049027a7337e8e04c1742faedfff3/contract.json b/examples/prisma7-adoption/migrations/snapshots/cee288aa57be618663c6c6118ece25e6ae2049027a7337e8e04c1742faedfff3/contract.json deleted file mode 100644 index 2b53b07db2ba..000000000000 --- a/examples/prisma7-adoption/migrations/snapshots/cee288aa57be618663c6c6118ece25e6ae2049027a7337e8e04c1742faedfff3/contract.json +++ /dev/null @@ -1 +0,0 @@ -{"_generated":{"message":"This file is automatically generated by \"prisma contract emit\".","regenerate":"To regenerate, run: prisma contract emit","warning":"⚠️ GENERATED FILE - DO NOT EDIT"},"capabilities":{"postgres":{"distinctOn":true,"forKeyShare":true,"forNoKeyUpdate":true,"jsonAgg":true,"lateral":true,"limit":true,"orderBy":true,"returning":true},"sql":{"checkConstraint":true,"defaultInInsert":true,"enums":true,"forShare":true,"forUpdate":true,"insertOnConflictSkip":true,"insertOnConflictWithoutTarget":true,"lateral":true,"lockNowait":true,"lockOf":true,"lockSkipLocked":true,"returning":true,"scalarList":true}},"domain":{"namespaces":{"public":{"models":{"Post":{"fields":{"authorId":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}},"content":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"id":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}},"published":{"nullable":false,"type":{"codecId":"pg/bool@1","kind":"scalar"}},"title":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"viewCount":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}}},"relations":{"author":{"cardinality":"N:1","nullable":false,"on":{"localFields":["authorId"],"targetFields":["id"]},"to":{"model":"User","namespace":"public"}},"tags":{"cardinality":"N:M","on":{"localFields":["id"],"targetFields":["A"]},"through":{"childColumns":["B"],"namespaceId":"public","parentColumns":["A"],"table":"_PostToTag","targetColumns":["id"]},"to":{"model":"Tag","namespace":"public"}}},"storage":{"fields":{"authorId":{"column":"authorId"},"content":{"column":"content"},"id":{"column":"id"},"published":{"column":"published"},"title":{"column":"title"},"viewCount":{"column":"viewCount"}},"namespaceId":"public","table":"Post"}},"PostToTag":{"fields":{"A":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}},"B":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}}},"relations":{"post":{"cardinality":"N:1","nullable":false,"on":{"localFields":["A"],"targetFields":["id"]},"to":{"model":"Post","namespace":"public"}},"tag":{"cardinality":"N:1","nullable":false,"on":{"localFields":["B"],"targetFields":["id"]},"to":{"model":"Tag","namespace":"public"}}},"storage":{"fields":{"A":{"column":"A"},"B":{"column":"B"}},"namespaceId":"public","table":"_PostToTag"}},"Tag":{"fields":{"id":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}},"name":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}}},"relations":{"posts":{"cardinality":"N:M","on":{"localFields":["id"],"targetFields":["B"]},"through":{"childColumns":["A"],"namespaceId":"public","parentColumns":["B"],"table":"_PostToTag","targetColumns":["id"]},"to":{"model":"Post","namespace":"public"}}},"storage":{"fields":{"id":{"column":"id"},"name":{"column":"name"}},"namespaceId":"public","table":"Tag"}},"User":{"fields":{"createdAt":{"nullable":false,"type":{"codecId":"pg/timestamp-string@1","kind":"scalar","typeParams":{"precision":3}}},"email":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"id":{"nullable":false,"type":{"codecId":"pg/int4@1","kind":"scalar"}},"name":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"role":{"nullable":false,"type":{"codecId":"pg/enum@1","kind":"scalar","typeParams":{"typeName":"Role"}}},"updatedAt":{"nullable":false,"type":{"codecId":"pg/timestamp-string@1","kind":"scalar","typeParams":{"precision":3}}}},"relations":{"posts":{"cardinality":"1:N","on":{"localFields":["id"],"targetFields":["authorId"]},"to":{"model":"Post","namespace":"public"}}},"storage":{"fields":{"createdAt":{"column":"createdAt"},"email":{"column":"email"},"id":{"column":"id"},"name":{"column":"name"},"role":{"column":"role"},"updatedAt":{"column":"updatedAt"}},"namespaceId":"public","table":"User"}}}}}},"execution":{"executionHash":"76ee8948067fff39e1b787eafdf2c9319ebe5e0f5029f71536b4f58b92d7790e","mutations":{"defaults":[{"onCreate":{"id":"timestampNow","kind":"generator"},"onUpdate":{"id":"timestampNow","kind":"generator"},"ref":{"entry":"User","field":"updatedAt","namespace":"public"}}]}},"extensions":{},"meta":{},"profileHash":"3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2","roots":{"Post":{"model":"Post","namespace":"public"},"Tag":{"model":"Tag","namespace":"public"},"User":{"model":"User","namespace":"public"},"_PostToTag":{"model":"PostToTag","namespace":"public"}},"schemaVersion":"1","storage":{"namespaces":{"public":{"entries":{"native_enum":{"Role":{"kind":"postgres-enum","members":["USER","ADMIN"],"typeName":"Role"}},"table":{"Post":{"columns":{"authorId":{"codecId":"pg/int4@1","dataType":"pg/int4","nullable":false},"content":{"codecId":"pg/text@1","dataType":"pg/text","nullable":true},"id":{"codecId":"pg/int4@1","dataType":"pg/int4","default":{"expression":"autoincrement()","kind":"function"},"nullable":false},"published":{"codecId":"pg/bool@1","dataType":"pg/bool","default":{"kind":"literal","value":false},"nullable":false},"title":{"codecId":"pg/text@1","dataType":"pg/text","nullable":false},"viewCount":{"codecId":"pg/int4@1","dataType":"pg/int4","default":{"kind":"literal","value":0},"nullable":false}},"foreignKeys":[{"onDelete":"restrict","onUpdate":"cascade","source":{"columns":["authorId"],"namespaceId":"public","tableName":"Post"},"target":{"columns":["id"],"namespaceId":"public","tableName":"User"}}],"indexes":[],"primaryKey":{"columns":["id"]},"uniques":[]},"Tag":{"columns":{"id":{"codecId":"pg/int4@1","dataType":"pg/int4","default":{"expression":"autoincrement()","kind":"function"},"nullable":false},"name":{"codecId":"pg/text@1","dataType":"pg/text","nullable":false}},"foreignKeys":[],"indexes":[{"columns":["name"],"name":"Tag_name_key","unique":true}],"primaryKey":{"columns":["id"]},"uniques":[]},"User":{"columns":{"createdAt":{"codecId":"pg/timestamp-string@1","dataType":"pg/timestamp","default":{"expression":"now()","kind":"function"},"nullable":false,"typeParams":{"precision":3}},"email":{"codecId":"pg/text@1","dataType":"pg/text","nullable":false},"id":{"codecId":"pg/int4@1","dataType":"pg/int4","default":{"expression":"autoincrement()","kind":"function"},"nullable":false},"name":{"codecId":"pg/text@1","dataType":"pg/text","nullable":true},"role":{"codecId":"pg/enum@1","dataType":"pg/enum","default":{"kind":"literal","value":"USER"},"nullable":false,"typeParams":{"typeName":"Role"},"valueSet":{"entityKind":"valueSet","entityName":"Role","namespaceId":"public","plane":"storage"}},"updatedAt":{"codecId":"pg/timestamp-string@1","dataType":"pg/timestamp","nullable":false,"typeParams":{"precision":3}}},"foreignKeys":[],"indexes":[{"columns":["email"],"name":"User_email_key","unique":true}],"primaryKey":{"columns":["id"]},"uniques":[]},"_PostToTag":{"columns":{"A":{"codecId":"pg/int4@1","dataType":"pg/int4","nullable":false},"B":{"codecId":"pg/int4@1","dataType":"pg/int4","nullable":false}},"foreignKeys":[{"onDelete":"cascade","onUpdate":"cascade","source":{"columns":["A"],"namespaceId":"public","tableName":"_PostToTag"},"target":{"columns":["id"],"namespaceId":"public","tableName":"Post"}},{"onDelete":"cascade","onUpdate":"cascade","source":{"columns":["B"],"namespaceId":"public","tableName":"_PostToTag"},"target":{"columns":["id"],"namespaceId":"public","tableName":"Tag"}}],"indexes":[{"columns":["B"],"name":"_PostToTag_B_index","unique":false}],"primaryKey":{"columns":["A","B"],"name":"_PostToTag_AB_pkey"},"uniques":[]}},"valueSet":{"Role":{"kind":"valueSet","values":["USER","ADMIN"]}}},"id":"public","kind":"postgres-schema"}},"storageHash":"cee288aa57be618663c6c6118ece25e6ae2049027a7337e8e04c1742faedfff3"},"target":"postgres","targetFamily":"sql"} diff --git a/examples/prisma7-adoption/src/main.ts b/examples/prisma7-adoption/src/main.ts index 07b122ad282e..f26d6e9c9c40 100644 --- a/examples/prisma7-adoption/src/main.ts +++ b/examples/prisma7-adoption/src/main.ts @@ -40,7 +40,7 @@ const renamed = await db.orm.public.User.where({ id: alice.id }).update({ name: `Alice (renamed by Prisma 8 at ${new Date().toISOString()})`, }); console.log( - `updatedAt advanced: ${alice.updatedAt.replace(' ', 'T')} -> ${renamed?.updatedAt.replace(' ', 'T')}`, + `updatedAt advanced: ${alice.updatedAt.toISOString()} -> ${renamed?.updatedAt.toISOString()}`, ); await prisma.$disconnect(); diff --git a/examples/prisma7-adoption/test/adoption.test.ts b/examples/prisma7-adoption/test/adoption.test.ts index 1b0ebd10dce0..434e3ee10225 100644 --- a/examples/prisma7-adoption/test/adoption.test.ts +++ b/examples/prisma7-adoption/test/adoption.test.ts @@ -56,8 +56,12 @@ describe('adopting Prisma 8 beside Prisma 7', () => { expect(prisma8Read).toMatch(/Created post \d+ through Prisma 8, tagged orm/); const advanced = /updatedAt advanced: (\S+) -> (\S+)/.exec(prisma8Read); expect(advanced, prisma8Read).not.toBeNull(); - const [, before, after] = advanced ?? []; - expect(new Date(`${after}Z`).getTime()).toBeGreaterThan(new Date(`${before}Z`).getTime()); + const [, before = '', after = ''] = advanced ?? []; + expect({ before, after }).toEqual({ + before: new Date(before).toISOString(), + after: new Date(after).toISOString(), + }); + expect(new Date(after).getTime()).toBeGreaterThan(new Date(before).getTime()); expect(await tsx('src/v7-read.ts')).toContain('Written through Prisma 8'); writeFileSync(join(dir, 'prisma/schema.prisma'), FINAL_SCHEMA); diff --git a/examples/react-router-demo/src/prisma/contract.prisma b/examples/react-router-demo/src/prisma/contract.prisma index 6dbca00db14c..b7eee4922a9a 100644 --- a/examples/react-router-demo/src/prisma/contract.prisma +++ b/examples/react-router-demo/src/prisma/contract.prisma @@ -3,7 +3,7 @@ model User { id Char(36) @id @onCreate(uuid()) email String - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) posts Post[] @@map("user") @@ -13,7 +13,7 @@ model Post { id Char(36) @id @onCreate(uuid()) title String userId String - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) user User @relation(fields: [userId], references: [id]) diff --git a/examples/react-router-demo/src/prisma/contract.ts b/examples/react-router-demo/src/prisma/contract.ts index 9c04a5bd4349..1737bd266ad8 100644 --- a/examples/react-router-demo/src/prisma/contract.ts +++ b/examples/react-router-demo/src/prisma/contract.ts @@ -5,8 +5,11 @@ export const contract = defineContract({}, ({ field, model, gen, type }) => { fields: { id: field.column(type.Char(36)).id().onCreate(gen.uuid()), email: field.column(type.String()), - createdAt: field.column(type.temporal.CreatedAt()), - updatedAt: field.column(type.temporal.UpdatedAt()), + createdAt: field.column(type.DateTime({ as: 'temporal' })).onCreate(gen.now()), + updatedAt: field + .column(type.DateTime({ as: 'temporal' })) + .onCreate(gen.now()) + .onUpdate(gen.now()), }, }); @@ -15,8 +18,11 @@ export const contract = defineContract({}, ({ field, model, gen, type }) => { id: field.column(type.Char(36)).id().onCreate(gen.uuid()), title: field.column(type.String()), userId: field.column(type.Char(36)), - createdAt: field.column(type.temporal.CreatedAt()), - updatedAt: field.column(type.temporal.UpdatedAt()), + createdAt: field.column(type.DateTime({ as: 'temporal' })).onCreate(gen.now()), + updatedAt: field + .column(type.DateTime({ as: 'temporal' })) + .onCreate(gen.now()) + .onUpdate(gen.now()), }, }); diff --git a/gotchas.md b/gotchas.md index 7ec0c24dec36..d99dfb4281a9 100644 --- a/gotchas.md +++ b/gotchas.md @@ -132,7 +132,7 @@ The same command with `--from 20260707T1005_init` (a migration directory name) s **Workaround.** `import 'temporal-polyfill/full/global'` before the client is created (the example did so in `src/db.ts` before the fix), or author the column with a `*String` type, such as `TimestamptzString`, to receive ISO 8601 text. -**Resolved.** `prisma7Schema(...)` and `contract infer` now write `TimestampString(3)`, `TimestamptzString(p)`, `DateString` and `TimeString(p)` for these columns, so they read as ISO 8601 text, `@updatedAt` writes UTC text, and the application needs no `Temporal`. A column written by hand as `DateTime`, `TimestampTemporal(p)`, `Date` or `Time(p)` still reads as a `Temporal` value and still needs one. +**Resolved.** `prisma7Schema(...)` and `contract infer` now write the convenience types' defaults for these columns: `DateTime(storage: timestamp)` (`TimestampJsDate(3)`) and `DateTime(precision: p)` (`TimestamptzJsDate(p)`), which read a JavaScript `Date` as Prisma 7 did, and `Date`, `Time(precision: p)` and `Time(storage: timetz)`, which read ISO strings. `@updatedAt` writes a `Date`, and the application needs no `Temporal`. A column written by hand as `TimestamptzTemporal(p)`, `TimestampTemporal(p)`, `DateTemporal` or `TimeTemporal(p)`, or as a convenience type with `as: temporal`, still reads as a `Temporal` value and still needs one; the convenience types `DateTime`, `Date` and `Time` need none at their defaults. **Reproduction.** 1. `cd examples/prisma7-adoption && pnpm db:start`, then `pnpm v7:migrate && pnpm emit && pnpm sign && pnpm seed`. diff --git a/packages/1-framework/1-core/framework-components/README.md b/packages/1-framework/1-core/framework-components/README.md index 14fd645a130d..bf181f1015de 100644 --- a/packages/1-framework/1-core/framework-components/README.md +++ b/packages/1-framework/1-core/framework-components/README.md @@ -7,7 +7,7 @@ Framework component types, authoring logic, control stack assembly, and emission ## What this package provides - **Component types** (`./components`): Base descriptor and instance interfaces for framework components (family, target, adapter, driver, extension), pack refs, and type renderer system -- **Authoring types** (`./authoring`): Declarative authoring contribution types, template resolution, and validation for type constructors and type aliases. `findAuthoringTypeConstructorCall(namespace, output)` is the inverse of `instantiateAuthoringTypeConstructor`: the first call to a constructor in a namespace that produces a given codec, native type and type parameters. `assembleDataTypeSupport(components, dataTypeOfCodec)` builds the data types and authoring entries every contract source reads values with, refusing duplicates and value arguments whose data type is unregistered or unwritten; the control stack and the TypeScript builder both call it. `readWrittenValueIntoCodec` reads a written value, a list included, into a codec reference by the cast rule, which every family's enum members and `@@base` values go through; `applyDiscriminatorRule` decides whether a field can be a discriminator and which of its variants' values it accepts, and `describeDiscriminatorRefusal` words its refusals for PSL +- **Authoring types** (`./authoring`): Declarative authoring contribution types, template resolution, and validation for type constructors, type aliases and type choices. A type choice (`kind: 'typeChoice'`) chooses a type constructor of its namespace from its option arguments, each of which (`kind: 'option'`) takes one of a list of bare names; the option kind exists only for type choice arguments: `resolveAuthoringTypeChoice` resolves a call, `typeChoiceArgumentsFromObject` reads the one object a TypeScript helper call writes into the choice's argument positions, `instantiateAuthoringTypeCall` instantiates a constructor or choice by name, and `assertTypeChoicesValid` refuses an unresolvable table at assembly (ADR 279). `findAuthoringTypeConstructorCall(namespace, output)` is the inverse of `instantiateAuthoringTypeConstructor`: the first call to a constructor in a namespace that produces a given codec, native type and type parameters. `assembleDataTypeSupport(components, dataTypeOfCodec)` builds the data types and authoring entries every contract source reads values with, refusing duplicates and value arguments whose data type is unregistered or unwritten; the control stack and the TypeScript builder both call it. `readWrittenValueIntoCodec` reads a written value, a list included, into a codec reference by the cast rule, which every family's enum members and `@@base` values go through; `applyDiscriminatorRule` decides whether a field can be a discriminator and which of its variants' values it accepts, and `describeDiscriminatorRefusal` words its refusals for PSL - **Codec base interface** (`./codec`): The cross-family `Codec` base type that SQL `Codec` and Mongo `MongoCodec` extend, plus the helpers targets declare data types and authoring entries with: shared casts, the number classifier, JSON text reading and printing, numeral text, and the date and time stored-form readers - **Control stack** (`./control`): Assembly functions that combine component descriptors into a unified `ControlStack` with derived state (codec imports, renderers, authoring contributions) - **Emission SPI** (`./emission`): Types for the emission pipeline — `TargetFamilyHook`, `GenerateContractTypesOptions`, `TypeRenderEntry`, `TypeRenderer`, `ParameterizedCodecDescriptor`, and related types diff --git a/packages/1-framework/1-core/framework-components/src/control/control-stack.ts b/packages/1-framework/1-core/framework-components/src/control/control-stack.ts index 4fe286f31a30..860037f52b83 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-stack.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-stack.ts @@ -3,6 +3,10 @@ import { blindCast } from '@internal/utils/casts'; import { InternalError, isInternalError } from '@internal/utils/internal-error'; import type { AggregateDescriptor } from '../shared/aggregate-descriptor'; import { aggregateDescriptorKey, isAggregateDescriptor } from '../shared/aggregate-descriptor'; +import { + assertTypeChoicesValid, + collectScalarTypeConstructors, +} from '../shared/authoring-type-choice'; import type { CapabilityMatrix } from '../shared/capabilities'; import { mergeCapabilityMatrices } from '../shared/capabilities'; import type { Codec } from '../shared/codec'; @@ -34,7 +38,6 @@ import { assertResolvableTypeConstructorTemplates, assertTypeAliasesValid, collectContributedDescriptorPaths, - collectScalarTypeConstructors, isAuthoringArgRef, mergeAuthoringAttributeSpecs, mergeAuthoringNamespaces, @@ -346,6 +349,10 @@ export function assembleAuthoringContributions( pslBlockDescriptorNamespace, modelAttributeNamespace, ); + for (const descriptor of descriptors) { + if (descriptor.authoring?.type === undefined) continue; + assertTypeChoicesValid(descriptor.authoring.type, descriptor.id ?? ''); + } if ( valueObjectStorageDeclaration !== undefined && diff --git a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts index 37ec58801796..1a6219d56f34 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts @@ -1,3 +1,28 @@ +export type { + AuthoringTypeChoiceArgument, + AuthoringTypeChoiceDescriptor, + AuthoringTypeChoiceOption, + AuthoringTypeChoicePassedArgument, + AuthoringTypeChoiceRow, + ResolvedAuthoringTypeChoice, +} from '../shared/authoring-type-choice'; +export { + assertTypeChoicesValid, + collectScalarTypeConstructors, + getAuthoringTypeChoice, + instantiateAuthoringTypeCall, + isAuthoringTypeChoiceOption, + resolveAuthoringTypeChoice, + typeChoiceArgumentsFromObject, + typeChoiceCombinationText, + typeConstructorLookup, + unknownTypeChoiceArgumentMessage, +} from '../shared/authoring-type-choice'; +export type { + AuthoringTypeChoiceCall, + AuthoringTypeChoiceCallArgument, +} from '../shared/authoring-type-choice-call'; +export { findAuthoringTypeChoiceCall } from '../shared/authoring-type-choice-call'; export type { AuthoringTypeConstructorCall, AuthoringTypeConstructorOutput, @@ -7,6 +32,13 @@ export { authoringTypeConstructors, findAuthoringTypeConstructorCall, } from '../shared/authoring-type-constructor-call'; +export { + type TypedLiteralOptions, + writeDataTypeValue, + writeTypedLiteral, + writtenTypeArguments, + writtenTypeConstructorCall, +} from '../shared/data-type-literal'; export type { AssembledDataTypeSupport, DataTypeComponent, @@ -71,6 +103,7 @@ export type { AuthoringTypeConstructorDescriptor, AuthoringTypeConstructorEntityRef, AuthoringTypeNamespace, + AuthoringTypeNamespaceMember, AuthoringTypeParamsIssue, AuthoringWarning, AuthoringWarningSink, @@ -85,7 +118,6 @@ export { assertResolvableTypeConstructorTemplates, assertTypeAliasesValid, classifyEnumMemberType, - collectScalarTypeConstructors, flushAuthoringWarnings, getAuthoringTypeConstructor, instantiateAuthoringEntityType, @@ -97,6 +129,7 @@ export { isAuthoringModelAttributeDescriptor, isAuthoringPslBlockDescriptor, isAuthoringTypeAliasDescriptor, + isAuthoringTypeChoiceDescriptor, isAuthoringTypeConstructorDescriptor, isAuthoringTypeDescriptor, mergeAuthoringNamespaces, @@ -104,6 +137,7 @@ export { resolveEnumCodecId, resolveTypeAliasGenerators, validateAuthoringHelperArguments, + validateAuthoringTypeChoiceParams, validateAuthoringTypeParams, } from '../shared/framework-authoring'; export type { diff --git a/packages/1-framework/1-core/framework-components/src/shared/authoring-type-choice-call.ts b/packages/1-framework/1-core/framework-components/src/shared/authoring-type-choice-call.ts new file mode 100644 index 000000000000..8334a1c03529 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/shared/authoring-type-choice-call.ts @@ -0,0 +1,137 @@ +import { isInternalError } from '@internal/utils/internal-error'; +import { + type AuthoringTypeChoiceDescriptor, + authoringTypeNamespaceLevel, + instantiateAuthoringTypeCall, + isAuthoringTypeChoiceOption, + resolveAuthoringTypeChoice, + typeConstructorLookup, +} from './authoring-type-choice'; +import { + type AuthoringTypeConstructorCall, + type AuthoringTypeConstructorOutput, + sameAuthoringTypeConstructorOutput, +} from './authoring-type-constructor-call'; +import { + type AuthoringTypeNamespace, + instantiateAuthoringTypeConstructor, + isAuthoringTypeChoiceDescriptor, +} from './framework-authoring'; + +/** One argument of a type choice call, written by name. */ +export interface AuthoringTypeChoiceCallArgument { + readonly name: string; + readonly value: unknown; +} + +/** A type choice, by its path in an authoring namespace, and the arguments of one call to it in the choice's argument order; an argument left out takes its default. */ +export interface AuthoringTypeChoiceCall { + readonly path: readonly string[]; + readonly args: readonly AuthoringTypeChoiceCallArgument[]; + /** The type constructor call the type choice call becomes. */ + readonly becomes: AuthoringTypeConstructorCall; +} + +interface ChoiceNaming { + readonly name: string; + readonly descriptor: AuthoringTypeChoiceDescriptor; + readonly when: Readonly>; +} + +function choicesNaming( + level: AuthoringTypeNamespace, + constructorName: string, +): readonly ChoiceNaming[] { + return Object.entries(level).flatMap(([name, member]) => { + if (!isAuthoringTypeChoiceDescriptor(member)) return []; + const row = member.choices.find((candidate) => candidate.type === constructorName); + return row === undefined ? [] : [{ name, descriptor: member, when: row.when }]; + }); +} + +function outputOf(produce: () => AuthoringTypeConstructorOutput) { + try { + return produce(); + } catch (error) { + if (isInternalError(error)) throw error; + return undefined; + } +} + +/** + * The call to a type choice that becomes the type constructor call `call`: of the type choices at the constructor's level with a row naming the constructor, the first in namespace order whose call gives the same output. Its options come from that row and its passed-on arguments from the call, and every argument whose omission still gives the same output is left out. `sameOutput` decides what counts as the same output; by default the codec and type parameters must be exactly equal. `undefined` when no type choice names the constructor or no call to one gives the same output. + */ +export function findAuthoringTypeChoiceCall(input: { + readonly namespace: AuthoringTypeNamespace; + readonly call: AuthoringTypeConstructorCall; + readonly sameOutput?: ( + produced: AuthoringTypeConstructorOutput, + wanted: AuthoringTypeConstructorOutput, + ) => boolean; +}): AuthoringTypeChoiceCall | undefined { + const { call } = input; + const sameOutput = input.sameOutput ?? sameAuthoringTypeConstructorOutput; + const prefix = call.path.slice(0, -1); + const constructorName = call.path.at(-1); + const level = authoringTypeNamespaceLevel(input.namespace, prefix); + if (level === undefined || constructorName === undefined) return undefined; + const explicit = typeConstructorLookup(level)(constructorName); + const wanted = + explicit && outputOf(() => instantiateAuthoringTypeConstructor(explicit, call.args)); + if (explicit === undefined || wanted === undefined) return undefined; + + const passedValue = (name: string) => + call.args[(explicit.args ?? []).findIndex((parameter) => parameter.name === name)]; + for (const choice of choicesNaming(level, constructorName)) { + const found = choiceCallReproducing({ + level, + prefix, + choice, + passedValue, + reproduces: (produced) => sameOutput(produced, wanted), + }); + if (found !== undefined) return found; + } + return undefined; +} + +function choiceCallReproducing(input: { + readonly level: AuthoringTypeNamespace; + readonly prefix: readonly string[]; + readonly choice: ChoiceNaming; + readonly passedValue: (name: string) => unknown; + readonly reproduces: (produced: AuthoringTypeConstructorOutput) => boolean; +}): AuthoringTypeChoiceCall | undefined { + const { level, prefix, choice } = input; + const reproduces = (args: readonly unknown[]) => { + const produced = outputOf(() => instantiateAuthoringTypeCall(level, choice.name, args)); + return produced !== undefined && input.reproduces(produced); + }; + + const args: unknown[] = choice.descriptor.args.map((argument) => + isAuthoringTypeChoiceOption(argument) + ? choice.when[argument.name] + : input.passedValue(argument.name), + ); + if (!reproduces(args)) return undefined; + for (const index of args.keys()) { + const without = args.map((value, at) => (at === index ? undefined : value)); + if (args[index] !== undefined && reproduces(without)) args[index] = undefined; + } + + const chosen = resolveAuthoringTypeChoice({ + helperPath: choice.name, + descriptor: choice.descriptor, + args, + constructorNamed: typeConstructorLookup(level), + }); + const chosenArgs = [...chosen.args]; + while (chosenArgs.length > 0 && chosenArgs.at(-1) === undefined) chosenArgs.pop(); + return { + path: [...prefix, choice.name], + args: choice.descriptor.args.flatMap((argument, index) => + args[index] === undefined ? [] : [{ name: argument.name, value: args[index] }], + ), + becomes: { path: [...prefix, chosen.type], args: chosenArgs }, + }; +} diff --git a/packages/1-framework/1-core/framework-components/src/shared/authoring-type-choice.ts b/packages/1-framework/1-core/framework-components/src/shared/authoring-type-choice.ts new file mode 100644 index 000000000000..bed3a665919c --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/shared/authoring-type-choice.ts @@ -0,0 +1,431 @@ +import type { JsonValue } from '@internal/contract/types'; +import { InternalError } from '@internal/utils/internal-error'; +import { + type AuthoringArgumentDescriptor, + type AuthoringTypeConstructorDescriptor, + type AuthoringTypeNamespace, + type AuthoringTypeNamespaceMember, + instantiateAuthoringTypeConstructor, + isAuthoringTypeChoiceDescriptor, + isAuthoringTypeConstructorDescriptor, + isAuthoringTypeDescriptor, + type ScalarTypeConstructorOutput, +} from './framework-authoring'; +import { runtimeError } from './runtime-error'; + +/** An argument of a type choice that picks its row: one of `values`, written as a bare name, and `default` when it is left out. */ +export interface AuthoringTypeChoiceOption { + readonly kind: 'option'; + readonly values: readonly string[]; + readonly name: string; + readonly default: string; + readonly optional: true; +} + +/** An argument a type choice passes on, by name, to the constructor its row names. */ +export type AuthoringTypeChoicePassedArgument = AuthoringArgumentDescriptor & { + readonly name: string; + readonly optional: true; +}; + +export type AuthoringTypeChoiceArgument = + | AuthoringTypeChoiceOption + | AuthoringTypeChoicePassedArgument; + +/** One allowed combination of option values, the type constructor it chooses, and the values of passed-on arguments when they are left out. */ +export interface AuthoringTypeChoiceRow { + readonly when: Readonly>; + /** A type constructor in the same namespace as the type choice. */ + readonly type: string; + readonly defaults?: Readonly>; +} + +/** + * A type whose arguments choose the type constructor it becomes. Every argument is optional, so it is also written bare. A combination of option values with no row is refused. ADR 279. + */ +export interface AuthoringTypeChoiceDescriptor { + readonly kind: 'typeChoice'; + readonly documentation?: string; + readonly args: readonly AuthoringTypeChoiceArgument[]; + readonly choices: readonly AuthoringTypeChoiceRow[]; +} + +/** The constructor a type choice call chooses, by its name, and the arguments it is called with. */ +export interface ResolvedAuthoringTypeChoice { + readonly type: string; + readonly descriptor: AuthoringTypeConstructorDescriptor; + readonly args: readonly unknown[]; +} + +export function isAuthoringTypeChoiceOption( + argument: AuthoringTypeChoiceArgument, +): argument is AuthoringTypeChoiceOption { + return argument.kind === 'option'; +} + +function rowMatches( + row: AuthoringTypeChoiceRow, + chosen: ReadonlyMap, + names: readonly string[], +): boolean { + return names.every((name) => row.when[name] === chosen.get(name)); +} + +/** How a combination of option values is written, as in `as: string, storage: timestamp`. */ +export function typeChoiceCombinationText(row: AuthoringTypeChoiceRow): string { + return Object.entries(row.when) + .map(([name, value]) => `${name}: ${value}`) + .join(', '); +} + +function listedNames(names: readonly string[]): string { + return names.length < 2 + ? names.join('') + : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; +} + +function isArgumentObject(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Why `subject`, a call of the type choice `descriptor`, cannot take the argument `name`, naming the arguments it takes. */ +export function unknownTypeChoiceArgumentMessage( + subject: string, + descriptor: AuthoringTypeChoiceDescriptor, + name: string, +): string { + const names = descriptor.args.map((argument) => argument.name); + return `${subject} has no argument "${name}"; its arguments are ${listedNames(names)}.`; +} + +/** + * The values of a type choice call that writes its arguments as one object, by the choice's argument positions, as {@link resolveAuthoringTypeChoice} reads them. `written` is the call's arguments: none, or one object keyed by argument name. Anything else, and a key the choice has no argument for, is refused with `CONTRACT.ARGUMENT_INVALID`. + */ +export function typeChoiceArgumentsFromObject( + helperPath: string, + descriptor: AuthoringTypeChoiceDescriptor, + written: readonly unknown[], +): readonly unknown[] { + const names = descriptor.args.map((argument) => argument.name); + const [first] = written; + const object = written.length === 0 ? {} : first; + if (written.length > 1 || !isArgumentObject(object)) { + const example = descriptor.args.find(isAuthoringTypeChoiceOption); + const sample = example === undefined ? '' : `{ ${example.name}: '${example.default}' }`; + throw runtimeError( + 'CONTRACT.ARGUMENT_INVALID', + `${helperPath} takes its arguments as one object with the keys ${listedNames(names)}, such as ${helperPath}(${sample}).`, + { helperPath }, + ); + } + const unknown = Object.keys(object).find((key) => !names.includes(key)); + if (unknown !== undefined) { + throw runtimeError( + 'CONTRACT.ARGUMENT_INVALID', + unknownTypeChoiceArgumentMessage(helperPath, descriptor, unknown), + { helperPath }, + ); + } + return names.map((name) => object[name]); +} + +/** + * The type constructor a call of the type choice `descriptor` chooses, and the arguments it passes on. `args` are the call's values by the choice's argument positions; an option left out takes its default. A value an option does not list, a combination with no row, and a passed-on argument the chosen constructor does not take are refused with `CONTRACT.ARGUMENT_INVALID`, naming the argument by its index. + */ +export function resolveAuthoringTypeChoice(input: { + readonly helperPath: string; + readonly descriptor: AuthoringTypeChoiceDescriptor; + readonly args: readonly unknown[]; + readonly constructorNamed: (name: string) => AuthoringTypeConstructorDescriptor | undefined; +}): ResolvedAuthoringTypeChoice { + const { helperPath, descriptor, args } = input; + const refuse = (argumentIndex: number, message: string): never => { + throw runtimeError('CONTRACT.ARGUMENT_INVALID', message, { helperPath, argumentIndex }); + }; + const label = (index: number) => `Argument "${descriptor.args[index]?.name}" of ${helperPath}`; + + const options = descriptor.args.flatMap((argument, index) => + isAuthoringTypeChoiceOption(argument) ? [{ argument, index }] : [], + ); + const written = options.filter(({ index }) => args[index] !== undefined); + const defaulted = options.filter(({ index }) => args[index] === undefined); + + const chosen = new Map(); + const decided: string[] = []; + for (const { argument, index } of [...written, ...defaulted]) { + const value = args[index] ?? argument.default; + if (typeof value !== 'string' || !argument.values.includes(value)) { + refuse( + index, + `${label(index)} is ${String(value)}; write one of: ${argument.values.join(', ')}.`, + ); + } + const fitting = descriptor.choices.filter((row) => rowMatches(row, chosen, decided)); + const accepted = argument.values.filter((candidate) => + fitting.some((row) => row.when[argument.name] === candidate), + ); + if (!accepted.includes(String(value))) { + const others = decided.map((name) => `${name}: ${chosen.get(name)}`).join(', '); + refuse( + index, + `${label(index)} cannot be ${String(value)} with ${others}; write one of: ${accepted.join(', ')}.`, + ); + } + chosen.set(argument.name, String(value)); + decided.push(argument.name); + } + + const row = descriptor.choices.find((candidate) => rowMatches(candidate, chosen, decided)); + const chosenType = row === undefined ? undefined : input.constructorNamed(row.type); + if (row === undefined || chosenType === undefined) { + throw runtimeError( + 'CONTRACT.PACK_CONTRIBUTION_INVALID', + `Type choice "${helperPath}" chooses no registered type constructor for ${[...chosen].map(([name, value]) => `${name}: ${value}`).join(', ')}.`, + { helperPath }, + ); + } + + const parameters = chosenType.args ?? []; + descriptor.args.forEach((argument, index) => { + if (isAuthoringTypeChoiceOption(argument) || args[index] === undefined) return; + if (!parameters.some((parameter) => parameter.name === argument.name)) { + refuse( + index, + `${label(index)} does not apply to ${row.type}, the type that ${typeChoiceCombinationText(row)} chooses.`, + ); + } + }); + + return { + type: row.type, + descriptor: chosenType, + args: parameters.map((parameter) => { + const index = descriptor.args.findIndex( + (argument) => !isAuthoringTypeChoiceOption(argument) && argument.name === parameter.name, + ); + const passed = index < 0 ? undefined : args[index]; + return passed ?? (parameter.name === undefined ? undefined : row.defaults?.[parameter.name]); + }), + }; +} + +/** Looks a type constructor up by name in one level of a type namespace, as a type choice's rows name them. */ +export function typeConstructorLookup(namespace: { + readonly [name: string]: object; +}): (name: string) => AuthoringTypeConstructorDescriptor | undefined { + return (name) => { + const member = Object.hasOwn(namespace, name) ? namespace[name] : undefined; + return member !== undefined && isAuthoringTypeConstructorDescriptor(member) + ? member + : undefined; + }; +} + +function memberNamed( + namespace: AuthoringTypeNamespace, + name: string, +): AuthoringTypeNamespaceMember | undefined { + return Object.hasOwn(namespace, name) ? namespace[name] : undefined; +} + +function subNamespace( + member: AuthoringTypeNamespaceMember | undefined, +): AuthoringTypeNamespace | undefined { + return member === undefined || isAuthoringTypeDescriptor(member) ? undefined : member; +} + +/** The sub-namespace at `path` of a type namespace, or `undefined` when a segment names no sub-namespace. */ +export function authoringTypeNamespaceLevel( + namespace: AuthoringTypeNamespace | undefined, + path: readonly string[], +): AuthoringTypeNamespace | undefined { + let level = namespace; + for (const segment of path) { + level = level === undefined ? undefined : subNamespace(memberNamed(level, segment)); + } + return level; +} + +/** The type choice at `path` in the contributions' type namespace, with a lookup of the type constructors beside it that its rows name, or `undefined` when none is there. */ +export function getAuthoringTypeChoice( + contributions: { readonly type?: AuthoringTypeNamespace } | undefined, + path: readonly string[], +): + | { + readonly descriptor: AuthoringTypeChoiceDescriptor; + readonly constructorNamed: (name: string) => AuthoringTypeConstructorDescriptor | undefined; + } + | undefined { + const level = authoringTypeNamespaceLevel(contributions?.type, path.slice(0, -1)); + const name = path.at(-1); + const member = level === undefined || name === undefined ? undefined : memberNamed(level, name); + return member !== undefined && level !== undefined && isAuthoringTypeChoiceDescriptor(member) + ? { descriptor: member, constructorNamed: typeConstructorLookup(level) } + : undefined; +} + +/** + * The column a call of the type constructor or type choice `name`, in one level of a type namespace, makes from its arguments. A type choice's arguments choose a constructor of the same level, which is instantiated with the arguments it passes on. + */ +export function instantiateAuthoringTypeCall( + namespace: AuthoringTypeNamespace, + name: string, + args: readonly unknown[], +): ScalarTypeConstructorOutput { + const member = memberNamed(namespace, name); + if (member !== undefined && isAuthoringTypeConstructorDescriptor(member)) { + return instantiateAuthoringTypeConstructor(member, args); + } + if (member === undefined || !isAuthoringTypeChoiceDescriptor(member)) { + throw new InternalError( + `"${name}" is not a type constructor or type choice of this namespace.`, + ); + } + const chosen = resolveAuthoringTypeChoice({ + helperPath: name, + descriptor: member, + args, + constructorNamed: typeConstructorLookup(namespace), + }); + return instantiateAuthoringTypeConstructor(chosen.descriptor, chosen.args); +} + +/** + * Derives the scalar view of an assembled authoring type namespace: every + * **top-level** type constructor that is instantiable with an empty argument + * list — all declared args optional and no entity-ref argument. A bare type + * name `T` in a schema is semantically the zero-arg instantiation `T()`, so + * each entry is exactly what that call produces (defaulted template values + * applied, absent optional-arg typeParams keys omitted). Constructors + * registered under a namespace segment, constructors with required args, and + * entity-ref constructors are not scalars and are excluded. + * + * Eligibility needs no template inspection: templates that cannot resolve + * for their legal call shapes are rejected at the composition boundary by + * {@link assertResolvableTypeConstructorTemplates}, so the zero-arg + * instantiation below cannot fail for an eligible constructor. + */ +export function collectScalarTypeConstructors( + namespace: AuthoringTypeNamespace, +): ReadonlyMap { + const result = new Map(); + for (const [name, value] of Object.entries(namespace)) { + if (isAuthoringTypeChoiceDescriptor(value)) { + result.set(name, instantiateAuthoringTypeCall(namespace, name, [])); + continue; + } + if (!isAuthoringTypeConstructorDescriptor(value)) continue; + if (value.entityRefArg !== undefined) continue; + if (value.args?.some((arg) => arg.optional !== true)) continue; + result.set(name, instantiateAuthoringTypeConstructor(value, [])); + } + return result; +} + +function assertTypeChoiceValid( + path: string, + descriptor: AuthoringTypeChoiceDescriptor, + contributedBy: string, + level: AuthoringTypeNamespace, +): void { + const invalidError = (detail: string) => + runtimeError( + 'CONTRACT.PACK_CONTRIBUTION_INVALID', + `Type choice "${path}" contributed by "${contributedBy}" ${detail}`, + { packId: contributedBy, contribution: path }, + ); + const invalid = (detail: string): never => { + throw invalidError(detail); + }; + const constructorNamed = typeConstructorLookup(level); + const options = descriptor.args.filter(isAuthoringTypeChoiceOption); + const passed = descriptor.args.filter( + (argument): argument is AuthoringTypeChoicePassedArgument => + !isAuthoringTypeChoiceOption(argument), + ); + for (const option of options) { + if (!option.values.includes(option.default)) { + invalid(`defaults option "${option.name}" to ${option.default}, which it does not list.`); + } + } + for (const row of descriptor.choices) { + const covered = + Object.keys(row.when).length === options.length && + options.every((option) => option.values.includes(row.when[option.name] ?? '')); + if (!covered) { + invalid( + `has a row for ${typeChoiceCombinationText(row)}, which does not give every option one of its values.`, + ); + } + const names = options.map((option) => option.name); + const chosen = new Map(Object.entries(row.when)); + if (descriptor.choices.filter((other) => rowMatches(other, chosen, names)).length > 1) { + invalid( + `has two rows for ${typeChoiceCombinationText(row)}; each combination chooses one type.`, + ); + } + const chosenType = constructorNamed(row.type); + if (chosenType === undefined) { + throw invalidError( + `chooses "${row.type}", which is not a type constructor "${contributedBy}" registers beside it.`, + ); + } + for (const name of Object.keys(row.defaults ?? {})) { + if (!passed.some((argument) => argument.name === name)) { + invalid(`gives a default for "${name}", which is not an argument it passes on.`); + } + if (!chosenType.args?.some((parameter) => parameter.name === name)) { + invalid( + `gives a default for "${name}" to "${row.type}", which takes no argument of that name.`, + ); + } + } + for (const argument of passed) { + const parameter = chosenType.args?.find((candidate) => candidate.name === argument.name); + if (parameter?.kind === 'value' && parameter.dataType !== argument.dataType) { + invalid( + `passes "${argument.name}" as a value of data type "${argument.dataType}" to "${row.type}", which takes "${parameter.dataType}".`, + ); + } + } + chosenType.args?.forEach((parameter, index) => { + if (parameter.optional === true) return; + if (parameter.name !== undefined && Object.hasOwn(row.defaults ?? {}, parameter.name)) return; + const required = + parameter.name === undefined ? `an unnamed argument ${index + 1}` : `"${parameter.name}"`; + invalid( + `has a row for ${typeChoiceCombinationText(row)} that chooses "${row.type}", which requires ${required}, and gives it no default.`, + ); + }); + } + const defaults: AuthoringTypeChoiceRow = { + when: Object.fromEntries(options.map((option) => [option.name, option.default])), + type: '', + }; + const names = options.map((option) => option.name); + const chosen = new Map(Object.entries(defaults.when)); + if (!descriptor.choices.some((row) => rowMatches(row, chosen, names))) { + invalid( + `has no row for its defaults ${typeChoiceCombinationText(defaults)}, so it cannot be written bare.`, + ); + } +} + +/** + * Refuses a type choice of a component's `contributed` type namespace whose table cannot be resolved: a row that names no type constructor the same component contributes at the same level, a combination or default an option does not list, defaults with no row, a parameter default for an argument it does not pass on, a passed-on argument of another data type than the constructor takes, or a row whose constructor requires an argument the row gives no default. ADR 279: each row names an explicit type of the same target. + */ +export function assertTypeChoicesValid( + contributed: AuthoringTypeNamespace, + contributedBy: string, + path: readonly string[] = [], +): void { + for (const [name, value] of Object.entries(contributed)) { + const currentPath = [...path, name]; + if (isAuthoringTypeChoiceDescriptor(value)) { + assertTypeChoiceValid(currentPath.join('.'), value, contributedBy, contributed); + continue; + } + const level = subNamespace(value); + if (level !== undefined) assertTypeChoicesValid(level, contributedBy, currentPath); + } +} diff --git a/packages/1-framework/1-core/framework-components/src/shared/authoring-type-constructor-call.ts b/packages/1-framework/1-core/framework-components/src/shared/authoring-type-constructor-call.ts index 9f43b7381a9d..37c75445dea5 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/authoring-type-constructor-call.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/authoring-type-constructor-call.ts @@ -4,8 +4,8 @@ import { type AuthoringTypeNamespace, instantiateAuthoringTypeConstructor, isAuthoringArgRef, - isAuthoringTypeAliasDescriptor, isAuthoringTypeConstructorDescriptor, + isAuthoringTypeDescriptor, } from './framework-authoring'; /** What a type constructor call produces for a column. */ @@ -28,13 +28,14 @@ export function* authoringTypeConstructors( for (const [name, value] of Object.entries(namespace)) { if (isAuthoringTypeConstructorDescriptor(value)) { yield { path: [...path, name], descriptor: value }; - } else if (!isAuthoringTypeAliasDescriptor(value)) { + } else if (!isAuthoringTypeDescriptor(value)) { yield* authoringTypeConstructors(value, [...path, name]); } } } -function produces( +/** Whether `produced` is exactly `wanted`: the same codec and the same type parameters, an absent set of parameters counting as an empty one. */ +export function sameAuthoringTypeConstructorOutput( produced: AuthoringTypeConstructorOutput, wanted: AuthoringTypeConstructorOutput, ): boolean { @@ -69,7 +70,10 @@ export function authoringTypeConstructorArguments( const requiredArgs = (descriptor.args ?? []).filter((arg) => arg.optional !== true).length; if (args.length < requiredArgs || Array.from(args).includes(undefined)) return undefined; try { - return produces(instantiateAuthoringTypeConstructor(descriptor, args), output) + return sameAuthoringTypeConstructorOutput( + instantiateAuthoringTypeConstructor(descriptor, args), + output, + ) ? args : undefined; } catch { diff --git a/packages/1-framework/1-core/framework-components/src/shared/data-type-literal.ts b/packages/1-framework/1-core/framework-components/src/shared/data-type-literal.ts new file mode 100644 index 000000000000..2463a20cd2c3 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/shared/data-type-literal.ts @@ -0,0 +1,403 @@ +/** + * Writing a stored value back as the PSL literal its data type takes. + * + * The inverse of reading one. The stored form is read with the same functions a written value + * takes, `readWrittenValue` and `castTypedValue`: a number or numeral text through the target's + * number entry, a document or text through the entry that owns that syntax, each a value its type + * constructs, and the receiving data type must be that type or declare a cast from it. The text is + * then printed with that type's authoring entry and read straight back the same way, so a literal + * that would not come back as the stored value never reaches the contract source. ADR 254. + */ + +import type { JsonValue } from '@internal/contract/types'; +import { blindCast } from '@internal/utils/casts'; +import { isInternalError } from '@internal/utils/internal-error'; +import { + type DataTypeId, + type DataTypeLookup, + type DataTypeParams, + type DataTypeValue, + dataTypeId, + dataTypeParams, +} from './data-type'; +import type { + AuthoringArgumentDescriptor, + AuthoringTypeConstructorDescriptor, + DataTypeAuthoringEntry, +} from './framework-authoring'; +import { numeralText } from './numeral-text'; +import { printTaggedLiteral } from './tagged-literal'; +import { + castTypedValue, + type DataTypeSupport, + readWrittenValue, + type WrittenScalar, +} from './written-value'; + +/** What a value is written for: the data type that receives it, that type's parameters, and whether it is a list. */ +export interface TypedLiteralOptions { + /** The stack's data types, whose casts say which other types' values each one takes, with the authoring entries that write them. */ + readonly dataTypes?: DataTypeSupport | undefined; + /** The data type the value is written in. */ + readonly dataType?: DataTypeId | undefined; + /** The receiving type's parameters, under which a shorter literal can read back as the stored value, as `-0.5` does on `numeric(65,30)`. */ + readonly typeParams?: unknown; + /** + * Whether the value is a list whose elements each carry the receiving data type. Otherwise a + * written list goes through that type's list cast. + */ + readonly list?: boolean; +} + +/** A string as a contract source writes it, with the escapes its string reader resolves. */ +function escapePslString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r'); +} + +/** + * The authoring entries the printer reads, arranged by what it has to look up: the entry that owns + * each type, and the types each tag writes. + */ +interface WritingSurface { + readonly support: DataTypeSupport; + readonly entryOf: ReadonlyMap; + readonly tagTypes: readonly DataTypeId[]; +} + +function writingSurface(support: DataTypeSupport): WritingSurface { + const entryOf = new Map(); + const tagTypes: DataTypeId[] = []; + for (const [key, entry] of Object.entries(support.entries)) { + const written = entry.written; + if (written.kind === 'tag') { + const type = dataTypeId(key); + if (!entryOf.has(type)) entryOf.set(type, entry); + tagTypes.push(type); + continue; + } + if (written.syntax === 'number') { + for (const type of written.types) entryOf.set(type, entry); + continue; + } + entryOf.set(key, entry); + } + return { support, entryOf, tagTypes }; +} + +function readValue(surface: WritingSurface, written: WrittenScalar): DataTypeValue | undefined { + const read = readWrittenValue(surface.support, written); + return read.ok ? read.value : undefined; +} + +/** The value of a tag's type that a stored document is, constructed by that type. */ +function tagValue( + surface: WritingSurface, + type: DataTypeId, + value: JsonValue, +): DataTypeValue | undefined { + try { + return surface.support.lookup.get(type)?.valueFrom(value, {}); + } catch (error) { + if (isInternalError(error)) throw error; + return undefined; + } +} + +/** + * Every value the stored form could be, in the order a written value would be read: a number or + * numeral text through the number entry first, then the plain syntax that matches the shape, then + * each tag, whose stored form is a whole document, which may itself be a number or a boolean. + */ +function classifications(value: JsonValue, surface: WritingSurface): readonly DataTypeValue[] { + const found: (DataTypeValue | undefined)[] = []; + if (typeof value === 'number') { + found.push(readValue(surface, { kind: 'number', text: numeralText(value) })); + } else if (typeof value === 'boolean') { + found.push(readValue(surface, { kind: 'boolean', value })); + } else if (typeof value === 'string') { + found.push(readValue(surface, { kind: 'number', text: value })); + found.push(readValue(surface, { kind: 'string', text: value })); + } + for (const type of surface.tagTypes) found.push(tagValue(surface, type, value)); + return found.filter((candidate) => candidate !== undefined); +} + +/** The receiving data type with the parameters a written value is read under. */ +interface ReceivingType { + readonly id: DataTypeId; + readonly params: DataTypeParams; +} + +/** What the receiving type stores for `json` under its parameters, or `undefined` when it holds no such value. */ +function storedUnder( + json: JsonValue, + receiver: ReceivingType, + lookup: DataTypeLookup, +): JsonValue | undefined { + const type = lookup.get(receiver.id); + if (type === undefined) return undefined; + try { + return type.toContract(type.valueFrom(json, receiver.params)); + } catch (error) { + if (isInternalError(error)) throw error; + return undefined; + } +} + +/** The stored form the receiving type takes this value as, spelled as its parameters give it, or `undefined` when its type takes no such value. */ +function admitted( + candidate: DataTypeValue, + receiver: ReceivingType, + surface: WritingSurface, +): JsonValue | undefined { + const cast = castTypedValue(surface.support, receiver.id, receiver.params, candidate); + return cast.ok ? cast.value.value : undefined; +} + +/** The text of the literal: what the entry prints, before the syntax that fences it. */ +function printedText(entry: DataTypeAuthoringEntry, value: JsonValue): string | undefined { + try { + return entry.print(value); + } catch { + return undefined; + } +} + +/** The printed text as the entry's syntax writes it, for reading back. */ +function writtenAs(entry: DataTypeAuthoringEntry, text: string): WrittenScalar | undefined { + const written = entry.written; + if (written.kind === 'tag') return { kind: 'tag', tag: written.tag, text }; + if (written.syntax !== 'boolean') return { kind: written.syntax, text }; + return text === 'true' || text === 'false' + ? { kind: 'boolean', value: text === 'true' } + : undefined; +} + +/** What the printed text reads back as, through the same read a written value takes. */ +function readBack( + surface: WritingSurface, + entry: DataTypeAuthoringEntry, + text: string, +): DataTypeValue | undefined { + const written = writtenAs(entry, text); + return written === undefined ? undefined : readValue(surface, written); +} + +function literalText(entry: DataTypeAuthoringEntry, text: string): string { + const written = entry.written; + if (written.kind === 'tag') return printTaggedLiteral(written.tag, text); + return written.syntax === 'string' ? `"${escapePslString(text)}"` : text; +} + +/** One element of a written list: its source text and the value the list cast receives. */ +interface WrittenElement { + readonly text: string; + readonly value: JsonValue; +} + +/** + * The value written as the literal of one of its types, proven by reading the text straight back: + * the same read, the same cast, and the stored form again. A number is tried first without the + * trailing fraction zeros the receiving type's scale spells it with. + */ +function writeScalar( + value: JsonValue, + receiver: ReceivingType, + surface: WritingSurface, +): string | undefined { + const shortest = withoutTrailingFractionZeros(value); + const candidates = [ + ...(shortest === undefined ? [] : classifications(shortest, surface)), + ...classifications(value, surface), + ]; + for (const candidate of candidates) { + const entry = surface.entryOf.get(candidate.type); + if (entry === undefined) continue; + const stored = admitted(candidate, receiver, surface); + if (stored === undefined || !sameForm(stored, value)) continue; + const text = printedText(entry, candidate.value); + if (text === undefined) continue; + const reread = readBack(surface, entry, text); + if (reread === undefined) continue; + const restored = admitted(reread, receiver, surface); + if (restored === undefined || !sameForm(restored, value)) continue; + return literalText(entry, text); + } + return undefined; +} + +/** + * `value`, a value of `dataType` as `contract.json` stores it, written as the literal of a type that `dataType` takes, proven to read back as `value`: `255` for `pg/int4`, ``sql`"MyType"[]` `` for `sql/expression`. `undefined` when no literal reads back as it. + */ +export function writeDataTypeValue( + value: JsonValue, + dataType: DataTypeId, + support: DataTypeSupport, +): string | undefined { + return writeScalar(value, { id: dataType, params: {} }, writingSurface(support)); +} + +const DECIMAL_WITH_FRACTION = /^-?\d+\.\d+$/; + +function withoutTrailingFractionZeros(value: JsonValue): string | undefined { + if (typeof value !== 'string' || !DECIMAL_WITH_FRACTION.test(value)) return undefined; + const shortened = value.replace(/0+$/, '').replace(/\.$/, ''); + return shortened === value ? undefined : shortened; +} + +/** One written element of a list, printed as a value of any type, with no receiving type to admit it. */ +function writeElement( + value: JsonValue, + of: readonly DataTypeId[], + surface: WritingSurface, +): WrittenElement | undefined { + for (const candidate of classifications(value, surface)) { + if (!of.includes(candidate.type)) continue; + const entry = surface.entryOf.get(candidate.type); + if (entry === undefined) continue; + const text = printedText(entry, candidate.value); + if (text === undefined) continue; + const reread = readBack(surface, entry, text); + if (reread === undefined || !of.includes(reread.type)) continue; + return { text: literalText(entry, text), value: reread.value }; + } + return undefined; +} + +/** + * A written list for a scalar data type, which that type's list cast turns into its one value, + * constructed by the type. Each element is classified on its own and must be of a type the + * cast takes. + */ +function writeListCast( + value: readonly JsonValue[], + receiver: ReceivingType, + surface: WritingSurface, +): string | undefined { + const listCast = surface.support.lookup.get(receiver.id)?.listCast; + if (listCast === undefined) return undefined; + const parts: string[] = []; + const elements: JsonValue[] = []; + for (const element of value) { + const written = writeElement(element, listCast.of, surface); + if (written === undefined) return undefined; + parts.push(written.text); + elements.push(written.value); + } + let cast: JsonValue; + try { + cast = listCast.cast(elements); + } catch (error) { + if (isInternalError(error)) throw error; + return undefined; + } + const stored = storedUnder(cast, receiver, surface.support.lookup); + return stored !== undefined && sameForm(stored, value) ? `[${parts.join(', ')}]` : undefined; +} + +/** The PSL literal a stored value is written as for the data type `options` describe, or `undefined` when it has no written form that reads back as that value. */ +export function writeTypedLiteral( + value: JsonValue | Date, + options: TypedLiteralOptions | undefined, +): string | undefined { + if (value instanceof Date) return undefined; + const { dataTypes, dataType } = options ?? {}; + if (dataTypes === undefined || dataType === undefined) return undefined; + const { lookup } = dataTypes; + const receivingType = lookup.get(dataType); + const receiver: ReceivingType = { + id: dataType, + params: receivingType === undefined ? {} : dataTypeParams(receivingType, options?.typeParams), + }; + const surface = writingSurface(dataTypes); + if (options?.list === true) { + if (!Array.isArray(value)) return undefined; + const parts: string[] = []; + for (const element of value) { + const written = writeScalar(element, receiver, surface); + if (written === undefined) return undefined; + parts.push(written); + } + return `[${parts.join(', ')}]`; + } + const written = writeScalar(value, receiver, surface); + if (written !== undefined) return written; + return Array.isArray(value) ? writeListCast(value, receiver, surface) : undefined; +} + +/** Whether two stored forms are the same JSON value, member by member and element by element. */ +function sameForm(left: JsonValue, right: JsonValue): boolean { + if (left === right) return true; + if (Array.isArray(left) !== Array.isArray(right)) return false; + if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) { + return false; + } + const members = Object.entries(left); + const others = new Map(Object.entries(right)); + return ( + members.length === others.size && + members.every(([key, member]) => { + const other = others.get(key); + return other !== undefined && sameForm(member, other); + }) + ); +} + +/** A value argument of a type constructor, written as a literal of its data type. */ +function writtenArgument( + argument: AuthoringArgumentDescriptor, + value: unknown, + dataTypes: DataTypeSupport, +): string | undefined { + return writeDataTypeValue( + blindCast< + JsonValue, + 'a type constructor argument is a type parameter, which contract.json stores as JSON' + >(value), + dataTypeId(argument.dataType), + dataTypes, + ); +} + +/** + * Each argument of a call to `descriptor` as PSL writes it: a value argument as a literal of its data type that reads back as the same value. `undefined` when an argument is not a value argument or no literal reads back as it. + */ +export function writtenTypeArguments( + descriptor: AuthoringTypeConstructorDescriptor, + args: readonly unknown[], + dataTypes: DataTypeSupport, +): readonly string[] | undefined { + const written: string[] = []; + for (const [index, value] of args.entries()) { + const argument = descriptor.args?.[index]; + const text = argument && writtenArgument(argument, value, dataTypes); + if (text === undefined) return undefined; + written.push(text); + } + return written; +} + +/** + * The call to the type constructor `descriptor` at `path` as PSL writes it, with each argument that has a value written by its name as a literal of its data type, and the bare path when none has one. `undefined` when no literal reads back as one of the values. + */ +export function writtenTypeConstructorCall( + path: readonly string[], + descriptor: AuthoringTypeConstructorDescriptor, + args: readonly unknown[], + dataTypes: DataTypeSupport, +): string | undefined { + const written: string[] = []; + for (const [index, argument] of (descriptor.args ?? []).entries()) { + const value = args[index]; + if (value === undefined) continue; + const text = writtenArgument(argument, value, dataTypes); + if (text === undefined) return undefined; + written.push(argument.name === undefined ? text : `${argument.name}: ${text}`); + } + const name = path.join('.'); + return written.length === 0 ? name : `${name}(${written.join(', ')})`; +} diff --git a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts index dfd9dce9a5ca..21c5f5ad14fd 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts @@ -10,6 +10,10 @@ import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok, type Result } from '@internal/utils/result'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { Type } from 'arktype'; +import type { + AuthoringTypeChoiceDescriptor, + AuthoringTypeChoiceOption, +} from './authoring-type-choice'; import type { CodecLookupWithDescriptors } from './codec-types'; import type { DataTypeId } from './data-type'; import type { @@ -85,7 +89,7 @@ export interface AuthoringTypeConstructorDescriptor { readonly entityRefArg?: AuthoringTypeConstructorEntityRef; /** Present when this name is kept only as an alias of `replacement` and will be removed; it resolves as before, and a source may warn. */ readonly deprecated?: { readonly replacement: string }; - /** Marks the constructor written for a column of its codec's data type when no schema names one: `contract infer` writes it, and the Prisma 7 reader will. `contract print` writes it when it produces the column's codec, and otherwise the first constructor in the stack that does, so a `timestamptz` column with the `Temporal` codec prints `DateTime`. At most one per data type. */ + /** Marks the constructor written for a column of its codec's data type when no schema names one: `contract infer` and the reader of an earlier Prisma version's schema pick it. `contract print` picks it when it produces the column's codec, and otherwise the first constructor in the stack that does; either writes a type choice that becomes it instead, where one does. At most one per data type. */ readonly inferred?: true; } @@ -105,14 +109,22 @@ export interface AuthoringTypeAliasDescriptor { } export type AuthoringTypeNamespace = { - readonly [name: string]: - | AuthoringTypeConstructorDescriptor - | AuthoringTypeAliasDescriptor - | AuthoringTypeNamespace; + readonly [name: string]: AuthoringTypeNamespaceMember; }; +/** A leaf of a `type` namespace, or a sub-namespace. */ +export type AuthoringTypeNamespaceMember = + | AuthoringTypeConstructorDescriptor + | AuthoringTypeAliasDescriptor + | AuthoringTypeChoiceDescriptor + | AuthoringTypeNamespace; + /** The descriptor kinds a `type` namespace holds as leaves. */ -export const AUTHORING_TYPE_DESCRIPTOR_KINDS = ['typeConstructor', 'typeAlias'] as const; +export const AUTHORING_TYPE_DESCRIPTOR_KINDS = [ + 'typeConstructor', + 'typeAlias', + 'typeChoice', +] as const; /** * Context surfaced to entity-type factories at call time. Currently a @@ -596,22 +608,35 @@ function isAuthoringTemplateRecord(value: unknown): value is Record; } -/** - * Derives the scalar view of an assembled authoring type namespace: every - * **top-level** type constructor that is instantiable with an empty argument - * list — all declared args optional and no entity-ref argument. A bare type - * name `T` in a schema is semantically the zero-arg instantiation `T()`, so - * each entry is exactly what that call produces (defaulted template values - * applied, absent optional-arg typeParams keys omitted). Constructors - * registered under a namespace segment, constructors with required args, and - * entity-ref constructors are not scalars and are excluded. - * - * Eligibility needs no template inspection: templates that cannot resolve - * for their legal call shapes are rejected at the composition boundary by - * {@link assertResolvableTypeConstructorTemplates}, so the zero-arg - * instantiation below cannot fail for an eligible constructor. - */ -export function collectScalarTypeConstructors( - namespace: AuthoringTypeNamespace, -): ReadonlyMap { - const result = new Map(); - for (const [name, value] of Object.entries(namespace)) { - if (!isAuthoringTypeConstructorDescriptor(value)) continue; - if (value.entityRefArg !== undefined) continue; - if (value.args?.some((arg) => arg.optional !== true)) continue; - result.set(name, instantiateAuthoringTypeConstructor(value, [])); - } - return result; -} - function visitTemplateArgRefs( template: AuthoringTemplateValue | undefined, visit: (ref: AuthoringArgRef) => void, @@ -999,6 +999,7 @@ export function assertResolvableTypeConstructorTemplates( } continue; } + if (isAuthoringTypeChoiceDescriptor(value)) continue; if (!isAuthoringTypeConstructorDescriptor(value)) { assertResolvableTypeConstructorTemplates(value, contributedBy, currentPath); continue; @@ -1372,7 +1373,7 @@ export function resolveAuthoringTemplateValue( export function validateAuthoringHelperArguments( helperPath: string, - descriptors: readonly AuthoringArgumentDescriptor[] | undefined, + descriptors: readonly HelperArgumentDescriptor[] | undefined, args: readonly unknown[], ): void { const expected = descriptors ?? []; @@ -1397,9 +1398,12 @@ export function validateAuthoringHelperArguments( }); } +/** An argument of a type constructor, or of a type choice, whose call a helper checks. */ +type HelperArgumentDescriptor = AuthoringArgumentDescriptor | AuthoringTypeChoiceOption; + function argumentLabel( helperPath: string, - descriptor: AuthoringArgumentDescriptor, + descriptor: HelperArgumentDescriptor, index: number, ): string { return descriptor.name === undefined @@ -1469,6 +1473,31 @@ export function validateAuthoringTypeParams( ); } +/** + * {@link validateAuthoringTypeParams} for a call of the type choice `choice` that chose `chosen`: a failure names the choice's own argument the failing parameter came from, by its name and its index in the choice's arguments. + */ +export function validateAuthoringTypeChoiceParams( + helperPath: string, + choice: AuthoringTypeChoiceDescriptor, + chosen: AuthoringTypeConstructorDescriptor, + typeParams: Record | undefined, + paramsSchema: StandardSchemaV1 | undefined, +): void { + const issue = authoringTypeParamsIssue(chosen.output, typeParams, paramsSchema); + if (issue === undefined) return; + const name = + issue.argumentIndex === undefined ? undefined : chosen.args?.[issue.argumentIndex]?.name; + const argumentIndex = + name === undefined ? -1 : choice.args.findIndex((argument) => argument.name === name); + throw runtimeError( + 'CONTRACT.ARGUMENT_INVALID', + argumentIndex < 0 + ? `The type parameters of ${helperPath} are invalid: ${issue.message}` + : `Argument "${name}" of ${helperPath} is invalid: ${issue.message}`, + { helperPath, ...ifDefined('argumentIndex', argumentIndex < 0 ? undefined : argumentIndex) }, + ); +} + function resolveAuthoringStorageTypeTemplate( template: AuthoringStorageTypeTemplate, args: readonly unknown[], diff --git a/packages/1-framework/1-core/framework-components/test/authoring-type-choice-call.test.ts b/packages/1-framework/1-core/framework-components/test/authoring-type-choice-call.test.ts new file mode 100644 index 000000000000..beb0c495c0aa --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/authoring-type-choice-call.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { findAuthoringTypeChoiceCall } from '../src/shared/authoring-type-choice-call'; +import type { AuthoringTypeConstructorOutput } from '../src/shared/authoring-type-constructor-call'; +import type { AuthoringTypeNamespace } from '../src/shared/framework-authoring'; + +const precision = { + kind: 'value', + dataType: 'test/int', + name: 'precision', + optional: true, +} as const; + +const constructors = { + WideDate: { + kind: 'typeConstructor', + args: [precision], + output: { codecId: 'test/wide-date@1', typeParams: { precision: { kind: 'arg', index: 0 } } }, + }, + WideText: { + kind: 'typeConstructor', + args: [precision], + output: { codecId: 'test/wide-text@1', typeParams: { precision: { kind: 'arg', index: 0 } } }, + }, + NarrowDate: { + kind: 'typeConstructor', + output: { codecId: 'test/narrow-date@1' }, + }, + WideTemporal: { + kind: 'typeConstructor', + args: [precision], + output: { + codecId: 'test/wide-temporal@1', + typeParams: { precision: { kind: 'arg', index: 0 } }, + }, + }, +} as const satisfies AuthoringTypeNamespace; + +const moment = { + kind: 'typeChoice', + args: [ + { kind: 'option', name: 'value', values: ['date', 'text'], default: 'date', optional: true }, + { + kind: 'option', + name: 'column', + values: ['wide', 'narrow'], + default: 'wide', + optional: true, + }, + precision, + ], + choices: [ + { when: { value: 'date', column: 'wide' }, type: 'WideDate', defaults: { precision: 3 } }, + { when: { value: 'text', column: 'wide' }, type: 'WideText' }, + { when: { value: 'date', column: 'narrow' }, type: 'NarrowDate' }, + ], +} as const; + +const namespace = { ...constructors, Moment: moment } as const satisfies AuthoringTypeNamespace; + +const find = ( + path: readonly string[], + args: readonly unknown[], + types: AuthoringTypeNamespace = namespace, +) => findAuthoringTypeChoiceCall({ namespace: types, call: { path, args } }); + +describe('findAuthoringTypeChoiceCall', () => { + it('writes the default row bare when its parameter is the row default', () => { + expect(find(['WideDate'], [3])).toEqual({ + path: ['Moment'], + args: [], + becomes: { path: ['WideDate'], args: [3] }, + }); + }); + + it('writes only the arguments that differ from the defaults, by name', () => { + expect(find(['WideText'], [2])).toEqual({ + path: ['Moment'], + args: [ + { name: 'value', value: 'text' }, + { name: 'precision', value: 2 }, + ], + becomes: { path: ['WideText'], args: [2] }, + }); + }); + + it('writes a parameter that differs from the row default', () => { + expect(find(['WideDate'], [6])).toEqual({ + path: ['Moment'], + args: [{ name: 'precision', value: 6 }], + becomes: { path: ['WideDate'], args: [6] }, + }); + }); + + it('writes the option of a row other than the default', () => { + expect(find(['NarrowDate'], [])).toEqual({ + path: ['Moment'], + args: [{ name: 'column', value: 'narrow' }], + becomes: { path: ['NarrowDate'], args: [] }, + }); + }); + + it('finds nothing for a constructor no row names', () => { + expect(find(['WideTemporal'], [3])).toBeUndefined(); + }); + + it('finds nothing when no call reproduces the parameters, as a row default would fill an omitted one', () => { + expect(find(['WideDate'], [])).toBeUndefined(); + }); + + it('finds nothing for a name that is not a type constructor', () => { + expect(find(['Moment'], [])).toBeUndefined(); + }); + + it('drops an argument whose omission the comparison counts as the same output', () => { + const omittedPrecisionIsSix = ( + produced: AuthoringTypeConstructorOutput, + wanted: AuthoringTypeConstructorOutput, + ) => + produced.codecId === wanted.codecId && + (produced.typeParams?.['precision'] ?? 6) === (wanted.typeParams?.['precision'] ?? 6); + expect( + findAuthoringTypeChoiceCall({ + namespace, + call: { path: ['WideText'], args: [6] }, + sameOutput: omittedPrecisionIsSix, + }), + ).toEqual({ + path: ['Moment'], + args: [{ name: 'value', value: 'text' }], + becomes: { path: ['WideText'], args: [] }, + }); + expect( + findAuthoringTypeChoiceCall({ + namespace, + call: { path: ['WideDate'], args: [6] }, + sameOutput: omittedPrecisionIsSix, + }), + ).toEqual({ + path: ['Moment'], + args: [{ name: 'precision', value: 6 }], + becomes: { path: ['WideDate'], args: [6] }, + }); + }); + + it('takes the first type choice in namespace order that names the constructor', () => { + const second = { + ...moment, + choices: [{ when: { value: 'text', column: 'wide' }, type: 'WideDate' }], + }; + expect(find(['WideDate'], [3], { ...constructors, Moment: moment, Other: second })).toEqual({ + path: ['Moment'], + args: [], + becomes: { path: ['WideDate'], args: [3] }, + }); + }); + + it('passes over a type choice that names the constructor but cannot reproduce the call', () => { + const fixed = { + kind: 'typeChoice', + args: [{ kind: 'option', name: 'value', values: ['date'], default: 'date', optional: true }], + choices: [{ when: { value: 'date' }, type: 'WideDate', defaults: { precision: 6 } }], + } as const; + expect(find(['WideDate'], [3], { ...constructors, Fixed: fixed, Moment: moment })).toEqual({ + path: ['Moment'], + args: [], + becomes: { path: ['WideDate'], args: [3] }, + }); + }); + + it('writes a type choice of the same sub-namespace as the constructor', () => { + expect(find(['pg', 'NarrowDate'], [], { pg: namespace })).toEqual({ + path: ['pg', 'Moment'], + args: [{ name: 'column', value: 'narrow' }], + becomes: { path: ['pg', 'NarrowDate'], args: [] }, + }); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/authoring-type-choice.test.ts b/packages/1-framework/1-core/framework-components/test/authoring-type-choice.test.ts new file mode 100644 index 000000000000..8d3dc6bdff06 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/authoring-type-choice.test.ts @@ -0,0 +1,217 @@ +import { type } from 'arktype'; +import { describe, expect, it } from 'vitest'; +import { + collectScalarTypeConstructors, + instantiateAuthoringTypeCall, + resolveAuthoringTypeChoice, + typeChoiceArgumentsFromObject, + typeConstructorLookup, +} from '../src/shared/authoring-type-choice'; +import { + type AuthoringTypeConstructorDescriptor, + type AuthoringTypeNamespace, + validateAuthoringTypeChoiceParams, +} from '../src/shared/framework-authoring'; + +const precision = { + kind: 'value', + dataType: 'test/int', + name: 'precision', + optional: true, +} as const; + +const namespace = { + Moment: { + kind: 'typeChoice', + args: [ + { kind: 'option', name: 'as', values: ['date', 'text'], default: 'date', optional: true }, + { + kind: 'option', + name: 'storage', + values: ['wide', 'narrow'], + default: 'wide', + optional: true, + }, + precision, + ], + choices: [ + { when: { as: 'date', storage: 'wide' }, type: 'WideDate', defaults: { precision: 3 } }, + { when: { as: 'text', storage: 'wide' }, type: 'WideText' }, + { when: { as: 'date', storage: 'narrow' }, type: 'NarrowDate' }, + ], + }, + WideDate: { + kind: 'typeConstructor', + args: [precision], + output: { codecId: 'test/wide-date@1', typeParams: { precision: { kind: 'arg', index: 0 } } }, + }, + WideText: { + kind: 'typeConstructor', + args: [precision], + output: { codecId: 'test/wide-text@1', typeParams: { precision: { kind: 'arg', index: 0 } } }, + }, + NarrowDate: { + kind: 'typeConstructor', + output: { codecId: 'test/narrow-date@1' }, + }, +} as const satisfies AuthoringTypeNamespace; + +const resolve = (args: readonly unknown[]) => + resolveAuthoringTypeChoice({ + helperPath: 'Moment', + descriptor: namespace.Moment, + args, + constructorNamed: typeConstructorLookup(namespace), + }); + +function refusal(args: readonly unknown[]) { + try { + resolve(args); + } catch (error) { + const { message, details } = error as { message: string; details: unknown }; + return { message, details }; + } + throw new Error('expected a refusal'); +} + +describe('resolveAuthoringTypeChoice', () => { + it('chooses the default row, with its parameter defaults, for a bare call', () => { + expect(resolve([])).toEqual({ + type: 'WideDate', + descriptor: namespace.WideDate, + args: [3], + }); + }); + + it('reads option arguments by position', () => { + expect(resolve(['text'])).toMatchObject({ type: 'WideText', args: [undefined] }); + }); + + it('fills an option argument left out before a written one with its default', () => { + expect(resolve([undefined, 'narrow'])).toMatchObject({ type: 'NarrowDate', args: [] }); + }); + + it('passes a written argument on by name, over the row default', () => { + expect(resolve(['date', undefined, 6])).toMatchObject({ type: 'WideDate', args: [6] }); + }); + + it('refuses a value the option does not list, at that argument, naming its values', () => { + expect(refusal(['bogus'])).toEqual({ + message: 'Argument "as" of Moment is bogus; write one of: date, text.', + details: { helperPath: 'Moment', argumentIndex: 0 }, + }); + }); + + it('refuses an unlisted combination at the argument that makes it invalid, naming what it accepts', () => { + expect(refusal(['text', 'narrow'])).toEqual({ + message: 'Argument "storage" of Moment cannot be narrow with as: text; write one of: wide.', + details: { helperPath: 'Moment', argumentIndex: 1 }, + }); + }); + + it('refuses a passed-on argument the chosen type does not take', () => { + expect(refusal([undefined, 'narrow', 6])).toEqual({ + message: + 'Argument "precision" of Moment does not apply to NarrowDate, the type that as: date, storage: narrow chooses.', + details: { helperPath: 'Moment', argumentIndex: 2 }, + }); + }); +}); + +describe('typeChoiceArgumentsFromObject', () => { + const fromObject = (written: readonly unknown[]) => + typeChoiceArgumentsFromObject('Moment', namespace.Moment, written); + + it('gives the values by the argument positions of the choice, undefined where left out', () => { + expect({ + none: fromObject([]), + empty: fromObject([{}]), + some: fromObject([{ precision: 6, as: 'text' }]), + }).toEqual({ + none: [undefined, undefined, undefined], + empty: [undefined, undefined, undefined], + some: ['text', undefined, 6], + }); + }); + + it.each([ + ['positional arguments', ['text', 'wide']], + ['a string', ['text']], + ['a list', [['text']]], + ['null', [null]], + ])('refuses %s in place of one object', (_, written) => { + expect(() => fromObject(written)).toThrow( + "Moment takes its arguments as one object with the keys as, storage and precision, such as Moment({ as: 'date' }).", + ); + }); + + it('refuses a key the choice has no argument for', () => { + expect(() => fromObject([{ as: 'text', value: 'text' }])).toThrow( + 'Moment has no argument "value"; its arguments are as, storage and precision.', + ); + }); +}); + +describe('a type choice in the type namespace', () => { + it('instantiates to the column its chosen constructor makes', () => { + expect(instantiateAuthoringTypeCall(namespace, 'Moment', ['text', undefined, 6])).toEqual({ + codecId: 'test/wide-text@1', + typeParams: { precision: 6 }, + }); + }); + + it('is a scalar, written bare as its default choice', () => { + expect(collectScalarTypeConstructors(namespace).get('Moment')).toEqual({ + codecId: 'test/wide-date@1', + typeParams: { precision: 3 }, + }); + }); +}); + +describe('validateAuthoringTypeChoiceParams', () => { + const paramsSchema = type({ 'precision?': 'number <= 6' }); + const check = ( + chosen: AuthoringTypeConstructorDescriptor, + typeParams: Record, + ) => { + try { + validateAuthoringTypeChoiceParams( + 'Moment', + namespace.Moment, + chosen, + typeParams, + paramsSchema, + ); + } catch (error) { + const { message, details } = error as { message: string; details: unknown }; + return { message, details }; + } + return undefined; + }; + + it('passes parameters the codec accepts', () => { + expect(check(namespace.WideDate, { precision: 6 })).toBeUndefined(); + }); + + it('names the argument of the choice a refused parameter came from', () => { + expect(check(namespace.WideDate, { precision: 7 })).toEqual({ + message: expect.stringMatching( + /^Argument "precision" of Moment is invalid: precision must be/, + ), + details: { helperPath: 'Moment', argumentIndex: 2 }, + }); + }); + + it('names the type parameters when a refused parameter came from no argument', () => { + const fixed: AuthoringTypeConstructorDescriptor = { + kind: 'typeConstructor', + output: { codecId: 'test/fixed@1', typeParams: { precision: 9 } }, + }; + expect(check(fixed, { precision: 9 })).toEqual({ + message: expect.stringMatching( + /^The type parameters of Moment are invalid: precision must be/, + ), + details: { helperPath: 'Moment' }, + }); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/data-type-literal.test.ts b/packages/1-framework/1-core/framework-components/test/data-type-literal.test.ts new file mode 100644 index 000000000000..d3909c330bce --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/data-type-literal.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { createDataTypeLookup, dataType } from '../src/shared/data-type'; +import { + writeDataTypeValue, + writtenTypeArguments, + writtenTypeConstructorCall, +} from '../src/shared/data-type-literal'; +import type { AuthoringTypeConstructorDescriptor } from '../src/shared/framework-authoring'; +import type { DataTypeSupport } from '../src/shared/written-value'; + +const int = dataType('t/int', { read: (json) => json }); +const text = dataType('t/text', { read: (json) => json }); + +const dataTypes: DataTypeSupport = { + entries: { + [int.id]: { + written: { + kind: 'plain', + syntax: 'number', + types: [int.id], + classify: (written) => + /^\d+$/.test(written) ? { type: int.id, value: Number(written) } : undefined, + }, + print: String, + documentation: 'A whole number.', + }, + [text.id]: { + written: { kind: 'plain', syntax: 'string', parse: (written) => written }, + print: String, + documentation: 'Text.', + }, + }, + lookup: createDataTypeLookup([int, text]), + dataTypeOfCodec: () => undefined, +}; + +const numeric: AuthoringTypeConstructorDescriptor = { + kind: 'typeConstructor', + args: [ + { kind: 'value', dataType: 't/int', name: 'precision', optional: true }, + { kind: 'value', dataType: 't/int', name: 'scale', optional: true }, + ], + output: { codecId: 't/numeric@1' }, +}; + +const labelled: AuthoringTypeConstructorDescriptor = { + kind: 'typeConstructor', + args: [{ kind: 'value', dataType: 't/text', name: 'label' }], + output: { codecId: 't/labelled@1' }, +}; + +describe('writeDataTypeValue', () => { + it('writes a value as the literal of its data type that reads back as it', () => { + expect([ + writeDataTypeValue(10, int.id, dataTypes), + writeDataTypeValue('line\nbreak', text.id, dataTypes), + ]).toEqual(['10', '"line\\nbreak"']); + }); + + it('writes nothing for a value no literal reads back as, without throwing', () => { + expect([ + writeDataTypeValue(-1, int.id, dataTypes), + writeDataTypeValue({ precision: 1 }, int.id, dataTypes), + ]).toEqual([undefined, undefined]); + }); +}); + +describe('writtenTypeArguments', () => { + it('writes each value argument as a literal of its data type', () => { + expect([ + writtenTypeArguments(numeric, [10, 2], dataTypes), + writtenTypeArguments(labelled, ['say "hi"'], dataTypes), + ]).toEqual([['10', '2'], ['"say \\"hi\\""']]); + }); + + it('writes nothing for a value no literal of its data type reads back as', () => { + expect([ + writtenTypeArguments(numeric, [-1], dataTypes), + writtenTypeArguments(numeric, [{ precision: 1 }], dataTypes), + writtenTypeArguments(numeric, [1, 2, 3], dataTypes), + ]).toEqual([undefined, undefined, undefined]); + }); +}); + +describe('writtenTypeConstructorCall', () => { + it('writes the path and each argument that has a value, by name', () => { + expect([ + writtenTypeConstructorCall(['pg', 'Numeric'], numeric, [10, 2], dataTypes), + writtenTypeConstructorCall(['Numeric'], numeric, [undefined, 2], dataTypes), + writtenTypeConstructorCall(['ext', 'Numeric'], numeric, [], dataTypes), + ]).toEqual(['pg.Numeric(precision: 10, scale: 2)', 'Numeric(scale: 2)', 'ext.Numeric']); + }); + + it('writes an unnamed argument by position', () => { + const unnamed: AuthoringTypeConstructorDescriptor = { + kind: 'typeConstructor', + args: [{ kind: 'value', dataType: 't/text' }], + output: { codecId: 't/unnamed@1' }, + }; + expect(writtenTypeConstructorCall(['Unnamed'], unnamed, ['x'], dataTypes)).toBe('Unnamed("x")'); + }); + + it('writes nothing for a value no literal reads back as, such as a bigint', () => { + expect(writtenTypeConstructorCall(['Numeric'], numeric, [10n], dataTypes)).toBeUndefined(); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts index a7c95c8bf4ef..2075f535daef 100644 --- a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts +++ b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { collectScalarTypeConstructors } from '../src/shared/authoring-type-choice'; import type { AuthoringTypeConstructorDescriptor, AuthoringTypeNamespace, @@ -6,7 +7,6 @@ import type { import { assertNoCrossRegistryCollisions, classifyEnumMemberType, - collectScalarTypeConstructors, instantiateAuthoringTypeConstructor, isAuthoringArgRef, isAuthoringTypeConstructorDescriptor, diff --git a/packages/1-framework/1-core/framework-components/test/type-choice-assembly.test.ts b/packages/1-framework/1-core/framework-components/test/type-choice-assembly.test.ts new file mode 100644 index 000000000000..83c60d8f643a --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/type-choice-assembly.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from 'vitest'; +import type { CreateControlStackInput } from '../src/control/control-stack'; +import { createControlStack } from '../src/control/control-stack'; +import { + type AuthoringTypeChoiceDescriptor, + assertTypeChoicesValid, +} from '../src/shared/authoring-type-choice'; +import type { AuthoringTypeNamespace } from '../src/shared/framework-authoring'; + +const precision = { + kind: 'value', + dataType: 'test/int', + name: 'precision', + optional: true, +} as const; + +const asOption = { + kind: 'option', + name: 'as', + values: ['date', 'text'], + default: 'date', + optional: true, +} as const; + +const choice = (overrides: Partial = {}) => + ({ + kind: 'typeChoice', + args: [asOption, precision], + choices: [ + { when: { as: 'date' }, type: 'WideDate', defaults: { precision: 3 } }, + { when: { as: 'text' }, type: 'WideText' }, + ], + ...overrides, + }) satisfies AuthoringTypeChoiceDescriptor; + +const constructors = { + WideDate: { + kind: 'typeConstructor', + args: [precision], + output: { codecId: 'test/wide-date@1', typeParams: { precision: { kind: 'arg', index: 0 } } }, + }, + WideText: { kind: 'typeConstructor', output: { codecId: 'test/wide-text@1' } }, + Bounded: { + kind: 'typeConstructor', + args: [{ kind: 'value', dataType: 'test/int', name: 'length' }], + output: { codecId: 'test/bounded@1', typeParams: { length: { kind: 'arg', index: 0 } } }, + }, +} as const satisfies AuthoringTypeNamespace; + +const check = (moment: AuthoringTypeChoiceDescriptor) => () => + assertTypeChoicesValid({ ...constructors, Moment: moment }, 'x-pack'); + +describe('assertTypeChoicesValid', () => { + it('passes a choice whose rows name constructors of its namespace', () => { + expect(check(choice())).not.toThrow(); + }); + + it('refuses a choice naming a constructor only another component registers', () => { + expect(() => + assertTypeChoicesValid({ Moment: choice(), WideText: constructors.WideText }, 'x-pack'), + ).toThrow( + 'Type choice "Moment" contributed by "x-pack" chooses "WideDate", which is not a type constructor "x-pack" registers beside it.', + ); + }); + + it('refuses a row naming a constructor no component registers, naming the pack and the name', () => { + expect(check(choice({ choices: [{ when: { as: 'date' }, type: 'Missing' }] }))).toThrow( + 'Type choice "Moment" contributed by "x-pack" chooses "Missing", which is not a type constructor "x-pack" registers beside it.', + ); + }); + + it('refuses a default no row matches', () => { + expect(check(choice({ choices: [{ when: { as: 'text' }, type: 'WideText' }] }))).toThrow( + 'Type choice "Moment" contributed by "x-pack" has no row for its defaults as: date, so it cannot be written bare.', + ); + }); + + it('refuses a default the option does not list', () => { + expect( + check( + choice({ + args: [{ kind: 'option', name: 'as', values: ['text'], default: 'date', optional: true }], + }), + ), + ).toThrow( + 'Type choice "Moment" contributed by "x-pack" defaults option "as" to date, which it does not list.', + ); + }); + + it('refuses a row whose combination names a value an option does not list', () => { + expect( + check( + choice({ + choices: [ + { when: { as: 'date' }, type: 'WideDate' }, + { when: { as: 'json' }, type: 'WideText' }, + ], + }), + ), + ).toThrow( + 'Type choice "Moment" contributed by "x-pack" has a row for as: json, which does not give every option one of its values.', + ); + }); + + it('refuses a parameter default for an argument the choice does not pass on', () => { + expect( + check( + choice({ + choices: [ + { when: { as: 'date' }, type: 'WideDate', defaults: { scale: 2 } }, + { when: { as: 'text' }, type: 'WideText' }, + ], + }), + ), + ).toThrow( + 'Type choice "Moment" contributed by "x-pack" gives a default for "scale", which is not an argument it passes on.', + ); + }); + + it('refuses two rows for one combination', () => { + expect( + check( + choice({ + choices: [ + { when: { as: 'date' }, type: 'WideDate' }, + { when: { as: 'text' }, type: 'WideText' }, + { when: { as: 'date' }, type: 'WideText' }, + ], + }), + ), + ).toThrow( + 'Type choice "Moment" contributed by "x-pack" has two rows for as: date; each combination chooses one type.', + ); + }); + + it('refuses a parameter default the chosen constructor does not take', () => { + expect( + check( + choice({ + choices: [ + { when: { as: 'date' }, type: 'WideDate' }, + { when: { as: 'text' }, type: 'WideText', defaults: { precision: 3 } }, + ], + }), + ), + ).toThrow( + 'Type choice "Moment" contributed by "x-pack" gives a default for "precision" to "WideText", which takes no argument of that name.', + ); + }); + + it('refuses a passed-on argument of another data type than the chosen constructor takes', () => { + expect(check(choice({ args: [asOption, { ...precision, dataType: 'test/text' }] }))).toThrow( + 'Type choice "Moment" contributed by "x-pack" passes "precision" as a value of data type "test/text" to "WideDate", which takes "test/int".', + ); + }); + + describe('a row naming a constructor with a required argument', () => { + const length = { kind: 'value', dataType: 'test/int', name: 'length', optional: true } as const; + const boundedRow = (defaults?: Record) => + choice({ + args: [asOption, length], + choices: [ + { when: { as: 'date' }, type: 'WideText' }, + { when: { as: 'text' }, type: 'Bounded', ...(defaults ? { defaults } : {}) }, + ], + }); + + it('refuses the row when the choice does not pass the argument on', () => { + expect( + check( + choice({ + args: [asOption], + choices: [ + { when: { as: 'date' }, type: 'WideText' }, + { when: { as: 'text' }, type: 'Bounded' }, + ], + }), + ), + ).toThrow( + 'Type choice "Moment" contributed by "x-pack" has a row for as: text that chooses "Bounded", which requires "length", and gives it no default.', + ); + }); + + it('refuses the row when the choice passes the argument on as optional with no row default', () => { + expect(check(boundedRow())).toThrow( + 'Type choice "Moment" contributed by "x-pack" has a row for as: text that chooses "Bounded", which requires "length", and gives it no default.', + ); + }); + + it('refuses the row when the required argument has no name a default could use', () => { + expect(() => + assertTypeChoicesValid( + { + ...constructors, + Unnamed: { + kind: 'typeConstructor', + args: [{ kind: 'value', dataType: 'test/int' }], + output: { codecId: 'test/unnamed@1' }, + }, + Moment: choice({ + args: [asOption], + choices: [ + { when: { as: 'date' }, type: 'WideText' }, + { when: { as: 'text' }, type: 'Unnamed' }, + ], + }), + }, + 'x-pack', + ), + ).toThrow( + 'Type choice "Moment" contributed by "x-pack" has a row for as: text that chooses "Unnamed", which requires an unnamed argument 1, and gives it no default.', + ); + }); + + it('passes the row when it gives the argument a default', () => { + expect(check(boundedRow({ length: 255 }))).not.toThrow(); + }); + }); +}); + +describe('createControlStack', () => { + it('refuses a type choice naming a constructor another component registers', () => { + const input = { + family: { + kind: 'family', + id: 'fam', + version: '0.0.1', + authoring: { type: { WideText: constructors.WideText } }, + }, + target: { + kind: 'target', + id: 'tgt', + version: '0.0.1', + authoring: { + type: { + Moment: choice({ + args: [asOption], + choices: [ + { when: { as: 'date' }, type: 'WideText' }, + { when: { as: 'text' }, type: 'WideText' }, + ], + }), + }, + }, + }, + extensions: [], + } as unknown as CreateControlStackInput; + + expect(() => createControlStack(input)).toThrow( + 'Type choice "Moment" contributed by "tgt" chooses "WideText", which is not a type constructor "tgt" registers beside it.', + ); + }); + + it('refuses a type choice naming a constructor no component registers', () => { + const input = { + family: { kind: 'family', id: 'fam', version: '0.0.1' }, + target: { + kind: 'target', + id: 'tgt', + version: '0.0.1', + authoring: { + type: { Moment: choice({ choices: [{ when: { as: 'date' }, type: 'Missing' }] }) }, + }, + }, + extensions: [], + } as unknown as CreateControlStackInput; + + expect(() => createControlStack(input)).toThrow(/"Moment" contributed by "tgt".*"Missing"/); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/generator-attribute.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/generator-attribute.ts index f7fda2aa1ca2..c0d45b466975 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/generator-attribute.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/generator-attribute.ts @@ -69,9 +69,9 @@ export function generatorFunctionCall( */ function generatorCallArm(ctx: TypeAttributeSpecContext) { const codecId = - ctx.typeConstructor === undefined + ctx.contributedType === undefined ? undefined - : contributedTypeCodecId(ctx.typeConstructor.descriptor); + : contributedTypeCodecId(ctx.contributedType, ctx.dataTypes); const functions = codecId === undefined ? [] : ctx.generatorFunctions.functionsFor(codecId); const subject = typeAttributeSubjectLabel(ctx.subject); const target = codecId === undefined ? "the field's type" : `codec "${codecId}"`; diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/spec-context.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/spec-context.ts index 47fd6f256e51..c556a19c1244 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/spec-context.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/spec-context.ts @@ -4,7 +4,7 @@ import type { GeneratorFunctionRegistry } from '@internal/framework-components/c import type { ControlMutationDefaultRegistry } from '@internal/framework-components/control'; import type { Resolution } from '../binder'; import type { BlockSpecContext } from '../block-spec/types'; -import type { ContributedTypeSymbol } from '../contributed-type-scope'; +import type { ContributedTypeUse } from '../contributed-type-scope'; import type { FieldSymbol, MixinSymbol, ModelSymbol, SymbolTable } from '../symbol-table'; import type { AttributeSpec, @@ -31,8 +31,8 @@ export const EMPTY_DATA_TYPES: DataTypeSupport = { export interface FieldAttributeSpecContext extends AttributeSpecContext { readonly field: FieldSymbol; readonly typeResolution: Resolution | undefined; - /** The type constructor the field's type resolves to, through a named type when it is declared with one. */ - readonly typeConstructor: ContributedTypeSymbol | undefined; + /** The contributed type the field's type resolves to, through a named type when it is declared with one, with the call it is written with. */ + readonly contributedType: ContributedTypeUse | undefined; } /** The declaration an attribute that reads its type is written on: a field, or a type alias whose fields take the attribute. */ @@ -54,8 +54,8 @@ export interface TypeAttributeSpecContext extends Omit + descriptor.kind === 'value' && + descriptor.name !== undefined && + readWrittenValueAs(dataTypes, dataTypeId(descriptor.dataType), { + kind: 'number', + text: written, + }).ok + ? [descriptor.name] + : [], + ); + const [taker] = takers; + return takers.length === 1 && taker !== undefined + ? `${refused} Write ${taker} by name, as in ${taker}: ${written}.` + : refused; +} + +/** Where a reader of a call's arguments reports what it refuses: at the call, at an argument, or at a value whose data type refuses it. */ +interface ArgumentRefusals { + call(message: string): void; + argument(span: PslSpan, message: string): void; + value(node: SyntaxNode, failure: { readonly code: string; readonly message: string }): void; } -interface MapPslHelperArgsInput { +interface ReadPslHelperArgsInput { readonly args: readonly ResolvedAttributeArg[]; - readonly descriptors: readonly AuthoringArgumentDescriptor[]; + readonly descriptors: readonly CallArgumentDescriptor[]; + /** The stack's data types, which a `value` argument is read as. ADR 254. */ + readonly dataTypes: DataTypeSupport; +} + +interface MapPslHelperArgsInput extends ReadPslHelperArgsInput { readonly helperLabel: string; readonly span: PslSpan; readonly diagnostics: PslDiagnosticCollector; readonly source: DiagnosticSource; readonly entityLabel: string; - /** The stack's data types, which a `value` argument is read as. ADR 254. */ - readonly dataTypes: DataTypeSupport; } +const SILENT_REFUSALS: ArgumentRefusals = { + call: () => {}, + argument: () => {}, + value: () => {}, +}; + /** - * The value of one argument as a value of its descriptor's data type, read from its expression as attribute arguments are. A refusal is pushed as a diagnostic at the argument. + * The value of one argument as `descriptor` reads it: a `value` argument as a value of its data type, read from its expression as attribute arguments are, and a type choice's option as a bare name. A refusal is reported at the argument. */ function readPslHelperArgument( - input: MapPslHelperArgsInput, - descriptor: AuthoringArgumentDescriptor, + input: ReadPslHelperArgsInput, + refusals: ArgumentRefusals, + descriptor: CallArgumentDescriptor, argument: ResolvedAttributeArg, argumentLabel: string, ): unknown | typeof INVALID_AUTHORING_ARGUMENT { + const refuse = (message: string): typeof INVALID_AUTHORING_ARGUMENT => { + refusals.argument(argument.span, message); + return INVALID_AUTHORING_ARGUMENT; + }; + + if (descriptor.kind === 'option') { + const written = argument.value.trim(); + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(written)) return written; + return refuse( + misplacedOptionMessage( + input.descriptors, + input.dataTypes, + descriptor, + written, + argumentLabel, + ), + ); + } + const { expression } = argument; if (expression === undefined && argument.node !== undefined) { - pushInvalidPslHelperArgument({ - diagnostics: input.diagnostics, - source: input.source, - span: argument.span, - entityLabel: input.entityLabel, - helperLabel: input.helperLabel, - message: `${argumentLabel} has no value.`, - }); - return INVALID_AUTHORING_ARGUMENT; + return refuse(`${argumentLabel} has no value.`); } if (expression === undefined) { throw new InternalError( - `${input.entityLabel} ${input.helperLabel} received ${argumentLabel} with no expression. A value argument is read from the expression a schema writes; a reader that builds a call must pass the arguments it parsed, or instantiate the constructor with values.`, + `A call received ${argumentLabel} with no expression. A value argument is read from the expression a schema writes; a reader that builds a call must pass the arguments it parsed, or instantiate the constructor with values.`, ); } const read = readExpressionAs(input.dataTypes, dataTypeId(descriptor.dataType), expression); if (read.ok) return read.value.value; - input.diagnostics.push({ - code: read.failure.code, - message: read.failure.message, - ...input.source.at(nodePslSpan(expression.syntax, input.source.sources)), - }); + refusals.value(expression.syntax, read.failure); return INVALID_AUTHORING_ARGUMENT; } -/** - * The arguments of a type constructor call, positional and named, each read as its descriptor says. `undefined`, with a diagnostic pushed, when one cannot be read or does not match a descriptor. - */ -export function mapPslHelperArgs(input: MapPslHelperArgsInput): readonly unknown[] | undefined { - const refuse = (span: PslSpan, message: string) => - pushInvalidPslHelperArgument({ - diagnostics: input.diagnostics, - source: input.source, - span, - entityLabel: input.entityLabel, - helperLabel: input.helperLabel, - message, - }); - +function readPslHelperArgsReporting( + input: ReadPslHelperArgsInput, + refusals: ArgumentRefusals, +): readonly unknown[] | undefined { const bound = bindCallArguments( input.args, input.descriptors.map((descriptor) => descriptor?.name), @@ -98,11 +128,12 @@ export function mapPslHelperArgs(input: MapPslHelperArgsInput): readonly unknown for (const { argument, index } of bound.ok ? bound.value : bound.failure.bound) { const descriptor = input.descriptors[index]; if (!descriptor) { - return refuse(argument.span, `does not define positional argument #${index + 1}.`); + refusals.argument(argument.span, `does not define positional argument #${index + 1}.`); + return undefined; } const label = argument.kind === 'named' ? `named argument "${argument.name}"` : `argument #${index + 1}`; - const value = readPslHelperArgument(input, descriptor, argument, label); + const value = readPslHelperArgument(input, refusals, descriptor, argument, label); if (value === INVALID_AUTHORING_ARGUMENT) return undefined; mappedArgs[index] = value; } @@ -111,19 +142,50 @@ export function mapPslHelperArgs(input: MapPslHelperArgsInput): readonly unknown const failure = bound.failure; switch (failure.kind) { case 'tooManyPositional': - return refuse( - input.span, + refusals.call( `accepts at most ${input.descriptors.length} argument(s), received ${failure.received}.`, ); + return undefined; case 'unknownName': - return refuse( + refusals.argument( failure.argument.span, `received unknown named argument "${failure.argument.name}".`, ); + return undefined; case 'filledTwice': - return refuse( + refusals.argument( failure.argument.span, `received duplicate value for argument "${failure.argument.name}".`, ); + return undefined; } } + +/** + * The arguments of a type constructor or type choice call, positional and named, each read as its descriptor says. `undefined`, with a diagnostic pushed, when one cannot be read or does not match a descriptor. + */ +export function mapPslHelperArgs(input: MapPslHelperArgsInput): readonly unknown[] | undefined { + const refused = (span: PslSpan, message: string) => + input.diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `${input.entityLabel} ${input.helperLabel} ${message}`, + ...input.source.at(span), + }); + return readPslHelperArgsReporting(input, { + call: (message) => refused(input.span, message), + argument: refused, + value: (node, failure) => + input.diagnostics.push({ + code: failure.code, + message: failure.message, + ...input.source.at(nodePslSpan(node, input.source.sources)), + }), + }); +} + +/** + * The arguments of a call read as {@link mapPslHelperArgs} reads them, without reporting what it refuses: `undefined` when the call does not read, which the reader that resolves the column reports. + */ +export function readPslHelperArgs(input: ReadPslHelperArgsInput): readonly unknown[] | undefined { + return readPslHelperArgsReporting(input, SILENT_REFUSALS); +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 0dec2c172872..62c85730e298 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -33,7 +33,7 @@ import { blockSpecFactoryOf } from './block-spec/descriptor'; import { blockSpecContext } from './block-spec/spec-context'; import { type ContributedTypeNamespace, - type ContributedTypeSymbol, + type ContributedTypeUse, contributedTypeScope, } from './contributed-type-scope'; import { diagnosticSource } from './diagnostic'; @@ -44,7 +44,7 @@ import { } from './entity-reference'; import { findBlockDescriptor } from './extension-block'; import type { ParseDiagnostic } from './parse'; -import type { ResolvedAttribute } from './resolve'; +import type { ResolvedAttribute, ResolvedTypeConstructorCall } from './resolve'; import { contributedScope, documentScope, @@ -202,20 +202,27 @@ export function typeReferenceNode(symbol: FieldSymbol | NamedTypeSymbol): Syntax return symbol.node.typeAnnotation()?.name()?.syntax; } +/** + * The contributed type a field's or type alias's type resolves to, through a named type when it is declared with one, and the call it is written with there: `call`, the field's own, for a contributed type, or the named type's. + */ export function contributedTypeOf( resolution: Resolution | undefined, binder: Binder, -): ContributedTypeSymbol | undefined { - return contributedTypeOfResolution(resolution, (base) => binder.symbolForNode(base)); + call: ResolvedTypeConstructorCall | undefined, +): ContributedTypeUse | undefined { + return contributedTypeOfResolution(resolution, (base) => binder.symbolForNode(base), call); } function contributedTypeOfResolution( resolution: Resolution | undefined, resolve: (node: SyntaxNode) => Resolution | undefined, -): ContributedTypeSymbol | undefined { - const base = resolution?.kind === 'namedType' ? typeReferenceNode(resolution.symbol) : undefined; + call: ResolvedTypeConstructorCall | undefined, +): ContributedTypeUse | undefined { + const named = resolution?.kind === 'namedType' ? resolution.symbol : undefined; + const base = named === undefined ? undefined : typeReferenceNode(named); const target = base === undefined ? resolution : resolve(base); - return target?.kind === 'contributedType' ? target.symbol : undefined; + if (target?.kind !== 'contributedType') return undefined; + return { symbol: target.symbol, call: named === undefined ? call : named.typeConstructor }; } class PslBinder implements Binder { @@ -513,10 +520,12 @@ function bind(options: BindingInputs): BinderResult { if (specContext === undefined) return undefined; const node = typeReferenceNode(field); const typeResolution = node === undefined ? undefined : references.get(node); - const typeConstructor = contributedTypeOfResolution(typeResolution, (base) => - references.get(base), + const contributedType = contributedTypeOfResolution( + typeResolution, + (base) => references.get(base), + field.typeConstructor, ); - return factory({ ...specContext, field, typeResolution, typeConstructor }); + return factory({ ...specContext, field, typeResolution, contributedType }); }, { ...context, field }, ); diff --git a/packages/1-framework/2-authoring/psl-parser/src/contributed-type-scope.ts b/packages/1-framework/2-authoring/psl-parser/src/contributed-type-scope.ts index 3249cad8643a..07a80f97f4d4 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/contributed-type-scope.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/contributed-type-scope.ts @@ -1,21 +1,55 @@ -import type { - AuthoringTypeAliasDescriptor, - AuthoringTypeConstructorDescriptor, +import { + type AuthoringTypeAliasDescriptor, + type AuthoringTypeChoiceDescriptor, + type AuthoringTypeConstructorDescriptor, + type DataTypeSupport, + type ResolvedAuthoringTypeChoice, + resolveAuthoringTypeChoice, + typeConstructorLookup, } from '@internal/framework-components/authoring'; +import { isInternalError } from '@internal/utils/internal-error'; +import { notOk, ok, type Result } from '@internal/utils/result'; +import { EMPTY_DATA_TYPES } from './attribute-spec/spec-context'; +import { readPslHelperArgs } from './authoring-arguments'; +import type { ResolvedTypeConstructorCall } from './resolve'; export type ContributedTypeDescriptor = | AuthoringTypeConstructorDescriptor - | AuthoringTypeAliasDescriptor; + | AuthoringTypeAliasDescriptor + | AuthoringTypeChoiceDescriptor; export type ContributedTypeNamespace = { readonly [name: string]: ContributedTypeDescriptor | ContributedTypeNamespace; }; -export interface ContributedTypeSymbol { +interface ContributedTypeSymbolBase { readonly kind: 'contributedType'; readonly name: string; readonly path: readonly string[]; - readonly descriptor: ContributedTypeDescriptor; +} + +/** A contributed type choice, with the type constructors its rows name, by name, from the namespace that holds it. */ +export interface ContributedTypeChoiceSymbol extends ContributedTypeSymbolBase { + readonly descriptor: AuthoringTypeChoiceDescriptor; + readonly choiceTypes: ReadonlyMap; +} + +export type ContributedTypeSymbol = + | (ContributedTypeSymbolBase & { + readonly descriptor: Exclude; + }) + | ContributedTypeChoiceSymbol; + +export function isContributedTypeChoice( + symbol: ContributedTypeSymbol, +): symbol is ContributedTypeChoiceSymbol { + return symbol.descriptor.kind === 'typeChoice'; +} + +/** A contributed type as a field or type alias uses it: the type, and the call it is written with, `undefined` when it is written bare. */ +export interface ContributedTypeUse { + readonly symbol: ContributedTypeSymbol; + readonly call: ResolvedTypeConstructorCall | undefined; } export interface ContributedNamespaceSymbol { @@ -35,11 +69,51 @@ export interface ContributedTypeScope { export function isContributedTypeDescriptor( value: ContributedTypeDescriptor | ContributedTypeNamespace, ): value is ContributedTypeDescriptor { - return 'kind' in value && (value.kind === 'typeConstructor' || value.kind === 'typeAlias'); + return ( + 'kind' in value && + (value.kind === 'typeConstructor' || value.kind === 'typeAlias' || value.kind === 'typeChoice') + ); } -/** The codec a column typed by `descriptor` is stored with. */ -export function contributedTypeCodecId(descriptor: ContributedTypeDescriptor): string { +/** The explicit type a call of the type choice `symbol` chooses and the arguments it is built with, reading the call as the column does, or why it chooses none. */ +export function chosenTypeConstructor( + symbol: ContributedTypeChoiceSymbol, + call: ResolvedTypeConstructorCall | undefined, + dataTypes: DataTypeSupport, +): Result { + const helperPath = symbol.path.join('.'); + const args = readPslHelperArgs({ + args: call?.args ?? [], + descriptors: symbol.descriptor.args, + dataTypes, + }); + if (args === undefined) return notOk(`The arguments of ${helperPath} do not read.`); + try { + return ok( + resolveAuthoringTypeChoice({ + helperPath, + descriptor: symbol.descriptor, + args, + constructorNamed: (name) => symbol.choiceTypes.get(name), + }), + ); + } catch (error) { + if (isInternalError(error) || !(error instanceof Error)) throw error; + return notOk(error.message); + } +} + +/** The codec a column typed by `use` is stored with; `undefined` for a type choice whose call chooses no type, which the column's resolution reports. */ +export function contributedTypeCodecId( + use: ContributedTypeUse, + dataTypes: DataTypeSupport, +): string | undefined { + const { symbol } = use; + if (isContributedTypeChoice(symbol)) { + const chosen = chosenTypeConstructor(symbol, use.call, dataTypes); + return chosen.ok ? chosen.value.descriptor.output.codecId : undefined; + } + const { descriptor } = symbol; return descriptor.kind === 'typeAlias' ? descriptor.output.type.codecId : descriptor.output.codecId; @@ -58,6 +132,33 @@ export function contributedTypeAt( return contributedTypeAt(value, rest); } +/** The codec a column typed by `call` is stored with, reading the call's arguments as the column does, or `undefined` when no type is contributed at its path or it is a type choice whose arguments choose no type. */ +export function writtenContributedTypeCodecId( + namespace: ContributedTypeNamespace, + call: ResolvedTypeConstructorCall, +): string | undefined { + return codecIdAtPath(namespace, call.path, [], call); +} + +function codecIdAtPath( + namespace: ContributedTypeNamespace, + path: readonly string[], + prefix: readonly string[], + call: ResolvedTypeConstructorCall, +): string | undefined { + const [name, ...rest] = path; + if (name === undefined || !Object.hasOwn(namespace, name)) return undefined; + const value = namespace[name]; + if (value === undefined) return undefined; + const at = [...prefix, name]; + if (!isContributedTypeDescriptor(value)) return codecIdAtPath(value, rest, at, call); + if (rest.length > 0) return undefined; + return contributedTypeCodecId( + { symbol: contributedTypeSymbol(name, at, value, namespace), call }, + EMPTY_DATA_TYPES, + ); +} + export function contributedTypeScope( contributedTypes: ContributedTypeNamespace, ): ContributedTypeScope { @@ -82,9 +183,25 @@ function collect( members.set( name, isContributedTypeDescriptor(value) - ? { kind: 'contributedType', name, path, descriptor: value } + ? contributedTypeSymbol(name, path, value, namespace) : { kind: 'contributedNamespace', name, path, members: collect(value, path) }, ); } return members; } + +function contributedTypeSymbol( + name: string, + path: readonly string[], + descriptor: ContributedTypeDescriptor, + namespace: ContributedTypeNamespace, +): ContributedTypeSymbol { + if (descriptor.kind !== 'typeChoice') return { kind: 'contributedType', name, path, descriptor }; + const constructorNamed = typeConstructorLookup(namespace); + const choiceTypes = new Map(); + for (const { type } of descriptor.choices) { + const chosen = constructorNamed(type); + if (chosen !== undefined) choiceTypes.set(type, chosen); + } + return { kind: 'contributedType', name, path, descriptor, choiceTypes }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts index a192bb839ee7..39af461053f3 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts @@ -173,15 +173,23 @@ export type { MapBlockSpec, StructBlockSpec, } from '../block-spec/types'; +export { printTypeConstructorCall } from '../call-arguments'; export type { ContributedMember, ContributedNamespaceSymbol, + ContributedTypeChoiceSymbol, ContributedTypeDescriptor, ContributedTypeNamespace, ContributedTypeScope, ContributedTypeSymbol, + ContributedTypeUse, +} from '../contributed-type-scope'; +export { + chosenTypeConstructor, + contributedTypeAt, + contributedTypeCodecId, + isContributedTypeChoice, } from '../contributed-type-scope'; -export { contributedTypeAt, contributedTypeCodecId } from '../contributed-type-scope'; export type { DiagnosticSource, PslDiagnostic, PslDiagnosticCollector } from '../diagnostic'; export { createPslDiagnosticCollector, diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/interpret.ts index 189699130bfc..c615cc7d34a4 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/exports/interpret.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/exports/interpret.ts @@ -8,11 +8,22 @@ export { fkRelationPairKey, requiredOneToOneBackrelationDiagnostic, } from '../relation-backrelations'; -export type { RemovedFieldPreset, RemovedFieldPresets } from '../removed-field-presets'; +export type { + RemovedFieldPreset, + RemovedFieldPresets, + RemovedPresetType, +} from '../removed-field-presets'; export { describeRemovedFieldPreset, + presetType, removedPresetWithPhases, removedPresetWrittenAs, + writtenPresetType, } from '../removed-field-presets'; +export { + argumentIndexOf, + argumentSpan, + instantiatePslTypeChoice, +} from '../type-choice-calls'; export { isBareTypeConstructor, reportTypeConstructorNotCalled } from '../type-constructor-calls'; export { claimedBlockKeywords, unsupportedBlockDiagnostic } from '../unclaimed-blocks'; diff --git a/packages/1-framework/2-authoring/psl-parser/src/removed-field-presets.ts b/packages/1-framework/2-authoring/psl-parser/src/removed-field-presets.ts index 334a21ff7f81..2b8923eee474 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/removed-field-presets.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/removed-field-presets.ts @@ -1,18 +1,43 @@ import { argumentsByParameter, printTypeConstructorCall } from './call-arguments'; import { type ContributedTypeNamespace, - contributedTypeAt, - contributedTypeCodecId, + writtenContributedTypeCodecId, } from './contributed-type-scope'; import type { ResolvedTypeConstructorCall } from './resolve'; +import type { FieldSymbol } from './symbol-table'; + +/** A type as a removed preset's rewrite writes it: its path, and the options it is called with, each written as a bare name, such as `as: temporal`. */ +export interface RemovedPresetType { + readonly path: readonly string[]; + readonly options: Readonly>; +} + +/** The type at the dotted `path`, called with `options`. */ +export function presetType( + path: string, + options: Readonly> = {}, +): RemovedPresetType { + return { path: path.split('.'), options }; +} + +/** The type as PSL writes it, with `extra` after its options, such as `precision: 3`. */ +export function writtenPresetType(type: RemovedPresetType, extra?: string): string { + const args = [ + ...Object.entries(type.options).map(([name, value]) => `${name}: ${value}`), + ...(extra === undefined ? [] : [extra]), + ]; + const path = type.path.join('.'); + return args.length === 0 ? path : `${path}(${args.join(', ')})`; +} /** - * A field preset Prisma 8 removed, and how to write what it stored. The preset is named only where `type` resolves, and to `codecId` when one is given, so a row does not fire on a target the preset never existed on. + * A field preset Prisma 8 removed, and how to write what it stored. The preset is named only where `type` resolves to `codecId`, the codec the preset stored, so a row does not fire on a target the preset never existed on, or name a type that stores something else. */ export interface RemovedFieldPreset { - readonly type: readonly string[]; - readonly codecId?: string; - readonly rewrite: (call: ResolvedTypeConstructorCall) => string; + readonly type: RemovedPresetType; + readonly codecId: string; + /** The rewrite of `call`, with `modifier`, the field's `?` or `[]`, written right after the type. */ + readonly rewrite: (call: ResolvedTypeConstructorCall, modifier: string) => string; } /** A family's removed field presets, keyed by the path a schema called: the rows of each, of which the first that applies names the rewrite, such as a target's own type before the family's. */ @@ -20,36 +45,49 @@ export type RemovedFieldPresets = Readonly `Write ${written}.`, + type: written, + codecId, + rewrite: (_, modifier) => `Write ${writtenPresetType(written)}${modifier}${suffix}.`, }, ]; } +/** A convenience type that gives a row's explicit type for every written precision, and the precision it gives when none is written. */ +export interface RemovedPresetPrecisionType { + readonly type: RemovedPresetType; + readonly defaultPrecision: string; +} + /** - * The row of a preset that took `onCreate: now` and `onUpdate: now`, and a precision first when `takesPrecision`. Its rewrite writes `type`, with the precision as its argument and `@onCreate(now())` and `@onUpdate(now())` for the phases the call set. + * The row of a preset that took `onCreate: now` and `onUpdate: now`, and a precision first when `takesPrecision`. Its rewrite writes `type`, with the precision as its `precision` argument and `@onCreate(now())` and `@onUpdate(now())` for the phases the call set. When the call writes a precision and `withPrecision` is given, it writes `withPrecision.type` instead, leaving out a precision equal to its default. */ export function removedPresetWithPhases( - type: string, + type: RemovedPresetType | string, codecId: string, takesPrecision: boolean, + withPrecision?: RemovedPresetPrecisionType, ): readonly RemovedFieldPreset[] { + const written = asType(type); const parameters = takesPrecision ? ['precision', ...PHASES] : [...PHASES]; - const fallback = `Write ${type}${takesPrecision ? '(...)' : ''} with @onCreate(now()) and @onUpdate(now()) for the phases the preset set.`; return [ { - type: [type], + type: written, codecId, - rewrite: (call) => { + rewrite: (call, modifier) => { const args = argumentsByParameter(call.args, parameters); const precision = args?.get('precision'); if ( @@ -57,37 +95,61 @@ export function removedPresetWithPhases( (precision !== undefined && !/^\d+$/.test(precision)) || PHASES.some((phase) => args.has(phase) && args.get(phase) !== 'now') ) { - return fallback; + return `Write ${writtenPresetType(written, takesPrecision ? '...' : undefined)}${modifier} with @onCreate(now()) and @onUpdate(now()) for the phases the preset set.`; } const attributes = PHASES.filter((phase) => args.has(phase)).map( (phase) => ` @${phase}(now())`, ); - return `Write ${type}${precision === undefined ? '' : `(${precision})`}${attributes.join('')}.`; + const typeText = + precision === undefined + ? writtenPresetType(written) + : withPrecision === undefined + ? writtenPresetType(written, `precision: ${precision}`) + : writtenPresetType( + withPrecision.type, + precision === withPrecision.defaultPrecision + ? undefined + : `precision: ${precision}`, + ); + return `Write ${typeText}${modifier}${attributes.join('')}.`; }, }, ]; } +/** The `?` or `[]` a field writes right after its type. */ +function typeModifier(field: FieldSymbol): string { + if (field.list) return field.elementOptional ? '?[]' : '[]'; + return field.optional ? '?' : ''; +} + /** - * The refusal of a call to a field preset Prisma 8 removed, naming what to write instead, or `undefined` when `presets` has no row for the call that applies to a stack whose types `types` holds. A row is skipped when `isDeclared` says the schema declares the first name of its type, because the rewrite would then name the schema's declaration. + * The refusal of a field typed by a call to a field preset Prisma 8 removed, naming what to write instead, with the field's `?` or `[]` right after the type and before any attribute. `undefined` when the field is not typed by a call, or `presets` has no row for the call that applies to a stack whose types `types` holds. A row applies where its type, called as the rewrite writes it, resolves as a column would, to the row's codec. A row is skipped when `isDeclared` says the schema declares the first name of its type, because the rewrite would then name the schema's declaration. */ export function describeRemovedFieldPreset( presets: RemovedFieldPresets, - call: ResolvedTypeConstructorCall, + field: FieldSymbol, types: ContributedTypeNamespace, isDeclared: (name: string) => boolean, ): string | undefined { + const call = field.typeConstructor; + if (call === undefined) return undefined; const path = call.path.join('.'); const rows = Object.hasOwn(presets, path) ? presets[path] : undefined; const preset = rows?.find((row) => { - const [name] = row.type; - const replacement = contributedTypeAt(types, row.type); - return ( - replacement !== undefined && - (name === undefined || !isDeclared(name)) && - (row.codecId === undefined || contributedTypeCodecId(replacement) === row.codecId) - ); + const [name] = row.type.path; + const codecId = writtenContributedTypeCodecId(types, { + path: row.type.path, + args: Object.entries(row.type.options).map(([option, value]) => ({ + kind: 'named', + name: option, + value, + span: call.span, + })), + span: call.span, + }); + return codecId === row.codecId && (name === undefined || !isDeclared(name)); }); if (preset === undefined) return undefined; - return `${printTypeConstructorCall(call)} was a field preset, and Prisma 8 removed field presets. ${preset.rewrite(call)}`; + return `${printTypeConstructorCall(call)} was a field preset, and Prisma 8 removed field presets. ${preset.rewrite(call, typeModifier(field))}`; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/type-aliases.ts b/packages/1-framework/2-authoring/psl-parser/src/type-aliases.ts index 5c44fc4d15e3..93f88df9583c 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/type-aliases.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/type-aliases.ts @@ -10,6 +10,10 @@ import type { GeneratorFunctionRegistry } from '@internal/framework-components/c import type { ControlMutationDefaultRegistry } from '@internal/framework-components/control'; import type { TypeAttributeSpecContext } from './attribute-spec/spec-context'; import { type Binder, contributedTypeOf, type Resolution, typeReferenceNode } from './binder'; +import { + type ContributedTypeChoiceSymbol, + isContributedTypeChoice, +} from './contributed-type-scope'; import { diagnosticSource, type PslDiagnosticCollector } from './diagnostic'; import type { PslSources } from './source-file'; import type { @@ -40,6 +44,12 @@ export type TypeAliasBase = /** `undefined` when the constructor is written bare. */ readonly call: ResolvedTypeConstructorCall | undefined; } + | { + readonly kind: 'typeChoice'; + readonly symbol: ContributedTypeChoiceSymbol; + /** `undefined` when the type choice is written bare. */ + readonly call: ResolvedTypeConstructorCall | undefined; + } | { readonly kind: 'block'; readonly symbol: BlockSymbol }; /** A `types {}` declaration read as a type alias: its base, one level deep, and the attributes its fields take. */ @@ -184,6 +194,9 @@ function baseOf( return { kind: 'block', symbol: resolution.symbol }; } if (resolution?.kind !== 'contributedType') return undefined; + if (isContributedTypeChoice(resolution.symbol)) { + return { kind: 'typeChoice', symbol: resolution.symbol, call: declaration.typeConstructor }; + } const { descriptor } = resolution.symbol; if (descriptor.kind !== 'typeConstructor') return undefined; if (declaration.isConstructor) { @@ -343,7 +356,11 @@ export function typeAliasSpecContext( list: false, typeName: alias.baseName, typeResolution: alias.baseResolution, - typeConstructor: contributedTypeOf(alias.baseResolution, input.binder), + contributedType: contributedTypeOf( + alias.baseResolution, + input.binder, + alias.symbol.typeConstructor, + ), }; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/type-choice-calls.ts b/packages/1-framework/2-authoring/psl-parser/src/type-choice-calls.ts new file mode 100644 index 000000000000..678dc7d1d8d7 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/type-choice-calls.ts @@ -0,0 +1,119 @@ +import { + type DataTypeSupport, + instantiateAuthoringTypeConstructor, + resolveAuthoringTypeChoice, + type ScalarTypeConstructorOutput, + unknownTypeChoiceArgumentMessage, + validateAuthoringTypeChoiceParams, +} from '@internal/framework-components/authoring'; +import type { CodecLookupWithDescriptors } from '@internal/framework-components/codec'; +import type { PslSpan } from '@internal/framework-components/psl-ast'; +import { isInternalError } from '@internal/utils/internal-error'; +import { mapPslHelperArgs } from './authoring-arguments'; +import type { ContributedTypeChoiceSymbol } from './contributed-type-scope'; +import type { DiagnosticSource, PslDiagnosticCollector } from './diagnostic'; +import type { ResolvedTypeConstructorCall } from './resolve'; + +/** The span of the argument at `index` of `descriptors` in `call`, named or positional, or the call's when it is not written. */ +export function argumentSpan( + call: ResolvedTypeConstructorCall, + descriptors: readonly { readonly name?: string | undefined }[] | undefined, + index: number | undefined, +): PslSpan { + if (index === undefined) return call.span; + const name = descriptors?.[index]?.name; + const named = call.args.find((arg) => arg.kind === 'named' && arg.name === name); + const positional = call.args.filter((arg) => arg.kind === 'positional')[index]; + return (named ?? positional)?.span ?? call.span; +} + +/** The index of the argument an authoring refusal names, when it names one. */ +export function argumentIndexOf(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null || !('details' in error)) return undefined; + const { details } = error; + if (typeof details !== 'object' || details === null || !('argumentIndex' in details)) { + return undefined; + } + return typeof details.argumentIndex === 'number' ? details.argumentIndex : undefined; +} + +/** + * The column a field or type alias typed by the type choice `symbol` gets: its call's arguments, read as the choice declares them, choose a type constructor, which is instantiated with the arguments the choice passes on. A refusal is reported at the argument that makes it. `span` is where a bare use is written. + */ +export function instantiatePslTypeChoice(input: { + readonly symbol: ContributedTypeChoiceSymbol; + readonly call: ResolvedTypeConstructorCall | undefined; + readonly span: PslSpan; + readonly codecLookup: Pick; + readonly diagnostics: PslDiagnosticCollector; + readonly source: DiagnosticSource; + readonly entityLabel: string; + readonly dataTypes: DataTypeSupport; +}): ScalarTypeConstructorOutput | undefined { + const { symbol } = input; + const { descriptor } = symbol; + const helperPath = symbol.path.join('.'); + const call = input.call ?? { path: symbol.path, args: [], span: input.span }; + const subject = `${input.entityLabel} type "${helperPath}"`; + const unknown = call.args.find( + (arg) => + arg.kind === 'named' && !descriptor.args.some((declared) => declared.name === arg.name), + ); + if (unknown?.name !== undefined) { + input.diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: unknownTypeChoiceArgumentMessage(subject, descriptor, unknown.name), + ...input.source.at(unknown.span), + }); + return undefined; + } + const args = mapPslHelperArgs({ + args: call.args, + descriptors: descriptor.args, + helperLabel: `type "${helperPath}"`, + span: call.span, + diagnostics: input.diagnostics, + source: input.source, + entityLabel: input.entityLabel, + dataTypes: input.dataTypes, + }); + if (!args) return undefined; + + let chosen: ReturnType; + try { + chosen = resolveAuthoringTypeChoice({ + helperPath, + descriptor, + args, + constructorNamed: (name) => symbol.choiceTypes.get(name), + }); + } catch (error) { + if (isInternalError(error)) throw error; + input.diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `${subject}: ${error instanceof Error ? error.message : String(error)}`, + ...input.source.at(argumentSpan(call, descriptor.args, argumentIndexOf(error))), + }); + return undefined; + } + + const output = instantiateAuthoringTypeConstructor(chosen.descriptor, chosen.args); + try { + validateAuthoringTypeChoiceParams( + helperPath, + descriptor, + chosen.descriptor, + output.typeParams, + input.codecLookup.descriptorFor(output.codecId)?.paramsSchema, + ); + } catch (error) { + if (isInternalError(error)) throw error; + input.diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `${subject}: ${error instanceof Error ? error.message : String(error)}`, + ...input.source.at(argumentSpan(call, descriptor.args, argumentIndexOf(error))), + }); + return undefined; + } + return output; +} diff --git a/packages/1-framework/2-authoring/psl-parser/test/authoring-arguments.test.ts b/packages/1-framework/2-authoring/psl-parser/test/authoring-arguments.test.ts new file mode 100644 index 000000000000..53642598ac5a --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/authoring-arguments.test.ts @@ -0,0 +1,61 @@ +import type { ContractSourceDiagnostic } from '@internal/config/config-types'; +import type { AuthoringTypeChoiceOption } from '@internal/framework-components/authoring'; +import type { PslSpan } from '@internal/framework-components/psl-ast'; +import { describe, expect, it } from 'vitest'; +import { EMPTY_DATA_TYPES } from '../src/attribute-spec/spec-context'; +import { mapPslHelperArgs } from '../src/authoring-arguments'; +import { createPslDiagnosticCollector, diagnosticSource } from '../src/diagnostic'; +import { parse } from '../src/parse'; +import type { ResolvedAttributeArg } from '../src/resolve'; + +const SPAN: PslSpan = { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 0, line: 1, column: 1 }, +}; + +const AS: AuthoringTypeChoiceOption = { + kind: 'option', + name: 'as', + values: ['string', 'temporal'], + default: 'string', + optional: true, +}; + +function positional(value: string): ResolvedAttributeArg { + return { kind: 'positional', value, span: SPAN }; +} + +function callMap(args: readonly ResolvedAttributeArg[]): { + result: readonly unknown[] | undefined; + diagnostics: ContractSourceDiagnostic[]; +} { + const { document, sources } = parse('', 'schema.prisma'); + const diagnostics = createPslDiagnosticCollector(sources); + const result = mapPslHelperArgs({ + args, + descriptors: [AS], + helperLabel: 'type "DateTime"', + span: SPAN, + diagnostics, + source: diagnosticSource(sources, document.syntax), + entityLabel: 'Field "Model.field"', + dataTypes: EMPTY_DATA_TYPES, + }); + return { result, diagnostics: diagnostics.toExternal() }; +} + +describe('mapPslHelperArgs on a type choice option', () => { + it('reads a bare name', () => { + expect(callMap([positional('temporal')])).toEqual({ result: ['temporal'], diagnostics: [] }); + }); + + it.each(['"string"', '1', '{a: 1}'])('names the values the option takes for %s', (written) => { + const { result, diagnostics } = callMap([positional(written)]); + expect(result).toBeUndefined(); + expect(diagnostics[0]?.message).toMatch( + new RegExp( + ` argument #1 is ${written.replace(/[{}]/g, '\\$&')}, but argument "as" takes one of these names, written without quotes: string, temporal\\.$`, + ), + ); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/authoring-arguments.value.test.ts b/packages/1-framework/2-authoring/psl-parser/test/authoring-arguments.value.test.ts index 4a189ad98f90..1e86b008917b 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/authoring-arguments.value.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/authoring-arguments.value.test.ts @@ -1,6 +1,7 @@ import type { ContractSourceDiagnostic } from '@internal/config/config-types'; import type { AuthoringArgumentDescriptor, + AuthoringTypeChoiceOption, DataTypeAuthoringEntry, DataTypeSupport, } from '@internal/framework-components/authoring'; @@ -64,7 +65,7 @@ const definition = { kind: 'value', dataType: 'sql/expression', name: 'sql' } as function callWith( argumentText: string, - descriptors: readonly AuthoringArgumentDescriptor[], + descriptors: readonly (AuthoringArgumentDescriptor | AuthoringTypeChoiceOption)[], ): { result: readonly unknown[] | undefined; diagnostics: ContractSourceDiagnostic[] } { const cursor = new Cursor('schema.prisma', `@x(${argumentText})`); const root = createSyntaxTree(parseAttribute(cursor)); @@ -199,6 +200,37 @@ describe('mapPslHelperArgs on a value argument with no expression', () => { }); }); +describe('a number written where an option is expected', () => { + const option = { + kind: 'option', + name: 'as', + values: ['string', 'temporal'], + default: 'string', + optional: true, + } as const; + const message = (refusal: { diagnostics: ContractSourceDiagnostic[] }) => + refusal.diagnostics[0]?.message; + + it('says to write the one value argument that takes a number by name', () => { + expect(message(callWith('3', [option, { ...length, name: 'precision' }]))).toBe( + 'Field "Model.field" constructor "Sized" argument #1 is 3, but argument "as" takes one of these names, written without quotes: string, temporal. Write precision by name, as in precision: 3.', + ); + }); + + it('says only which names the option takes when no value argument takes a number', () => { + const label = { kind: 'value', dataType: 'pg/text', name: 'label', optional: true } as const; + expect(message(callWith('3', [option, label]))).toBe( + 'Field "Model.field" constructor "Sized" argument #1 is 3, but argument "as" takes one of these names, written without quotes: string, temporal.', + ); + }); + + it('says only which names the option takes when two value arguments take a number', () => { + expect(message(callWith('3', [option, length, { ...length, name: 'scale' }]))).toBe( + 'Field "Model.field" constructor "Sized" argument #1 is 3, but argument "as" takes one of these names, written without quotes: string, temporal.', + ); + }); +}); + describe('mapPslHelperArgs positional and named dispatch', () => { const descriptors = [ { kind: 'value', dataType: 'pg/int4', name: 'x' }, diff --git a/packages/1-framework/2-authoring/psl-parser/test/binder-contributed-types.entity-argument.test.ts b/packages/1-framework/2-authoring/psl-parser/test/binder-contributed-types.entity-argument.test.ts new file mode 100644 index 000000000000..37b77484c6fc --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/binder-contributed-types.entity-argument.test.ts @@ -0,0 +1,372 @@ +import { describe, expect, it } from 'vitest'; +import type { FieldSymbol, NamedTypeSymbol } from '../src/symbol-table'; +import { PathExprAst } from '../src/syntax/ast/expressions'; +import type { SyntaxNode } from '../src/syntax/red'; +import { bindProject } from './binder-contributed-types.helpers'; + +function argumentNode( + holder: FieldSymbol | NamedTypeSymbol | undefined, + index = 0, +): SyntaxNode | undefined { + return holder?.typeConstructor?.args[index]?.expression?.syntax; +} + +const entities = + 'tag TopTag {}\nmodel TopModel {\n id String\n}\nnamespace auth {\n tag AuthTag {}\n}\n'; + +describe('createBinder — entity argument of a type constructor', () => { + it('binds a name in scope to the block it names', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.tagged(TopTag)\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'block', + symbol: symbolTable.defaultNamespace.blocks['TopTag'], + namespace: symbolTable.defaultNamespace, + }); + }); + + it('binds a name that is a model, without checking the kind', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.tagged(TopModel)\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'model', + symbol: symbolTable.defaultNamespace.models['TopModel'], + namespace: symbolTable.defaultNamespace, + }); + }); + + it('binds a qualified name to the member, and its qualifier to the namespace', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.tagged(auth.AuthTag)\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); + const [qualifier] = (argument && PathExprAst.cast(argument)?.segments()) ?? []; + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'block', + symbol: symbolTable.namespaces['auth']?.blocks['AuthTag'], + namespace: symbolTable.namespaces['auth'], + }); + expect(qualifier && binder.symbolForNode(qualifier.syntax)).toEqual({ + kind: 'namespace', + symbol: symbolTable.namespaces['auth'], + }); + }); + + it('reports an unknown name once, on the argument, and records it as unresolved', () => { + const { symbolTable, binder, diagnostics, sources } = bindProject( + `${entities}model Post {\n value db.tagged(Nope)\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); + const file = argument && sources.sourceFileFor(argument); + + expect(diagnostics).toEqual([ + { + code: 'PSL_UNRESOLVED_REFERENCE', + message: 'Cannot find entity "Nope"', + data: { reference: 'entity' }, + filename: 'schema.prisma', + range: argument && + file && { + start: file.positionAt(argument.offset), + end: file.positionAt(argument.endOffset), + }, + }, + ]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'unresolved', + name: 'Nope', + }); + }); + + it('does not see a name declared inside a namespace when it is written unqualified', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.tagged(AuthTag)\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); + + expect(diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ + { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find entity "AuthTag"' }, + ]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'unresolved', + name: 'AuthTag', + }); + }); + + it('binds the positional argument at the index the constructor declares', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.secondTagged(TopModel, TopTag)\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value'], 1); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'block', + symbol: symbolTable.defaultNamespace.blocks['TopTag'], + namespace: symbolTable.defaultNamespace, + }); + }); + + it('counts positional arguments only when named arguments are mixed in', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.secondTagged(TopModel, extra: TopModel, TopTag)\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value'], 2); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'block', + symbol: symbolTable.defaultNamespace.blocks['TopTag'], + namespace: symbolTable.defaultNamespace, + }); + }); + + it('records nothing for a string', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.tagged("TopTag")\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toBeUndefined(); + }); + + it('records nothing for a number', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.tagged(1)\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toBeUndefined(); + }); + + it('records nothing for a path of three segments', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.tagged(auth.AuthTag.more)\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toBeUndefined(); + }); + + it('records nothing for a named argument', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.tagged(tag: TopTag)\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toBeUndefined(); + }); + + it('records nothing for an empty argument list', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.tagged()\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toBeUndefined(); + }); + + it('records nothing for the arguments of a constructor that declares no entity argument', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}model Post {\n value db.VarChar(TopTag)\n}`, + ); + const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toBeUndefined(); + }); + + it('binds a name of the namespace of a model declared in a namespace', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}namespace auth {\n model Post {\n value db.tagged(AuthTag)\n }\n}`, + ); + const argument = argumentNode(symbolTable.namespaces['auth']?.models['Post']?.fields['value']); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'block', + symbol: symbolTable.namespaces['auth']?.blocks['AuthTag'], + namespace: symbolTable.namespaces['auth'], + }); + }); + + it('binds a top-level name from a model declared in a namespace', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}namespace auth {\n model Post {\n value db.tagged(TopTag)\n }\n}`, + ); + const argument = argumentNode(symbolTable.namespaces['auth']?.models['Post']?.fields['value']); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'block', + symbol: symbolTable.defaultNamespace.blocks['TopTag'], + namespace: symbolTable.defaultNamespace, + }); + }); + + it('binds a qualified name and its qualifier from a model declared in a namespace', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}namespace auth {\n model Post {\n value db.tagged(auth.AuthTag)\n }\n}`, + ); + const argument = argumentNode(symbolTable.namespaces['auth']?.models['Post']?.fields['value']); + const [qualifier] = (argument && PathExprAst.cast(argument)?.segments()) ?? []; + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'block', + symbol: symbolTable.namespaces['auth']?.blocks['AuthTag'], + namespace: symbolTable.namespaces['auth'], + }); + expect(qualifier && binder.symbolForNode(qualifier.syntax)).toEqual({ + kind: 'namespace', + symbol: symbolTable.namespaces['auth'], + }); + }); + + it('reports an unknown name from a model declared in a namespace', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}namespace auth {\n model Post {\n value db.tagged(Nope)\n }\n}`, + ); + const argument = argumentNode(symbolTable.namespaces['auth']?.models['Post']?.fields['value']); + + expect(diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ + { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find entity "Nope"' }, + ]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'unresolved', + name: 'Nope', + }); + }); + + it('binds a name in scope from a field of a composite type', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}type Address {\n value db.tagged(TopTag)\n}`, + ); + const argument = argumentNode( + symbolTable.defaultNamespace.compositeTypes['Address']?.fields['value'], + ); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'block', + symbol: symbolTable.defaultNamespace.blocks['TopTag'], + namespace: symbolTable.defaultNamespace, + }); + }); + + it('binds a qualified name and its qualifier from a field of a composite type', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}type Address {\n value db.tagged(auth.AuthTag)\n}`, + ); + const argument = argumentNode( + symbolTable.defaultNamespace.compositeTypes['Address']?.fields['value'], + ); + const [qualifier] = (argument && PathExprAst.cast(argument)?.segments()) ?? []; + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'block', + symbol: symbolTable.namespaces['auth']?.blocks['AuthTag'], + namespace: symbolTable.namespaces['auth'], + }); + expect(qualifier && binder.symbolForNode(qualifier.syntax)).toEqual({ + kind: 'namespace', + symbol: symbolTable.namespaces['auth'], + }); + }); + + it('reports an unknown name from a field of a composite type', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}type Address {\n value db.tagged(Nope)\n}`, + ); + const argument = argumentNode( + symbolTable.defaultNamespace.compositeTypes['Address']?.fields['value'], + ); + + expect(diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ + { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find entity "Nope"' }, + ]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'unresolved', + name: 'Nope', + }); + }); + + it('binds a top-level name from a named type', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}types {\n Alias = db.tagged(TopTag)\n}`, + ); + const argument = argumentNode(symbolTable.namedTypes['Alias']); + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'block', + symbol: symbolTable.defaultNamespace.blocks['TopTag'], + namespace: symbolTable.defaultNamespace, + }); + }); + + it('binds a name of a namespace when it is qualified, from a named type', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}types {\n Alias = db.tagged(auth.AuthTag)\n}`, + ); + const argument = argumentNode(symbolTable.namedTypes['Alias']); + const [qualifier] = (argument && PathExprAst.cast(argument)?.segments()) ?? []; + + expect(diagnostics).toEqual([]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'block', + symbol: symbolTable.namespaces['auth']?.blocks['AuthTag'], + namespace: symbolTable.namespaces['auth'], + }); + expect(qualifier && binder.symbolForNode(qualifier.syntax)).toEqual({ + kind: 'namespace', + symbol: symbolTable.namespaces['auth'], + }); + }); + + it('reports an unknown name from a named type', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}types {\n Alias = db.tagged(Nope)\n}`, + ); + const argument = argumentNode(symbolTable.namedTypes['Alias']); + + expect(diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ + { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find entity "Nope"' }, + ]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'unresolved', + name: 'Nope', + }); + }); + + it('does not resolve the own name of a named type to itself', () => { + const { symbolTable, binder, diagnostics } = bindProject( + `${entities}types {\n Alias = db.tagged(Alias)\n}`, + ); + const argument = argumentNode(symbolTable.namedTypes['Alias']); + + expect(diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ + { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find entity "Alias"' }, + ]); + expect(argument && binder.symbolForNode(argument)).toEqual({ + kind: 'unresolved', + name: 'Alias', + }); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/binder-contributed-types.helpers.ts b/packages/1-framework/2-authoring/psl-parser/test/binder-contributed-types.helpers.ts new file mode 100644 index 000000000000..5f2cc9ae8e6e --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/binder-contributed-types.helpers.ts @@ -0,0 +1,111 @@ +import type { + AuthoringTypeChoiceDescriptor, + AuthoringTypeConstructorDescriptor, +} from '@internal/framework-components/authoring'; +import { EMPTY_GENERATOR_FUNCTIONS } from '@internal/framework-components/components'; +import { EMPTY_DATA_TYPES } from '../src/attribute-spec/spec-context'; +import { type BinderContext, createBinder, typeReferenceNode } from '../src/binder'; +import { parse } from '../src/parse'; +import { PslSources } from '../src/source-file'; +import { buildSymbolTable } from '../src/symbol-table'; + +export const text: AuthoringTypeConstructorDescriptor = { + kind: 'typeConstructor', + output: { codecId: 'fixture/text@1' }, +}; + +export const varchar: AuthoringTypeConstructorDescriptor = { + kind: 'typeConstructor', + args: [{ kind: 'value', dataType: 'fixture/int', name: 'length' }], + output: { codecId: 'fixture/varchar@1' }, +}; + +const tagged: AuthoringTypeConstructorDescriptor = { + kind: 'typeConstructor', + entityRefArg: { index: 0, entityKind: 'tag' }, + output: { codecId: 'fixture/tagged@1' }, +}; + +const secondTagged: AuthoringTypeConstructorDescriptor = { + kind: 'typeConstructor', + entityRefArg: { index: 1, entityKind: 'tag' }, + output: { codecId: 'fixture/tagged@1' }, +}; + +const wideDate: AuthoringTypeConstructorDescriptor = { + kind: 'typeConstructor', + output: { codecId: 'fixture/wide-date@1' }, +}; + +const wideText: AuthoringTypeConstructorDescriptor = { + kind: 'typeConstructor', + output: { codecId: 'fixture/wide-text@1' }, +}; + +const narrowDate: AuthoringTypeConstructorDescriptor = { + kind: 'typeConstructor', + output: { codecId: 'fixture/narrow-date@1' }, +}; + +const moment: AuthoringTypeChoiceDescriptor = { + kind: 'typeChoice', + args: [ + { kind: 'option', name: 'as', values: ['date', 'text'], default: 'date', optional: true }, + { + kind: 'option', + name: 'storage', + values: ['wide', 'narrow'], + default: 'wide', + optional: true, + }, + ], + choices: [ + { when: { as: 'date', storage: 'wide' }, type: 'WideDate' }, + { when: { as: 'text', storage: 'wide' }, type: 'WideText' }, + { when: { as: 'date', storage: 'narrow' }, type: 'NarrowDate' }, + ], +}; + +function context(): BinderContext { + return { + authoringContributions: { + type: { + String: text, + Moment: moment, + WideDate: wideDate, + WideText: wideText, + NarrowDate: narrowDate, + db: { VarChar: varchar, tagged, secondTagged }, + }, + entityTypes: {}, + attributeSpecs: { model: {}, field: {} }, + modelAttributes: {}, + pslBlockDescriptors: {}, + dataTypes: {}, + }, + controlMutationDefaults: { defaultFunctionRegistry: new Map() }, + generatorFunctions: EMPTY_GENERATOR_FUNCTIONS, + dataTypes: EMPTY_DATA_TYPES, + }; +} + +export function bindProject(text: string) { + const { document, sources } = parse(text, 'schema.prisma'); + const pslSources = new PslSources([[document.syntax, sources.sourceFileFor(document.syntax)]]); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources: pslSources, + defaultNamespaceId: 'public', + }); + const { binder, diagnostics } = createBinder({ + symbolTable, + sources: pslSources, + context: context(), + }); + const resolve = (fieldName: string) => { + const field = symbolTable.defaultNamespace.models['Post']?.fields[fieldName]; + const node = field === undefined ? undefined : typeReferenceNode(field); + return node === undefined ? undefined : binder.symbolForNode(node); + }; + return { resolve, diagnostics, binder, symbolTable, sources: pslSources }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/test/binder-contributed-types.test.ts b/packages/1-framework/2-authoring/psl-parser/test/binder-contributed-types.test.ts index fc04158a34dd..09a73a3b2af9 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder-contributed-types.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder-contributed-types.test.ts @@ -1,88 +1,8 @@ -import type { AuthoringTypeConstructorDescriptor } from '@internal/framework-components/authoring'; -import { EMPTY_GENERATOR_FUNCTIONS } from '@internal/framework-components/components'; import { describe, expect, it } from 'vitest'; import { EMPTY_DATA_TYPES } from '../src/attribute-spec/spec-context'; -import { - type BinderContext, - contributedTypeOf, - createBinder, - typeReferenceNode, -} from '../src/binder'; -import { parse } from '../src/parse'; -import { PslSources } from '../src/source-file'; -import { buildSymbolTable, type FieldSymbol, type NamedTypeSymbol } from '../src/symbol-table'; -import { PathExprAst } from '../src/syntax/ast/expressions'; -import type { SyntaxNode } from '../src/syntax/red'; - -const text: AuthoringTypeConstructorDescriptor = { - kind: 'typeConstructor', - output: { codecId: 'fixture/text@1' }, -}; - -const varchar: AuthoringTypeConstructorDescriptor = { - kind: 'typeConstructor', - args: [{ kind: 'value', dataType: 'fixture/int', name: 'length' }], - output: { codecId: 'fixture/varchar@1' }, -}; - -const tagged: AuthoringTypeConstructorDescriptor = { - kind: 'typeConstructor', - entityRefArg: { index: 0, entityKind: 'tag' }, - output: { codecId: 'fixture/tagged@1' }, -}; - -const secondTagged: AuthoringTypeConstructorDescriptor = { - kind: 'typeConstructor', - entityRefArg: { index: 1, entityKind: 'tag' }, - output: { codecId: 'fixture/tagged@1' }, -}; - -function context(): BinderContext { - return { - authoringContributions: { - type: { String: text, db: { VarChar: varchar, tagged, secondTagged } }, - entityTypes: {}, - attributeSpecs: { model: {}, field: {} }, - modelAttributes: {}, - pslBlockDescriptors: {}, - dataTypes: {}, - }, - controlMutationDefaults: { defaultFunctionRegistry: new Map() }, - generatorFunctions: EMPTY_GENERATOR_FUNCTIONS, - dataTypes: EMPTY_DATA_TYPES, - }; -} - -function bindProject(text: string) { - const { document, sources } = parse(text, 'schema.prisma'); - const pslSources = new PslSources([[document.syntax, sources.sourceFileFor(document.syntax)]]); - const { symbolTable } = buildSymbolTable({ - documents: [document], - sources: pslSources, - defaultNamespaceId: 'public', - }); - const { binder, diagnostics } = createBinder({ - symbolTable, - sources: pslSources, - context: context(), - }); - const resolve = (fieldName: string) => { - const field = symbolTable.defaultNamespace.models['Post']?.fields[fieldName]; - const node = field === undefined ? undefined : typeReferenceNode(field); - return node === undefined ? undefined : binder.symbolForNode(node); - }; - return { resolve, diagnostics, binder, symbolTable, sources: pslSources }; -} - -function argumentNode( - holder: FieldSymbol | NamedTypeSymbol | undefined, - index = 0, -): SyntaxNode | undefined { - return holder?.typeConstructor?.args[index]?.expression?.syntax; -} - -const entities = - 'tag TopTag {}\nmodel TopModel {\n id String\n}\nnamespace auth {\n tag AuthTag {}\n}\n'; +import { contributedTypeOf } from '../src/binder'; +import { contributedTypeCodecId } from '../src/contributed-type-scope'; +import { bindProject, text, varchar } from './binder-contributed-types.helpers'; describe('createBinder — contributed types', () => { it('binds a type-constructor name to the type-constructor descriptor', () => { @@ -107,11 +27,9 @@ describe('createBinder — contributed types', () => { it('returns the contributed type a field resolves to', () => { const { resolve, binder } = bindProject('model Post {\n title String\n}'); - expect(contributedTypeOf(resolve('title'), binder)).toEqual({ - kind: 'contributedType', - name: 'String', - path: ['String'], - descriptor: text, + expect(contributedTypeOf(resolve('title'), binder, undefined)).toEqual({ + symbol: { kind: 'contributedType', name: 'String', path: ['String'], descriptor: text }, + call: undefined, }); }); @@ -121,11 +39,9 @@ describe('createBinder — contributed types', () => { ); expect(resolve('slug')?.kind).toBe('namedType'); - expect(contributedTypeOf(resolve('slug'), binder)).toEqual({ - kind: 'contributedType', - name: 'VarChar', - path: ['db', 'VarChar'], - descriptor: varchar, + expect(contributedTypeOf(resolve('slug'), binder, undefined)).toMatchObject({ + symbol: { kind: 'contributedType', name: 'VarChar', path: ['db', 'VarChar'] }, + call: { path: ['db', 'VarChar'] }, }); }); @@ -134,366 +50,59 @@ describe('createBinder — contributed types', () => { 'model Author {\n id String\n}\nmodel Post {\n author Author\n missing Nope\n}', ); - expect(contributedTypeOf(resolve('author'), binder)).toBeUndefined(); - expect(contributedTypeOf(resolve('missing'), binder)).toBeUndefined(); - expect(contributedTypeOf(undefined, binder)).toBeUndefined(); - }); - }); -}); - -describe('createBinder — entity argument of a type constructor', () => { - it('binds a name in scope to the block it names', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.tagged(TopTag)\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'block', - symbol: symbolTable.defaultNamespace.blocks['TopTag'], - namespace: symbolTable.defaultNamespace, - }); - }); - - it('binds a name that is a model, without checking the kind', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.tagged(TopModel)\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'model', - symbol: symbolTable.defaultNamespace.models['TopModel'], - namespace: symbolTable.defaultNamespace, - }); - }); - - it('binds a qualified name to the member, and its qualifier to the namespace', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.tagged(auth.AuthTag)\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); - const [qualifier] = (argument && PathExprAst.cast(argument)?.segments()) ?? []; - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'block', - symbol: symbolTable.namespaces['auth']?.blocks['AuthTag'], - namespace: symbolTable.namespaces['auth'], - }); - expect(qualifier && binder.symbolForNode(qualifier.syntax)).toEqual({ - kind: 'namespace', - symbol: symbolTable.namespaces['auth'], - }); - }); - - it('reports an unknown name once, on the argument, and records it as unresolved', () => { - const { symbolTable, binder, diagnostics, sources } = bindProject( - `${entities}model Post {\n value db.tagged(Nope)\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); - const file = argument && sources.sourceFileFor(argument); - - expect(diagnostics).toEqual([ - { - code: 'PSL_UNRESOLVED_REFERENCE', - message: 'Cannot find entity "Nope"', - data: { reference: 'entity' }, - filename: 'schema.prisma', - range: argument && - file && { - start: file.positionAt(argument.offset), - end: file.positionAt(argument.endOffset), - }, - }, - ]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'unresolved', - name: 'Nope', - }); - }); - - it('does not see a name declared inside a namespace when it is written unqualified', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.tagged(AuthTag)\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); - - expect(diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ - { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find entity "AuthTag"' }, - ]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'unresolved', - name: 'AuthTag', - }); - }); - - it('binds the positional argument at the index the constructor declares', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.secondTagged(TopModel, TopTag)\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value'], 1); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'block', - symbol: symbolTable.defaultNamespace.blocks['TopTag'], - namespace: symbolTable.defaultNamespace, - }); - }); - - it('counts positional arguments only when named arguments are mixed in', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.secondTagged(TopModel, extra: TopModel, TopTag)\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value'], 2); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'block', - symbol: symbolTable.defaultNamespace.blocks['TopTag'], - namespace: symbolTable.defaultNamespace, - }); - }); - - it('records nothing for a string', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.tagged("TopTag")\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toBeUndefined(); - }); - - it('records nothing for a number', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.tagged(1)\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toBeUndefined(); - }); - - it('records nothing for a path of three segments', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.tagged(auth.AuthTag.more)\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toBeUndefined(); - }); - - it('records nothing for a named argument', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.tagged(tag: TopTag)\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toBeUndefined(); - }); - - it('records nothing for an empty argument list', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.tagged()\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toBeUndefined(); - }); - - it('records nothing for the arguments of a constructor that declares no entity argument', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}model Post {\n value db.VarChar(TopTag)\n}`, - ); - const argument = argumentNode(symbolTable.defaultNamespace.models['Post']?.fields['value']); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toBeUndefined(); - }); - - it('binds a name of the namespace of a model declared in a namespace', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}namespace auth {\n model Post {\n value db.tagged(AuthTag)\n }\n}`, - ); - const argument = argumentNode(symbolTable.namespaces['auth']?.models['Post']?.fields['value']); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'block', - symbol: symbolTable.namespaces['auth']?.blocks['AuthTag'], - namespace: symbolTable.namespaces['auth'], - }); - }); - - it('binds a top-level name from a model declared in a namespace', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}namespace auth {\n model Post {\n value db.tagged(TopTag)\n }\n}`, - ); - const argument = argumentNode(symbolTable.namespaces['auth']?.models['Post']?.fields['value']); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'block', - symbol: symbolTable.defaultNamespace.blocks['TopTag'], - namespace: symbolTable.defaultNamespace, - }); - }); - - it('binds a qualified name and its qualifier from a model declared in a namespace', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}namespace auth {\n model Post {\n value db.tagged(auth.AuthTag)\n }\n}`, - ); - const argument = argumentNode(symbolTable.namespaces['auth']?.models['Post']?.fields['value']); - const [qualifier] = (argument && PathExprAst.cast(argument)?.segments()) ?? []; - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'block', - symbol: symbolTable.namespaces['auth']?.blocks['AuthTag'], - namespace: symbolTable.namespaces['auth'], - }); - expect(qualifier && binder.symbolForNode(qualifier.syntax)).toEqual({ - kind: 'namespace', - symbol: symbolTable.namespaces['auth'], - }); - }); - - it('reports an unknown name from a model declared in a namespace', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}namespace auth {\n model Post {\n value db.tagged(Nope)\n }\n}`, - ); - const argument = argumentNode(symbolTable.namespaces['auth']?.models['Post']?.fields['value']); - - expect(diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ - { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find entity "Nope"' }, - ]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'unresolved', - name: 'Nope', - }); - }); - - it('binds a name in scope from a field of a composite type', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}type Address {\n value db.tagged(TopTag)\n}`, - ); - const argument = argumentNode( - symbolTable.defaultNamespace.compositeTypes['Address']?.fields['value'], - ); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'block', - symbol: symbolTable.defaultNamespace.blocks['TopTag'], - namespace: symbolTable.defaultNamespace, - }); - }); - - it('binds a qualified name and its qualifier from a field of a composite type', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}type Address {\n value db.tagged(auth.AuthTag)\n}`, - ); - const argument = argumentNode( - symbolTable.defaultNamespace.compositeTypes['Address']?.fields['value'], - ); - const [qualifier] = (argument && PathExprAst.cast(argument)?.segments()) ?? []; - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'block', - symbol: symbolTable.namespaces['auth']?.blocks['AuthTag'], - namespace: symbolTable.namespaces['auth'], - }); - expect(qualifier && binder.symbolForNode(qualifier.syntax)).toEqual({ - kind: 'namespace', - symbol: symbolTable.namespaces['auth'], - }); - }); - - it('reports an unknown name from a field of a composite type', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}type Address {\n value db.tagged(Nope)\n}`, - ); - const argument = argumentNode( - symbolTable.defaultNamespace.compositeTypes['Address']?.fields['value'], - ); - - expect(diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ - { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find entity "Nope"' }, - ]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'unresolved', - name: 'Nope', - }); - }); - - it('binds a top-level name from a named type', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}types {\n Alias = db.tagged(TopTag)\n}`, - ); - const argument = argumentNode(symbolTable.namedTypes['Alias']); - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'block', - symbol: symbolTable.defaultNamespace.blocks['TopTag'], - namespace: symbolTable.defaultNamespace, - }); - }); - - it('binds a name of a namespace when it is qualified, from a named type', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}types {\n Alias = db.tagged(auth.AuthTag)\n}`, - ); - const argument = argumentNode(symbolTable.namedTypes['Alias']); - const [qualifier] = (argument && PathExprAst.cast(argument)?.segments()) ?? []; - - expect(diagnostics).toEqual([]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'block', - symbol: symbolTable.namespaces['auth']?.blocks['AuthTag'], - namespace: symbolTable.namespaces['auth'], - }); - expect(qualifier && binder.symbolForNode(qualifier.syntax)).toEqual({ - kind: 'namespace', - symbol: symbolTable.namespaces['auth'], + expect(contributedTypeOf(resolve('author'), binder, undefined)).toBeUndefined(); + expect(contributedTypeOf(resolve('missing'), binder, undefined)).toBeUndefined(); + expect(contributedTypeOf(undefined, binder, undefined)).toBeUndefined(); + }); + }); + + describe('contributedTypeCodecId of a type choice', () => { + const { resolve, binder, symbolTable } = bindProject( + [ + 'types {', + ' Stamp = Moment(as: text)', + '}', + 'model Post {', + ' bare Moment', + ' named Moment(storage: narrow)', + ' positional Moment(text)', + ' aliased Stamp', + ' refused Moment(as: text, storage: narrow)', + ' unknownNamed Moment(bogus: narrow)', + ' twice Moment(text, as: date)', + '}', + ].join('\n'), + ); + const codecOf = (fieldName: string) => { + const field = symbolTable.defaultNamespace.models['Post']?.fields[fieldName]; + const use = contributedTypeOf(resolve(fieldName), binder, field?.typeConstructor); + return use === undefined + ? 'no contributed type' + : contributedTypeCodecId(use, EMPTY_DATA_TYPES); + }; + + it('is the codec of the constructor the call chooses, bare, named or positional', () => { + expect({ + bare: codecOf('bare'), + named: codecOf('named'), + positional: codecOf('positional'), + }).toEqual({ + bare: 'fixture/wide-date@1', + named: 'fixture/narrow-date@1', + positional: 'fixture/wide-text@1', + }); }); - }); - - it('reports an unknown name from a named type', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}types {\n Alias = db.tagged(Nope)\n}`, - ); - const argument = argumentNode(symbolTable.namedTypes['Alias']); - expect(diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ - { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find entity "Nope"' }, - ]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'unresolved', - name: 'Nope', + it('follows a type alias to the call it is written as', () => { + expect(codecOf('aliased')).toBe('fixture/wide-text@1'); }); - }); - - it('does not resolve the own name of a named type to itself', () => { - const { symbolTable, binder, diagnostics } = bindProject( - `${entities}types {\n Alias = db.tagged(Alias)\n}`, - ); - const argument = argumentNode(symbolTable.namedTypes['Alias']); - expect(diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ - { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find entity "Alias"' }, - ]); - expect(argument && binder.symbolForNode(argument)).toEqual({ - kind: 'unresolved', - name: 'Alias', + it('is unknown for a call the column refuses, as the column reads the call', () => { + expect({ + refused: codecOf('refused'), + unknownNamed: codecOf('unknownNamed'), + twice: codecOf('twice'), + }).toEqual({ refused: undefined, unknownNamed: undefined, twice: undefined }); }); }); }); diff --git a/packages/1-framework/2-authoring/psl-parser/test/binder.mixin.test.ts b/packages/1-framework/2-authoring/psl-parser/test/binder.mixin.test.ts index f5703c26f75c..7b438ff4428f 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.mixin.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.mixin.test.ts @@ -602,7 +602,7 @@ describe('createBinder — binding a mixin body once', () => { ); expect(diagnostics).toEqual([]); - expect(fieldContexts.map((ctx) => ctx.typeConstructor?.name)).toEqual([ + expect(fieldContexts.map((ctx) => ctx.contributedType?.symbol.name)).toEqual([ 'Int', 'Int', undefined, diff --git a/packages/1-framework/2-authoring/psl-parser/test/removed-field-presets.test.ts b/packages/1-framework/2-authoring/psl-parser/test/removed-field-presets.test.ts index ddaf56282cd1..c0750e60cfd6 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/removed-field-presets.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/removed-field-presets.test.ts @@ -3,53 +3,104 @@ import type { ContributedTypeNamespace } from '../src/contributed-type-scope'; import { parse } from '../src/parse'; import { describeRemovedFieldPreset, + presetType, removedPresetWithPhases, removedPresetWrittenAs, } from '../src/removed-field-presets'; -import type { ResolvedTypeConstructorCall } from '../src/resolve'; import { PslSources } from '../src/source-file'; -import { buildSymbolTable } from '../src/symbol-table'; +import { buildSymbolTable, type FieldSymbol } from '../src/symbol-table'; -function callOf(type: string): ResolvedTypeConstructorCall { +function callOf(type: string): FieldSymbol { const { document, sources } = parse(`model M {\n f ${type}\n}\n`, 'schema.prisma'); const { symbolTable } = buildSymbolTable({ documents: [document], sources: new PslSources([[document.syntax, sources.sourceFileFor(document.syntax)]]), defaultNamespaceId: 'public', }); - const call = symbolTable.defaultNamespace.models['M']?.fields['f']?.typeConstructor; - if (call === undefined) throw new Error(`${type} is not a call`); - return call; + const field = symbolTable.defaultNamespace.models['M']?.fields['f']; + if (field?.typeConstructor === undefined) throw new Error(`${type} is not a call`); + return field; } const stamp = { kind: 'typeConstructor', output: { codecId: 'test/stamp@1' } } as const; +const text = { kind: 'typeConstructor', output: { codecId: 'test/text@1' } } as const; +const moment = { + kind: 'typeChoice', + args: [ + { kind: 'option', name: 'as', values: ['stamp', 'text'], default: 'text', optional: true }, + ], + choices: [ + { when: { as: 'stamp' }, type: 'Stamp' }, + { when: { as: 'text' }, type: 'Text' }, + ], +} as const; const presets = { 'ns.stamp': removedPresetWithPhases('Stamp', 'test/stamp@1', true), - plain: removedPresetWrittenAs('Stamp @id', 'test/stamp@1'), + plain: removedPresetWrittenAs('Stamp', 'test/stamp@1', '@id'), either: [ ...removedPresetWrittenAs('Stamp', 'test/stamp@1'), ...removedPresetWrittenAs('ns.Stamp', 'test/stamp@1'), ], + chosen: removedPresetWithPhases(presetType('Moment', { as: 'stamp' }), 'test/stamp@1', true), }; function describeCall( - call: ResolvedTypeConstructorCall, + field: FieldSymbol, types: ContributedTypeNamespace, isDeclared: (name: string) => boolean = () => false, ) { - return describeRemovedFieldPreset(presets, call, types, isDeclared); + return describeRemovedFieldPreset(presets, field, types, isDeclared); } describe('describeRemovedFieldPreset', () => { it('names the call and its rewrite', () => { expect(describeCall(callOf('ns.stamp(3, onUpdate: now)'), { Stamp: stamp })).toBe( - 'ns.stamp(3, onUpdate: now) was a field preset, and Prisma 8 removed field presets. Write Stamp(3) @onUpdate(now()).', + 'ns.stamp(3, onUpdate: now) was a field preset, and Prisma 8 removed field presets. Write Stamp(precision: 3) @onUpdate(now()).', ); expect(describeCall(callOf('plain()'), { Stamp: stamp })).toBe( 'plain() was a field preset, and Prisma 8 removed field presets. Write Stamp @id.', ); }); + it('names a type choice called with options where that call resolves to the codec', () => { + const types = { Moment: moment, Stamp: stamp, Text: text }; + expect([ + describeCall(callOf('chosen(3, onCreate: now)'), types), + describeCall(callOf('chosen(onCreate: later)'), types), + ]).toEqual([ + 'chosen(3, onCreate: now) was a field preset, and Prisma 8 removed field presets. Write Moment(as: stamp, precision: 3) @onCreate(now()).', + 'chosen(onCreate: later) was a field preset, and Prisma 8 removed field presets. Write Moment(as: stamp, ...) with @onCreate(now()) and @onUpdate(now()) for the phases the preset set.', + ]); + }); + + it('names nothing where the type choice call resolves to another codec', () => { + const textOnly = { + ...moment, + choices: [{ when: { as: 'stamp' }, type: 'Text' }], + } as const; + expect(describeCall(callOf('chosen()'), { Moment: textOnly, Text: text })).toBeUndefined(); + }); + + it.each([ + ['plain()?', 'Write Stamp? @id.'], + ['plain()[]', 'Write Stamp[] @id.'], + ['ns.stamp(3, onCreate: now)?', 'Write Stamp(precision: 3)? @onCreate(now()).'], + [ + 'ns.stamp(onCreate: later)[]', + 'Write Stamp(...)[] with @onCreate(now()) and @onUpdate(now()) for the phases the preset set.', + ], + ])('writes the modifier of %s right after the type, before the attributes', (type, rewrite) => { + const call = type.replace(/(\?|\[\])$/, ''); + expect(describeCall(callOf(type), { Stamp: stamp })).toBe( + `${call} was a field preset, and Prisma 8 removed field presets. ${rewrite}`, + ); + }); + + it('names nothing for a field not typed by a call', () => { + const { typeConstructor: _, ...field } = callOf('plain()'); + expect(describeCall(field, { Stamp: stamp })).toBeUndefined(); + }); + it('names the rewrite of the first row that applies to the stack', () => { expect( describeCall(callOf('either()'), { diff --git a/packages/1-framework/2-authoring/psl-parser/test/type-aliases.test.ts b/packages/1-framework/2-authoring/psl-parser/test/type-aliases.test.ts index ee085115b2a7..1c9d34c555f1 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/type-aliases.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/type-aliases.test.ts @@ -350,9 +350,9 @@ model Post { list: context?.list, typeName: context?.typeName, codecId: - context?.typeConstructor === undefined + context?.contributedType === undefined ? undefined - : contributedTypeCodecId(context.typeConstructor.descriptor), + : contributedTypeCodecId(context.contributedType, EMPTY_DATA_TYPES), }).toEqual({ alias: 'Slug', untyped: undefined, diff --git a/packages/1-framework/3-tooling/cli/src/commands/init/templates/code-templates.ts b/packages/1-framework/3-tooling/cli/src/commands/init/templates/code-templates.ts index 666cc1842db4..0d0efc3b69be 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/init/templates/code-templates.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/init/templates/code-templates.ts @@ -181,23 +181,23 @@ function starterSchemaPslPostgres(): string { return `// use prisma-8 model User { - id Int @id @default(autoincrement()) - email String @unique + id Int @id @default(autoincrement()) + email String @unique username String? name String? posts Post[] - createdAt TimestamptzString @default(now()) - updatedAt temporal.UpdatedAtString + createdAt DateTime @default(now()) @onCreate(now()) + updatedAt temporal.UpdatedAt } model Post { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) title String content String? - author User @relation(fields: [authorId], references: [id]) + author User @relation(fields: [authorId], references: [id]) authorId Int - createdAt TimestamptzString @default(now()) - updatedAt temporal.UpdatedAtString + createdAt DateTime @default(now()) @onCreate(now()) + updatedAt temporal.UpdatedAt } `; } @@ -226,7 +226,7 @@ model Post { } function starterSchemaTsPostgres(builder: string): string { - return `import { defineContract } from '${builder}'; + return `import { defineContract, now } from '${builder}'; export const contract = defineContract({}, ({ field, model, rel, gen, type }) => { const User = model('User', { @@ -235,8 +235,8 @@ export const contract = defineContract({}, ({ field, model, rel, gen, type }) => email: field.column(type.String()).unique(), username: field.column(type.String()).optional(), name: field.column(type.String()).optional(), - createdAt: field.column(type.temporal.CreatedAtString()), - updatedAt: field.column(type.temporal.UpdatedAtString()), + createdAt: field.column(type.DateTime()).default(now()).onCreate(gen.now()), + updatedAt: field.column(type.temporal.UpdatedAt()), }, }); @@ -246,8 +246,8 @@ export const contract = defineContract({}, ({ field, model, rel, gen, type }) => title: field.column(type.String()), content: field.column(type.String()).optional(), authorId: field.column(type.Uuid()), - createdAt: field.column(type.temporal.CreatedAtString()), - updatedAt: field.column(type.temporal.UpdatedAtString()), + createdAt: field.column(type.DateTime()).default(now()).onCreate(gen.now()), + updatedAt: field.column(type.temporal.UpdatedAt()), }, }); diff --git a/packages/1-framework/3-tooling/cli/test/commands/init/__snapshots__/templates.test.ts.snap b/packages/1-framework/3-tooling/cli/test/commands/init/__snapshots__/templates.test.ts.snap index cc827736bc74..90d73a69c4df 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/init/__snapshots__/templates.test.ts.snap +++ b/packages/1-framework/3-tooling/cli/test/commands/init/__snapshots__/templates.test.ts.snap @@ -623,23 +623,23 @@ exports[`templates > per-cell snapshots (FR5.4) > postgres + psl > starterSchema "// use prisma-8 model User { - id Int @id @default(autoincrement()) - email String @unique + id Int @id @default(autoincrement()) + email String @unique username String? name String? posts Post[] - createdAt TimestamptzString @default(now()) - updatedAt temporal.UpdatedAtString + createdAt DateTime @default(now()) @onCreate(now()) + updatedAt temporal.UpdatedAt } model Post { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) title String content String? - author User @relation(fields: [authorId], references: [id]) + author User @relation(fields: [authorId], references: [id]) authorId Int - createdAt TimestamptzString @default(now()) - updatedAt temporal.UpdatedAtString + createdAt DateTime @default(now()) @onCreate(now()) + updatedAt temporal.UpdatedAt } " `; @@ -798,7 +798,7 @@ If this project lives inside a pnpm workspace, a few things are worth knowing: `; exports[`templates > per-cell snapshots (FR5.4) > postgres + typescript > starterSchema is stable 1`] = ` -"import { defineContract } from '@internal/postgres/contract-builder'; +"import { defineContract, now } from '@internal/postgres/contract-builder'; export const contract = defineContract({}, ({ field, model, rel, gen, type }) => { const User = model('User', { @@ -807,8 +807,8 @@ export const contract = defineContract({}, ({ field, model, rel, gen, type }) => email: field.column(type.String()).unique(), username: field.column(type.String()).optional(), name: field.column(type.String()).optional(), - createdAt: field.column(type.temporal.CreatedAtString()), - updatedAt: field.column(type.temporal.UpdatedAtString()), + createdAt: field.column(type.DateTime()).default(now()).onCreate(gen.now()), + updatedAt: field.column(type.temporal.UpdatedAt()), }, }); @@ -818,8 +818,8 @@ export const contract = defineContract({}, ({ field, model, rel, gen, type }) => title: field.column(type.String()), content: field.column(type.String()).optional(), authorId: field.column(type.Uuid()), - createdAt: field.column(type.temporal.CreatedAtString()), - updatedAt: field.column(type.temporal.UpdatedAtString()), + createdAt: field.column(type.DateTime()).default(now()).onCreate(gen.now()), + updatedAt: field.column(type.temporal.UpdatedAt()), }, }); diff --git a/packages/1-framework/3-tooling/cli/test/commands/init/templates.test.ts b/packages/1-framework/3-tooling/cli/test/commands/init/templates.test.ts index 7ac6ae0e98f8..3196fb41bf34 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/init/templates.test.ts +++ b/packages/1-framework/3-tooling/cli/test/commands/init/templates.test.ts @@ -72,12 +72,12 @@ describe('templates', () => { expect(schema).toContain('id: field.column(type.Uuid()).id().onCreate(gen.uuid(7))'); expect(schema).toContain('authorId: field.column(type.Uuid())'); expect(schema).not.toContain('type.Char('); - expect(schema).toContain('updatedAt: field.column(type.temporal.UpdatedAtString())'); + expect(schema).toContain('updatedAt: field.column(type.temporal.UpdatedAt())'); expect(schema).not.toMatch(/field\.(id|temporal)\.|field\.(text|uuidString)\(/); }); it('writes no field preset into the postgres PSL starter schema', () => { - expect(starterSchema('postgres', 'psl')).toContain('updatedAt temporal.UpdatedAtString\n'); + expect(starterSchema('postgres', 'psl')).toContain('updatedAt temporal.UpdatedAt\n'); }); it('uses defineContract for mongo TypeScript imported from facade', () => { diff --git a/packages/1-framework/3-tooling/language-server/src/attribute-spec-resolution.ts b/packages/1-framework/3-tooling/language-server/src/attribute-spec-resolution.ts index dfc482863c13..d21bfbed86f6 100644 --- a/packages/1-framework/3-tooling/language-server/src/attribute-spec-resolution.ts +++ b/packages/1-framework/3-tooling/language-server/src/attribute-spec-resolution.ts @@ -193,9 +193,13 @@ export function attributeSpecResolver( }; const node = typeReferenceNode(field); const typeResolution = node === undefined ? undefined : source.binder.symbolForNode(node); - const typeConstructor = contributedTypeOf(typeResolution, source.binder); + const contributedType = contributedTypeOf( + typeResolution, + source.binder, + field.typeConstructor, + ); return (name) => - specs.field[name]?.({ ...specContext, field, typeResolution, typeConstructor }); + specs.field[name]?.({ ...specContext, field, typeResolution, contributedType }); } } } diff --git a/packages/1-framework/3-tooling/language-server/src/completion-scope.ts b/packages/1-framework/3-tooling/language-server/src/completion-scope.ts index bc22be3ba0a8..bf2941e1ef44 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-scope.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-scope.ts @@ -85,9 +85,11 @@ export function scopeCompletionItems( break; case 'contributedType': { const descriptor = resolution.symbol.descriptor; - if (descriptor.kind === 'typeAlias') { + if (descriptor.kind === 'typeAlias' || descriptor.kind === 'typeChoice') { item.kind = CompletionItemKind.Class; - item.detail = descriptor.documentation || 'Type alias'; + item.detail = + descriptor.documentation || + (descriptor.kind === 'typeAlias' ? 'Type alias' : 'Convenience type'); break; } const callable = descriptor.args !== undefined || descriptor.entityRefArg !== undefined; diff --git a/packages/1-framework/3-tooling/language-server/src/hover.ts b/packages/1-framework/3-tooling/language-server/src/hover.ts index 4f90368cc079..78e2866c2d65 100644 --- a/packages/1-framework/3-tooling/language-server/src/hover.ts +++ b/packages/1-framework/3-tooling/language-server/src/hover.ts @@ -1,30 +1,42 @@ import { + type AuthoringArgumentDescriptor, type AuthoringPslBlockDescriptorNamespace, + type AuthoringTypeChoiceOption, + type AuthoringTypeChoiceRow, type DataTypeSupport, describeAdmittedForms, + isAuthoringTypeChoiceOption, + writtenTypeConstructorCall, } from '@internal/framework-components/authoring'; import { dataTypeId } from '@internal/framework-components/codec'; -import type { - AttributeSymbol, - Binder, - BlockSymbol, - CompositeTypeSymbol, - ConstantSymbol, - ContributedTypeSymbol, - FieldSymbol, - FunctionSymbol, - MixinSymbol, - ModelSymbol, - NamedTypeSymbol, - NamespaceSymbol, - ParameterSymbol, +import { + type AttributeSymbol, + type Binder, + type BlockSymbol, + type CompositeTypeSymbol, + type ConstantSymbol, + type ContributedTypeChoiceSymbol, + type ContributedTypeSymbol, + chosenTypeConstructor, + type FieldSymbol, + type FunctionSymbol, + findBlockDescriptor, + isContributedTypeChoice, + type MixinSymbol, + type ModelSymbol, + type NamedTypeSymbol, + type NamespaceSymbol, + type ParameterSymbol, + printTypeConstructorCall, + readResolvedConstructorCall, } from '@internal/psl-parser'; -import { findBlockDescriptor } from '@internal/psl-parser'; import { GenericBlockDeclarationAst, MixinDeclarationAst, + type PslSources, type SyntaxNode, type SyntaxToken, + TypeAnnotationAst, } from '@internal/psl-parser/syntax'; import { type Hover, MarkupKind } from 'vscode-languageserver'; import type { PslCursorInput } from './attribute-syntax-context'; @@ -40,6 +52,7 @@ export interface ProvidePslHoverInput extends PslCursorInput { readonly pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; /** The stack's data types, which say how a type constructor's value arguments are written. */ readonly dataTypes: DataTypeSupport; + readonly sources: PslSources; } type HoverEntitySymbol = @@ -54,7 +67,11 @@ type HoverResult = | { readonly kind: 'entity'; readonly symbol: HoverEntitySymbol } | { readonly kind: 'namespace'; readonly symbol: NamespaceSymbol } | { readonly kind: 'attribute'; readonly symbol: AttributeSymbol } - | { readonly kind: 'contributedType'; readonly symbol: ContributedTypeSymbol } + | { + readonly kind: 'contributedType'; + readonly symbol: ContributedTypeSymbol; + readonly annotation: TypeAnnotationAst | undefined; + } | { readonly kind: 'parameter'; readonly symbol: ParameterSymbol } | { readonly kind: 'function'; readonly symbol: FunctionSymbol } | { readonly kind: 'constant'; readonly symbol: ConstantSymbol }; @@ -76,7 +93,7 @@ export function providePslHover(input: ProvidePslHoverInput): Hover | null { function hoverValueAt(input: ProvidePslHoverInput, token: SyntaxToken): string | undefined { const result = resolveHoverResult(input.binder, token); - if (result !== undefined) return renderHoverResult(result, input.dataTypes); + if (result !== undefined) return renderHoverResult(result, input); return blockKeywordDocumentationAt(token, input.pslBlockDescriptors); } @@ -95,7 +112,11 @@ function resolveHoverResult(binder: Binder, token: SyntaxToken): HoverResult | u case 'attribute': return { kind: 'attribute', symbol: resolution.symbol }; case 'contributedType': - return { kind: 'contributedType', symbol: resolution.symbol }; + return { + kind: 'contributedType', + symbol: resolution.symbol, + annotation: token.parent.findAncestor(TypeAnnotationAst.cast), + }; case 'parameter': return { kind: 'parameter', symbol: resolution.symbol }; case 'function': @@ -107,13 +128,11 @@ function resolveHoverResult(binder: Binder, token: SyntaxToken): HoverResult | u } } -function renderHoverResult(result: HoverResult, dataTypes: DataTypeSupport): string { +function renderHoverResult(result: HoverResult, input: ProvidePslHoverInput): string { if (result.kind === 'entity') return renderEntityContent(result.symbol); if (result.kind === 'namespace') return renderNamespaceContent(result.symbol); if (result.kind === 'attribute') return renderAttributeContent(result.symbol); - if (result.kind === 'contributedType') { - return renderContributedTypeContent(result.symbol, dataTypes); - } + if (result.kind === 'contributedType') return renderContributedTypeContent(result, input); if (result.kind === 'parameter') return renderParameterContent(result.symbol); if (result.kind === 'function') return renderFunctionContent(result.symbol); return renderConstantContent(result.symbol); @@ -137,11 +156,85 @@ function renderAttributeContent(symbol: AttributeSymbol): string { } function renderContributedTypeContent( - symbol: ContributedTypeSymbol, + result: Extract, + input: ProvidePslHoverInput, +): string { + const { symbol } = result; + const fence = ['```prisma', renderContributedTypeLabel(symbol, input.dataTypes), '```'].join( + '\n', + ); + if (!isContributedTypeChoice(symbol)) { + return withDocumentation(fence, symbol.descriptor.documentation); + } + const chosen = renderChosenType(symbol, result.annotation, input); + const head = chosen === undefined ? fence : `${fence}\n\n${chosen}`; + return `${withDocumentation(head, symbol.descriptor.documentation)}\n\n${renderTypeChoices(symbol, input.dataTypes)}`; +} + +/** The explicit type `type`, at the level of the type choice `symbol`, called with `args` as the tools write it; `undefined` when the type choice names no such type or an argument has no literal. */ +function writtenChosenType( + symbol: ContributedTypeChoiceSymbol, + type: string, + args: readonly unknown[], + dataTypes: DataTypeSupport, +): string | undefined { + const descriptor = symbol.choiceTypes.get(type); + return ( + descriptor && + writtenTypeConstructorCall([...symbol.path.slice(0, -1), type], descriptor, args, dataTypes) + ); +} + +/** The explicit type the call at `annotation` becomes, or `undefined` when its arguments choose none or it cannot be written. */ +function renderChosenType( + symbol: ContributedTypeChoiceSymbol, + annotation: TypeAnnotationAst | undefined, + input: ProvidePslHoverInput, +): string | undefined { + const call = readResolvedConstructorCall(annotation, input.sources); + const chosen = chosenTypeConstructor(symbol, call, input.dataTypes); + const written = + chosen.ok && writtenChosenType(symbol, chosen.value.type, chosen.value.args, input.dataTypes); + if (written === false || written === undefined) return undefined; + const callText = call === undefined ? symbol.path.join('.') : printTypeConstructorCall(call); + return `${inlineCode(callText)} is exactly ${inlineCode(written)}.`; +} + +/** Each combination of a type choice's options, as written, and the explicit type it becomes. */ +function renderTypeChoices( + symbol: ContributedTypeChoiceSymbol, dataTypes: DataTypeSupport, ): string { - const fence = ['```prisma', renderContributedTypeLabel(symbol, dataTypes), '```'].join('\n'); - return withDocumentation(fence, symbol.descriptor.documentation); + const { descriptor } = symbol; + const options = descriptor.args.filter(isAuthoringTypeChoiceOption); + const cell = (value: string | undefined, option: AuthoringTypeChoiceOption) => + value === option.default ? `${value} (default)` : (value ?? ''); + const becomes = (row: AuthoringTypeChoiceRow) => { + const defaults = row.defaults ?? {}; + const args = (symbol.choiceTypes.get(row.type)?.args ?? []).map((argument) => + argument.name !== undefined && Object.hasOwn(defaults, argument.name) + ? defaults[argument.name] + : undefined, + ); + const written = writtenChosenType(symbol, row.type, args, dataTypes); + return inlineCode(written ?? `${[...symbol.path.slice(0, -1), row.type].join('.')}(...)`); + }; + return [ + `| ${[...options.map((option) => option.name), 'Becomes'].join(' | ')} |`, + `| ${[...options, undefined].map(() => '---').join(' | ')} |`, + ...descriptor.choices.map( + (row) => + `| ${[...options.map((option) => cell(row.when[option.name], option)), becomes(row)].join(' | ')} |`, + ), + ].join('\n'); +} + +/** `text` as a Markdown code span, fenced by more backticks than any run inside it. */ +function inlineCode(text: string): string { + const longestRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length)); + const fence = '`'.repeat(longestRun + 1); + const padding = text.startsWith('`') || text.endsWith('`') || longestRun > 0 ? ' ' : ''; + return `${fence}${padding}${text}${padding}${fence}`; } function renderParameterContent(symbol: ParameterSymbol): string { @@ -177,11 +270,20 @@ function renderContributedTypeLabel( if (args.length === 0) return path; const labels = args.map((arg, index) => { const key = arg.name ?? `arg${index + 1}`; - return `${key}${arg.optional === true ? '?' : ''}: ${describeAdmittedForms(dataTypes, dataTypeId(arg.dataType))}`; + return `${key}${arg.optional === true ? '?' : ''}: ${argumentTypeLabel(arg, dataTypes)}`; }); return `${path}(${labels.join(', ')})`; } +function argumentTypeLabel( + arg: AuthoringArgumentDescriptor | AuthoringTypeChoiceOption, + dataTypes: DataTypeSupport, +): string { + return arg.kind === 'value' + ? describeAdmittedForms(dataTypes, dataTypeId(arg.dataType)) + : arg.values.join(' | '); +} + function blockKeywordDocumentationAt( token: SyntaxToken, pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace, diff --git a/packages/1-framework/3-tooling/language-server/src/project.ts b/packages/1-framework/3-tooling/language-server/src/project.ts index 69ed53a96606..60c01e7319d2 100644 --- a/packages/1-framework/3-tooling/language-server/src/project.ts +++ b/packages/1-framework/3-tooling/language-server/src/project.ts @@ -199,6 +199,7 @@ export class Project { binder: data.artifacts.binder(), pslBlockDescriptors: data.controlStack.pslBlockDescriptors, dataTypes: data.controlStack.dataTypes ?? EMPTY_DATA_TYPES, + sources: data.artifacts.sources, }); } catch { return null; diff --git a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts index 39a619c92c74..c87c1c60b553 100644 --- a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts @@ -191,7 +191,7 @@ describe('assembled attribute specs are consumable from a resolved project', () model, field, typeResolution: undefined, - typeConstructor: undefined, + contributedType: undefined, defaultFunctionRegistry: interpretation.context.controlMutationDefaults.defaultFunctionRegistry, generatorFunctions: interpretation.context.generatorFunctions, diff --git a/packages/1-framework/3-tooling/language-server/test/completion-provider.attribute-arguments.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-provider.attribute-arguments.test.ts new file mode 100644 index 000000000000..51f9258591a2 --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/completion-provider.attribute-arguments.test.ts @@ -0,0 +1,248 @@ +import type { DataTypeAuthoringEntry } from '@internal/framework-components/authoring'; +import { EMPTY_DATA_TYPES, type FuncCallSig } from '@internal/psl-parser'; +import { describe, expect, it } from 'vitest'; +import { InsertTextFormat } from 'vscode-languageserver'; +import { renderSignatureLabel, resolveSignatureParameters } from '../src/signature-help'; +import { + type ActualPostgresDataTypesModule, + type ActualPostgresDefaultsModule, + type ActualSqlExpressionModule, + type ActualSqliteDataTypesModule, + actualMongoStack, + actualSqlStack, + completeWithActualStack, + completionItemByLabel, + controlMutationDefaults, + emptySnippetPlaceholder1, + importFromPackageRoot, +} from './helpers/completion-provider'; + +describe('providePslCompletionItems', () => { + it('uses actual SQL enum metadata and rejects an empty enum without invented values', async () => { + const stack = await actualSqlStack(); + const names = (schema: string) => + completeWithActualStack(schema, stack).items.map((item) => item.label); + expect(names('enum Mood { Happy Sad }\nmodel Post { mood Mood @default(|) }')).toEqual([ + 'Happy', + 'Sad', + ]); + expect(names('enum Mood {}\nmodel Post { mood Mood @default(|) }')).toEqual([]); + expect( + names( + 'enum Mood { Top }\nnamespace scoped { enum Mood { Scoped }\nmodel Post { mood scoped.Mood @default(|) } }', + ), + ).toEqual(['Scoped']); + const degraded = names('enum Mood { Happy Happy }\nmodel Post { mood Mood @default(|) }'); + expect(degraded).toEqual(['Happy']); + }, 5_000); + + it('uses actual adapter default-function signatures through the SQL factory', async () => { + const stack = await actualSqlStack(); + const defaults = await importFromPackageRoot( + '../../../3-targets/postgres/2-adapter/src/core/migrations/control-mutation-defaults.ts', + ); + const options = { + controlMutationDefaults: { + ...controlMutationDefaults, + defaultFunctionRegistry: defaults.createPostgresDefaultFunctionRegistry(), + }, + }; + const source = (args: string) => `model Post { value String @default(${args}) }`; + const candidates = completeWithActualStack(source('|'), stack, options).items; + expect(candidates.map((item) => item.label)).toEqual([ + 'true', + 'false', + 'null', + 'autoincrement', + 'now', + ]); + const snippetItems = completeWithActualStack(source('|'), stack, { + ...options, + clientSupportsSnippets: true, + }).items; + expect(completionItemByLabel(snippetItems, 'now').textEdit?.newText).toBe('now()'); + }, 5_000); + + it('labels each generator function argument by the values the function takes', async () => { + const postgres = await importFromPackageRoot<{ + readonly postgresGeneratorFunctions: readonly { + readonly name: string; + readonly signature?: unknown; + }[]; + }>('../../../3-targets/postgres/1-target/src/core/generator-functions.ts'); + const labelOf = (name: string) => { + const declared = postgres.postgresGeneratorFunctions.find( + (contribution) => contribution.name === name && contribution.signature !== undefined, + )!.signature as (dataTypes: unknown) => FuncCallSig; + const signature = declared(EMPTY_DATA_TYPES); + return renderSignatureLabel(name, signature, resolveSignatureParameters(signature, undefined)) + .label; + }; + expect(['uuid', 'cuid', 'nanoid'].map(labelOf)).toEqual([ + 'uuid((4 | 7)?)', + 'cuid(2)', + 'nanoid(integer from 2 to 255?)', + ]); + }); + + it('offers each registered tag inside @default( with its own documentation', async () => { + const stack = await actualSqlStack(); + const [postgres, sqlite, family] = await Promise.all([ + importFromPackageRoot( + '../../../3-targets/postgres/1-target/src/exports/data-types.ts', + ), + importFromPackageRoot( + '../../../3-targets/sqlite/1-target/src/exports/data-types.ts', + ), + importFromPackageRoot( + '../../../2-sql-family/1-core/contract/src/exports/sql-expression.ts', + ), + ]); + const withFamilyEntry = ( + targetEntries: Readonly>, + ): Readonly> => ({ + ...family.sqlExpressionRegistration.authoring.dataTypes, + ...targetEntries, + }); + const complete = ( + dataTypes: Readonly>, + clientSupportsSnippets: boolean, + ) => + completeWithActualStack('model Post { value String @default(|) }', stack, { + clientSupportsSnippets, + controlMutationDefaults, + dataTypes, + }).items.map((item) => ({ + label: item.label, + detail: item.detail, + newText: item.textEdit?.newText, + insertTextFormat: item.insertTextFormat, + })); + const postgresEntries = withFamilyEntry(postgres.postgresDataTypeEntries()); + // This stack has no type constructors, so no column has a known type and every tag is offered. + const postgresTags = [ + 'sql', + 'json', + 'date', + 'time', + 'timetz', + 'timestamp', + 'timestamptz', + 'interval', + 'bytea', + ]; + const documentationOf = ( + entries: Readonly>, + tag: string, + ) => + Object.values(entries).find( + (entry) => entry.written.kind === 'tag' && entry.written.tag === tag, + )?.documentation; + const value = (label: string) => ({ + label, + detail: 'PSL argument value', + newText: label, + insertTextFormat: undefined, + }); + const tag = ( + entries: Readonly>, + label: string, + snippet: boolean, + ) => ({ + label, + detail: documentationOf(entries, label), + newText: snippet ? `${label}\`$1\`` : label, + insertTextFormat: snippet ? InsertTextFormat.Snippet : undefined, + }); + + expect(complete(postgresEntries, true)).toEqual([ + value('true'), + value('false'), + value('null'), + ...postgresTags.map((name) => tag(postgresEntries, name, true)), + ]); + const sqliteEntries = withFamilyEntry(sqlite.sqliteDataTypeEntries()); + expect(complete(sqliteEntries, true)).toEqual([ + value('true'), + value('false'), + value('null'), + tag(sqliteEntries, 'sql', true), + tag(sqliteEntries, 'json', true), + tag(sqliteEntries, 'blob', true), + tag(sqliteEntries, 'datetime', true), + ]); + expect(complete(postgresEntries, false)).toEqual([ + value('true'), + value('false'), + value('null'), + ...postgresTags.map((name) => tag(postgresEntries, name, false)), + ]); + + // Each tag carries the text of the tag it names, not every registered tag's text. + expect(documentationOf(postgresEntries, 'json')).not.toBe( + documentationOf(postgresEntries, 'sql'), + ); + }, 5_000); + + it('uses distinct local and referenced fields through actual SQL relation specs', async () => { + const stack = await actualSqlStack(); + const schema = (args: string) => + `model Target { topOnly Int }\nnamespace remote { model Target { remoteOnly Int } }\nmodel Owner { ownOnly Int\n relation remote.Target @relation(${args}) }`; + expect( + completeWithActualStack(schema('fields: [|]'), stack).items.map((item) => item.label), + ).toEqual(['ownOnly']); + expect( + completeWithActualStack(schema('references: [|]'), stack).items.map((item) => item.label), + ).toEqual(['remoteOnly']); + expect( + completeWithActualStack(schema('onDelete: |'), stack).items.map((item) => item.label), + ).toEqual(['NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault']); + }, 5_000); + + it('uses actual Mongo sort functions and completes their positional arguments', async () => { + const stack = await actualMongoStack(); + const schema = (args: string) => `model Post { title String\n slug String\n @@index(${args}) }`; + const plain = completeWithActualStack(schema('[|]'), stack).items; + expect(plain.map((item) => item.label)).toEqual(['title', 'slug', 'wildcard', 'sort']); + const snippets = completeWithActualStack(schema('[|]'), stack, { + clientSupportsSnippets: true, + }).items; + expect(snippets.map((item) => [item.label, item.textEdit?.newText])).toEqual([ + ['title', 'title'], + ['slug', 'slug'], + ['wildcard', `wildcard(${emptySnippetPlaceholder1})`], + ['sort', 'sort($' + '{1:field}, $' + '{2:direction})'], + ]); + expect( + completeWithActualStack(schema('[sort(|)]'), stack).items.map((item) => item.label), + ).toEqual(['title', 'slug']); + expect( + completeWithActualStack(schema('[sort(title, |)]'), stack).items.map((item) => item.label), + ).toEqual(['Asc', 'Desc']); + expect(completeWithActualStack(schema('[wildcard(|)]'), stack).items).toEqual([]); + expect( + completeWithActualStack(schema('[title], type: |'), stack).items.map((item) => item.label), + ).toEqual(['1', '-1', '"text"', '"2dsphere"', '"2d"', '"hashed"']); + }, 5_000); + + it('scopes actual Mongo sort fields to the declaring namespace model', async () => { + const stack = await actualMongoStack(); + const schema = + 'model Post { topOnly String }\nnamespace scoped { model Post { scopedOnly String\n @@index([|]) } }'; + expect( + completeWithActualStack(schema, stack, { clientSupportsSnippets: true }).items.map((item) => [ + item.label, + item.textEdit?.newText, + ]), + ).toEqual([ + ['scopedOnly', 'scopedOnly'], + ['wildcard', `wildcard(${emptySnippetPlaceholder1})`], + ['sort', 'sort($' + '{1:field}, $' + '{2:direction})'], + ]); + expect( + completeWithActualStack(schema.replace('[|]', '[sort(|)]'), stack).items.map( + (item) => item.label, + ), + ).toEqual(['scopedOnly']); + }, 5_000); +}); diff --git a/packages/1-framework/3-tooling/language-server/test/completion-provider.attributes.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-provider.attributes.test.ts new file mode 100644 index 000000000000..2feb807a506f --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/completion-provider.attributes.test.ts @@ -0,0 +1,479 @@ +import { EMPTY_GENERATOR_FUNCTIONS } from '@internal/framework-components/components'; +import { assembleAuthoringContributions } from '@internal/framework-components/control'; +import { + buildSymbolTable, + type FieldAttributeSpecContext, + fieldAttribute, +} from '@internal/psl-parser'; +import { parse } from '@internal/psl-parser/syntax'; +import { describe, expect, it } from 'vitest'; +import { CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; +import { classifyPslCompletionContext } from '../src/completion-context'; +import { providePslCompletionItems } from '../src/completion-provider'; +import { testBinder } from './helpers/binder'; +import { + actualMongoStack, + actualSqlStack, + applyCompletionItem, + attributeContributions, + candidateSource, + complete, + completeWithActualStack, + completionItemByLabel, + controlMutationDefaults, + pslBlockDescriptors, + scalarTypes, + sqlExpressionDataTypes, + sqlExpressionSnippet, +} from './helpers/completion-provider'; + +describe('providePslCompletionItems', () => { + it('returns registry-backed attribute name completions as function items', () => { + const fieldItems = complete(['model Post {', ' id Int @|', '}'].join('\n')).items; + expect(fieldItems.map((item) => item.label)).toEqual(['marker', 'orderFixture', 'ownerAware']); + expect(fieldItems.find((item) => item.label === 'marker')?.detail).toBe( + 'Attaches a named marker to a target.', + ); + expect(fieldItems.map((item) => item.kind)).toEqual([ + CompletionItemKind.Function, + CompletionItemKind.Function, + CompletionItemKind.Function, + ]); + + const modelItems = complete(['model Post {', ' id Int', ' @@|', '}'].join('\n')).items; + expect(modelItems.map((item) => item.label)).toEqual(['rls']); + expect(modelItems.map((item) => item.kind)).toEqual([CompletionItemKind.Function]); + + const blockItems = complete(['policy Rule {', ' @@|', '}'].join('\n')).items; + expect(blockItems.map((item) => item.label)).toEqual(['audit']); + expect(blockItems.map((item) => item.kind)).toEqual([CompletionItemKind.Function]); + }); + + it('resolves the attribute owner without enumerating declarations', () => { + const markedSource = ['model User {', ' id Int @|', '}'].join('\n'); + const cursorOffset = markedSource.indexOf('|'); + const source = `${markedSource.slice(0, cursorOffset)}${markedSource.slice(cursorOffset + 1)}`; + const { document, sources } = parse(source, 'language-server-test.psl'); + const sourceFile = sources.sourceFileFor(document.syntax); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + defaultNamespaceId: 'public', + }); + const context = classifyPslCompletionContext({ + document, + sourceFile, + position: sourceFile.positionAt(cursorOffset), + }); + let modelEnumerationCount = 0; + const observedDefaultNamespace = { + ...symbolTable.defaultNamespace, + models: new Proxy(symbolTable.defaultNamespace.models, { + ownKeys(target) { + modelEnumerationCount += 1; + return Reflect.ownKeys(target); + }, + }), + }; + const observedSymbolTable = { + ...symbolTable, + defaultNamespace: observedDefaultNamespace, + namespaces: { [observedDefaultNamespace.id]: observedDefaultNamespace }, + }; + const factoryOwnerNames: string[] = []; + const observedAuthoringContributions = assembleAuthoringContributions([ + { + id: 'observed-family', + authoring: { + attributeSpecs: { + field: { + first: (ctx: FieldAttributeSpecContext) => { + factoryOwnerNames.push(ctx.model.name); + return fieldAttribute('first', { + documentation: 'Marks the first contributed field attribute.', + }); + }, + second: (ctx: FieldAttributeSpecContext) => { + factoryOwnerNames.push(ctx.model.name); + return fieldAttribute('second', { + documentation: 'Marks the second contributed field attribute.', + }); + }, + }, + model: {}, + }, + }, + }, + ]); + + const items = providePslCompletionItems({ + context, + sourceFile, + candidates: { + scalarTypes, + pslBlockDescriptors, + symbolTable: observedSymbolTable, + binder: testBinder({ + sources, + symbolTable, + scalarTypes, + authoringContributions: attributeContributions, + controlMutationDefaults, + pslBlockDescriptors, + }), + authoringContributions: observedAuthoringContributions, + controlMutationDefaults, + generatorFunctions: EMPTY_GENERATOR_FUNCTIONS, + }, + clientSupportsSnippets: true, + }); + + expect(items.map((item) => item.label)).toEqual(['first', 'second']); + expect(factoryOwnerNames).toEqual(['User', 'User']); + expect(modelEnumerationCount).toBe(0); + }); + + it('returns attribute named keys including optional keys while omitting supplied keys', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' id Int @marker(name: "id", pr|)', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual(['priority']); + expect(items[0]?.textEdit).toEqual({ + range: { + start: sourceFile.positionAt(cursorOffset - 'pr'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'priority: ', + }); + }); + + it('preserves named-key declaration order while filtering supplied keys', () => { + const { items } = complete( + ['model Post {', ' id Int @orderFixture(alpha: 1, |)', '}'].join('\n'), + ); + + expect(items.map((item) => [item.label, item.kind])).toEqual([ + ['zebra', CompletionItemKind.Property], + ['middle', CompletionItemKind.Property], + ]); + }); + + it('uses the current declaration owner when model names collide across namespaces', () => { + const { items } = complete( + [ + 'namespace feature {', + ' model User {', + ' scopedOnly String @ownerAware(|)', + ' }', + '}', + ].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual(['scopedKey']); + }); + + it('inserts required contributed attribute arguments as snippets for snippet clients', () => { + const { items, sourceFile } = complete( + ['model Post {', ' id Int @mar| // keep', '}'].join('\n'), + { + clientSupportsSnippets: true, + }, + ); + const item = completionItemByLabel(items, 'marker'); + + expect(item).toMatchObject({ + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + newText: `marker("\${1:target}", name: "\${2:name}")`, + }, + }); + expect(applyCompletionItem({ sourceFile, item })).toEqual( + [ + candidateSource, + 'model Post {', + ` id Int @marker("\${1:target}", name: "\${2:name}") // keep`, + '}', + ].join('\n'), + ); + }); + + it.each([false, true])('gates argument snippet hints on client support: %s', (supported) => { + for (const [source, snippets, hints] of [ + ['model Post { id Int @mar| }', true, true], + ['model Post { id Int @mar| }', false, false], + ['model Post { id Int @mar|ker("x") }', true, false], + ['|', true, false], + ] as const) { + const { items } = complete(source, { + clientSupportsSnippets: snippets, + clientSupportsTriggerParameterHintsCommand: supported, + }); + const item = completionItemByLabel(items, source === '|' ? 'model' : 'marker'); + expect(item.command).toEqual( + supported && hints + ? { title: 'Show argument hints', command: 'editor.action.triggerParameterHints' } + : undefined, + ); + } + }); + + it('keeps plain contributed attribute completion free of snippet syntax', () => { + const { items, sourceFile } = complete( + ['model Post {', ' id Int @mar| // keep', '}'].join('\n'), + ); + const item = completionItemByLabel(items, 'marker'); + + expect(item.insertTextFormat).toBeUndefined(); + expect(item.textEdit).toMatchObject({ newText: 'marker' }); + expect(applyCompletionItem({ sourceFile, item })).toEqual( + [candidateSource, 'model Post {', ' id Int @marker // keep', '}'].join('\n'), + ); + }); + + it('preserves existing attribute delimiters and suffixes instead of inserting required arguments again', () => { + const { items, sourceFile } = complete( + ['model Post {', ' id Int @mar|ker(name: "id") @unique', '}'].join('\n'), + { clientSupportsSnippets: true }, + ); + const item = completionItemByLabel(items, 'marker'); + + expect(item.insertTextFormat).toBeUndefined(); + expect(item.textEdit).toMatchObject({ newText: 'marker' }); + expect(applyCompletionItem({ sourceFile, item })).toEqual( + [candidateSource, 'model Post {', ' id Int @marker(name: "id") @unique', '}'].join('\n'), + ); + }); + + it('uses the attribute AST to preserve an incomplete existing argument list', () => { + const { items, sourceFile } = complete(['model Post {', ' id Int @mar|ker(', '}'].join('\n'), { + clientSupportsSnippets: true, + }); + const item = completionItemByLabel(items, 'marker'); + + expect(item.insertTextFormat).toBeUndefined(); + expect(item.textEdit).toMatchObject({ newText: 'marker' }); + expect(applyCompletionItem({ sourceFile, item })).toEqual( + [candidateSource, 'model Post {', ' id Int @marker(', '}'].join('\n'), + ); + }); + + it('uses actual SQL attribute specs for names and top-level named keys', async () => { + const stack = await actualSqlStack(); + + expect( + completeWithActualStack(['model Post {', ' id Int @|', '}'].join('\n'), stack).items.map( + (item) => item.label, + ), + ).toEqual(['default', 'id', 'map', 'onCreate', 'onUpdate', 'relation', 'unique']); + expect( + completeWithActualStack( + ['model Post {', ' id Int', ' @@|', '}'].join('\n'), + stack, + ).items.map((item) => item.label), + ).toEqual(['base', 'check', 'control', 'discriminator', 'id', 'index', 'map', 'unique']); + expect( + completeWithActualStack(['enum Role {', ' Admin', ' @@|', '}'].join('\n'), stack).items.map( + (item) => item.label, + ), + ).toEqual(['type']); + + const { items, sourceFile, cursorOffset } = completeWithActualStack( + ['model Post {', ' id Int', ' @@index(expression: sql`lower(name)`, ma|)', '}'].join('\n'), + stack, + ); + expect(items.map((item) => item.label)).toEqual([ + 'where', + 'unique', + 'name', + 'map', + 'type', + 'options', + ]); + expect(items.find((item) => item.label === 'map')?.textEdit).toEqual({ + range: { + start: sourceFile.positionAt(cursorOffset - 'ma'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'map: ', + }); + + expect( + completeWithActualStack( + ['model Post {', ' id Int @default(|)', '}'].join('\n'), + stack, + ).items.map((item) => item.label), + ).toEqual(['true', 'false', 'null']); + + const mapCompletion = completeWithActualStack( + ['model Post {', ' id Int @ma| // keep', '}'].join('\n'), + stack, + { clientSupportsSnippets: true }, + ); + const mapItem = completionItemByLabel(mapCompletion.items, 'map'); + expect(mapItem).toMatchObject({ + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { newText: `map("\${1:name}")` }, + }); + expect(applyCompletionItem({ sourceFile: mapCompletion.sourceFile, item: mapItem })).toEqual( + ['model Post {', ` id Int @map("\${1:name}") // keep`, '}'].join('\n'), + ); + + const checkCompletion = completeWithActualStack( + ['model Post {', ' id Int', ' @@che| // keep', '}'].join('\n'), + stack, + { clientSupportsSnippets: true, dataTypes: await sqlExpressionDataTypes() }, + ); + const checkItem = completionItemByLabel(checkCompletion.items, 'check'); + expect(checkItem).toMatchObject({ + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { newText: `check(expression: ${sqlExpressionSnippet})` }, + }); + expect( + applyCompletionItem({ sourceFile: checkCompletion.sourceFile, item: checkItem }), + ).toEqual( + [ + 'model Post {', + ' id Int', + ` @@check(expression: ${sqlExpressionSnippet}) // keep`, + '}', + ].join('\n'), + ); + }, 5_000); + + it('offers a sql literal where @@index and @@check take SQL', async () => { + const stack = await actualSqlStack(); + const dataTypes = await sqlExpressionDataTypes(); + const [entry] = Object.values(dataTypes); + const valuesAt = (attribute: string) => + completeWithActualStack( + ['model Post {', ' id Int', ` ${attribute}`, '}'].join('\n'), + stack, + { + clientSupportsSnippets: true, + dataTypes, + }, + ).items.map((item) => ({ + label: item.label, + detail: item.detail, + newText: item.textEdit?.newText, + insertTextFormat: item.insertTextFormat, + })); + const sqlItem = { + label: 'sql', + detail: entry?.documentation, + newText: 'sql`$1`', + insertTextFormat: InsertTextFormat.Snippet, + }; + + expect(valuesAt('@@index([id], where: |)')).toEqual([sqlItem]); + expect(valuesAt('@@index(expression: |, map: "post_idx")')).toEqual([sqlItem]); + expect(valuesAt('@@check(expression: |, name: "post_check")')).toEqual([sqlItem]); + }, 5_000); + + it('completes model and field attributes when the source has no data types', async () => { + const stack = await actualSqlStack(); + const labelsAt = (markedSource: string) => + completeWithActualStack(markedSource, stack).items.map((item) => item.label); + + expect(labelsAt(['model Post {', ' id Int', ' @@|', '}'].join('\n'))).toEqual([ + 'base', + 'check', + 'control', + 'discriminator', + 'id', + 'index', + 'map', + 'unique', + ]); + expect(labelsAt(['model Post {', ' id Int @|', '}'].join('\n'))).toEqual([ + 'default', + 'id', + 'map', + 'onCreate', + 'onUpdate', + 'relation', + 'unique', + ]); + expect( + labelsAt(['model Post {', ' id Int', ' @@check(expression: |)', '}'].join('\n')), + ).toEqual([]); + }, 5_000); + + it('uses actual Mongo attribute specs for names and dynamic factory keys', async () => { + const stack = await actualMongoStack(); + + expect( + completeWithActualStack(['model Post {', ' id String @|', '}'].join('\n'), stack).items.map( + (item) => item.label, + ), + ).toEqual(['id', 'map', 'onCreate', 'onUpdate', 'relation', 'unique']); + expect( + completeWithActualStack( + ['model Post {', ' id String', ' @@|', '}'].join('\n'), + stack, + ).items.map((item) => item.label), + ).toEqual(['base', 'discriminator', 'index', 'map', 'textIndex', 'unique']); + expect( + completeWithActualStack(['enum Role {', ' Admin', ' @@|', '}'].join('\n'), stack).items.map( + (item) => item.label, + ), + ).toEqual(['type']); + + expect( + completeWithActualStack( + [ + 'namespace scoped {', + ' model User {', + ' id String', + ' localOnly String', + ' @@index(fields: [id], la|)', + ' }', + '}', + 'model User {', + ' id String', + '}', + ].join('\n'), + stack, + ).items.map((item) => item.label), + ).toEqual([ + 'type', + 'sparse', + 'expireAfterSeconds', + 'filter', + 'include', + 'exclude', + 'default_language', + 'languageOverride', + 'collationLocale', + 'collationStrength', + 'collationCaseLevel', + 'collationCaseFirst', + 'collationNumericOrdering', + 'collationAlternate', + 'collationMaxVariable', + 'collationBackwards', + 'collationNormalization', + ]); + expect( + completeWithActualStack( + ['model Post {', ' author User @relation(name: "Author", f|)', '}'].join('\n'), + stack, + ).items.map((item) => item.label), + ).toEqual(['fields', 'references']); + + const mapCompletion = completeWithActualStack( + ['model Post {', ' id String @ma| // keep', '}'].join('\n'), + stack, + { clientSupportsSnippets: true }, + ); + const mapItem = completionItemByLabel(mapCompletion.items, 'map'); + expect(mapItem).toMatchObject({ + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { newText: `map("\${1:name}")` }, + }); + expect(applyCompletionItem({ sourceFile: mapCompletion.sourceFile, item: mapItem })).toEqual( + ['model Post {', ` id String @map("\${1:name}") // keep`, '}'].join('\n'), + ); + }, 5_000); +}); diff --git a/packages/1-framework/3-tooling/language-server/test/completion-provider.block-values.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-provider.block-values.test.ts new file mode 100644 index 000000000000..e7d17291d867 --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/completion-provider.block-values.test.ts @@ -0,0 +1,167 @@ +import type { DataTypeSupport } from '@internal/framework-components/authoring'; +import { createDataTypeLookup } from '@internal/framework-components/codec'; +import { assembleAuthoringContributions } from '@internal/framework-components/control'; +import { + type BlockSpecContext, + fieldAttribute, + jsonValue, + structBlock, +} from '@internal/psl-parser'; +import { describe, expect, it } from 'vitest'; +import { + complete, + completeWithSource, + completionItemByLabel, + controlMutationDefaults, +} from './helpers/completion-provider'; + +describe('providePslCompletionItems', () => { + it('completes a generic block value from the block spec', () => { + const { items } = complete(['policy Rule {', ' on = |', '}'].join('\n')); + + expect(items.map((item) => item.label)).toEqual(['User', 'public', 'auth']); + }); + + it('returns descriptor-backed generic block parameter completions', () => { + const { items, sourceFile, cursorOffset } = complete(['policy Rule {', ' |', '}'].join('\n')); + + expect(items.map((item) => item.label)).toEqual(['on', 'where', 'mode', 'using']); + expect(items.map((item) => item.detail)).toEqual([ + 'Generic block parameter', + 'The policy predicate.', + 'Generic block parameter', + 'Generic block parameter', + ]); + expect(items[0]?.textEdit).toEqual({ + range: { + start: sourceFile.positionAt(cursorOffset), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'on = ', + }); + }); + + it('returns the full descriptor-backed generic block parameter set excluding already-present sibling keys', () => { + const { items, sourceFile, cursorOffset } = complete( + ['policy Rule {', ' on = User', ' wh|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual(['where', 'mode', 'using']); + expect(items.find((item) => item.label === 'where')).toMatchObject({ + filterText: 'where', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'wh'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'where = ', + }, + }); + }); + + it('uses generic block parameter documentation with a fallback for undocumented parameters', () => { + const { items } = complete('policy Rule { | }'); + expect(completionItemByLabel(items, 'where').detail).toBe('The policy predicate.'); + expect(completionItemByLabel(items, 'on').detail).toBe('Generic block parameter'); + }); + + it('still offers the in-progress key while excluding an already-present sibling key', () => { + const { items } = complete(['policy Rule {', ' where = "x"', ' on|', '}'].join('\n')); + + expect(items.map((item) => item.label)).toEqual(['on', 'mode', 'using']); + }); + + it('returns no generic block parameter completions without a matching descriptor', () => { + const { items } = complete(['extension Rule {', ' |', '}'].join('\n')); + + expect(items).toEqual([]); + }); + + it('offers no key candidates for an arbitrary-key block spec', () => { + const { items } = complete(['inventory Stock {', ' |', '}'].join('\n')); + + expect(items).toEqual([]); + }); + + it('keeps key completion available inside an invalid block', () => { + const { items } = complete( + ['policy Rule {', ' bogus = "not a declared key"', ' |', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual(['on', 'where', 'mode', 'using']); + }); + + it('calls the spec factory with the spec context and never invokes rule parsing', () => { + const dataTypes: DataTypeSupport = { + entries: {}, + lookup: createDataTypeLookup([]), + dataTypeOfCodec: () => undefined, + }; + const factoryContexts: BlockSpecContext[] = []; + const throwingRule = { + kind: 'str' as const, + label: 'string', + value: undefined, + parse: () => { + throw new Error('metadata inspection must not invoke rule parsing'); + }, + }; + const { items } = completeWithSource({ + markedSource: ['guard Rule {', ' |', '}'].join('\n'), + pslBlockDescriptors: { + guard: { + kind: 'pslBlock', + keyword: 'guard', + discriminator: 'fixture-guard', + name: { required: true }, + spec: (ctx: BlockSpecContext) => { + factoryContexts.push(ctx); + return structBlock({ + parameters: { + shield: { type: throwingRule, documentation: 'The shield key.' }, + }, + }); + }, + }, + }, + dataTypes, + }); + + expect(items.map((item) => item.label)).toEqual(['shield']); + expect(items[0]?.detail).toBe('The shield key.'); + expect(factoryContexts).toHaveLength(2); + for (const ctx of factoryContexts) { + expect(ctx).toEqual({ symbols: expect.any(Object), dataTypes: expect.any(Object) }); + expect(ctx.dataTypes).toBe(dataTypes); + } + }); + + it.each(['|', '[|]', '{ nested: | }', '[{ nested: [|] }]'])( + 'completes recursive JSON values at %s', + (value) => { + const { items } = completeWithSource({ + markedSource: `model Post { value String @jsonFixture(${value}) }`, + pslBlockDescriptors: {}, + authoringContributions: assembleAuthoringContributions([ + { + id: 'json-fixture', + authoring: { + attributeSpecs: { + field: { + jsonFixture: () => + fieldAttribute('jsonFixture', { + documentation: '', + positional: [{ key: 'value', type: jsonValue(), documentation: '' }], + }), + }, + model: {}, + }, + }, + }, + ]), + controlMutationDefaults, + }); + expect(items.map((item) => item.label)).toEqual(['true', 'false', 'null']); + }, + ); +}); diff --git a/packages/1-framework/3-tooling/language-server/test/completion-provider.declaration-keywords.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-provider.declaration-keywords.test.ts new file mode 100644 index 000000000000..86e0216ed2e1 --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/completion-provider.declaration-keywords.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from 'vitest'; +import { type CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; +import { + auditAttribute, + complete, + completeWithSource, + completionItemByLabel, + nameSnippetPlaceholder, + policySpec, +} from './helpers/completion-provider'; + +describe('providePslCompletionItems', () => { + it('returns document-level declaration keyword candidates with stable plain-text edits', () => { + const { items, sourceFile, cursorOffset } = complete('|'); + + expect(items.map((item) => item.label)).toEqual([ + 'model', + 'model mixin', + 'type', + 'type mixin', + 'types', + 'namespace', + 'audit', + 'audit mixin', + 'inventory', + 'inventory mixin', + 'policy', + 'policy mixin', + ]); + expect(items.map((item) => item.detail)).toEqual([ + 'Defines a data model.', + 'Declares a mixin for model blocks.', + 'Defines a reusable composite type.', + 'Declares a mixin for type blocks.', + 'Defines reusable type aliases.', + 'Groups declarations belonging to the same database schema or database.', + 'Generic block keyword', + 'Declares a mixin for audit blocks.', + 'An arbitrary-key block.', + 'Declares a mixin for inventory blocks.', + 'Defines a security policy.', + 'Declares a mixin for policy blocks.', + ]); + expect(items[0]).toMatchObject({ + kind: CompletionItemKind.Keyword, + filterText: 'model', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'model ', + }, + }); + expect(items[0]?.insertTextFormat).toBeUndefined(); + }); + + it('returns the full document-level declaration keyword set with a replace range over the typed segment', () => { + const { items, sourceFile, cursorOffset } = complete('mo|'); + + expect(items.map((item) => item.label)).toEqual([ + 'model', + 'model mixin', + 'type', + 'type mixin', + 'types', + 'namespace', + 'audit', + 'audit mixin', + 'inventory', + 'inventory mixin', + 'policy', + 'policy mixin', + ]); + expect(items[0]).toMatchObject({ + filterText: 'model', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'mo'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'model ', + }, + }); + }); + + it('returns namespace-body declaration keywords without document-only native keywords', () => { + const { items } = complete(['namespace feature {', ' |', '}'].join('\n')); + + expect(items.map((item) => item.label)).toEqual([ + 'model', + 'model mixin', + 'type', + 'type mixin', + 'audit', + 'audit mixin', + 'inventory', + 'inventory mixin', + 'policy', + 'policy mixin', + ]); + expect(items.map((item) => item.label)).not.toContain('types'); + expect(items.map((item) => item.label)).not.toContain('namespace'); + }); + + it('returns the full namespace-body declaration keyword set with a replace range over the typed segment', () => { + const { items, sourceFile, cursorOffset } = complete( + ['namespace feature {', ' po|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual([ + 'model', + 'model mixin', + 'type', + 'type mixin', + 'audit', + 'audit mixin', + 'inventory', + 'inventory mixin', + 'policy', + 'policy mixin', + ]); + expect(items.find((item) => item.label === 'policy')).toMatchObject({ + filterText: 'policy', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'po'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'policy ', + }, + }); + }); + + it('returns snippet declaration keyword edits only when the client supports snippets', () => { + const { items } = complete('|', { clientSupportsSnippets: true }); + + expect(items.find((item) => item.label === 'model')).toMatchObject({ + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { newText: `model ${nameSnippetPlaceholder} {\n \${0:// Fields}\n}` }, + }); + expect(items.find((item) => item.label === 'policy')).toMatchObject({ + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + newText: `policy ${nameSnippetPlaceholder} {\n \${0:// Block keys and attributes}\n}`, + }, + }); + }); + + it.each([true, false])( + 'inserts a body placeholder without pre-filled keys, snippets=%s', + (snippets) => { + const { items } = completeWithSource({ + markedSource: '|', + clientSupportsSnippets: snippets, + pslBlockDescriptors: { + security: { + policy: { + kind: 'pslBlock', + keyword: 'policy', + discriminator: 'policy', + name: { required: true }, + spec: policySpec, + attributes: { audit: () => auditAttribute }, + }, + }, + }, + }); + const item = completionItemByLabel(items, 'policy'); + expect(item.textEdit?.newText).toBe( + snippets + ? [ + `policy ${nameSnippetPlaceholder} {`, + ' $' + '{0:// Block keys and attributes}', + '}', + ].join('\n') + : 'policy ', + ); + expect(item.insertTextFormat).toBe(snippets ? InsertTextFormat.Snippet : undefined); + }, + ); + + describe('mixin declaration items', () => { + const sortedLabels = (items: readonly CompletionItem[]) => + [...items] + .sort((left, right) => ((left.sortText ?? '') < (right.sortText ?? '') ? -1 : 1)) + .map((item) => item.label); + + it('offers model before model mixin when a prefix of the keyword is typed', () => { + const { items, sourceFile, cursorOffset } = complete('mo|'); + const range = { + start: sourceFile.positionAt(cursorOffset - 'mo'.length), + end: sourceFile.positionAt(cursorOffset), + }; + + expect(sortedLabels(items).slice(0, 2)).toEqual(['model', 'model mixin']); + expect(completionItemByLabel(items, 'model mixin')).toMatchObject({ + kind: CompletionItemKind.Keyword, + detail: 'Declares a mixin for model blocks.', + filterText: 'model mixin', + sortText: '0:model mixin', + textEdit: { range, newText: 'model mixin ' }, + }); + }); + + it('sorts each mixin item directly after the item of its keyword', () => { + expect(sortedLabels(complete('|').items)).toEqual([ + 'model', + 'model mixin', + 'namespace', + 'type', + 'type mixin', + 'types', + 'audit', + 'audit mixin', + 'inventory', + 'inventory mixin', + 'policy', + 'policy mixin', + ]); + }); + + it('offers no mixin item for namespace or types, and never the word mixin alone', () => { + const labels = complete('|').items.map((item) => item.label); + + expect(labels).not.toContain('namespace mixin'); + expect(labels).not.toContain('types mixin'); + expect(labels).not.toContain('mixin'); + }); + + it('inserts a mixin declaration with an empty body for a native and for a struct block keyword', () => { + const { items } = completeWithSource({ + markedSource: '|', + clientSupportsSnippets: true, + pslBlockDescriptors: { + security: { + policy: { + kind: 'pslBlock', + keyword: 'policy', + discriminator: 'policy', + name: { required: true }, + spec: policySpec, + attributes: { audit: () => auditAttribute }, + }, + }, + }, + }); + + expect(completionItemByLabel(items, 'model mixin')).toMatchObject({ + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { newText: `model mixin ${nameSnippetPlaceholder} {\n \${0:// Fields}\n}` }, + }); + expect(completionItemByLabel(items, 'policy mixin')).toMatchObject({ + kind: CompletionItemKind.Keyword, + detail: 'Declares a mixin for policy blocks.', + filterText: 'policy mixin', + sortText: '1:policy mixin', + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + newText: `policy mixin ${nameSnippetPlaceholder} {\n \${0:// Block keys and attributes}\n}`, + }, + }); + }); + + it('offers the mixin item while the whole keyword is the word before the cursor, and nothing once more is typed', () => { + expect(complete('model|').items.map((item) => item.label)).toContain('model mixin'); + expect(complete('model m|').items).toEqual([]); + expect(complete('model mixin|').items).toEqual([]); + }); + + it.each([ + ['a model', ['model User {', ' |', '}']], + ['a block', ['policy P {', ' |', '}']], + ['a model mixin', ['model mixin M {', ' |', '}']], + ['a block mixin', ['policy mixin M {', ' |', '}']], + ])('offers none inside %s', (_where, lines) => { + const labels = complete(lines.join('\n')).items.map((item) => item.label); + + expect(labels.filter((label) => label.endsWith(' mixin'))).toEqual([]); + }); + }); +}); diff --git a/packages/1-framework/3-tooling/language-server/test/completion-provider.field-types.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-provider.field-types.test.ts new file mode 100644 index 000000000000..c03e3389ca8b --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/completion-provider.field-types.test.ts @@ -0,0 +1,305 @@ +import type { + AuthoringContributions, + AuthoringTypeNamespace, +} from '@internal/framework-components/authoring'; +import { assembleAuthoringContributions } from '@internal/framework-components/control'; +import { timeouts } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { CompletionItemKind, CompletionItemTag } from 'vscode-languageserver'; +import { + complete, + completeWithSource, + completionItemByLabel, + controlMutationDefaults, + importFromPackageRoot, +} from './helpers/completion-provider'; + +describe('providePslCompletionItems', () => { + it('returns bare model field type completion candidates without custom ranking', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' author |', '}'].join('\n'), + ); + + expect(items.map(({ label, detail }) => [label, detail]).sort()).toEqual([ + ['Address', 'Composite type'], + ['Boolean', 'Configured scalar type'], + ['DateTime', 'Configured scalar type'], + ['Email', 'Scalar type'], + ['Int', 'Configured scalar type'], + ['Post', 'Model'], + ['String', 'Configured scalar type'], + ['User', 'Model'], + ['UserId', 'Type alias'], + ['auth', 'Namespace'], + ['public', 'Namespace'], + ]); + for (const item of items) expect(item).not.toHaveProperty('sortText'); + expect(completionItemByLabel(items, 'Boolean').textEdit).toEqual({ + range: { + start: sourceFile.positionAt(cursorOffset), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'Boolean', + }); + }); + + it('returns the full bare candidate set with a replace range over the typed segment', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' reviewer U|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label).sort()).toEqual([ + 'Address', + 'Boolean', + 'DateTime', + 'Email', + 'Int', + 'Post', + 'String', + 'User', + 'UserId', + 'auth', + 'public', + ]); + expect(items.find((item) => item.label === 'User')).toMatchObject({ + filterText: 'User', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'U'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'User', + }, + }); + }); + + it('returns the full bare candidate set including the namespace qualifier with a replace range over the typed segment', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' reviewer a|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label).sort()).toEqual([ + 'Address', + 'Boolean', + 'DateTime', + 'Email', + 'Int', + 'Post', + 'String', + 'User', + 'UserId', + 'auth', + 'public', + ]); + expect(items.find((item) => item.label === 'auth')).toMatchObject({ + kind: CompletionItemKind.Module, + detail: 'Namespace', + filterText: 'auth', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'a'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'auth.', + }, + }); + }); + + it('returns namespace members after a namespace qualifier', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' owner auth.|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label).sort()).toEqual(['Account', 'Profile', 'User']); + expect(completionItemByLabel(items, 'Account').textEdit).toEqual({ + range: { + start: sourceFile.positionAt(cursorOffset), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'Account', + }); + }); + + it('returns the full namespace member set with replacement metadata for the typed segment', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' owner auth.U|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label).sort()).toEqual(['Account', 'Profile', 'User']); + for (const item of items) expect(item).not.toHaveProperty('sortText'); + expect(items.find((item) => item.label === 'User')).toMatchObject({ + filterText: 'User', + detail: 'Model', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'U'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'User', + }, + }); + }); + + it('does not leak local namespace members into a foreign contract-space reference', () => { + const { items } = complete(['model Post {', ' owner supabase:auth.P|', '}'].join('\n')); + + expect(items).toEqual([]); + }); + + it('returns no completions for a contract-space-qualified position', () => { + const { items } = complete(['model Post {', ' external supabase:|', '}'].join('\n')); + + expect(items).toEqual([]); + }); + + it('uses registered scalar documentation for completion details', () => { + const { items } = completeWithSource({ + markedSource: 'model Post { value | }', + pslBlockDescriptors: {}, + authoringContributions: assembleAuthoringContributions([ + { + id: 'documented-scalars', + authoring: { + type: { + String: { + kind: 'typeConstructor', + documentation: 'Variable-length Unicode text.', + output: { codecId: 'fixture/text@1' }, + }, + }, + }, + }, + ]), + controlMutationDefaults, + }); + expect(completionItemByLabel(items, 'String').detail).toBe('Variable-length Unicode text.'); + expect(completionItemByLabel(items, 'Int').detail).toBe('Configured scalar type'); + }); + + it( + 'offers no field preset of the real Postgres pack in type position', + async () => { + const { postgresTargetDescriptorMeta } = await importFromPackageRoot<{ + readonly postgresTargetDescriptorMeta: { readonly authoring: AuthoringContributions }; + }>('../../../3-targets/postgres/1-target/src/core/descriptor-meta.ts'); + const authoringContributions = assembleAuthoringContributions([ + { id: 'postgres', authoring: postgresTargetDescriptorMeta.authoring }, + ]); + const labels = (markedSource: string) => + completeWithSource({ + markedSource, + pslBlockDescriptors: {}, + clientSupportsSnippets: false, + authoringContributions, + controlMutationDefaults, + }).items.map((item) => item.label); + + const topLevel = labels('model Post { value | }'); + const temporal = labels('model Post { value temporal.| }'); + expect({ + topLevel: ['text', 'json', 'uuidNative', 'id'].filter((name) => topLevel.includes(name)), + temporal: ['createdAt', 'updatedAt', 'timestamp'].filter((name) => temporal.includes(name)), + aliases: ['CreatedAt', 'UpdatedAt'].filter((name) => temporal.includes(name)), + }).toEqual({ topLevel: [], temporal: [], aliases: ['CreatedAt', 'UpdatedAt'] }); + }, + timeouts.typeScriptCompilation, + ); + + it( + 'lists deprecated Mongo scalar names tagged deprecated, naming the replacement', + async () => { + const { mongoScalarAuthoringTypes } = await importFromPackageRoot<{ + readonly mongoScalarAuthoringTypes: AuthoringTypeNamespace; + }>('../../../3-targets/mongo/2-adapter/src/exports/control.ts'); + const { items } = completeWithSource({ + markedSource: 'model Post { value | }', + pslBlockDescriptors: {}, + scalarTypes: Object.keys(mongoScalarAuthoringTypes), + authoringContributions: assembleAuthoringContributions([ + { id: 'mongo-scalars', authoring: { type: mongoScalarAuthoringTypes } }, + ]), + controlMutationDefaults, + }); + const scalars = items.filter((item) => item.kind === CompletionItemKind.Class); + const current = ['Int32', 'Double', 'Bool', 'Date']; + const deprecated = [ + ['Int', 'Int32'], + ['Float', 'Double'], + ['Boolean', 'Bool'], + ['DateTime', 'Date'], + ] as const; + + expect( + scalars + .filter((item) => item.tags?.includes(CompletionItemTag.Deprecated)) + .map((item) => item.label) + .sort(), + ).toEqual(deprecated.map(([name]) => name).sort()); + for (const name of current) { + expect(completionItemByLabel(items, name)).not.toHaveProperty('tags'); + } + for (const [name, replacement] of deprecated) { + expect(completionItemByLabel(items, name)).toMatchObject({ + tags: [CompletionItemTag.Deprecated], + detail: expect.stringContaining(`Deprecated: use ${replacement}`), + }); + } + }, + timeouts.coldTransformImport, + ); + + it('does not return generic block symbols as model field type candidates', () => { + const { items } = complete(['model Post {', ' audit |', '}'].join('\n')); + + expect(items.map((item) => item.label)).not.toContain('Audit'); + expect(items.map((item) => item.label)).not.toContain('auth.ScopedAudit'); + }); + + it('offers the same field type candidates in a document that holds a mixin and an inclusion', () => { + const model = ['model Post {', ' author |', '}'].join('\n'); + const mixins = [ + 'model mixin Timestamps {', + ' createdAt DateTime', + '}', + 'model Tagged {', + ' id Int', + ' +Timestamps', + '}', + ].join('\n'); + const plain = ['', '', '', 'model Tagged {', ' id Int', '', '}'].join('\n'); + + expect(complete(`${mixins}\n${model}`).items).toEqual(complete(`${plain}\n${model}`).items); + }); + + it.each([ + ['in a mixin field type slot', 'model mixin Timestamps {\n createdAt |\n}'], + ['in a mixin header', 'model mixin |'], + ['after the plus of an inclusion', 'model Post {\n id Int\n +|\n}'], + ['inside the name of an inclusion', 'model Post {\n +auth.Ti|\n}'], + ['in an enum mixin body', 'enum mixin BaseRoles {\n |\n}'], + ])('completes without an error %s', (_case, markedSource) => { + expect(() => complete(markedSource)).not.toThrow(); + }); + + it('does not offer a mixin as a field type, by its name or as a namespace member', () => { + const mixins = [ + 'model mixin Timestamps {', + ' createdAt DateTime', + '}', + 'namespace shared {', + ' model mixin Audited {', + ' actor String', + ' }', + ' model Marker {', + ' id Int', + ' }', + '}', + ].join('\n'); + const unqualified = complete(`${mixins}\nmodel Post {\n stamps |\n}`).items; + const qualified = complete(`${mixins}\nmodel Post {\n stamps shared.|\n}`).items; + + expect(unqualified.map((item) => item.label)).toContain('shared'); + expect(unqualified.map((item) => item.label)).not.toContain('Timestamps'); + expect(qualified.map((item) => item.label)).toEqual(['Marker']); + }); +}); diff --git a/packages/1-framework/3-tooling/language-server/test/completion-provider.inclusions.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-provider.inclusions.test.ts new file mode 100644 index 000000000000..f801c77e0b2a --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/completion-provider.inclusions.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; +import { CompletionItemKind } from 'vscode-languageserver'; +import { + completeWithSource, + completionItemByLabel, + pslBlockDescriptors, +} from './helpers/completion-provider'; + +describe('providePslCompletionItems — after the plus of an inclusion', () => { + const mixinsOf = (prefix: string, indent: string) => + [ + `model mixin ${prefix}Stamps {`, + '}', + `type mixin ${prefix}Geo {`, + '}', + `enum mixin ${prefix}Roles {`, + '}', + `policy mixin ${prefix}Policy {`, + '}', + ].map((line) => `${indent}${line}`); + + function after(block: readonly string[], placement: 'billing' | 'top' = 'billing') { + const namespace = placement === 'top' ? undefined : placement; + const blockLines = + namespace === undefined ? block : block.map((line) => (line === '' ? line : ` ${line}`)); + const markedSource = [ + ...mixinsOf('Top', ''), + ...(namespace === undefined ? blockLines : []), + 'namespace auth {', + ...mixinsOf('Auth', ' '), + '}', + 'namespace billing {', + ...mixinsOf('Billing', ' '), + ' model mixin Used {', + ' }', + ...(namespace === undefined ? [] : blockLines), + '}', + ].join('\n'); + return completeWithSource({ markedSource, pslBlockDescriptors }); + } + + const offered = (block: readonly string[], placement: 'billing' | 'top' = 'billing') => + after(block, placement) + .items.map(({ label, detail }) => [label, detail]) + .sort(); + + const namespaces = [ + ['auth', 'Namespace'], + ['billing', 'Namespace'], + ['public', 'Namespace'], + ]; + + it.each([ + ['a model', ['model Invoice {', ' id Int', ' +Used', ' +|', '}'], 'Stamps', 'model mixin'], + ['a composite type', ['type Address {', ' +|', ' street String', '}'], 'Geo', 'type mixin'], + ['an enum', ['enum Role {', ' +|', ' GUEST', '}'], 'Roles', 'enum mixin'], + ['a struct block', ['policy P {', ' +|', '}'], 'Policy', 'policy mixin'], + ])( + 'offers the mixins of the keyword that an unqualified name reaches, and each namespace, in %s', + (_block, lines, suffix, detail) => { + expect(offered(lines)).toEqual( + [[`Billing${suffix}`, detail], [`Top${suffix}`, detail], ...namespaces].sort(), + ); + }, + ); + + it('offers the same while a name is being typed after the plus', () => { + expect(offered(['model Invoice {', ' +Bil|', '}'])).toEqual( + offered(['model Invoice {', ' +|', '}']), + ); + }); + + it('offers only top-level mixins of the keyword and each namespace in a top-level block', () => { + expect(offered(['enum Role {', ' +|', '}'], 'top')).toEqual( + [['TopRoles', 'enum mixin'], ...namespaces].sort(), + ); + }); + + it.each([ + ['a model', ['model Invoice {', ' +auth.|', '}'], [['AuthStamps', 'model mixin']]], + ['a composite type', ['type Address {', ' +auth.|', '}'], [['AuthGeo', 'type mixin']]], + ['an enum', ['enum Role {', ' +auth.|', '}'], [['AuthRoles', 'enum mixin']]], + ['a struct block', ['policy P {', ' +auth.|', '}'], [['AuthPolicy', 'policy mixin']]], + ])( + 'offers the mixins of the keyword in the named namespace after a qualifier, in %s', + (_block, lines, expected) => { + expect(offered(lines)).toEqual(expected); + expect(offered(lines.map((line) => line.replace('auth.|', 'auth.Au|')))).toEqual(expected); + }, + ); + + it('offers a namespace only when it has a mixin that would be offered after its qualifier', () => { + const source = (block: readonly string[]) => + [ + 'namespace models {', + ' model mixin Stamps {', + ' }', + '}', + 'namespace enums {', + ' enum mixin Roles {', + ' }', + '}', + 'namespace empty {', + ' model Plain {', + ' id Int', + ' }', + '}', + ...block, + ].join('\n'); + const labels = (block: readonly string[]) => + completeWithSource({ markedSource: source(block), pslBlockDescriptors }) + .items.map(({ label, detail }) => [label, detail]) + .sort(); + + expect(labels(['model Invoice {', ' +|', '}'])).toEqual([['models', 'Namespace']]); + expect(labels(['enum Role {', ' +|', '}'])).toEqual([['enums', 'Namespace']]); + expect(labels(['model Invoice {', ' +models.Stamps', ' +|', '}'])).toEqual([]); + expect(labels(['policy P {', ' +|', '}'])).toEqual([]); + }); + + it('does not offer a top-level mixin whose name a model of the namespace hides', () => { + const { items } = completeWithSource({ + markedSource: [ + 'model mixin Timestamps {', + ' createdAt DateTime', + '}', + 'namespace app {', + ' model Timestamps {', + ' id Int', + ' }', + ' model User {', + ' id Int', + ' +|', + ' }', + '}', + ].join('\n'), + pslBlockDescriptors, + }); + + expect(items.map(({ label, detail }) => [label, detail])).toEqual([['public', 'Namespace']]); + }); + + it('offers nothing after a qualifier that names no namespace', () => { + expect(offered(['model Invoice {', ' +nowhere.|', '}'])).toEqual([]); + expect(offered(['model Invoice {', ' +Invoice.|', '}'])).toEqual([]); + }); + + it('leaves out a mixin the block already includes, by either spelling', () => { + expect( + offered(['model Invoice {', ' +Used', ' +billing.BillingStamps', ' +|', '}']), + ).toEqual([ + ['TopStamps', 'model mixin'], + ['auth', 'Namespace'], + ['public', 'Namespace'], + ]); + expect(offered(['model Invoice {', ' +Used', ' +billing.|', '}'])).toEqual([ + ['BillingStamps', 'model mixin'], + ]); + }); + + it('still offers the mixin whose complete name the cursor is at the end of', () => { + expect(offered(['model Invoice {', ' +Used|', '}'])).toContainEqual(['Used', 'model mixin']); + }); + + it('offers nothing after a plus inside a mixin body', () => { + expect(offered(['model mixin Audited {', ' +|', '}'])).toEqual([]); + expect(offered(['policy mixin Shared {', ' +auth.|', '}'])).toEqual([]); + }); + + it('offers no entry keys after a plus in a struct block', () => { + const labels = after(['policy P {', ' +|', '}']).items.map((item) => item.label); + const keys = after(['policy P {', ' |', '}']).items.map((item) => item.label); + + expect(keys.length).toBeGreaterThan(0); + expect(labels.filter((label) => keys.includes(label))).toEqual([]); + }); + + it('offers no mixins on the line after a plus', () => { + expect(offered(['model Invoice {', ' +', ' |', '}'])).toEqual([]); + }); + + it('replaces the typed name with the mixin name, and a namespace with its name and a dot', () => { + const { items, sourceFile, cursorOffset } = after(['model Invoice {', ' +Bil|', '}']); + const range = { + start: sourceFile.positionAt(cursorOffset - 3), + end: sourceFile.positionAt(cursorOffset), + }; + + expect(completionItemByLabel(items, 'BillingStamps')).toMatchObject({ + kind: CompletionItemKind.Interface, + detail: 'model mixin', + filterText: 'BillingStamps', + textEdit: { range, newText: 'BillingStamps' }, + }); + expect(completionItemByLabel(items, 'auth')).toMatchObject({ + kind: CompletionItemKind.Module, + detail: 'Namespace', + textEdit: { range, newText: 'auth.' }, + }); + }); +}); diff --git a/packages/1-framework/3-tooling/language-server/test/completion-provider.mixin-bodies.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-provider.mixin-bodies.test.ts new file mode 100644 index 000000000000..9d90ab4c6bef --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/completion-provider.mixin-bodies.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import type { CompletionItem } from 'vscode-languageserver'; +import { complete } from './helpers/completion-provider'; + +describe('providePslCompletionItems — inside a mixin body', () => { + const declarationKeywords = ['namespace', 'types']; + + const withoutOwnName = (items: readonly CompletionItem[]) => + items.filter((item) => item.label !== 'Probe'); + + function itemsIn(header: string, body: readonly string[], closed = true) { + return complete([header, ...body, ...(closed ? ['}'] : [])].join('\n')).items; + } + + it.each([ + ['a field type', [' scopedOnly Int', ' value |']], + ['a field attribute name', [' scopedOnly Int', ' value Int @|']], + ['a field attribute argument', [' value Int @marker(|)']], + [ + 'a field attribute key that depends on the fields of the owner', + [' scopedOnly Int', ' value Int @ownerAware(|)'], + ], + ['a field attribute key when the owner lacks the field', [' value Int @ownerAware(|)']], + ['a block attribute name', [' value Int', ' @@|']], + ['a block attribute argument', [' value Int', ' @@security.rls(|)']], + ])('offers in a model mixin what a model offers at %s', (_position, body) => { + const inMixin = itemsIn('model mixin Probe {', body); + + expect(inMixin).toEqual(withoutOwnName(itemsIn('model Probe {', body))); + expect( + inMixin.map((item) => item.label).filter((label) => declarationKeywords.includes(label)), + ).toEqual([]); + }); + + it('offers something at each model position the fixture has candidates for', () => { + expect(itemsIn('model mixin Probe {', [' value |']).length).toBeGreaterThan(0); + expect(itemsIn('model mixin Probe {', [' value Int @|']).length).toBeGreaterThan(0); + expect(itemsIn('model mixin Probe {', [' value Int', ' @@|']).length).toBeGreaterThan(0); + expect( + itemsIn('model mixin Probe {', [' scopedOnly Int', ' value Int @ownerAware(|)']).map( + (item) => item.label, + ), + ).toEqual(['scopedKey']); + }); + + it.each([ + ['a field type', [' value |']], + ['a field attribute name', [' value Int @|']], + ['a field attribute argument', [' value Int @marker(|)']], + ['a block attribute name', [' value Int', ' @@|']], + ])('offers in a type mixin what a composite type offers at %s', (_position, body) => { + const inMixin = itemsIn('type mixin Probe {', body); + + expect(inMixin).toEqual(withoutOwnName(itemsIn('type Probe {', body))); + expect( + inMixin.map((item) => item.label).filter((label) => declarationKeywords.includes(label)), + ).toEqual([]); + }); + + it.each([ + ['an entry key', 'policy', [' |']], + ['an entry key after another entry', 'policy', [' on = User', ' |']], + ['an entry value that names a model', 'policy', [' on = |']], + ['an entry value with fixed identifiers', 'policy', [' mode = |']], + ['a block attribute name', 'policy', [' @@|']], + ['a block attribute argument', 'policy', [' @@audit(|)']], + ['an entry key of a map block', 'inventory', [' |']], + ['an entry value of a map block', 'inventory', [' count = |']], + ])( + 'offers in a mixin for a key = value keyword what a block of that keyword offers at %s', + (_position, keyword, body) => { + const inMixin = itemsIn(`${keyword} mixin Probe {`, body); + + expect(inMixin).toEqual(itemsIn(`${keyword} Probe {`, body)); + expect( + inMixin.map((item) => item.label).filter((label) => declarationKeywords.includes(label)), + ).toEqual([]); + }, + ); + + it('offers something at each struct block position', () => { + expect(itemsIn('policy mixin Probe {', [' |']).map((item) => item.label)).toContain('on'); + expect(itemsIn('policy mixin Probe {', [' mode = |']).map((item) => item.label)).toEqual([ + 'permissive', + 'restrictive', + ]); + expect(itemsIn('policy mixin Probe {', [' @@|']).map((item) => item.label)).toEqual(['audit']); + }); + + it('offers nothing and does not fail in a mixin for a keyword with no registered block', () => { + expect(itemsIn('widget mixin Probe {', [' |'])).toEqual(itemsIn('widget Probe {', [' |'])); + expect(itemsIn('widget mixin Probe {', [' |'])).toEqual([]); + expect(itemsIn('widget mixin Probe {', [' @@|'])).toEqual([]); + expect(itemsIn('widget mixin Probe {', [' key = |'])).toEqual([]); + }); + + it('completes inside a mixin that is not closed yet as inside a model that is not closed yet', () => { + const body = [' scopedOnly Int', ' value Int @|']; + + expect(itemsIn('model mixin Probe {', body, false)).toEqual( + itemsIn('model Probe {', body, false), + ); + expect(itemsIn('model mixin Probe {', body, false).length).toBeGreaterThan(0); + expect(itemsIn('policy mixin Probe {', [' |'], false)).toEqual( + itemsIn('policy Probe {', [' |'], false), + ); + }); + + it('offers the same inside a mixin declared in a namespace', () => { + const inNamespace = (header: string) => + complete(['namespace scoped {', ` ${header}`, ' value |', ' }', '}'].join('\n')).items; + + expect(inNamespace('model mixin Probe {')).toEqual( + withoutOwnName(inNamespace('model Probe {')), + ); + }); +}); + +describe('providePslCompletionItems — keys a block already has', () => { + const keysOf = (block: readonly string[]) => + complete(['policy mixin Shared {', ' on = User', ' where = "true"', '}', ...block].join('\n')) + .items.map((item) => item.label) + .sort(); + + it('does not offer a key that an included mixin provides', () => { + expect(keysOf(['policy P {', ' +Shared', ' |', '}'])).toEqual(['mode', 'using']); + }); + + it('offers that key in the same block without the inclusion', () => { + expect(keysOf(['policy P {', ' |', '}'])).toEqual(['mode', 'on', 'using', 'where']); + }); + + it('does not offer a key written in the block itself, and still offers the key under the cursor', () => { + expect(keysOf(['policy P {', ' mode = permissive', ' |', '}'])).toEqual([ + 'on', + 'using', + 'where', + ]); + expect(keysOf(['policy P {', ' +Shared', ' mo|', '}'])).toEqual(['mode', 'using']); + }); +}); 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 42a98a69e951..302c16ac371e 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 @@ -1,477 +1,14 @@ -import { dirname, resolve } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import type { - AuthoringContributions, - AuthoringEntityTypeNamespace, - AuthoringPslBlockDescriptorNamespace, - AuthoringTypeNamespace, - DataTypeAuthoringEntry, - DataTypeSupport, -} from '@internal/framework-components/authoring'; -import { createDataTypeLookup, dataType } from '@internal/framework-components/codec'; -import { - EMPTY_GENERATOR_FUNCTIONS, - type GeneratorFunctionRegistry, -} from '@internal/framework-components/components'; - -import { - assembleAuthoringContributions, - assembleControlMutationDefaults, - type ControlMutationDefaultRegistry, -} from '@internal/framework-components/control'; -import { - type AttributeSpecNamespace, - type BlockSpecContext, - blockAttribute, - buildSymbolTable, - EMPTY_DATA_TYPES, - entityRef, - type FieldAttributeSpecContext, - type FuncCallSig, - fieldAttribute, - identifier, - int, - jsonValue, - mapBlock, - modelAttribute, - oneOf, - optional, - str, - structBlock, -} from '@internal/psl-parser'; -import { parse, type SourceFile } from '@internal/psl-parser/syntax'; -import { timeouts } from '@repo/test-utils'; +import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import { assembleAuthoringContributions } from '@internal/framework-components/control'; import { describe, expect, it } from 'vitest'; +import { CompletionItemKind, CompletionItemTag, InsertTextFormat } from 'vscode-languageserver'; import { - type CompletionItem, - CompletionItemKind, - CompletionItemTag, - InsertTextFormat, -} from 'vscode-languageserver'; -import { classifyPslCompletionContext } from '../src/completion-context'; -import { providePslCompletionItems } from '../src/completion-provider'; -import { renderSignatureLabel, resolveSignatureParameters } from '../src/signature-help'; -import { testBinder } from './helpers/binder'; - -const scalarTypes = ['String', 'Int', 'Boolean', 'DateTime'] as const; -const nameSnippetPlaceholder = '$' + '{1:Name}'; -const emptySnippetPlaceholder1 = '$' + '{1:}'; -const sqlExpressionSnippet = 'sql`$' + '{1:expression}`'; -const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); - -const markerAttribute = fieldAttribute('marker', { - documentation: 'Attaches a named marker to a target.', - positional: [{ key: 'target', type: str(), documentation: 'The marker target.' }], - named: { - name: { type: str(), documentation: 'The marker name.' }, - priority: { type: optional(int()), documentation: 'The marker priority.' }, - }, -}); -const orderFixtureAttribute = fieldAttribute('orderFixture', { - documentation: 'Accepts named values in declaration order rather than alphabetical order.', - named: { - zebra: { type: int(), documentation: 'The first declared value.' }, - alpha: { type: int(), documentation: 'The second declared value.' }, - middle: { type: int(), documentation: 'The third declared value.' }, - }, -}); -const rlsAttribute = modelAttribute('rls', { - documentation: 'Configures row-level security for this model.', - named: { - enabled: { type: optional(str()), documentation: 'The security enablement setting.' }, - mode: { type: str(), documentation: 'The security mode.' }, - }, -}); -const auditAttribute = blockAttribute('audit', { - documentation: 'Configures auditing for this block.', - named: { - reason: { type: optional(str()), documentation: 'The reason for auditing.' }, - level: { type: int(), documentation: 'The audit level.' }, - }, -}); - -const attributeContributions = assembleAuthoringContributions([ - { - id: 'fixture-family', - authoring: { - attributeSpecs: { - field: { - marker: () => markerAttribute, - orderFixture: () => orderFixtureAttribute, - ownerAware: (ctx: FieldAttributeSpecContext) => - fieldAttribute('ownerAware', { - documentation: 'Selects a key based on the declaring model’s fields.', - named: { - [Object.hasOwn(ctx.model.fields, 'scopedOnly') ? 'scopedKey' : 'topKey']: { - type: str(), - documentation: 'The value for the owner-specific key.', - }, - }, - }), - }, - model: {}, - }, - }, - }, - { - id: 'fixture-target', - authoring: { - modelAttributes: { - security: { - rls: { - kind: 'modelAttribute', - attribute: 'rls', - spec: () => rlsAttribute, - lower: () => undefined, - }, - }, - }, - }, - }, -]); -const controlMutationDefaults = assembleControlMutationDefaults([]); -const policySpec = () => - structBlock({ - parameters: { - on: { type: optional(entityRef({ kind: 'model' })), documentation: '' }, - where: { type: optional(str()), documentation: 'The policy predicate.' }, - mode: { - type: optional( - oneOf( - identifier('permissive', { documentation: 'Combined with OR.' }), - identifier('restrictive', { documentation: 'Combined with AND.' }), - ), - ), - documentation: '', - }, - using: { type: optional(str()), documentation: '' }, - }, - }); - -const pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace = { - policy: { - kind: 'pslBlock', - keyword: 'policy', - documentation: 'Defines a security policy.', - discriminator: 'fixture-policy', - name: { required: true }, - spec: policySpec, - attributes: { audit: () => auditAttribute }, - }, - inventory: { - kind: 'pslBlock', - keyword: 'inventory', - documentation: 'An arbitrary-key block.', - discriminator: 'fixture-inventory', - name: { required: true }, - spec: () => - mapBlock({ - value: { type: jsonValue(), documentation: 'The entry value.' }, - allowBare: true, - }), - }, - access: { - audit: { - kind: 'pslBlock', - keyword: 'audit', - discriminator: 'fixture-audit', - name: { required: true }, - spec: () => - structBlock({ - parameters: { - on: { type: optional(entityRef({ kind: 'model' })), documentation: '' }, - }, - }), - }, - }, -}; - -const candidateSource = [ - 'types {', - ' Email = String', - ' UserId = User', - '}', - 'model User {', - ' id Int', - ' topOnly String', - '}', - 'type Address {', - ' street String', - '}', - 'policy Audit {', - ' on = read', - '}', - 'namespace auth {', - ' model Account {', - ' id Int', - ' }', - ' model User {', - ' id Int', - ' scopedOnly String', - ' }', - ' type Profile {', - ' displayName String', - ' }', - ' policy ScopedAudit {', - ' on = read', - ' }', - '}', -].join('\n'); - -function complete( - markedFieldSource: string, - options: { - readonly clientSupportsSnippets?: boolean; - readonly clientSupportsTriggerParameterHintsCommand?: boolean; - } = {}, -) { - return completeWithSource({ - markedSource: `${candidateSource}\n${markedFieldSource}`, - pslBlockDescriptors, - authoringContributions: attributeContributions, - controlMutationDefaults, - clientSupportsSnippets: options.clientSupportsSnippets === true, - clientSupportsTriggerParameterHintsCommand: - options.clientSupportsTriggerParameterHintsCommand === true, - }); -} - -interface CompletionTestStack { - readonly attributeSpecs: AttributeSpecNamespace; - readonly entityTypes: AuthoringEntityTypeNamespace; - readonly pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; -} - -interface ActualSqlAttributeModule { - readonly sqlAttributeSpecs: AttributeSpecNamespace; -} - -interface ActualSqlBlockModule { - readonly sqlFamilyEntityTypes: AuthoringEntityTypeNamespace; - readonly sqlFamilyPslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; -} - -interface ActualPostgresDefaultsModule { - createPostgresDefaultFunctionRegistry(): ControlMutationDefaultRegistry; -} - -interface ActualPostgresDataTypesModule { - postgresDataTypeEntries(): Readonly>; -} - -interface ActualSqliteDataTypesModule { - sqliteDataTypeEntries(): Readonly>; -} - -interface ActualSqlExpressionModule { - readonly sqlExpressionRegistration: { - readonly authoring: { - readonly dataTypes: Readonly>; - }; - }; -} - -interface ActualMongoAttributeModule { - readonly mongoAttributeSpecs: AttributeSpecNamespace; -} - -interface ActualMongoBlockModule { - readonly mongoFamilyEntityTypes: AuthoringEntityTypeNamespace; - readonly mongoFamilyPslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; -} - -function completeWithSource(input: { - readonly markedSource: string; - readonly siblings?: readonly string[]; - readonly pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; - readonly authoringContributions?: typeof attributeContributions; - readonly controlMutationDefaults?: typeof controlMutationDefaults; - readonly dataTypes?: DataTypeSupport; - readonly clientSupportsSnippets?: boolean; - readonly clientSupportsTriggerSuggestCommand?: boolean; - readonly clientSupportsTriggerParameterHintsCommand?: boolean; - readonly scalarTypes?: readonly string[]; - readonly generatorFunctions?: GeneratorFunctionRegistry; -}) { - const cursorOffset = input.markedSource.indexOf('|'); - expect(cursorOffset).toBeGreaterThanOrEqual(0); - const source = `${input.markedSource.slice(0, cursorOffset)}${input.markedSource.slice(cursorOffset + 1)}`; - const parsed = parse(source, 'language-server-test.psl'); - const { document } = parsed; - const others = (input.siblings ?? []).map((text, index) => parse(text, `sibling-${index}.psl`)); - const sources = parsed.sources.merge(...others.map((other) => other.sources)); - const sourceFile = sources.sourceFileFor(document.syntax); - const { symbolTable } = buildSymbolTable({ - documents: [...others.map((other) => other.document), document], - sources, - defaultNamespaceId: 'public', - }); - const context = classifyPslCompletionContext({ - document, - sourceFile, - position: sourceFile.positionAt(cursorOffset), - }); - - return { - items: providePslCompletionItems({ - context, - sourceFile, - candidates: { - binder: testBinder({ - ...input, - sources, - symbolTable, - scalarTypes: input.scalarTypes ?? scalarTypes, - }), - scalarTypes: input.scalarTypes ?? scalarTypes, - pslBlockDescriptors: input.pslBlockDescriptors, - symbolTable, - ...(input.authoringContributions === undefined - ? {} - : { authoringContributions: input.authoringContributions }), - ...(input.controlMutationDefaults === undefined - ? {} - : { controlMutationDefaults: input.controlMutationDefaults }), - ...(input.dataTypes === undefined ? {} : { dataTypes: input.dataTypes }), - generatorFunctions: input.generatorFunctions ?? EMPTY_GENERATOR_FUNCTIONS, - }, - clientSupportsSnippets: input.clientSupportsSnippets === true, - clientSupportsTriggerSuggestCommand: input.clientSupportsTriggerSuggestCommand === true, - clientSupportsTriggerParameterHintsCommand: - input.clientSupportsTriggerParameterHintsCommand === true, - }), - sourceFile, - cursorOffset, - }; -} - -async function actualSqlStack(): Promise { - const [attributes, blocks] = await Promise.all([ - importFromPackageRoot( - '../../../2-sql-family/2-authoring/contract-psl/src/sql-attribute-specs.ts', - ), - importFromPackageRoot( - '../../../2-sql-family/9-family/src/core/authoring-entity-types.ts', - ), - ]); - return { - attributeSpecs: attributes.sqlAttributeSpecs, - entityTypes: blocks.sqlFamilyEntityTypes, - pslBlockDescriptors: blocks.sqlFamilyPslBlockDescriptors, - }; -} - -async function actualMongoStack(): Promise { - const [attributes, blocks] = await Promise.all([ - importFromPackageRoot( - '../../../2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts', - ), - importFromPackageRoot( - '../../../2-mongo-family/9-family/src/core/authoring-entity-types.ts', - ), - ]); - return { - attributeSpecs: attributes.mongoAttributeSpecs, - entityTypes: blocks.mongoFamilyEntityTypes, - pslBlockDescriptors: blocks.mongoFamilyPslBlockDescriptors, - }; -} - -const fixtureInt = dataType('fixture/int', { read: (json) => json }); -const fixtureExpression = dataType('fixture/expression', { read: (json) => json }); -const constructorArgumentDataTypes: DataTypeSupport = { - entries: { - [fixtureInt.id]: { - written: { - kind: 'plain', - syntax: 'number', - types: [fixtureInt.id], - classify: (text) => ({ type: fixtureInt.id, value: Number(text) }), - }, - print: String, - documentation: 'A whole number.', - }, - [fixtureExpression.id]: { - written: { kind: 'tag', tag: 'sql', parse: (text) => text }, - print: String, - documentation: 'A SQL expression.', - }, - }, - lookup: createDataTypeLookup([fixtureInt, fixtureExpression]), - dataTypeOfCodec: () => undefined, -}; - -async function sqlExpressionDataTypes(): Promise>> { - const family = await importFromPackageRoot( - '../../../2-sql-family/1-core/contract/src/exports/sql-expression.ts', - ); - return family.sqlExpressionRegistration.authoring.dataTypes; -} - -async function importFromPackageRoot(relativePath: string): Promise { - return (await import(pathToFileURL(resolve(packageRoot, relativePath)).href)) as T; -} - -function actualAuthoringContributions(stack: CompletionTestStack): typeof attributeContributions { - return assembleAuthoringContributions([ - { - id: 'actual-family', - authoring: { - attributeSpecs: stack.attributeSpecs, - entityTypes: stack.entityTypes, - pslBlockDescriptors: stack.pslBlockDescriptors, - }, - }, - ]); -} - -function completeWithActualStack( - markedSource: string, - stack: CompletionTestStack, - options: { - readonly clientSupportsSnippets?: boolean; - readonly controlMutationDefaults?: typeof controlMutationDefaults; - readonly dataTypes?: Readonly>; - } = {}, -) { - return completeWithSource({ - markedSource, - pslBlockDescriptors: stack.pslBlockDescriptors, - authoringContributions: actualAuthoringContributions(stack), - ...(options.dataTypes === undefined - ? {} - : { - dataTypes: { - entries: options.dataTypes, - lookup: createDataTypeLookup([]), - dataTypeOfCodec: () => undefined, - }, - }), - controlMutationDefaults: options.controlMutationDefaults ?? controlMutationDefaults, - clientSupportsSnippets: options.clientSupportsSnippets === true, - }); -} - -function applyCompletionItem(input: { - readonly sourceFile: SourceFile; - readonly item: CompletionItem; -}) { - const edit = input.item.textEdit; - if (edit === undefined || !('range' in edit)) { - throw new Error('Expected a range text edit'); - } - const start = input.sourceFile.offsetAt(edit.range.start); - const end = input.sourceFile.offsetAt(edit.range.end); - return `${input.sourceFile.text.slice(0, start)}${edit.newText}${input.sourceFile.text.slice(end)}`; -} - -function completionItemByLabel(items: readonly CompletionItem[], label: string): CompletionItem { - const item = items.find((candidate) => candidate.label === label); - if (item === undefined) { - throw new Error(`Expected completion item "${label}"`); - } - return item; -} + complete, + completeWithSource, + completionItemByLabel, + constructorArgumentDataTypes, + pslBlockDescriptors, +} from './helpers/completion-provider'; describe('providePslCompletionItems', () => { it('filters candidates after nearest-name shadowing and includes namespace locals', () => { @@ -697,1734 +234,82 @@ namespace other { model Hidden { id Int } }`, ]); }); - it('does not complete a global namespace when its qualifier is shadowed locally', () => { - const { items } = complete(`namespace remote { model Item { id Int } } -namespace app { - model remote { id Int } - model Owner { value remote.| } -}`); - expect(items).toEqual([]); - }); - - it('returns document-level declaration keyword candidates with stable plain-text edits', () => { - const { items, sourceFile, cursorOffset } = complete('|'); - - expect(items.map((item) => item.label)).toEqual([ - 'model', - 'model mixin', - 'type', - 'type mixin', - 'types', - 'namespace', - 'audit', - 'audit mixin', - 'inventory', - 'inventory mixin', - 'policy', - 'policy mixin', - ]); - expect(items.map((item) => item.detail)).toEqual([ - 'Defines a data model.', - 'Declares a mixin for model blocks.', - 'Defines a reusable composite type.', - 'Declares a mixin for type blocks.', - 'Defines reusable type aliases.', - 'Groups declarations belonging to the same database schema or database.', - 'Generic block keyword', - 'Declares a mixin for audit blocks.', - 'An arbitrary-key block.', - 'Declares a mixin for inventory blocks.', - 'Defines a security policy.', - 'Declares a mixin for policy blocks.', - ]); - expect(items[0]).toMatchObject({ - kind: CompletionItemKind.Keyword, - filterText: 'model', - textEdit: { - range: { - start: sourceFile.positionAt(cursorOffset), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'model ', - }, - }); - expect(items[0]?.insertTextFormat).toBeUndefined(); - }); - - it('returns the full document-level declaration keyword set with a replace range over the typed segment', () => { - const { items, sourceFile, cursorOffset } = complete('mo|'); - - expect(items.map((item) => item.label)).toEqual([ - 'model', - 'model mixin', - 'type', - 'type mixin', - 'types', - 'namespace', - 'audit', - 'audit mixin', - 'inventory', - 'inventory mixin', - 'policy', - 'policy mixin', - ]); - expect(items[0]).toMatchObject({ - filterText: 'model', - textEdit: { - range: { - start: sourceFile.positionAt(cursorOffset - 'mo'.length), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'model ', - }, - }); - }); - - it('returns namespace-body declaration keywords without document-only native keywords', () => { - const { items } = complete(['namespace feature {', ' |', '}'].join('\n')); - - expect(items.map((item) => item.label)).toEqual([ - 'model', - 'model mixin', - 'type', - 'type mixin', - 'audit', - 'audit mixin', - 'inventory', - 'inventory mixin', - 'policy', - 'policy mixin', - ]); - expect(items.map((item) => item.label)).not.toContain('types'); - expect(items.map((item) => item.label)).not.toContain('namespace'); - }); - - it('returns the full namespace-body declaration keyword set with a replace range over the typed segment', () => { - const { items, sourceFile, cursorOffset } = complete( - ['namespace feature {', ' po|', '}'].join('\n'), - ); - - expect(items.map((item) => item.label)).toEqual([ - 'model', - 'model mixin', - 'type', - 'type mixin', - 'audit', - 'audit mixin', - 'inventory', - 'inventory mixin', - 'policy', - 'policy mixin', - ]); - expect(items.find((item) => item.label === 'policy')).toMatchObject({ - filterText: 'policy', - textEdit: { - range: { - start: sourceFile.positionAt(cursorOffset - 'po'.length), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'policy ', - }, - }); - }); - - it('returns snippet declaration keyword edits only when the client supports snippets', () => { - const { items } = complete('|', { clientSupportsSnippets: true }); - - expect(items.find((item) => item.label === 'model')).toMatchObject({ - insertTextFormat: InsertTextFormat.Snippet, - textEdit: { newText: `model ${nameSnippetPlaceholder} {\n \${0:// Fields}\n}` }, - }); - expect(items.find((item) => item.label === 'policy')).toMatchObject({ - insertTextFormat: InsertTextFormat.Snippet, - textEdit: { - newText: `policy ${nameSnippetPlaceholder} {\n \${0:// Block keys and attributes}\n}`, - }, - }); - }); - - it.each([true, false])( - 'inserts a body placeholder without pre-filled keys, snippets=%s', - (snippets) => { - const { items } = completeWithSource({ - markedSource: '|', - clientSupportsSnippets: snippets, - pslBlockDescriptors: { - security: { - policy: { - kind: 'pslBlock', - keyword: 'policy', - discriminator: 'policy', - name: { required: true }, - spec: policySpec, - attributes: { audit: () => auditAttribute }, - }, - }, - }, - }); - const item = completionItemByLabel(items, 'policy'); - expect(item.textEdit?.newText).toBe( - snippets - ? [ - `policy ${nameSnippetPlaceholder} {`, - ' $' + '{0:// Block keys and attributes}', - '}', - ].join('\n') - : 'policy ', - ); - expect(item.insertTextFormat).toBe(snippets ? InsertTextFormat.Snippet : undefined); - }, - ); - - describe('mixin declaration items', () => { - const sortedLabels = (items: readonly CompletionItem[]) => - [...items] - .sort((left, right) => ((left.sortText ?? '') < (right.sortText ?? '') ? -1 : 1)) - .map((item) => item.label); - - it('offers model before model mixin when a prefix of the keyword is typed', () => { - const { items, sourceFile, cursorOffset } = complete('mo|'); - const range = { - start: sourceFile.positionAt(cursorOffset - 'mo'.length), - end: sourceFile.positionAt(cursorOffset), - }; - - expect(sortedLabels(items).slice(0, 2)).toEqual(['model', 'model mixin']); - expect(completionItemByLabel(items, 'model mixin')).toMatchObject({ - kind: CompletionItemKind.Keyword, - detail: 'Declares a mixin for model blocks.', - filterText: 'model mixin', - sortText: '0:model mixin', - textEdit: { range, newText: 'model mixin ' }, - }); - }); - - it('sorts each mixin item directly after the item of its keyword', () => { - expect(sortedLabels(complete('|').items)).toEqual([ - 'model', - 'model mixin', - 'namespace', - 'type', - 'type mixin', - 'types', - 'audit', - 'audit mixin', - 'inventory', - 'inventory mixin', - 'policy', - 'policy mixin', - ]); - }); - - it('offers no mixin item for namespace or types, and never the word mixin alone', () => { - const labels = complete('|').items.map((item) => item.label); - - expect(labels).not.toContain('namespace mixin'); - expect(labels).not.toContain('types mixin'); - expect(labels).not.toContain('mixin'); - }); - - it('inserts a mixin declaration with an empty body for a native and for a struct block keyword', () => { - const { items } = completeWithSource({ - markedSource: '|', - clientSupportsSnippets: true, - pslBlockDescriptors: { - security: { - policy: { - kind: 'pslBlock', - keyword: 'policy', - discriminator: 'policy', - name: { required: true }, - spec: policySpec, - attributes: { audit: () => auditAttribute }, - }, - }, - }, - }); - - expect(completionItemByLabel(items, 'model mixin')).toMatchObject({ - insertTextFormat: InsertTextFormat.Snippet, - textEdit: { newText: `model mixin ${nameSnippetPlaceholder} {\n \${0:// Fields}\n}` }, - }); - expect(completionItemByLabel(items, 'policy mixin')).toMatchObject({ - kind: CompletionItemKind.Keyword, - detail: 'Declares a mixin for policy blocks.', - filterText: 'policy mixin', - sortText: '1:policy mixin', - insertTextFormat: InsertTextFormat.Snippet, - textEdit: { - newText: `policy mixin ${nameSnippetPlaceholder} {\n \${0:// Block keys and attributes}\n}`, - }, - }); - }); - - it('offers the mixin item while the whole keyword is the word before the cursor, and nothing once more is typed', () => { - expect(complete('model|').items.map((item) => item.label)).toContain('model mixin'); - expect(complete('model m|').items).toEqual([]); - expect(complete('model mixin|').items).toEqual([]); - }); - - it.each([ - ['a model', ['model User {', ' |', '}']], - ['a block', ['policy P {', ' |', '}']], - ['a model mixin', ['model mixin M {', ' |', '}']], - ['a block mixin', ['policy mixin M {', ' |', '}']], - ])('offers none inside %s', (_where, lines) => { - const labels = complete(lines.join('\n')).items.map((item) => item.label); - - expect(labels.filter((label) => label.endsWith(' mixin'))).toEqual([]); - }); - }); - - it('returns registry-backed attribute name completions as function items', () => { - const fieldItems = complete(['model Post {', ' id Int @|', '}'].join('\n')).items; - expect(fieldItems.map((item) => item.label)).toEqual(['marker', 'orderFixture', 'ownerAware']); - expect(fieldItems.find((item) => item.label === 'marker')?.detail).toBe( - 'Attaches a named marker to a target.', - ); - expect(fieldItems.map((item) => item.kind)).toEqual([ - CompletionItemKind.Function, - CompletionItemKind.Function, - CompletionItemKind.Function, - ]); - - const modelItems = complete(['model Post {', ' id Int', ' @@|', '}'].join('\n')).items; - expect(modelItems.map((item) => item.label)).toEqual(['rls']); - expect(modelItems.map((item) => item.kind)).toEqual([CompletionItemKind.Function]); - - const blockItems = complete(['policy Rule {', ' @@|', '}'].join('\n')).items; - expect(blockItems.map((item) => item.label)).toEqual(['audit']); - expect(blockItems.map((item) => item.kind)).toEqual([CompletionItemKind.Function]); - }); - - it('resolves the attribute owner without enumerating declarations', () => { - const markedSource = ['model User {', ' id Int @|', '}'].join('\n'); - const cursorOffset = markedSource.indexOf('|'); - const source = `${markedSource.slice(0, cursorOffset)}${markedSource.slice(cursorOffset + 1)}`; - const { document, sources } = parse(source, 'language-server-test.psl'); - const sourceFile = sources.sourceFileFor(document.syntax); - const { symbolTable } = buildSymbolTable({ - documents: [document], - sources, - defaultNamespaceId: 'public', - }); - const context = classifyPslCompletionContext({ - document, - sourceFile, - position: sourceFile.positionAt(cursorOffset), - }); - let modelEnumerationCount = 0; - const observedDefaultNamespace = { - ...symbolTable.defaultNamespace, - models: new Proxy(symbolTable.defaultNamespace.models, { - ownKeys(target) { - modelEnumerationCount += 1; - return Reflect.ownKeys(target); - }, - }), - }; - const observedSymbolTable = { - ...symbolTable, - defaultNamespace: observedDefaultNamespace, - namespaces: { [observedDefaultNamespace.id]: observedDefaultNamespace }, - }; - const factoryOwnerNames: string[] = []; - const observedAuthoringContributions = assembleAuthoringContributions([ - { - id: 'observed-family', - authoring: { - attributeSpecs: { - field: { - first: (ctx: FieldAttributeSpecContext) => { - factoryOwnerNames.push(ctx.model.name); - return fieldAttribute('first', { - documentation: 'Marks the first contributed field attribute.', - }); + it('completes a type choice as its bare name, which writes its default choice', () => { + const { items } = completeWithSource({ + markedSource: 'model Owner { value | }', + pslBlockDescriptors: {}, + scalarTypes: [], + clientSupportsSnippets: true, + authoringContributions: assembleAuthoringContributions([ + { + id: 'choices', + authoring: { + type: { + Moment: { + kind: 'typeChoice', + documentation: 'A moment in time.', + args: [ + { + kind: 'option', + name: 'as', + values: ['date', 'text'], + default: 'date', + optional: true, + }, + ], + choices: [ + { when: { as: 'date' }, type: 'Stamp' }, + { when: { as: 'text' }, type: 'Stamp' }, + ], }, - second: (ctx: FieldAttributeSpecContext) => { - factoryOwnerNames.push(ctx.model.name); - return fieldAttribute('second', { - documentation: 'Marks the second contributed field attribute.', - }); + Undocumented: { + kind: 'typeChoice', + args: [], + choices: [{ when: {}, type: 'Stamp' }], }, + Stamp: { kind: 'typeConstructor', output: { codecId: 'fixture/value' } }, }, - model: {}, }, }, - }, - ]); - - const items = providePslCompletionItems({ - context, - sourceFile, - candidates: { - scalarTypes, - pslBlockDescriptors, - symbolTable: observedSymbolTable, - binder: testBinder({ - sources, - symbolTable, - scalarTypes, - authoringContributions: attributeContributions, - controlMutationDefaults, - pslBlockDescriptors, - }), - authoringContributions: observedAuthoringContributions, - controlMutationDefaults, - generatorFunctions: EMPTY_GENERATOR_FUNCTIONS, - }, - clientSupportsSnippets: true, - }); - - expect(items.map((item) => item.label)).toEqual(['first', 'second']); - expect(factoryOwnerNames).toEqual(['User', 'User']); - expect(modelEnumerationCount).toBe(0); - }); - - it('returns attribute named keys including optional keys while omitting supplied keys', () => { - const { items, sourceFile, cursorOffset } = complete( - ['model Post {', ' id Int @marker(name: "id", pr|)', '}'].join('\n'), - ); - - expect(items.map((item) => item.label)).toEqual(['priority']); - expect(items[0]?.textEdit).toEqual({ - range: { - start: sourceFile.positionAt(cursorOffset - 'pr'.length), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'priority: ', + ]), }); - }); - - it('preserves named-key declaration order while filtering supplied keys', () => { - const { items } = complete( - ['model Post {', ' id Int @orderFixture(alpha: 1, |)', '}'].join('\n'), - ); - - expect(items.map((item) => [item.label, item.kind])).toEqual([ - ['zebra', CompletionItemKind.Property], - ['middle', CompletionItemKind.Property], - ]); - }); - - it('uses the current declaration owner when model names collide across namespaces', () => { - const { items } = complete( - [ - 'namespace feature {', - ' model User {', - ' scopedOnly String @ownerAware(|)', - ' }', - '}', - ].join('\n'), - ); - - expect(items.map((item) => item.label)).toEqual(['scopedKey']); - }); - - it('inserts required contributed attribute arguments as snippets for snippet clients', () => { - const { items, sourceFile } = complete( - ['model Post {', ' id Int @mar| // keep', '}'].join('\n'), + expect( + items + .filter((item) => item.label === 'Moment' || item.label === 'Undocumented') + .map((item) => ({ + kind: item.kind, + detail: item.detail, + newText: item.textEdit?.newText, + format: item.insertTextFormat, + })), + ).toEqual([ { - clientSupportsSnippets: true, + kind: CompletionItemKind.Class, + detail: 'A moment in time.', + newText: 'Moment', + format: undefined, }, - ); - const item = completionItemByLabel(items, 'marker'); - - expect(item).toMatchObject({ - insertTextFormat: InsertTextFormat.Snippet, - textEdit: { - newText: `marker("\${1:target}", name: "\${2:name}")`, + { + kind: CompletionItemKind.Class, + detail: 'Convenience type', + newText: 'Undocumented', + format: undefined, }, - }); - expect(applyCompletionItem({ sourceFile, item })).toEqual( - [ - candidateSource, - 'model Post {', - ` id Int @marker("\${1:target}", name: "\${2:name}") // keep`, - '}', - ].join('\n'), - ); - }); - - it.each([false, true])('gates argument snippet hints on client support: %s', (supported) => { - for (const [source, snippets, hints] of [ - ['model Post { id Int @mar| }', true, true], - ['model Post { id Int @mar| }', false, false], - ['model Post { id Int @mar|ker("x") }', true, false], - ['|', true, false], - ] as const) { - const { items } = complete(source, { - clientSupportsSnippets: snippets, - clientSupportsTriggerParameterHintsCommand: supported, - }); - const item = completionItemByLabel(items, source === '|' ? 'model' : 'marker'); - expect(item.command).toEqual( - supported && hints - ? { title: 'Show argument hints', command: 'editor.action.triggerParameterHints' } - : undefined, - ); - } - }); - - it('keeps plain contributed attribute completion free of snippet syntax', () => { - const { items, sourceFile } = complete( - ['model Post {', ' id Int @mar| // keep', '}'].join('\n'), - ); - const item = completionItemByLabel(items, 'marker'); - - expect(item.insertTextFormat).toBeUndefined(); - expect(item.textEdit).toMatchObject({ newText: 'marker' }); - expect(applyCompletionItem({ sourceFile, item })).toEqual( - [candidateSource, 'model Post {', ' id Int @marker // keep', '}'].join('\n'), - ); - }); - - it('preserves existing attribute delimiters and suffixes instead of inserting required arguments again', () => { - const { items, sourceFile } = complete( - ['model Post {', ' id Int @mar|ker(name: "id") @unique', '}'].join('\n'), - { clientSupportsSnippets: true }, - ); - const item = completionItemByLabel(items, 'marker'); - - expect(item.insertTextFormat).toBeUndefined(); - expect(item.textEdit).toMatchObject({ newText: 'marker' }); - expect(applyCompletionItem({ sourceFile, item })).toEqual( - [candidateSource, 'model Post {', ' id Int @marker(name: "id") @unique', '}'].join('\n'), - ); + ]); }); - it('uses the attribute AST to preserve an incomplete existing argument list', () => { - const { items, sourceFile } = complete(['model Post {', ' id Int @mar|ker(', '}'].join('\n'), { - clientSupportsSnippets: true, - }); - const item = completionItemByLabel(items, 'marker'); - - expect(item.insertTextFormat).toBeUndefined(); - expect(item.textEdit).toMatchObject({ newText: 'marker' }); - expect(applyCompletionItem({ sourceFile, item })).toEqual( - [candidateSource, 'model Post {', ' id Int @marker(', '}'].join('\n'), - ); + it('does not complete a global namespace when its qualifier is shadowed locally', () => { + const { items } = complete(`namespace remote { model Item { id Int } } +namespace app { + model remote { id Int } + model Owner { value remote.| } +}`); + expect(items).toEqual([]); }); - it('uses actual SQL attribute specs for names and top-level named keys', async () => { - const stack = await actualSqlStack(); - - expect( - completeWithActualStack(['model Post {', ' id Int @|', '}'].join('\n'), stack).items.map( - (item) => item.label, - ), - ).toEqual(['default', 'id', 'map', 'onCreate', 'onUpdate', 'relation', 'unique']); - expect( - completeWithActualStack( - ['model Post {', ' id Int', ' @@|', '}'].join('\n'), - stack, - ).items.map((item) => item.label), - ).toEqual(['base', 'check', 'control', 'discriminator', 'id', 'index', 'map', 'unique']); - expect( - completeWithActualStack(['enum Role {', ' Admin', ' @@|', '}'].join('\n'), stack).items.map( - (item) => item.label, - ), - ).toEqual(['type']); - - const { items, sourceFile, cursorOffset } = completeWithActualStack( - ['model Post {', ' id Int', ' @@index(expression: sql`lower(name)`, ma|)', '}'].join('\n'), - stack, - ); - expect(items.map((item) => item.label)).toEqual([ - 'where', - 'unique', - 'name', - 'map', - 'type', - 'options', - ]); - expect(items.find((item) => item.label === 'map')?.textEdit).toEqual({ - range: { - start: sourceFile.positionAt(cursorOffset - 'ma'.length), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'map: ', - }); - - expect( - completeWithActualStack( - ['model Post {', ' id Int @default(|)', '}'].join('\n'), - stack, - ).items.map((item) => item.label), - ).toEqual(['true', 'false', 'null']); - - const mapCompletion = completeWithActualStack( - ['model Post {', ' id Int @ma| // keep', '}'].join('\n'), - stack, - { clientSupportsSnippets: true }, - ); - const mapItem = completionItemByLabel(mapCompletion.items, 'map'); - expect(mapItem).toMatchObject({ - insertTextFormat: InsertTextFormat.Snippet, - textEdit: { newText: `map("\${1:name}")` }, - }); - expect(applyCompletionItem({ sourceFile: mapCompletion.sourceFile, item: mapItem })).toEqual( - ['model Post {', ` id Int @map("\${1:name}") // keep`, '}'].join('\n'), - ); - - const checkCompletion = completeWithActualStack( - ['model Post {', ' id Int', ' @@che| // keep', '}'].join('\n'), - stack, - { clientSupportsSnippets: true, dataTypes: await sqlExpressionDataTypes() }, - ); - const checkItem = completionItemByLabel(checkCompletion.items, 'check'); - expect(checkItem).toMatchObject({ - insertTextFormat: InsertTextFormat.Snippet, - textEdit: { newText: `check(expression: ${sqlExpressionSnippet})` }, - }); - expect( - applyCompletionItem({ sourceFile: checkCompletion.sourceFile, item: checkItem }), - ).toEqual( - [ - 'model Post {', - ' id Int', - ` @@check(expression: ${sqlExpressionSnippet}) // keep`, - '}', - ].join('\n'), - ); - }, 5_000); - - it('offers a sql literal where @@index and @@check take SQL', async () => { - const stack = await actualSqlStack(); - const dataTypes = await sqlExpressionDataTypes(); - const [entry] = Object.values(dataTypes); - const valuesAt = (attribute: string) => - completeWithActualStack( - ['model Post {', ' id Int', ` ${attribute}`, '}'].join('\n'), - stack, - { - clientSupportsSnippets: true, - dataTypes, - }, - ).items.map((item) => ({ - label: item.label, - detail: item.detail, - newText: item.textEdit?.newText, - insertTextFormat: item.insertTextFormat, - })); - const sqlItem = { - label: 'sql', - detail: entry?.documentation, - newText: 'sql`$1`', - insertTextFormat: InsertTextFormat.Snippet, - }; - - expect(valuesAt('@@index([id], where: |)')).toEqual([sqlItem]); - expect(valuesAt('@@index(expression: |, map: "post_idx")')).toEqual([sqlItem]); - expect(valuesAt('@@check(expression: |, name: "post_check")')).toEqual([sqlItem]); - }, 5_000); - - it('completes model and field attributes when the source has no data types', async () => { - const stack = await actualSqlStack(); - const labelsAt = (markedSource: string) => - completeWithActualStack(markedSource, stack).items.map((item) => item.label); - - expect(labelsAt(['model Post {', ' id Int', ' @@|', '}'].join('\n'))).toEqual([ - 'base', - 'check', - 'control', - 'discriminator', - 'id', - 'index', - 'map', - 'unique', - ]); - expect(labelsAt(['model Post {', ' id Int @|', '}'].join('\n'))).toEqual([ - 'default', - 'id', - 'map', - 'onCreate', - 'onUpdate', - 'relation', - 'unique', - ]); - expect( - labelsAt(['model Post {', ' id Int', ' @@check(expression: |)', '}'].join('\n')), - ).toEqual([]); - }, 5_000); - - it('uses actual Mongo attribute specs for names and dynamic factory keys', async () => { - const stack = await actualMongoStack(); - - expect( - completeWithActualStack(['model Post {', ' id String @|', '}'].join('\n'), stack).items.map( - (item) => item.label, - ), - ).toEqual(['id', 'map', 'onCreate', 'onUpdate', 'relation', 'unique']); - expect( - completeWithActualStack( - ['model Post {', ' id String', ' @@|', '}'].join('\n'), - stack, - ).items.map((item) => item.label), - ).toEqual(['base', 'discriminator', 'index', 'map', 'textIndex', 'unique']); - expect( - completeWithActualStack(['enum Role {', ' Admin', ' @@|', '}'].join('\n'), stack).items.map( - (item) => item.label, - ), - ).toEqual(['type']); - - expect( - completeWithActualStack( - [ - 'namespace scoped {', - ' model User {', - ' id String', - ' localOnly String', - ' @@index(fields: [id], la|)', - ' }', - '}', - 'model User {', - ' id String', - '}', - ].join('\n'), - stack, - ).items.map((item) => item.label), - ).toEqual([ - 'type', - 'sparse', - 'expireAfterSeconds', - 'filter', - 'include', - 'exclude', - 'default_language', - 'languageOverride', - 'collationLocale', - 'collationStrength', - 'collationCaseLevel', - 'collationCaseFirst', - 'collationNumericOrdering', - 'collationAlternate', - 'collationMaxVariable', - 'collationBackwards', - 'collationNormalization', - ]); - expect( - completeWithActualStack( - ['model Post {', ' author User @relation(name: "Author", f|)', '}'].join('\n'), - stack, - ).items.map((item) => item.label), - ).toEqual(['fields', 'references']); - - const mapCompletion = completeWithActualStack( - ['model Post {', ' id String @ma| // keep', '}'].join('\n'), - stack, - { clientSupportsSnippets: true }, - ); - const mapItem = completionItemByLabel(mapCompletion.items, 'map'); - expect(mapItem).toMatchObject({ - insertTextFormat: InsertTextFormat.Snippet, - textEdit: { newText: `map("\${1:name}")` }, - }); - expect(applyCompletionItem({ sourceFile: mapCompletion.sourceFile, item: mapItem })).toEqual( - ['model Post {', ` id String @map("\${1:name}") // keep`, '}'].join('\n'), - ); - }, 5_000); - - it('returns bare model field type completion candidates without custom ranking', () => { - const { items, sourceFile, cursorOffset } = complete( - ['model Post {', ' author |', '}'].join('\n'), - ); - - expect(items.map(({ label, detail }) => [label, detail]).sort()).toEqual([ - ['Address', 'Composite type'], - ['Boolean', 'Configured scalar type'], - ['DateTime', 'Configured scalar type'], - ['Email', 'Scalar type'], - ['Int', 'Configured scalar type'], - ['Post', 'Model'], - ['String', 'Configured scalar type'], - ['User', 'Model'], - ['UserId', 'Type alias'], - ['auth', 'Namespace'], - ['public', 'Namespace'], - ]); - for (const item of items) expect(item).not.toHaveProperty('sortText'); - expect(completionItemByLabel(items, 'Boolean').textEdit).toEqual({ - range: { - start: sourceFile.positionAt(cursorOffset), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'Boolean', - }); - }); - - it('returns the full bare candidate set with a replace range over the typed segment', () => { - const { items, sourceFile, cursorOffset } = complete( - ['model Post {', ' reviewer U|', '}'].join('\n'), - ); - - expect(items.map((item) => item.label).sort()).toEqual([ - 'Address', - 'Boolean', - 'DateTime', - 'Email', - 'Int', - 'Post', - 'String', - 'User', - 'UserId', - 'auth', - 'public', - ]); - expect(items.find((item) => item.label === 'User')).toMatchObject({ - filterText: 'User', - textEdit: { - range: { - start: sourceFile.positionAt(cursorOffset - 'U'.length), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'User', - }, - }); - }); - - it('returns the full bare candidate set including the namespace qualifier with a replace range over the typed segment', () => { - const { items, sourceFile, cursorOffset } = complete( - ['model Post {', ' reviewer a|', '}'].join('\n'), - ); - - expect(items.map((item) => item.label).sort()).toEqual([ - 'Address', - 'Boolean', - 'DateTime', - 'Email', - 'Int', - 'Post', - 'String', - 'User', - 'UserId', - 'auth', - 'public', - ]); - expect(items.find((item) => item.label === 'auth')).toMatchObject({ - kind: CompletionItemKind.Module, - detail: 'Namespace', - filterText: 'auth', - textEdit: { - range: { - start: sourceFile.positionAt(cursorOffset - 'a'.length), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'auth.', - }, - }); - }); - - it('returns namespace members after a namespace qualifier', () => { - const { items, sourceFile, cursorOffset } = complete( - ['model Post {', ' owner auth.|', '}'].join('\n'), - ); - - expect(items.map((item) => item.label).sort()).toEqual(['Account', 'Profile', 'User']); - expect(completionItemByLabel(items, 'Account').textEdit).toEqual({ - range: { - start: sourceFile.positionAt(cursorOffset), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'Account', - }); - }); - - it('returns the full namespace member set with replacement metadata for the typed segment', () => { - const { items, sourceFile, cursorOffset } = complete( - ['model Post {', ' owner auth.U|', '}'].join('\n'), - ); - - expect(items.map((item) => item.label).sort()).toEqual(['Account', 'Profile', 'User']); - for (const item of items) expect(item).not.toHaveProperty('sortText'); - expect(items.find((item) => item.label === 'User')).toMatchObject({ - filterText: 'User', - detail: 'Model', - textEdit: { - range: { - start: sourceFile.positionAt(cursorOffset - 'U'.length), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'User', - }, - }); - }); - - it('does not leak local namespace members into a foreign contract-space reference', () => { - const { items } = complete(['model Post {', ' owner supabase:auth.P|', '}'].join('\n')); + it('returns an empty list for unsupported classifier contexts', () => { + const { items } = complete(['model Post {', ' // @|', '}'].join('\n')); expect(items).toEqual([]); }); - - it('returns no completions for a contract-space-qualified position', () => { - const { items } = complete(['model Post {', ' external supabase:|', '}'].join('\n')); - - expect(items).toEqual([]); - }); - - it('completes a generic block value from the block spec', () => { - const { items } = complete(['policy Rule {', ' on = |', '}'].join('\n')); - - expect(items.map((item) => item.label)).toEqual(['User', 'public', 'auth']); - }); - - it('returns descriptor-backed generic block parameter completions', () => { - const { items, sourceFile, cursorOffset } = complete(['policy Rule {', ' |', '}'].join('\n')); - - expect(items.map((item) => item.label)).toEqual(['on', 'where', 'mode', 'using']); - expect(items.map((item) => item.detail)).toEqual([ - 'Generic block parameter', - 'The policy predicate.', - 'Generic block parameter', - 'Generic block parameter', - ]); - expect(items[0]?.textEdit).toEqual({ - range: { - start: sourceFile.positionAt(cursorOffset), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'on = ', - }); - }); - - it('returns the full descriptor-backed generic block parameter set excluding already-present sibling keys', () => { - const { items, sourceFile, cursorOffset } = complete( - ['policy Rule {', ' on = User', ' wh|', '}'].join('\n'), - ); - - expect(items.map((item) => item.label)).toEqual(['where', 'mode', 'using']); - expect(items.find((item) => item.label === 'where')).toMatchObject({ - filterText: 'where', - textEdit: { - range: { - start: sourceFile.positionAt(cursorOffset - 'wh'.length), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'where = ', - }, - }); - }); - - it('uses generic block parameter documentation with a fallback for undocumented parameters', () => { - const { items } = complete('policy Rule { | }'); - expect(completionItemByLabel(items, 'where').detail).toBe('The policy predicate.'); - expect(completionItemByLabel(items, 'on').detail).toBe('Generic block parameter'); - }); - - it('still offers the in-progress key while excluding an already-present sibling key', () => { - const { items } = complete(['policy Rule {', ' where = "x"', ' on|', '}'].join('\n')); - - expect(items.map((item) => item.label)).toEqual(['on', 'mode', 'using']); - }); - - it('returns no generic block parameter completions without a matching descriptor', () => { - const { items } = complete(['extension Rule {', ' |', '}'].join('\n')); - - expect(items).toEqual([]); - }); - - it('offers no key candidates for an arbitrary-key block spec', () => { - const { items } = complete(['inventory Stock {', ' |', '}'].join('\n')); - - expect(items).toEqual([]); - }); - - it('keeps key completion available inside an invalid block', () => { - const { items } = complete( - ['policy Rule {', ' bogus = "not a declared key"', ' |', '}'].join('\n'), - ); - - expect(items.map((item) => item.label)).toEqual(['on', 'where', 'mode', 'using']); - }); - - it('calls the spec factory with the spec context and never invokes rule parsing', () => { - const dataTypes: DataTypeSupport = { - entries: {}, - lookup: createDataTypeLookup([]), - dataTypeOfCodec: () => undefined, - }; - const factoryContexts: BlockSpecContext[] = []; - const throwingRule = { - kind: 'str' as const, - label: 'string', - value: undefined, - parse: () => { - throw new Error('metadata inspection must not invoke rule parsing'); - }, - }; - const { items } = completeWithSource({ - markedSource: ['guard Rule {', ' |', '}'].join('\n'), - pslBlockDescriptors: { - guard: { - kind: 'pslBlock', - keyword: 'guard', - discriminator: 'fixture-guard', - name: { required: true }, - spec: (ctx: BlockSpecContext) => { - factoryContexts.push(ctx); - return structBlock({ - parameters: { - shield: { type: throwingRule, documentation: 'The shield key.' }, - }, - }); - }, - }, - }, - dataTypes, - }); - - expect(items.map((item) => item.label)).toEqual(['shield']); - expect(items[0]?.detail).toBe('The shield key.'); - expect(factoryContexts).toHaveLength(2); - for (const ctx of factoryContexts) { - expect(ctx).toEqual({ symbols: expect.any(Object), dataTypes: expect.any(Object) }); - expect(ctx.dataTypes).toBe(dataTypes); - } - }); - - it.each(['|', '[|]', '{ nested: | }', '[{ nested: [|] }]'])( - 'completes recursive JSON values at %s', - (value) => { - const { items } = completeWithSource({ - markedSource: `model Post { value String @jsonFixture(${value}) }`, - pslBlockDescriptors: {}, - authoringContributions: assembleAuthoringContributions([ - { - id: 'json-fixture', - authoring: { - attributeSpecs: { - field: { - jsonFixture: () => - fieldAttribute('jsonFixture', { - documentation: '', - positional: [{ key: 'value', type: jsonValue(), documentation: '' }], - }), - }, - model: {}, - }, - }, - }, - ]), - controlMutationDefaults, - }); - expect(items.map((item) => item.label)).toEqual(['true', 'false', 'null']); - }, - ); - - it('returns an empty list for unsupported classifier contexts', () => { - const { items } = complete(['model Post {', ' // @|', '}'].join('\n')); - - expect(items).toEqual([]); - }); - - it('uses registered scalar documentation for completion details', () => { - const { items } = completeWithSource({ - markedSource: 'model Post { value | }', - pslBlockDescriptors: {}, - authoringContributions: assembleAuthoringContributions([ - { - id: 'documented-scalars', - authoring: { - type: { - String: { - kind: 'typeConstructor', - documentation: 'Variable-length Unicode text.', - output: { codecId: 'fixture/text@1' }, - }, - }, - }, - }, - ]), - controlMutationDefaults, - }); - expect(completionItemByLabel(items, 'String').detail).toBe('Variable-length Unicode text.'); - expect(completionItemByLabel(items, 'Int').detail).toBe('Configured scalar type'); - }); - - it( - 'offers no field preset of the real Postgres pack in type position', - async () => { - const { postgresTargetDescriptorMeta } = await importFromPackageRoot<{ - readonly postgresTargetDescriptorMeta: { readonly authoring: AuthoringContributions }; - }>('../../../3-targets/postgres/1-target/src/core/descriptor-meta.ts'); - const authoringContributions = assembleAuthoringContributions([ - { id: 'postgres', authoring: postgresTargetDescriptorMeta.authoring }, - ]); - const labels = (markedSource: string) => - completeWithSource({ - markedSource, - pslBlockDescriptors: {}, - clientSupportsSnippets: false, - authoringContributions, - controlMutationDefaults, - }).items.map((item) => item.label); - - const topLevel = labels('model Post { value | }'); - const temporal = labels('model Post { value temporal.| }'); - expect({ - topLevel: ['text', 'json', 'uuidNative', 'id'].filter((name) => topLevel.includes(name)), - temporal: ['createdAt', 'updatedAt', 'timestamp'].filter((name) => temporal.includes(name)), - aliases: ['CreatedAt', 'UpdatedAt'].filter((name) => temporal.includes(name)), - }).toEqual({ topLevel: [], temporal: [], aliases: ['CreatedAt', 'UpdatedAt'] }); - }, - timeouts.typeScriptCompilation, - ); - - it( - 'lists deprecated Mongo scalar names tagged deprecated, naming the replacement', - async () => { - const { mongoScalarAuthoringTypes } = await importFromPackageRoot<{ - readonly mongoScalarAuthoringTypes: AuthoringTypeNamespace; - }>('../../../3-targets/mongo/2-adapter/src/exports/control.ts'); - const { items } = completeWithSource({ - markedSource: 'model Post { value | }', - pslBlockDescriptors: {}, - scalarTypes: Object.keys(mongoScalarAuthoringTypes), - authoringContributions: assembleAuthoringContributions([ - { id: 'mongo-scalars', authoring: { type: mongoScalarAuthoringTypes } }, - ]), - controlMutationDefaults, - }); - const scalars = items.filter((item) => item.kind === CompletionItemKind.Class); - const current = ['Int32', 'Double', 'Bool', 'Date']; - const deprecated = [ - ['Int', 'Int32'], - ['Float', 'Double'], - ['Boolean', 'Bool'], - ['DateTime', 'Date'], - ] as const; - - expect( - scalars - .filter((item) => item.tags?.includes(CompletionItemTag.Deprecated)) - .map((item) => item.label) - .sort(), - ).toEqual(deprecated.map(([name]) => name).sort()); - for (const name of current) { - expect(completionItemByLabel(items, name)).not.toHaveProperty('tags'); - } - for (const [name, replacement] of deprecated) { - expect(completionItemByLabel(items, name)).toMatchObject({ - tags: [CompletionItemTag.Deprecated], - detail: expect.stringContaining(`Deprecated: use ${replacement}`), - }); - } - }, - timeouts.coldTransformImport, - ); - - it('uses actual SQL enum metadata and rejects an empty enum without invented values', async () => { - const stack = await actualSqlStack(); - const names = (schema: string) => - completeWithActualStack(schema, stack).items.map((item) => item.label); - expect(names('enum Mood { Happy Sad }\nmodel Post { mood Mood @default(|) }')).toEqual([ - 'Happy', - 'Sad', - ]); - expect(names('enum Mood {}\nmodel Post { mood Mood @default(|) }')).toEqual([]); - expect( - names( - 'enum Mood { Top }\nnamespace scoped { enum Mood { Scoped }\nmodel Post { mood scoped.Mood @default(|) } }', - ), - ).toEqual(['Scoped']); - const degraded = names('enum Mood { Happy Happy }\nmodel Post { mood Mood @default(|) }'); - expect(degraded).toEqual(['Happy']); - }, 5_000); - - it('uses actual adapter default-function signatures through the SQL factory', async () => { - const stack = await actualSqlStack(); - const defaults = await importFromPackageRoot( - '../../../3-targets/postgres/2-adapter/src/core/migrations/control-mutation-defaults.ts', - ); - const options = { - controlMutationDefaults: { - ...controlMutationDefaults, - defaultFunctionRegistry: defaults.createPostgresDefaultFunctionRegistry(), - }, - }; - const source = (args: string) => `model Post { value String @default(${args}) }`; - const candidates = completeWithActualStack(source('|'), stack, options).items; - expect(candidates.map((item) => item.label)).toEqual([ - 'true', - 'false', - 'null', - 'autoincrement', - 'now', - ]); - const snippetItems = completeWithActualStack(source('|'), stack, { - ...options, - clientSupportsSnippets: true, - }).items; - expect(completionItemByLabel(snippetItems, 'now').textEdit?.newText).toBe('now()'); - }, 5_000); - - it('labels each generator function argument by the values the function takes', async () => { - const postgres = await importFromPackageRoot<{ - readonly postgresGeneratorFunctions: readonly { - readonly name: string; - readonly signature?: unknown; - }[]; - }>('../../../3-targets/postgres/1-target/src/core/generator-functions.ts'); - const labelOf = (name: string) => { - const declared = postgres.postgresGeneratorFunctions.find( - (contribution) => contribution.name === name && contribution.signature !== undefined, - )!.signature as (dataTypes: unknown) => FuncCallSig; - const signature = declared(EMPTY_DATA_TYPES); - return renderSignatureLabel(name, signature, resolveSignatureParameters(signature, undefined)) - .label; - }; - expect(['uuid', 'cuid', 'nanoid'].map(labelOf)).toEqual([ - 'uuid((4 | 7)?)', - 'cuid(2)', - 'nanoid(integer from 2 to 255?)', - ]); - }); - - it('offers each registered tag inside @default( with its own documentation', async () => { - const stack = await actualSqlStack(); - const [postgres, sqlite, family] = await Promise.all([ - importFromPackageRoot( - '../../../3-targets/postgres/1-target/src/exports/data-types.ts', - ), - importFromPackageRoot( - '../../../3-targets/sqlite/1-target/src/exports/data-types.ts', - ), - importFromPackageRoot( - '../../../2-sql-family/1-core/contract/src/exports/sql-expression.ts', - ), - ]); - const withFamilyEntry = ( - targetEntries: Readonly>, - ): Readonly> => ({ - ...family.sqlExpressionRegistration.authoring.dataTypes, - ...targetEntries, - }); - const complete = ( - dataTypes: Readonly>, - clientSupportsSnippets: boolean, - ) => - completeWithActualStack('model Post { value String @default(|) }', stack, { - clientSupportsSnippets, - controlMutationDefaults, - dataTypes, - }).items.map((item) => ({ - label: item.label, - detail: item.detail, - newText: item.textEdit?.newText, - insertTextFormat: item.insertTextFormat, - })); - const postgresEntries = withFamilyEntry(postgres.postgresDataTypeEntries()); - // This stack has no type constructors, so no column has a known type and every tag is offered. - const postgresTags = [ - 'sql', - 'json', - 'date', - 'time', - 'timetz', - 'timestamp', - 'timestamptz', - 'interval', - 'bytea', - ]; - const documentationOf = ( - entries: Readonly>, - tag: string, - ) => - Object.values(entries).find( - (entry) => entry.written.kind === 'tag' && entry.written.tag === tag, - )?.documentation; - const value = (label: string) => ({ - label, - detail: 'PSL argument value', - newText: label, - insertTextFormat: undefined, - }); - const tag = ( - entries: Readonly>, - label: string, - snippet: boolean, - ) => ({ - label, - detail: documentationOf(entries, label), - newText: snippet ? `${label}\`$1\`` : label, - insertTextFormat: snippet ? InsertTextFormat.Snippet : undefined, - }); - - expect(complete(postgresEntries, true)).toEqual([ - value('true'), - value('false'), - value('null'), - ...postgresTags.map((name) => tag(postgresEntries, name, true)), - ]); - const sqliteEntries = withFamilyEntry(sqlite.sqliteDataTypeEntries()); - expect(complete(sqliteEntries, true)).toEqual([ - value('true'), - value('false'), - value('null'), - tag(sqliteEntries, 'sql', true), - tag(sqliteEntries, 'json', true), - tag(sqliteEntries, 'blob', true), - tag(sqliteEntries, 'datetime', true), - ]); - expect(complete(postgresEntries, false)).toEqual([ - value('true'), - value('false'), - value('null'), - ...postgresTags.map((name) => tag(postgresEntries, name, false)), - ]); - - // Each tag carries the text of the tag it names, not every registered tag's text. - expect(documentationOf(postgresEntries, 'json')).not.toBe( - documentationOf(postgresEntries, 'sql'), - ); - }, 5_000); - - it('uses distinct local and referenced fields through actual SQL relation specs', async () => { - const stack = await actualSqlStack(); - const schema = (args: string) => - `model Target { topOnly Int }\nnamespace remote { model Target { remoteOnly Int } }\nmodel Owner { ownOnly Int\n relation remote.Target @relation(${args}) }`; - expect( - completeWithActualStack(schema('fields: [|]'), stack).items.map((item) => item.label), - ).toEqual(['ownOnly']); - expect( - completeWithActualStack(schema('references: [|]'), stack).items.map((item) => item.label), - ).toEqual(['remoteOnly']); - expect( - completeWithActualStack(schema('onDelete: |'), stack).items.map((item) => item.label), - ).toEqual(['NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault']); - }, 5_000); - - it('uses actual Mongo sort functions and completes their positional arguments', async () => { - const stack = await actualMongoStack(); - const schema = (args: string) => `model Post { title String\n slug String\n @@index(${args}) }`; - const plain = completeWithActualStack(schema('[|]'), stack).items; - expect(plain.map((item) => item.label)).toEqual(['title', 'slug', 'wildcard', 'sort']); - const snippets = completeWithActualStack(schema('[|]'), stack, { - clientSupportsSnippets: true, - }).items; - expect(snippets.map((item) => [item.label, item.textEdit?.newText])).toEqual([ - ['title', 'title'], - ['slug', 'slug'], - ['wildcard', `wildcard(${emptySnippetPlaceholder1})`], - ['sort', 'sort($' + '{1:field}, $' + '{2:direction})'], - ]); - expect( - completeWithActualStack(schema('[sort(|)]'), stack).items.map((item) => item.label), - ).toEqual(['title', 'slug']); - expect( - completeWithActualStack(schema('[sort(title, |)]'), stack).items.map((item) => item.label), - ).toEqual(['Asc', 'Desc']); - expect(completeWithActualStack(schema('[wildcard(|)]'), stack).items).toEqual([]); - expect( - completeWithActualStack(schema('[title], type: |'), stack).items.map((item) => item.label), - ).toEqual(['1', '-1', '"text"', '"2dsphere"', '"2d"', '"hashed"']); - }, 5_000); - - it('scopes actual Mongo sort fields to the declaring namespace model', async () => { - const stack = await actualMongoStack(); - const schema = - 'model Post { topOnly String }\nnamespace scoped { model Post { scopedOnly String\n @@index([|]) } }'; - expect( - completeWithActualStack(schema, stack, { clientSupportsSnippets: true }).items.map((item) => [ - item.label, - item.textEdit?.newText, - ]), - ).toEqual([ - ['scopedOnly', 'scopedOnly'], - ['wildcard', `wildcard(${emptySnippetPlaceholder1})`], - ['sort', 'sort($' + '{1:field}, $' + '{2:direction})'], - ]); - expect( - completeWithActualStack(schema.replace('[|]', '[sort(|)]'), stack).items.map( - (item) => item.label, - ), - ).toEqual(['scopedOnly']); - }, 5_000); - - it('does not return generic block symbols as model field type candidates', () => { - const { items } = complete(['model Post {', ' audit |', '}'].join('\n')); - - expect(items.map((item) => item.label)).not.toContain('Audit'); - expect(items.map((item) => item.label)).not.toContain('auth.ScopedAudit'); - }); - - it('offers the same field type candidates in a document that holds a mixin and an inclusion', () => { - const model = ['model Post {', ' author |', '}'].join('\n'); - const mixins = [ - 'model mixin Timestamps {', - ' createdAt DateTime', - '}', - 'model Tagged {', - ' id Int', - ' +Timestamps', - '}', - ].join('\n'); - const plain = ['', '', '', 'model Tagged {', ' id Int', '', '}'].join('\n'); - - expect(complete(`${mixins}\n${model}`).items).toEqual(complete(`${plain}\n${model}`).items); - }); - - it.each([ - ['in a mixin field type slot', 'model mixin Timestamps {\n createdAt |\n}'], - ['in a mixin header', 'model mixin |'], - ['after the plus of an inclusion', 'model Post {\n id Int\n +|\n}'], - ['inside the name of an inclusion', 'model Post {\n +auth.Ti|\n}'], - ['in an enum mixin body', 'enum mixin BaseRoles {\n |\n}'], - ])('completes without an error %s', (_case, markedSource) => { - expect(() => complete(markedSource)).not.toThrow(); - }); - - it('does not offer a mixin as a field type, by its name or as a namespace member', () => { - const mixins = [ - 'model mixin Timestamps {', - ' createdAt DateTime', - '}', - 'namespace shared {', - ' model mixin Audited {', - ' actor String', - ' }', - ' model Marker {', - ' id Int', - ' }', - '}', - ].join('\n'); - const unqualified = complete(`${mixins}\nmodel Post {\n stamps |\n}`).items; - const qualified = complete(`${mixins}\nmodel Post {\n stamps shared.|\n}`).items; - - expect(unqualified.map((item) => item.label)).toContain('shared'); - expect(unqualified.map((item) => item.label)).not.toContain('Timestamps'); - expect(qualified.map((item) => item.label)).toEqual(['Marker']); - }); -}); - -describe('providePslCompletionItems — after the plus of an inclusion', () => { - const mixinsOf = (prefix: string, indent: string) => - [ - `model mixin ${prefix}Stamps {`, - '}', - `type mixin ${prefix}Geo {`, - '}', - `enum mixin ${prefix}Roles {`, - '}', - `policy mixin ${prefix}Policy {`, - '}', - ].map((line) => `${indent}${line}`); - - function after(block: readonly string[], placement: 'billing' | 'top' = 'billing') { - const namespace = placement === 'top' ? undefined : placement; - const blockLines = - namespace === undefined ? block : block.map((line) => (line === '' ? line : ` ${line}`)); - const markedSource = [ - ...mixinsOf('Top', ''), - ...(namespace === undefined ? blockLines : []), - 'namespace auth {', - ...mixinsOf('Auth', ' '), - '}', - 'namespace billing {', - ...mixinsOf('Billing', ' '), - ' model mixin Used {', - ' }', - ...(namespace === undefined ? [] : blockLines), - '}', - ].join('\n'); - return completeWithSource({ markedSource, pslBlockDescriptors }); - } - - const offered = (block: readonly string[], placement: 'billing' | 'top' = 'billing') => - after(block, placement) - .items.map(({ label, detail }) => [label, detail]) - .sort(); - - const namespaces = [ - ['auth', 'Namespace'], - ['billing', 'Namespace'], - ['public', 'Namespace'], - ]; - - it.each([ - ['a model', ['model Invoice {', ' id Int', ' +Used', ' +|', '}'], 'Stamps', 'model mixin'], - ['a composite type', ['type Address {', ' +|', ' street String', '}'], 'Geo', 'type mixin'], - ['an enum', ['enum Role {', ' +|', ' GUEST', '}'], 'Roles', 'enum mixin'], - ['a struct block', ['policy P {', ' +|', '}'], 'Policy', 'policy mixin'], - ])( - 'offers the mixins of the keyword that an unqualified name reaches, and each namespace, in %s', - (_block, lines, suffix, detail) => { - expect(offered(lines)).toEqual( - [[`Billing${suffix}`, detail], [`Top${suffix}`, detail], ...namespaces].sort(), - ); - }, - ); - - it('offers the same while a name is being typed after the plus', () => { - expect(offered(['model Invoice {', ' +Bil|', '}'])).toEqual( - offered(['model Invoice {', ' +|', '}']), - ); - }); - - it('offers only top-level mixins of the keyword and each namespace in a top-level block', () => { - expect(offered(['enum Role {', ' +|', '}'], 'top')).toEqual( - [['TopRoles', 'enum mixin'], ...namespaces].sort(), - ); - }); - - it.each([ - ['a model', ['model Invoice {', ' +auth.|', '}'], [['AuthStamps', 'model mixin']]], - ['a composite type', ['type Address {', ' +auth.|', '}'], [['AuthGeo', 'type mixin']]], - ['an enum', ['enum Role {', ' +auth.|', '}'], [['AuthRoles', 'enum mixin']]], - ['a struct block', ['policy P {', ' +auth.|', '}'], [['AuthPolicy', 'policy mixin']]], - ])( - 'offers the mixins of the keyword in the named namespace after a qualifier, in %s', - (_block, lines, expected) => { - expect(offered(lines)).toEqual(expected); - expect(offered(lines.map((line) => line.replace('auth.|', 'auth.Au|')))).toEqual(expected); - }, - ); - - it('offers a namespace only when it has a mixin that would be offered after its qualifier', () => { - const source = (block: readonly string[]) => - [ - 'namespace models {', - ' model mixin Stamps {', - ' }', - '}', - 'namespace enums {', - ' enum mixin Roles {', - ' }', - '}', - 'namespace empty {', - ' model Plain {', - ' id Int', - ' }', - '}', - ...block, - ].join('\n'); - const labels = (block: readonly string[]) => - completeWithSource({ markedSource: source(block), pslBlockDescriptors }) - .items.map(({ label, detail }) => [label, detail]) - .sort(); - - expect(labels(['model Invoice {', ' +|', '}'])).toEqual([['models', 'Namespace']]); - expect(labels(['enum Role {', ' +|', '}'])).toEqual([['enums', 'Namespace']]); - expect(labels(['model Invoice {', ' +models.Stamps', ' +|', '}'])).toEqual([]); - expect(labels(['policy P {', ' +|', '}'])).toEqual([]); - }); - - it('does not offer a top-level mixin whose name a model of the namespace hides', () => { - const { items } = completeWithSource({ - markedSource: [ - 'model mixin Timestamps {', - ' createdAt DateTime', - '}', - 'namespace app {', - ' model Timestamps {', - ' id Int', - ' }', - ' model User {', - ' id Int', - ' +|', - ' }', - '}', - ].join('\n'), - pslBlockDescriptors, - }); - - expect(items.map(({ label, detail }) => [label, detail])).toEqual([['public', 'Namespace']]); - }); - - it('offers nothing after a qualifier that names no namespace', () => { - expect(offered(['model Invoice {', ' +nowhere.|', '}'])).toEqual([]); - expect(offered(['model Invoice {', ' +Invoice.|', '}'])).toEqual([]); - }); - - it('leaves out a mixin the block already includes, by either spelling', () => { - expect( - offered(['model Invoice {', ' +Used', ' +billing.BillingStamps', ' +|', '}']), - ).toEqual([ - ['TopStamps', 'model mixin'], - ['auth', 'Namespace'], - ['public', 'Namespace'], - ]); - expect(offered(['model Invoice {', ' +Used', ' +billing.|', '}'])).toEqual([ - ['BillingStamps', 'model mixin'], - ]); - }); - - it('still offers the mixin whose complete name the cursor is at the end of', () => { - expect(offered(['model Invoice {', ' +Used|', '}'])).toContainEqual(['Used', 'model mixin']); - }); - - it('offers nothing after a plus inside a mixin body', () => { - expect(offered(['model mixin Audited {', ' +|', '}'])).toEqual([]); - expect(offered(['policy mixin Shared {', ' +auth.|', '}'])).toEqual([]); - }); - - it('offers no entry keys after a plus in a struct block', () => { - const labels = after(['policy P {', ' +|', '}']).items.map((item) => item.label); - const keys = after(['policy P {', ' |', '}']).items.map((item) => item.label); - - expect(keys.length).toBeGreaterThan(0); - expect(labels.filter((label) => keys.includes(label))).toEqual([]); - }); - - it('offers no mixins on the line after a plus', () => { - expect(offered(['model Invoice {', ' +', ' |', '}'])).toEqual([]); - }); - - it('replaces the typed name with the mixin name, and a namespace with its name and a dot', () => { - const { items, sourceFile, cursorOffset } = after(['model Invoice {', ' +Bil|', '}']); - const range = { - start: sourceFile.positionAt(cursorOffset - 3), - end: sourceFile.positionAt(cursorOffset), - }; - - expect(completionItemByLabel(items, 'BillingStamps')).toMatchObject({ - kind: CompletionItemKind.Interface, - detail: 'model mixin', - filterText: 'BillingStamps', - textEdit: { range, newText: 'BillingStamps' }, - }); - expect(completionItemByLabel(items, 'auth')).toMatchObject({ - kind: CompletionItemKind.Module, - detail: 'Namespace', - textEdit: { range, newText: 'auth.' }, - }); - }); -}); - -describe('providePslCompletionItems — inside a mixin body', () => { - const declarationKeywords = ['namespace', 'types']; - - const withoutOwnName = (items: readonly CompletionItem[]) => - items.filter((item) => item.label !== 'Probe'); - - function itemsIn(header: string, body: readonly string[], closed = true) { - return complete([header, ...body, ...(closed ? ['}'] : [])].join('\n')).items; - } - - it.each([ - ['a field type', [' scopedOnly Int', ' value |']], - ['a field attribute name', [' scopedOnly Int', ' value Int @|']], - ['a field attribute argument', [' value Int @marker(|)']], - [ - 'a field attribute key that depends on the fields of the owner', - [' scopedOnly Int', ' value Int @ownerAware(|)'], - ], - ['a field attribute key when the owner lacks the field', [' value Int @ownerAware(|)']], - ['a block attribute name', [' value Int', ' @@|']], - ['a block attribute argument', [' value Int', ' @@security.rls(|)']], - ])('offers in a model mixin what a model offers at %s', (_position, body) => { - const inMixin = itemsIn('model mixin Probe {', body); - - expect(inMixin).toEqual(withoutOwnName(itemsIn('model Probe {', body))); - expect( - inMixin.map((item) => item.label).filter((label) => declarationKeywords.includes(label)), - ).toEqual([]); - }); - - it('offers something at each model position the fixture has candidates for', () => { - expect(itemsIn('model mixin Probe {', [' value |']).length).toBeGreaterThan(0); - expect(itemsIn('model mixin Probe {', [' value Int @|']).length).toBeGreaterThan(0); - expect(itemsIn('model mixin Probe {', [' value Int', ' @@|']).length).toBeGreaterThan(0); - expect( - itemsIn('model mixin Probe {', [' scopedOnly Int', ' value Int @ownerAware(|)']).map( - (item) => item.label, - ), - ).toEqual(['scopedKey']); - }); - - it.each([ - ['a field type', [' value |']], - ['a field attribute name', [' value Int @|']], - ['a field attribute argument', [' value Int @marker(|)']], - ['a block attribute name', [' value Int', ' @@|']], - ])('offers in a type mixin what a composite type offers at %s', (_position, body) => { - const inMixin = itemsIn('type mixin Probe {', body); - - expect(inMixin).toEqual(withoutOwnName(itemsIn('type Probe {', body))); - expect( - inMixin.map((item) => item.label).filter((label) => declarationKeywords.includes(label)), - ).toEqual([]); - }); - - it.each([ - ['an entry key', 'policy', [' |']], - ['an entry key after another entry', 'policy', [' on = User', ' |']], - ['an entry value that names a model', 'policy', [' on = |']], - ['an entry value with fixed identifiers', 'policy', [' mode = |']], - ['a block attribute name', 'policy', [' @@|']], - ['a block attribute argument', 'policy', [' @@audit(|)']], - ['an entry key of a map block', 'inventory', [' |']], - ['an entry value of a map block', 'inventory', [' count = |']], - ])( - 'offers in a mixin for a key = value keyword what a block of that keyword offers at %s', - (_position, keyword, body) => { - const inMixin = itemsIn(`${keyword} mixin Probe {`, body); - - expect(inMixin).toEqual(itemsIn(`${keyword} Probe {`, body)); - expect( - inMixin.map((item) => item.label).filter((label) => declarationKeywords.includes(label)), - ).toEqual([]); - }, - ); - - it('offers something at each struct block position', () => { - expect(itemsIn('policy mixin Probe {', [' |']).map((item) => item.label)).toContain('on'); - expect(itemsIn('policy mixin Probe {', [' mode = |']).map((item) => item.label)).toEqual([ - 'permissive', - 'restrictive', - ]); - expect(itemsIn('policy mixin Probe {', [' @@|']).map((item) => item.label)).toEqual(['audit']); - }); - - it('offers nothing and does not fail in a mixin for a keyword with no registered block', () => { - expect(itemsIn('widget mixin Probe {', [' |'])).toEqual(itemsIn('widget Probe {', [' |'])); - expect(itemsIn('widget mixin Probe {', [' |'])).toEqual([]); - expect(itemsIn('widget mixin Probe {', [' @@|'])).toEqual([]); - expect(itemsIn('widget mixin Probe {', [' key = |'])).toEqual([]); - }); - - it('completes inside a mixin that is not closed yet as inside a model that is not closed yet', () => { - const body = [' scopedOnly Int', ' value Int @|']; - - expect(itemsIn('model mixin Probe {', body, false)).toEqual( - itemsIn('model Probe {', body, false), - ); - expect(itemsIn('model mixin Probe {', body, false).length).toBeGreaterThan(0); - expect(itemsIn('policy mixin Probe {', [' |'], false)).toEqual( - itemsIn('policy Probe {', [' |'], false), - ); - }); - - it('offers the same inside a mixin declared in a namespace', () => { - const inNamespace = (header: string) => - complete(['namespace scoped {', ` ${header}`, ' value |', ' }', '}'].join('\n')).items; - - expect(inNamespace('model mixin Probe {')).toEqual( - withoutOwnName(inNamespace('model Probe {')), - ); - }); -}); - -describe('providePslCompletionItems — keys a block already has', () => { - const keysOf = (block: readonly string[]) => - complete(['policy mixin Shared {', ' on = User', ' where = "true"', '}', ...block].join('\n')) - .items.map((item) => item.label) - .sort(); - - it('does not offer a key that an included mixin provides', () => { - expect(keysOf(['policy P {', ' +Shared', ' |', '}'])).toEqual(['mode', 'using']); - }); - - it('offers that key in the same block without the inclusion', () => { - expect(keysOf(['policy P {', ' |', '}'])).toEqual(['mode', 'on', 'using', 'where']); - }); - - it('does not offer a key written in the block itself, and still offers the key under the cursor', () => { - expect(keysOf(['policy P {', ' mode = permissive', ' |', '}'])).toEqual([ - 'on', - 'using', - 'where', - ]); - expect(keysOf(['policy P {', ' +Shared', ' mo|', '}'])).toEqual(['mode', 'using']); - }); }); diff --git a/packages/1-framework/3-tooling/language-server/test/generator-attributes.test.ts b/packages/1-framework/3-tooling/language-server/test/generator-attributes.test.ts index 018b16a72f4d..c1add9d134e4 100644 --- a/packages/1-framework/3-tooling/language-server/test/generator-attributes.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/generator-attributes.test.ts @@ -102,7 +102,7 @@ function prepare(markedSource: string, stack: Stack) { }); const controlMutationDefaults = { defaultFunctionRegistry: new Map(), generatorDescriptors: [] }; const binder = testBinder({ ...stack, controlMutationDefaults, sources, symbolTable }); - return { document, sourceFile, symbolTable, binder, offset, controlMutationDefaults }; + return { document, sourceFile, symbolTable, binder, offset, controlMutationDefaults, sources }; } function complete(markedSource: string, stack: Stack, clientSupportsSnippets = false) { @@ -158,7 +158,7 @@ describe('generator attributes in the language server', { it('show the signature of a generator function on hover', async () => { const stack = await actualPostgresStack(); - const { document, sourceFile, binder, offset } = prepare( + const { document, sourceFile, binder, offset, sources } = prepare( 'model Post {\n id String @onCreate(uu|id(7))\n}', stack, ); @@ -170,6 +170,7 @@ describe('generator attributes in the language server', { binder, pslBlockDescriptors: stack.pslBlockDescriptors, dataTypes: EMPTY_DATA_TYPES, + sources, })?.contents, ).toEqual({ kind: 'markdown', diff --git a/packages/1-framework/3-tooling/language-server/test/helpers/completion-provider.ts b/packages/1-framework/3-tooling/language-server/test/helpers/completion-provider.ts new file mode 100644 index 000000000000..2ed58487a9dd --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/helpers/completion-provider.ts @@ -0,0 +1,468 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { + AuthoringEntityTypeNamespace, + AuthoringPslBlockDescriptorNamespace, + DataTypeAuthoringEntry, + DataTypeSupport, +} from '@internal/framework-components/authoring'; +import { createDataTypeLookup, dataType } from '@internal/framework-components/codec'; +import { + EMPTY_GENERATOR_FUNCTIONS, + type GeneratorFunctionRegistry, +} from '@internal/framework-components/components'; +import { + assembleAuthoringContributions, + assembleControlMutationDefaults, + type ControlMutationDefaultRegistry, +} from '@internal/framework-components/control'; +import { + type AttributeSpecNamespace, + blockAttribute, + buildSymbolTable, + entityRef, + type FieldAttributeSpecContext, + fieldAttribute, + identifier, + int, + jsonValue, + mapBlock, + modelAttribute, + oneOf, + optional, + str, + structBlock, +} from '@internal/psl-parser'; +import { parse, type SourceFile } from '@internal/psl-parser/syntax'; +import { expect } from 'vitest'; +import type { CompletionItem } from 'vscode-languageserver'; +import { classifyPslCompletionContext } from '../../src/completion-context'; +import { providePslCompletionItems } from '../../src/completion-provider'; +import { testBinder } from './binder'; + +export const scalarTypes = ['String', 'Int', 'Boolean', 'DateTime'] as const; +export const nameSnippetPlaceholder = '$' + '{1:Name}'; +export const emptySnippetPlaceholder1 = '$' + '{1:}'; +export const sqlExpressionSnippet = 'sql`$' + '{1:expression}`'; +export const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); + +export const markerAttribute = fieldAttribute('marker', { + documentation: 'Attaches a named marker to a target.', + positional: [{ key: 'target', type: str(), documentation: 'The marker target.' }], + named: { + name: { type: str(), documentation: 'The marker name.' }, + priority: { type: optional(int()), documentation: 'The marker priority.' }, + }, +}); +export const orderFixtureAttribute = fieldAttribute('orderFixture', { + documentation: 'Accepts named values in declaration order rather than alphabetical order.', + named: { + zebra: { type: int(), documentation: 'The first declared value.' }, + alpha: { type: int(), documentation: 'The second declared value.' }, + middle: { type: int(), documentation: 'The third declared value.' }, + }, +}); +export const rlsAttribute = modelAttribute('rls', { + documentation: 'Configures row-level security for this model.', + named: { + enabled: { type: optional(str()), documentation: 'The security enablement setting.' }, + mode: { type: str(), documentation: 'The security mode.' }, + }, +}); +export const auditAttribute = blockAttribute('audit', { + documentation: 'Configures auditing for this block.', + named: { + reason: { type: optional(str()), documentation: 'The reason for auditing.' }, + level: { type: int(), documentation: 'The audit level.' }, + }, +}); + +export const attributeContributions = assembleAuthoringContributions([ + { + id: 'fixture-family', + authoring: { + attributeSpecs: { + field: { + marker: () => markerAttribute, + orderFixture: () => orderFixtureAttribute, + ownerAware: (ctx: FieldAttributeSpecContext) => + fieldAttribute('ownerAware', { + documentation: 'Selects a key based on the declaring model’s fields.', + named: { + [Object.hasOwn(ctx.model.fields, 'scopedOnly') ? 'scopedKey' : 'topKey']: { + type: str(), + documentation: 'The value for the owner-specific key.', + }, + }, + }), + }, + model: {}, + }, + }, + }, + { + id: 'fixture-target', + authoring: { + modelAttributes: { + security: { + rls: { + kind: 'modelAttribute', + attribute: 'rls', + spec: () => rlsAttribute, + lower: () => undefined, + }, + }, + }, + }, + }, +]); +export const controlMutationDefaults = assembleControlMutationDefaults([]); +export const policySpec = () => + structBlock({ + parameters: { + on: { type: optional(entityRef({ kind: 'model' })), documentation: '' }, + where: { type: optional(str()), documentation: 'The policy predicate.' }, + mode: { + type: optional( + oneOf( + identifier('permissive', { documentation: 'Combined with OR.' }), + identifier('restrictive', { documentation: 'Combined with AND.' }), + ), + ), + documentation: '', + }, + using: { type: optional(str()), documentation: '' }, + }, + }); + +export const pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace = { + policy: { + kind: 'pslBlock', + keyword: 'policy', + documentation: 'Defines a security policy.', + discriminator: 'fixture-policy', + name: { required: true }, + spec: policySpec, + attributes: { audit: () => auditAttribute }, + }, + inventory: { + kind: 'pslBlock', + keyword: 'inventory', + documentation: 'An arbitrary-key block.', + discriminator: 'fixture-inventory', + name: { required: true }, + spec: () => + mapBlock({ + value: { type: jsonValue(), documentation: 'The entry value.' }, + allowBare: true, + }), + }, + access: { + audit: { + kind: 'pslBlock', + keyword: 'audit', + discriminator: 'fixture-audit', + name: { required: true }, + spec: () => + structBlock({ + parameters: { + on: { type: optional(entityRef({ kind: 'model' })), documentation: '' }, + }, + }), + }, + }, +}; + +export const candidateSource = [ + 'types {', + ' Email = String', + ' UserId = User', + '}', + 'model User {', + ' id Int', + ' topOnly String', + '}', + 'type Address {', + ' street String', + '}', + 'policy Audit {', + ' on = read', + '}', + 'namespace auth {', + ' model Account {', + ' id Int', + ' }', + ' model User {', + ' id Int', + ' scopedOnly String', + ' }', + ' type Profile {', + ' displayName String', + ' }', + ' policy ScopedAudit {', + ' on = read', + ' }', + '}', +].join('\n'); + +export function complete( + markedFieldSource: string, + options: { + readonly clientSupportsSnippets?: boolean; + readonly clientSupportsTriggerParameterHintsCommand?: boolean; + } = {}, +) { + return completeWithSource({ + markedSource: `${candidateSource}\n${markedFieldSource}`, + pslBlockDescriptors, + authoringContributions: attributeContributions, + controlMutationDefaults, + clientSupportsSnippets: options.clientSupportsSnippets === true, + clientSupportsTriggerParameterHintsCommand: + options.clientSupportsTriggerParameterHintsCommand === true, + }); +} + +export interface CompletionTestStack { + readonly attributeSpecs: AttributeSpecNamespace; + readonly entityTypes: AuthoringEntityTypeNamespace; + readonly pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; +} + +export interface ActualSqlAttributeModule { + readonly sqlAttributeSpecs: AttributeSpecNamespace; +} + +export interface ActualSqlBlockModule { + readonly sqlFamilyEntityTypes: AuthoringEntityTypeNamespace; + readonly sqlFamilyPslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; +} + +export interface ActualPostgresDefaultsModule { + createPostgresDefaultFunctionRegistry(): ControlMutationDefaultRegistry; +} + +export interface ActualPostgresDataTypesModule { + postgresDataTypeEntries(): Readonly>; +} + +export interface ActualSqliteDataTypesModule { + sqliteDataTypeEntries(): Readonly>; +} + +export interface ActualSqlExpressionModule { + readonly sqlExpressionRegistration: { + readonly authoring: { + readonly dataTypes: Readonly>; + }; + }; +} + +export interface ActualMongoAttributeModule { + readonly mongoAttributeSpecs: AttributeSpecNamespace; +} + +export interface ActualMongoBlockModule { + readonly mongoFamilyEntityTypes: AuthoringEntityTypeNamespace; + readonly mongoFamilyPslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; +} + +export function completeWithSource(input: { + readonly markedSource: string; + readonly siblings?: readonly string[]; + readonly pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; + readonly authoringContributions?: typeof attributeContributions; + readonly controlMutationDefaults?: typeof controlMutationDefaults; + readonly dataTypes?: DataTypeSupport; + readonly clientSupportsSnippets?: boolean; + readonly clientSupportsTriggerSuggestCommand?: boolean; + readonly clientSupportsTriggerParameterHintsCommand?: boolean; + readonly scalarTypes?: readonly string[]; + readonly generatorFunctions?: GeneratorFunctionRegistry; +}) { + const cursorOffset = input.markedSource.indexOf('|'); + expect(cursorOffset).toBeGreaterThanOrEqual(0); + const source = `${input.markedSource.slice(0, cursorOffset)}${input.markedSource.slice(cursorOffset + 1)}`; + const parsed = parse(source, 'language-server-test.psl'); + const { document } = parsed; + const others = (input.siblings ?? []).map((text, index) => parse(text, `sibling-${index}.psl`)); + const sources = parsed.sources.merge(...others.map((other) => other.sources)); + const sourceFile = sources.sourceFileFor(document.syntax); + const { symbolTable } = buildSymbolTable({ + documents: [...others.map((other) => other.document), document], + sources, + defaultNamespaceId: 'public', + }); + const context = classifyPslCompletionContext({ + document, + sourceFile, + position: sourceFile.positionAt(cursorOffset), + }); + + return { + items: providePslCompletionItems({ + context, + sourceFile, + candidates: { + binder: testBinder({ + ...input, + sources, + symbolTable, + scalarTypes: input.scalarTypes ?? scalarTypes, + }), + scalarTypes: input.scalarTypes ?? scalarTypes, + pslBlockDescriptors: input.pslBlockDescriptors, + symbolTable, + ...(input.authoringContributions === undefined + ? {} + : { authoringContributions: input.authoringContributions }), + ...(input.controlMutationDefaults === undefined + ? {} + : { controlMutationDefaults: input.controlMutationDefaults }), + ...(input.dataTypes === undefined ? {} : { dataTypes: input.dataTypes }), + generatorFunctions: input.generatorFunctions ?? EMPTY_GENERATOR_FUNCTIONS, + }, + clientSupportsSnippets: input.clientSupportsSnippets === true, + clientSupportsTriggerSuggestCommand: input.clientSupportsTriggerSuggestCommand === true, + clientSupportsTriggerParameterHintsCommand: + input.clientSupportsTriggerParameterHintsCommand === true, + }), + sourceFile, + cursorOffset, + }; +} + +export async function actualSqlStack(): Promise { + const [attributes, blocks] = await Promise.all([ + importFromPackageRoot( + '../../../2-sql-family/2-authoring/contract-psl/src/sql-attribute-specs.ts', + ), + importFromPackageRoot( + '../../../2-sql-family/9-family/src/core/authoring-entity-types.ts', + ), + ]); + return { + attributeSpecs: attributes.sqlAttributeSpecs, + entityTypes: blocks.sqlFamilyEntityTypes, + pslBlockDescriptors: blocks.sqlFamilyPslBlockDescriptors, + }; +} + +export async function actualMongoStack(): Promise { + const [attributes, blocks] = await Promise.all([ + importFromPackageRoot( + '../../../2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts', + ), + importFromPackageRoot( + '../../../2-mongo-family/9-family/src/core/authoring-entity-types.ts', + ), + ]); + return { + attributeSpecs: attributes.mongoAttributeSpecs, + entityTypes: blocks.mongoFamilyEntityTypes, + pslBlockDescriptors: blocks.mongoFamilyPslBlockDescriptors, + }; +} + +export const fixtureInt = dataType('fixture/int', { read: (json) => json }); +export const fixtureExpression = dataType('fixture/expression', { read: (json) => json }); +export const constructorArgumentDataTypes: DataTypeSupport = { + entries: { + [fixtureInt.id]: { + written: { + kind: 'plain', + syntax: 'number', + types: [fixtureInt.id], + classify: (text) => ({ type: fixtureInt.id, value: Number(text) }), + }, + print: String, + documentation: 'A whole number.', + }, + [fixtureExpression.id]: { + written: { kind: 'tag', tag: 'sql', parse: (text) => text }, + print: String, + documentation: 'A SQL expression.', + }, + }, + lookup: createDataTypeLookup([fixtureInt, fixtureExpression]), + dataTypeOfCodec: () => undefined, +}; + +export async function sqlExpressionDataTypes(): Promise< + Readonly> +> { + const family = await importFromPackageRoot( + '../../../2-sql-family/1-core/contract/src/exports/sql-expression.ts', + ); + return family.sqlExpressionRegistration.authoring.dataTypes; +} + +export async function importFromPackageRoot(relativePath: string): Promise { + return (await import(pathToFileURL(resolve(packageRoot, relativePath)).href)) as T; +} + +export function actualAuthoringContributions( + stack: CompletionTestStack, +): typeof attributeContributions { + return assembleAuthoringContributions([ + { + id: 'actual-family', + authoring: { + attributeSpecs: stack.attributeSpecs, + entityTypes: stack.entityTypes, + pslBlockDescriptors: stack.pslBlockDescriptors, + }, + }, + ]); +} + +export function completeWithActualStack( + markedSource: string, + stack: CompletionTestStack, + options: { + readonly clientSupportsSnippets?: boolean; + readonly controlMutationDefaults?: typeof controlMutationDefaults; + readonly dataTypes?: Readonly>; + } = {}, +) { + return completeWithSource({ + markedSource, + pslBlockDescriptors: stack.pslBlockDescriptors, + authoringContributions: actualAuthoringContributions(stack), + ...(options.dataTypes === undefined + ? {} + : { + dataTypes: { + entries: options.dataTypes, + lookup: createDataTypeLookup([]), + dataTypeOfCodec: () => undefined, + }, + }), + controlMutationDefaults: options.controlMutationDefaults ?? controlMutationDefaults, + clientSupportsSnippets: options.clientSupportsSnippets === true, + }); +} + +export function applyCompletionItem(input: { + readonly sourceFile: SourceFile; + readonly item: CompletionItem; +}) { + const edit = input.item.textEdit; + if (edit === undefined || !('range' in edit)) { + throw new Error('Expected a range text edit'); + } + const start = input.sourceFile.offsetAt(edit.range.start); + const end = input.sourceFile.offsetAt(edit.range.end); + return `${input.sourceFile.text.slice(0, start)}${edit.newText}${input.sourceFile.text.slice(end)}`; +} + +export function completionItemByLabel( + items: readonly CompletionItem[], + label: string, +): CompletionItem { + const item = items.find((candidate) => candidate.label === label); + if (item === undefined) { + throw new Error(`Expected completion item "${label}"`); + } + return item; +} diff --git a/packages/1-framework/3-tooling/language-server/test/helpers/hover.ts b/packages/1-framework/3-tooling/language-server/test/helpers/hover.ts new file mode 100644 index 000000000000..7a4a5d553962 --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/helpers/hover.ts @@ -0,0 +1,283 @@ +import type { + AuthoringPslBlockDescriptorNamespace, + DataTypeSupport, +} from '@internal/framework-components/authoring'; +import { createDataTypeLookup, dataType } from '@internal/framework-components/codec'; +import { assembleAuthoringContributions } from '@internal/framework-components/control'; +import { + type ArgType, + type AttributeCtx, + blockAttribute, + bool, + buildSymbolTable, + entityRef, + fieldAttribute, + fieldRef, + funcCall, + identifier, + list, + modelAttribute, + numLiteral, + oneOf, + optional, + str, + structBlock, +} from '@internal/psl-parser'; +import { parse } from '@internal/psl-parser/syntax'; +import { ok } from '@internal/utils/result'; +import { expect } from 'vitest'; +import { providePslHover } from '../../src/hover'; +import { testBinder } from './binder'; + +const relatesTo = fieldAttribute('relatesTo', { + documentation: '', + named: { fields: { type: fieldRef(), documentation: '' } }, +}); +const guardedBy = modelAttribute('guardedBy', { + documentation: '', + named: { policy: { type: entityRef({ kind: 'block', keyword: 'policy' }), documentation: '' } }, +}); +const relationSpec = fieldAttribute('relation', { + documentation: 'Declares a relation.', + named: { + name: { type: optional(str()), documentation: 'An explicit relation name.' }, + }, +}); +const incompleteKeySpec = fieldAttribute('incompleteKey', { + documentation: '', + named: { + references: { type: optional(str()), documentation: 'Referenced fields.' }, + }, +}); +const onDeleteSpec = fieldAttribute('onDeleteExample', { + documentation: '', + named: { + onDelete: { + type: oneOf( + identifier('NoAction', { documentation: 'Takes no action.' }), + identifier('Restrict', { documentation: 'Rejects the delete.' }), + identifier('Cascade', { documentation: 'Cascades the delete.' }), + ), + documentation: '', + }, + }, +}); +const indexSpec = modelAttribute('index', { + documentation: 'Declares an index.', + named: { fields: { type: list(str()), documentation: 'Indexed field names.' } }, +}); +const autoincrementFunc = funcCall('autoincrement', { + documentation: 'Generates sequential integers.', +}); +const dbgeneratedFunc = funcCall('dbgenerated', { + documentation: 'Lets the database generate the value.', + named: { expr: { type: str(), documentation: 'The database expression.' } }, +}); +const defaultSpec = fieldAttribute('default', { + documentation: '', + positional: [ + { key: 'value', type: oneOf(autoincrementFunc, dbgeneratedFunc), documentation: '' }, + ], +}); +function nullLiteral(): ArgType { + const nullIdentifier = identifier('null', { documentation: 'A null value.' }); + return { + kind: 'null', + label: 'null', + parse: (arg, ctx) => { + const result = nullIdentifier.parse(arg, ctx); + return result.ok ? ok(null) : result; + }, + }; +} +const autoincrementRealOrderFunc = funcCall('autoincrement', { + documentation: 'Generates sequential integers.', +}); +const defaultRealOrderSpec = fieldAttribute('default', { + documentation: '', + positional: [ + { + key: 'value', + type: oneOf(str(), numLiteral(), bool(), nullLiteral(), autoincrementRealOrderFunc), + documentation: '', + }, + ], +}); +const someBlockAttribute = blockAttribute('someBlockAttribute', { + documentation: 'Does something.', + positional: [{ key: 'value', type: str(), documentation: 'The value.' }], +}); +const authoringContributions = assembleAuthoringContributions([ + { + id: 'hover-fixture', + authoring: { + attributeSpecs: { + field: { + relatesTo: () => relatesTo, + relation: () => relationSpec, + incompleteKey: () => incompleteKeySpec, + onDeleteExample: () => onDeleteSpec, + default: () => defaultSpec, + defaultRealOrder: () => defaultRealOrderSpec, + }, + model: { guardedBy: () => guardedBy, index: () => indexSpec }, + }, + type: { + pg: { + Varchar: { + kind: 'typeConstructor', + documentation: 'A variable-length string.', + args: [{ name: 'length', kind: 'value', dataType: 'fixture/int' }], + output: { codecId: 'fixture/varchar' }, + }, + Text: { + kind: 'typeConstructor', + output: { codecId: 'fixture/text' }, + }, + Raw: { + kind: 'typeConstructor', + documentation: 'A type written as SQL.', + args: [{ name: 'sql', kind: 'value', dataType: 'fixture/expression' }], + output: { codecId: 'fixture/raw' }, + }, + Flag: { + kind: 'typeConstructor', + documentation: '', + output: { codecId: 'fixture/flag' }, + }, + Moment: { + kind: 'typeChoice', + documentation: 'A moment in time.', + args: [ + { + kind: 'option', + name: 'as', + values: ['date', 'text'], + default: 'date', + optional: true, + }, + { + kind: 'option', + name: 'storage', + values: ['wide', 'narrow'], + default: 'wide', + optional: true, + }, + { kind: 'value', name: 'precision', dataType: 'fixture/int', optional: true }, + ], + choices: [ + { + when: { as: 'date', storage: 'wide' }, + type: 'Stamp', + defaults: { precision: 3 }, + }, + { when: { as: 'text', storage: 'wide' }, type: 'Text' }, + { when: { as: 'date', storage: 'narrow' }, type: 'Flag' }, + ], + }, + Shape: { + kind: 'typeChoice', + documentation: '', + args: [ + { + kind: 'option', + name: 'as', + values: ['raw', 'huge'], + default: 'raw', + optional: true, + }, + { kind: 'value', name: 'sql', dataType: 'fixture/expression', optional: true }, + { kind: 'value', name: 'precision', dataType: 'fixture/int', optional: true }, + ], + choices: [ + { when: { as: 'raw' }, type: 'Raw', defaults: { sql: 'text' } }, + { when: { as: 'huge' }, type: 'Stamp', defaults: { precision: { digits: 10 } } }, + ], + }, + Stamp: { + kind: 'typeConstructor', + args: [{ name: 'precision', kind: 'value', dataType: 'fixture/int', optional: true }], + output: { codecId: 'fixture/stamp' }, + }, + CreatedAt: { + kind: 'typeAlias', + documentation: 'Set to now when a row is created.', + output: { type: { codecId: 'fixture/text' }, onCreate: { name: 'now', args: {} } }, + }, + }, + }, + }, + }, +]); +const pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace = { + policy: { + kind: 'pslBlock', + documentation: 'Row-level access rule.', + keyword: 'policy', + discriminator: 'hover-policy', + name: { required: true }, + spec: () => + structBlock({ + parameters: { + using: { type: str(), documentation: 'The policy filter expression.' }, + }, + }), + attributes: { someBlockAttribute: () => someBlockAttribute }, + }, + view: { + kind: 'pslBlock', + keyword: 'view', + discriminator: 'hover-view', + name: { required: true }, + spec: () => structBlock({ parameters: {} }), + }, +}; + +const fixtureInt = dataType('fixture/int', { read: (json) => json }); +const fixtureExpression = dataType('fixture/expression', { read: (json) => json }); +const dataTypes: DataTypeSupport = { + entries: { + [fixtureInt.id]: { + written: { + kind: 'plain', + syntax: 'number', + types: [fixtureInt.id], + classify: (text) => ({ type: fixtureInt.id, value: Number(text) }), + }, + print: String, + documentation: 'A whole number.', + }, + [fixtureExpression.id]: { + written: { kind: 'tag', tag: 'sql', parse: (text) => text }, + print: String, + documentation: 'A SQL expression.', + }, + }, + lookup: createDataTypeLookup([fixtureInt, fixtureExpression]), + dataTypeOfCodec: () => undefined, +}; + +export function hover(markedSource: string, siblings: readonly string[] = []) { + const offset = markedSource.indexOf('|'); + expect(offset).toBeGreaterThanOrEqual(0); + const source = markedSource.slice(0, offset) + markedSource.slice(offset + 1); + const parsed = parse(source, 'language-server-test.psl'); + const { document } = parsed; + const others = siblings.map((text, index) => parse(text, `sibling-${index}.psl`)); + const sources = parsed.sources.merge(...others.map((other) => other.sources)); + const sourceFile = sources.sourceFileFor(document.syntax); + const { symbolTable } = buildSymbolTable({ + documents: [...others.map((other) => other.document), document], + sources, + defaultNamespaceId: 'public', + }); + return providePslHover({ + document, + sourceFile, + position: sourceFile.positionAt(offset), + binder: testBinder({ sources, symbolTable, authoringContributions, pslBlockDescriptors }), + pslBlockDescriptors, + dataTypes, + sources, + }); +} diff --git a/packages/1-framework/3-tooling/language-server/test/helpers/reference-fixtures.ts b/packages/1-framework/3-tooling/language-server/test/helpers/reference-fixtures.ts index 0ccee50538e7..be92fb0445c3 100644 --- a/packages/1-framework/3-tooling/language-server/test/helpers/reference-fixtures.ts +++ b/packages/1-framework/3-tooling/language-server/test/helpers/reference-fixtures.ts @@ -26,7 +26,12 @@ import { structBlock, } from '@internal/psl-parser'; import { type ResolvedFormatOptions, resolveFormatOptions } from '@internal/psl-parser/format'; -import { type DocumentAst, parse, type SourceFile } from '@internal/psl-parser/syntax'; +import { + type DocumentAst, + type PslSources, + parse, + type SourceFile, +} from '@internal/psl-parser/syntax'; import type { Position } from 'vscode-languageserver'; import type { ReferencesDocument } from '../../src/references'; import { testBinder } from './binder'; @@ -260,7 +265,7 @@ function project(files: Files, stack: FixtureStack) { sourceFile: file.sources.sourceFileFor(file.document.syntax), }), ); - return { documents, binder, symbolTable }; + return { documents, binder, symbolTable, sources }; } export interface FixtureCursorInput extends FixtureStack { @@ -274,6 +279,7 @@ export interface FixtureCursorInput extends FixtureStack { readonly generatorFunctions: GeneratorFunctionRegistry; readonly formatOptions: ResolvedFormatOptions; readonly dataTypes: DataTypeSupport; + readonly sources: PslSources; } export function cursorInput( @@ -282,7 +288,7 @@ export function cursorInput( marked: string, stack: FixtureStack = fixtureStack, ): FixtureCursorInput { - const { documents, binder, symbolTable } = project(files, stack); + const { documents, binder, symbolTable, sources } = project(files, stack); const current = documents.find((document) => document.sourceFile.filename === name); if (current === undefined) throw new Error(`no file ${name}`); const needle = marked.replace('|', ''); @@ -303,5 +309,6 @@ export function cursorInput( generatorFunctions: EMPTY_GENERATOR_FUNCTIONS, formatOptions: resolveFormatOptions(undefined), dataTypes: EMPTY_DATA_TYPES, + sources, }; } diff --git a/packages/1-framework/3-tooling/language-server/test/hover.attributes.test.ts b/packages/1-framework/3-tooling/language-server/test/hover.attributes.test.ts new file mode 100644 index 000000000000..dc83368118f7 --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/hover.attributes.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { hover } from './helpers/hover'; + +describe('providePslHover', () => { + it('shows a field-attribute signature label and its documentation', () => { + const result = hover('model User {\n id Int\n author User @relat|ion\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\n@relation(name?: string)\n```\n\nDeclares a relation.', + }, + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 23 } }, + }); + }); + + it('shows a model-attribute signature label and its documentation', () => { + const result = hover('model User {\n id Int\n @@ind|ex\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\n@@index(fields: string[])\n```\n\nDeclares an index.', + }, + range: { start: { line: 2, character: 4 }, end: { line: 2, character: 9 } }, + }); + }); + + it('omits the documentation section when spec.documentation is empty', () => { + const result = hover('model User {\n id Int\n author User @relates|To(fields: id)\n}'); + expect(result).toEqual({ + contents: { kind: 'markdown', value: '```prisma\n@relatesTo(fields: field name)\n```' }, + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 24 } }, + }); + }); + + it('shows a block keyword with documentation', () => { + const result = hover('polic|y ReadOwn {\n}'); + expect(result).toEqual({ + contents: { kind: 'markdown', value: 'Row-level access rule.' }, + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 6 } }, + }); + }); + + it('returns null for a block keyword without documentation', () => { + const result = hover('vie|w Summary {\n}'); + expect(result).toBeNull(); + }); + + it('shows a named attribute-argument key and its documentation', () => { + const result = hover('model User {\n author User @relation(nam|e: "x")\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\nname?: string\n```\n\nAn explicit relation name.', + }, + range: { start: { line: 1, character: 24 }, end: { line: 1, character: 28 } }, + }); + }); + + it('shows a named attribute-argument key while its value is missing', () => { + const result = hover('model User {\n author User @incompleteKey(referen|ces: )\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\nreferences?: string\n```\n\nReferenced fields.', + }, + range: { start: { line: 1, character: 29 }, end: { line: 1, character: 39 } }, + }); + }); + + it('omits the documentation section for a parameter with empty documentation', () => { + const result = hover('model User {\n id Int\n author User @relatesTo(field|s: id)\n}'); + expect(result).toEqual({ + contents: { kind: 'markdown', value: '```prisma\nfields: field name\n```' }, + range: { start: { line: 2, character: 25 }, end: { line: 2, character: 31 } }, + }); + }); + + it('shows a struct-block entry key and its documentation', () => { + const result = hover('policy ReadOwn {\n usi|ng = "x"\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\nusing: string\n```\n\nThe policy filter expression.', + }, + range: { start: { line: 1, character: 2 }, end: { line: 1, character: 7 } }, + }); + }); + + it('shows a function name without args and its documentation', () => { + const result = hover('model User {\n id Int @default(autoincreme|nt())\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\nautoincrement()\n```\n\nGenerates sequential integers.', + }, + range: { start: { line: 1, character: 18 }, end: { line: 1, character: 31 } }, + }); + }); + + it('shows a function name past leading scalar leaves, in the real @default arm order', () => { + const result = hover('model User {\n id Int @defaultRealOrder(autoincreme|nt())\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\nautoincrement()\n```\n\nGenerates sequential integers.', + }, + range: { start: { line: 1, character: 27 }, end: { line: 1, character: 40 } }, + }); + }); + + it('shows a function name with args and its documentation', () => { + const result = hover('model User {\n value String @default(dbgenerat|ed(expr: "now()"))\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\ndbgenerated(expr: string)\n```\n\nLets the database generate the value.', + }, + range: { start: { line: 1, character: 24 }, end: { line: 1, character: 35 } }, + }); + }); + + it('shows a named function-call-argument key and its documentation', () => { + const result = hover('model User {\n value String @default(dbgenerated(ex|pr: "now()"))\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\nexpr: string\n```\n\nThe database expression.', + }, + range: { start: { line: 1, character: 36 }, end: { line: 1, character: 40 } }, + }); + }); + + it('shows a constant not first in a oneOf and its documentation', () => { + const result = hover('model User {\n author User @onDeleteExample(onDelete: Casc|ade)\n}'); + expect(result).toEqual({ + contents: { kind: 'markdown', value: '```prisma\nCascade\n```\n\nCascades the delete.' }, + range: { start: { line: 1, character: 41 }, end: { line: 1, character: 48 } }, + }); + }); + + it('shows a block attribute and its documentation', () => { + const result = hover('policy ReadOwn {\n @@someBlockAttri|bute("x")\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\n@@someBlockAttribute(string)\n```\n\nDoes something.', + }, + range: { start: { line: 1, character: 4 }, end: { line: 1, character: 22 } }, + }); + }); +}); diff --git a/packages/1-framework/3-tooling/language-server/test/hover.contributed-types.test.ts b/packages/1-framework/3-tooling/language-server/test/hover.contributed-types.test.ts new file mode 100644 index 000000000000..59862a268d13 --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/hover.contributed-types.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { hover } from './helpers/hover'; + +describe('providePslHover', () => { + it('shows a contributed type with documentation and args', () => { + const result = hover('model Product {\n price pg.Varc|har(255)\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\npg.Varchar(length: a number)\n```\n\nA variable-length string.', + }, + range: { start: { line: 1, character: 11 }, end: { line: 1, character: 18 } }, + }); + }); + + it('shows a contributed type alias with its documentation', () => { + const result = hover('model Product {\n createdAt pg.Created|At\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\npg.CreatedAt\n```\n\nSet to now when a row is created.', + }, + range: { start: { line: 1, character: 15 }, end: { line: 1, character: 24 } }, + }); + }); + + it('shows a contributed type without documentation as the bare path', () => { + const result = hover('model Product {\n label pg.Te|xt\n}'); + expect(result).toEqual({ + contents: { kind: 'markdown', value: '```prisma\npg.Text\n```' }, + range: { start: { line: 1, character: 11 }, end: { line: 1, character: 15 } }, + }); + }); + + it('omits the documentation section when descriptor.documentation is empty', () => { + const result = hover('model Product {\n flag pg.Fl|ag\n}'); + expect(result).toEqual({ + contents: { kind: 'markdown', value: '```prisma\npg.Flag\n```' }, + range: { start: { line: 1, character: 10 }, end: { line: 1, character: 14 } }, + }); + }); + + it('shows a value argument by the forms its data type is written in', () => { + const result = hover('model Product {\n labels pg.Ra|w(sql`text`)\n}'); + expect(result).toEqual({ + contents: { + kind: 'markdown', + value: '```prisma\npg.Raw(sql: sql`...`)\n```\n\nA type written as SQL.', + }, + range: { start: { line: 1, character: 12 }, end: { line: 1, character: 15 } }, + }); + }); + + it('shows a type choice with its choices as written and the explicit type each becomes', () => { + const result = hover('model Product {\n at pg.Mo|ment(as: text)\n}'); + expect(result?.contents).toEqual({ + kind: 'markdown', + value: [ + '```prisma', + 'pg.Moment(as?: date | text, storage?: wide | narrow, precision?: a number)', + '```', + '', + '`pg.Moment(as: text)` is exactly `pg.Text`.', + '', + 'A moment in time.', + '', + '| as | storage | Becomes |', + '| --- | --- | --- |', + '| date (default) | wide (default) | `pg.Stamp(precision: 3)` |', + '| text | wide (default) | `pg.Text` |', + '| date (default) | narrow | `pg.Flag` |', + ].join('\n'), + }); + }); + + it('shows the explicit type a bare type choice is, with the arguments its row defaults', () => { + const value = hover('model Product {\n at pg.Mo|ment\n}')?.contents; + expect(value).toMatchObject({ + value: expect.stringContaining('`pg.Moment` is exactly `pg.Stamp(precision: 3)`.'), + }); + }); + + it('shows the explicit type with the arguments a call hands on', () => { + const value = hover('model Product {\n at pg.Mo|ment(precision: 6)\n}')?.contents; + expect(value).toMatchObject({ + value: expect.stringContaining( + '`pg.Moment(precision: 6)` is exactly `pg.Stamp(precision: 6)`.', + ), + }); + }); + + it('names no explicit type for a call whose combination has no row', () => { + const value = hover('model Product {\n at pg.Mo|ment(as: text, storage: narrow)\n}')?.contents; + expect(value).toMatchObject({ value: expect.not.stringContaining('is exactly') }); + }); + + it('writes each argument of the explicit type as a literal of its data type', () => { + const value = hover('model Product {\n shape pg.Sha|pe\n}')?.contents; + expect(value).toMatchObject({ + value: expect.stringContaining('`pg.Shape` is exactly `` pg.Raw(sql: sql`text`) ``.'), + }); + }); + + it('names no explicit type, and still hovers, when an argument has no literal', () => { + const value = hover('model Product {\n shape pg.Sha|pe(as: huge)\n}')?.contents; + expect(value).toMatchObject({ value: expect.not.stringContaining('is exactly') }); + expect(value).toMatchObject({ value: expect.stringContaining('| huge | `pg.Stamp(...)` |') }); + }); +}); diff --git a/packages/1-framework/3-tooling/language-server/test/hover.test.ts b/packages/1-framework/3-tooling/language-server/test/hover.test.ts index c3f1f66da87d..d64f64ee1537 100644 --- a/packages/1-framework/3-tooling/language-server/test/hover.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/hover.test.ts @@ -1,231 +1,5 @@ -import type { - AuthoringPslBlockDescriptorNamespace, - DataTypeSupport, -} from '@internal/framework-components/authoring'; -import { createDataTypeLookup, dataType } from '@internal/framework-components/codec'; -import { assembleAuthoringContributions } from '@internal/framework-components/control'; -import { - type ArgType, - type AttributeCtx, - blockAttribute, - bool, - buildSymbolTable, - entityRef, - fieldAttribute, - fieldRef, - funcCall, - identifier, - list, - modelAttribute, - numLiteral, - oneOf, - optional, - str, - structBlock, -} from '@internal/psl-parser'; -import { parse } from '@internal/psl-parser/syntax'; -import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; -import { providePslHover } from '../src/hover'; -import { testBinder } from './helpers/binder'; - -const relatesTo = fieldAttribute('relatesTo', { - documentation: '', - named: { fields: { type: fieldRef(), documentation: '' } }, -}); -const guardedBy = modelAttribute('guardedBy', { - documentation: '', - named: { policy: { type: entityRef({ kind: 'block', keyword: 'policy' }), documentation: '' } }, -}); -const relationSpec = fieldAttribute('relation', { - documentation: 'Declares a relation.', - named: { - name: { type: optional(str()), documentation: 'An explicit relation name.' }, - }, -}); -const incompleteKeySpec = fieldAttribute('incompleteKey', { - documentation: '', - named: { - references: { type: optional(str()), documentation: 'Referenced fields.' }, - }, -}); -const onDeleteSpec = fieldAttribute('onDeleteExample', { - documentation: '', - named: { - onDelete: { - type: oneOf( - identifier('NoAction', { documentation: 'Takes no action.' }), - identifier('Restrict', { documentation: 'Rejects the delete.' }), - identifier('Cascade', { documentation: 'Cascades the delete.' }), - ), - documentation: '', - }, - }, -}); -const indexSpec = modelAttribute('index', { - documentation: 'Declares an index.', - named: { fields: { type: list(str()), documentation: 'Indexed field names.' } }, -}); -const autoincrementFunc = funcCall('autoincrement', { - documentation: 'Generates sequential integers.', -}); -const dbgeneratedFunc = funcCall('dbgenerated', { - documentation: 'Lets the database generate the value.', - named: { expr: { type: str(), documentation: 'The database expression.' } }, -}); -const defaultSpec = fieldAttribute('default', { - documentation: '', - positional: [ - { key: 'value', type: oneOf(autoincrementFunc, dbgeneratedFunc), documentation: '' }, - ], -}); -function nullLiteral(): ArgType { - const nullIdentifier = identifier('null', { documentation: 'A null value.' }); - return { - kind: 'null', - label: 'null', - parse: (arg, ctx) => { - const result = nullIdentifier.parse(arg, ctx); - return result.ok ? ok(null) : result; - }, - }; -} -const autoincrementRealOrderFunc = funcCall('autoincrement', { - documentation: 'Generates sequential integers.', -}); -const defaultRealOrderSpec = fieldAttribute('default', { - documentation: '', - positional: [ - { - key: 'value', - type: oneOf(str(), numLiteral(), bool(), nullLiteral(), autoincrementRealOrderFunc), - documentation: '', - }, - ], -}); -const someBlockAttribute = blockAttribute('someBlockAttribute', { - documentation: 'Does something.', - positional: [{ key: 'value', type: str(), documentation: 'The value.' }], -}); -const authoringContributions = assembleAuthoringContributions([ - { - id: 'hover-fixture', - authoring: { - attributeSpecs: { - field: { - relatesTo: () => relatesTo, - relation: () => relationSpec, - incompleteKey: () => incompleteKeySpec, - onDeleteExample: () => onDeleteSpec, - default: () => defaultSpec, - defaultRealOrder: () => defaultRealOrderSpec, - }, - model: { guardedBy: () => guardedBy, index: () => indexSpec }, - }, - type: { - pg: { - Varchar: { - kind: 'typeConstructor', - documentation: 'A variable-length string.', - args: [{ name: 'length', kind: 'value', dataType: 'fixture/int' }], - output: { codecId: 'fixture/varchar' }, - }, - Text: { - kind: 'typeConstructor', - output: { codecId: 'fixture/text' }, - }, - Raw: { - kind: 'typeConstructor', - documentation: 'A type written as SQL.', - args: [{ name: 'sql', kind: 'value', dataType: 'fixture/expression' }], - output: { codecId: 'fixture/raw' }, - }, - Flag: { - kind: 'typeConstructor', - documentation: '', - output: { codecId: 'fixture/flag' }, - }, - CreatedAt: { - kind: 'typeAlias', - documentation: 'Set to now when a row is created.', - output: { type: { codecId: 'fixture/text' }, onCreate: { name: 'now', args: {} } }, - }, - }, - }, - }, - }, -]); -const pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace = { - policy: { - kind: 'pslBlock', - documentation: 'Row-level access rule.', - keyword: 'policy', - discriminator: 'hover-policy', - name: { required: true }, - spec: () => - structBlock({ - parameters: { - using: { type: str(), documentation: 'The policy filter expression.' }, - }, - }), - attributes: { someBlockAttribute: () => someBlockAttribute }, - }, - view: { - kind: 'pslBlock', - keyword: 'view', - discriminator: 'hover-view', - name: { required: true }, - spec: () => structBlock({ parameters: {} }), - }, -}; - -const fixtureInt = dataType('fixture/int', { read: (json) => json }); -const fixtureExpression = dataType('fixture/expression', { read: (json) => json }); -const dataTypes: DataTypeSupport = { - entries: { - [fixtureInt.id]: { - written: { - kind: 'plain', - syntax: 'number', - types: [fixtureInt.id], - classify: (text) => ({ type: fixtureInt.id, value: Number(text) }), - }, - print: String, - documentation: 'A whole number.', - }, - [fixtureExpression.id]: { - written: { kind: 'tag', tag: 'sql', parse: (text) => text }, - print: String, - documentation: 'A SQL expression.', - }, - }, - lookup: createDataTypeLookup([fixtureInt, fixtureExpression]), - dataTypeOfCodec: () => undefined, -}; - -function hover(markedSource: string, siblings: readonly string[] = []) { - const offset = markedSource.indexOf('|'); - expect(offset).toBeGreaterThanOrEqual(0); - const source = markedSource.slice(0, offset) + markedSource.slice(offset + 1); - const parsed = parse(source, 'language-server-test.psl'); - const { document } = parsed; - const others = siblings.map((text, index) => parse(text, `sibling-${index}.psl`)); - const sources = parsed.sources.merge(...others.map((other) => other.sources)); - const sourceFile = sources.sourceFileFor(document.syntax); - const { symbolTable } = buildSymbolTable({ - documents: [...others.map((other) => other.document), document], - sources, - defaultNamespaceId: 'public', - }); - return providePslHover({ - document, - sourceFile, - position: sourceFile.positionAt(offset), - binder: testBinder({ sources, symbolTable, authoringContributions, pslBlockDescriptors }), - pslBlockDescriptors, - dataTypes, - }); -} +import { hover } from './helpers/hover'; describe('providePslHover', () => { it('shows a model declaration line and doc at a reference', () => { @@ -452,200 +226,4 @@ describe('providePslHover', () => { const result = hover('model User {| id Int\n}'); expect(result).toBeNull(); }); - - it('shows a field-attribute signature label and its documentation', () => { - const result = hover('model User {\n id Int\n author User @relat|ion\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\n@relation(name?: string)\n```\n\nDeclares a relation.', - }, - range: { start: { line: 2, character: 15 }, end: { line: 2, character: 23 } }, - }); - }); - - it('shows a model-attribute signature label and its documentation', () => { - const result = hover('model User {\n id Int\n @@ind|ex\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\n@@index(fields: string[])\n```\n\nDeclares an index.', - }, - range: { start: { line: 2, character: 4 }, end: { line: 2, character: 9 } }, - }); - }); - - it('omits the documentation section when spec.documentation is empty', () => { - const result = hover('model User {\n id Int\n author User @relates|To(fields: id)\n}'); - expect(result).toEqual({ - contents: { kind: 'markdown', value: '```prisma\n@relatesTo(fields: field name)\n```' }, - range: { start: { line: 2, character: 15 }, end: { line: 2, character: 24 } }, - }); - }); - - it('shows a contributed type with documentation and args', () => { - const result = hover('model Product {\n price pg.Varc|har(255)\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\npg.Varchar(length: a number)\n```\n\nA variable-length string.', - }, - range: { start: { line: 1, character: 11 }, end: { line: 1, character: 18 } }, - }); - }); - - it('shows a contributed type alias with its documentation', () => { - const result = hover('model Product {\n createdAt pg.Created|At\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\npg.CreatedAt\n```\n\nSet to now when a row is created.', - }, - range: { start: { line: 1, character: 15 }, end: { line: 1, character: 24 } }, - }); - }); - - it('shows a contributed type without documentation as the bare path', () => { - const result = hover('model Product {\n label pg.Te|xt\n}'); - expect(result).toEqual({ - contents: { kind: 'markdown', value: '```prisma\npg.Text\n```' }, - range: { start: { line: 1, character: 11 }, end: { line: 1, character: 15 } }, - }); - }); - - it('omits the documentation section when descriptor.documentation is empty', () => { - const result = hover('model Product {\n flag pg.Fl|ag\n}'); - expect(result).toEqual({ - contents: { kind: 'markdown', value: '```prisma\npg.Flag\n```' }, - range: { start: { line: 1, character: 10 }, end: { line: 1, character: 14 } }, - }); - }); - - it('shows a value argument by the forms its data type is written in', () => { - const result = hover('model Product {\n labels pg.Ra|w(sql`text`)\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\npg.Raw(sql: sql`...`)\n```\n\nA type written as SQL.', - }, - range: { start: { line: 1, character: 12 }, end: { line: 1, character: 15 } }, - }); - }); - - it('shows a block keyword with documentation', () => { - const result = hover('polic|y ReadOwn {\n}'); - expect(result).toEqual({ - contents: { kind: 'markdown', value: 'Row-level access rule.' }, - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 6 } }, - }); - }); - - it('returns null for a block keyword without documentation', () => { - const result = hover('vie|w Summary {\n}'); - expect(result).toBeNull(); - }); - - it('shows a named attribute-argument key and its documentation', () => { - const result = hover('model User {\n author User @relation(nam|e: "x")\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\nname?: string\n```\n\nAn explicit relation name.', - }, - range: { start: { line: 1, character: 24 }, end: { line: 1, character: 28 } }, - }); - }); - - it('shows a named attribute-argument key while its value is missing', () => { - const result = hover('model User {\n author User @incompleteKey(referen|ces: )\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\nreferences?: string\n```\n\nReferenced fields.', - }, - range: { start: { line: 1, character: 29 }, end: { line: 1, character: 39 } }, - }); - }); - - it('omits the documentation section for a parameter with empty documentation', () => { - const result = hover('model User {\n id Int\n author User @relatesTo(field|s: id)\n}'); - expect(result).toEqual({ - contents: { kind: 'markdown', value: '```prisma\nfields: field name\n```' }, - range: { start: { line: 2, character: 25 }, end: { line: 2, character: 31 } }, - }); - }); - - it('shows a struct-block entry key and its documentation', () => { - const result = hover('policy ReadOwn {\n usi|ng = "x"\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\nusing: string\n```\n\nThe policy filter expression.', - }, - range: { start: { line: 1, character: 2 }, end: { line: 1, character: 7 } }, - }); - }); - - it('shows a function name without args and its documentation', () => { - const result = hover('model User {\n id Int @default(autoincreme|nt())\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\nautoincrement()\n```\n\nGenerates sequential integers.', - }, - range: { start: { line: 1, character: 18 }, end: { line: 1, character: 31 } }, - }); - }); - - it('shows a function name past leading scalar leaves, in the real @default arm order', () => { - const result = hover('model User {\n id Int @defaultRealOrder(autoincreme|nt())\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\nautoincrement()\n```\n\nGenerates sequential integers.', - }, - range: { start: { line: 1, character: 27 }, end: { line: 1, character: 40 } }, - }); - }); - - it('shows a function name with args and its documentation', () => { - const result = hover('model User {\n value String @default(dbgenerat|ed(expr: "now()"))\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\ndbgenerated(expr: string)\n```\n\nLets the database generate the value.', - }, - range: { start: { line: 1, character: 24 }, end: { line: 1, character: 35 } }, - }); - }); - - it('shows a named function-call-argument key and its documentation', () => { - const result = hover('model User {\n value String @default(dbgenerated(ex|pr: "now()"))\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\nexpr: string\n```\n\nThe database expression.', - }, - range: { start: { line: 1, character: 36 }, end: { line: 1, character: 40 } }, - }); - }); - - it('shows a constant not first in a oneOf and its documentation', () => { - const result = hover('model User {\n author User @onDeleteExample(onDelete: Casc|ade)\n}'); - expect(result).toEqual({ - contents: { kind: 'markdown', value: '```prisma\nCascade\n```\n\nCascades the delete.' }, - range: { start: { line: 1, character: 41 }, end: { line: 1, character: 48 } }, - }); - }); - - it('shows a block attribute and its documentation', () => { - const result = hover('policy ReadOwn {\n @@someBlockAttri|bute("x")\n}'); - expect(result).toEqual({ - contents: { - kind: 'markdown', - value: '```prisma\n@@someBlockAttribute(string)\n```\n\nDoes something.', - }, - range: { start: { line: 1, character: 4 }, end: { line: 1, character: 22 } }, - }); - }); }); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/describe-unresolved-type.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/describe-unresolved-type.ts index bcb55842863d..7e3c2f10028c 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/describe-unresolved-type.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/describe-unresolved-type.ts @@ -39,12 +39,7 @@ export function describeUnresolvedMongoType( const scalars = collectScalarTypeConstructors(types); return ({ field, owner, written, isDeclared }) => { if (field.typeConstructor !== undefined) { - return describeRemovedFieldPreset( - removedMongoFieldPresets, - field.typeConstructor, - types, - isDeclared, - ); + return describeRemovedFieldPreset(removedMongoFieldPresets, field, types, isDeclared); } if (field.typeNamespaceId !== undefined) { return undefined; diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts index 53bf6c4313d3..135e653d9ce1 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts @@ -66,6 +66,7 @@ import { type DiagnosticSource, diagnosticSource, interpretExtensionBlocks, + isContributedTypeChoice, mapPslDiagnostics, nodePslSpan, type PslDiagnosticCollector, @@ -121,6 +122,7 @@ import { type ResolvedTypeAlias, type ResolvedTypeAliases, resolveMongoTypeAliases, + scalarTypeOfChoice, scalarTypeOfConstructor, } from './type-aliases'; @@ -184,6 +186,7 @@ interface ScalarNames { interface FieldResolutionContext { readonly sources: PslSources; readonly diagnostics: PslDiagnosticCollector; + readonly dataTypes: DataTypeSupport; } /** @@ -239,7 +242,11 @@ function resolvePhysicalNames(input: { ...specContext, field, typeResolution: fieldTypeResolution(field, binder), - typeConstructor: contributedTypeOf(fieldTypeResolution(field, binder), binder), + contributedType: contributedTypeOf( + fieldTypeResolution(field, binder), + binder, + field.typeConstructor, + ), }), model, field, @@ -600,7 +607,11 @@ function collectIndexes( ...specContext, field, typeResolution: fieldTypeResolution(field, binder), - typeConstructor: contributedTypeOf(fieldTypeResolution(field, binder), binder), + contributedType: contributedTypeOf( + fieldTypeResolution(field, binder), + binder, + field.typeConstructor, + ), }), model: pslModel, field, @@ -784,6 +795,7 @@ function resolveNonRelationField( context: FieldResolutionContext, scalarNames: ScalarNames, typeAliases: ResolvedTypeAliases, + codecLookup: CodecLookupWithDescriptors, ): ResolvedNonRelationField | undefined { const ownerName = owner.name; if (resolution?.kind === 'compositeType') { @@ -849,10 +861,31 @@ function resolveNonRelationField( if (resolution?.kind !== 'contributedType') { return undefined; } - const { descriptor, path } = resolution.symbol; const call = field.typeConstructor; const entityLabel = `Field "${ownerName}.${field.name}"`; const source = diagnosticSource(context.sources, field.node.syntax); + if (isContributedTypeChoice(resolution.symbol)) { + const type = scalarTypeOfChoice({ + symbol: resolution.symbol, + call, + span: field.span, + codecLookup, + diagnostics: context.diagnostics, + source, + entityLabel, + dataTypes: context.dataTypes, + }); + return type === undefined + ? undefined + : { + field: { + type, + nullable: field.optional, + many: field.list ? { elementNullable: field.elementOptional } : false, + }, + }; + } + const { descriptor, path } = resolution.symbol; if (descriptor.kind === 'typeAlias') { if (call !== undefined) { context.diagnostics.push({ @@ -1014,7 +1047,11 @@ export function interpretPslDocumentToMongoContract( ): Result { const { symbolTable, sources, codecLookup } = input; const diagnostics = createPslDiagnosticCollector(sources); - const fieldResolution: FieldResolutionContext = { sources, diagnostics }; + const fieldResolution: FieldResolutionContext = { + sources, + diagnostics, + dataTypes: input.dataTypes, + }; const scalarNames: ScalarNames = { warnDeprecated: deprecatedScalarWarner({ sources, reportWarning: input.reportWarning }), }; @@ -1106,6 +1143,7 @@ export function interpretPslDocumentToMongoContract( defaultFunctionRegistry: input.defaultFunctionRegistry, generatorFunctions, dataTypes: input.dataTypes, + codecLookup: input.codecLookup, authoringTypes: input.authoringContributions?.type, }); @@ -1156,7 +1194,11 @@ export function interpretPslDocumentToMongoContract( ...specContext, field, typeResolution: fieldTypeResolution(field, binder), - typeConstructor: contributedTypeOf(fieldTypeResolution(field, binder), binder), + contributedType: contributedTypeOf( + fieldTypeResolution(field, binder), + binder, + field.typeConstructor, + ), }), model: pslModel, field, @@ -1218,6 +1260,7 @@ export function interpretPslDocumentToMongoContract( fieldResolution, scalarNames, typeAliases, + codecLookup, ); if ( resolved === undefined || @@ -1242,7 +1285,7 @@ export function interpretPslDocumentToMongoContract( ...specContext, field, typeResolution: resolution, - typeConstructor: contributedTypeOf(resolution, binder), + contributedType: contributedTypeOf(resolution, binder, field.typeConstructor), }, generatorFunctions, sources, @@ -1270,7 +1313,11 @@ export function interpretPslDocumentToMongoContract( ...specContext, field, typeResolution: fieldTypeResolution(field, binder), - typeConstructor: contributedTypeOf(fieldTypeResolution(field, binder), binder), + contributedType: contributedTypeOf( + fieldTypeResolution(field, binder), + binder, + field.typeConstructor, + ), }), model: pslModel, field, @@ -1337,6 +1384,7 @@ export function interpretPslDocumentToMongoContract( fieldResolution, scalarNames, typeAliases, + codecLookup, ); if (resolved === undefined) { droppedFields.add(siteKey); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/type-aliases.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/type-aliases.ts index 56455d72d6d1..0b631343673a 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/type-aliases.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/type-aliases.ts @@ -12,6 +12,7 @@ import { instantiateAuthoringTypeConstructor, resolveTypeAliasGenerators, } from '@internal/framework-components/authoring'; +import type { CodecLookupWithDescriptors } from '@internal/framework-components/codec'; import type { GeneratorFunctionRegistry } from '@internal/framework-components/components'; import type { ControlMutationDefaultRegistry } from '@internal/framework-components/control'; import { @@ -33,12 +34,23 @@ import { typeAttributeSubjectLabel, typeGeneratorAttributeSpec, } from '@internal/psl-parser'; +import { instantiatePslTypeChoice } from '@internal/psl-parser/interpret'; import type { FieldAttributeAst, PslSources } from '@internal/psl-parser/syntax'; import { ifDefined } from '@internal/utils/defined'; import { unloweredAttributeHint } from './mongo-attribute-specs'; type ScalarFieldType = Extract; +/** The field type a use of a type choice gives: the column of the constructor its call chooses, read and refused as a SQL column's is. `undefined`, with a diagnostic pushed, when the call is refused. */ +export function scalarTypeOfChoice( + input: Parameters[0], +): ScalarFieldType | undefined { + const output = instantiatePslTypeChoice(input); + return output === undefined + ? undefined + : { kind: 'scalar', codecId: output.codecId, ...ifDefined('typeParams', output.typeParams) }; +} + /** The field type a type constructor gives, keeping a bare constructor's fixed type params. */ export function scalarTypeOfConstructor( descriptor: AuthoringTypeConstructorDescriptor, @@ -75,6 +87,7 @@ interface ResolveMongoTypeAliasesInput { readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; readonly generatorFunctions: GeneratorFunctionRegistry; readonly dataTypes: DataTypeSupport; + readonly codecLookup: CodecLookupWithDescriptors; readonly authoringTypes: AuthoringTypeNamespace | undefined; } @@ -145,6 +158,19 @@ function aliasType( if (base.kind === 'typeConstructor') { return { type: scalarTypeOfConstructor(base.descriptor, base.call), valueSet: undefined }; } + if (base.kind === 'typeChoice') { + const type = scalarTypeOfChoice({ + symbol: base.symbol, + call: base.call, + span: symbol.span, + codecLookup: input.codecLookup, + diagnostics: input.diagnostics, + source: diagnosticSource(input.sources, symbol.node.syntax), + entityLabel: `Type alias "${symbol.name}"`, + dataTypes: input.dataTypes, + }); + return type === undefined ? undefined : { type, valueSet: undefined }; + } if (base.symbol.keyword !== 'enum') { input.diagnostics.push({ code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE', diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.type-choices.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.type-choices.test.ts new file mode 100644 index 000000000000..3e435804a8a2 --- /dev/null +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.type-choices.test.ts @@ -0,0 +1,98 @@ +import type { + AuthoringContributions, + AuthoringTypeNamespace, +} from '@internal/framework-components/authoring'; +import { describe, expect, it } from 'vitest'; +import { interpretMongoContract } from './interpreter-test-helpers'; + +const label = { kind: 'value', dataType: 'mongo/string', name: 'label', optional: true } as const; + +const type = { + ObjectId: { kind: 'typeConstructor', output: { codecId: 'mongo/objectId@1' } }, + Stamp: { kind: 'typeConstructor', output: { codecId: 'mongo/date@1' } }, + Text: { + kind: 'typeConstructor', + args: [label], + output: { codecId: 'mongo/string@1', typeParams: { label: { kind: 'arg', index: 0 } } }, + }, + Moment: { + kind: 'typeChoice', + args: [ + { kind: 'option', name: 'as', values: ['date', 'text'], default: 'date', optional: true }, + label, + ], + choices: [ + { when: { as: 'date' }, type: 'Stamp' }, + { when: { as: 'text' }, type: 'Text' }, + ], + }, +} as const satisfies AuthoringTypeNamespace; + +const authoringContributions: AuthoringContributions = { type }; + +function interpret(schema: string) { + return interpretMongoContract(schema, { + defaultFunctionRegistry: new Map(), + authoringContributions, + }); +} + +const model = (...fields: readonly string[]) => + ['model Doc {', ' id ObjectId @id @map("_id")', ...fields.map((f) => ` ${f}`), '}', ''].join( + '\n', + ); + +function fieldTypes(schema: string) { + const result = interpret(schema); + if (!result.ok) throw new Error(JSON.stringify(result.failure.diagnostics)); + const fields = result.value.domain.namespaces['__unbound__']?.models['Doc']?.fields ?? {}; + return Object.fromEntries(Object.entries(fields).map(([name, field]) => [name, field.type])); +} + +function diagnostics(schema: string) { + const result = interpret(schema); + if (result.ok) throw new Error('expected the schema to be refused'); + return result.failure.diagnostics.map(({ code, message }) => ({ code, message })); +} + +describe('a MongoDB field typed by a type choice', () => { + it('gets the type of the constructor its call chooses, bare, named and positional', () => { + expect( + fieldTypes(model('bare Moment', 'named Moment(as: text)', 'positional Moment(text, "x")')), + ).toEqual({ + id: { kind: 'scalar', codecId: 'mongo/objectId@1' }, + bare: { kind: 'scalar', codecId: 'mongo/date@1' }, + named: { kind: 'scalar', codecId: 'mongo/string@1' }, + positional: { kind: 'scalar', codecId: 'mongo/string@1', typeParams: { label: 'x' } }, + }); + }); + + it('gets the type its alias’s call chooses', () => { + expect(fieldTypes(`types {\n Note = Moment(as: text)\n}\n${model('note Note')}`)).toEqual({ + id: { kind: 'scalar', codecId: 'mongo/objectId@1' }, + note: { kind: 'scalar', codecId: 'mongo/string@1' }, + }); + }); + + it.each([ + [ + 'an unknown named argument', + 'Moment(bogus: date)', + 'Field "Doc.at" type "Moment" has no argument "bogus"; its arguments are as and label.', + ], + [ + 'a value the option does not list', + 'Moment(as: json)', + 'Field "Doc.at" type "Moment": Argument "as" of Moment is json; write one of: date, text.', + ], + [ + 'an argument the chosen type does not take', + 'Moment(label: "x")', + 'Field "Doc.at" type "Moment": Argument "label" of Moment does not apply to Stamp, the type that as: date chooses.', + ], + ])('refuses %s at the argument', (_case, written, message) => { + expect(diagnostics(model(`at ${written}`))).toEqual([ + { code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', message }, + ]); + }); +}); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts index 322d4b38b154..0f97de937118 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts @@ -115,7 +115,7 @@ function contexts(): { model: AttributeSpecContext; field: FieldAttributeSpecCon }; return { model: modelContext, - field: { ...modelContext, field, typeResolution: undefined, typeConstructor: undefined }, + field: { ...modelContext, field, typeResolution: undefined, contributedType: undefined }, }; } diff --git a/packages/2-sql-family/1-core/contract/src/exports/data-type.ts b/packages/2-sql-family/1-core/contract/src/exports/data-type.ts index 2f0a478bdeb8..0ac630b7a9d6 100644 --- a/packages/2-sql-family/1-core/contract/src/exports/data-type.ts +++ b/packages/2-sql-family/1-core/contract/src/exports/data-type.ts @@ -1,5 +1,6 @@ export type { ResolvedSqlType, + ResolvedWrittenSqlType, SqlColumnTypeFields, SqlDataType, SqlDataTypeCollision, diff --git a/packages/2-sql-family/1-core/contract/src/sql-data-type.ts b/packages/2-sql-family/1-core/contract/src/sql-data-type.ts index 8905e209dd7a..836c636cb462 100644 --- a/packages/2-sql-family/1-core/contract/src/sql-data-type.ts +++ b/packages/2-sql-family/1-core/contract/src/sql-data-type.ts @@ -53,6 +53,8 @@ export interface SqlDataTypeSpec /** The id of the type a column of this type is stored as, with the same parameters. A type stored as another declares no texts. */ readonly storedAs?: DataTypeId; readonly normalize?: (params: Params) => Params; + /** The parameters the database catalog prints for parameters as written, when they are not their normal form: PostgreSQL prints no precision for a bare `timestamptz`, though its normal form has precision 6. */ + readonly catalogParams?: (params: Params) => Params; /** The type's name from its parameters, for a type that claims a kind or whose name is its catalog text. */ readonly render?: (params: Params) => string; readonly fromReported?: (reported: ReportedSqlType) => Params; @@ -70,6 +72,8 @@ export interface SqlDataTypeFacts readonly storedAs: DataTypeId | undefined; /** The normal form of `params`; parameters the type's schema refuses are returned as written. */ readonly normalize: (params: Params) => Params; + /** The parameters the catalog prints for `params` as written: the declared {@link SqlDataTypeSpec.catalogParams}, or else the normal form. */ + readonly catalogParams: (params: Params) => Params; readonly render: ((params: Params) => string) | undefined; readonly fromReported: ((reported: ReportedSqlType) => Params) | undefined; readonly nameIsCatalogText: boolean; @@ -86,6 +90,11 @@ export interface ResolvedSqlType { readonly typeParams: SqlTypeParams; } +/** A type a migration writes, read as {@link ResolvedSqlType}, with its parameters also as the text writes them, before the normal form. */ +export interface ResolvedWrittenSqlType extends ResolvedSqlType { + readonly writtenTypeParams: SqlTypeParams; +} + const PLACEHOLDER = /\{([A-Za-z][A-Za-z0-9]*)\}/g; const LITERAL_CHARACTERS = /^[a-z0-9_ (),.]*$/; const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g; @@ -269,6 +278,12 @@ export function sqlDataType( checkMarks(declared.id, texts); checkKindClaim(declared.id, spec); checkStoredAs(declared.id, spec); + const declaredCatalogParams = spec.catalogParams; + const catalogParams = + declaredCatalogParams === undefined + ? normalize + : (params: Params): Params => + isParamsOf(spec.params, params) ? declaredCatalogParams(params) : params; const type: SqlDataType = { ...declared, ...ifDefined('params', spec.params), @@ -277,6 +292,7 @@ export function sqlDataType( claimsKind: spec.claimsKind, storedAs: spec.storedAs, normalize, + catalogParams, render: spec.render, fromReported: spec.fromReported, nameIsCatalogText: spec.nameIsCatalogText === true, @@ -556,7 +572,7 @@ export function renderSqlTypeName( return substitute(type, pickText(type, 'written', keys), params); } -/** The text the database catalog prints for `type` with `params`, in their normal form. */ +/** The text the database catalog prints for `type` with `params` as written, from the parameters its catalog prints for them ({@link SqlDataTypeFacts.catalogParams}). */ export function renderSqlCatalogText( type: SqlDataType, params: SqlTypeParams, @@ -568,8 +584,8 @@ export function renderSqlCatalogText( ); } if (type.sql.render !== undefined) return type.sql.render(validated); - const normalized = type.sql.normalize(validated); - return substitute(type, pickText(type, 'catalog', presentKeys(normalized)), normalized); + const printed = type.sql.catalogParams(validated); + return substitute(type, pickText(type, 'catalog', presentKeys(printed)), printed); } function prepareReportedText(text: string): string { @@ -695,21 +711,21 @@ function paramsMatching( /** * Every data type in `dataTypes` that reads `text` as a migration may write it, with its parameters - * in normal form: any of a type's texts counts, whether written, printed by the catalog or only + * in normal form and as written: any of a type's texts counts, whether written, printed by the catalog or only * claimed. Empty when no type reads it or its parameters fail the type's schema. A type that claims * a kind has no texts, so it never reads one. */ export function resolveWrittenSqlTypes( text: string, dataTypes: readonly DataType[], -): readonly ResolvedSqlType[] { +): readonly ResolvedWrittenSqlType[] { const prepared = prepareReportedText(text); return dataTypes.filter(isSqlDataType).flatMap((type) => { for (const candidate of type.sql.texts) { const params = paramsMatching(type, candidate, prepared); if (params === undefined) continue; const resolved = resolvedWith(type, params); - if (resolved !== undefined) return [resolved]; + if (resolved !== undefined) return [{ ...resolved, writtenTypeParams: params }]; } return []; }); diff --git a/packages/2-sql-family/1-core/contract/test/sql-data-type-fixtures.ts b/packages/2-sql-family/1-core/contract/test/sql-data-type-fixtures.ts index 4f4b510d563a..0b3ead000caa 100644 --- a/packages/2-sql-family/1-core/contract/test/sql-data-type-fixtures.ts +++ b/packages/2-sql-family/1-core/contract/test/sql-data-type-fixtures.ts @@ -62,6 +62,32 @@ export const timestamp = sqlDataType('t/timestamp', { ], }); +/** Like the Postgres timestamp types: an omitted precision is the precision 6 the database stores either way, though its catalog prints the precision as written. */ +export const timestamptz = sqlDataType('t/timestamptz', { + read: (json) => json, + params: type({ 'precision?': 'number.integer >= 0 & number.integer <= 6' }), + texts: [ + { text: 'timestamptz', written: true }, + { text: 'timestamptz({precision})', written: true }, + { text: 'timestamp with time zone', catalog: true }, + { text: 'timestamp({precision}) with time zone', catalog: true }, + ], + normalize: (params) => (params.precision === undefined ? { ...params, precision: 6 } : params), + catalogParams: (params) => params, +}); + +/** Like MySQL's datetime: precision 0 is the normal form of no precision, and the catalog prints neither. */ +export const datetime = sqlDataType('t/datetime', { + read: (json) => json, + params: type({ 'precision?': 'number.integer >= 0 & number.integer <= 6' }), + texts: [ + { text: 'datetime', written: true, catalog: true }, + { text: 'datetime({precision})', written: true, catalog: true }, + ], + normalize: ({ precision, ...rest }) => + precision === undefined || precision === 0 ? rest : { ...rest, precision }, +}); + /** Like the SQLite character types: the length is written, then forgotten. */ export const character = sqlDataType('t/character', { read: (json) => json, diff --git a/packages/2-sql-family/1-core/contract/test/sql-data-type.render.test.ts b/packages/2-sql-family/1-core/contract/test/sql-data-type.render.test.ts index cae5c5fe84ee..035288efcd36 100644 --- a/packages/2-sql-family/1-core/contract/test/sql-data-type.render.test.ts +++ b/packages/2-sql-family/1-core/contract/test/sql-data-type.render.test.ts @@ -11,12 +11,14 @@ import { import { char, character, + datetime, enumType, geometry, int4, numeric, textArray, timestamp, + timestamptz, vector, } from './sql-data-type-fixtures'; @@ -149,6 +151,26 @@ describe('renderSqlCatalogText', () => { expect(renderSqlCatalogText(timestamp, {})).toBe('timestamp without time zone'); }); + it('prints the normal form of the parameters for a type that declares no catalog parameters', () => { + expect([ + renderSqlCatalogText(datetime, { precision: 0 }), + renderSqlCatalogText(datetime, {}), + renderSqlCatalogText(datetime, { precision: 3 }), + ]).toEqual(['datetime', 'datetime', 'datetime(3)']); + }); + + it('prints the parameters a type declares its catalog prints for those written', () => { + expect([ + renderSqlCatalogText(timestamptz, {}), + renderSqlCatalogText(timestamptz, { precision: 6 }), + renderSqlCatalogText(timestamptz, { precision: 3 }), + ]).toEqual([ + 'timestamp with time zone', + 'timestamp(6) with time zone', + 'timestamp(3) with time zone', + ]); + }); + it('prints the display when the text has one', () => { expect(renderSqlCatalogText(geometry, { srid: 4326 })).toBe('geometry(Geometry,4326)'); }); diff --git a/packages/2-sql-family/1-core/contract/test/sql-data-type.resolve-written.test.ts b/packages/2-sql-family/1-core/contract/test/sql-data-type.resolve-written.test.ts index bf43e66fade8..5b701c1838f4 100644 --- a/packages/2-sql-family/1-core/contract/test/sql-data-type.resolve-written.test.ts +++ b/packages/2-sql-family/1-core/contract/test/sql-data-type.resolve-written.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { resolveWrittenSqlTypes as resolveWritten } from '../src/sql-data-type'; -import { char, character, int4, numeric, timestamp } from './sql-data-type-fixtures'; +import { char, character, int4, numeric, timestamp, timestamptz } from './sql-data-type-fixtures'; /** resolveWrittenSqlTypes, with each resolved data type named by its id for the expectations. */ const resolveWrittenSqlTypes = (...args: Parameters) => @@ -36,6 +36,21 @@ describe('resolveWrittenSqlTypes', () => { ]); }); + it('keeps the parameters as the text writes them beside their normal form', () => { + expect( + ['timestamptz', 'timestamptz(6)', 'numeric(10)'].map((text) => + resolveWritten(text, [timestamptz, numeric]).map(({ typeParams, writtenTypeParams }) => ({ + typeParams, + writtenTypeParams, + })), + ), + ).toEqual([ + [{ typeParams: { precision: 6 }, writtenTypeParams: {} }], + [{ typeParams: { precision: 6 }, writtenTypeParams: { precision: 6 } }], + [{ typeParams: { precision: 10, scale: 0 }, writtenTypeParams: { precision: 10 } }], + ]); + }); + it('lists every type that reads the text, and none for a text no type reads or parameters it refuses', () => { expect([ resolveWrittenSqlTypes('character', [char, character]).map(({ dataType }) => dataType), diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/README.md b/packages/2-sql-family/2-authoring/contract-prisma7/README.md index 9cd49166dfa6..23fc7ea0bc80 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/README.md +++ b/packages/2-sql-family/2-authoring/contract-prisma7/README.md @@ -31,9 +31,9 @@ The package itself is target-neutral: everything specific to a database comes fr | `model` | Model named verbatim; table is `@@map` or the name; column is `@map` or the field name. | | `enum` | Native enum type named by `@@map` or the enum name, members in order, each member's value its `@map` or its name; placed in the enum's `@@schema`. | | `@@schema("s")` | Namespace `s`; without it, the target's default namespace. | -| Scalars and `@db.*` | The target binding's type map (`typeMap`), for example `DateTime` to `timestamp(3)` and `Json` to `jsonb`; lists are nullable array columns with no derived element check. | +| Scalars and `@db.*` | The target binding's type map (`typeMap`), for example `DateTime` to `timestamp(3)` and `Json` to `jsonb`; on Postgres a `DateTime` reads a JavaScript `Date`, as Prisma 7 read it, and `@db.Date` and `@db.Time` read strings. Lists are nullable array columns with no derived element check. | | `@default(...)` | Column defaults through the target's default function registry, literals, list literals, enum members; `now()` is both the column default and the `now` generator on create, resolved for the column's codec, as Prisma 8 PSL resolves `@default(now()) @onCreate(now())`, because Prisma 7's client filled it too; `uuid`, `ulid`, `nanoid`, `cuid` are client generators on create, resolved for the column's codec through the stack's generator function registry, as Prisma 8 PSL resolves `@onCreate(fn())` (`cuid` maps to `cuid(2)`); `dbgenerated("")` is a raw SQL default and `dbgenerated()` means no column default. On a list field, any function other than `dbgenerated` is `PSL.PRISMA7_UNKNOWN_DEFAULT`; write a list literal instead. | -| `@updatedAt` | `now()` resolved for the column's codec through the stack's generator function registry, on create and on update, as Prisma 8 PSL resolves `@onCreate(now()) @onUpdate(now())`; Postgres binds it to every `DateTime` column type, and a `@db.Time` or `@db.Timetz` generator receives the column's precision. Beside `@default(now())`, the column default is kept too. On a field that is not a `DateTime`, on a list, or with a native type that is not a date or time, an error. | +| `@updatedAt` | `now()` resolved for the column's codec through the stack's generator function registry, on create and on update, as Prisma 8 PSL resolves `@onCreate(now()) @onUpdate(now())`; Postgres binds it to every `DateTime` column type, and a timestamp, `@db.Time` or `@db.Timetz` generator receives the column's precision. Beside `@default(now())`, the column default is kept too. On a field that is not a `DateTime`, on a list, or with a native type that is not a date or time, an error. | | `@id`, `@@id` | Primary key, named by `map` or `{table}_pkey` cut to 63 bytes as Prisma 7 cuts it; the name is stated only where it differs from the target's own (see [Constraint names](#constraint-names)). | | `@unique`, `@@unique`, `@@index` | Indexes named `{table}_{columns}_key` and `{table}_{columns}_idx` cut to 63 bytes as Prisma 7 cuts them, `map` overriding, `type` mapped. | | Explicit relations | Foreign keys with `onDelete` `restrict` (any foreign key field required) or `setNull` (every field optional) and `onUpdate` `cascade` unless given, named by `map` or `{table}_{columns}_fkey` cut as Prisma 7 cuts it; paired through `@internal/sql-contract-psl/resolution`. | @@ -81,7 +81,7 @@ Explicit relations keep their fields, references, and actions; an omitted `onDel ## Defaults, generators, `@updatedAt`, and indexes -`@default(autoincrement())` and `@default(now())` become column defaults through the target's default function registry (`context.controlMutationDefaults`). `@default(now())` also becomes the `now` generator on create, resolved for the column's codec through the generator function registry, as Prisma 8 PSL resolves `@default(now()) @onCreate(now())`: Prisma 7's client filled the value itself and wrote the column default for other writers, so a create through Prisma 8 writes UTC whatever the session time zone, and an `INSERT` from outside Prisma still gets the column default. On a field that is not a `DateTime`, or on a `DateTime` field whose native type is not a date or time (`@db.Text`), it is `PSL.PRISMA7_UNKNOWN_DEFAULT`, as Prisma 7 refuses both. The ORM-side generators `uuid()`, `uuid(4)`, `uuid(7)`, `ulid()`, `nanoid()`, `nanoid(n)`, `cuid()`, and `cuid(2)` become execution generators on create with no column default. `cuid()` maps to `cuid2` because Prisma 7's `cuid()` is cuid v1, which Prisma 8 does not ship; the column type is the same and the ids are opaque, so existing rows keep working beside new ones. Every other call's arguments are bound through the target's signatures, as Prisma 8 PSL binds them: an argument the function does not take (`uuid(5)`, `cuid(1)`, `nanoid(1)`, `nanoid(1000)`, `nanoid(8.5)`) or a named argument (`nanoid(size: 8)`) is `PSL.PRISMA7_UNKNOWN_DEFAULT` at the argument, with the function's message, such as `Field "User.id": uuid: version must be 4 or 7, got 5`. `@default(dbgenerated(""))` becomes a raw SQL default carrying the string as written, without going through the registry; `@default(dbgenerated())` with no argument means the column has no default, on required, optional, and list fields alike. Literals of every scalar, list literals, and enum members (the member's mapped storage value) become literal defaults; a number literal is carried as the value the column codec reads: the number itself when the codec reads a JSON number, otherwise what the codec decodes from the literal exactly as written, so `Decimal` and `BigInt` defaults keep every digit and trailing zero (`"1.50"`, `"9007199254740993"`); a number with a fraction on an `Int` or `BigInt` field, or one the codec reads neither way, is `PSL.PRISMA7_UNKNOWN_DEFAULT`, as Prisma 7 rejects it, and the message says an `Int` or `BigInt` default must be a whole number; `Json` literals are parsed, and `Bytes` and `DateTime` literals are read through the column type's tag, as the current schema language reads them, and stored as values: a `Bytes` default's base64 as the hex body of `bytea`, stored as base64, and a `DateTime` default as the body that keeps what it means on the column: the written date on `date`, the written wall-clock time without its offset on `timestamp` and `time`, the time with its offset on `timetz`, and the written instant on `timestamptz`, stored in UTC (`2024-01-02T01:04:05Z`). A value with more fraction digits than its column holds (three on a plain `DateTime`, which is `timestamp(3)`) is `PSL.PRISMA7_UNKNOWN_DEFAULT`, and the message names the value PostgreSQL stores instead, rounded as PostgreSQL rounds it, to write in its place. Second 60, which PostgreSQL carries into the next minute, is refused with the tag's reason. A `Bytes[]` or `DateTime[]` list default stores each element the same way. `@updatedAt` becomes an ORM-side "now" generator on create and update; the generator function registry picks the generator from the column's codec and type parameters, and on Postgres every date and time column type has one, each writing UTC text (`timestampStringNow` for a zoneless `timestamp(3)`, `dateStringNow` for `@db.Date`, and so on); the `@db.Time(p)` and `@db.Timetz(p)` generators get `p` as their `precision` param and round down to it, so Postgres never rounds a time up to `24:00:00`. `@updatedAt` on a field that is not a `DateTime`, on a list field, or on a `DateTime` field whose native type is not a date or time (`@db.Text`), is `PSL.PRISMA7_UPDATED_AT_FIELD_INVALID`, as Prisma 7 refuses all three. `@updatedAt` beside `@default(now())` keeps the column default as well, so writes outside the ORM still get a value. A generator or `@updatedAt` on an optional field gives a nullable column: a value the client leaves out is generated, and an explicit `null` is stored as `null`, as in Prisma 7. List columns decline the element-not-null check Prisma 8 would otherwise derive, because Prisma 7 creates none. +`@default(autoincrement())` and `@default(now())` become column defaults through the target's default function registry (`context.controlMutationDefaults`). `@default(now())` also becomes the `now` generator on create, resolved for the column's codec through the generator function registry, as Prisma 8 PSL resolves `@default(now()) @onCreate(now())`: Prisma 7's client filled the value itself and wrote the column default for other writers, so a create through Prisma 8 writes UTC whatever the session time zone, and an `INSERT` from outside Prisma still gets the column default. On a field that is not a `DateTime`, or on a `DateTime` field whose native type is not a date or time (`@db.Text`), it is `PSL.PRISMA7_UNKNOWN_DEFAULT`, as Prisma 7 refuses both. The ORM-side generators `uuid()`, `uuid(4)`, `uuid(7)`, `ulid()`, `nanoid()`, `nanoid(n)`, `cuid()`, and `cuid(2)` become execution generators on create with no column default. `cuid()` maps to `cuid2` because Prisma 7's `cuid()` is cuid v1, which Prisma 8 does not ship; the column type is the same and the ids are opaque, so existing rows keep working beside new ones. Every other call's arguments are bound through the target's signatures, as Prisma 8 PSL binds them: an argument the function does not take (`uuid(5)`, `cuid(1)`, `nanoid(1)`, `nanoid(1000)`, `nanoid(8.5)`) or a named argument (`nanoid(size: 8)`) is `PSL.PRISMA7_UNKNOWN_DEFAULT` at the argument, with the function's message, such as `Field "User.id": uuid: version must be 4 or 7, got 5`. `@default(dbgenerated(""))` becomes a raw SQL default carrying the string as written, without going through the registry; `@default(dbgenerated())` with no argument means the column has no default, on required, optional, and list fields alike. Literals of every scalar, list literals, and enum members (the member's mapped storage value) become literal defaults; a number literal is carried as the value the column codec reads: the number itself when the codec reads a JSON number, otherwise what the codec decodes from the literal exactly as written, so `Decimal` and `BigInt` defaults keep every digit and trailing zero (`"1.50"`, `"9007199254740993"`); a number with a fraction on an `Int` or `BigInt` field, or one the codec reads neither way, is `PSL.PRISMA7_UNKNOWN_DEFAULT`, as Prisma 7 rejects it, and the message says an `Int` or `BigInt` default must be a whole number; `Json` literals are parsed, and `Bytes` and `DateTime` literals are read through the column type's tag, as the current schema language reads them, and stored as values: a `Bytes` default's base64 as the hex body of `bytea`, stored as base64, and a `DateTime` default as the body that keeps what it means on the column: the written date on `date`, the written wall-clock time without its offset on `timestamp` and `time`, the time with its offset on `timetz`, and the written instant on `timestamptz`, stored in UTC (`2024-01-02T01:04:05Z`). A value with more fraction digits than its column holds (three on a plain `DateTime`, which is `timestamp(3)`) is `PSL.PRISMA7_UNKNOWN_DEFAULT`, and the message names the value PostgreSQL stores instead, rounded as PostgreSQL rounds it, to write in its place. Second 60, which PostgreSQL carries into the next minute, is refused with the tag's reason. A `Bytes[]` or `DateTime[]` list default stores each element the same way. A value the column's type holds but its codec cannot hold exactly, such as a `DateTime` default with digits below a millisecond on a column read as a JavaScript `Date`, is kept as the SQL expression the target binding writes for it (`literalDefaultExpression`), for the whole default, list or not. `@updatedAt` becomes an ORM-side "now" generator on create and update; the generator function registry picks the generator from the column's codec and type parameters, and on Postgres every date and time column type has one, each writing the current UTC time as the column's codec reads it (`timestampNow`, a `Date`, for a plain `DateTime` and a `@db.Timestamptz`, `dateStringNow` for `@db.Date`, and so on); the timestamp and `@db.Time(p)` and `@db.Timetz(p)` generators get the column's precision as their `precision` param and round down to it, so the client holds the value Postgres stores, which never rounds a `@db.Timestamp(0)` value up to the next second or a time up to `24:00:00`. `@updatedAt` on a field that is not a `DateTime`, on a list field, or on a `DateTime` field whose native type is not a date or time (`@db.Text`), is `PSL.PRISMA7_UPDATED_AT_FIELD_INVALID`, as Prisma 7 refuses all three. `@updatedAt` beside `@default(now())` keeps the column default as well, so writes outside the ORM still get a value. A generator or `@updatedAt` on an optional field gives a nullable column: a value the client leaves out is generated, and an explicit `null` is stored as `null`, as in Prisma 7. List columns decline the element-not-null check Prisma 8 would otherwise derive, because Prisma 7 creates none. `@unique` and `@@unique` become unique indexes named `{table}_{columns}_key` and `@@index` becomes an index named `{table}_{columns}_idx`, `map` overriding either (`name` on `@@unique` is the client-side name and is ignored). A generated name is cut the way Prisma 7 cuts it to fit PostgreSQL's 63-byte identifier limit: the `{table}_{columns}` part is shortened to 63 bytes minus the suffix, on a character boundary, and the suffix stays whole (`AVeryLongModelNameThatKeepsGoingAndGoingForever_aVeryLongCo_idx`). The same rule cuts an implicit junction's table name (no suffix) and its `_B_index`. `db verify` compares indexes by name, so the contract must carry the name Prisma 7 created. `type: Hash` and the other Prisma 8 index types map through; field arguments such as `sort` and `length`, and `ops`, are `PSL.PRISMA7_INDEX_ARGUMENT_UNSUPPORTED` because Prisma 8 indexes carry none. diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/src/defaults.ts b/packages/2-sql-family/2-authoring/contract-prisma7/src/defaults.ts index 1136b8ed624b..aadc96948f9b 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/src/defaults.ts +++ b/packages/2-sql-family/2-authoring/contract-prisma7/src/defaults.ts @@ -58,6 +58,8 @@ export interface LowerPrisma7DefaultInput { readonly nativeTypeName: string | undefined; readonly codecLookup: CodecLookupWithDescriptors; readonly literalForm: Prisma7LiteralDefaultForm | undefined; + /** The SQL expression of a default whose value the column's codec cannot hold exactly; see `Prisma7TargetBinding.literalDefaultExpression`. */ + readonly literalDefaultExpression: (stored: JsonValue) => string; /** Storage value per member name when the field is typed by a Prisma 7 enum. */ readonly enumMembers: ReadonlyMap | undefined; readonly controlMutationDefaults: ControlMutationDefaults; @@ -113,24 +115,39 @@ export function lowerPrisma7Default( return lowerFunction(call, expression, input, label, unknown); } - const scalar = scalarValue(expression, input, unknown); - if (scalar === undefined) return undefined; - return { storage: { kind: 'literal', value: scalar, inStoredForm: true }, onCreate: undefined }; + const storage = scalarValue(expression, input, unknown); + return storage === undefined ? undefined : { storage, onCreate: undefined }; } /** - * The stored value of a written default: an enum member resolves through the enum's members, and + * The stored default of a written value: an enum member resolves through the enum's members, and * every other value is read by the authoring entry for its syntax, cast into the column's data type - * and validated by the column's codec — the same path the current schema language takes. + * and validated by the column's codec — the same path the current schema language takes. A value + * the column's type holds but its codec cannot hold exactly, such as a timestamp with digits below + * a millisecond on a column read as a JavaScript `Date`, is kept as the SQL expression the target + * writes for it. */ function scalarValue( expression: ExpressionAst, input: LowerPrisma7DefaultInput, unknown: (reason: string, span: PslSpan) => undefined, -): AuthoredColumnDefaultLiteralValue | undefined { +): AuthoredColumnDefault | undefined { + const literal = scalarLiteral(expression, input, unknown); + if (literal === undefined || 'expression' in literal) return literal; + return { kind: 'literal', value: literal.value, inStoredForm: true }; +} + +function scalarLiteral( + expression: ExpressionAst, + input: LowerPrisma7DefaultInput, + unknown: (reason: string, span: PslSpan) => undefined, +): + | { readonly value: AuthoredColumnDefaultLiteralValue } + | { readonly kind: 'function'; readonly expression: string } + | undefined { const span = input.attribute.span; const enumValue = enumMemberValue(expression, input); - if (enumValue !== undefined) return enumValue; + if (enumValue !== undefined) return { value: enumValue }; const array = ArrayLiteralAst.cast(expression.syntax); const elements = array === undefined ? undefined : [...array.elements()]; @@ -143,7 +160,7 @@ function scalarValue( } values.push(value); } - return values; + return { value: values }; } const finer = finerThanColumn(elements ?? [expression], elements !== undefined, input); @@ -153,7 +170,7 @@ function scalarValue( if (written === undefined) return unreadableValue(expression, input, unknown); const jsonNull = jsonNullDefault(written, expression, input); - if (jsonNull !== undefined) return jsonNull; + if (jsonNull !== undefined) return { value: jsonNull }; const read = readDataTypeDefault({ written, @@ -163,7 +180,12 @@ function scalarValue( dataTypes: input.dataTypes, fieldPath: `${input.modelName}.${input.field.name}`, }); - return read.ok ? read.value : unknown(refusalReason(read.refusal), span); + if (read.ok) return { value: read.value }; + const { refusal } = read; + if (refusal.kind === 'refused-by-codec') { + return { kind: 'function', expression: input.literalDefaultExpression(refusal.stored) }; + } + return unknown(refusalReason(refusal), span); } /** @@ -325,6 +347,7 @@ function refusalReason(refusal: DefaultRefusal): string { case 'no-element-cast': return `holds a ${refusal.valueType} value${at}, which the list cast of ${refusal.receivingType} does not take; it takes ${refusal.elementTypes.join(', ')}.`; case 'refused-by-column': + case 'refused-by-codec': return `holds a value${at} that ${refusal.codecId} does not read: ${refusal.message}`; } } diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/src/interpreter.ts b/packages/2-sql-family/2-authoring/contract-prisma7/src/interpreter.ts index 66dd400b897f..c8862e738207 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/src/interpreter.ts +++ b/packages/2-sql-family/2-authoring/contract-prisma7/src/interpreter.ts @@ -10,9 +10,11 @@ import type { WrittenValue, } from '@internal/framework-components/authoring'; import { + getAuthoringTypeChoice, getAuthoringTypeConstructor, instantiateAuthoringEntityType, instantiateAuthoringTypeConstructorFromValues, + resolveAuthoringTypeChoice, } from '@internal/framework-components/authoring'; import type { CodecLookupWithDescriptors } from '@internal/framework-components/codec'; import { @@ -78,7 +80,7 @@ import { } 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'; +import { InternalError, isInternalError } from '@internal/utils/internal-error'; import { notOk, ok, type Result } from '@internal/utils/result'; import { basename } from 'pathe'; import { @@ -1007,6 +1009,28 @@ function lowerNativeEnums( /** The SQL family's type constructor for a column of a type no data type claims (ADR 273). */ const RAW_TYPE_CONSTRUCTOR: readonly string[] = ['Raw']; +/** The constructor the values a binding maps a Prisma 7 type to choose from the type choice it names; a refusal is the binding's mistake. */ +function chooseBindingType( + label: string, + path: readonly string[], + typeChoice: NonNullable>, + values: readonly unknown[], +): ReturnType { + try { + return resolveAuthoringTypeChoice({ + helperPath: path.join('.'), + descriptor: typeChoice.descriptor, + args: values, + constructorNamed: typeChoice.constructorNamed, + }); + } catch (error) { + if (isInternalError(error) || !(error instanceof Error)) throw error; + throw new InternalError( + `The Prisma 7 target binding maps ${label} to type choice "${path.join('.')}" with values it refuses: ${error.message}`, + ); + } +} + /** Prisma 7's `Unsupported("...")`, which this source reads as ``Raw(sql`...`)``. */ function isUnsupportedType(call: ResolvedTypeConstructorCall | undefined): boolean { return call?.path.length === 1 && call.path[0] === 'Unsupported'; @@ -1297,10 +1321,18 @@ function readField(args: ReadFieldArgs): void { } const constructorPath = fieldType.kind === 'written' ? fieldType.call.path : fieldType.path; - const typeConstructor = getAuthoringTypeConstructor( - input.authoringContributions, - constructorPath, - ); + const typeChoice = + fieldType.kind === 'values' + ? getAuthoringTypeChoice(input.authoringContributions, constructorPath) + : undefined; + const chosen = + typeChoice === undefined || fieldType.kind !== 'values' + ? undefined + : chooseBindingType(label, constructorPath, typeChoice, fieldType.values); + const typeConstructor = + chosen?.descriptor ?? + getAuthoringTypeConstructor(input.authoringContributions, constructorPath); + const typeValues = chosen?.args ?? (fieldType.kind === 'values' ? fieldType.values : []); if (typeConstructor === undefined) { diagnostics.push( prisma7Diagnostic( @@ -1330,7 +1362,7 @@ function readField(args: ReadFieldArgs): void { } else { const instantiated = instantiateAuthoringTypeConstructorFromValues( typeConstructor, - fieldType.values, + typeValues, input.codecLookup.descriptorFor(typeConstructor.output.codecId)?.paramsSchema, ); if (!instantiated.ok && unsupportedArgument !== undefined) { @@ -1393,6 +1425,12 @@ function readField(args: ReadFieldArgs): void { dataType: sqlDataTypeOfCodec(resolved.descriptor.codecId, typeLookups).id, typeParams: resolved.descriptor.typeParams, }), + literalDefaultExpression: (stored) => + binding.literalDefaultExpression(stored, { + codecId: resolved.descriptor.codecId, + dataType: sqlDataTypeOfCodec(resolved.descriptor.codecId, typeLookups).id, + typeParams: resolved.descriptor.typeParams, + }), enumMembers: enumDeclaration === undefined ? undefined diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/src/target-binding.ts b/packages/2-sql-family/2-authoring/contract-prisma7/src/target-binding.ts index d9bfd9baa182..efcaa1db3819 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/src/target-binding.ts +++ b/packages/2-sql-family/2-authoring/contract-prisma7/src/target-binding.ts @@ -1,3 +1,4 @@ +import type { JsonValue } from '@internal/contract/types'; import type { TargetPackRef } from '@internal/framework-components/components'; import type { SqlNamespaceBase, SqlNamespaceInput } from '@internal/sql-contract/types'; import type { Prisma7TypeMap } from './native-types'; @@ -62,4 +63,6 @@ export interface Prisma7TargetBinding { ) => readonly [string, string]; /** How a literal `@default` on the column is read; `undefined` when the codec takes the written value. */ readonly literalDefaultForm: (column: Prisma7ColumnType) => Prisma7LiteralDefaultForm | undefined; + /** The SQL expression of a default whose value the column's type holds but its codec cannot hold exactly, such as a timestamp with digits below a millisecond read as a JavaScript `Date`: `stored` is the value, or a list column's elements, as the type stores them. */ + readonly literalDefaultExpression: (stored: JsonValue, column: Prisma7ColumnType) => string; } diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/defaults.test.ts b/packages/2-sql-family/2-authoring/contract-prisma7/test/defaults.test.ts index 4a430dbfbcc9..675ada33da52 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/defaults.test.ts +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/defaults.test.ts @@ -52,7 +52,7 @@ describe('Bytes[] and DateTime[] list defaults', () => { datel: { kind: 'literal', value: ['2024-01-02'] }, timel: { kind: 'literal', value: ['12:34:56'] }, timetzl: { kind: 'literal', value: ['12:34:56Z'] }, - ts6l: { kind: 'literal', value: ['2024-01-02T03:04:05.123456'] }, + ts6l: { kind: 'function', expression: "ARRAY['2024-01-02T03:04:05.123456']::timestamp(6)[]" }, }); }); }); diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/datetime-defaults/expected-contract.json b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/datetime-defaults/expected-contract.json index ff60d35016e6..b0ce8c179493 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/datetime-defaults/expected-contract.json +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/datetime-defaults/expected-contract.json @@ -75,7 +75,7 @@ "tsZ": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -86,7 +86,7 @@ "tsMs": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -97,7 +97,7 @@ "tsPlus": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -108,7 +108,7 @@ "tsMinus": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -119,7 +119,7 @@ "tsMicro": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 6 } @@ -130,7 +130,7 @@ "tzZ": { "type": { "kind": "scalar", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "typeParams": { "precision": 6 } @@ -141,7 +141,7 @@ "tzPlus": { "type": { "kind": "scalar", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "typeParams": { "precision": 6 } @@ -254,7 +254,7 @@ "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "meta": {}, "storage": { - "storageHash": "feda26d6f6cfb7e3e2fd75cf177cc4e5c1e7ff60deff04d5ab208cf114426e02", + "storageHash": "5772a67257453bdad9ff5ce4e2b5098608b3719f70f3479194fe2bfd6b446050", "namespaces": { "public": { "id": "public", @@ -271,7 +271,7 @@ }, "tsZ": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { @@ -284,7 +284,7 @@ }, "tsMs": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { @@ -297,7 +297,7 @@ }, "tsPlus": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { @@ -310,7 +310,7 @@ }, "tsMinus": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { @@ -323,20 +323,20 @@ }, "tsMicro": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { "precision": 6 }, "default": { - "kind": "literal", - "value": "2024-01-02T03:04:05.123456" + "kind": "function", + "expression": "'2024-01-02T03:04:05.123456'::timestamp(6)" } }, "tzZ": { "dataType": "pg/timestamptz", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "nullable": false, "many": false, "typeParams": { @@ -349,7 +349,7 @@ }, "tzPlus": { "dataType": "pg/timestamptz", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "nullable": false, "many": false, "typeParams": { diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/defaults/expected-contract.json b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/defaults/expected-contract.json index e2adb69e8b51..e6d66c9c6c0d 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/defaults/expected-contract.json +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/defaults/expected-contract.json @@ -86,7 +86,7 @@ "createdAt": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -97,7 +97,7 @@ "createdAtTz": { "type": { "kind": "scalar", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "typeParams": { "precision": 6 } @@ -168,7 +168,7 @@ "dateTimeLiteral": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -250,7 +250,7 @@ } }, "execution": { - "executionHash": "34724d7f5b296e0eee40a5a82495ef0ff3eb4bcb7428d1261cf475fb29be215b", + "executionHash": "16f44213037f9551e1ff3ab95c43a8ff95c77b0332ce526f45df70bea0c4c416", "mutations": { "defaults": [ { @@ -261,7 +261,7 @@ }, "onCreate": { "kind": "generator", - "id": "timestampStringNow", + "id": "timestampNow", "params": { "precision": 3 } @@ -275,7 +275,7 @@ }, "onCreate": { "kind": "generator", - "id": "timestamptzStringNow", + "id": "timestampNow", "params": { "precision": 6 } @@ -289,7 +289,7 @@ "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "meta": {}, "storage": { - "storageHash": "df40eca2aa7086780f4e2e2bb1ad623cc60b7c5253a32cd076be318e8ae69c27", + "storageHash": "3866a5e224af9199d6b8ae227ff30109a72405c2d23a1209458c2d3f63596324", "namespaces": { "public": { "id": "public", @@ -320,7 +320,7 @@ }, "createdAt": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { @@ -333,7 +333,7 @@ }, "createdAtTz": { "dataType": "pg/timestamptz", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "nullable": false, "many": false, "typeParams": { @@ -420,7 +420,7 @@ }, "dateTimeLiteral": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/ignored-field-defaults/expected-contract.json b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/ignored-field-defaults/expected-contract.json index 2d84eee37b3f..84ef0dd4289e 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/ignored-field-defaults/expected-contract.json +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/ignored-field-defaults/expected-contract.json @@ -42,7 +42,7 @@ "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "meta": {}, "storage": { - "storageHash": "8e7bd87d3410fa8c14b9861bc31aaa1f572c78a30e045d315c3c436fd5b90f94", + "storageHash": "5e7f6b214527aec54879d1123e2e50bf622b4750aa0e5b3d4a6f7803691436f3", "namespaces": { "public": { "id": "public", @@ -69,7 +69,7 @@ }, "createdAt": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { @@ -94,7 +94,7 @@ }, "touchedAt": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { @@ -103,7 +103,7 @@ }, "seenAt": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": true, "many": false, "typeParams": { @@ -126,7 +126,7 @@ }, "stamp": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/list-defaults/expected-contract.json b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/list-defaults/expected-contract.json index 665612d88e19..d17a2f057bda 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/list-defaults/expected-contract.json +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/list-defaults/expected-contract.json @@ -74,7 +74,7 @@ "dl": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -87,7 +87,7 @@ "dlEmpty": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -100,7 +100,7 @@ "tzl": { "type": { "kind": "scalar", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "typeParams": { "precision": 6 } @@ -149,7 +149,7 @@ "ts6l": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 6 } @@ -177,7 +177,7 @@ "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "meta": {}, "storage": { - "storageHash": "f1cdd79e91d6fdd6e05ca7cc6078d30664a13a3fec4c10cad4947bb2dc2616b4", + "storageHash": "5eb58269a5271620ca1beb81f4e851671e4f6c10dedb97290eb185fac02f6f70", "namespaces": { "public": { "id": "public", @@ -218,7 +218,7 @@ }, "dl": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": true, "many": { "elementNullable": true @@ -233,7 +233,7 @@ }, "dlEmpty": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": true, "many": { "elementNullable": true @@ -248,7 +248,7 @@ }, "tzl": { "dataType": "pg/timestamptz", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "nullable": true, "many": { "elementNullable": true @@ -305,7 +305,7 @@ }, "ts6l": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": true, "many": { "elementNullable": true @@ -314,8 +314,8 @@ "precision": 6 }, "default": { - "kind": "literal", - "value": ["2024-01-02T03:04:05.123456"] + "kind": "function", + "expression": "ARRAY['2024-01-02T03:04:05.123456']::timestamp(6)[]" } } }, diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/native-types-accepted/expected-contract.json b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/native-types-accepted/expected-contract.json index 3185681e7cd6..13dadf2c2ad7 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/native-types-accepted/expected-contract.json +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/native-types-accepted/expected-contract.json @@ -188,7 +188,7 @@ "timestamp": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 6 } @@ -199,7 +199,7 @@ "timestamptz": { "type": { "kind": "scalar", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "typeParams": { "precision": 6 } @@ -277,7 +277,7 @@ "timestamptzOpt": { "type": { "kind": "scalar", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "typeParams": { "precision": 3 } @@ -303,7 +303,7 @@ "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "meta": {}, "storage": { - "storageHash": "149d8890e723d0b02331ea679cf5b6bc01db44215c8a0c87fb9df29f5a983f35", + "storageHash": "8838cef0f08f8f027e855e594f549c3f55e8fdc5723c6338f5151cc7e903e932", "namespaces": { "public": { "id": "public", @@ -396,7 +396,7 @@ }, "timestamp": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { @@ -405,7 +405,7 @@ }, "timestamptz": { "dataType": "pg/timestamptz", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "nullable": false, "many": false, "typeParams": { @@ -467,7 +467,7 @@ }, "timestamptzOpt": { "dataType": "pg/timestamptz", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "nullable": true, "many": false, "typeParams": { diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/native-types-without-arguments/expected-contract.json b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/native-types-without-arguments/expected-contract.json index 6d6d915620cf..d3ff5acdfdf0 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/native-types-without-arguments/expected-contract.json +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/native-types-without-arguments/expected-contract.json @@ -75,7 +75,7 @@ "timestamp": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1" + "codecId": "pg/timestamp-date@1" }, "nullable": false, "many": false @@ -83,7 +83,7 @@ "timestamptz": { "type": { "kind": "scalar", - "codecId": "pg/timestamptz-string@1" + "codecId": "pg/timestamptz-date@1" }, "nullable": false, "many": false @@ -122,7 +122,7 @@ "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "meta": {}, "storage": { - "storageHash": "f28e64193b60a26ff5ceaa3bb9339d2b5a10ff67030771a3a52ab7334105a5a1", + "storageHash": "eae4cf8beefa2f644b3e2d462b69e8447946c347f1aa21fda6587cd3b829dbba", "namespaces": { "public": { "id": "public", @@ -160,13 +160,13 @@ }, "timestamp": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false }, "timestamptz": { "dataType": "pg/timestamptz", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "nullable": false, "many": false }, diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/scalars/expected-contract.json b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/scalars/expected-contract.json index 52868fe228a2..bd142191e7ea 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/scalars/expected-contract.json +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/scalars/expected-contract.json @@ -265,7 +265,7 @@ "dateTime": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -276,7 +276,7 @@ "dateTimeOpt": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -287,7 +287,7 @@ "dateTimeList": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -367,7 +367,7 @@ "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "meta": {}, "storage": { - "storageHash": "936fa7188bbe44706b929d6392eb9f0b9c0329f0b3cd9a513c78ac9126b64424", + "storageHash": "1578dc0d71fb46e51ae88d63cbaad7cb666f0a37b99de9921ea55d88892603e7", "namespaces": { "public": { "id": "public", @@ -510,7 +510,7 @@ }, "dateTime": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { @@ -519,7 +519,7 @@ }, "dateTimeOpt": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": true, "many": false, "typeParams": { @@ -528,7 +528,7 @@ }, "dateTimeList": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": true, "many": { "elementNullable": true diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at-optional/expected-contract.json b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at-optional/expected-contract.json index 1fd89ad2fb00..a3d487089d3f 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at-optional/expected-contract.json +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at-optional/expected-contract.json @@ -30,7 +30,7 @@ "updatedAtOpt": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -52,7 +52,7 @@ } }, "execution": { - "executionHash": "fd49086f64d04b8fbdb1a5168eae87b6272974b85c774c2ba52bc13665040d49", + "executionHash": "1760fa0f34d6d5e40e3be067e8204259f351ff29ac6f9a4f86b497657976ed77", "mutations": { "defaults": [ { @@ -63,14 +63,14 @@ }, "onCreate": { "kind": "generator", - "id": "timestampStringNow", + "id": "timestampNow", "params": { "precision": 3 } }, "onUpdate": { "kind": "generator", - "id": "timestampStringNow", + "id": "timestampNow", "params": { "precision": 3 } @@ -84,7 +84,7 @@ "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "meta": {}, "storage": { - "storageHash": "9971b630bbcace6e16ebbdbf2faae2ec4401c53c511aa014b9b0138c4a276897", + "storageHash": "cfb60251abfe2853eb71b3ed9282850fd6a1845831007eedc90d771c9f882074", "namespaces": { "public": { "id": "public", @@ -101,7 +101,7 @@ }, "updatedAtOpt": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": true, "many": false, "typeParams": { diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at-with-default/expected-contract.json b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at-with-default/expected-contract.json index 8fecb848c6e0..5b7251d10b1c 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at-with-default/expected-contract.json +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at-with-default/expected-contract.json @@ -30,7 +30,7 @@ "updatedAtNow": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -52,7 +52,7 @@ } }, "execution": { - "executionHash": "7a7d1cf6cde9c00b1c57375b140d9c74dd354caedbbb7c0e201c8bd51cfc9e1e", + "executionHash": "761c21afb0a2f13f4614a430fa13d19469afa51b650d39e6f27b74969eeec340", "mutations": { "defaults": [ { @@ -63,14 +63,14 @@ }, "onCreate": { "kind": "generator", - "id": "timestampStringNow", + "id": "timestampNow", "params": { "precision": 3 } }, "onUpdate": { "kind": "generator", - "id": "timestampStringNow", + "id": "timestampNow", "params": { "precision": 3 } @@ -84,7 +84,7 @@ "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "meta": {}, "storage": { - "storageHash": "46ca87631e23000ebc3f972b217ed1c5ca71e70935080266337c0388aed2d13b", + "storageHash": "f47affdbe95aea63ee9609e5958360707a670a88f55ce8d5b93d56a9c6a16d8f", "namespaces": { "public": { "id": "public", @@ -101,7 +101,7 @@ }, "updatedAtNow": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at/expected-contract.json b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at/expected-contract.json index dedc0f97de6b..45976f3255bd 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at/expected-contract.json +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/fixtures/updated-at/expected-contract.json @@ -33,7 +33,7 @@ "updatedAt": { "type": { "kind": "scalar", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "typeParams": { "precision": 3 } @@ -44,7 +44,7 @@ "updatedAtTz": { "type": { "kind": "scalar", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "typeParams": { "precision": 6 } @@ -66,7 +66,7 @@ } }, "execution": { - "executionHash": "6648fa9bcd0cacf082bbb524662c5e16474259ce08470b5ee52cf6826b1b88a8", + "executionHash": "b2ae3a1c323b84ac42b2e3cbf2b9815dda604f2ab28dde7f041581168eeae217", "mutations": { "defaults": [ { @@ -77,14 +77,14 @@ }, "onCreate": { "kind": "generator", - "id": "timestampStringNow", + "id": "timestampNow", "params": { "precision": 3 } }, "onUpdate": { "kind": "generator", - "id": "timestampStringNow", + "id": "timestampNow", "params": { "precision": 3 } @@ -98,14 +98,14 @@ }, "onCreate": { "kind": "generator", - "id": "timestamptzStringNow", + "id": "timestampNow", "params": { "precision": 6 } }, "onUpdate": { "kind": "generator", - "id": "timestamptzStringNow", + "id": "timestampNow", "params": { "precision": 6 } @@ -119,7 +119,7 @@ "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "meta": {}, "storage": { - "storageHash": "b6ff179714a91b72e54295867c9a137b278c3923f5bf7ff03b719763d5dfc60b", + "storageHash": "ca3e29fc4e90265e1b10a48ef33decf69b48a26c5df9b257afc326b457127339", "namespaces": { "public": { "id": "public", @@ -136,7 +136,7 @@ }, "updatedAt": { "dataType": "pg/timestamp", - "codecId": "pg/timestamp-string@1", + "codecId": "pg/timestamp-date@1", "nullable": false, "many": false, "typeParams": { @@ -145,7 +145,7 @@ }, "updatedAtTz": { "dataType": "pg/timestamptz", - "codecId": "pg/timestamptz-string@1", + "codecId": "pg/timestamptz-date@1", "nullable": false, "many": false, "typeParams": { diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/now-default-generators.test.ts b/packages/2-sql-family/2-authoring/contract-prisma7/test/now-default-generators.test.ts index abb34d951948..48cb47856ea8 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/now-default-generators.test.ts +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/now-default-generators.test.ts @@ -38,10 +38,10 @@ model Post { }); expect(contract.execution?.mutations.defaults).toEqual([ { ref: ref('clock'), onCreate: now('timeStringNow', { precision: 0 }) }, - { ref: ref('createdAt'), onCreate: now('timestampStringNow', { precision: 3 }) }, + { ref: ref('createdAt'), onCreate: now('timestampNow', { precision: 3 }) }, { ref: ref('day'), onCreate: now('dateStringNow') }, - { ref: ref('optionalAt'), onCreate: now('timestampStringNow', { precision: 3 }) }, - { ref: ref('zonedAt'), onCreate: now('timestamptzStringNow', { precision: 6 }) }, + { ref: ref('optionalAt'), onCreate: now('timestampNow', { precision: 3 }) }, + { ref: ref('zonedAt'), onCreate: now('timestampNow', { precision: 6 }) }, ]); }); @@ -61,8 +61,8 @@ model Post { expect(contract.execution?.mutations.defaults).toEqual([ { ref: ref('updatedAt'), - onCreate: now('timestampStringNow', { precision: 3 }), - onUpdate: now('timestampStringNow', { precision: 3 }), + onCreate: now('timestampNow', { precision: 3 }), + onUpdate: now('timestampNow', { precision: 3 }), }, ]); }); diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/sub-millisecond-defaults.test.ts b/packages/2-sql-family/2-authoring/contract-prisma7/test/sub-millisecond-defaults.test.ts new file mode 100644 index 000000000000..afd40a0ffda8 --- /dev/null +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/sub-millisecond-defaults.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { interpretSchemaText, serializedTable } from './support'; + +describe('a DateTime default with digits below a millisecond', () => { + it('is kept as the SQL the target writes for it, which a Date cannot hold as a value, single or in a list', () => { + const contract = interpretSchemaText(`datasource db { + provider = "postgresql" +} + +model Post { + id Int @id + tsMicro DateTime @default("2024-01-02T03:04:05.123456Z") @db.Timestamp(6) + tsMs DateTime @default("2024-01-02T03:04:05.123Z") + ts6l DateTime[] @default(["2024-01-02T03:04:05.123Z", "2024-01-02T03:04:05.123456Z"]) @db.Timestamp(6) +} +`); + const { columns } = serializedTable(contract, 'Post'); + expect({ + tsMicro: columns['tsMicro']?.['default'], + tsMs: columns['tsMs']?.['default'], + ts6l: columns['ts6l']?.['default'], + }).toEqual({ + tsMicro: { kind: 'function', expression: "'2024-01-02T03:04:05.123456'::timestamp(6)" }, + tsMs: { kind: 'literal', value: '2024-01-02T03:04:05.123' }, + ts6l: { + kind: 'function', + expression: + "ARRAY['2024-01-02T03:04:05.123', '2024-01-02T03:04:05.123456']::timestamp(6)[]", + }, + }); + }); +}); diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/support.ts b/packages/2-sql-family/2-authoring/contract-prisma7/test/support.ts index b59c8499b89c..23e4528d0ed6 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/support.ts +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/support.ts @@ -14,6 +14,7 @@ import { PostgresContractSerializer } from '@internal/target-postgres/runtime'; import { dirname, join } from 'pathe'; import { interpretPrisma7Documents } from '../src/interpreter'; import { prisma7Contract } from '../src/provider'; +import type { Prisma7TargetBinding } from '../src/target-binding'; export const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); @@ -110,7 +111,10 @@ export function fixtureSchemaText(caseName: string): string { } /** Interprets Prisma 7 schema text with the Postgres composition. */ -export function interpretSchemaTextResult(text: string) { +export function interpretSchemaTextResult( + text: string, + binding: Prisma7TargetBinding = prisma7PostgresBinding, +) { const context = postgresSourceContext(['schema.prisma']); const { document, sources } = parse(text, 'schema.prisma', { grammar: 'prisma-7' }); return interpretPrisma7Documents({ @@ -124,7 +128,7 @@ export function interpretSchemaTextResult(text: string) { ], seedDiagnostics: [], defaultNamespaceId: context.defaultNamespaceId, - binding: prisma7PostgresBinding, + binding, controlMutationDefaults: context.controlMutationDefaults, generatorFunctions: context.generatorFunctions, authoringContributions: context.authoringContributions, diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/type-choice-binding.test.ts b/packages/2-sql-family/2-authoring/contract-prisma7/test/type-choice-binding.test.ts new file mode 100644 index 000000000000..b7cad8fc148f --- /dev/null +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/type-choice-binding.test.ts @@ -0,0 +1,51 @@ +import type { Contract } from '@internal/contract/types'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { prisma7PostgresBinding } from '@internal/target-postgres/prisma7-binding'; +import { describe, expect, it } from 'vitest'; +import { interpretSchemaTextResult, serializedTable } from './support'; + +const SCHEMA = `datasource db { + provider = "postgresql" +} + +model Event { + id Int @id + at DateTime +} +`; + +const bindingWithDateTime = (args: readonly unknown[]) => ({ + ...prisma7PostgresBinding, + typeMap: { + ...prisma7PostgresBinding.typeMap, + scalars: { + ...prisma7PostgresBinding.typeMap.scalars, + DateTime: { constructorName: 'DateTime', args }, + }, + }, +}); + +describe('a Prisma 7 type the binding maps to a type choice', () => { + it('becomes the explicit type the values choose', () => { + const result = interpretSchemaTextResult( + SCHEMA, + bindingWithDateTime(['jsDate', 'timestamp', 3]), + ); + if (!result.ok) throw new Error(JSON.stringify(result.failure.diagnostics)); + expect(serializedTable(result.value as Contract, 'Event').columns['at']).toEqual({ + codecId: 'pg/timestamp-date@1', + dataType: 'pg/timestamp', + many: false, + nullable: false, + typeParams: { precision: 3 }, + }); + }); + + it('names the field and the binding when the values choose no type', () => { + expect(() => + interpretSchemaTextResult(SCHEMA, bindingWithDateTime(['jsDate', 'timetz'])), + ).toThrow( + 'The Prisma 7 target binding maps Field "Event.at" to type choice "DateTime" with values it refuses: Argument "storage" of DateTime is timetz; write one of: timestamptz, timestamp.', + ); + }); +}); diff --git a/packages/2-sql-family/2-authoring/contract-prisma7/test/updated-at-generators.test.ts b/packages/2-sql-family/2-authoring/contract-prisma7/test/updated-at-generators.test.ts index 453ead0dd568..28554577df53 100644 --- a/packages/2-sql-family/2-authoring/contract-prisma7/test/updated-at-generators.test.ts +++ b/packages/2-sql-family/2-authoring/contract-prisma7/test/updated-at-generators.test.ts @@ -61,13 +61,13 @@ model Clock { }, { ref: ref('stampedAt'), - onCreate: now('timestampStringNow', { precision: 0 }), - onUpdate: now('timestampStringNow', { precision: 0 }), + onCreate: now('timestampNow', { precision: 0 }), + onUpdate: now('timestampNow', { precision: 0 }), }, { ref: ref('updatedAt'), - onCreate: now('timestampStringNow', { precision: 3 }), - onUpdate: now('timestampStringNow', { precision: 3 }), + onCreate: now('timestampNow', { precision: 3 }), + onUpdate: now('timestampNow', { precision: 3 }), }, ]); }); diff --git a/packages/2-sql-family/2-authoring/contract-psl/README.md b/packages/2-sql-family/2-authoring/contract-psl/README.md index c0e09d84cc01..8373e51d1f28 100644 --- a/packages/2-sql-family/2-authoring/contract-psl/README.md +++ b/packages/2-sql-family/2-authoring/contract-psl/README.md @@ -18,7 +18,7 @@ This keeps core/CLI source-agnostic while giving PSL-first SQL users a one-line - Interpret SQL timestamp semantics: `DateTime @default(now())` as a storage default, and `temporal.CreatedAt` / `temporal.UpdatedAt` as client-side execution mutation defaults - Lower shared constructor expressions in both `types {}` blocks and inline field positions (for example `ShortName = sql.String(length: 35)` and `embedding pgvector.Vector(length: 1536)?`) - Lower supported default functions through composed registry inputs -- Resolve Postgres native storage types from bare names and constructor calls in type position (`Char`, `VarChar`, `Numeric`, `Uuid`, `Inet`, `SmallInt`, `Real`, `TimestampTemporal`, `TimestamptzTemporal`, `Date`, `DateTemporal`, `Time`, `TimeTemporal`, `Timetz`, `IntervalParts`, `Bit`, `VarBit`, `Json`, `Jsonb`, `BigIntNumber`, `UnboundedInt`) +- Resolve Postgres native storage types from bare names and constructor calls in type position (`Char`, `VarChar`, `Numeric`, `Uuid`, `Inet`, `SmallInt`, `Real`, `TimestampTemporal`, `TimestamptzTemporal`, `DateTemporal`, `TimeTemporal`, `Timetz`, `IntervalParts`, `Bit`, `VarBit`, `Json`, `Jsonb`, `BigIntNumber`, `UnboundedInt`), and resolve a type choice, such as the convenience types `DateTime`, `Date`, `Time` and `Duration`, to the constructor its arguments choose, in a field and in a `types {}` alias; `@default` and `@onCreate`/`@onUpdate` are read against the chosen constructor's codec - Map PSL relation action tokens to SQL contract referential actions and emit diagnostics for unsupported values - Emit deterministic relation metadata in `models..relations` - Enforce extension composition for namespaced constructor expressions and emit strict diagnostics for unsupported namespaced attributes @@ -90,7 +90,7 @@ Removed field presets: Generated values: - `@default(...)` sets a database default only: a literal, a tagged literal, `now()` or `autoincrement()`. A function the client generates in `@default`, such as `@default(uuid())`, is refused with `PSL_GENERATOR_IN_DEFAULT`, whose message names the `@onCreate` form. -- `@onCreate(fn())` and `@onUpdate(fn())` set the generator the client runs on create and on every update. `fn` is one of the generator functions the stack binds to the field's codec: `now()` on every date and time type, and `uuid()`, `uuid(7)`, `cuid(2)`, `ulid()`, `nanoid()`, `nanoid(n)` and `ksuid()` on every text type (`uuid` also on `Uuid`). The spec of each attribute offers only the functions bound to the field's codec, and the call resolves through `generatorFunctions` with the column's codec and type params: `now()` on a `Time(3)` column takes `precision: 3`. `nanoid()` writes 21 characters, or the size the call gives, on every column. A generator is refused on a column shorter than what it writes (`uuid` 36, `ksuid` 27, `ulid` 26, `cuid` 24), with the column's length read in its type's normal form, so a bare `Char` holds 1; SQLite does not limit the length. A function that does not apply is `PSL_GENERATOR_NOT_APPLICABLE`, an unknown one `PSL_UNKNOWN_GENERATOR_FUNCTION`, either attribute on a list field `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`, and the same attribute twice on one field `PSL_DUPLICATE_ATTRIBUTE`. +- `@onCreate(fn())` and `@onUpdate(fn())` set the generator the client runs on create and on every update. `fn` is one of the generator functions the stack binds to the field's codec: `now()` on every date and time type, and `uuid()`, `uuid(7)`, `cuid(2)`, `ulid()`, `nanoid()`, `nanoid(n)` and `ksuid()` on every text type (`uuid` also on `Uuid`). The spec of each attribute offers only the functions bound to the field's codec, and the call resolves through `generatorFunctions` with the column's codec and type params: `now()` on a `TimeTemporal(3)` column takes `precision: 3`. `nanoid()` writes 21 characters, or the size the call gives, on every column. A generator is refused on a column shorter than what it writes (`uuid` 36, `ksuid` 27, `ulid` 26, `cuid` 24), with the column's length read in its type's normal form, so a bare `Char` holds 1; SQLite does not limit the length. A function that does not apply is `PSL_GENERATOR_NOT_APPLICABLE`, an unknown one `PSL_UNKNOWN_GENERATOR_FUNCTION`, either attribute on a list field `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`, and the same attribute twice on one field `PSL_DUPLICATE_ATTRIBUTE`. - `@default(now()) @onCreate(now())` on one column keeps both: the client writes its time on create and the database default fills other inserts. `@onUpdate(...)` alone is allowed. - A generator works on an optional field, such as `token String? @onCreate(uuid())`. On create and update, a value left out is generated and an explicit `null` is stored. @@ -114,7 +114,7 @@ model Post { - An alias's base is a type constructor, called or bare, or an enum. It may not name another alias or a contributed alias (`PSL_UNSUPPORTED_NAMED_TYPE_BASE`). - An alias may carry `@default`, `@onCreate` and `@onUpdate`. They are lowered once, at the declaration, so a diagnostic is reported once, on the alias. Every field typed by the alias takes them. `@id` and `@unique` on an alias are `PSL_UNSUPPORTED_NAMED_TYPE_ATTRIBUTE`, because they belong to a model's field, not to its type; write them on the field. A refused attribute is dropped and the alias is kept. - A field may add any attribute its alias does not set, including `@id` and `@unique`. A field that writes an attribute its alias already sets is `PSL_DUPLICATE_ATTRIBUTE`, naming the alias. A list field typed by an alias that generates a value is `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`; an optional field may use one. -- The targets contribute aliases with no arguments, written bare: `temporal.CreatedAt` (`now()` on create) and `temporal.UpdatedAt` (`now()` on create and on update). On Postgres they are `TimestamptzTemporal`, with `temporal.CreatedAtString` / `temporal.UpdatedAtString` on `TimestamptzString` and `temporal.CreatedAtJsDate` / `temporal.UpdatedAtJsDate` on `TimestamptzJsDate`; on SQLite they are `DateTime`. Each stores what the matching `temporal.createdAt()` or `temporal.updatedAt()` preset stores. `temporal.CreatedAt(3)` is `PSL_INVALID_ATTRIBUTE_ARGUMENT`; write a precision with the attributes, as in `TimestamptzTemporal(3) @onCreate(now())`. +- The targets contribute aliases with no arguments, written bare: `temporal.CreatedAt` (`now()` on create) and `temporal.UpdatedAt` (`now()` on create and on update). On Postgres they are the convenience type `DateTime` with its defaults, a `timestamptz(3)` column read as a JavaScript `Date`; on SQLite they are `DateTime`. `temporal.CreatedAt(3)` is `PSL_INVALID_ATTRIBUTE_ARGUMENT`; write the type with the attributes, as in `DateTime(as: temporal) @onCreate(now())`. Integer representation authoring surface: diff --git a/packages/2-sql-family/2-authoring/contract-psl/src/data-type-default.ts b/packages/2-sql-family/2-authoring/contract-psl/src/data-type-default.ts index eda332d68ee6..12c35144217a 100644 --- a/packages/2-sql-family/2-authoring/contract-psl/src/data-type-default.ts +++ b/packages/2-sql-family/2-authoring/contract-psl/src/data-type-default.ts @@ -57,13 +57,23 @@ export interface DefaultColumn { readonly typeParams?: Record | undefined; } -/** A value in its stored JSON form that the column's type, under the column's parameters, or the column's codec refuses. */ -type CodecRefusal = { - readonly kind: 'refused-by-column'; - readonly codecId: string; - readonly message: string; - readonly elementIndex: number | undefined; -}; +/** + * A value in its stored JSON form that the column refuses: `refused-by-column` when its type, under the column's parameters, refuses it, and `refused-by-codec` when the type holds it as `stored` but the column's codec cannot hold it exactly, as a JavaScript `Date` cannot hold digits below a millisecond. On a list column, `stored` holds every element, and `elementIndex` is the first the codec refuses. + */ +type CodecRefusal = + | { + readonly kind: 'refused-by-column'; + readonly codecId: string; + readonly message: string; + readonly elementIndex: number | undefined; + } + | { + readonly kind: 'refused-by-codec'; + readonly codecId: string; + readonly message: string; + readonly elementIndex: number | undefined; + readonly stored: JsonValue; + }; /** * Why a default was refused, in parts, so each contract source words its own diagnostic: the cast @@ -89,9 +99,11 @@ export type DefaultRefusal = { /** The element types the receiving type's list cast takes. */ readonly elementTypes: readonly DataTypeId[]; } - | Omit + | DistributiveOmit ); +type DistributiveOmit = T extends unknown ? Omit : never; + export type ReadDefaultResult = | { readonly ok: true; readonly value: JsonValue } | { @@ -195,11 +207,9 @@ function storedValueReader(input: { const suggestedTypes = [descriptor.dataType]; const params = dataTypeParams(codec.dataType, input.column.typeParams); const read = (written: JsonValue, elementIndex: number | undefined): StoredReadResult => { - let stored: JsonValue; + let value: ReturnType; try { - const value = codec.dataType.valueFrom(written, params); - codec.fromDataTypeValue(value); - stored = codec.dataType.toContract(value); + value = codec.dataType.valueFrom(written, params); } catch (error) { if (isInternalError(error)) throw error; return { @@ -213,6 +223,23 @@ function storedValueReader(input: { suggestedTypes, }; } + const stored = codec.dataType.toContract(value); + try { + codec.fromDataTypeValue(value); + } catch (error) { + if (isInternalError(error)) throw error; + return { + ok: false, + refusal: { + kind: 'refused-by-codec', + codecId: input.column.codecId, + message: messageOf(error), + elementIndex, + stored, + }, + suggestedTypes, + }; + } return { ok: true, value: stored }; }; return { descriptor, params, read }; @@ -289,13 +316,33 @@ export function readDataTypeDefault(input: { if (input.isList) { const elements: JsonValue[] = []; + let codecRefusal: Extract | undefined; for (const [elementIndex, written] of input.written.elements.entries()) { if (written.kind === 'list') return nestedList(elementIndex, suggestedTypes); const element = readOne(written, elementIndex); - if (!element.ok) return element; + if (!element.ok && element.refusal.kind === 'refused-by-codec') { + codecRefusal ??= element.refusal; + elements.push(element.refusal.stored); + continue; + } + if (!element.ok) { + return codecRefusal === undefined + ? element + : refused( + { + kind: 'refused-by-column', + codecId: codecRefusal.codecId, + message: codecRefusal.message, + elementIndex: codecRefusal.elementIndex, + }, + suggestedTypes, + ); + } elements.push(element.value); } - return { ok: true, value: elements }; + return codecRefusal === undefined + ? { ok: true, value: elements } + : refused({ ...codecRefusal, stored: elements }, suggestedTypes); } return readListIntoScalar({ ...input, written: input.written, columnType, readStored }); @@ -450,11 +497,7 @@ function codecRefusalMessage(refusal: CodecRefusal, subject: string): RefusalMes } function storedRefusalMessage(refusal: StoredRefusal, subject: string): RefusalMessage { - if (refusal.kind === 'refused-by-column') return codecRefusalMessage(refusal, subject); - return { - code: 'PSL_INVALID_LITERAL', - message: `${location(subject, refusal.elementIndex)}: ${refusal.message}`, - }; + return codecRefusalMessage(refusal, subject); } /** A refusal worded as a PSL diagnostic's code and message; `guidance` says what to write instead. */ @@ -472,6 +515,7 @@ function refusalMessage( message: `${where}: this column holds a list, so its default is a list literal, as in [1, 2]`, }; case 'refused-by-column': + case 'refused-by-codec': return codecRefusalMessage(refusal, subject); case 'no-list-cast': return { @@ -494,6 +538,7 @@ function refusalSpan(refusal: DefaultRefusal, spans: DefaultSpans, subject: stri switch (refusal.kind) { case 'not-a-list': case 'refused-by-column': + case 'refused-by-codec': return spans.attribute; default: return writtenValueSpan(spans, refusal.elementIndex, subject); diff --git a/packages/2-sql-family/2-authoring/contract-psl/src/psl-column-resolution.ts b/packages/2-sql-family/2-authoring/contract-psl/src/psl-column-resolution.ts index 25be5e39c9b9..0889eeced8ef 100644 --- a/packages/2-sql-family/2-authoring/contract-psl/src/psl-column-resolution.ts +++ b/packages/2-sql-family/2-authoring/contract-psl/src/psl-column-resolution.ts @@ -52,6 +52,7 @@ import type { import { type DiagnosticSource, diagnosticSource, + isContributedTypeChoice, nodePslSpan, type PslDiagnosticCollector, typeAttributeSpecContextOfField, @@ -59,6 +60,9 @@ import { typeGeneratorAttributeSpec, } from '@internal/psl-parser'; import { + argumentIndexOf, + argumentSpan, + instantiatePslTypeChoice, isBareTypeConstructor, mapPslHelperArgs, reportTypeConstructorNotCalled, @@ -109,18 +113,6 @@ export type ColumnDescriptor = { readonly valueSet?: ValueSetRef; }; -function argumentSpan( - call: ResolvedTypeConstructorCall, - descriptors: readonly AuthoringArgumentDescriptor[] | undefined, - index: number | undefined, -): PslSpan { - if (index === undefined) return call.span; - const name = descriptors?.[index]?.name; - const named = call.args.find((arg) => arg.kind === 'named' && arg.name === name); - const positional = call.args.filter((arg) => arg.kind === 'positional')[index]; - return (named ?? positional)?.span ?? call.span; -} - /** * Checks the type parameters a constructor produced against its codec's parameter schema, * reporting a failure at the argument the failing parameter came from. Returns whether they passed. @@ -155,15 +147,6 @@ export function checkPslTypeParams(input: { } } -function argumentIndexOf(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null || !('details' in error)) return undefined; - const { details } = error; - if (typeof details !== 'object' || details === null || !('argumentIndex' in details)) { - return undefined; - } - return typeof details.argumentIndex === 'number' ? details.argumentIndex : undefined; -} - /** * Walks `authoringContributions.entityTypes` segment-by-segment and returns * the entity type descriptor at the resolved path, or `undefined` if no @@ -500,6 +483,19 @@ export function resolveFieldTypeDescriptor( case 'mixin': return NOT_RESOLVED; case 'contributedType': { + if (isContributedTypeChoice(resolution.symbol)) { + const instantiated = instantiatePslTypeChoice({ + symbol: resolution.symbol, + call, + span: field.span, + codecLookup: input.codecLookup, + diagnostics, + source, + entityLabel, + dataTypes: input.dataTypes, + }); + return instantiated === undefined ? NOT_RESOLVED : { ok: true, descriptor: instantiated }; + } const { descriptor, path } = resolution.symbol; const written = path.join('.'); if (descriptor.kind === 'typeAlias') { diff --git a/packages/2-sql-family/2-authoring/contract-psl/src/psl-named-type-resolution.ts b/packages/2-sql-family/2-authoring/contract-psl/src/psl-named-type-resolution.ts index 84d40e7d80c4..4132ac685ed2 100644 --- a/packages/2-sql-family/2-authoring/contract-psl/src/psl-named-type-resolution.ts +++ b/packages/2-sql-family/2-authoring/contract-psl/src/psl-named-type-resolution.ts @@ -26,6 +26,7 @@ import { resolveTypeAliases, typeAliasSpecContext, } from '@internal/psl-parser'; +import { instantiatePslTypeChoice } from '@internal/psl-parser/interpret'; import type { FieldAttributeAst, PslSources } from '@internal/psl-parser/syntax'; import { sqlCodecColumnLength } from '@internal/sql-contract/data-type'; import type { @@ -91,6 +92,18 @@ function aliasDescriptor( ): ColumnDescriptor | undefined { const { base, symbol } = alias; const source = diagnosticSource(input.sources, symbol.node.syntax); + if (base.kind === 'typeChoice') { + return instantiatePslTypeChoice({ + symbol: base.symbol, + call: base.call, + span: symbol.span, + codecLookup: input.codecLookup, + diagnostics: input.diagnostics, + source, + entityLabel: `Type alias "${symbol.name}"`, + dataTypes: input.dataTypes, + }); + } if (base.kind === 'typeConstructor') { if (base.call === undefined) return instantiateAuthoringTypeConstructor(base.descriptor, []); return instantiatePslTypeConstructor({ diff --git a/packages/2-sql-family/2-authoring/contract-psl/src/removed-field-presets.ts b/packages/2-sql-family/2-authoring/contract-psl/src/removed-field-presets.ts index fef5a0f9c873..33d9287751d3 100644 --- a/packages/2-sql-family/2-authoring/contract-psl/src/removed-field-presets.ts +++ b/packages/2-sql-family/2-authoring/contract-psl/src/removed-field-presets.ts @@ -1,5 +1,6 @@ import type { ResolvedTypeConstructorCall } from '@internal/psl-parser'; import { + presetType, type RemovedFieldPreset, type RemovedFieldPresets, removedPresetWithPhases, @@ -33,33 +34,40 @@ function idAlias( ): readonly RemovedFieldPreset[] { return GENERATED_ID_COLUMN_ALIAS_NAMESPACES.flatMap((namespace) => removedPresetWrittenAs( - `${qualified(namespace, alias)}${attributes === undefined ? '' : ` ${attributes}`}`, + qualified(namespace, alias), generatedIdColumnAliases[alias].output.type.codecId, + attributes, ), ); } function nanoid(id: boolean): readonly RemovedFieldPreset[] { return GENERATED_ID_COLUMN_ALIAS_NAMESPACES.map((namespace) => { - const write = (size: string | undefined) => { + const write = (size: string | undefined, modifier: string) => { const column = size === undefined ? qualified(namespace, 'Nanoid') : qualified(namespace, `Char(${size})`); - return id ? `${column} @id @onCreate(nanoid(${size ?? ''}))` : column; + return `${column}${modifier}${id ? ` @id @onCreate(nanoid(${size ?? ''}))` : ''}`; }; return { - type: [...namespace, 'Nanoid'], + type: presetType(qualified(namespace, 'Nanoid')), codecId: generatedIdColumnAliases.Nanoid.output.type.codecId, - rewrite: (call) => { + rewrite: (call, modifier) => { const options = nanoidSize(call); return options === undefined - ? `Write ${write(undefined)}, or ${write('n')} for a size of n.` - : `Write ${write(options.size)}.`; + ? `Write ${write(undefined, modifier)}, or ${write('n', modifier)} for a size of n.` + : `Write ${write(options.size, modifier)}.`; }, }; }); } -/** The SQL family's removed field presets: each row applies only where its rewrite's type stores the preset's codec, and a row with no codec id writes a type alias only the targets with the preset contribute. A generated id preset is written with the target's top-level alias where it has one, and with the `sql.` alias elsewhere. */ +const TEMPORAL_DATE_TIME = presetType('DateTime', { as: 'temporal' }); +const STRING_DATE_TIME = presetType('DateTime', { as: 'string' }); +const SQLITE_DATETIME = 'sqlite/datetime@1'; +const CREATED = '@onCreate(now())'; +const UPDATED = '@onCreate(now()) @onUpdate(now())'; + +/** The SQL family's removed field presets: each row applies only where its rewrite's type stores the preset's codec. A generated id preset is written with the target's top-level alias where it has one, and with the `sql.` alias elsewhere. A date and time preset is written with the convenience call that gives the explicit type it stored, where one does (ADR 279). */ export const removedSqlFieldPresets: RemovedFieldPresets = { uuidString: idAlias('UuidString'), ulid: idAlias('Ulid'), @@ -80,33 +88,55 @@ export const removedSqlFieldPresets: RemovedFieldPresets = { boolean: removedPresetWrittenAs('Boolean', 'pg/bool@1'), json: removedPresetWrittenAs('Jsonb', 'pg/jsonb@1'), bytes: removedPresetWrittenAs('Bytes', 'pg/bytea@1'), - dateTime: removedPresetWrittenAs('DateTime', 'pg/timestamptz-temporal@1'), + dateTime: removedPresetWrittenAs(TEMPORAL_DATE_TIME, 'pg/timestamptz-temporal@1'), uuidNative: removedPresetWrittenAs('Uuid', 'pg/uuid@1'), - 'id.uuidv4Native': removedPresetWrittenAs('Uuid @id @onCreate(uuid())', 'pg/uuid@1'), - 'id.uuidv7Native': removedPresetWrittenAs('Uuid @id @onCreate(uuid(7))', 'pg/uuid@1'), - 'temporal.createdAt': removedPresetWrittenAs('temporal.CreatedAt'), - 'temporal.updatedAt': removedPresetWrittenAs('temporal.UpdatedAt'), - 'temporal.createdAtString': removedPresetWrittenAs('temporal.CreatedAtString'), - 'temporal.updatedAtString': removedPresetWrittenAs('temporal.UpdatedAtString'), - 'temporal.createdAtJsDate': removedPresetWrittenAs('temporal.CreatedAtJsDate'), - 'temporal.updatedAtJsDate': removedPresetWrittenAs('temporal.UpdatedAtJsDate'), + 'id.uuidv4Native': removedPresetWrittenAs('Uuid', 'pg/uuid@1', '@id @onCreate(uuid())'), + 'id.uuidv7Native': removedPresetWrittenAs('Uuid', 'pg/uuid@1', '@id @onCreate(uuid(7))'), + 'temporal.createdAt': [ + ...removedPresetWrittenAs(TEMPORAL_DATE_TIME, 'pg/timestamptz-temporal@1', CREATED), + ...removedPresetWrittenAs('temporal.CreatedAt', SQLITE_DATETIME), + ], + 'temporal.updatedAt': [ + ...removedPresetWrittenAs(TEMPORAL_DATE_TIME, 'pg/timestamptz-temporal@1', UPDATED), + ...removedPresetWrittenAs('temporal.UpdatedAt', SQLITE_DATETIME), + ], + 'temporal.createdAtString': removedPresetWrittenAs( + STRING_DATE_TIME, + 'pg/timestamptz-string@1', + CREATED, + ), + 'temporal.updatedAtString': removedPresetWrittenAs( + STRING_DATE_TIME, + 'pg/timestamptz-string@1', + UPDATED, + ), + 'temporal.createdAtJsDate': removedPresetWrittenAs( + 'TimestamptzJsDate', + 'pg/timestamptz-date@1', + CREATED, + ), + 'temporal.updatedAtJsDate': removedPresetWrittenAs( + 'TimestamptzJsDate', + 'pg/timestamptz-date@1', + UPDATED, + ), 'temporal.timestamp': removedPresetWithPhases( 'TimestampTemporal', 'pg/timestamp-temporal@1', true, ), 'temporal.timestamptz': removedPresetWithPhases( - 'TimestamptzTemporal', + TEMPORAL_DATE_TIME, 'pg/timestamptz-temporal@1', true, ), 'temporal.timestampString': removedPresetWithPhases( - 'TimestampString', + presetType('DateTime', { as: 'string', storage: 'timestamp' }), 'pg/timestamp-string@1', true, ), 'temporal.timestamptzString': removedPresetWithPhases( - 'TimestamptzString', + STRING_DATE_TIME, 'pg/timestamptz-string@1', true, ), @@ -114,11 +144,24 @@ export const removedSqlFieldPresets: RemovedFieldPresets = { 'TimestamptzJsDate', 'pg/timestamptz-date@1', true, + { type: presetType('DateTime'), defaultPrecision: '3' }, + ), + 'temporal.date': removedPresetWithPhases( + presetType('Date', { as: 'temporal' }), + 'pg/date-temporal@1', + false, + ), + 'temporal.dateString': removedPresetWithPhases('Date', 'pg/date-string@1', false), + 'temporal.time': removedPresetWithPhases( + presetType('Time', { as: 'temporal' }), + 'pg/time-temporal@1', + true, + ), + 'temporal.timeString': removedPresetWithPhases('Time', 'pg/time-string@1', true), + 'temporal.timetz': removedPresetWithPhases( + presetType('Time', { storage: 'timetz' }), + 'pg/timetz@1', + true, ), - 'temporal.date': removedPresetWithPhases('Date', 'pg/date-temporal@1', false), - 'temporal.dateString': removedPresetWithPhases('DateString', 'pg/date-string@1', false), - 'temporal.time': removedPresetWithPhases('Time', 'pg/time-temporal@1', true), - 'temporal.timeString': removedPresetWithPhases('TimeString', 'pg/time-string@1', true), - 'temporal.timetz': removedPresetWithPhases('Timetz', 'pg/timetz@1', true), - 'temporal.datetime': removedPresetWithPhases('DateTime', 'sqlite/datetime@1', false), + 'temporal.datetime': removedPresetWithPhases('DateTime', SQLITE_DATETIME, false), }; diff --git a/packages/2-sql-family/2-authoring/contract-psl/src/sql-attribute-specs.ts b/packages/2-sql-family/2-authoring/contract-psl/src/sql-attribute-specs.ts index cb2857d3a042..136dc317dc29 100644 --- a/packages/2-sql-family/2-authoring/contract-psl/src/sql-attribute-specs.ts +++ b/packages/2-sql-family/2-authoring/contract-psl/src/sql-attribute-specs.ts @@ -298,10 +298,11 @@ export function defaultFunctionCall( /** The data type of the column a field declares, when its type, or the named type it is declared with, is a constructor the stack knows. */ function receivingDataType(ctx: TypeAttributeSpecContext): string | undefined { - const typeConstructor = ctx.typeConstructor; - return typeConstructor === undefined - ? undefined - : ctx.dataTypes.dataTypeOfCodec(contributedTypeCodecId(typeConstructor.descriptor)); + const codecId = + ctx.contributedType === undefined + ? undefined + : contributedTypeCodecId(ctx.contributedType, ctx.dataTypes); + return codecId === undefined ? undefined : ctx.dataTypes.dataTypeOfCodec(codecId); } /** Whether a value of `type` can be written for a column of `receiving`: it is that type, or the column's type casts from it. An unknown column takes every type. */ @@ -876,7 +877,7 @@ export function fieldSpecContext(input: { model: input.model, field: input.field, typeResolution, - typeConstructor: contributedTypeOf(typeResolution, input.binder), + contributedType: contributedTypeOf(typeResolution, input.binder, input.field.typeConstructor), defaultFunctionRegistry: input.defaultFunctionRegistry, generatorFunctions: input.generatorFunctions, dataTypes: input.dataTypes, diff --git a/packages/2-sql-family/2-authoring/contract-psl/src/unresolved-type.ts b/packages/2-sql-family/2-authoring/contract-psl/src/unresolved-type.ts index 3cb796df22e8..b2921f736678 100644 --- a/packages/2-sql-family/2-authoring/contract-psl/src/unresolved-type.ts +++ b/packages/2-sql-family/2-authoring/contract-psl/src/unresolved-type.ts @@ -46,7 +46,7 @@ export function describeUnresolvedSqlType( if (written !== 'Unsupported') return describeRemovedFieldPreset( removedSqlFieldPresets, - call, + field, contributions.type, isDeclared, ); diff --git a/packages/2-sql-family/2-authoring/contract-psl/test/interpreter.defaults.temporal-codecs.test.ts b/packages/2-sql-family/2-authoring/contract-psl/test/interpreter.defaults.temporal-codecs.test.ts index 35003b814384..1dc2efadca39 100644 --- a/packages/2-sql-family/2-authoring/contract-psl/test/interpreter.defaults.temporal-codecs.test.ts +++ b/packages/2-sql-family/2-authoring/contract-psl/test/interpreter.defaults.temporal-codecs.test.ts @@ -52,7 +52,13 @@ stamped ${field} columnAndDefaults(model('TimestampTemporal(3) @onCreate(now()) @onUpdate(now())')), ).toEqual({ column: { ...timestamp, typeParams: { precision: 3 } }, - defaults: [{ ref: stampedRef, onCreate: plainDateTimeNow, onUpdate: plainDateTimeNow }], + defaults: [ + { + ref: stampedRef, + onCreate: { ...plainDateTimeNow, params: { precision: 3 } }, + onUpdate: { ...plainDateTimeNow, params: { precision: 3 } }, + }, + ], }); }); diff --git a/packages/2-sql-family/2-authoring/contract-psl/test/interpreter.removed-field-presets.test.ts b/packages/2-sql-family/2-authoring/contract-psl/test/interpreter.removed-field-presets.test.ts index 473c9b7a1958..fad0862ddc4a 100644 --- a/packages/2-sql-family/2-authoring/contract-psl/test/interpreter.removed-field-presets.test.ts +++ b/packages/2-sql-family/2-authoring/contract-psl/test/interpreter.removed-field-presets.test.ts @@ -50,8 +50,58 @@ function temporalAliases(codecId: string) { } as const; } +const precision = { + kind: 'value', + dataType: 'pg/int4', + name: 'precision', + optional: true, +} as const; + +function option(name: string, values: readonly [string, ...string[]]) { + return { kind: 'option', name, values, default: values[0], optional: true } as const; +} + +const convenienceTypes = { + DateTime: { + kind: 'typeChoice', + args: [ + option('as', ['jsDate', 'temporal', 'string']), + option('storage', ['timestamptz', 'timestamp']), + precision, + ], + choices: [ + { + when: { as: 'jsDate', storage: 'timestamptz' }, + type: 'TimestamptzJsDate', + defaults: { precision: 3 }, + }, + { when: { as: 'temporal', storage: 'timestamptz' }, type: 'TimestamptzTemporal' }, + { when: { as: 'string', storage: 'timestamptz' }, type: 'TimestamptzString' }, + { when: { as: 'string', storage: 'timestamp' }, type: 'TimestampString' }, + ], + }, + Date: { + kind: 'typeChoice', + args: [option('as', ['string', 'temporal'])], + choices: [ + { when: { as: 'string' }, type: 'DateString' }, + { when: { as: 'temporal' }, type: 'DateTemporal' }, + ], + }, + Time: { + kind: 'typeChoice', + args: [option('as', ['string', 'temporal']), option('storage', ['time', 'timetz']), precision], + choices: [ + { when: { as: 'string', storage: 'time' }, type: 'TimeString' }, + { when: { as: 'temporal', storage: 'time' }, type: 'TimeTemporal' }, + { when: { as: 'string', storage: 'timetz' }, type: 'Timetz' }, + ], + }, +} as const satisfies AuthoringTypeNamespace; + const postgresTypes: AuthoringTypeNamespace = { ...postgresScalarAuthoringTypes, + ...convenienceTypes, Char: char, ...generatedIdAliases, DateString: { kind: 'typeConstructor', output: { codecId: 'pg/date-string@1' } }, @@ -59,26 +109,10 @@ const postgresTypes: AuthoringTypeNamespace = { TimestamptzString: precisionType('pg/timestamptz-string@1'), TimestamptzJsDate: precisionType('pg/timestamptz-date@1'), TimeString: precisionType('pg/time-string@1'), + DateTemporal: { kind: 'typeConstructor', output: { codecId: 'pg/date-temporal@1' } }, + TimeTemporal: precisionType('pg/time-temporal@1'), sql: sqlChar, - temporal: { - ...temporalAliases('pg/timestamptz-temporal@1'), - CreatedAtString: { - kind: 'typeAlias', - output: { type: { codecId: 'pg/timestamptz-string@1' } }, - }, - UpdatedAtString: { - kind: 'typeAlias', - output: { type: { codecId: 'pg/timestamptz-string@1' } }, - }, - CreatedAtJsDate: { - kind: 'typeAlias', - output: { type: { codecId: 'pg/timestamptz-date@1' } }, - }, - UpdatedAtJsDate: { - kind: 'typeAlias', - output: { type: { codecId: 'pg/timestamptz-date@1' } }, - }, - }, + temporal: temporalAliases('pg/timestamptz-date@1'), }; const sqliteTypes: AuthoringTypeNamespace = { @@ -154,50 +188,64 @@ describe('a removed field preset in a SQL schema', () => { ['boolean()', 'Write Boolean.'], ['json()', 'Write Jsonb.'], ['bytes()', 'Write Bytes.'], - ['dateTime()', 'Write DateTime.'], + ['dateTime()', 'Write DateTime(as: temporal).'], ['uuidNative()', 'Write Uuid.'], ['id.uuidv4Native()', 'Write Uuid @id @onCreate(uuid()).'], ['id.uuidv7Native()', 'Write Uuid @id @onCreate(uuid(7)).'], - ['temporal.createdAt()', 'Write temporal.CreatedAt.'], - ['temporal.updatedAt()', 'Write temporal.UpdatedAt.'], - ['temporal.createdAtString()', 'Write temporal.CreatedAtString.'], - ['temporal.updatedAtString()', 'Write temporal.UpdatedAtString.'], - ['temporal.createdAtJsDate()', 'Write temporal.CreatedAtJsDate.'], - ['temporal.updatedAtJsDate()', 'Write temporal.UpdatedAtJsDate.'], + ['temporal.createdAt()', 'Write DateTime(as: temporal) @onCreate(now()).'], + ['temporal.updatedAt()', 'Write DateTime(as: temporal) @onCreate(now()) @onUpdate(now()).'], + ['temporal.createdAtString()', 'Write DateTime(as: string) @onCreate(now()).'], + [ + 'temporal.updatedAtString()', + 'Write DateTime(as: string) @onCreate(now()) @onUpdate(now()).', + ], + ['temporal.createdAtJsDate()', 'Write TimestamptzJsDate @onCreate(now()).'], + ['temporal.updatedAtJsDate()', 'Write TimestamptzJsDate @onCreate(now()) @onUpdate(now()).'], [ 'temporal.timestamp(3, onCreate: now, onUpdate: now)', - 'Write TimestampTemporal(3) @onCreate(now()) @onUpdate(now()).', + 'Write TimestampTemporal(precision: 3) @onCreate(now()) @onUpdate(now()).', ], [ 'temporal.timestamp(precision: 6, onCreate: now)', - 'Write TimestampTemporal(6) @onCreate(now()).', + 'Write TimestampTemporal(precision: 6) @onCreate(now()).', ], [ 'temporal.timestamp(3, now, now)', - 'Write TimestampTemporal(3) @onCreate(now()) @onUpdate(now()).', + 'Write TimestampTemporal(precision: 3) @onCreate(now()) @onUpdate(now()).', + ], + ['temporal.timestamptz()', 'Write DateTime(as: temporal).'], + ['temporal.timestamptz(onUpdate: now)', 'Write DateTime(as: temporal) @onUpdate(now()).'], + [ + 'temporal.timestampString(6, onUpdate: now)', + 'Write DateTime(as: string, storage: timestamp, precision: 6) @onUpdate(now()).', ], - ['temporal.timestamptz()', 'Write TimestamptzTemporal.'], - ['temporal.timestamptz(onUpdate: now)', 'Write TimestamptzTemporal @onUpdate(now()).'], - ['temporal.timestampString(6, onUpdate: now)', 'Write TimestampString(6) @onUpdate(now()).'], [ 'temporal.timestamptzString(3, onCreate: now, onUpdate: now)', - 'Write TimestamptzString(3) @onCreate(now()) @onUpdate(now()).', + 'Write DateTime(as: string, precision: 3) @onCreate(now()) @onUpdate(now()).', ], + ['temporal.timestamptzJsDate(3, onCreate: now)', 'Write DateTime @onCreate(now()).'], [ - 'temporal.timestamptzJsDate(3, onCreate: now)', - 'Write TimestamptzJsDate(3) @onCreate(now()).', + 'temporal.timestamptzJsDate(6, onCreate: now)', + 'Write DateTime(precision: 6) @onCreate(now()).', ], - ['temporal.date(onCreate: now)', 'Write Date @onCreate(now()).'], + ['temporal.timestamptzJsDate(onCreate: now)', 'Write TimestamptzJsDate @onCreate(now()).'], + ['temporal.date(onCreate: now)', 'Write Date(as: temporal) @onCreate(now()).'], [ 'temporal.dateString(onCreate: now, onUpdate: now)', - 'Write DateString @onCreate(now()) @onUpdate(now()).', + 'Write Date @onCreate(now()) @onUpdate(now()).', + ], + [ + 'temporal.time(3, onUpdate: now)', + 'Write Time(as: temporal, precision: 3) @onUpdate(now()).', + ], + [ + 'temporal.time(3, onUpdate: now, now)', + 'Write Time(as: temporal, precision: 3) @onCreate(now()) @onUpdate(now()).', ], - ['temporal.time(3, onUpdate: now)', 'Write Time(3) @onUpdate(now()).'], - ['temporal.time(3, onUpdate: now, now)', 'Write Time(3) @onCreate(now()) @onUpdate(now()).'], - ['temporal.timeString(3, onCreate: now)', 'Write TimeString(3) @onCreate(now()).'], + ['temporal.timeString(3, onCreate: now)', 'Write Time(precision: 3) @onCreate(now()).'], [ 'temporal.timetz(6, onCreate: now, onUpdate: now)', - 'Write Timetz(6) @onCreate(now()) @onUpdate(now()).', + 'Write Time(storage: timetz, precision: 6) @onCreate(now()) @onUpdate(now()).', ], ])('refuses %s with its rewrite', (call, rewrite) => { expect(diagnosticsOf(call, postgresInput)).toEqual(removed(call, rewrite)); @@ -209,8 +257,8 @@ describe('a removed field preset in a SQL schema', () => { ['temporal.timestamp(3, precision: 4)', 'TimestampTemporal(...)'], ['temporal.timestamp(frequency: now)', 'TimestampTemporal(...)'], ['temporal.timestamp(-1)', 'TimestampTemporal(...)'], - ['temporal.time(onCreate: now, 3, now)', 'Time(...)'], - ['temporal.date(3)', 'Date'], + ['temporal.time(onCreate: now, 3, now)', 'Time(as: temporal, ...)'], + ['temporal.date(3)', 'Date(as: temporal)'], ])( 'names the rewrite without the arguments of %s, which the preset did not take', (call, type) => { @@ -256,17 +304,50 @@ describe('a removed field preset in a SQL schema', () => { ).toEqual(removed('id.ulid()', 'Write sql.Ulid @id @onCreate(ulid()).')); }); + it('names no temporal alias where the schema declares DateTime, since the alias stores another codec than the preset', () => { + const result = interpretSqlContract( + 'model DateTime {\n id Int @id\n}\n\nmodel Event {\n id Int @id\n value temporal.createdAt()\n}\n', + postgresInput, + ); + expect( + result.ok ? [] : result.failure.diagnostics.map(({ code, message }) => ({ code, message })), + ).toEqual([ + { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find type "temporal.createdAt"' }, + ]); + }); + it('keeps the binder message for a preset written without a call', () => { expect(diagnosticsOf('temporal.createdAt', postgresInput)).toEqual([ { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find type "temporal.createdAt"' }, ]); }); - it('refuses each preset field once, keeping the field modifiers out of the call', () => { - expect(diagnosticsOf('temporal.createdAt()?', postgresInput)).toEqual( - removed('temporal.createdAt()', 'Write temporal.CreatedAt.'), - ); - }); + it.each([ + ['temporal.createdAt()?', 'Write DateTime(as: temporal)? @onCreate(now()).'], + ['temporal.timestamptz(onCreate: now)?', 'Write DateTime(as: temporal)? @onCreate(now()).'], + [ + 'temporal.time(3, onUpdate: now)[]', + 'Write Time(as: temporal, precision: 3)[] @onUpdate(now()).', + ], + ['id.ulid()?', 'Write Ulid? @id @onCreate(ulid()).'], + ['nanoid()[]', 'Write Nanoid[].'], + [ + 'id.nanoid({ length: 16 })?', + 'Write Nanoid? @id @onCreate(nanoid()), or Char(n)? @id @onCreate(nanoid(n)) for a size of n.', + ], + [ + 'temporal.timestamp(onCreate: later)?', + 'Write TimestampTemporal(...)? with @onCreate(now()) and @onUpdate(now()) for the phases the preset set.', + ], + ])( + 'refuses %s once, writing the modifier right after the type and before the attributes', + (field, rewrite) => { + const refusals = diagnosticsOf(field, postgresInput).filter( + ({ code }) => code === 'PSL_UNRESOLVED_REFERENCE', + ); + expect(refusals).toEqual(removed(field.replace(/(\?|\[\])$/, ''), rewrite)); + }, + ); it('keeps the binder message for a SQLite preset', () => { expect(diagnosticsOf('temporal.datetime(onCreate: now)', postgresInput)).toEqual([ diff --git a/packages/2-sql-family/2-authoring/contract-psl/test/interpreter.type-choices.test.ts b/packages/2-sql-family/2-authoring/contract-psl/test/interpreter.type-choices.test.ts new file mode 100644 index 000000000000..c0e7a6533dcb --- /dev/null +++ b/packages/2-sql-family/2-authoring/contract-psl/test/interpreter.type-choices.test.ts @@ -0,0 +1,280 @@ +import type { + AuthoringContributions, + AuthoringTypeNamespace, +} from '@internal/framework-components/authoring'; +import { describe, expect, it } from 'vitest'; +import { postgresNativeScalarTypeDescriptors, postgresScalarAuthoringTypes } from './fixtures'; +import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contract-storage'; +import { + builtinControlMutationDefaults, + interpretPostgresSchema, +} from './interpreter-defaults-support'; +import { unboundTables } from './unbound-tables'; + +const precision = { + kind: 'value', + dataType: 'pg/int4', + name: 'precision', + optional: true, +} as const; + +const types = { + ...postgresScalarAuthoringTypes, + TimestampString: { + kind: 'typeConstructor', + args: [precision], + output: { + codecId: 'pg/timestamp-string@1', + typeParams: { precision: { kind: 'arg', index: 0 } }, + }, + }, + DateTemporal: { kind: 'typeConstructor', output: { codecId: 'pg/date-temporal@1' } }, + DateString: { kind: 'typeConstructor', output: { codecId: 'pg/date-string@1' } }, + Moment: { + kind: 'typeChoice', + args: [ + { + kind: 'option', + name: 'as', + values: ['temporal', 'string'], + default: 'temporal', + optional: true, + }, + { kind: 'option', name: 'storage', values: ['tz', 'local'], default: 'tz', optional: true }, + precision, + ], + choices: [ + { + when: { as: 'temporal', storage: 'tz' }, + type: 'TimestamptzTemporal', + defaults: { precision: 3 }, + }, + { when: { as: 'temporal', storage: 'local' }, type: 'TimestampTemporal' }, + { when: { as: 'string', storage: 'local' }, type: 'TimestampString' }, + ], + }, + Day: { + kind: 'typeChoice', + args: [ + { + kind: 'option', + name: 'as', + values: ['string', 'temporal'], + default: 'string', + optional: true, + }, + precision, + ], + choices: [ + { when: { as: 'string' }, type: 'DateString' }, + { when: { as: 'temporal' }, type: 'DateTemporal' }, + ], + }, + Label: { + kind: 'typeChoice', + args: [ + { + kind: 'option', + name: 'as', + values: ['text', 'number'], + default: 'text', + optional: true, + }, + ], + choices: [ + { when: { as: 'text' }, type: 'String' }, + { when: { as: 'number' }, type: 'Int' }, + ], + }, +} satisfies AuthoringTypeNamespace; + +const authoringContributions = { + entityTypes: {}, + pslBlockDescriptors: {}, + modelAttributes: {}, + type: types, +} satisfies AuthoringContributions; + +function interpret(schema: string) { + return interpretPostgresSchema(schema, { + scalarColumnDescriptors: postgresNativeScalarTypeDescriptors, + controlMutationDefaults: builtinControlMutationDefaults, + authoringContributions, + }); +} + +function lowered(schema: string) { + const result = interpret(schema); + if (!result.ok) throw new Error(JSON.stringify(result.failure.diagnostics, null, 2)); + return { + tables: unboundTables(sqlStorageFromSuccessfulSqlInterpretation(result.value)), + defaults: result.value.execution?.mutations.defaults ?? [], + }; +} + +function diagnostics(schema: string) { + const result = interpret(schema); + if (result.ok) throw new Error('expected the schema to be refused'); + return result.failure.diagnostics.map(({ code, message, span }) => ({ + code, + message, + columns: [span?.start.column, span?.end.column], + })); +} + +const model = (...fields: readonly string[]) => + ['model T {', ' id Int @id', ...fields.map((field) => ` ${field}`), '}'].join('\n'); + +describe('a type choice in a field type', () => { + it('emits what writing the type it chooses emits, bare, named and positional', () => { + expect( + lowered( + model( + 'bare Moment', + 'named Moment(as: string, storage: local)', + 'positional Moment(temporal, local, 6)', + 'day Day(as: temporal)', + 'list Moment[]', + 'maybe Moment?', + ), + ), + ).toEqual( + lowered( + model( + 'bare TimestamptzTemporal(3)', + 'named TimestampString', + 'positional TimestampTemporal(6)', + 'day DateTemporal', + 'list TimestamptzTemporal(3)[]', + 'maybe TimestamptzTemporal(3)?', + ), + ), + ); + }); + + it('passes a written precision over the row default', () => { + expect(lowered(model('at Moment(precision: 6)'))).toEqual( + lowered(model('at TimestamptzTemporal(6)')), + ); + }); + + it('refuses an unlisted combination at the argument that makes it invalid', () => { + const line = 'at Moment(as: string, storage: tz)'; + const start = ' '.length + line.indexOf('storage'); + expect(diagnostics(model(line))).toEqual([ + { + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: + 'Field "T.at" type "Moment": Argument "storage" of Moment cannot be tz with as: string; write one of: local.', + columns: [start + 1, start + 1 + 'storage: tz'.length], + }, + ]); + }); + + it('refuses a value the option does not list, naming the values it does', () => { + expect(diagnostics(model('at Day(as: jsDate)'))).toMatchObject([ + { + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: + 'Field "T.at" type "Day": Argument "as" of Day is jsDate; write one of: string, temporal.', + }, + ]); + }); + + it('refuses a passed-on argument the chosen type does not take', () => { + expect(diagnostics(model('at Day(precision: 3)'))).toMatchObject([ + { + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: + 'Field "T.at" type "Day": Argument "precision" of Day does not apply to DateString, the type that as: string chooses.', + }, + ]); + }); + + it('refuses an argument the choice does not declare, naming the arguments it does', () => { + const line = 'at Moment(value: 3)'; + const start = ' '.length + line.indexOf('value'); + expect(diagnostics(model(line))).toEqual([ + { + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: + 'Field "T.at" type "Moment" has no argument "value"; its arguments are as, storage and precision.', + columns: [start + 1, start + 1 + 'value: 3'.length], + }, + ]); + }); + + it('refuses a precision the chosen type’s codec refuses, naming the argument of the choice', () => { + const line = 'at Moment(as: temporal, storage: local, precision: 7)'; + const start = ' '.length + line.indexOf('precision'); + expect(diagnostics(model(line))).toEqual([ + { + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: + 'Field "T.at" type "Moment": Argument "precision" of Moment is invalid: precision must be at most 6 (was 7)', + columns: [start + 1, start + 1 + 'precision: 7'.length], + }, + ]); + }); +}); + +describe('a type choice behind a types {} alias', () => { + it('emits what the type it chooses emits', () => { + expect( + lowered(`types { + Stamp = Moment(as: string, storage: local) + Plain = Moment +} +${model('stamp Stamp', 'plain Plain')}`), + ).toEqual(lowered(model('stamp TimestampString', 'plain TimestamptzTemporal(3)'))); + }); +}); + +describe('attributes on a field typed by a type choice', () => { + it('admit a @default by the codec the call chooses', () => { + expect(lowered(model('label Label @default("a")'))).toEqual( + lowered(model('label String @default("a")')), + ); + expect(diagnostics(model('label Label(as: number) @default("a")'))).toMatchObject([ + { code: 'PSL_VALUE_TYPE_INCOMPATIBLE' }, + ]); + }); + + it('admit a @default by the codec the alias’s call chooses', () => { + expect( + diagnostics(`types { + Count = Label(as: number) +} +${model('count Count @default("a")')}`), + ).toMatchObject([{ code: 'PSL_VALUE_TYPE_INCOMPATIBLE' }]); + }); + + it('resolve @onCreate and @onUpdate generators for the codec the call chooses', () => { + expect( + lowered( + model( + 'a Moment @onCreate(now()) @onUpdate(now())', + 'b Moment(as: string, storage: local) @onCreate(now())', + 'c Day @onCreate(now())', + ), + ), + ).toEqual( + lowered( + model( + 'a TimestamptzTemporal(3) @onCreate(now()) @onUpdate(now())', + 'b TimestampString @onCreate(now())', + 'c DateString @onCreate(now())', + ), + ), + ); + }); + + it('resolve generators through an alias written as a type choice', () => { + expect( + lowered(`types { + Stamp = Moment(as: string, storage: local) @onCreate(now()) +} +${model('stamp Stamp')}`), + ).toEqual(lowered(model('stamp TimestampString @onCreate(now())'))); + }); +}); diff --git a/packages/2-sql-family/2-authoring/contract-ts/README.md b/packages/2-sql-family/2-authoring/contract-ts/README.md index 875bdd073529..fb99460f10e3 100644 --- a/packages/2-sql-family/2-authoring/contract-ts/README.md +++ b/packages/2-sql-family/2-authoring/contract-ts/README.md @@ -143,7 +143,7 @@ Anything else inside `${…}` is a type error, and `CONTRACT.SQL_EXPRESSION_INTE Pack-provided helpers are available through the callback overload. This is the surface that exposes the `type.*` helpers, the `gen.*` calls, and extension helpers such as `type.pgvector.Vector(...)`. There are no field presets: `field.text()`, `field.id.*`, `field.temporal.*` and `field.generated(...)` were removed, and a field is written `field.column(type.String())`, with `.id()` and `.onCreate(gen.uuid())` where the preset set them. A read of a removed preset from the callback's `field`, such as `field.text` from a contract that was not type-checked, throws `CONTRACT.FIELD_HELPER_UNKNOWN`, with the preset's rewrite where the target has the type it writes. -`type.*` has a helper for every PSL type constructor of the stack, such as `type.VarChar(64)`, `type.TimestampString(3)` and `type.String()`, plus the type aliases the packs contribute, such as `type.temporal.CreatedAt()` and `type.temporal.UpdatedAt()`. Pass the result to `field.column(...)`. A type alias is a constant: field builders are immutable, so one constant serves every field and model that uses it, and each column stores the alias's codec and params inline. A constant may carry a default and generators, and a field may add what the constant does not set: +`type.*` has a helper for every PSL type constructor of the stack, such as `type.VarChar(64)`, `type.TimestampString(3)` and `type.String()`, plus the type aliases the packs contribute, such as `type.temporal.CreatedAt()` and `type.temporal.UpdatedAt()`, which on Postgres are `type.DateTime()` with the `now` generators. A type choice, such as PostgreSQL's `DateTime`, takes its arguments as one object, `type.DateTime({ as: 'string', precision: 6 })`, and is typed as the explicit type its arguments choose. Because the type depends on the option values, each must be a literal: write the object inline, or build it in a variable with `as const`, such as `const options = { as: 'string' } as const`. An object whose option is typed `string`, or a union of values, does not compile, since no single explicit type fits it. Pass the result to `field.column(...)`. A type alias is a constant: field builders are immutable, so one constant serves every field and model that uses it, and each column stores the alias's codec and params inline. A constant may carry a default and generators, and a field may add what the constant does not set: ```typescript const UpdatedAt = field.column(type.TimestampString(3)).onCreate(gen.now()).onUpdate(gen.now()); diff --git a/packages/2-sql-family/2-authoring/contract-ts/src/authoring-helper-runtime.ts b/packages/2-sql-family/2-authoring/contract-ts/src/authoring-helper-runtime.ts index ca69f0a9a5fe..a3609be9a46d 100644 --- a/packages/2-sql-family/2-authoring/contract-ts/src/authoring-helper-runtime.ts +++ b/packages/2-sql-family/2-authoring/contract-ts/src/authoring-helper-runtime.ts @@ -1,6 +1,7 @@ import { assertSafeAuthoringHelperKey } from '@internal/contract-authoring'; import type { AuthoringArgumentDescriptor, + AuthoringTypeChoiceOption, AuthoringTypeNamespace, DataTypeSupport, WrittenScalar, @@ -10,9 +11,14 @@ import { instantiateAuthoringTypeAlias, instantiateAuthoringTypeConstructor, isAuthoringTypeAliasDescriptor, + isAuthoringTypeChoiceDescriptor, isAuthoringTypeConstructorDescriptor, readWrittenValueAs, + resolveAuthoringTypeChoice, + typeChoiceArgumentsFromObject, + typeConstructorLookup, validateAuthoringHelperArguments, + validateAuthoringTypeChoiceParams, validateAuthoringTypeParams, } from '@internal/framework-components/authoring'; import { @@ -67,17 +73,17 @@ function readValueArgument( } /** - * The arguments of a type constructor call as the constructor receives them: each `value` argument read as a value of its data type, through the same read and cast as a PSL argument, so both give the same parameters. ADR 129, ADR 254. + * The arguments of a type constructor or type choice call as the constructor receives them: each `value` argument read as a value of its data type, through the same read and cast as a PSL argument, so both give the same parameters. ADR 129, ADR 254. */ export function readValueArguments( helperPath: string, - descriptors: readonly AuthoringArgumentDescriptor[] | undefined, + descriptors: readonly (AuthoringArgumentDescriptor | AuthoringTypeChoiceOption)[] | undefined, args: readonly unknown[], dataTypes: DataTypeSupport, ): readonly unknown[] { return args.map((value, index) => { const descriptor = descriptors?.[index]; - if (descriptor === undefined || value === undefined) return value; + if (descriptor?.kind !== 'value' || value === undefined) return value; const label = descriptor.name === undefined ? `Argument #${index + 1} of ${helperPath}` @@ -118,6 +124,35 @@ export function createTypeHelpersFromNamespace( continue; } + if (isAuthoringTypeChoiceDescriptor(value)) { + const helperPath = currentPath.join('.'); + const constructorNamed = typeConstructorLookup(namespace); + helpers[key] = (...written: readonly unknown[]): AuthoredStorageTypeInstance => { + const args = typeChoiceArgumentsFromObject(helperPath, value, written); + validateAuthoringHelperArguments(helperPath, value.args, args); + const chosen = resolveAuthoringTypeChoice({ + helperPath, + descriptor: value, + args: readValueArguments(helperPath, value.args, args, lookups.dataTypes), + constructorNamed, + }); + const output = instantiateAuthoringTypeConstructor(chosen.descriptor, chosen.args); + validateAuthoringTypeChoiceParams( + helperPath, + value, + chosen.descriptor, + output.typeParams, + lookups.codecLookup.descriptorFor(output.codecId)?.paramsSchema, + ); + return { + kind: CODEC_INSTANCE_KIND, + codecId: output.codecId, + typeParams: output.typeParams ?? {}, + }; + }; + continue; + } + if (isAuthoringTypeAliasDescriptor(value)) { const helperPath = currentPath.join('.'); helpers[key] = (...args: readonly unknown[]): TypeAliasColumnType => { diff --git a/packages/2-sql-family/2-authoring/contract-ts/src/composed-authoring-helpers.ts b/packages/2-sql-family/2-authoring/contract-ts/src/composed-authoring-helpers.ts index d8a4e487ed2f..2b6b4925064e 100644 --- a/packages/2-sql-family/2-authoring/contract-ts/src/composed-authoring-helpers.ts +++ b/packages/2-sql-family/2-authoring/contract-ts/src/composed-authoring-helpers.ts @@ -1,4 +1,5 @@ import { + type ArgTypeFromDescriptor, type CanonicalArguments, type CodecTypeMap, composePackAuthoringNamespace, @@ -16,6 +17,9 @@ import type { AuthoringArgumentDescriptor, AuthoringEntityTypeNamespace, AuthoringTypeAliasDescriptor, + AuthoringTypeChoiceArgument, + AuthoringTypeChoiceDescriptor, + AuthoringTypeChoiceRow, AuthoringTypeConstructorDescriptor, AuthoringTypeNamespace, DataTypeComponent, @@ -23,6 +27,7 @@ import type { import { assembleDataTypeSupport, assertNoCrossRegistryCollisions, + assertTypeChoicesValid, } from '@internal/framework-components/authoring'; import type { CodecLookupWithDescriptors } from '@internal/framework-components/codec'; import { @@ -101,6 +106,161 @@ type TypeHelperFunction< ) => StorageTypeFromDescriptor> : () => StorageTypeFromDescriptor; +type RowDefault = Row extends { + readonly defaults: infer Defaults; +} + ? Name extends keyof Defaults + ? Defaults[Name] + : undefined + : undefined; + +/** The arguments a type choice passes on to the constructor its row chooses, by that constructor's argument positions: the written value, or the row's default. */ +type PassedOnArguments< + Parameters extends readonly AuthoringArgumentDescriptor[], + Written extends object, + Row extends AuthoringTypeChoiceRow, +> = { + readonly [Index in keyof Parameters]: Parameters[Index] extends { + readonly name: infer Name extends string; + } + ? Name extends keyof Written + ? Written[Name] extends undefined + ? RowDefault + : Written[Name] + : RowDefault + : undefined; +}; + +type ChosenStorageType< + Chosen, + Written extends object, + Row extends AuthoringTypeChoiceRow, + ValueTypes extends DataTypeArgumentTypes, +> = Chosen extends AuthoringTypeConstructorDescriptor + ? Chosen extends { + readonly args: infer Parameters extends readonly AuthoringArgumentDescriptor[]; + } + ? StorageTypeFromDescriptor< + Chosen, + CanonicalArguments, ValueTypes> + > + : StorageTypeFromDescriptor + : never; + +type ParameterNames = Chosen extends { + readonly args: readonly (infer Parameter)[]; +} + ? Parameter extends { readonly name: infer Name extends string } + ? Name + : never + : never; + +type RowValue< + Row extends AuthoringTypeChoiceRow, + Name extends string, +> = Name extends keyof Row['when'] ? Row['when'][Name] : never; + +/** Whether `Row` gives the option `Argument` its default, so a call choosing the row may leave it out. */ +type RowTakesDefault< + Argument extends AuthoringTypeChoiceArgument, + Row extends AuthoringTypeChoiceRow, +> = Argument extends { readonly default: infer Default } + ? RowValue extends Default + ? true + : false + : true; + +/** The options a call choosing `Row` must write: those whose value in the row is not their default. */ +type RequiredRowOptions< + Args extends readonly AuthoringTypeChoiceArgument[], + Row extends AuthoringTypeChoiceRow, +> = { + readonly [Argument in Args[number] as Argument extends { readonly kind: 'option' } + ? RowTakesDefault extends true + ? never + : Argument['name'] + : never]: RowValue; +}; + +/** What else a call choosing `Row` may write: an option the row gives its default, and an argument the chosen constructor takes. */ +type OptionalRowArguments< + Args extends readonly AuthoringTypeChoiceArgument[], + Row extends AuthoringTypeChoiceRow, + Chosen, + ValueTypes extends DataTypeArgumentTypes, +> = { + readonly [Argument in Args[number] as Argument extends { readonly kind: 'option' } + ? RowTakesDefault extends true + ? Argument['name'] + : never + : Argument['name'] extends ParameterNames + ? Argument['name'] + : never]?: + | (Argument extends { readonly kind: 'option' } + ? RowValue + : Argument extends AuthoringArgumentDescriptor + ? ArgTypeFromDescriptor + : never) + | undefined; +}; + +/** `Params`, with every key `Allowed` does not name typed `never`, so a call writing such a key does not compile. */ +type OnlyKeys = Params & { + readonly [Key in Exclude]: never; +}; + +type RowArguments< + Args extends readonly AuthoringTypeChoiceArgument[], + Row extends AuthoringTypeChoiceRow, + Chosen, + ValueTypes extends DataTypeArgumentTypes, +> = RequiredRowOptions & OptionalRowArguments; + +type RowObjectHelperFunction< + Args extends readonly AuthoringTypeChoiceArgument[], + Row extends AuthoringTypeChoiceRow, + Chosen, + ValueTypes extends DataTypeArgumentTypes, +> = >( + args: OnlyKeys>, +) => ChosenStorageType; + +/** A call choosing `Row`: with no arguments too when the row gives every option its default. */ +type RowHelperFunction< + Args extends readonly AuthoringTypeChoiceArgument[], + Row extends AuthoringTypeChoiceRow, + Chosen, + ValueTypes extends DataTypeArgumentTypes, +> = keyof RequiredRowOptions extends never + ? (() => ChosenStorageType, Row, ValueTypes>) & + RowObjectHelperFunction + : RowObjectHelperFunction; + +type RowOverloads< + Args extends readonly AuthoringTypeChoiceArgument[], + Rows extends readonly AuthoringTypeChoiceRow[], + Namespace, + ValueTypes extends DataTypeArgumentTypes, +> = Rows extends readonly [ + infer Row extends AuthoringTypeChoiceRow, + ...infer Rest extends readonly AuthoringTypeChoiceRow[], +] + ? RowHelperFunction< + Args, + Row, + Row['type'] extends keyof Namespace ? Namespace[Row['type']] : never, + ValueTypes + > & + RowOverloads + : unknown; + +/** A type choice's helper, which takes its arguments as one object keyed by name: one overload per row, so a call is typed by the row its option values choose, and a combination with no row, an unknown key, or an argument the chosen type does not take does not compile. */ +type TypeChoiceHelperFunction< + Descriptor extends AuthoringTypeChoiceDescriptor, + Namespace, + ValueTypes extends DataTypeArgumentTypes, +> = RowOverloads; + type AliasGeneratorCalls = (Output extends { readonly onCreate: GeneratorFunctionCall } ? { readonly onCreate: GeneratorFunctionCall } : unknown) & @@ -127,9 +287,11 @@ type TypeHelpersFromNamespace : Namespace[K] extends AuthoringTypeAliasDescriptor ? TypeAliasHelperFunction - : Namespace[K] extends Record - ? TypeHelpersFromNamespace - : never; + : Namespace[K] extends AuthoringTypeChoiceDescriptor + ? TypeChoiceHelperFunction + : Namespace[K] extends Record + ? TypeHelpersFromNamespace + : never; }; type MergeExtensionDataTypeArgumentTypes = @@ -291,6 +453,9 @@ export function createComposedAuthoringHelpers< // `createControlStack`, the TS DSL composes here. Both seams need the guard. assertNoCrossRegistryCollisions(typeNamespace, entityNamespace); assertNoBuiltInEntityCollisions(entityNamespace); + for (const component of components) { + assertTypeChoicesValid(component.authoring?.type ?? {}, component.id ?? ''); + } const { support } = assembleDataTypeSupport( components, (codecId) => options.codecLookup.descriptorFor(codecId)?.dataType, diff --git a/packages/2-sql-family/2-authoring/contract-ts/src/removed-field-presets.ts b/packages/2-sql-family/2-authoring/contract-ts/src/removed-field-presets.ts index 671c7fa3461f..52a686e72866 100644 --- a/packages/2-sql-family/2-authoring/contract-ts/src/removed-field-presets.ts +++ b/packages/2-sql-family/2-authoring/contract-ts/src/removed-field-presets.ts @@ -1,7 +1,9 @@ import { answerMissingPropertyReads } from '@internal/contract-authoring'; import { type AuthoringTypeNamespace, + instantiateAuthoringTypeCall, isAuthoringTypeAliasDescriptor, + isAuthoringTypeChoiceDescriptor, isAuthoringTypeDescriptor, } from '@internal/framework-components/authoring'; import { @@ -9,21 +11,47 @@ import { type GeneratedIdColumnAlias, generatedIdColumnAliases, } from '@internal/sql-contract/generated-id-column-aliases'; +import { isInternalError } from '@internal/utils/internal-error'; import { contractError } from './contract-errors'; const GUIDE = 'The upgrade guide "Field presets are removed" lists every rewrite.'; -/** A removed field preset: its rewrite, named only where the type the rewrite writes resolves, and to `codecId` when one is given, so a row does not fire on a target the preset never existed on. */ +/** The type a rewrite writes: its path, and for a type choice the options it is called with, such as `{ as: 'temporal' }`. */ +interface RewriteType { + readonly path: readonly string[]; + readonly options: Readonly> | undefined; +} + +/** A removed field preset: its rewrite, named only where `type` resolves to `codecId`, the codec the preset stored, so a row does not fire on a target the preset never existed on. */ interface RemovedFieldPreset { - readonly type: readonly string[]; - readonly codecId: string | undefined; + readonly type: RewriteType; + readonly codecId: string; readonly rewrite: string; } -/** The row of a preset whose rewrite is `rewrite`; its type is the first `type.X(` that `rewrite` writes. */ -function writtenAs(rewrite: string, codecId?: string): readonly RemovedFieldPreset[] { - const type = /type\.([\w.]+)\(/.exec(rewrite)?.[1] ?? ''; - return [{ type: type.split('.'), codecId, rewrite }]; +/** The explicit type constructor at the dotted `path`. */ +const explicit = (path: string): RewriteType => ({ path: path.split('.'), options: undefined }); + +/** The type choice at `path`, called with `options`. */ +const choice = (path: string, options: Readonly> = {}): RewriteType => ({ + path: path.split('.'), + options, +}); + +/** The call to `type` as TypeScript writes it, with `precision` when given: one object of arguments for a type choice, and a positional argument for an explicit type. */ +function typeCall({ path, options }: RewriteType, precision?: string): string { + const name = `type.${path.join('.')}`; + if (options === undefined) return `${name}(${precision ?? ''})`; + const members = [ + ...Object.entries(options).map(([option, value]) => `${option}: '${value}'`), + ...(precision === undefined ? [] : [`precision: ${precision}`]), + ]; + return members.length === 0 ? `${name}()` : `${name}({ ${members.join(', ')} })`; +} + +/** The row of a preset whose rewrite is the column of `type`, then `chain`. */ +function writtenAs(type: RewriteType, codecId: string, chain = ''): readonly RemovedFieldPreset[] { + return [{ type, codecId, rewrite: `field.column(${typeCall(type)})${chain}` }]; } /** The `type.` prefix of a namespace, such as `sql.` for `['sql']`. */ @@ -35,7 +63,7 @@ function idAlias( write: (prefix: string) => string, ): readonly RemovedFieldPreset[] { return GENERATED_ID_COLUMN_ALIAS_NAMESPACES.map((namespace) => ({ - type: [...namespace, alias], + type: { path: [...namespace, alias], options: undefined }, codecId: generatedIdColumnAliases[alias].output.type.codecId, rewrite: write(prefixOf(namespace)), })); @@ -48,12 +76,21 @@ const aliasColumn = (alias: GeneratedIdColumnAlias) => idAlias(alias, (prefix) => column(prefix, `${alias}()`)); const aliasId = (alias: GeneratedIdColumnAlias, generator: string) => idAlias(alias, (prefix) => id(column(prefix, `${alias}()`), generator)); -const withPhases = (type: string, codecId: string, precision: boolean) => - writtenAs( - `field.column(type.${type}(${precision ? 'p' : ''})), with .onCreate(gen.now()) and .onUpdate(gen.now()) for the phases the preset set${precision ? ', and without p when it set no precision' : ''}`, +const withPhases = (type: RewriteType, codecId: string, precision: boolean) => [ + { + type, codecId, - ); + rewrite: `field.column(${typeCall(type, precision ? 'p' : undefined)}), with .onCreate(gen.now()) and .onUpdate(gen.now()) for the phases the preset set${precision ? `, and without ${type.options === undefined ? 'p' : 'precision: p'} when it set no precision` : ''}`, + }, +]; +const SQLITE_DATETIME = 'sqlite/datetime@1'; +const CREATED = '.onCreate(gen.now())'; +const UPDATED = '.onCreate(gen.now()).onUpdate(gen.now())'; +const TEMPORAL_DATE_TIME = choice('DateTime', { as: 'temporal' }); +const STRING_DATE_TIME = choice('DateTime', { as: 'string' }); + +/** Each removed SQL preset and its rewrites. A date and time preset is written with the convenience call that gives the explicit type it stored, where one does (ADR 279). */ export const PRESETS: Readonly> = { uuidString: aliasColumn('UuidString'), ulid: aliasColumn('Ulid'), @@ -64,16 +101,16 @@ export const PRESETS: Readonly> = ), cuid2: aliasColumn('Cuid2'), ksuid: aliasColumn('Ksuid'), - text: writtenAs('field.column(type.String())', 'pg/text@1'), - int: writtenAs('field.column(type.Int())', 'pg/int4@1'), - bigint: writtenAs('field.column(type.BigInt())', 'pg/int8@1'), - float: writtenAs('field.column(type.Float())', 'pg/float8@1'), - decimal: writtenAs('field.column(type.Decimal())', 'pg/numeric@1'), - boolean: writtenAs('field.column(type.Boolean())', 'pg/bool@1'), - json: writtenAs('field.column(type.Jsonb())', 'pg/jsonb@1'), - bytes: writtenAs('field.column(type.Bytes())', 'pg/bytea@1'), - dateTime: writtenAs('field.column(type.DateTime())', 'pg/timestamptz-temporal@1'), - uuidNative: writtenAs('field.column(type.Uuid())', 'pg/uuid@1'), + text: writtenAs(explicit('String'), 'pg/text@1'), + int: writtenAs(explicit('Int'), 'pg/int4@1'), + bigint: writtenAs(explicit('BigInt'), 'pg/int8@1'), + float: writtenAs(explicit('Float'), 'pg/float8@1'), + decimal: writtenAs(explicit('Decimal'), 'pg/numeric@1'), + boolean: writtenAs(explicit('Boolean'), 'pg/bool@1'), + json: writtenAs(explicit('Jsonb'), 'pg/jsonb@1'), + bytes: writtenAs(explicit('Bytes'), 'pg/bytea@1'), + dateTime: writtenAs(TEMPORAL_DATE_TIME, 'pg/timestamptz-temporal@1'), + uuidNative: writtenAs(explicit('Uuid'), 'pg/uuid@1'), generated: idAlias( 'UuidString', (prefix) => @@ -89,40 +126,79 @@ export const PRESETS: Readonly> = ), 'id.cuid2': aliasId('Cuid2', 'cuid(2)'), 'id.ksuid': aliasId('Ksuid', 'ksuid()'), - 'id.uuidv4Native': writtenAs(id('field.column(type.Uuid())', 'uuid()'), 'pg/uuid@1'), - 'id.uuidv7Native': writtenAs(id('field.column(type.Uuid())', 'uuid(7)'), 'pg/uuid@1'), - 'temporal.createdAt': writtenAs('field.column(type.temporal.CreatedAt())'), - 'temporal.updatedAt': writtenAs('field.column(type.temporal.UpdatedAt())'), - 'temporal.createdAtString': writtenAs('field.column(type.temporal.CreatedAtString())'), - 'temporal.updatedAtString': writtenAs('field.column(type.temporal.UpdatedAtString())'), - 'temporal.createdAtJsDate': writtenAs('field.column(type.temporal.CreatedAtJsDate())'), - 'temporal.updatedAtJsDate': writtenAs('field.column(type.temporal.UpdatedAtJsDate())'), - 'temporal.timestamp': withPhases('TimestampTemporal', 'pg/timestamp-temporal@1', true), - 'temporal.timestamptz': withPhases('TimestamptzTemporal', 'pg/timestamptz-temporal@1', true), - 'temporal.timestampString': withPhases('TimestampString', 'pg/timestamp-string@1', true), - 'temporal.timestamptzString': withPhases('TimestamptzString', 'pg/timestamptz-string@1', true), - 'temporal.timestamptzJsDate': withPhases('TimestamptzJsDate', 'pg/timestamptz-date@1', true), - 'temporal.date': withPhases('Date', 'pg/date-temporal@1', false), - 'temporal.dateString': withPhases('DateString', 'pg/date-string@1', false), - 'temporal.time': withPhases('Time', 'pg/time-temporal@1', true), - 'temporal.timeString': withPhases('TimeString', 'pg/time-string@1', true), - 'temporal.timetz': withPhases('Timetz', 'pg/timetz@1', true), - 'temporal.datetime': withPhases('DateTime', 'sqlite/datetime@1', false), + 'id.uuidv4Native': writtenAs(explicit('Uuid'), 'pg/uuid@1', '.id().onCreate(gen.uuid())'), + 'id.uuidv7Native': writtenAs(explicit('Uuid'), 'pg/uuid@1', '.id().onCreate(gen.uuid(7))'), + 'temporal.createdAt': [ + ...writtenAs(TEMPORAL_DATE_TIME, 'pg/timestamptz-temporal@1', CREATED), + ...writtenAs(explicit('temporal.CreatedAt'), SQLITE_DATETIME), + ], + 'temporal.updatedAt': [ + ...writtenAs(TEMPORAL_DATE_TIME, 'pg/timestamptz-temporal@1', UPDATED), + ...writtenAs(explicit('temporal.UpdatedAt'), SQLITE_DATETIME), + ], + 'temporal.createdAtString': writtenAs(STRING_DATE_TIME, 'pg/timestamptz-string@1', CREATED), + 'temporal.updatedAtString': writtenAs(STRING_DATE_TIME, 'pg/timestamptz-string@1', UPDATED), + 'temporal.createdAtJsDate': writtenAs( + explicit('TimestamptzJsDate'), + 'pg/timestamptz-date@1', + CREATED, + ), + 'temporal.updatedAtJsDate': writtenAs( + explicit('TimestamptzJsDate'), + 'pg/timestamptz-date@1', + UPDATED, + ), + 'temporal.timestamp': withPhases(explicit('TimestampTemporal'), 'pg/timestamp-temporal@1', true), + 'temporal.timestamptz': withPhases(TEMPORAL_DATE_TIME, 'pg/timestamptz-temporal@1', true), + 'temporal.timestampString': withPhases( + choice('DateTime', { as: 'string', storage: 'timestamp' }), + 'pg/timestamp-string@1', + true, + ), + 'temporal.timestamptzString': withPhases(STRING_DATE_TIME, 'pg/timestamptz-string@1', true), + 'temporal.timestamptzJsDate': withPhases( + explicit('TimestamptzJsDate'), + 'pg/timestamptz-date@1', + true, + ), + 'temporal.date': withPhases(choice('Date', { as: 'temporal' }), 'pg/date-temporal@1', false), + 'temporal.dateString': withPhases(choice('Date'), 'pg/date-string@1', false), + 'temporal.time': withPhases(choice('Time', { as: 'temporal' }), 'pg/time-temporal@1', true), + 'temporal.timeString': withPhases(choice('Time'), 'pg/time-string@1', true), + 'temporal.timetz': withPhases(choice('Time', { storage: 'timetz' }), 'pg/timetz@1', true), + 'temporal.datetime': withPhases(explicit('DateTime'), SQLITE_DATETIME, false), }; -function codecIdAt(types: AuthoringTypeNamespace, path: readonly string[]): string | undefined { +/** The codec a column of `type` is stored with in `types`, a type choice resolved with its options as a column resolves it; `undefined` when `types` has no such type or the options choose none. */ +function codecIdAt( + types: AuthoringTypeNamespace, + path: readonly string[], + options: Readonly> | undefined, +): string | undefined { const [name, ...rest] = path; if (name === undefined || !Object.hasOwn(types, name)) return undefined; const value = types[name]; if (value === undefined) return undefined; - if (!isAuthoringTypeDescriptor(value)) return codecIdAt(value, rest); + if (!isAuthoringTypeDescriptor(value)) return codecIdAt(value, rest, options); if (rest.length > 0) return undefined; + if (isAuthoringTypeChoiceDescriptor(value)) { + const args = value.args.map((argument) => + argument.name !== undefined && options !== undefined && Object.hasOwn(options, argument.name) + ? options[argument.name] + : undefined, + ); + try { + return instantiateAuthoringTypeCall(types, name, args).codecId; + } catch (error) { + if (isInternalError(error) || !(error instanceof Error)) throw error; + return undefined; + } + } return isAuthoringTypeAliasDescriptor(value) ? value.output.type.codecId : value.output.codecId; } function appliesTo(preset: RemovedFieldPreset, types: AuthoringTypeNamespace): boolean { - const codecId = codecIdAt(types, preset.type); - return codecId !== undefined && (preset.codecId === undefined || codecId === preset.codecId); + return codecIdAt(types, preset.type.path, preset.type.options) === preset.codecId; } function refuse(path: string, types: AuthoringTypeNamespace): never { diff --git a/packages/2-sql-family/2-authoring/contract-ts/test/helpers/type-choice-packs.ts b/packages/2-sql-family/2-authoring/contract-ts/test/helpers/type-choice-packs.ts new file mode 100644 index 000000000000..16cb5659796f --- /dev/null +++ b/packages/2-sql-family/2-authoring/contract-ts/test/helpers/type-choice-packs.ts @@ -0,0 +1,94 @@ +import type { FamilyPackRef, TargetPackRef } from '@internal/framework-components/components'; +import { + fixtureDataTypeEntries, + fixtureDataTypes, +} from '../../../contract-psl/test/fixture-data-types'; +import { + familyPack as generatorFamilyPack, + targetPack as generatorTargetPack, +} from './generator-function-packs'; + +const familyPackBase = { + ...generatorFamilyPack, + dataTypes: fixtureDataTypes, + authoring: { dataTypes: fixtureDataTypeEntries }, +} as const satisfies FamilyPackRef<'sql'>; + +export const familyPack: typeof familyPackBase & { + readonly __dataTypeArguments?: { + readonly 'pg/int4': { readonly argument: number; readonly parameter: number }; + }; +} = familyPackBase; + +const precision = { + kind: 'value', + dataType: 'pg/int4', + name: 'precision', + optional: true, +} as const; + +const withPrecision = (codecId: CodecId) => + ({ + kind: 'typeConstructor', + args: [precision], + output: { codecId, typeParams: { precision: { kind: 'arg', index: 0 } } }, + }) as const; + +export const targetPack = { + ...generatorTargetPack, + authoring: { + type: { + TimestamptzTemporal: withPrecision('pg/timestamptz-temporal@1'), + TimestampTemporal: withPrecision('pg/timestamp-temporal@1'), + TimestampString: withPrecision('pg/timestamp-string@1'), + DateString: { kind: 'typeConstructor', output: { codecId: 'pg/date-string@1' } }, + DateTemporal: { kind: 'typeConstructor', output: { codecId: 'pg/date-temporal@1' } }, + Moment: { + kind: 'typeChoice', + args: [ + { + kind: 'option', + name: 'as', + values: ['temporal', 'string', 'date'], + default: 'temporal', + optional: true, + }, + { + kind: 'option', + name: 'storage', + values: ['tz', 'local'], + default: 'tz', + optional: true, + }, + precision, + ], + choices: [ + { + when: { as: 'temporal', storage: 'tz' }, + type: 'TimestamptzTemporal', + defaults: { precision: 3 }, + }, + { when: { as: 'temporal', storage: 'local' }, type: 'TimestampTemporal' }, + { when: { as: 'string', storage: 'local' }, type: 'TimestampString' }, + { when: { as: 'date', storage: 'local' }, type: 'DateString' }, + ], + }, + Day: { + kind: 'typeChoice', + args: [ + { + kind: 'option', + name: 'as', + values: ['string', 'temporal'], + default: 'string', + optional: true, + }, + ], + choices: [ + { when: { as: 'string' }, type: 'DateString' }, + { when: { as: 'temporal' }, type: 'DateTemporal' }, + ], + }, + }, + }, +} as const satisfies TargetPackRef<'sql', 'postgres'>; diff --git a/packages/2-sql-family/2-authoring/contract-ts/test/removed-field-presets.test.ts b/packages/2-sql-family/2-authoring/contract-ts/test/removed-field-presets.test.ts index 57c31b5f782e..358a5a20020a 100644 --- a/packages/2-sql-family/2-authoring/contract-ts/test/removed-field-presets.test.ts +++ b/packages/2-sql-family/2-authoring/contract-ts/test/removed-field-presets.test.ts @@ -32,13 +32,48 @@ const postgresLike = { ...generatedIdAliases, Jsonb: typeConstructor('pg/jsonb@1'), Uuid: typeConstructor('pg/uuid@1'), - Time: typeConstructor('pg/time-temporal@1'), - DateTime: typeConstructor('pg/timestamptz-temporal@1'), + TimeTemporal: typeConstructor('pg/time-temporal@1'), + TimeString: typeConstructor('pg/time-string@1'), + TimestamptzTemporal: typeConstructor('pg/timestamptz-temporal@1'), + TimestamptzJsDate: typeConstructor('pg/timestamptz-date@1'), + DateTime: { + kind: 'typeChoice', + args: [ + { + kind: 'option', + name: 'as', + values: ['date', 'temporal'], + default: 'date', + optional: true, + }, + ], + choices: [ + { when: { as: 'date' }, type: 'TimestamptzJsDate' }, + { when: { as: 'temporal' }, type: 'TimestamptzTemporal' }, + ], + }, + Time: { + kind: 'typeChoice', + args: [ + { + kind: 'option', + name: 'as', + values: ['string', 'temporal'], + default: 'string', + optional: true, + }, + { kind: 'value', name: 'precision', dataType: 'pg/int4', optional: true }, + ], + choices: [ + { when: { as: 'string' }, type: 'TimeString' }, + { when: { as: 'temporal' }, type: 'TimeTemporal' }, + ], + }, sql: { Char: typeConstructor('sql/char@1'), ...generatedIdAliases }, temporal: { UpdatedAt: { kind: 'typeAlias', - output: { type: { codecId: 'pg/timestamptz-temporal@1' } }, + output: { type: { codecId: 'pg/timestamptz-date@1' } }, }, }, } as const; @@ -100,10 +135,17 @@ describe('a read of a removed field preset from the SQL field helpers', () => { ['id.cuid2', 'field.column(type.Cuid2()).id().onCreate(gen.cuid(2))'], ['id.ksuid', 'field.column(type.Ksuid()).id().onCreate(gen.ksuid())'], ['id.uuidv7Native', 'field.column(type.Uuid()).id().onCreate(gen.uuid(7))'], - ['temporal.updatedAt', 'field.column(type.temporal.UpdatedAt())'], + [ + 'temporal.updatedAt', + "field.column(type.DateTime({ as: 'temporal' })).onCreate(gen.now()).onUpdate(gen.now())", + ], [ 'temporal.time', - 'field.column(type.Time(p)), with .onCreate(gen.now()) and .onUpdate(gen.now()) for the phases the preset set, and without p when it set no precision', + "field.column(type.Time({ as: 'temporal', precision: p })), with .onCreate(gen.now()) and .onUpdate(gen.now()) for the phases the preset set, and without precision: p when it set no precision", + ], + [ + 'temporal.timeString', + 'field.column(type.Time({ precision: p })), with .onCreate(gen.now()) and .onUpdate(gen.now()) for the phases the preset set, and without precision: p when it set no precision', ], [ 'generated', @@ -121,6 +163,25 @@ describe('a read of a removed field preset from the SQL field helpers', () => { expect(refusalOf(sqliteField, path)).toMatchObject(unknown(path)); }); + it('names the forms that replace presets where the type choice call stores another codec, and temporal.UpdatedAt stores another codec than the preset', () => { + const jsDateOnly = { + ...postgresLike, + DateTime: { + ...postgresLike.DateTime, + choices: [{ when: { as: 'date' }, type: 'TimestamptzJsDate' }], + }, + } as const; + expect( + refusalOf(refusingRemovedFieldPresets(field, jsDateOnly), 'temporal.updatedAt'), + ).toMatchObject(unknown('temporal.updatedAt')); + }); + + it('names the forms that replace presets where the rewrite type is a type choice that stores another codec', () => { + expect(refusalOf(postgresField, 'temporal.datetime')).toMatchObject( + unknown('temporal.datetime'), + ); + }); + it.each([ ['ulid', 'field.column(type.sql.Ulid())'], ['id.ulid', 'field.column(type.sql.Ulid()).id().onCreate(gen.ulid())'], diff --git a/packages/2-sql-family/2-authoring/contract-ts/test/type-choice-helpers.test-d.ts b/packages/2-sql-family/2-authoring/contract-ts/test/type-choice-helpers.test-d.ts new file mode 100644 index 000000000000..e3ea91336152 --- /dev/null +++ b/packages/2-sql-family/2-authoring/contract-ts/test/type-choice-helpers.test-d.ts @@ -0,0 +1,90 @@ +import { expectTypeOf, test } from 'vitest'; +import { testTypeLookups } from '../../../1-core/contract/test/test-type-lookups'; +import { createComposedAuthoringHelpers } from '../src/composed-authoring-helpers'; +import { familyPack, targetPack } from './helpers/type-choice-packs'; + +const { type } = createComposedAuthoringHelpers({ + ...testTypeLookups, + family: familyPack, + target: targetPack, +}); + +test('a bare call resolves to the default row, with its parameter defaults', () => { + expectTypeOf(type.Moment()).not.toBeAny(); + expectTypeOf(type.Moment()).toEqualTypeOf(type.TimestamptzTemporal(3)); + expectTypeOf(type.Moment({})).toEqualTypeOf(type.TimestamptzTemporal(3)); + expectTypeOf(type.Moment().codecId).toEqualTypeOf<'pg/timestamptz-temporal@1'>(); +}); + +test('a written precision replaces the row default', () => { + expectTypeOf(type.Moment({ precision: 6 })).toEqualTypeOf(type.TimestamptzTemporal(6)); + expectTypeOf(type.Moment({ as: 'temporal', storage: 'tz', precision: 6 })).toEqualTypeOf( + type.TimestamptzTemporal(6), + ); +}); + +test('each row resolves to the type it names', () => { + expectTypeOf(type.Moment({ storage: 'local' })).toEqualTypeOf(type.TimestampTemporal()); + expectTypeOf(type.Moment({ storage: 'local', precision: 6 })).toEqualTypeOf( + type.TimestampTemporal(6), + ); + expectTypeOf(type.Moment({ as: 'string', storage: 'local' })).toEqualTypeOf( + type.TimestampString(), + ); + expectTypeOf(type.Moment({ as: 'date', storage: 'local' })).toEqualTypeOf(type.DateString()); + expectTypeOf(type.Day()).toEqualTypeOf(type.DateString()); + expectTypeOf(type.Day({ as: 'temporal' })).toEqualTypeOf(type.DateTemporal()); +}); + +test('an option argument takes only the values it lists', () => { + // @ts-expect-error Moment's as is temporal, string or date + type.Moment({ as: 'jsDate' }); +}); + +test('an unlisted combination does not compile', () => { + // @ts-expect-error as: string chooses no type with the default storage tz + type.Moment({ as: 'string' }); +}); + +test('an argument the chosen type does not take does not compile', () => { + // @ts-expect-error DateString takes no precision + type.Moment({ as: 'date', storage: 'local', precision: 3 }); + // @ts-expect-error Day passes on no precision + type.Day({ precision: 3 }); +}); + +test('a key the type choice has no argument for does not compile', () => { + // @ts-expect-error Moment has no argument value + type.Moment({ value: 'string' }); + // @ts-expect-error Moment has no argument bogus + type.Moment({ as: 'temporal', bogus: 1 }); + // @ts-expect-error Moment has no argument bogus + type.Moment({ storage: 'local', bogus: 1 }); +}); + +test('positional arguments do not compile', () => { + // @ts-expect-error a type choice takes one object + type.Moment('temporal'); + // @ts-expect-error a type choice takes one object + type.Moment({}, {}); +}); + +test('an arguments object from a variable compiles when written as const', () => { + const options = { as: 'string', storage: 'local' } as const; + expectTypeOf(type.Moment(options)).toEqualTypeOf(type.TimestampString()); +}); + +test('an arguments object from a variable with a widened option value does not compile', () => { + const widened = { as: 'string', storage: 'local' }; + // @ts-expect-error as is typed string, so no overload can say which explicit type the call is + type.Moment(widened); + const union: { as: 'temporal' | 'string'; storage: 'local' } = { as: 'string', storage: 'local' }; + // @ts-expect-error as is typed 'temporal' | 'string', which two rows share, so no overload takes it + type.Moment(union); +}); + +test('a key the type choice has no argument for does not compile through a variable', () => { + const extra = { storage: 'local', bogus: 1 } as const; + // @ts-expect-error Moment has no argument bogus + type.Moment(extra); +}); diff --git a/packages/2-sql-family/2-authoring/contract-ts/test/type-choice-helpers.test.ts b/packages/2-sql-family/2-authoring/contract-ts/test/type-choice-helpers.test.ts new file mode 100644 index 000000000000..2c8149df52a7 --- /dev/null +++ b/packages/2-sql-family/2-authoring/contract-ts/test/type-choice-helpers.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { testTypeLookups } from '../../../1-core/contract/test/test-type-lookups'; +import { createComposedAuthoringHelpers } from '../src/composed-authoring-helpers'; +import { familyPack, targetPack } from './helpers/type-choice-packs'; + +const { type } = createComposedAuthoringHelpers({ + ...testTypeLookups, + family: familyPack, + target: targetPack, +}); + +const untyped = type.Moment as (...args: readonly unknown[]) => unknown; + +describe('a type.* helper of a type choice', () => { + it('gives what the helper of the type it chooses gives, for each row', () => { + expect({ + bare: type.Moment(), + empty: type.Moment({}), + precision: type.Moment({ precision: 6 }), + local: type.Moment({ storage: 'local', precision: 6 }), + string: type.Moment({ as: 'string', storage: 'local' }), + date: type.Moment({ as: 'date', storage: 'local' }), + day: type.Day(), + temporalDay: type.Day({ as: 'temporal' }), + }).toEqual({ + bare: type.TimestamptzTemporal(3), + empty: type.TimestamptzTemporal(3), + precision: type.TimestamptzTemporal(6), + local: type.TimestampTemporal(6), + string: type.TimestampString(), + date: type.DateString(), + day: type.DateString(), + temporalDay: type.DateTemporal(), + }); + }); + + it('gives the explicit codec and parameters', () => { + expect(type.Moment()).toEqual({ + kind: 'codec-instance', + codecId: 'pg/timestamptz-temporal@1', + typeParams: { precision: 3 }, + }); + }); + + it('reads an argument written as undefined as left out', () => { + expect(type.Moment({ as: undefined, precision: undefined })).toEqual( + type.TimestamptzTemporal(3), + ); + }); + + it('refuses an unlisted combination at the argument that makes it invalid', () => { + expect(() => untyped({ as: 'string' })).toThrow( + 'Argument "storage" of Moment cannot be tz with as: string; write one of: local.', + ); + }); + + it('refuses a value the option does not list', () => { + expect(() => untyped({ as: 'jsDate' })).toThrow( + 'Argument "as" of Moment is jsDate; write one of: temporal, string, date.', + ); + }); + + it('refuses an argument the chosen type does not take', () => { + expect(() => untyped({ as: 'date', storage: 'local', precision: 3 })).toThrow( + 'Argument "precision" of Moment does not apply to DateString, the type that as: date, storage: local chooses.', + ); + }); + + it.each([ + ['positional arguments', ['string', 'local']], + ['a string', ['string']], + ['a list', [['string']]], + ['null', [null]], + ])('refuses %s in place of one object', (_, args) => { + expect(() => untyped(...args)).toThrow( + "Moment takes its arguments as one object with the keys as, storage and precision, such as Moment({ as: 'temporal' }).", + ); + }); + + it('refuses an argument the type choice does not have', () => { + expect(() => untyped({ value: 'string' })).toThrow( + 'Moment has no argument "value"; its arguments are as, storage and precision.', + ); + }); +}); + +describe('composing helpers for a pack with a type choice', () => { + it('refuses a choice naming a constructor no pack registers', () => { + const broken = { + ...targetPack, + authoring: { + type: { + Moment: { + ...targetPack.authoring.type.Moment, + choices: [{ when: { as: 'temporal', storage: 'tz' }, type: 'Missing' }], + }, + }, + }, + } as const; + expect(() => + createComposedAuthoringHelpers({ ...testTypeLookups, family: familyPack, target: broken }), + ).toThrow(/Type choice "Moment" contributed by "postgres" chooses "Missing"/); + }); +}); diff --git a/packages/2-sql-family/9-family/README.md b/packages/2-sql-family/9-family/README.md index f02f9a54550f..8aebb9e6632d 100644 --- a/packages/2-sql-family/9-family/README.md +++ b/packages/2-sql-family/9-family/README.md @@ -138,7 +138,7 @@ The runner returns structured errors with the following codes: - **`./control`**: Control plane entry point for CLI/config usage (exports `SqlFamilyDescriptor`) - **`./control-adapter`**: SQL control adapter interface (`SqlControlAdapter`, `SqlControlAdapterDescriptor`) for target-specific adapters -- **`./psl-build`**: PSL building blocks both `contract infer` and `contract print` use, with no dialect knowledge: `mapDefault` (a stored default as the PSL attribute that reads back as it), `toEnumMemberName`, `inferredTypeConstructors` (the constructor marked `inferred` for each data type of a stack), `fewestArgumentsCall` and `pslTypeArgumentText` (the call infer and print write for a column's parameters), and `inferColumnTypes` (each introspected column's type, through the data type that claims its reported type, failing with `CONTRACT.INFER_COLUMN_TYPE_UNCLAIMED` on every column none writes) +- **`./psl-build`**: PSL building blocks both `contract infer` and `contract print` use, with no dialect knowledge: `mapDefault` (a stored default as the PSL attribute that reads back as it), `toEnumMemberName`, `inferredTypeConstructors` (the constructor marked `inferred` for each data type of a stack, with the other constructors of that data type), `fewestArgumentsCall` and `writtenTypeArguments` (the call infer and print write for a column's parameters), `writtenTypeChoiceCall` (the type choice call, written by name, that becomes a constructor call; ADR 279) and `sameInNormalForm` (the comparison infer uses for it), and `inferColumnTypes` (each introspected column's type, through the data type that claims its reported type, moving to another row of the same type choice when the target says the default-path codec cannot read the column default, failing with `CONTRACT.INFER_COLUMN_TYPE_UNCLAIMED` on every column none writes) - **`./psl-infer`**: Database-to-PSL inference utilities for `contract infer`: name transforms, relation inference, and the printer-config types - **`./runtime`**: Runtime plane identity exports only (family ID, types, descriptor identity). Does **not** export runtime creation helpers—use `instantiateExecutionStack` from `@internal/framework-components/execution` and `createExecutionContext`, `createRuntime`, `createSqlExecutionStack` from `@internal/sql-runtime`. See [ADR 152](../../../docs/architecture%20docs/adrs/ADR%20152%20-%20Execution%20Plane%20Descriptors%20and%20Instances.md). - **`./verify`**: Marker row parsing helper (`parseContractMarkerRow`). Marker reads are owned by each `SqlControlAdapter` (e.g. `PostgresControlAdapter.readMarker`) so dialect-specific SQL stays target-local. diff --git a/packages/2-sql-family/9-family/src/core/migrations/contract-to-schema-ir.ts b/packages/2-sql-family/9-family/src/core/migrations/contract-to-schema-ir.ts index 57e2cbca66e2..030b8e699aa5 100644 --- a/packages/2-sql-family/9-family/src/core/migrations/contract-to-schema-ir.ts +++ b/packages/2-sql-family/9-family/src/core/migrations/contract-to-schema-ir.ts @@ -158,8 +158,7 @@ function contractDefaultValue( } /** - * The column's type as the database reports it, from the type it is stored as: the written name with its normal-form parameters (`character(1)` for a bare `character`), or for a - * type that claims a kind (an enum), its type name as stored. + * The column's type as the database reports it, from the type it is stored as: the written name with the parameters the catalog prints for those written (`character(1)` for a bare `character`, `timestamptz` for a bare `timestamptz`), or for a type that claims a kind (an enum), its type name as stored. */ function schemaTypeText( type: SqlDataType, @@ -167,7 +166,7 @@ function schemaTypeText( ): string { const params = dataTypeParams(type, typeParams); return type.sql.claimsKind === undefined - ? renderSqlTypeName(type, type.sql.normalize(params)) + ? renderSqlTypeName(type, type.sql.catalogParams(params)) : unquotedSqlBaseName(type, params); } diff --git a/packages/2-sql-family/9-family/src/core/psl-build/column-types.ts b/packages/2-sql-family/9-family/src/core/psl-build/column-types.ts index fe1280671d7b..33e027448e98 100644 --- a/packages/2-sql-family/9-family/src/core/psl-build/column-types.ts +++ b/packages/2-sql-family/9-family/src/core/psl-build/column-types.ts @@ -1,4 +1,11 @@ -import type { DataTypeSupport } from '@internal/framework-components/authoring'; +import { + type AuthoringTypeNamespace, + authoringTypeConstructorArguments, + type DataTypeSupport, + getAuthoringTypeChoice, + getAuthoringTypeConstructor, + writtenTypeArguments, +} from '@internal/framework-components/authoring'; import type { DataType } from '@internal/framework-components/codec'; import { resolveReportedSqlType } from '@internal/sql-contract/data-type'; import type { ReportedSqlType, SqlColumnIR } from '@internal/sql-schema-ir/types'; @@ -6,20 +13,30 @@ import { InternalError } from '@internal/utils/internal-error'; import { sqlFamilyError } from '../errors'; import { fewestArgumentsCall, + type InferredTypeConstructor, type InferredTypeConstructors, - writtenTypeArguments, + type PslTypeArgument, + sameInNormalForm, + writtenTypeChoiceCall, } from './inferred-type-constructors'; -/** A call to a type constructor as PSL writes it: the constructor's path, its arguments' text, and the value each argument gives the constructor. */ +/** A type call as PSL writes it, and the explicit type constructor it becomes, with the value of each of its arguments. */ export interface PslTypeCall { readonly path: readonly string[]; - readonly args: readonly string[]; - readonly values: readonly unknown[]; + readonly args: readonly PslTypeArgument[]; + readonly explicit: { readonly path: readonly string[]; readonly values: readonly unknown[] }; } /** The PSL type `contract infer` writes for a column that is not an enum, by table and column name. */ export type ColumnTypeOf = (tableName: string, columnName: string) => PslTypeCall; +/** Whether the codec of `type` reads the default the database reports for `column` as a row value. */ +export type ColumnTypeReadsDefault = ( + tableName: string, + column: SqlColumnIR, + type: PslTypeCall, +) => Promise; + /** One introspected table, with the schema it was read from. */ export interface IntrospectedTable { readonly schemaName: string; @@ -37,32 +54,109 @@ function reportedTypeOf(column: SqlColumnIR, tableName: string): ReportedSqlType return column.reportedType; } -function writtenCall( +/** The call to the type choice that becomes `marked` with every parameter in `params`, compared in the normal form, as `db verify` compares the column, with the explicit call it becomes. */ +function convenienceCall( + marked: InferredTypeConstructor, + params: Readonly>, + authoringTypes: AuthoringTypeNamespace, + support: DataTypeSupport, +): PslTypeCall | undefined { + const values = authoringTypeConstructorArguments(marked.descriptor, { + codecId: marked.descriptor.output.codecId, + typeParams: params, + }); + const written = + values && + writtenTypeChoiceCall({ + authoringTypes, + call: { path: marked.path, args: values }, + dataTypes: support, + sameOutput: sameInNormalForm(marked.dataType), + }); + return ( + written && { + path: written.path, + args: written.args, + explicit: { path: written.becomes.path, values: written.becomes.args }, + } + ); +} + +/** + * The first call to a row of the type choice `written` names whose type constructor represents the same data type, with the same parameters, and whose codec reads the column default. + */ +async function rowReadingDefault( + written: PslTypeCall, + marked: InferredTypeConstructor, + params: Readonly>, + authoringTypes: AuthoringTypeNamespace, + support: DataTypeSupport, + readsDefault: (type: PslTypeCall) => Promise, +): Promise { + const choice = getAuthoringTypeChoice({ type: authoringTypes }, written.path); + for (const row of choice?.descriptor.choices ?? []) { + const path = [...written.path.slice(0, -1), row.type]; + const descriptor = getAuthoringTypeConstructor({ type: authoringTypes }, path); + const call = + descriptor !== undefined && + support.dataTypeOfCodec(descriptor.output.codecId) === marked.dataType.id + ? convenienceCall({ ...marked, path, descriptor }, params, authoringTypes, support) + : undefined; + if (call !== undefined && (await readsDefault(call))) return call; + } + return undefined; +} + +async function writtenCall( constructors: InferredTypeConstructors, dataTypes: readonly DataType[], + authoringTypes: AuthoringTypeNamespace, support: DataTypeSupport, reported: ReportedSqlType, -): PslTypeCall | undefined { + readsDefault: (type: PslTypeCall) => Promise, +): Promise { const resolved = resolveReportedSqlType(reported, dataTypes); const marked = resolved && constructors.get(resolved.dataType.id); - const call = resolved && marked && fewestArgumentsCall(marked, resolved.typeParams); - const args = call && marked && writtenTypeArguments(marked.descriptor, call.args, support); - return call && args && { path: call.path, args, values: call.args }; + if (resolved === undefined || marked === undefined) return undefined; + const convenience = convenienceCall(marked, resolved.typeParams, authoringTypes, support); + if (convenience !== undefined) { + if (await readsDefault(convenience)) return convenience; + const reading = await rowReadingDefault( + convenience, + marked, + resolved.typeParams, + authoringTypes, + support, + readsDefault, + ); + return reading ?? convenience; + } + const call = fewestArgumentsCall(marked, resolved.typeParams); + const args = call && writtenTypeArguments(marked.descriptor, call.args, support); + return ( + call && + args && { + path: call.path, + args: args.map((text) => ({ name: undefined, text })), + explicit: { path: call.path, values: call.args }, + } + ); } /** - * The type of every column of `tables` that `isEnumColumn` does not claim: the call, with the - * fewest arguments, to the constructor marked `inferred` for the data type that claims the - * column's reported type. When any column has no such call, throws - * `CONTRACT.INFER_COLUMN_TYPE_UNCLAIMED` listing every one of them. + * The type of every column of `tables` that `isEnumColumn` does not claim, through the constructor marked `inferred` for the data type that claims the column's reported type: the type choice call that becomes it, with only the arguments that differ from the defaults, or else the call to it with the fewest arguments. When the codec of that type choice call cannot read the column default as a row, the first row of the same type choice whose codec can is written instead. When any column has no such call, throws `CONTRACT.INFER_COLUMN_TYPE_UNCLAIMED` listing every one of them. */ -export function inferColumnTypes(input: { +export async function inferColumnTypes(input: { readonly tables: readonly IntrospectedTable[]; readonly constructors: InferredTypeConstructors; + /** The stack's type namespace, whose type choices infer writes where one becomes a marked constructor. */ + readonly authoringTypes: AuthoringTypeNamespace; /** The stack's data types, whose literals write each argument. */ readonly dataTypes: DataTypeSupport; readonly isEnumColumn: (column: SqlColumnIR) => boolean; -}): ColumnTypeOf { + /** Whether a type's codec reads the column default; every type does when `undefined`. */ + readonly readsDefault: ColumnTypeReadsDefault | undefined; +}): Promise { const dataTypes = [...input.constructors.values()].map((marked) => marked.dataType); const types = new Map(); const unclaimed: string[] = []; @@ -70,7 +164,15 @@ export function inferColumnTypes(input: { for (const column of columns) { if (input.isEnumColumn(column)) continue; const reported = reportedTypeOf(column, tableName); - const call = writtenCall(input.constructors, dataTypes, input.dataTypes, reported); + const readsDefault = input.readsDefault; + const call = await writtenCall( + input.constructors, + dataTypes, + input.authoringTypes, + input.dataTypes, + reported, + async (type) => (readsDefault === undefined ? true : readsDefault(tableName, column, type)), + ); if (call === undefined) { unclaimed.push( `${schemaName}.${tableName}.${column.name}: ${reported.text}${column.many === true ? '[]' : ''}`, diff --git a/packages/2-sql-family/9-family/src/core/psl-build/default-mapping.ts b/packages/2-sql-family/9-family/src/core/psl-build/default-mapping.ts index 8971971f04dc..324fb95c8418 100644 --- a/packages/2-sql-family/9-family/src/core/psl-build/default-mapping.ts +++ b/packages/2-sql-family/9-family/src/core/psl-build/default-mapping.ts @@ -1,40 +1,12 @@ -/** - * Writing a stored default back as the PSL literal the column takes. - * - * The inverse of reading one. The stored form is read with the same functions a written value - * takes, `readWrittenValue` and `castTypedValue`: a number or numeral text through the target's - * number entry, a document or text through the entry that owns that syntax, each a value its type - * constructs, and the column's data type must be that type or declare a cast from it. The text is - * then printed with that type's authoring entry and read straight back the same way, so a literal - * that would not come back as the stored value never reaches the contract source. ADR 254. - */ - -import type { - ColumnDefault, - ColumnDefaultLiteralInputValue, - JsonValue, -} from '@internal/contract/types'; -import type { - DataTypeAuthoringEntry, - DataTypeSupport, - WrittenScalar, -} from '@internal/framework-components/authoring'; +import type { ColumnDefault } from '@internal/contract/types'; import { - castTypedValue, + type DataTypeSupport, printTaggedLiteral, - readWrittenValue, + writeTypedLiteral, } from '@internal/framework-components/authoring'; -import type { - CodecDescriptor, - DataTypeId, - DataTypeLookup, - DataTypeParams, - DataTypeValue, -} from '@internal/framework-components/codec'; -import { dataTypeId, dataTypeParams, numeralText } from '@internal/framework-components/codec'; -import { escapePslString } from '@internal/sql-contract/data-type-support'; +import type { CodecDescriptor } from '@internal/framework-components/codec'; import { SQL_EXPRESSION_TAG } from '@internal/sql-contract/sql-expression'; -import { isInternalError } from '@internal/utils/internal-error'; +import { ifDefined } from '@internal/utils/defined'; const DEFAULT_FUNCTION_ATTRIBUTES: Readonly> = { 'autoincrement()': '@default(autoincrement())', @@ -71,7 +43,12 @@ export function mapDefault( ): DefaultMappingResult | undefined { switch (columnDefault.kind) { case 'literal': { - const text = writeDefaultLiteral(columnDefault.value, options); + const text = writeTypedLiteral(columnDefault.value, { + dataTypes: options?.dataTypes, + dataType: options?.columnCodec?.dataType, + typeParams: options?.typeParams, + ...ifDefined('list', options?.list), + }); return text === undefined ? undefined : { attribute: `@default(${text})` }; } case 'function': { @@ -83,293 +60,3 @@ export function mapDefault( } } } - -/** - * The authoring entries the printer reads, arranged by what it has to look up: the entry that owns - * each type, and the types each tag writes. - */ -interface WritingSurface { - readonly support: DataTypeSupport; - readonly entryOf: ReadonlyMap; - readonly tagTypes: readonly DataTypeId[]; -} - -function writingSurface(support: DataTypeSupport): WritingSurface { - const entryOf = new Map(); - const tagTypes: DataTypeId[] = []; - for (const [key, entry] of Object.entries(support.entries)) { - const written = entry.written; - if (written.kind === 'tag') { - const type = dataTypeId(key); - if (!entryOf.has(type)) entryOf.set(type, entry); - tagTypes.push(type); - continue; - } - if (written.syntax === 'number') { - for (const type of written.types) entryOf.set(type, entry); - continue; - } - entryOf.set(key, entry); - } - return { support, entryOf, tagTypes }; -} - -function readValue(surface: WritingSurface, written: WrittenScalar): DataTypeValue | undefined { - const read = readWrittenValue(surface.support, written); - return read.ok ? read.value : undefined; -} - -/** The value of a tag's type that a stored document is, constructed by that type. */ -function tagValue( - surface: WritingSurface, - type: DataTypeId, - value: JsonValue, -): DataTypeValue | undefined { - try { - return surface.support.lookup.get(type)?.valueFrom(value, {}); - } catch (error) { - if (isInternalError(error)) throw error; - return undefined; - } -} - -/** - * Every value the stored form could be, in the order a written value would be read: a number or - * numeral text through the number entry first, then the plain syntax that matches the shape, then - * each tag, whose stored form is a whole document, which may itself be a number or a boolean. - */ -function classifications(value: JsonValue, surface: WritingSurface): readonly DataTypeValue[] { - const found: (DataTypeValue | undefined)[] = []; - if (typeof value === 'number') { - found.push(readValue(surface, { kind: 'number', text: numeralText(value) })); - } else if (typeof value === 'boolean') { - found.push(readValue(surface, { kind: 'boolean', value })); - } else if (typeof value === 'string') { - found.push(readValue(surface, { kind: 'number', text: value })); - found.push(readValue(surface, { kind: 'string', text: value })); - } - for (const type of surface.tagTypes) found.push(tagValue(surface, type, value)); - return found.filter((candidate) => candidate !== undefined); -} - -/** The column's data type with the parameters a written value is read under. */ -interface ColumnType { - readonly id: DataTypeId; - readonly params: DataTypeParams; -} - -/** What the column's type stores for `json` under the column's parameters, or `undefined` when it holds no such value. */ -function storedUnder( - json: JsonValue, - column: ColumnType, - lookup: DataTypeLookup, -): JsonValue | undefined { - const type = lookup.get(column.id); - if (type === undefined) return undefined; - try { - return type.toContract(type.valueFrom(json, column.params)); - } catch (error) { - if (isInternalError(error)) throw error; - return undefined; - } -} - -/** The stored form the column takes this value as, spelled as its parameters give it, or `undefined` when its type takes no such value. */ -function admitted( - candidate: DataTypeValue, - column: ColumnType, - surface: WritingSurface, -): JsonValue | undefined { - const cast = castTypedValue(surface.support, column.id, column.params, candidate); - return cast.ok ? cast.value.value : undefined; -} - -/** The text of the literal: what the entry prints, before the syntax that fences it. */ -function printedText(entry: DataTypeAuthoringEntry, value: JsonValue): string | undefined { - try { - return entry.print(value); - } catch { - return undefined; - } -} - -/** The printed text as the entry's syntax writes it, for reading back. */ -function writtenAs(entry: DataTypeAuthoringEntry, text: string): WrittenScalar | undefined { - const written = entry.written; - if (written.kind === 'tag') return { kind: 'tag', tag: written.tag, text }; - if (written.syntax !== 'boolean') return { kind: written.syntax, text }; - return text === 'true' || text === 'false' - ? { kind: 'boolean', value: text === 'true' } - : undefined; -} - -/** What the printed text reads back as, through the same read a written value takes. */ -function readBack( - surface: WritingSurface, - entry: DataTypeAuthoringEntry, - text: string, -): DataTypeValue | undefined { - const written = writtenAs(entry, text); - return written === undefined ? undefined : readValue(surface, written); -} - -function literalText(entry: DataTypeAuthoringEntry, text: string): string { - const written = entry.written; - if (written.kind === 'tag') return printTaggedLiteral(written.tag, text); - return written.syntax === 'string' ? `"${escapePslString(text)}"` : text; -} - -/** One element of a written list: its source text and the value the list cast receives. */ -interface WrittenElement { - readonly text: string; - readonly value: JsonValue; -} - -/** - * The value written as the literal of one of its types, proven by reading the text straight back: - * the same read, the same cast, and the stored form again. A number is tried first without the - * trailing fraction zeros the column's scale spells it with. - */ -function writeScalar( - value: JsonValue, - column: ColumnType, - surface: WritingSurface, -): string | undefined { - const shortest = withoutTrailingFractionZeros(value); - const candidates = [ - ...(shortest === undefined ? [] : classifications(shortest, surface)), - ...classifications(value, surface), - ]; - for (const candidate of candidates) { - const entry = surface.entryOf.get(candidate.type); - if (entry === undefined) continue; - const stored = admitted(candidate, column, surface); - if (stored === undefined || !sameForm(stored, value)) continue; - const text = printedText(entry, candidate.value); - if (text === undefined) continue; - const reread = readBack(surface, entry, text); - if (reread === undefined) continue; - const restored = admitted(reread, column, surface); - if (restored === undefined || !sameForm(restored, value)) continue; - return literalText(entry, text); - } - return undefined; -} - -/** - * `value`, a value of `dataType` as `contract.json` stores it, written as the literal of a type that `dataType` takes, proven to read back as `value`: `255` for `pg/int4`, ``sql`"MyType"[]` `` for `sql/expression`. `undefined` when no literal reads back as it. - */ -export function writeDataTypeValue( - value: JsonValue, - dataType: DataTypeId, - support: DataTypeSupport, -): string | undefined { - return writeScalar(value, { id: dataType, params: {} }, writingSurface(support)); -} - -const DECIMAL_WITH_FRACTION = /^-?\d+\.\d+$/; - -function withoutTrailingFractionZeros(value: JsonValue): string | undefined { - if (typeof value !== 'string' || !DECIMAL_WITH_FRACTION.test(value)) return undefined; - const shortened = value.replace(/0+$/, '').replace(/\.$/, ''); - return shortened === value ? undefined : shortened; -} - -/** One written element of a list, printed as a value of any type, with no column type to admit it. */ -function writeElement( - value: JsonValue, - of: readonly DataTypeId[], - surface: WritingSurface, -): WrittenElement | undefined { - for (const candidate of classifications(value, surface)) { - if (!of.includes(candidate.type)) continue; - const entry = surface.entryOf.get(candidate.type); - if (entry === undefined) continue; - const text = printedText(entry, candidate.value); - if (text === undefined) continue; - const reread = readBack(surface, entry, text); - if (reread === undefined || !of.includes(reread.type)) continue; - return { text: literalText(entry, text), value: reread.value }; - } - return undefined; -} - -/** - * A written list on a scalar column, which the column type's list cast turns into its one value, - * constructed by the column's type. Each element is classified on its own and must be of a type the - * cast takes. - */ -function writeListCast( - value: readonly JsonValue[], - column: ColumnType, - surface: WritingSurface, -): string | undefined { - const listCast = surface.support.lookup.get(column.id)?.listCast; - if (listCast === undefined) return undefined; - const parts: string[] = []; - const elements: JsonValue[] = []; - for (const element of value) { - const written = writeElement(element, listCast.of, surface); - if (written === undefined) return undefined; - parts.push(written.text); - elements.push(written.value); - } - let cast: JsonValue; - try { - cast = listCast.cast(elements); - } catch (error) { - if (isInternalError(error)) throw error; - return undefined; - } - const stored = storedUnder(cast, column, surface.support.lookup); - return stored !== undefined && sameForm(stored, value) ? `[${parts.join(', ')}]` : undefined; -} - -/** The PSL literal a stored value is written as for the column `options` describe, or `undefined` when it has no written form that reads back as that value. */ -export function writeDefaultLiteral( - value: ColumnDefaultLiteralInputValue, - options: DefaultMappingOptions | undefined, -): string | undefined { - if (value instanceof Date) return undefined; - const { dataTypes, columnCodec } = options ?? {}; - if (dataTypes === undefined || columnCodec === undefined) return undefined; - const { lookup } = dataTypes; - const columnDataType = columnCodec.dataType; - const columnType = lookup.get(columnDataType); - const column: ColumnType = { - id: columnDataType, - params: columnType === undefined ? {} : dataTypeParams(columnType, options?.typeParams), - }; - const surface = writingSurface(dataTypes); - if (options?.list === true) { - if (!Array.isArray(value)) return undefined; - const parts: string[] = []; - for (const element of value) { - const written = writeScalar(element, column, surface); - if (written === undefined) return undefined; - parts.push(written); - } - return `[${parts.join(', ')}]`; - } - const written = writeScalar(value, column, surface); - if (written !== undefined) return written; - return Array.isArray(value) ? writeListCast(value, column, surface) : undefined; -} - -/** Whether two stored forms are the same JSON value, member by member and element by element. */ -function sameForm(left: JsonValue, right: JsonValue): boolean { - if (left === right) return true; - if (Array.isArray(left) !== Array.isArray(right)) return false; - if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) { - return false; - } - const members = Object.entries(left); - const others = new Map(Object.entries(right)); - return ( - members.length === others.size && - members.every(([key, member]) => { - const other = others.get(key); - return other !== undefined && sameForm(member, other); - }) - ); -} diff --git a/packages/2-sql-family/9-family/src/core/psl-build/inferred-type-constructors.ts b/packages/2-sql-family/9-family/src/core/psl-build/inferred-type-constructors.ts index 1c2f71f58241..a7bf33fb53de 100644 --- a/packages/2-sql-family/9-family/src/core/psl-build/inferred-type-constructors.ts +++ b/packages/2-sql-family/9-family/src/core/psl-build/inferred-type-constructors.ts @@ -2,11 +2,16 @@ import type { JsonValue } from '@internal/contract/types'; import { type AuthoringTypeConstructorCall, type AuthoringTypeConstructorDescriptor, + type AuthoringTypeConstructorOutput, type AuthoringTypeNamespace, authoringTypeConstructorArguments, authoringTypeConstructors, type DataTypeSupport, + findAuthoringTypeChoiceCall, + getAuthoringTypeChoice, instantiateAuthoringTypeConstructor, + isAuthoringTypeChoiceOption, + writeDataTypeValue, } from '@internal/framework-components/authoring'; import type { CodecLookupWithDescriptors, @@ -20,8 +25,8 @@ import { type SqlTypeParams, } from '@internal/sql-contract/data-type'; import { blindCast } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; import { isInternalError } from '@internal/utils/internal-error'; -import { writeDataTypeValue } from './default-mapping'; /** The type constructor `contract infer` writes for a data type, with the data type it produces. */ export interface InferredTypeConstructor { @@ -85,28 +90,73 @@ export function fewestArgumentsCall( return { path: marked.path, args }; } +/** An argument of a type call as PSL writes it: by name, or by position when `name` is undefined. */ +export interface PslTypeArgument { + readonly name: string | undefined; + readonly text: string; +} + +/** A call to a type constructor or type choice as PSL writes it. */ +export interface WrittenPslTypeCall { + readonly path: readonly string[]; + readonly args: readonly PslTypeArgument[]; +} + +/** A type choice call as PSL writes it, and the type constructor call it becomes. */ +export interface WrittenTypeChoiceCall extends WrittenPslTypeCall { + readonly becomes: AuthoringTypeConstructorCall; +} + /** - * Each argument of a call to `descriptor` as PSL writes it: a value argument as a literal of its data type that reads back as the same value. `undefined` when an argument is not a value argument or no literal reads back as it. + * Whether two outputs have the same codec and the same parameters in the normal form of `dataType`, which the codecs represent: how `db verify` and the planner compare a column with the database. */ -export function writtenTypeArguments( - descriptor: AuthoringTypeConstructorDescriptor, - args: readonly unknown[], - dataTypes: DataTypeSupport, -): readonly string[] | undefined { - const written: string[] = []; - for (const [index, value] of args.entries()) { - const argument = descriptor.args?.[index]; - if (argument === undefined) return undefined; - const text = writeDataTypeValue( - blindCast< - JsonValue, - 'a type constructor argument is a type parameter, which contract.json stores as JSON' - >(value), - dataTypeId(argument.dataType), - dataTypes, - ); +export function sameInNormalForm( + dataType: SqlDataType, +): (produced: AuthoringTypeConstructorOutput, wanted: AuthoringTypeConstructorOutput) => boolean { + const normal = (output: AuthoringTypeConstructorOutput) => + canonicalizeJson(dataType.sql.normalize(dataTypeParams(dataType, output.typeParams ?? {}))); + return (produced, wanted) => + produced.codecId === wanted.codecId && normal(produced) === normal(wanted); +} + +/** + * The type choice call that becomes the type constructor call `call`, written by name with only the arguments that differ from the defaults (ADR 279). `sameOutput` decides when it becomes the same type; by default the codec and type parameters must be exactly equal. `undefined` when no type choice call becomes it or an argument has no literal that reads back as it. + */ +export function writtenTypeChoiceCall(input: { + readonly authoringTypes: AuthoringTypeNamespace; + readonly call: AuthoringTypeConstructorCall; + readonly dataTypes: DataTypeSupport; + readonly sameOutput: + | (( + produced: AuthoringTypeConstructorOutput, + wanted: AuthoringTypeConstructorOutput, + ) => boolean) + | undefined; +}): WrittenTypeChoiceCall | undefined { + const found = findAuthoringTypeChoiceCall({ + namespace: input.authoringTypes, + call: input.call, + ...ifDefined('sameOutput', input.sameOutput), + }); + const choice = found && getAuthoringTypeChoice({ type: input.authoringTypes }, found.path); + if (found === undefined || choice === undefined) return undefined; + const args: PslTypeArgument[] = []; + for (const { name, value } of found.args) { + const argument = choice.descriptor.args.find((candidate) => candidate.name === name); + const text = + argument === undefined + ? undefined + : isAuthoringTypeChoiceOption(argument) + ? String(value) + : writeDataTypeValue( + blindCast( + value, + ), + dataTypeId(argument.dataType), + input.dataTypes, + ); if (text === undefined) return undefined; - written.push(text); + args.push({ name, text }); } - return written; + return { path: found.path, args, becomes: found.becomes }; } diff --git a/packages/2-sql-family/9-family/src/core/timestamp-now-runtime-generator.ts b/packages/2-sql-family/9-family/src/core/timestamp-now-runtime-generator.ts index 36703962325f..70b81f5b2b8f 100644 --- a/packages/2-sql-family/9-family/src/core/timestamp-now-runtime-generator.ts +++ b/packages/2-sql-family/9-family/src/core/timestamp-now-runtime-generator.ts @@ -1,10 +1,20 @@ import { TIMESTAMP_NOW_GENERATOR_ID } from '@internal/framework-components/authoring'; import type { RuntimeMutationDefaultGenerator } from '@internal/framework-components/runtime'; +const DATE_FRACTION_DIGITS = 3; + +function roundedDown(now: number, params: Readonly> | undefined): Date { + const precision = params?.['precision']; + if (typeof precision !== 'number' || precision >= DATE_FRACTION_DIGITS) return new Date(now); + const step = 10 ** (DATE_FRACTION_DIGITS - Math.max(precision, 0)); + return new Date(Math.floor(now / step) * step); +} + /** * Builds the canonical runtime-plane generator for the wall-clock-now - * mutation default. Returns `new Date()`; semantics are target-agnostic - * so all SQL targets share this single implementation. + * mutation default. Returns a `Date`, rounded down to the `precision` param + * when a target passes one; semantics are target-agnostic so all SQL targets + * share this single implementation. * * Declares `stability: 'query'` so a single ORM bulk operation * (e.g. `createAll([...])`) shares one timestamp across every row and @@ -17,7 +27,7 @@ import type { RuntimeMutationDefaultGenerator } from '@internal/framework-compon export function timestampNowRuntimeGenerator(): RuntimeMutationDefaultGenerator { return { id: TIMESTAMP_NOW_GENERATOR_ID, - generate: () => new Date(), + generate: (params) => roundedDown(Date.now(), params), stability: 'query', }; } diff --git a/packages/2-sql-family/9-family/src/exports/psl-build.ts b/packages/2-sql-family/9-family/src/exports/psl-build.ts index bdcc99468bc1..16a6d097cd8a 100644 --- a/packages/2-sql-family/9-family/src/exports/psl-build.ts +++ b/packages/2-sql-family/9-family/src/exports/psl-build.ts @@ -6,6 +6,7 @@ export { type ColumnTypeOf, + type ColumnTypeReadsDefault, type IntrospectedTable, inferColumnTypes, type PslTypeCall, @@ -14,11 +15,7 @@ export type { DefaultMappingOptions, DefaultMappingResult, } from '../core/psl-build/default-mapping'; -export { - mapDefault, - writeDataTypeValue, - writeDefaultLiteral, -} from '../core/psl-build/default-mapping'; +export { mapDefault } from '../core/psl-build/default-mapping'; export type { GeneratorCallRefusal, GeneratorCallsResult, @@ -30,6 +27,10 @@ export { type InferredTypeConstructor, type InferredTypeConstructors, inferredTypeConstructors, - writtenTypeArguments, + type PslTypeArgument, + sameInNormalForm, + type WrittenPslTypeCall, + type WrittenTypeChoiceCall, + writtenTypeChoiceCall, } from '../core/psl-build/inferred-type-constructors'; export { toEnumMemberName } from '../core/psl-build/psl-names'; diff --git a/packages/2-sql-family/9-family/test/contract-to-schema-ir.test.ts b/packages/2-sql-family/9-family/test/contract-to-schema-ir.test.ts index 2dbee368b369..5b58151d262f 100644 --- a/packages/2-sql-family/9-family/test/contract-to-schema-ir.test.ts +++ b/packages/2-sql-family/9-family/test/contract-to-schema-ir.test.ts @@ -85,6 +85,16 @@ const fixedCharacterType = sqlDataType('test/fixed-character', { ], normalize: (params) => (params.length === undefined ? { ...params, length: 1 } : params), }); +const preciseTimestamptzType = sqlDataType('test/precise-timestamptz', { + read: (json) => json, + params: type({ 'precision?': 'number.integer >= 0 & number.integer <= 6' }), + texts: [ + { text: 'timestamptz', written: true }, + { text: 'timestamptz({precision})', written: true }, + ], + normalize: (params) => (params.precision === undefined ? { ...params, precision: 6 } : params), + catalogParams: (params) => params, +}); const codeType = sqlDataType('test/code', { read: (json) => json, params: type({ 'length?': 'number.integer >= 1' }), @@ -113,6 +123,7 @@ const dataTypeOfCodec: Readonly> = { 'pg/timestamptz@1': timestamptzType, 'pg/numeric@1': numericType, 'pg/char@1': fixedCharacterType, + 'test/precise-timestamptz@1': preciseTimestamptzType, 'pg/enum@1': enumType, 'test/code@1': codeType, 'sql/raw@1': sqlRawDataType, @@ -149,6 +160,7 @@ function dataTypeOf(codecId: string): string { } const testDataTypes = createDataTypeLookup([ + preciseTimestamptzType, textType, characterType, vectorType, @@ -396,6 +408,23 @@ describe('contractToSchemaIR', () => { }); }); + it('writes a column type with the parameters the catalog prints for those written, where the type declares them', () => { + const storage = unboundStorage('test' as StorageHashBase, { + T: table({ + columns: { + bare: col({ codecId: 'test/precise-timestamptz@1' }), + micro: col({ codecId: 'test/precise-timestamptz@1', typeParams: { precision: 6 } }), + }, + }), + }); + + const columns = contractToSchemaIR(wrap(storage)).tables['T']!.columns; + expect({ bare: columns['bare']!.typeText, micro: columns['micro']!.typeText }).toEqual({ + bare: 'timestamptz', + micro: 'timestamptz(6)', + }); + }); + it('gives each column its data type, the type it is stored as, and that type parameters in normal form', () => { const storage = unboundStorage('test' as StorageHashBase, { T: table({ diff --git a/packages/2-sql-family/9-family/test/psl-build/inferred-type-constructors.test.ts b/packages/2-sql-family/9-family/test/psl-build/inferred-type-constructors.test.ts index 33afcadd3e48..e8f624c73122 100644 --- a/packages/2-sql-family/9-family/test/psl-build/inferred-type-constructors.test.ts +++ b/packages/2-sql-family/9-family/test/psl-build/inferred-type-constructors.test.ts @@ -3,17 +3,21 @@ import type { DataTypeSupport, } from '@internal/framework-components/authoring'; import type { AnyCodecDescriptor } from '@internal/framework-components/codec'; -import { createDataTypeLookup } from '@internal/framework-components/codec'; +import { createDataTypeLookup, dataTypeId } from '@internal/framework-components/codec'; import { sqlDataType } from '@internal/sql-contract/data-type'; import type { ReportedSqlType, SqlColumnIRInput } from '@internal/sql-schema-ir/types'; import { SqlColumnIR } from '@internal/sql-schema-ir/types'; import { type } from 'arktype'; import { describe, expect, it } from 'vitest'; -import { type IntrospectedTable, inferColumnTypes } from '../../src/core/psl-build/column-types'; +import { + type IntrospectedTable, + inferColumnTypes, + type PslTypeCall, +} from '../../src/core/psl-build/column-types'; import { fewestArgumentsCall, inferredTypeConstructors, - writtenTypeArguments, + writtenTypeChoiceCall, } from '../../src/core/psl-build/inferred-type-constructors'; const numeric = sqlDataType('t/numeric', { @@ -41,6 +45,26 @@ const int = sqlDataType('t/int', { texts: [{ text: 'int', written: true, catalog: true }], }); +const stamp = sqlDataType('t/stamp', { + read: (json) => json, + params: type({ 'precision?': 'number.integer >= 0 & number.integer <= 6' }), + texts: [ + { text: 'stamp', written: true, catalog: true }, + { text: 'stamp({precision})', written: true, catalog: true }, + ], + normalize: (params) => (params.precision === undefined ? { ...params, precision: 6 } : params), +}); + +const clock = sqlDataType('t/clock', { + read: (json) => json, + params: type({ 'precision?': 'number.integer >= 0 & number.integer <= 6' }), + texts: [ + { text: 'clock', written: true, catalog: true }, + { text: 'clock({precision})', written: true, catalog: true }, + ], + normalize: (params) => (params.precision === undefined ? { ...params, precision: 6 } : params), +}); + const labelled = sqlDataType('t/labelled', { read: (json) => json, params: type({ label: 'string' }), @@ -66,6 +90,42 @@ const authoringTypes = { }, }, }, + StampDate: { + kind: 'typeConstructor', + inferred: true, + args: [{ kind: 'value', dataType: 't/int', name: 'precision', optional: true }], + output: { codecId: 't/stamp-date@1', typeParams: { precision: { kind: 'arg', index: 0 } } }, + }, + StampText: { + kind: 'typeConstructor', + args: [{ kind: 'value', dataType: 't/int', name: 'precision', optional: true }], + output: { codecId: 't/stamp-text@1', typeParams: { precision: { kind: 'arg', index: 0 } } }, + }, + Stamp: { + kind: 'typeChoice', + args: [ + { kind: 'option', name: 'value', values: ['date', 'text'], default: 'date', optional: true }, + { kind: 'value', dataType: 't/int', name: 'precision', optional: true }, + ], + choices: [ + { when: { value: 'date' }, type: 'StampDate', defaults: { precision: 3 } }, + { when: { value: 'text' }, type: 'StampText' }, + ], + }, + ClockText: { + kind: 'typeConstructor', + inferred: true, + args: [{ kind: 'value', dataType: 't/int', name: 'precision', optional: true }], + output: { codecId: 't/clock-text@1', typeParams: { precision: { kind: 'arg', index: 0 } } }, + }, + Clock: { + kind: 'typeChoice', + args: [ + { kind: 'option', name: 'value', values: ['text'], default: 'text', optional: true }, + { kind: 'value', dataType: 't/int', name: 'precision', optional: true }, + ], + choices: [{ when: { value: 'text' }, type: 'ClockText' }], + }, ext: { Labelled: { kind: 'typeConstructor', @@ -80,6 +140,9 @@ const dataTypeOfCodec: Record = { 't/text@1': 't/text', 't/numeric@1': 't/numeric', 't/labelled@1': 't/labelled', + 't/stamp-date@1': 't/stamp', + 't/stamp-text@1': 't/stamp', + 't/clock-text@1': 't/clock', }; const constructors = inferredTypeConstructors({ @@ -90,7 +153,7 @@ const constructors = inferredTypeConstructors({ ? undefined : ({ dataType: dataTypeOfCodec[codecId] } as unknown as AnyCodecDescriptor), }, - dataTypeLookup: createDataTypeLookup([numeric, text, labelled]), + dataTypeLookup: createDataTypeLookup([numeric, text, labelled, stamp, clock]), }); const dataTypes: DataTypeSupport = { @@ -112,8 +175,11 @@ const dataTypes: DataTypeSupport = { documentation: 'Text.', }, }, - lookup: createDataTypeLookup([int, numeric, text, labelled]), - dataTypeOfCodec: () => undefined, + lookup: createDataTypeLookup([int, numeric, text, labelled, stamp, clock]), + dataTypeOfCodec: (codecId) => { + const id = dataTypeOfCodec[codecId]; + return id === undefined ? undefined : dataTypeId(id); + }, }; describe('inferredTypeConstructors', () => { @@ -123,6 +189,8 @@ describe('inferredTypeConstructors', () => { ).toEqual({ 't/text': ['Text'], 't/numeric': ['Numeric'], + 't/stamp': ['StampDate'], + 't/clock': ['ClockText'], 't/labelled': ['ext', 'Labelled'], }); }); @@ -162,30 +230,59 @@ const catalog = (text: string): ReportedSqlType => ({ name: undefined, }); -describe('writtenTypeArguments', () => { - const numericConstructor = authoringTypes.Numeric; +describe('writtenTypeChoiceCall', () => { + const write = (path: readonly string[], args: readonly unknown[]) => + writtenTypeChoiceCall({ + authoringTypes, + call: { path, args }, + dataTypes, + sameOutput: undefined, + }); - it('writes each value argument as a literal of its data type', () => { + it('writes the type choice call that becomes the constructor call, each argument by name', () => { expect([ - writtenTypeArguments(numericConstructor, [10, 2], dataTypes), - writtenTypeArguments(authoringTypes.ext.Labelled, ['say "hi"'], dataTypes), - ]).toEqual([['10', '2'], ['"say \\"hi\\""']]); + write(['StampDate'], [3]), + write(['StampText'], [2]), + write(['StampText'], []), + ]).toEqual([ + { path: ['Stamp'], args: [], becomes: { path: ['StampDate'], args: [3] } }, + { + path: ['Stamp'], + args: [ + { name: 'value', text: 'text' }, + { name: 'precision', text: '2' }, + ], + becomes: { path: ['StampText'], args: [2] }, + }, + { + path: ['Stamp'], + args: [{ name: 'value', text: 'text' }], + becomes: { path: ['StampText'], args: [] }, + }, + ]); }); - it('writes nothing for a value no literal of its data type reads back as', () => { - expect([ - writtenTypeArguments(numericConstructor, [-1], dataTypes), - writtenTypeArguments(numericConstructor, [{ precision: 1 }], dataTypes), - ]).toEqual([undefined, undefined]); + it('writes nothing for a call no type choice reproduces exactly, or one no type choice names', () => { + expect([write(['StampDate'], []), write(['Numeric'], [10])]).toEqual([undefined, undefined]); }); }); describe('inferColumnTypes', () => { - const infer = (tables: readonly IntrospectedTable[]) => - inferColumnTypes({ tables, constructors, dataTypes, isEnumColumn: () => false }); + const infer = ( + tables: readonly IntrospectedTable[], + readsDefault: Parameters[0]['readsDefault'] = undefined, + ) => + inferColumnTypes({ + tables, + constructors, + authoringTypes, + dataTypes, + isEnumColumn: () => false, + readsDefault, + }); - it("writes each column as its data type's marked constructor, quoting a string argument", () => { - const typeOf = infer([ + it("writes each column as its data type's marked constructor, quoting a string argument", async () => { + const typeOf = await infer([ { schemaName: 'public', tableName: 'thing', @@ -201,13 +298,50 @@ describe('inferColumnTypes', () => { }, ]); expect({ amount: typeOf('thing', 'amount'), label: typeOf('thing', 'label') }).toEqual({ - amount: { path: ['Numeric'], args: ['10'], values: [10] }, - label: { path: ['ext', 'Labelled'], args: ['"gold"'], values: ['gold'] }, + amount: { + path: ['Numeric'], + args: [{ name: undefined, text: '10' }], + explicit: { path: ['Numeric'], values: [10] }, + }, + label: { + path: ['ext', 'Labelled'], + args: [{ name: undefined, text: '"gold"' }], + explicit: { path: ['ext', 'Labelled'], values: ['gold'] }, + }, + }); + }); + + it('writes the type choice that becomes the marked constructor, with the arguments that differ from its defaults', async () => { + const typeOf = await infer([ + { + schemaName: 'public', + tableName: 'event', + columns: [column('at', catalog('stamp')), column('paid', catalog('stamp(3)'))], + }, + ]); + expect({ at: typeOf('event', 'at'), paid: typeOf('event', 'paid') }).toEqual({ + at: { + path: ['Stamp'], + args: [{ name: 'precision', text: '6' }], + explicit: { path: ['StampDate'], values: [6] }, + }, + paid: { path: ['Stamp'], args: [], explicit: { path: ['StampDate'], values: [3] } }, + }); + }); + + it('records the explicit call the written type choice call becomes, not the column parameters in normal form', async () => { + const typeOf = await infer([ + { schemaName: 'public', tableName: 'shop', columns: [column('opens', catalog('clock'))] }, + ]); + expect(typeOf('shop', 'opens')).toEqual({ + path: ['Clock'], + args: [], + explicit: { path: ['ClockText'], values: [] }, }); }); - it('fails on every column no marked constructor writes, listing them sorted', () => { - expect(() => + it('fails on every column no marked constructor writes, listing them sorted', async () => { + await expect( infer([ { schemaName: 'public', @@ -216,7 +350,7 @@ describe('inferColumnTypes', () => { }, { schemaName: 'public', tableName: 'alpha', columns: [column('meta', catalog('hstore'))] }, ]), - ).toThrow( + ).rejects.toThrow( expect.objectContaining({ code: 'CONTRACT.INFER_COLUMN_TYPE_UNCLAIMED', fix: `No configured pack has a type for these columns. If a type comes from a database extension that has a Prisma 8 pack, add the pack to extensions in prisma.config.ts. Otherwise, to keep such a column, write the schema by hand and declare its field as Raw(sql\`\`) with the type as listed, or change the column's type or remove the column from the database.`, @@ -225,8 +359,8 @@ describe('inferColumnTypes', () => { ); }); - it('treats a column without a reported type as an internal error', () => { - expect(() => + it('treats a column without a reported type as an internal error', async () => { + await expect( infer([ { schemaName: 'public', @@ -234,6 +368,60 @@ describe('inferColumnTypes', () => { columns: [new SqlColumnIR({ name: 'id', typeText: 'int4', nullable: false })], }, ]), - ).toThrow(/without the type the database reported/); + ).rejects.toThrow(/without the type the database reported/); + }); + + describe('when the codec of the type choice call cannot read the column default', () => { + const event: readonly IntrospectedTable[] = [ + { schemaName: 'public', tableName: 'event', columns: [column('at', catalog('stamp(3)'))] }, + ]; + + it('writes the first row of the same type choice whose codec reads it', async () => { + const readsDefault = async (_table: string, _column: SqlColumnIR, call: PslTypeCall) => + call.explicit.path.join('.') !== 'StampDate'; + const typeOf = await infer(event, readsDefault); + expect(typeOf('event', 'at')).toEqual({ + path: ['Stamp'], + args: [ + { name: 'value', text: 'text' }, + { name: 'precision', text: '3' }, + ], + explicit: { path: ['StampText'], values: [3] }, + }); + }); + + it('keeps the type choice call when no row reads it', async () => { + const typeOf = await infer(event, async () => false); + expect(typeOf('event', 'at')).toEqual({ + path: ['Stamp'], + args: [], + explicit: { path: ['StampDate'], values: [3] }, + }); + }); + }); + + it('asks nothing of a column written as the fewest-arguments call of its constructor', async () => { + const asked: string[] = []; + const typeOf = await infer( + [ + { + schemaName: 'public', + tableName: 'thing', + columns: [column('amount', catalog('numeric(10,0)'))], + }, + ], + async (_table, column) => { + asked.push(column.name); + return false; + }, + ); + expect({ amount: typeOf('thing', 'amount'), asked }).toEqual({ + amount: { + path: ['Numeric'], + args: [{ name: undefined, text: '10' }], + explicit: { path: ['Numeric'], values: [10] }, + }, + asked: [], + }); }); }); diff --git a/packages/2-sql-family/9-family/test/timestamp-now-runtime-generator.test.ts b/packages/2-sql-family/9-family/test/timestamp-now-runtime-generator.test.ts new file mode 100644 index 000000000000..9d8de107d88b --- /dev/null +++ b/packages/2-sql-family/9-family/test/timestamp-now-runtime-generator.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { timestampNowRuntimeGenerator } from '../src/core/timestamp-now-runtime-generator'; + +describe('timestampNowRuntimeGenerator', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('generates the current time once per ORM operation', () => { + vi.useFakeTimers({ now: new Date('2024-01-02T23:59:59.999Z') }); + const generator = timestampNowRuntimeGenerator(); + expect(generator).toMatchObject({ id: 'timestampNow', stability: 'query' }); + expect(generator.generate()).toEqual(new Date('2024-01-02T23:59:59.999Z')); + }); + + it.each([ + [0, '2024-01-02T23:59:59.000Z'], + [1, '2024-01-02T23:59:59.900Z'], + [2, '2024-01-02T23:59:59.990Z'], + [3, '2024-01-02T23:59:59.999Z'], + [6, '2024-01-02T23:59:59.999Z'], + ])( + 'rounds down to precision %s, so the column stores the value the client holds', + (precision, iso) => { + vi.useFakeTimers({ now: new Date('2024-01-02T23:59:59.999Z') }); + expect(timestampNowRuntimeGenerator().generate({ precision })).toEqual(new Date(iso)); + }, + ); +}); diff --git a/packages/3-targets/mysql/1-target/src/core/codecs.ts b/packages/3-targets/mysql/1-target/src/core/codecs.ts index 48ae9ccf3a26..1cf9a3bb5249 100644 --- a/packages/3-targets/mysql/1-target/src/core/codecs.ts +++ b/packages/3-targets/mysql/1-target/src/core/codecs.ts @@ -9,6 +9,7 @@ import { type CodecInstanceContext, type DataTypeValue, decimalTextBigintLiteral, + refuseJsonValue, renderTsLiteral, } from '@internal/framework-components/codec'; import { sqlRawDataType } from '@internal/sql-contract/data-type'; @@ -259,6 +260,13 @@ export class MysqlDatetimeCodec extends CodecImpl< return decodeMysqlDatetime(wire); } fromDataTypeValue(value: DataTypeValue): Date { + if (/[1-9]/.test(/\.(\d+)/.exec(value.value)?.[1]?.slice(3) ?? '')) { + return refuseJsonValue( + this.id, + `a datetime with no digits below a millisecond, which a Date drops: ${JSON.stringify(value.value)} has them`, + value.value, + ); + } return new Date(`${value.value}Z`); } toDataTypeValue(input: Date): DataTypeValue { diff --git a/packages/3-targets/mysql/1-target/test/codecs.test.ts b/packages/3-targets/mysql/1-target/test/codecs.test.ts index 54b3a4e0ba15..3e5b1857830a 100644 --- a/packages/3-targets/mysql/1-target/test/codecs.test.ts +++ b/packages/3-targets/mysql/1-target/test/codecs.test.ts @@ -50,6 +50,18 @@ describe('MySQL codecs read the text MySQL sends', () => { expect(codec.fromDataTypeValue(value(mysqlBigint, '12'))).toBe(12n); }); + it('refuses a stored datetime with digits below a millisecond, and reads a row with them at the millisecond', async () => { + const codec = mysqlDatetimeDescriptor.factory({ precision: 6 })(instance); + expect(() => + codec.fromDataTypeValue( + mysqlDatetime.valueFrom('2024-01-02T03:04:05.678912', { precision: 6 }), + ), + ).toThrow(/2024-01-02T03:04:05\.678912/); + expect(await codec.fromWire('2024-01-02 03:04:05.678912', ctx)).toEqual( + new Date('2024-01-02T03:04:05.678Z'), + ); + }); + it('reads a datetime as the instant it names in UTC', async () => { const codec = mysqlDatetimeDescriptor.factory({ precision: 3 })(instance); const instant = new Date('2024-01-02T03:04:05.678Z'); diff --git a/packages/3-targets/postgres/1-target/README.md b/packages/3-targets/postgres/1-target/README.md index 7306622b950e..b0ecb72e79e2 100644 --- a/packages/3-targets/postgres/1-target/README.md +++ b/packages/3-targets/postgres/1-target/README.md @@ -27,8 +27,8 @@ Provides the Postgres target descriptor (`SqlControlTargetDescriptor`) for CLI c - **Generated Defaults Policy**: Treats client-generated defaults as non-DB defaults when emitting DDL - **Database Dependency Consumption**: The planner extracts database dependencies from the configured framework components (passed as `frameworkComponents`), verifies each dependency against the live schema, and only emits install operations when required. The runner reuses the same metadata for post-apply verification, so there are no hardcoded extension mappings—database dependencies stay component-owned. - **Storage Type Planning**: The planner dispatches storage type hooks for `storage.types` and emits type operations before table creation when supported by the policy -- **Contract-to-PSL Printing**: Implements the `buildPslContract` descriptor hook, which `prisma contract print` uses. It takes a Postgres contract the contract serializer accepted, from any source, and returns the Prisma 8 PSL document that reads back as the same contract. It does not check the contract's structure again; a broken structure is an internal error. It writes models, value objects, named types, domain and native enums, keys with their names, indexes, checks, relations, polymorphism, control policies, row-level security (`@@rls`, policies, and roles in `namespace unbound`) and defaults. A column is written as a type constructor the configured stack contributes that reads back with its codec, native type and type parameters: the constructor marked `inferred` for the column's data type, which is the one `contract infer` writes, when it does, or else the first constructor in the stack that does, such as `TimestamptzTemporal` or `TimestamptzTemporal(3)` for a `Temporal`-backed `timestamptz` column, because the target lists each explicit type before a second name for it such as `DateTime`, or an extension's `pgvector.Vector(3)`. A value-object member has no column, so its type goes through the stack the same way: the stack's codec names the native type for the member's type parameters, and the member is written as the type constructor for that native type, called with those parameters (`Numeric(10, 2)`); a member typed by a domain enum is written by the enum's name. The contract does not record which named type a member used, so a member written `code Short`, with `types { Short = VarChar(10) }`, is printed as the type it stands for, `code VarChar(10)`; a model field keeps `Short`, because its column names the named type. A literal default is written by the data type of the column's codec. The family passes the stack's type constructors, codecs and data types to the hook for this. Anything it cannot write so that it reads back the same is refused by name with `CONTRACT.PRINT_UNSUPPORTED` (the full list is in `docs/reference/error-reference.md`, one function per case in `src/core/psl-print/refusals.ts`); nothing is dropped. It lives in `src/core/psl-print/`, the counterpart of the `inferPslContract` hook in `src/core/psl-infer/`, which writes PSL from a live database. Both build with the shared builders in `src/core/psl-build/`: literals, index and check attributes, and the native enum block. Both find the constructor marked `inferred` for a data type through the SQL family's `@internal/family-sql/psl-build`. Model and field names come from the contract; `@@map` and `@map` appear only where the name the PSL source would derive differs. A unique constraint prints as `@@unique` and a unique index as `@@index(..., unique: true)`; every index prints explicitly, and a relation writes `index: false` when nothing backs its foreign key, no `index` argument when a relation that says nothing would be backed by the same object (`defaultForeignKeyIndex`), and otherwise `index: ""` naming the index, or the key whose first columns are the foreign key's. Execution generators join back onto their columns, through whatever the configured stack contributes, extensions included. A column's client-generated values print after its type as `@onCreate((...))` and `@onUpdate((...))`, each the call of the generator function bound to the column's codec that produces the stored generator; a generator that no function in the stack produces is refused, naming the component that registers it. -- **Database-to-PSL inference**: Implements the `inferPslContract` descriptor hook, which `prisma contract infer` uses. Introspection records each column's element type as the database reports it (`format_type`, its kind, schema and name). Infer resolves that reported type through the data types of the configured stack, the target's and the extension packs', and writes the constructor marked `inferred` for the data type, with the fewest arguments that give the same parameters (`numeric(10,0)` is written `Numeric(10)`); a list column adds `[]`, and a native enum column is written `pg.enum()`. When a column's type is claimed by no data type, or by one with no marked constructor (such as `tsquery`), infer writes nothing and fails with `CONTRACT.INFER_COLUMN_TYPE_UNCLAIMED`, listing every such column as `.
.: `. No configured pack has a type for such a column. When the type comes from a database extension that has a Prisma 8 pack, such as `pgvector` or `postgis`, adding the pack to `extensions` in `prisma.config.ts` lets infer write it, for the forms of the type the pack supports. Otherwise, as for domains, `"char"`, `hstore` and `tsquery`, the column's type must change, or the column must go. The rule itself (resolving a reported type through the data types, the fewest-arguments call, the unclaimed error) lives in the SQL family's `@internal/family-sql/psl-build`; the target adds the walk over its schema tree and the native enum handling in `src/core/psl-infer/`. +- **Contract-to-PSL Printing**: Implements the `buildPslContract` descriptor hook, which `prisma contract print` uses. It takes a Postgres contract the contract serializer accepted, from any source, and returns the Prisma 8 PSL document that reads back as the same contract. It does not check the contract's structure again; a broken structure is an internal error. It writes models, value objects, named types, domain and native enums, keys with their names, indexes, checks, relations, polymorphism, control policies, row-level security (`@@rls`, policies, and roles in `namespace unbound`) and defaults. A column is written as a type constructor the configured stack contributes that reads back with its codec, native type and type parameters: the constructor marked `inferred` for the column's data type, which is the one `contract infer` writes, when it does, or else the first constructor in the stack that does, such as `TimestampTemporal(3)` or an extension's `pgvector.Vector(3)`. Where a convenience type call becomes that constructor call exactly, the convenience call is written instead, by name with only the arguments that differ from its defaults: `DateTime` for `TimestamptzJsDate(3)`, `DateTime(as: temporal, precision: 3)` for `TimestamptzTemporal(3)` (ADR 279). A value-object member has no column, so its type goes through the stack the same way: the stack's codec names the native type for the member's type parameters, and the member is written as the type constructor for that native type, called with those parameters (`Numeric(10, 2)`); a member typed by a domain enum is written by the enum's name. The contract does not record which named type a member used, so a member written `code Short`, with `types { Short = VarChar(10) }`, is printed as the type it stands for, `code VarChar(10)`; a model field keeps `Short`, because its column names the named type. A literal default is written by the data type of the column's codec. The family passes the stack's type constructors, codecs and data types to the hook for this. Anything it cannot write so that it reads back the same is refused by name with `CONTRACT.PRINT_UNSUPPORTED` (the full list is in `docs/reference/error-reference.md`, one function per case in `src/core/psl-print/refusals.ts`); nothing is dropped. It lives in `src/core/psl-print/`, the counterpart of the `inferPslContract` hook in `src/core/psl-infer/`, which writes PSL from a live database. Both build with the shared builders in `src/core/psl-build/`: literals, index and check attributes, and the native enum block. Both find the constructor marked `inferred` for a data type through the SQL family's `@internal/family-sql/psl-build`. Model and field names come from the contract; `@@map` and `@map` appear only where the name the PSL source would derive differs. A unique constraint prints as `@@unique` and a unique index as `@@index(..., unique: true)`; every index prints explicitly, and a relation writes `index: false` when nothing backs its foreign key, no `index` argument when a relation that says nothing would be backed by the same object (`defaultForeignKeyIndex`), and otherwise `index: ""` naming the index, or the key whose first columns are the foreign key's. Execution generators join back onto their columns, through whatever the configured stack contributes, extensions included. A column's client-generated values print after its type as `@onCreate((...))` and `@onUpdate((...))`, each the call of the generator function bound to the column's codec that produces the stored generator; a generator that no function in the stack produces is refused, naming the component that registers it. +- **Database-to-PSL inference**: Implements the `inferPslContract` descriptor hook, which `prisma contract infer` uses. Introspection records each column's element type as the database reports it (`format_type`, its kind, schema and name). Infer resolves that reported type through the data types of the configured stack, the target's and the extension packs', and writes the constructor marked `inferred` for the data type, with the fewest arguments that give the same parameters (`numeric(10,0)` is written `Numeric(10)`). For a date or time column the marked constructor is the one the convenience type's default gives, and infer writes the convenience call that becomes it, with the arguments that differ from the defaults and the column's parameters compared in normal form: `timestamptz(3)` is `DateTime`, a `timestamptz` with no declared precision `DateTime(precision: 6)`, `time(0)` `Time(precision: 0)`, `interval` `Duration`. When the codec of that call cannot read the column's reported default as a row (its `fromWire` refuses the literal), infer writes the first row of the same convenience type whose codec can, so a `timestamp(3)` column with `DEFAULT 'infinity'` is `DateTime(as: string, storage: timestamp, precision: 3)`. A default that codec reads but whose value it cannot hold exactly, such as a timestamp with microseconds on a `Date` column, is written as its raw `sql` expression; a list column adds `[]`, and a native enum column is written `pg.enum()`. When a column's type is claimed by no data type, or by one with no marked constructor (such as `tsquery`), infer writes nothing and fails with `CONTRACT.INFER_COLUMN_TYPE_UNCLAIMED`, listing every such column as `.
.: `. No configured pack has a type for such a column. When the type comes from a database extension that has a Prisma 8 pack, such as `pgvector` or `postgis`, adding the pack to `extensions` in `prisma.config.ts` lets infer write it, for the forms of the type the pack supports. Otherwise, as for domains, `"char"`, `hstore` and `tsquery`, the column's type must change, or the column must go. The rule itself (resolving a reported type through the data types, the fewest-arguments call, the unclaimed error) lives in the SQL family's `@internal/family-sql/psl-build`; the target adds the walk over its schema tree and the native enum handling in `src/core/psl-infer/`. - **`Temporal` for the target's own code**: The `Temporal`-backed codecs (`pg/date-temporal@1`, `pg/time-temporal@1`, `pg/timestamp-temporal@1`, `pg/timestamp-instant@1` and `pg/timestamptz-temporal@1`), when they decode, and the `instantNow`, `plainDateTimeNow`, `plainDateNow` and `plainTimeNow` generators get `Temporal` from `requireTemporal()` (`src/core/require-temporal.ts`): the runtime's global `Temporal` when it has one, else the fallback `Temporal`, else it throws `RUNTIME.TEMPORAL_UNAVAILABLE`. The text codecs (`pg/date-string@1`, `pg/time-string@1`, `pg/timestamp-string@1`, `pg/timestamptz-string@1` and `pg/timetz@1`), `pg/timestamptz-date@1`, `pg/timestamp-date@1`, `pg/interval@1`, `pg/interval-string@1`, the `timestampNow` generator and the string generators (`timestampStringNow`, `timestamptzStringNow`, `dateStringNow`, `timeStringNow` and `timetzNow`) do not use `Temporal`, so an application that uses only them reads and writes its date and time columns without one. The target's control entry (`@internal/target-postgres/control`) sets the fallback `Temporal`, from `temporal-polyfill`, when it is loaded. Nothing sets `globalThis.Temporal`. No other entry of this package imports `temporal-polyfill`. `temporal-polyfill` is a required peer dependency of this package and of the published `@prisma/orm-target-postgres` and `@prisma/orm-postgres`, so a project that already installs it for its own code has one copy. npm, pnpm and bun install it automatically; a project that uses Yarn adds `temporal-polyfill` to its own dependencies. In the published package, `@prisma/orm-target-postgres/target` is the aggregate of every entry of this package, the control entry included, so it loads the control entry too; an application imports the entry it needs, for example `@prisma/orm-target-postgres/target/runtime`. The fallback is held once per process, and every `Temporal`-backed codec in the process uses it. An application process that loads no control-plane code and has no global `Temporal` gets `RUNTIME.TEMPORAL_UNAVAILABLE` when one of those codecs decodes or one of those generators runs. An application that loads control-plane code in its own process, such as server code under `vite dev` with the Prisma Vite plugin or a script that calls the control client, decodes dates without its own `Temporal`, and then fails in production. An application that uses the Temporal codecs must load its own `Temporal`. Every signature that names a `Temporal` type takes it from `temporal-spec`, a package of type declarations with no runtime code (`import type { Temporal } from 'temporal-spec'`), never from a global declaration, so a Temporal field is typed precisely in a project that has no global `Temporal` declarations. `tsconfig.no-temporal-global.json` type-checks `src` without them. - **Runtime List Framing**: Parses inbound Postgres array text for contract-declared list columns before applying the scalar element codec. Builtin arrays and enum arrays therefore share one target-owned decode path; fixed-scale `numeric(30,10)[]` reads database-normalized text such as `"1.5000000000"`, matching scalar numeric decoding rather than the previous driver numeric-array float spelling `"1.5"`. @@ -254,7 +254,7 @@ See `@internal/family-sql/control` README for full error code documentation. ## Prisma 7 binding -`./prisma7-binding` exports `prisma7PostgresBinding`, everything the Postgres target supplies to the Prisma 7 interpreter in `@internal/sql-contract-prisma7`: the accepted `provider` names, the index types, the 63-byte identifier limit, the `@updatedAt` generator for each codec, how `Json`, `Bytes` and `DateTime` literal defaults are read (the SQL text Postgres stores for `Bytes` and `DateTime`, with `ARRAY[...]::type[]` for lists), and the type map. The type map is the table of what Prisma 7.10.0 creates in Postgres for each Prisma 7 scalar and `@db.*` native type, expressed as the Prisma 8 type constructor that produces the same column (`DateTime` is `TimestampString(3)`, `Decimal` is `Numeric(65, 30)`, `Json` is `Jsonb`, `@db.VarChar(n)` passes its argument through); the recorded SQL Prisma 7 generated for the reference schema is what the rows were read from. A `@db.*` spelling missing from the table is a hard error for the source, never a guess. The Postgres facade and the interpreter's tests import this one instance. +`./prisma7-binding` exports `prisma7PostgresBinding`, everything the Postgres target supplies to the Prisma 7 interpreter in `@internal/sql-contract-prisma7`: the accepted `provider` names, the index types, the 63-byte identifier limit, the `@updatedAt` generator for each codec, how `Json`, `Bytes` and `DateTime` literal defaults are read (the SQL text Postgres stores for `Bytes` and `DateTime`, with `ARRAY[...]::type[]` for lists), the SQL of a default whose value the column's codec cannot hold exactly (a quoted literal cast to the column type, or `ARRAY[...]::type[]`), and the type map. The type map is the table of what Prisma 7.10.0 creates in Postgres for each Prisma 7 scalar and `@db.*` native type, expressed as the Prisma 8 type constructor that produces the same column (`DateTime` is `TimestampJsDate(3)`, a `timestamp(3)` column read as a JavaScript `Date`, `Decimal` is `Numeric(65, 30)`, `Json` is `Jsonb`, `@db.VarChar(n)` passes its argument through); the recorded SQL Prisma 7 generated for the reference schema is what the rows were read from. A `@db.*` spelling missing from the table is a hard error for the source, never a guess. The Postgres facade and the interpreter's tests import this one instance. ## Introspection session settings diff --git a/packages/3-targets/postgres/1-target/src/core/authoring.ts b/packages/3-targets/postgres/1-target/src/core/authoring.ts index 4241088873e5..b61f0b45616b 100644 --- a/packages/3-targets/postgres/1-target/src/core/authoring.ts +++ b/packages/3-targets/postgres/1-target/src/core/authoring.ts @@ -56,12 +56,7 @@ import { assertWireNamePrefixLength, normalizeSqlBody } from '@internal/sql-sche import { assertDefined, invariant } from '@internal/utils/assertions'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; -import { - PG_ENUM_CODEC_ID, - PG_TIMESTAMPTZ_DATE_CODEC_ID, - PG_TIMESTAMPTZ_STRING_CODEC_ID, - PG_TIMESTAMPTZ_TEMPORAL_CODEC_ID, -} from './codec-ids'; +import { PG_ENUM_CODEC_ID, PG_TIMESTAMPTZ_DATE_CODEC_ID } from './codec-ids'; import { postgresError } from './errors'; import { describeFullTextIndexProblem, @@ -155,27 +150,15 @@ export const postgresAuthoringTypes = { temporal: postgresTemporalTypeAliases(), } as const satisfies AuthoringTypeNamespace; +/** + * The created-at and updated-at aliases, on the type bare `DateTime` becomes: a `timestamptz(3)` column read as a JavaScript `Date` (ADR 279). + */ /* @__NO_SIDE_EFFECTS__ */ function postgresTemporalTypeAliases() { - const temporal = temporalTypeAliases( - { codecId: PG_TIMESTAMPTZ_TEMPORAL_CODEC_ID }, - 'a Temporal.Instant', - ); - const string = temporalTypeAliases( - { codecId: PG_TIMESTAMPTZ_STRING_CODEC_ID }, - 'a string, the timestamp as Postgres writes it, such as 2024-01-02 03:04:05+00', - ); - const jsDate = temporalTypeAliases( - { codecId: PG_TIMESTAMPTZ_DATE_CODEC_ID }, + return temporalTypeAliases( + { codecId: PG_TIMESTAMPTZ_DATE_CODEC_ID, typeParams: { precision: 3 } }, 'a JavaScript Date', ); - return { - ...temporal, - CreatedAtString: string.CreatedAt, - UpdatedAtString: string.UpdatedAt, - CreatedAtJsDate: jsDate.CreatedAt, - UpdatedAtJsDate: jsDate.UpdatedAt, - } as const; } const policyTargetParam = { diff --git a/packages/3-targets/postgres/1-target/src/core/data-types.ts b/packages/3-targets/postgres/1-target/src/core/data-types.ts index 76ed5a19daf5..fbf1dd35e80e 100644 --- a/packages/3-targets/postgres/1-target/src/core/data-types.ts +++ b/packages/3-targets/postgres/1-target/src/core/data-types.ts @@ -337,6 +337,14 @@ export const pgPrecisionParams = arktype({ 'precision?': 'number.integer >= 0 & number.integer <= 6', }); +/** PostgreSQL stores microseconds whether a precision of 6 is declared or omitted, so an omitted precision is 6. */ +const withDefaultPrecision = (params: Params) => + params.precision === undefined ? { ...params, precision: 6 } : params; + +/** PostgreSQL's catalog prints a precision only where one was declared. */ +const precisionAsWritten = (params: Params) => + params; + export const pgEnumParams = arktype({ typeName: 'string > 0' }); /** A type with no parameters that is written and reported by its own name. */ @@ -600,6 +608,8 @@ export const pgTimetz = sqlDataType('pg/timetz', { ), spell: spellStoredDateTime(pgTimetzStoredText), params: pgPrecisionParams, + normalize: withDefaultPrecision, + catalogParams: precisionAsWritten, texts: [ written('timetz'), written('timetz({precision})'), @@ -618,6 +628,8 @@ export const pgInterval = sqlDataType('pg/interval', { spell: spellStoredDateTime(pgIntervalStoredText), equalityKey: intervalEqualityKey, params: pgPrecisionParams, + normalize: withDefaultPrecision, + catalogParams: precisionAsWritten, texts: [writtenAndCatalog('interval'), writtenAndCatalog('interval({precision})')], }); @@ -668,6 +680,8 @@ export const pgTime = sqlDataType('pg/time', { ), spell: spellStoredDateTime(pgTimeStoredText), params: pgPrecisionParams, + normalize: withDefaultPrecision, + catalogParams: precisionAsWritten, texts: [ written('time'), written('time({precision})'), @@ -685,6 +699,8 @@ export const pgTimestamp = sqlDataType('pg/timestamp', { ), spell: spellStoredDateTime(pgTimestampStoredText), params: pgPrecisionParams, + normalize: withDefaultPrecision, + catalogParams: precisionAsWritten, texts: [ written('timestamp'), written('timestamp({precision})'), @@ -702,6 +718,8 @@ export const pgTimestamptz = sqlDataType('pg/timestamptz', { ), spell: spellStoredDateTime(pgTimestamptzStoredText), params: pgPrecisionParams, + normalize: withDefaultPrecision, + catalogParams: precisionAsWritten, texts: [ written('timestamptz'), written('timestamptz({precision})'), diff --git a/packages/3-targets/postgres/1-target/src/core/date-codecs.ts b/packages/3-targets/postgres/1-target/src/core/date-codecs.ts index 3cdeb7557f39..0aa35ff5d8c1 100644 --- a/packages/3-targets/postgres/1-target/src/core/date-codecs.ts +++ b/packages/3-targets/postgres/1-target/src/core/date-codecs.ts @@ -38,6 +38,8 @@ interface DateCodecIdentity { readonly codecId: string; readonly typeDescription: string; readonly stringType: string; + /** The explicit types that hold digits below a millisecond, which a `Date` does not. */ + readonly finerTypes: string; readonly textCodecId: string; /** The instant, in the stored spelling of `pg/timestamptz`, that text of the codec's data type names. */ readonly instantText: (text: string) => string; @@ -47,6 +49,7 @@ const TIMESTAMPTZ_DATE: DateCodecIdentity = { codecId: PG_TIMESTAMPTZ_DATE_CODEC_ID, typeDescription: 'timestamp with time zone', stringType: 'TimestamptzString', + finerTypes: 'TimestamptzString and TimestamptzTemporal', textCodecId: PG_TIMESTAMPTZ_STRING_CODEC_ID, instantText: pgTimestamptzStoredText, }; @@ -55,6 +58,7 @@ const TIMESTAMP_DATE: DateCodecIdentity = { codecId: PG_TIMESTAMP_DATE_CODEC_ID, typeDescription: 'timestamp, read as UTC', stringType: 'TimestampString', + finerTypes: 'TimestampString and TimestampInstant', textCodecId: PG_TIMESTAMP_STRING_CODEC_ID, instantText: (text) => pgTimestamptzStoredText(`${pgTimestampStoredText(text)}Z`), }; @@ -99,8 +103,20 @@ function decodeDate(identity: DateCodecIdentity, wire: unknown): Date { return date; } -/** The `Date` a stored value names. A `Date` holds neither `infinity` nor an instant before 4714-11-24 BC or after +275760-09-13, so those are refused. */ +/** Whether stored text has nonzero digits below a millisecond, which a `Date` drops. */ +function hasDigitsBelowAMillisecond(text: string): boolean { + return /[1-9]/.test(/\.(\d+)/.exec(text)?.[1]?.slice(3) ?? ''); +} + +/** The `Date` a stored value names. A `Date` holds neither `infinity`, nor an instant before 4714-11-24 BC or after +275760-09-13, nor digits below a millisecond, so those are refused. */ function dateOfStoredText(identity: DateCodecIdentity, text: string): Date { + if (hasDigitsBelowAMillisecond(text)) { + return refuseJsonValue( + identity.codecId, + `an instant with no digits below a millisecond, which a Date drops: ${JSON.stringify(text)} has them, and ${identity.finerTypes} hold it`, + text, + ); + } const instant = storedInstant(identity, text); const date = instant === undefined ? undefined : new Date(instant); if (date === undefined || !isRepresentable(date)) { diff --git a/packages/3-targets/postgres/1-target/src/core/date-time-now-generators.ts b/packages/3-targets/postgres/1-target/src/core/date-time-now-generators.ts index 8e96cbc723d4..2b6a6695d4c8 100644 --- a/packages/3-targets/postgres/1-target/src/core/date-time-now-generators.ts +++ b/packages/3-targets/postgres/1-target/src/core/date-time-now-generators.ts @@ -3,7 +3,7 @@ import type { Temporal } from 'temporal-spec'; import { requireTemporal } from './require-temporal'; /** - * The Postgres "now" generators, one per date and time codec except `pg/timestamptz-date@1`, which uses the framework's `timestampNow`. All read the clock through `Date.now()` and write UTC. The time generators and the string timestamp generators round down to their `precision` param, so the column stores the value as written. The string generators are named after the codec they serve and write the codec's stored form (ADR 254, "Date and time types") in UTC, with milliseconds and no trailing zeros in the fraction. + * The Postgres "now" generators, one per date and time codec except `pg/timestamptz-date@1` and `pg/timestamp-date@1`, which use the SQL family's `timestampNow`. All read the clock through `Date.now()` and write UTC. The time and timestamp generators round down to their `precision` param, so the column stores the value as written. The string generators are named after the codec they serve and write the codec's stored form (ADR 254, "Date and time types") in UTC, with milliseconds and no trailing zeros in the fraction. */ export const INSTANT_NOW_GENERATOR_ID = 'instantNow' as const; export const PLAIN_DATE_TIME_NOW_GENERATOR_ID = 'plainDateTimeNow' as const; @@ -17,6 +17,7 @@ export const TIMETZ_NOW_GENERATOR_ID = 'timetzNow' as const; const UTC_OFFSET = 'Z'; const MAX_TIME_PRECISION = 6; +const MILLISECOND_DIGITS = 3; type GeneratorParams = Readonly> | undefined; @@ -50,20 +51,32 @@ function truncateTime(time: Temporal.PlainTime, fractionDigits: number): Tempora }); } -function utcDateTimeNow(generatorId: string): Temporal.ZonedDateTime { - return requireTemporal({ generatorId }) - .Instant.fromEpochMilliseconds(Date.now()) - .toZonedDateTimeISO('UTC'); +function epochMillisecondsNow(fractionDigits = MAX_TIME_PRECISION): number { + const now = Date.now(); + if (fractionDigits >= MILLISECOND_DIGITS) return now; + const step = 10 ** (MILLISECOND_DIGITS - Math.max(fractionDigits, 0)); + return Math.floor(now / step) * step; } -export function instantNow(): Temporal.Instant { - return requireTemporal({ generatorId: INSTANT_NOW_GENERATOR_ID }).Instant.fromEpochMilliseconds( - Date.now(), +function utcInstantNow(generatorId: string, fractionDigits?: number): Temporal.Instant { + return requireTemporal({ generatorId }).Instant.fromEpochMilliseconds( + epochMillisecondsNow(fractionDigits), ); } -export function plainDateTimeNow(): Temporal.PlainDateTime { - return utcDateTimeNow(PLAIN_DATE_TIME_NOW_GENERATOR_ID).toPlainDateTime(); +function utcDateTimeNow(generatorId: string, fractionDigits?: number): Temporal.ZonedDateTime { + return utcInstantNow(generatorId, fractionDigits).toZonedDateTimeISO('UTC'); +} + +export function instantNow(params?: GeneratorParams): Temporal.Instant { + return utcInstantNow(INSTANT_NOW_GENERATOR_ID, fractionDigitsOf(params)); +} + +export function plainDateTimeNow(params?: GeneratorParams): Temporal.PlainDateTime { + return utcDateTimeNow( + PLAIN_DATE_TIME_NOW_GENERATOR_ID, + fractionDigitsOf(params), + ).toPlainDateTime(); } export function plainDateNow(): Temporal.PlainDate { diff --git a/packages/3-targets/postgres/1-target/src/core/errors.ts b/packages/3-targets/postgres/1-target/src/core/errors.ts index 38795919a3fb..71ab5ed83c9f 100644 --- a/packages/3-targets/postgres/1-target/src/core/errors.ts +++ b/packages/3-targets/postgres/1-target/src/core/errors.ts @@ -71,13 +71,16 @@ export function errorPostgresMigrationStackMissing(operation: string): CliStruct ); } +const TEMPORAL_UNAVAILABLE_FIX = + 'Run on a runtime with Temporal available, install a Temporal polyfill before creating the client, or write the column as a convenience type at its default, which needs no Temporal: `DateTime` reads a JavaScript `Date`, and `Date`, `Time` and `Duration` read ISO 8601 strings.'; + export function errorTemporalUnavailable(codecId: string, operation: string): StructuredError { return postgresError( 'RUNTIME.TEMPORAL_UNAVAILABLE', `Codec '${codecId}' cannot ${operation} a value because this runtime has no global Temporal implementation.`, { why: 'Temporal-backed codecs read and write their values through the global Temporal API, which this runtime does not provide.', - fix: 'Run on a runtime with Temporal available, install a Temporal polyfill before creating the client, or author the column with its *String type to read and write ISO 8601 text instead.', + fix: TEMPORAL_UNAVAILABLE_FIX, meta: { codecId, operation }, }, ); @@ -88,8 +91,8 @@ export function errorTemporalUnavailableForDefault(generatorId: string): Structu 'RUNTIME.TEMPORAL_UNAVAILABLE', `Mutation default generator '${generatorId}' cannot produce a value because this runtime has no global Temporal implementation.`, { - why: 'The generator answers a Temporal-backed column, so the value it produces is a Temporal.Instant, which this runtime cannot construct.', - fix: 'Run on a runtime with Temporal available, install a Temporal polyfill before creating the client, or author the column with `temporal.CreatedAtString` / `temporal.UpdatedAtString` to store ISO 8601 text instead.', + why: 'The generator answers a Temporal-backed column, so the value it produces is a Temporal value, which this runtime cannot construct.', + fix: TEMPORAL_UNAVAILABLE_FIX, meta: { generatorId }, }, ); diff --git a/packages/3-targets/postgres/1-target/src/core/migrations/planner-sql-checks.ts b/packages/3-targets/postgres/1-target/src/core/migrations/planner-sql-checks.ts index cf6a6061c59f..501ce39be280 100644 --- a/packages/3-targets/postgres/1-target/src/core/migrations/planner-sql-checks.ts +++ b/packages/3-targets/postgres/1-target/src/core/migrations/planner-sql-checks.ts @@ -130,6 +130,7 @@ type WrittenTypeRead = readonly ok: true; readonly type: SqlDataType; readonly typeParams: SqlTypeParams; + readonly writtenTypeParams: SqlTypeParams; readonly many: boolean; } | { readonly ok: false; readonly reason: string }; @@ -147,14 +148,25 @@ function readWrittenType(type: string, dataTypes: readonly DataType[]): WrittenT reason: `more than one data type of the configured stack reads the type "${type}", as ${named}`, }; } - const resolved = byText[0] ?? resolvedByKind(base, sqlTypes); - if (resolved === undefined) { + const [written] = byText; + if (written !== undefined) { + return { + ok: true, + type: written.dataType, + typeParams: written.typeParams, + writtenTypeParams: written.writtenTypeParams, + many: array !== null, + }; + } + const byKind = resolvedByKind(base, sqlTypes); + if (byKind === undefined) { return { ok: false, reason: `no data type of the configured stack reads the type "${type}"` }; } return { ok: true, - type: resolved.dataType, - typeParams: resolved.typeParams, + type: byKind.dataType, + typeParams: byKind.typeParams, + writtenTypeParams: byKind.typeParams, many: array !== null, }; } @@ -177,7 +189,7 @@ function readColumnTypeCheck( }, }; } - const text = renderSqlCatalogText(read.type, read.typeParams); + const text = renderSqlCatalogText(read.type, read.writtenTypeParams); return { ok: true, check: { kind: 'catalog-text', text: `${text}${read.many ? '[]' : ''}` } }; } diff --git a/packages/3-targets/postgres/1-target/src/core/now-generators.ts b/packages/3-targets/postgres/1-target/src/core/now-generators.ts index 9fac8e9eb64c..fa96acc59911 100644 --- a/packages/3-targets/postgres/1-target/src/core/now-generators.ts +++ b/packages/3-targets/postgres/1-target/src/core/now-generators.ts @@ -51,13 +51,18 @@ export function postgresNowGeneratorIdFor(codecId: string): string | undefined { return nowGeneratorIdByCodecId.get(codecId); } -/** The codecs whose "now" generator takes the column's precision and rounds down to it, so PostgreSQL stores the value as generated rather than rounding it up, which for a time can reach `24:00:00`. */ +/** The codecs whose "now" generator takes the column's precision and rounds down to it, so PostgreSQL stores the value the client holds rather than rounding it up, which for a time can reach `24:00:00`. */ export const postgresNowGeneratorTakesPrecision = { [PG_TIME_TEMPORAL_CODEC_ID]: true, [PG_TIME_STRING_CODEC_ID]: true, [PG_TIMETZ_CODEC_ID]: true, [PG_TIMESTAMP_STRING_CODEC_ID]: true, [PG_TIMESTAMPTZ_STRING_CODEC_ID]: true, + [PG_TIMESTAMPTZ_DATE_CODEC_ID]: true, + [PG_TIMESTAMP_DATE_CODEC_ID]: true, + [PG_TIMESTAMP_TEMPORAL_CODEC_ID]: true, + [PG_TIMESTAMPTZ_TEMPORAL_CODEC_ID]: true, + [PG_TIMESTAMP_INSTANT_CODEC_ID]: true, } as const; /** The `now` generator function bound to each Postgres date and time codec, with the column's precision on the codecs that take it. */ diff --git a/packages/3-targets/postgres/1-target/src/core/prisma7-binding.ts b/packages/3-targets/postgres/1-target/src/core/prisma7-binding.ts index 4d04e44d335a..21d8a255a3d8 100644 --- a/packages/3-targets/postgres/1-target/src/core/prisma7-binding.ts +++ b/packages/3-targets/postgres/1-target/src/core/prisma7-binding.ts @@ -1,3 +1,6 @@ +import type { JsonValue } from '@internal/contract/types'; +import { isSqlDataType, renderSqlTypeName } from '@internal/sql-contract/data-type'; +import { InternalError } from '@internal/utils/internal-error'; import { postgresDataTypeEntries } from './data-type-entries'; import { pgBytea, @@ -8,13 +11,16 @@ import { pgTimestamp, pgTimestamptz, pgTimetz, + postgresDataTypes, } from './data-types'; import { defaultForeignKeyName, defaultPrimaryKeyName } from './default-constraint-names'; import { postgresTargetDescriptorMeta } from './descriptor-meta'; import { postgresCreateNamespace } from './postgres-schema'; +import { postgresYearText } from './postgres-year-text'; import { prisma7TemporalStored, prisma7TemporalTagBody } from './prisma7-temporal-defaults'; import { prisma7PostgresTypeMap } from './prisma7-type-map'; import { junctionRelationFieldNames } from './psl-infer/junction-relation-field-names'; +import { inlineLiteral } from './sql-utils'; /** The date and time types a Prisma 7 `DateTime` default can be written on. */ const DATE_TIME_TYPES: ReadonlySet = new Set([ @@ -44,6 +50,36 @@ function base64ToHex(base64: string): string | undefined { return `\\x${Buffer.from(base64, 'base64').toString('hex')}`; } +/** The column's type as a migration writes it, such as `timestamp(6)`. */ +function writtenTypeName( + dataType: string, + typeParams: Readonly> | undefined, +): string { + const type = postgresDataTypes.find((candidate) => candidate.id === dataType); + if (type === undefined || !isSqlDataType(type)) { + throw new InternalError(`The Postgres target registers no SQL data type "${dataType}".`); + } + return renderSqlTypeName(type, typeParams ?? {}); +} + +/** The SQL expression of a literal default, written as DDL writes the value: one literal, or for a list an array of them cast to an array of the column type. A date or time is written with the year text PostgreSQL reads. */ +function literalDefaultExpression( + stored: JsonValue, + column: { + readonly dataType: string; + readonly typeParams?: Readonly> | undefined; + }, +): string { + const typeName = writtenTypeName(column.dataType, column.typeParams); + const text = (value: JsonValue) => + typeof value === 'string' && DATE_TIME_TYPES.has(column.dataType) + ? postgresYearText(value) + : value; + return Array.isArray(stored) + ? inlineLiteral(stored.map(text), `${typeName}[]`) + : inlineLiteral(text(stored), typeName); +} + /** * What the Postgres target supplies to the Prisma 7 interpreter. A `Bytes` or * `DateTime` default is read through the tag its column type's authoring entry @@ -69,6 +105,7 @@ export const prisma7PostgresBinding = { identifierMaxBytes: 63, defaultConstraintNames: { primaryKey: defaultPrimaryKeyName, foreignKey: defaultForeignKeyName }, junctionRelationFieldNames, + literalDefaultExpression, literalDefaultForm: ({ dataType, typeParams, diff --git a/packages/3-targets/postgres/1-target/src/core/psl-build/psl-literals.ts b/packages/3-targets/postgres/1-target/src/core/psl-build/psl-literals.ts index c62b837ffb41..346f8338805f 100644 --- a/packages/3-targets/postgres/1-target/src/core/psl-build/psl-literals.ts +++ b/packages/3-targets/postgres/1-target/src/core/psl-build/psl-literals.ts @@ -56,3 +56,13 @@ export function positionalArg(value: string): PslAttributeArgument { export function namedArg(name: string, value: string): PslAttributeArgument { return { kind: 'named', name, value, span: SYNTHETIC_SPAN }; } + +/** A type call argument, by name when it has one. */ +export function typeArgument(argument: { + readonly name: string | undefined; + readonly text: string; +}): PslAttributeArgument { + return argument.name === undefined + ? positionalArg(argument.text) + : namedArg(argument.name, argument.text); +} diff --git a/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-model-blocks.ts b/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-model-blocks.ts index b46916917ea1..5d73d25d891b 100644 --- a/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-model-blocks.ts +++ b/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-model-blocks.ts @@ -37,6 +37,7 @@ import { parseDefaultAttributeString, positionalArg, SYNTHETIC_SPAN, + typeArgument, } from '../psl-build/psl-literals'; import { createUniqueFieldName } from '../psl-build/unique-name'; import type { InferredColumnDefaults } from './infer-default-codec'; @@ -253,7 +254,7 @@ function buildScalarField( const pslType = enumPslName === undefined ? columnTypes(table.name, column.name) - : { path: [enumPslName], args: [], values: [] }; + : { path: [enumPslName], args: [], explicit: { path: [enumPslName], values: [] } }; const typeName = pslType.path.join('.'); const typeConstructor: PslTypeConstructorCall | undefined = enumPslName !== undefined @@ -267,7 +268,7 @@ function buildScalarField( ? { kind: 'typeConstructor', path: pslType.path, - args: pslType.args.map(positionalArg), + args: pslType.args.map(typeArgument), span: SYNTHETIC_SPAN, } : undefined; @@ -284,11 +285,12 @@ function buildScalarField( defaultOf(table.name, column), { ...defaultMapping, - ...ifDefined('columnCodec', columnDefaults.codecOf(pslType, isEnumColumn)), - ...ifDefined('typeParams', columnDefaults.typeParamsOf(pslType, isEnumColumn)), + ...ifDefined('columnCodec', columnDefaults.codecOf(pslType.explicit, isEnumColumn)), + ...ifDefined('typeParams', columnDefaults.typeParamsOf(pslType.explicit, isEnumColumn)), list: column.many === true, }, - (value) => columnDefaults.readsBack(value, pslType, isEnumColumn, column.many === true), + (value) => + columnDefaults.readsBack(value, pslType.explicit, isEnumColumn, column.many === true), noteDefaultThatDoesNotReadBack, ); if (defaultAttribute !== undefined) { diff --git a/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-psl-contract.ts b/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-psl-contract.ts index e3b8e7f15e72..470895138ddf 100644 --- a/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-psl-contract.ts +++ b/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-psl-contract.ts @@ -38,7 +38,11 @@ import { import { buildModel } from './infer-model-blocks'; import { buildFieldNamesByTable, buildTopLevelNameMap, topologicalSort } from './infer-names'; import { buildIntrospectedPolicyBlocks } from './infer-policy-blocks'; -import { type InferredDefaultOf, readInferredDefaults } from './infer-read-defaults'; +import { + columnTypeReadsDefault, + type InferredDefaultOf, + readInferredDefaults, +} from './infer-read-defaults'; import { createPostgresDefaultMapping } from './postgres-default-mapping'; /** @@ -218,15 +222,17 @@ export async function inferPostgresPslContract( const enumInfo: EnumInfo = { definitions: enumDefinitions }; const isEnumColumn = (column: SqlColumnIR) => adoptedEnumTypeName(column, enumInfo.definitions, wrapNamespaceName) !== undefined; - const columnTypes = inferColumnTypes({ + const columnTypes = await inferColumnTypes({ tables: introspectedTables, constructors: inferredTypeConstructors({ authoringTypes: context.authoringContributions.type, codecLookup: context.codecLookup, dataTypeLookup: context.dataTypes.lookup, }), + authoringTypes: context.authoringContributions.type, dataTypes: context.dataTypes, isEnumColumn, + readsDefault: columnTypeReadsDefault(context), }); const options: PostgresPslInferOptions = { columnTypes, diff --git a/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-read-defaults.ts b/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-read-defaults.ts index aef9978ee383..566a1d984e24 100644 --- a/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-read-defaults.ts +++ b/packages/3-targets/postgres/1-target/src/core/psl-infer/infer-read-defaults.ts @@ -9,8 +9,8 @@ import { readReportedDefault, type SqlPslBuildContext, } from '@internal/family-sql/control'; -import type { ColumnTypeOf } from '@internal/family-sql/psl-build'; -import { type Codec, codecForRef } from '@internal/framework-components/codec'; +import type { ColumnTypeOf, ColumnTypeReadsDefault } from '@internal/family-sql/psl-build'; +import { type Codec, codecForRef, type DataTypeValue } from '@internal/framework-components/codec'; import type { SqlColumnIR, SqlSchemaIR } from '@internal/sql-schema-ir/types'; import { isInternalError } from '@internal/utils/internal-error'; import { pgJson, pgJsonb } from '../data-types'; @@ -89,10 +89,58 @@ export async function readDefaultWithCodec( if (JSON_TYPES.has(codec.dataType.id) && !bodiesOf(reported.literal).every(jsonBodyIsExact)) { return rawExpression; } + if (!bodiesOf(reported.literal).every((body) => codecHoldsExactly(codec, body))) { + return rawExpression; + } const read = await readReportedDefault(codec, reported.literal, raw); return 'resolvedDefault' in read ? read.resolvedDefault : rawExpression; } +/** + * Whether the codec of the type `contract infer` would write reads the column's reported default as a row value, with `fromWire`. A default that is not a literal, or a type that names no codec, has nothing to read. A `Date` codec reads a time with digits below a millisecond, dropping them, so such a default stays with it. + */ +export function columnTypeReadsDefault(context: SqlPslBuildContext): ColumnTypeReadsDefault { + return async (_tableName, column, type) => { + const raw: unknown = column.default; + if (typeof raw !== 'string') return true; + const reported = readPostgresDefault(raw, column.many === true); + if (reported.kind !== 'literal') return true; + const codec = codecOf(() => type, false, context); + if (codec === undefined) return true; + for (const body of bodiesOf(reported.literal)) { + if (body === null) continue; + try { + await codec.fromWire(body, {}); + } catch (error) { + if (isInternalError(error)) throw error; + return false; + } + } + return true; + }; +} + +/** + * Whether the codec's application value holds `body` exactly, as `contract emit` asks of a written default: the codec's `fromDataTypeValue` refuses a value it cannot hold exactly, such as a `Date` codec a time with digits below a millisecond. A body the data type does not read as text is left to the codec's wire reading. + */ +function codecHoldsExactly(codec: Codec, body: ReportedBody | null): boolean { + if (typeof body !== 'string') return true; + let value: DataTypeValue; + try { + value = codec.dataType.valueFrom(body, {}); + } catch (error) { + if (isInternalError(error)) throw error; + return true; + } + try { + codec.fromDataTypeValue(value); + return true; + } catch (error) { + if (isInternalError(error)) throw error; + return false; + } +} + const JSON_TYPES: ReadonlySet = new Set([pgJson.id, pgJsonb.id]); function bodiesOf(literal: ReportedLiteralDefault): readonly (ReportedBody | null)[] { @@ -122,7 +170,7 @@ function codecOf( context: SqlPslBuildContext, ) { try { - const ref = inferredCodecRef(context, isEnum ? { path: [] } : pslType(), isEnum); + const ref = inferredCodecRef(context, isEnum ? { path: [] } : pslType().explicit, isEnum); return ref === undefined ? undefined : codecForRef(context.codecLookup, ref); } catch (error) { if (isInternalError(error)) throw error; diff --git a/packages/3-targets/postgres/1-target/src/core/psl-infer/junction-relation-field-names.ts b/packages/3-targets/postgres/1-target/src/core/psl-infer/junction-relation-field-names.ts index 32834c3a08ed..d660eba8437d 100644 --- a/packages/3-targets/postgres/1-target/src/core/psl-infer/junction-relation-field-names.ts +++ b/packages/3-targets/postgres/1-target/src/core/psl-infer/junction-relation-field-names.ts @@ -57,7 +57,7 @@ export function junctionRelationFieldNames( tables: { [tableA]: idTable(tableA), [tableB]: idTable(tableB), [junctionName]: junction }, }), { - columnTypes: () => ({ path: ['Int'], args: [], values: [] }), + columnTypes: () => ({ path: ['Int'], args: [], explicit: { path: ['Int'], values: [] } }), defaultMapping: createPostgresDefaultMapping(), defaultOf: noInferredDefaults, columnDefaults: noColumnDefaults, diff --git a/packages/3-targets/postgres/1-target/src/core/psl-print/column-types.ts b/packages/3-targets/postgres/1-target/src/core/psl-print/column-types.ts index b48d005483dd..a2f1d37c5797 100644 --- a/packages/3-targets/postgres/1-target/src/core/psl-print/column-types.ts +++ b/packages/3-targets/postgres/1-target/src/core/psl-print/column-types.ts @@ -1,6 +1,7 @@ import { type InferredTypeConstructors, - writtenTypeArguments, + type PslTypeArgument, + writtenTypeChoiceCall, } from '@internal/family-sql/psl-build'; import { type AuthoringTypeConstructorCall, @@ -9,6 +10,7 @@ import { type DataTypeSupport, findAuthoringTypeConstructorCall, getAuthoringTypeConstructor, + writtenTypeArguments, } from '@internal/framework-components/authoring'; import type { DataTypeLookup } from '@internal/framework-components/codec'; import { dataTypeParams } from '@internal/framework-components/codec'; @@ -16,7 +18,7 @@ import type { PslTypeConstructorCall } from '@internal/framework-components/psl- import { isSqlDataType, unquotedSqlBaseName } from '@internal/sql-contract/data-type'; import type { StorageColumn } from '@internal/sql-contract/types'; import { PG_ENUM_CODEC_ID } from '../codec-ids'; -import { positionalArg, SYNTHETIC_SPAN } from '../psl-build/psl-literals'; +import { positionalArg, SYNTHETIC_SPAN, typeArgument } from '../psl-build/psl-literals'; import { refuseColumnWithoutPslType, refuseUnwritableTypeArgument } from './refusals'; /** @@ -50,13 +52,16 @@ function typeArgumentTexts( authoringTypes: AuthoringTypeNamespace, dataTypes: DataTypeSupport, coordinate: string, -): readonly string[] { +): readonly PslTypeArgument[] { const descriptor = getAuthoringTypeConstructor({ type: authoringTypes }, call.path); const written = descriptor && writtenTypeArguments(descriptor, call.args, dataTypes); - return written ?? refuseUnwritableTypeArgument(call, coordinate); + return ( + written?.map((text) => ({ name: undefined, text })) ?? + refuseUnwritableTypeArgument(call, coordinate) + ); } -function typeCall(path: readonly string[], args: readonly string[]): PslColumnType { +function typeCall(path: readonly string[], args: readonly PslTypeArgument[]): PslColumnType { const name = path.join('.'); const isNamespaced = path.length > 1; if (args.length === 0 && !isNamespaced) { @@ -68,7 +73,7 @@ function typeCall(path: readonly string[], args: readonly string[]): PslColumnTy typeConstructor: { kind: 'typeConstructor', path, - args: args.map(positionalArg), + args: args.map(typeArgument), span: SYNTHETIC_SPAN, }, }; @@ -91,8 +96,10 @@ function inferredConstructorCall( * The PSL type position for a storage column: a call to a type constructor the configured stack * contributes that produces exactly the column's codec, native type and type parameters. The * constructor `contract infer` writes for the column's data type comes first; otherwise - * the first one in the stack that produces them, such as `DateTime` for a `timestamptz` column read - * as a `Temporal.Instant`, or `pgvector.Vector(3)`. An enum-typed column takes the + * the first one in the stack that produces them, such as `TimestamptzTemporal` or + * `pgvector.Vector(3)`. Where a type choice call becomes that call exactly, the type choice is + * written instead, by name with only the arguments that differ from its defaults: `DateTime` for + * `TimestamptzJsDate(3)`, `DateTime(as: temporal)` for `TimestamptzTemporal` (ADR 279). An enum-typed column takes the * `pg.enum()` constructor, named after the value set the column points at; a column typed by * a domain enum takes that enum's name. A column no PSL type reads back is refused. */ @@ -137,10 +144,15 @@ export function buildColumnType(input: { inferredConstructorCall(column, inferredConstructors) ?? findAuthoringTypeConstructorCall(authoringTypes, column); if (call !== undefined) { - return typeCall( - call.path, - typeArgumentTexts(call, authoringTypes, input.dataTypes, coordinate), - ); + const convenience = writtenTypeChoiceCall({ + authoringTypes, + call, + dataTypes: input.dataTypes, + sameOutput: undefined, + }); + return convenience !== undefined + ? typeCall(convenience.path, convenience.args) + : typeCall(call.path, typeArgumentTexts(call, authoringTypes, input.dataTypes, coordinate)); } refuseColumnWithoutPslType(column, coordinate); diff --git a/packages/3-targets/postgres/1-target/src/core/psl-print/enum-blocks.ts b/packages/3-targets/postgres/1-target/src/core/psl-print/enum-blocks.ts index 36726a1fd243..6cb4d9bcb16c 100644 --- a/packages/3-targets/postgres/1-target/src/core/psl-print/enum-blocks.ts +++ b/packages/3-targets/postgres/1-target/src/core/psl-print/enum-blocks.ts @@ -1,6 +1,6 @@ import type { ContractEnum } from '@internal/contract/types'; import type { SqlPslBuildContext } from '@internal/family-sql/control'; -import { writeDefaultLiteral } from '@internal/family-sql/psl-build'; +import { writeTypedLiteral } from '@internal/framework-components/authoring'; import type { PslExtensionBlock, PslExtensionBlockPrintEntry, @@ -31,9 +31,9 @@ export function buildDomainEnumBlocks( const parameters = Object.fromEntries( domainEnum.members.map((member): [string, PslExtensionBlockPrintEntry] => { refuseUnwritableName('enum member', member.name); - const written = writeDefaultLiteral(member.value, { + const written = writeTypedLiteral(member.value, { dataTypes: context.dataTypes, - columnCodec: context.codecLookup.descriptorFor(domainEnum.codecId), + dataType: context.codecLookup.descriptorFor(domainEnum.codecId)?.dataType, }); if (written === undefined) { refuseUnwritableEnumMember({ enumName: name, member: member.name, value: member.value }); diff --git a/packages/3-targets/postgres/1-target/src/core/sql-utils.ts b/packages/3-targets/postgres/1-target/src/core/sql-utils.ts index 0b335d4bf69c..8d1edf6fd1f0 100644 --- a/packages/3-targets/postgres/1-target/src/core/sql-utils.ts +++ b/packages/3-targets/postgres/1-target/src/core/sql-utils.ts @@ -117,3 +117,57 @@ export const POSTGRES_QUOTING = [ { open: "'", close: "'", doubledCloseEscapes: true, backslashEscapes: false }, { open: '"', close: '"', doubledCloseEscapes: true, backslashEscapes: false }, ] as const; + +function isTextLikeTypeText(typeText: string): boolean { + return ( + typeText === 'text' || + typeText === 'varchar' || + typeText.startsWith('varchar(') || + typeText === 'character varying' || + typeText.startsWith('character varying(') || + typeText === 'char' || + typeText.startsWith('char(') || + typeText === 'character' || + typeText.startsWith('character(') + ); +} + +function literalNeedsCast(value: unknown, typeText: string): boolean { + if (typeof value === 'number') return !Number.isFinite(value); + if (typeof value === 'string') return !isTextLikeTypeText(typeText); + return typeof value === 'object' && value !== null; +} + +/** A value's literal without a cast: SQL NULL, a bare number or boolean, or quoted text. */ +function literalText(value: unknown): string { + if (value === null) return 'NULL'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number') + return Number.isFinite(value) ? String(value) : `'${String(value)}'`; + if (typeof value === 'bigint') return String(value); + if (typeof value === 'string') return `'${escapeLiteral(value)}'`; + if (value instanceof Uint8Array) { + const hex = Array.from(value) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return `'\\x${hex}'`; + } + if (typeof value === 'object') return `'${escapeLiteral(JSON.stringify(value))}'`; + throw postgresError( + 'CONTRACT.PACK_CONTRIBUTION_INVALID', + `pgRenderDdlExecuteRequest: unexpected wire type "${typeof value}"`, + { meta: { wireType: typeof value } }, + ); +} + +/** + * A value as DDL writes it for a column of `typeText`: `ARRAY[...]` of each element's literal, cast to the list type, for a list written to a list type, and otherwise the value's literal, cast to the column type where PostgreSQL would read it as another type. `value` is what the driver exchanges for the column, with a `Date` already written as text. + */ +export function inlineLiteral(value: unknown, typeText: string): string { + if (Array.isArray(value) && typeText.endsWith('[]')) { + if (value.length === 0) return "'{}'"; + return `ARRAY[${value.map(literalText).join(', ')}]::${typeText}`; + } + const text = literalText(value); + return literalNeedsCast(value, typeText) ? `${text}::${typeText}` : text; +} diff --git a/packages/3-targets/postgres/1-target/src/core/type-constructors.ts b/packages/3-targets/postgres/1-target/src/core/type-constructors.ts index 9b897ad8499d..cea6c2d57826 100644 --- a/packages/3-targets/postgres/1-target/src/core/type-constructors.ts +++ b/packages/3-targets/postgres/1-target/src/core/type-constructors.ts @@ -52,12 +52,6 @@ export const postgresScalarAuthoringTypes = { documentation: 'An exact decimal value stored as PostgreSQL numeric.', output: { codecId: 'pg/numeric@1' }, }, - DateTime: { - kind: 'typeConstructor', - documentation: - 'An instant stored as PostgreSQL timestamptz and represented as Temporal.Instant.', - output: { codecId: 'pg/timestamptz-temporal@1' }, - }, Json: { kind: 'typeConstructor', inferred: true, @@ -143,15 +137,6 @@ export const postgresNativeAuthoringTypes = { typeParams: { precision: { kind: 'arg', index: 0 } }, }, }, - Time: { - kind: 'typeConstructor', - documentation: 'A time of day without a time zone, represented as Temporal.PlainTime.', - args: [{ kind: 'value', dataType: PG_SIZE_TYPE, name: 'precision', optional: true }], - output: { - codecId: 'pg/time-temporal@1', - typeParams: { precision: { kind: 'arg', index: 0 } }, - }, - }, Timetz: { kind: 'typeConstructor', inferred: true, @@ -192,14 +177,6 @@ export const postgresNativeAuthoringTypes = { documentation: 'A calendar date represented as Temporal.PlainDate.', output: { codecId: 'pg/date-temporal@1' }, }, - Date: { - kind: 'typeConstructor', - documentation: 'A calendar date represented as Temporal.PlainDate.', - output: { codecId: 'pg/date-temporal@1' }, - }, - // The representation-explicit spellings. Same columns, same precision, same native types — the - // only difference is that a read hands back ISO 8601 text instead of a `Temporal.*`, so a value - // Temporal cannot express still round-trips. DateString: { kind: 'typeConstructor', inferred: true, @@ -208,7 +185,6 @@ export const postgresNativeAuthoringTypes = { }, TimestampString: { kind: 'typeConstructor', - inferred: true, documentation: 'A timestamp without a time zone represented as ISO 8601 text.', args: [{ kind: 'value', dataType: PG_SIZE_TYPE, name: 'precision', optional: true }], output: { @@ -218,6 +194,7 @@ export const postgresNativeAuthoringTypes = { }, TimestamptzJsDate: { kind: 'typeConstructor', + inferred: true, documentation: 'An instant stored as PostgreSQL timestamptz and represented as a JavaScript Date.', args: [{ kind: 'value', dataType: PG_SIZE_TYPE, name: 'precision', optional: true }], @@ -228,6 +205,7 @@ export const postgresNativeAuthoringTypes = { }, TimestampJsDate: { kind: 'typeConstructor', + inferred: true, documentation: 'A date and time stored as PostgreSQL timestamp and represented as a JavaScript Date, read and written as UTC.', args: [{ kind: 'value', dataType: PG_SIZE_TYPE, name: 'precision', optional: true }], @@ -248,7 +226,6 @@ export const postgresNativeAuthoringTypes = { }, TimestamptzString: { kind: 'typeConstructor', - inferred: true, documentation: 'A timestamp with time zone represented as ISO 8601 text, the instant in UTC.', args: [{ kind: 'value', dataType: PG_SIZE_TYPE, name: 'precision', optional: true }], output: { @@ -288,7 +265,6 @@ export const postgresNativeAuthoringTypes = { }, IntervalParts: { kind: 'typeConstructor', - inferred: true, documentation: 'A span of time stored as PostgreSQL interval and represented as { months, days, micros }, with optional fractional-second precision.', args: [{ kind: 'value', dataType: PG_SIZE_TYPE, name: 'precision', optional: true }], @@ -299,6 +275,7 @@ export const postgresNativeAuthoringTypes = { }, IntervalString: { kind: 'typeConstructor', + inferred: true, documentation: 'A span of time stored as PostgreSQL interval and represented as an ISO 8601 duration such as P1Y2M, with optional fractional-second precision.', args: [{ kind: 'value', dataType: PG_SIZE_TYPE, name: 'precision', optional: true }], @@ -309,10 +286,134 @@ export const postgresNativeAuthoringTypes = { }, } as const satisfies AuthoringTypeNamespace; +const precision = { + kind: 'value', + dataType: PG_SIZE_TYPE, + name: 'precision', + optional: true, +} as const; + +/** + * The convenience date and time types. Each is a type choice: its `as` and `storage` arguments choose the explicit type it becomes, and `precision` is passed on to that type. ADR 279. + */ +export const postgresConvenienceDateTimeTypes = { + DateTime: { + kind: 'typeChoice', + documentation: + 'A moment in time. `as` is what the application reads: jsDate (a JavaScript Date), temporal (Temporal.Instant) or string. `storage` is timestamptz or timestamp, a timestamp read and written as UTC.', + args: [ + { + kind: 'option', + name: 'as', + values: ['jsDate', 'temporal', 'string'], + default: 'jsDate', + optional: true, + }, + { + kind: 'option', + name: 'storage', + values: ['timestamptz', 'timestamp'], + default: 'timestamptz', + optional: true, + }, + precision, + ], + choices: [ + { + when: { as: 'jsDate', storage: 'timestamptz' }, + type: 'TimestamptzJsDate', + defaults: { precision: 3 }, + }, + { when: { as: 'temporal', storage: 'timestamptz' }, type: 'TimestamptzTemporal' }, + { when: { as: 'string', storage: 'timestamptz' }, type: 'TimestamptzString' }, + { + when: { as: 'jsDate', storage: 'timestamp' }, + type: 'TimestampJsDate', + defaults: { precision: 3 }, + }, + { when: { as: 'temporal', storage: 'timestamp' }, type: 'TimestampInstant' }, + { when: { as: 'string', storage: 'timestamp' }, type: 'TimestampString' }, + ], + }, + Date: { + kind: 'typeChoice', + documentation: + 'A calendar date. `as` is what the application reads: string, such as "2024-02-29", or temporal (Temporal.PlainDate).', + args: [ + { + kind: 'option', + name: 'as', + values: ['string', 'temporal'], + default: 'string', + optional: true, + }, + { kind: 'option', name: 'storage', values: ['date'], default: 'date', optional: true }, + ], + choices: [ + { when: { as: 'string', storage: 'date' }, type: 'DateString' }, + { when: { as: 'temporal', storage: 'date' }, type: 'DateTemporal' }, + ], + }, + Time: { + kind: 'typeChoice', + documentation: + 'A time of day. `as` is what the application reads: string, such as "09:30:00", or temporal (Temporal.PlainTime). `storage` is time, or timetz with as: string.', + args: [ + { + kind: 'option', + name: 'as', + values: ['string', 'temporal'], + default: 'string', + optional: true, + }, + { + kind: 'option', + name: 'storage', + values: ['time', 'timetz'], + default: 'time', + optional: true, + }, + precision, + ], + choices: [ + { when: { as: 'string', storage: 'time' }, type: 'TimeString' }, + { when: { as: 'temporal', storage: 'time' }, type: 'TimeTemporal' }, + { when: { as: 'string', storage: 'timetz' }, type: 'Timetz' }, + ], + }, + Duration: { + kind: 'typeChoice', + documentation: + 'A length of time. `as` is what the application reads: string, an ISO 8601 duration such as "P1M", or parts, the object { months, days, micros }.', + args: [ + { + kind: 'option', + name: 'as', + values: ['string', 'parts'], + default: 'string', + optional: true, + }, + { + kind: 'option', + name: 'storage', + values: ['interval'], + default: 'interval', + optional: true, + }, + precision, + ], + choices: [ + { when: { as: 'string', storage: 'interval' }, type: 'IntervalString' }, + { when: { as: 'parts', storage: 'interval' }, type: 'IntervalParts' }, + ], + }, +} as const satisfies AuthoringTypeNamespace; + /** * The base scalars and native types as type constructors. The target contributes them, so PSL and the TypeScript builder's `type.*` see the same names. */ export const postgresScalarAndNativeTypeConstructors = { ...postgresNativeAuthoringTypes, ...postgresScalarAuthoringTypes, + ...postgresConvenienceDateTimeTypes, } as const satisfies AuthoringTypeNamespace; diff --git a/packages/3-targets/postgres/1-target/src/exports/sql-utils.ts b/packages/3-targets/postgres/1-target/src/exports/sql-utils.ts index 03fa97b0fc8a..1583dea68ebc 100644 --- a/packages/3-targets/postgres/1-target/src/exports/sql-utils.ts +++ b/packages/3-targets/postgres/1-target/src/exports/sql-utils.ts @@ -1,6 +1,7 @@ export { renderFullTextIndexDocument } from '../core/full-text-search-document'; export { escapeLiteral, + inlineLiteral, qualifyName, quoteIdentifier, quoteQualifiedName, diff --git a/packages/3-targets/postgres/1-target/test/data-type-texts.test.ts b/packages/3-targets/postgres/1-target/test/data-type-texts.test.ts index 59c113051b38..799c36f98266 100644 --- a/packages/3-targets/postgres/1-target/test/data-type-texts.test.ts +++ b/packages/3-targets/postgres/1-target/test/data-type-texts.test.ts @@ -102,11 +102,11 @@ const ROUND_TRIPS: ReadonlyArray< [pgBit, { length: 8 }, 'bit(8)', 'bit(8)', { length: 8 }], [pgVarbit, {}, 'bit varying', 'bit varying', {}], [pgVarbit, { length: 8 }, 'bit varying(8)', 'bit varying(8)', { length: 8 }], - [pgTime, {}, 'time', 'time without time zone', {}], + [pgTime, {}, 'time', 'time without time zone', { precision: 6 }], [pgTime, { precision: 3 }, 'time(3)', 'time(3) without time zone', { precision: 3 }], - [pgTimetz, {}, 'timetz', 'time with time zone', {}], + [pgTimetz, {}, 'timetz', 'time with time zone', { precision: 6 }], [pgTimetz, { precision: 3 }, 'timetz(3)', 'time(3) with time zone', { precision: 3 }], - [pgTimestamp, {}, 'timestamp', 'timestamp without time zone', {}], + [pgTimestamp, {}, 'timestamp', 'timestamp without time zone', { precision: 6 }], [ pgTimestamp, { precision: 3 }, @@ -121,7 +121,7 @@ const ROUND_TRIPS: ReadonlyArray< 'timestamp(0) without time zone', { precision: 0 }, ], - [pgTimestamptz, {}, 'timestamptz', 'timestamp with time zone', {}], + [pgTimestamptz, {}, 'timestamptz', 'timestamp with time zone', { precision: 6 }], [ pgTimestamptz, { precision: 3 }, @@ -129,7 +129,7 @@ const ROUND_TRIPS: ReadonlyArray< 'timestamp(3) with time zone', { precision: 3 }, ], - [pgInterval, {}, 'interval', 'interval', {}], + [pgInterval, {}, 'interval', 'interval', { precision: 6 }], [pgInterval, { precision: 3 }, 'interval(3)', 'interval(3)', { precision: 3 }], ]; @@ -279,15 +279,15 @@ const CLAIMING_TEXTS: ReadonlyArray = ['bit varying(8)', 'pg/varbit', { length: 8 }], ['varbit', 'pg/varbit', {}], ['varbit(8)', 'pg/varbit', { length: 8 }], - ['time without time zone', 'pg/time', {}], + ['time without time zone', 'pg/time', { precision: 6 }], ['time(3) without time zone', 'pg/time', { precision: 3 }], - ['time with time zone', 'pg/timetz', {}], + ['time with time zone', 'pg/timetz', { precision: 6 }], ['time(3) with time zone', 'pg/timetz', { precision: 3 }], - ['timestamp without time zone', 'pg/timestamp', {}], + ['timestamp without time zone', 'pg/timestamp', { precision: 6 }], ['timestamp(3) without time zone', 'pg/timestamp', { precision: 3 }], - ['timestamp with time zone', 'pg/timestamptz', {}], + ['timestamp with time zone', 'pg/timestamptz', { precision: 6 }], ['timestamp(3) with time zone', 'pg/timestamptz', { precision: 3 }], - ['interval', 'pg/interval', {}], + ['interval', 'pg/interval', { precision: 6 }], ['interval(3)', 'pg/interval', { precision: 3 }], ]; diff --git a/packages/3-targets/postgres/1-target/test/date-codec-wiring.test.ts b/packages/3-targets/postgres/1-target/test/date-codec-wiring.test.ts index 59972ac630aa..b2245da0f28a 100644 --- a/packages/3-targets/postgres/1-target/test/date-codec-wiring.test.ts +++ b/packages/3-targets/postgres/1-target/test/date-codec-wiring.test.ts @@ -35,11 +35,11 @@ describe('Postgres Date codec wiring', () => { ); }); - it('provides opt-in Date type aliases that store the Date codec', () => { - const { CreatedAtJsDate, UpdatedAtJsDate } = postgresAuthoringTypes.temporal; - expect([CreatedAtJsDate.output.type, UpdatedAtJsDate.output.type]).toEqual([ - { codecId }, - { codecId }, + it('builds the created-at and updated-at aliases on the Date codec at millisecond precision', () => { + const { CreatedAt, UpdatedAt } = postgresAuthoringTypes.temporal; + expect([CreatedAt.output.type, UpdatedAt.output.type]).toEqual([ + { codecId, typeParams: { precision: 3 } }, + { codecId, typeParams: { precision: 3 } }, ]); }); }); diff --git a/packages/3-targets/postgres/1-target/test/date-codecs.sub-millisecond.test.ts b/packages/3-targets/postgres/1-target/test/date-codecs.sub-millisecond.test.ts new file mode 100644 index 000000000000..7a00cd9413a2 --- /dev/null +++ b/packages/3-targets/postgres/1-target/test/date-codecs.sub-millisecond.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { pgTimestamp, pgTimestamptz } from '../src/core/data-types'; +import { pgTimestampDateDescriptor, pgTimestamptzDateDescriptor } from '../src/core/date-codecs'; + +const instance = { name: 'test' }; +const timestamptzDate = pgTimestamptzDateDescriptor.factory({ precision: 6 })(instance); +const timestampDate = pgTimestampDateDescriptor.factory({ precision: 6 })(instance); + +describe('the Date codecs and digits below a millisecond', () => { + it('refuse a stored value with them, naming the types that hold it', () => { + expect(() => + timestamptzDate.fromDataTypeValue( + pgTimestamptz.valueFrom('2024-01-01T01:00:00.123456Z', { precision: 6 }), + ), + ).toThrow(/2024-01-01T01:00:00\.123456Z[\s\S]*TimestamptzString[\s\S]*TimestamptzTemporal/); + expect(() => + timestampDate.fromDataTypeValue( + pgTimestamp.valueFrom('2024-01-01T01:00:00.123456', { precision: 6 }), + ), + ).toThrow(/2024-01-01T01:00:00\.123456[\s\S]*TimestampString[\s\S]*TimestampInstant/); + }); + + it('take a stored value at a millisecond', () => { + expect([ + timestamptzDate.fromDataTypeValue( + pgTimestamptz.valueFrom('2024-01-01T01:00:00.123Z', { precision: 6 }), + ), + timestampDate.fromDataTypeValue( + pgTimestamp.valueFrom('2024-01-01T01:00:00.12', { precision: 6 }), + ), + ]).toEqual([new Date('2024-01-01T01:00:00.123Z'), new Date('2024-01-01T01:00:00.120Z')]); + }); + + it('read a row with them as the Date at the millisecond', async () => { + expect([ + await timestamptzDate.fromWire('2024-01-01 01:00:00.123456+00', {}), + await timestampDate.fromWire('2024-01-01 01:00:00.123456', {}), + ]).toEqual([new Date('2024-01-01T01:00:00.123Z'), new Date('2024-01-01T01:00:00.123Z')]); + }); +}); diff --git a/packages/3-targets/postgres/1-target/test/date-time-text-wording.test.ts b/packages/3-targets/postgres/1-target/test/date-time-text-wording.test.ts index e9ba15f10df6..63bb9299e3b5 100644 --- a/packages/3-targets/postgres/1-target/test/date-time-text-wording.test.ts +++ b/packages/3-targets/postgres/1-target/test/date-time-text-wording.test.ts @@ -41,6 +41,15 @@ describe('the wording that sends a user to a text date or time type', () => { }); }); + it.each([ + ['a codec', errorTemporalUnavailable('pg/date-temporal@1', 'decode')], + ['a generator', errorTemporalUnavailableForDefault('instantNow')], + ])('sends the missing Temporal error of %s to the convenience defaults', (_name, error) => { + expect(error.fix).toBe( + 'Run on a runtime with Temporal available, install a Temporal polyfill before creating the client, or write the column as a convenience type at its default, which needs no Temporal: `DateTime` reads a JavaScript `Date`, and `Date`, `Time` and `Duration` read ISO 8601 strings.', + ); + }); + it('says the driver could not ensure DateStyle=ISO, names a pooler as one cause, and gives no advice to set startup options', () => { const error = errorDateStyleNotIso('pg/date-string@1', '02.01.2026'); expect({ diff --git a/packages/3-targets/postgres/1-target/test/existing-column-types-without-temporal.test.ts b/packages/3-targets/postgres/1-target/test/existing-column-types-without-temporal.test.ts index 93eedb547989..cee3c8ec93bd 100644 --- a/packages/3-targets/postgres/1-target/test/existing-column-types-without-temporal.test.ts +++ b/packages/3-targets/postgres/1-target/test/existing-column-types-without-temporal.test.ts @@ -5,6 +5,7 @@ import { postgresScalarAndNativeTypeConstructors } from '../src/core/type-constr const TEMPORAL_CODEC_IDS = [ 'pg/date-temporal@1', 'pg/timestamp-temporal@1', + 'pg/timestamp-instant@1', 'pg/timestamptz-temporal@1', 'pg/time-temporal@1', ]; @@ -20,11 +21,12 @@ const constructorNames = [ ]; function codecIdOf(name: string): string | undefined { - return Object.hasOwn(postgresScalarAndNativeTypeConstructors, name) + const descriptor = Object.hasOwn(postgresScalarAndNativeTypeConstructors, name) ? postgresScalarAndNativeTypeConstructors[ name as keyof typeof postgresScalarAndNativeTypeConstructors - ].output.codecId + ] : undefined; + return descriptor?.kind === 'typeConstructor' ? descriptor.output.codecId : undefined; } describe('the types contract infer and prisma7Schema write', () => { @@ -46,8 +48,11 @@ describe('the constructors marked inferred', () => { it('name no codec that needs Temporal', () => { expect( Object.entries(postgresScalarAndNativeTypeConstructors) - .filter(([, descriptor]) => 'inferred' in descriptor) - .map(([name, descriptor]) => ({ name, codecId: descriptor.output.codecId })) + .flatMap(([name, descriptor]) => + descriptor.kind === 'typeConstructor' && 'inferred' in descriptor + ? [{ name, codecId: descriptor.output.codecId }] + : [], + ) .filter(({ codecId }) => TEMPORAL_CODEC_IDS.includes(codecId)), ).toEqual([]); }); diff --git a/packages/3-targets/postgres/1-target/test/generator-functions.test.ts b/packages/3-targets/postgres/1-target/test/generator-functions.test.ts index 7d7e5105c6b6..dfbce407fbb2 100644 --- a/packages/3-targets/postgres/1-target/test/generator-functions.test.ts +++ b/packages/3-targets/postgres/1-target/test/generator-functions.test.ts @@ -11,11 +11,6 @@ function resolve(name: string, codecId: string, typeParams?: Record { it.each([ - ['pg/timestamptz-temporal@1', 'instantNow'], - ['pg/timestamp-temporal@1', 'plainDateTimeNow'], - ['pg/timestamptz-date@1', 'timestampNow'], - ['pg/timestamp-date@1', 'timestampNow'], - ['pg/timestamp-instant@1', 'instantNow'], ['pg/date-temporal@1', 'plainDateNow'], ['pg/date-string@1', 'dateStringNow'], ])('resolve now() on %s to %s with no params', (codecId, generatorId) => { @@ -31,6 +26,11 @@ describe('the Postgres generator functions', () => { ['pg/timetz@1', 'timetzNow'], ['pg/timestamptz-string@1', 'timestamptzStringNow'], ['pg/timestamp-string@1', 'timestampStringNow'], + ['pg/timestamptz-date@1', 'timestampNow'], + ['pg/timestamp-date@1', 'timestampNow'], + ['pg/timestamptz-temporal@1', 'instantNow'], + ['pg/timestamp-temporal@1', 'plainDateTimeNow'], + ['pg/timestamp-instant@1', 'instantNow'], ])("resolve now() on %s to %s with the column's precision", (codecId, generatorId) => { expect({ withPrecision: resolve('now', codecId, { precision: 3 }), diff --git a/packages/3-targets/postgres/1-target/test/migrations/alter-type-check.test.ts b/packages/3-targets/postgres/1-target/test/migrations/alter-type-check.test.ts index c02f17888fa7..9e246af72bd3 100644 --- a/packages/3-targets/postgres/1-target/test/migrations/alter-type-check.test.ts +++ b/packages/3-targets/postgres/1-target/test/migrations/alter-type-check.test.ts @@ -31,6 +31,20 @@ describe('alterTypeCheck', () => { ]); }); + it('compares a date or time type by the text format_type prints for its precision as written', () => { + expect( + ['timestamptz', 'timestamptz(6)', 'time', 'timetz(6)', 'interval'].map((type) => + alterTypeCheck(type, postgresDataTypes, where), + ), + ).toEqual([ + catalogText('timestamp with time zone'), + catalogText('timestamp(6) with time zone'), + catalogText('time without time zone'), + catalogText('time(6) with time zone'), + catalogText('interval'), + ]); + }); + it('compares an enum by identity, by the name its data type renders, whatever schema it is in', () => { expect( ['"Mood"', '"app"."status"', '"app"."Status"', '"public"."Mood"', '"Mood"[]'].map((type) => @@ -77,7 +91,7 @@ describe('alterTypeCheck', () => { ); }); - it('gives the catalog text of the parameters in normal form for every written text of every Postgres data type', () => { + it('gives the catalog text of the parameters as written for every written text of every Postgres data type', () => { const sqlTypes = postgresDataTypes.filter( (type): type is SqlDataType => isSqlDataType(type) && type.sql.claimsKind === undefined, ); diff --git a/packages/3-targets/postgres/1-target/test/migrations/column-type-comparison.test.ts b/packages/3-targets/postgres/1-target/test/migrations/column-type-comparison.test.ts index a4d650cafe9e..23fa9440f205 100644 --- a/packages/3-targets/postgres/1-target/test/migrations/column-type-comparison.test.ts +++ b/packages/3-targets/postgres/1-target/test/migrations/column-type-comparison.test.ts @@ -118,6 +118,29 @@ describe('the planner compares a live column with the contract by data type', () ]).toEqual([0, 0, 0, 0, 0]); }); + it('plans no type change between an omitted precision and precision 6 on a date or time column', () => { + const timestamptz = (typeParams?: { precision: number }) => ({ + dataType: 'pg/timestamptz', + codecId: 'pg/timestamptz-date@1', + nullable: true, + ...(typeParams === undefined ? {} : { typeParams }), + }); + expect([ + typeChangesPlanned(timestamptz(), { + typeText: 'timestamp(6) with time zone', + nullable: true, + }), + typeChangesPlanned(timestamptz({ precision: 6 }), { + typeText: 'timestamp with time zone', + nullable: true, + }), + typeChangesPlanned(timestamptz({ precision: 3 }), { + typeText: 'timestamp with time zone', + nullable: true, + }), + ]).toEqual([0, 0, 1]); + }); + it('plans a type change for a live column no data type claims, whatever its text', () => { expect( typeChangesPlanned( diff --git a/packages/3-targets/postgres/1-target/test/now-generators.test.ts b/packages/3-targets/postgres/1-target/test/now-generators.test.ts index ed4d28d8bacd..efd75cc54565 100644 --- a/packages/3-targets/postgres/1-target/test/now-generators.test.ts +++ b/packages/3-targets/postgres/1-target/test/now-generators.test.ts @@ -144,6 +144,11 @@ describe('the text "now" generators', () => { expect(postgresNowGeneratorTakesPrecision).toMatchObject({ 'pg/timestamp-string@1': true, 'pg/timestamptz-string@1': true, + 'pg/timestamptz-date@1': true, + 'pg/timestamp-date@1': true, + 'pg/timestamptz-temporal@1': true, + 'pg/timestamp-temporal@1': true, + 'pg/timestamp-instant@1': true, }); }); @@ -180,6 +185,31 @@ describe('the time "now" generators', () => { }); }); +describe('the Temporal timestamp "now" generators', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + [0, '2024-01-02T23:59:59'], + [1, '2024-01-02T23:59:59.9'], + [2, '2024-01-02T23:59:59.99'], + [3, '2024-01-02T23:59:59.999'], + [6, '2024-01-02T23:59:59.999'], + [undefined, '2024-01-02T23:59:59.999'], + ])( + 'round down to precision %s, so the column stores the value the client holds', + (precision, text) => { + vi.useFakeTimers({ now: new Date('2024-01-02T23:59:59.999Z') }); + const params = precision === undefined ? undefined : { precision }; + expect([instantNow(params).toString(), plainDateTimeNow(params).toString()]).toEqual([ + `${text}Z`, + text, + ]); + }, + ); +}); + describe('the Temporal date and time "now" generators', () => { const DAY_MILLISECONDS = 86_400_000; const utcDate = (epochMilliseconds: number) => diff --git a/packages/3-targets/postgres/1-target/test/prisma7-binding.refused-defaults.test.ts b/packages/3-targets/postgres/1-target/test/prisma7-binding.refused-defaults.test.ts new file mode 100644 index 000000000000..027666738f8b --- /dev/null +++ b/packages/3-targets/postgres/1-target/test/prisma7-binding.refused-defaults.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { prisma7PostgresBinding } from '../src/core/prisma7-binding'; + +const column = (dataType: string, typeParams?: Record) => ({ + codecId: 'test@1', + dataType, + ...(typeParams === undefined ? {} : { typeParams }), +}); + +describe('the SQL expression the Postgres binding writes for a literal default', () => { + it('is a quoted literal cast to the column type, for one value', () => { + expect( + prisma7PostgresBinding.literalDefaultExpression( + '2024-01-02T03:04:05.123456', + column('pg/timestamp', { precision: 6 }), + ), + ).toBe("'2024-01-02T03:04:05.123456'::timestamp(6)"); + }); + + it('is an array of quoted literals cast to an array of the column type, for a list', () => { + expect( + prisma7PostgresBinding.literalDefaultExpression( + ['2024-01-02T03:04:05.123456Z', null], + column('pg/timestamptz'), + ), + ).toBe("ARRAY['2024-01-02T03:04:05.123456Z', NULL]::timestamptz[]"); + }); + + it('escapes a quote in the value, and casts text only where PostgreSQL would read another type', () => { + expect(prisma7PostgresBinding.literalDefaultExpression("it's", column('pg/text'))).toBe( + "'it''s'", + ); + }); +}); diff --git a/packages/3-targets/postgres/1-target/test/prisma7-type-map.test.ts b/packages/3-targets/postgres/1-target/test/prisma7-type-map.test.ts index 11d7ae6f0b00..028f0fa536e6 100644 --- a/packages/3-targets/postgres/1-target/test/prisma7-type-map.test.ts +++ b/packages/3-targets/postgres/1-target/test/prisma7-type-map.test.ts @@ -11,7 +11,7 @@ describe('the Prisma 7 type table', () => { BigInt: { constructorName: 'BigInt', args: [] }, Float: { constructorName: 'Float', args: [] }, Decimal: { constructorName: 'Numeric', args: [65, 30] }, - DateTime: { constructorName: 'TimestampString', args: [3] }, + DateTime: { constructorName: 'TimestampJsDate', args: [3] }, Json: { constructorName: 'Jsonb', args: [] }, Bytes: { constructorName: 'Bytes', args: [] }, }, @@ -28,8 +28,8 @@ describe('the Prisma 7 type table', () => { Real: { constructorName: 'Real', args: [] }, DoublePrecision: { constructorName: 'Float', args: [] }, Decimal: { constructorName: 'Numeric', args: [] }, - Timestamp: { constructorName: 'TimestampString', args: [] }, - Timestamptz: { constructorName: 'TimestamptzString', args: [] }, + Timestamp: { constructorName: 'TimestampJsDate', args: [] }, + Timestamptz: { constructorName: 'TimestamptzJsDate', args: [] }, Date: { constructorName: 'DateString', args: [] }, Time: { constructorName: 'TimeString', args: [] }, Timetz: { constructorName: 'Timetz', args: [] }, diff --git a/packages/3-targets/postgres/1-target/test/psl-build/marked-type-constructors.test.ts b/packages/3-targets/postgres/1-target/test/psl-build/marked-type-constructors.test.ts index 49da2d6573c3..26499461dab2 100644 --- a/packages/3-targets/postgres/1-target/test/psl-build/marked-type-constructors.test.ts +++ b/packages/3-targets/postgres/1-target/test/psl-build/marked-type-constructors.test.ts @@ -35,7 +35,7 @@ describe('inferredTypeConstructors', () => { ).toEqual({ 'pg/text': ['String'], 'pg/numeric': ['Numeric'], - 'pg/timestamptz': ['TimestamptzString'], + 'pg/timestamptz': ['TimestamptzJsDate'], 'pg/bit': ['Bit'], 'pg/enum': ['pg', 'enum'], }); @@ -69,7 +69,7 @@ describe('fewestArgumentsCall', () => { char5: { path: ['Char'], args: [5] }, bit1: { path: ['Bit'], args: [] }, varbit: { path: ['VarBit'], args: [] }, - timestamptz3: { path: ['TimestamptzString'], args: [3] }, + timestamptz3: { path: ['TimestamptzJsDate'], args: [3] }, text: { path: ['String'], args: [] }, }); }); diff --git a/packages/3-targets/postgres/1-target/test/psl-infer/fixtures.ts b/packages/3-targets/postgres/1-target/test/psl-infer/fixtures.ts index 4e3d44c26b68..56d3b5127b89 100644 --- a/packages/3-targets/postgres/1-target/test/psl-infer/fixtures.ts +++ b/packages/3-targets/postgres/1-target/test/psl-infer/fixtures.ts @@ -60,7 +60,7 @@ export function reportedFromWritten( const [resolved] = resolveWrittenSqlTypes(typeText, postgresDataTypes); const text = resolved !== undefined && resolved.dataType.sql.claimsKind === undefined - ? renderSqlCatalogText(resolved.dataType, resolved.typeParams) + ? renderSqlCatalogText(resolved.dataType, resolved.writtenTypeParams) : typeText; return { text, kind: undefined, schema: 'pg_catalog', name: undefined }; } @@ -145,7 +145,7 @@ export function inferFromFixtureTree( export function inferredColumnTypesOf( tables: Readonly>, enums: FixtureEnums = new Map(), -): ColumnTypeOf { +): Promise { return inferColumnTypes({ tables: Object.values(tables).map((table) => ({ schemaName: 'public', @@ -157,8 +157,10 @@ export function inferredColumnTypesOf( codecLookup: inferBuildContext.codecLookup, dataTypeLookup: inferBuildContext.dataTypes.lookup, }), + authoringTypes: inferBuildContext.authoringContributions.type, dataTypes: inferBuildContext.dataTypes, isEnumColumn: (column) => column.reportedType?.kind === 'enum', + readsDefault: undefined, }); } @@ -213,6 +215,8 @@ export function treeFromFlat(schemaIR: SqlSchemaIR): PostgresDatabaseSchemaNode }); } +const inferCodecLookup = createPostgresBuiltinCodecLookup(); + /** * The stack `contract emit` reads an inferred schema with: the type constructors the family and the * adapter contribute, the target's own, and the target's codecs and data types. @@ -227,11 +231,11 @@ export const inferBuildContext: SqlPslBuildContext = { }, generatorFunctions: EMPTY_GENERATOR_FUNCTIONS, componentIdsByGenerator: new Map(), - codecLookup: createPostgresBuiltinCodecLookup(), + codecLookup: inferCodecLookup, dataTypes: { entries: postgresDataTypeEntries(), lookup: createDataTypeLookup(postgresDataTypes), - dataTypeOfCodec: () => undefined, + dataTypeOfCodec: (codecId) => inferCodecLookup.descriptorFor(codecId)?.dataType, }, }; diff --git a/packages/3-targets/postgres/1-target/test/psl-infer/infer-column-types.test.ts b/packages/3-targets/postgres/1-target/test/psl-infer/infer-column-types.test.ts index b304447c2e26..5e6cb49e015c 100644 --- a/packages/3-targets/postgres/1-target/test/psl-infer/infer-column-types.test.ts +++ b/packages/3-targets/postgres/1-target/test/psl-infer/infer-column-types.test.ts @@ -7,7 +7,7 @@ function reported(text: string, rest: Partial = {}): ReportedSq return { text, kind: undefined, schema: 'pg_catalog', name: undefined, ...rest }; } -/** Each Postgres data type with a marked constructor, as Postgres reports a column of it, and what infer prints. */ +/** Each Postgres data type with a marked constructor, as Postgres reports a column of it, and what infer prints: the type choice that becomes the marked constructor where one does. */ const COLUMN_TYPES = [ ['text', 'String'], ['boolean', 'Boolean'], @@ -29,21 +29,23 @@ const COLUMN_TYPES = [ ['character varying(20)', 'VarChar(20)'], ['uuid', 'Uuid'], ['inet', 'Inet'], - ['date', 'DateString'], - ['time without time zone', 'TimeString'], - ['time(3) without time zone', 'TimeString(3)'], - ['time with time zone', 'Timetz'], - ['time(2) with time zone', 'Timetz(2)'], - ['timestamp without time zone', 'TimestampString'], - ['timestamp(3) without time zone', 'TimestampString(3)'], - ['timestamp with time zone', 'TimestamptzString'], - ['timestamp(6) with time zone', 'TimestamptzString(6)'], + ['date', 'Date'], + ['time without time zone', 'Time'], + ['time(0) without time zone', 'Time(precision: 0)'], + ['time(3) without time zone', 'Time(precision: 3)'], + ['time with time zone', 'Time(storage: timetz)'], + ['time(2) with time zone', 'Time(storage: timetz, precision: 2)'], + ['timestamp without time zone', 'DateTime(storage: timestamp, precision: 6)'], + ['timestamp(3) without time zone', 'DateTime(storage: timestamp)'], + ['timestamp with time zone', 'DateTime(precision: 6)'], + ['timestamp(3) with time zone', 'DateTime'], + ['timestamp(6) with time zone', 'DateTime(precision: 6)'], ['bit(1)', 'Bit'], ['bit(8)', 'Bit(8)'], ['bit varying', 'VarBit'], ['bit varying(16)', 'VarBit(16)'], - ['interval', 'IntervalParts'], - ['interval(3)', 'IntervalParts(3)'], + ['interval', 'Duration'], + ['interval(3)', 'Duration(precision: 3)'], ] as const; function tableWith(columns: Record) { @@ -75,7 +77,7 @@ function printedFieldTypes(printed: string): Record { } describe('contract infer picks each column type through its data type', () => { - it('prints the marked constructor with the fewest arguments, for a scalar and a list', async () => { + it('prints the type choice with the arguments that differ from its defaults, or else the marked constructor with the fewest arguments, for a scalar and a list', async () => { const columns = Object.fromEntries( COLUMN_TYPES.flatMap(([text], index) => [ [`c${index * 2}`, { reportedType: reported(text) }], diff --git a/packages/3-targets/postgres/1-target/test/psl-infer/infer-parse-emit.test.ts b/packages/3-targets/postgres/1-target/test/psl-infer/infer-parse-emit.test.ts index 9e4fa5bb63f2..2ba123bf6dc1 100644 --- a/packages/3-targets/postgres/1-target/test/psl-infer/infer-parse-emit.test.ts +++ b/packages/3-targets/postgres/1-target/test/psl-infer/infer-parse-emit.test.ts @@ -24,66 +24,11 @@ import { } from '../../src/core/authoring'; import { createPostgresBuiltinCodecLookup } from '../../src/core/codec-registry'; import { type PostgresSchema, postgresCreateNamespace } from '../../src/core/postgres-schema'; +import { postgresScalarAndNativeTypeConstructors } from '../../src/core/type-constructors'; import { postgresDataTypeSupport } from '../fixtures/postgres-data-type-support'; import { printPslFromFlat } from './fixtures'; -const authoringTypes = { - Int: { kind: 'typeConstructor', output: { codecId: 'pg/int4@1' } }, - Uuid: { kind: 'typeConstructor', output: { codecId: 'pg/uuid@1' } }, - Inet: { kind: 'typeConstructor', output: { codecId: 'pg/inet@1' } }, - TimestampString: { - kind: 'typeConstructor', - args: [{ kind: 'value', dataType: 'pg/int4', name: 'precision', optional: true }], - output: { - codecId: 'pg/timestamp-string@1', - typeParams: { precision: { kind: 'arg', index: 0 } }, - }, - }, - TimestamptzString: { - kind: 'typeConstructor', - args: [{ kind: 'value', dataType: 'pg/int4', name: 'precision', optional: true }], - output: { - codecId: 'pg/timestamptz-string@1', - typeParams: { precision: { kind: 'arg', index: 0 } }, - }, - }, - DateString: { - kind: 'typeConstructor', - output: { codecId: 'pg/date-string@1' }, - }, - TimeString: { - kind: 'typeConstructor', - args: [{ kind: 'value', dataType: 'pg/int4', name: 'precision', optional: true }], - output: { - codecId: 'pg/time-string@1', - typeParams: { precision: { kind: 'arg', index: 0 } }, - }, - }, - VarChar: { - kind: 'typeConstructor', - args: [{ kind: 'value', dataType: 'pg/int4', name: 'length', optional: true }], - output: { - codecId: 'sql/varchar@1', - typeParams: { length: { kind: 'arg', index: 0 } }, - }, - }, - Numeric: { - kind: 'typeConstructor', - args: [ - { kind: 'value', dataType: 'pg/int4', name: 'precision', optional: true }, - { kind: 'value', dataType: 'pg/int4', name: 'scale', optional: true }, - ], - output: { - codecId: 'pg/numeric@1', - typeParams: { - precision: { kind: 'arg', index: 0 }, - scale: { kind: 'arg', index: 1 }, - }, - }, - }, - Json: { kind: 'typeConstructor', output: { codecId: 'pg/json@1' } }, - Jsonb: { kind: 'typeConstructor', output: { codecId: 'pg/jsonb@1' } }, -} as const satisfies AuthoringTypeNamespace; +const authoringTypes: AuthoringTypeNamespace = postgresScalarAndNativeTypeConstructors; const assembled = assembleAuthoringContributions([ { @@ -180,10 +125,10 @@ describe('Postgres PSL inference round trip', () => { expect(inferred).not.toContain('bareAmount Numeric()'); expect(inferred).toMatch(/jsonValue\s+Json/); expect(inferred).toMatch(/jsonbValue\s+Jsonb/); - expect(inferred).toMatch(/occurredAt\s+TimestamptzString\s/); - expect(inferred).toMatch(/loggedAt\s+TimestampString\(3\)/); - expect(inferred).toMatch(/dueOn\s+DateString\s/); - expect(inferred).toMatch(/opensAt\s+TimeString\s/); + expect(inferred).toMatch(/occurredAt\s+DateTime\(precision: 6\)\s/); + expect(inferred).toMatch(/loggedAt\s+DateTime\(storage: timestamp\)\s/); + expect(inferred).toMatch(/dueOn\s+Date\s/); + expect(inferred).toMatch(/opensAt\s+Time\s/); expect(inferred).toMatch(/label\s+VarChar\(191\)/); const emitted = parseAndEmit(inferred); @@ -237,13 +182,14 @@ describe('Postgres PSL inference round trip', () => { many: false, }, occurred_at: { - codecId: 'pg/timestamptz-string@1', + codecId: 'pg/timestamptz-date@1', dataType: 'pg/timestamptz', nullable: false, many: false, + typeParams: { precision: 6 }, }, logged_at: { - codecId: 'pg/timestamp-string@1', + codecId: 'pg/timestamp-date@1', dataType: 'pg/timestamp', nullable: false, many: false, diff --git a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl.round-trip.test.ts b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl.round-trip.test.ts index 288592a921d9..bbee676be935 100644 --- a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl.round-trip.test.ts +++ b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl.round-trip.test.ts @@ -198,11 +198,6 @@ two lines é'::text`, '2024-01-01T00:00:00.5', ], ['timestamptz', "'2024-01-01 01:00:00+00'::timestamp with time zone", '2024-01-01T01:00:00Z'], - [ - 'timestamptz(6)', - "'2024-01-01 01:00:00.123456+00'::timestamp(6) with time zone", - '2024-01-01T01:00:00.123456Z', - ], [ 'timestamptz', "'0044-03-15 00:00:00+00 BC'::timestamp with time zone", @@ -213,8 +208,6 @@ two lines é'::text`, ['time', "'12:34:56.5'::time without time zone", '12:34:56.5'], ['time(3)', "'12:34:56.123'::time(3) without time zone", '12:34:56.123'], ['timetz', "'12:34:56+02'::time with time zone", '12:34:56+02:00'], - ['timestamp', "'infinity'::timestamp without time zone", 'infinity'], - ['timestamptz', "'-infinity'::timestamp with time zone", '-infinity'], ['date', "'infinity'::date", 'infinity'], ])('round-trips a %s default of %s', async (typeText, rawDefault, value) => { expect(await roundTrippedDefaults([introspected('stamp', typeText, rawDefault)])).toEqual({ diff --git a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.core.test.ts b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.core.test.ts index 0e475669b238..c045c22aa27f 100644 --- a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.core.test.ts +++ b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.core.test.ts @@ -75,7 +75,7 @@ describe('printPsl', () => { // WARNING: This table has no primary key in the database model AuditLog { event String - timestamp TimestamptzString + timestamp DateTime(precision: 6) @@map("audit_log") } diff --git a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.data-type-defaults.test.ts b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.data-type-defaults.test.ts index e54f1c52dfd1..a15188da17fb 100644 --- a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.data-type-defaults.test.ts +++ b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.data-type-defaults.test.ts @@ -1,9 +1,12 @@ import { inferredTypeConstructors } from '@internal/family-sql/psl-build'; +import type { Codec } from '@internal/framework-components/codec'; import { type SqlColumnIRInput, SqlSchemaIR } from '@internal/sql-schema-ir/types'; import { ifDefined } from '@internal/utils/defined'; import { InternalError } from '@internal/utils/internal-error'; import { describe, expect, it } from 'vitest'; +import { pgTimestamp } from '../../../src/core/data-types'; import { inferredColumnDefaults } from '../../../src/core/psl-infer/infer-default-codec'; +import { readDefaultWithCodec } from '../../../src/core/psl-infer/infer-read-defaults'; import { inferBuildContext, printPslFromFlat } from '../fixtures'; /** The backtick fencing a tagged literal, as an escape so no quoted string in this file holds one. */ @@ -150,12 +153,61 @@ describe('printPsl writes each default as the literal the column data type takes }, ); - it.each([ - ['infinity', 'timestamp', "'infinity'::timestamp without time zone"], - ['-infinity', 'timestamp', "'-infinity'::timestamp without time zone"], - ['infinity', 'timestamptz', "'infinity'::timestamp with time zone"], - ['-infinity', 'date', "'-infinity'::date"], - ])( + it('prints a default with digits below a millisecond, which a Date cannot hold, as its raw expression', async () => { + const rawDefault = "'2024-01-01 01:00:00.123456+00'::timestamp(6) with time zone"; + expect(await printedDefaults([introspected('stamp', 'timestamptz(6)', rawDefault)])).toEqual({ + stamp: `@default(sql\`${rawDefault}\`)`, + }); + }); + + it('prints a list default with an element a Date cannot hold as its raw expression, and one it holds as a literal list', async () => { + const micro = + "ARRAY['2024-01-01 01:00:00.123+00'::timestamp with time zone, '2024-01-01 01:00:00.123456+00'::timestamp with time zone]"; + const milli = + "ARRAY['2024-01-01 01:00:00.123+00'::timestamp with time zone, '2024-01-01 01:00:00.5+00'::timestamp with time zone]"; + expect( + await printedDefaults([ + introspected('micro', 'timestamptz(6)', micro, { many: true }), + introspected('milli', 'timestamptz(6)', milli, { many: true }), + ]), + ).toEqual({ + micro: `@default(sql\`${micro}\`)`, + milli: + '@default([timestamptz`2024-01-01T01:00:00.123Z`, timestamptz`2024-01-01T01:00:00.5Z`])', + }); + }); + + it('prints the raw expression when the codec refuses the value its data type reads from the body', async () => { + const codec = { + id: 'test/strict-timestamp@1', + dataType: pgTimestamp, + fromWire: async () => new Date('2024-01-01T00:00:00Z'), + toDataTypeValue: () => pgTimestamp.valueFrom('2024-01-01T00:00:00', {}), + fromDataTypeValue: () => { + throw new Error('the application value cannot hold this instant exactly'); + }, + toWire: async () => '', + } as unknown as Codec; + const raw = "'2024-01-01 00:00:00'::timestamp without time zone"; + expect(await readDefaultWithCodec(raw, false, codec)).toEqual({ + kind: 'function', + expression: raw, + }); + }); + + it('prints a timestamp default written with an offset as the instant, when a Date holds it', async () => { + expect( + await printedDefaults([ + introspected( + 'stamp', + 'timestamptz(6)', + "'2024-01-01 03:00:00.5+02'::timestamp with time zone", + ), + ]), + ).toEqual({ stamp: '@default(timestamptz`2024-01-01T01:00:00.5Z`)' }); + }); + + it.each([['-infinity', 'date', "'-infinity'::date"]])( 'prints the %s sentinel on a %s column with the column type tag', async (sentinel, typeText, rawDefault) => { expect(await printedDefaults([introspected('stamp', typeText, rawDefault)])).toEqual({ diff --git a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.defaults-and-types.test.ts b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.defaults-and-types.test.ts index 1dc6d14ea43d..98cb5f5c9be4 100644 --- a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.defaults-and-types.test.ts +++ b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.defaults-and-types.test.ts @@ -53,11 +53,11 @@ describe('printPsl', () => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. model Post { - id Int @id @default(autoincrement()) - title String @default("Untitled") - isPublished Boolean @default(false) @map("is_published") - viewCount Int @default(0) @map("view_count") - createdAt TimestamptzString @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + title String @default("Untitled") + isPublished Boolean @default(false) @map("is_published") + viewCount Int @default(0) @map("view_count") + createdAt DateTime(precision: 6) @default(now()) @map("created_at") @@map("post") } @@ -374,9 +374,9 @@ describe('printPsl', () => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. model Schedule { - id Uuid @id - bookedOn DateString @map("booked_on") - slot TimeString(3) + id Uuid @id + bookedOn Date @map("booked_on") + slot Time(precision: 3) rating SmallInt payload Json @@ -425,10 +425,10 @@ describe('printPsl', () => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. model Data { - id Int @id - computed String @default(sql\`my_custom_func()\`) - payload Jsonb @default(json\`{}\`) - touchedAt TimestamptzString @default(sql\`clock_timestamp()\`) @map("touched_at") + id Int @id + computed String @default(sql\`my_custom_func()\`) + payload Jsonb @default(json\`{}\`) + touchedAt DateTime(precision: 6) @default(sql\`clock_timestamp()\`) @map("touched_at") @@map("data") } diff --git a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.enums.test.ts b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.enums.test.ts index 50d5884329ea..8cc3bad14539 100644 --- a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.enums.test.ts +++ b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.enums.test.ts @@ -36,10 +36,10 @@ function enumInfoOf(definitions: Record): EnumInfo { }; } -function printWithEnums( +async function printWithEnums( tables: Record, definitions: Record, -): string { +): Promise { const enumInfo = enumInfoOf(definitions); const enums = fixtureEnums('public', Object.keys(definitions)); const schemaIR = new SqlSchemaIR({ @@ -52,7 +52,7 @@ function printWithEnums( }); const options: PostgresPslInferOptions = { columnDefaults: inferredColumnDefaults(inferBuildContext), - columnTypes: inferredColumnTypesOf(schemaIR.tables, enums), + columnTypes: await inferredColumnTypesOf(schemaIR.tables, enums), defaultMapping: createPostgresDefaultMapping(), defaultOf: noInferredDefaults, enumInfo, @@ -79,7 +79,7 @@ const sessionsTable: SqlTableIRInput = { describe('native_enum block emission', () => { it('emits a block with @@map and declaration-ordered members (the auth.aal_level shape)', async () => { - const output = printWithEnums( + const output = await printWithEnums( { sessions: sessionsTable }, { aal_level: ['aal1', 'aal2', 'aal3'] }, ); @@ -90,14 +90,14 @@ describe('native_enum block emission', () => { }); it('omits @@map when the type name already is the PSL name', async () => { - const output = printWithEnums({}, { Status: ['draft', 'done'] }); + const output = await printWithEnums({}, { Status: ['draft', 'done'] }); expect(output).toContain('native_enum Status {\n draft = "draft"\n done = "done"\n}'); expect(output).not.toContain('@@map'); }); it('emits a block for an enum type no column references', async () => { - const output = printWithEnums( + const output = await printWithEnums( { user: { name: 'user', @@ -116,7 +116,7 @@ describe('native_enum block emission', () => { }); it('preserves declaration order, not alphabetical order', async () => { - const output = printWithEnums({}, { priority: ['low', 'high', 'medium'] }); + const output = await printWithEnums({}, { priority: ['low', 'high', 'medium'] }); const lowAt = output.indexOf('low = "low"'); const highAt = output.indexOf('high = "high"'); @@ -129,7 +129,10 @@ describe('native_enum block emission', () => { describe('member-name sanitization with explicit values', () => { it('sanitizes non-identifier values while the string value carries the truth', async () => { - const output = printWithEnums({}, { ticket_priority: ['low', 'high-priority', '2nd', 'enum'] }); + const output = await printWithEnums( + {}, + { ticket_priority: ['low', 'high-priority', '2nd', 'enum'] }, + ); expect(output).toContain('low = "low"'); expect(output).toContain('highPriority = "high-priority"'); @@ -138,14 +141,14 @@ describe('member-name sanitization with explicit values', () => { }); it('deduplicates sanitized member names that collide', async () => { - const output = printWithEnums({}, { weird: ['a-b', 'a b'] }); + const output = await printWithEnums({}, { weird: ['a-b', 'a b'] }); expect(output).toContain('aB = "a-b"'); expect(output).toContain('aB2 = "a b"'); }); it('escapes quotes and backslashes in member values', async () => { - const output = printWithEnums({}, { quoted: ['say "hi"', 'back\\slash'] }); + const output = await printWithEnums({}, { quoted: ['say "hi"', 'back\\slash'] }); expect(output).toContain('= "say \\"hi\\""'); expect(output).toContain('= "back\\\\slash"'); @@ -154,7 +157,7 @@ describe('member-name sanitization with explicit values', () => { describe('name collision against a model of the same name', () => { it('renames the enum away from the model and keeps @@map carrying the type name', async () => { - const output = printWithEnums( + const output = await printWithEnums( { status: { name: 'status', @@ -180,7 +183,7 @@ describe('name collision against a model of the same name', () => { describe('pg.enum(Name) column emission', () => { it('emits the call syntax with optionality composing like any other field type', async () => { - const output = printWithEnums( + const output = await printWithEnums( { sessions: { ...sessionsTable, @@ -199,7 +202,7 @@ describe('pg.enum(Name) column emission', () => { }); it('emits the list form for an enum array column', async () => { - const output = printWithEnums( + const output = await printWithEnums( { user: { name: 'user', @@ -220,7 +223,7 @@ describe('pg.enum(Name) column emission', () => { }); it('types an enum column by its enum, not through a data type', async () => { - const output = printWithEnums( + const output = await printWithEnums( { sessions: sessionsTable }, { aal_level: ['aal1', 'aal2', 'aal3'] }, ); @@ -241,7 +244,7 @@ describe('emitted PSL parses', () => { } it('the @@map + sanitized-member shape parses with zero diagnostics', async () => { - const output = printWithEnums( + const output = await printWithEnums( { sessions: sessionsTable }, { aal_level: ['aal1', 'aal2', 'aal3'], ticket_priority: ['low', 'high-priority', '2nd'] }, ); @@ -250,7 +253,7 @@ describe('emitted PSL parses', () => { }); it('the model-collision + array shape parses with zero diagnostics', async () => { - const output = printWithEnums( + const output = await printWithEnums( { status: { name: 'status', diff --git a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.literal-defaults.test.ts b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.literal-defaults.test.ts index 373d0005253a..6a6c9a420388 100644 --- a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.literal-defaults.test.ts +++ b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.literal-defaults.test.ts @@ -110,11 +110,11 @@ describe('printPsl literal defaults', () => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. model RawDefaults { - id Int @id - stamp TimestampString(3) @default(timestamp\`2024-01-01T00:00:00\`) - day DateString @default(date\`2024-01-01\`) - jsonNull Jsonb? @default(json\`null\`) - textNull VarChar(32)? @default(sql\`NULL::character varying\`) + id Int @id + stamp DateTime(storage: timestamp) @default(timestamp\`2024-01-01T00:00:00\`) + day Date @default(date\`2024-01-01\`) + jsonNull Jsonb? @default(json\`null\`) + textNull VarChar(32)? @default(sql\`NULL::character varying\`) @@map("raw_defaults") } @@ -158,12 +158,12 @@ describe('printPsl literal defaults', () => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. model SpecialValueDefaults { - id Int @id - floatNaN Float @default(NaN) - floatNegInf Float @default(-Infinity) - realNaN Real @default(NaN) - decimalNaN Numeric @default(NaN) - timeWithZone Timetz @default(timetz\`12:34:56Z\`) + id Int @id + floatNaN Float @default(NaN) + floatNegInf Float @default(-Infinity) + realNaN Real @default(NaN) + decimalNaN Numeric @default(NaN) + timeWithZone Time(storage: timetz) @default(timetz\`12:34:56Z\`) @@map("special_value_defaults") } @@ -254,8 +254,8 @@ describe('printPsl literal defaults', () => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. model RawListDefaults { - id Int @id - timestamps TimestampString(3)?[]? @default([timestamp\`2024-01-01T00:00:00\`]) + id Int @id + timestamps DateTime(storage: timestamp)?[]? @default([timestamp\`2024-01-01T00:00:00\`]) @@map("raw_list_defaults") } diff --git a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.top-level-blocks.test.ts b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.top-level-blocks.test.ts index 9530d327399c..967010096d74 100644 --- a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.top-level-blocks.test.ts +++ b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.top-level-blocks.test.ts @@ -43,7 +43,7 @@ import { import { sqlContextInput } from '@internal/sql-contract-psl/test'; import { SqlSchemaIR } from '@internal/sql-schema-ir/types'; import { postgresDataTypes } from '@internal/target-postgres/data-types'; -import { assert, describe, expect, it } from 'vitest'; +import { assert, beforeAll, describe, expect, it } from 'vitest'; import { postgresAuthoringEntityTypes, postgresAuthoringPslBlockDescriptors, @@ -312,12 +312,15 @@ describe('buildPslDocumentAst and the top-level bucket', () => { }, }); - const printerOptions: PostgresPslInferOptions = { - columnDefaults: inferredColumnDefaults(inferBuildContext), - columnTypes: inferredColumnTypesOf(schemaIR.tables), - defaultMapping: createPostgresDefaultMapping(), - defaultOf: noInferredDefaults, - }; + let printerOptions: PostgresPslInferOptions; + beforeAll(async () => { + printerOptions = { + columnDefaults: inferredColumnDefaults(inferBuildContext), + columnTypes: await inferredColumnTypesOf(schemaIR.tables), + defaultMapping: createPostgresDefaultMapping(), + defaultOf: noInferredDefaults, + }; + }); const foreignKeyExtras = { extraRelationsByTable: new Map(), diff --git a/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.type-reading-default.test.ts b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.type-reading-default.test.ts new file mode 100644 index 000000000000..a57d281c2ec0 --- /dev/null +++ b/packages/3-targets/postgres/1-target/test/psl-infer/inferred-psl/inferred-psl.type-reading-default.test.ts @@ -0,0 +1,60 @@ +import { SqlSchemaIR } from '@internal/sql-schema-ir/types'; +import { describe, expect, it } from 'vitest'; +import { printPslFromFlat } from '../fixtures'; + +async function printedStampField(typeText: string, rawDefault: string): Promise { + const output = await printPslFromFlat( + new SqlSchemaIR({ + tables: { + account: { + name: 'account', + columns: { + id: { name: 'id', typeText: 'int4', nullable: false }, + stamp: { name: 'stamp', typeText, nullable: false, default: rawDefault }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }), + ); + const line = output.split('\n').find((candidate) => /^\s+stamp\s/.test(candidate)); + return (line ?? '').trim().replace(/\s+/g, ' '); +} + +describe('contract infer picks a type whose codec reads the column default as a row', () => { + it.each([ + [ + 'timestamp(3)', + "'infinity'::timestamp without time zone", + 'stamp DateTime(as: string, storage: timestamp, precision: 3) @default(timestamp`infinity`)', + ], + [ + 'timestamp(3)', + "'-infinity'::timestamp without time zone", + 'stamp DateTime(as: string, storage: timestamp, precision: 3) @default(timestamp`-infinity`)', + ], + [ + 'timestamptz', + "'-infinity'::timestamp with time zone", + 'stamp DateTime(as: string) @default(timestamptz`-infinity`)', + ], + ])('writes a %s column with default %s as a string', async (typeText, rawDefault, expected) => { + expect(await printedStampField(typeText, rawDefault)).toBe(expected); + }); + + it('keeps a Date for a default with digits below a millisecond, which a Date reads, and writes the default as SQL', async () => { + const rawDefault = "'2024-01-01 01:00:00.123456+00'::timestamp(6) with time zone"; + expect(await printedStampField('timestamptz(6)', rawDefault)).toBe( + `stamp DateTime(precision: 6) @default(sql\`${rawDefault}\`)`, + ); + }); + + it('keeps the string date type for an infinite date default, which it reads', async () => { + expect(await printedStampField('date', "'infinity'::date")).toBe( + 'stamp Date @default(date`infinity`)', + ); + }); +}); diff --git a/packages/3-targets/postgres/1-target/test/psl-print/convenience-types.test.ts b/packages/3-targets/postgres/1-target/test/psl-print/convenience-types.test.ts new file mode 100644 index 000000000000..0d16520ca914 --- /dev/null +++ b/packages/3-targets/postgres/1-target/test/psl-print/convenience-types.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { buildModels, type ColumnShape, fieldText, INT_COLUMN, table } from './print-support'; + +function printedType(column: ColumnShape): string | undefined { + const [model] = buildModels({ + models: { + Event: { table: 'Event', fields: { id: { column: 'id' }, at: { column: 'at' } } }, + }, + tables: { + Event: table({ columns: { id: INT_COLUMN, at: column }, primaryKey: { columns: ['id'] } }), + }, + }); + const field = model?.fields[1]; + return field === undefined ? undefined : fieldText(field).replace(/^at /, ''); +} + +const column = (dataType: string, codecId: string, precision?: number): ColumnShape => ({ + dataType, + codecId, + nullable: false, + ...(precision === undefined ? {} : { typeParams: { precision } }), +}); + +describe('contract print writes a date or time column as the convenience type it becomes', () => { + it.each([ + [column('pg/timestamptz', 'pg/timestamptz-date@1', 3), 'DateTime'], + [column('pg/timestamptz', 'pg/timestamptz-date@1', 6), 'DateTime(precision: 6)'], + [column('pg/timestamp', 'pg/timestamp-date@1', 3), 'DateTime(storage: timestamp)'], + [column('pg/timestamptz', 'pg/timestamptz-temporal@1'), 'DateTime(as: temporal)'], + [column('pg/timestamptz', 'pg/timestamptz-string@1', 3), 'DateTime(as: string, precision: 3)'], + [ + column('pg/timestamp', 'pg/timestamp-instant@1'), + 'DateTime(as: temporal, storage: timestamp)', + ], + [column('pg/date', 'pg/date-string@1'), 'Date'], + [column('pg/date', 'pg/date-temporal@1'), 'Date(as: temporal)'], + [column('pg/time', 'pg/time-string@1', 0), 'Time(precision: 0)'], + [column('pg/timetz', 'pg/timetz@1'), 'Time(storage: timetz)'], + [column('pg/timetz', 'pg/timetz@1', 3), 'Time(storage: timetz, precision: 3)'], + [column('pg/interval', 'pg/interval-string@1'), 'Duration'], + [column('pg/interval', 'pg/interval@1'), 'Duration(as: parts)'], + ])('%j prints as %s', (shape, expected) => { + expect(printedType(shape)).toBe(expected); + }); + + it('writes the explicit type for a column no convenience call becomes exactly', () => { + expect([ + printedType(column('pg/timestamp', 'pg/timestamp-temporal@1', 3)), + printedType(column('pg/timestamptz', 'pg/timestamptz-date@1')), + ]).toEqual(['TimestampTemporal(3)', 'TimestamptzJsDate']); + }); +}); diff --git a/packages/3-targets/postgres/1-target/test/psl-print/generated-values.test.ts b/packages/3-targets/postgres/1-target/test/psl-print/generated-values.test.ts index 5a3d69c2c60c..2c4241ff07d7 100644 --- a/packages/3-targets/postgres/1-target/test/psl-print/generated-values.test.ts +++ b/packages/3-targets/postgres/1-target/test/psl-print/generated-values.test.ts @@ -124,13 +124,13 @@ describe('generated values', () => { it('writes a now generator on create and on update', () => { const now = generator(postgresNowGeneratorIds[PG_TIMESTAMPTZ_TEMPORAL_CODEC_ID]); expect(withGenerator(timestamptz, { onCreate: now, onUpdate: now })).toBe( - 'value TimestamptzTemporal @onCreate(now()) @onUpdate(now())', + 'value DateTime(as: temporal) @onCreate(now()) @onUpdate(now())', ); }); it("writes a time now generator whose precision is the column's as @onUpdate(now())", () => { expect(withGenerator(time3, { onUpdate: generator('plainTimeNow', { precision: 3 }) })).toBe( - 'value TimeTemporal(3) @onUpdate(now())', + 'value Time(as: temporal, precision: 3) @onUpdate(now())', ); }); @@ -140,7 +140,7 @@ describe('generated values', () => { { ...timestamptz, default: { kind: 'function', expression: 'now()' } }, { onCreate: generator('instantNow') }, ), - ).toBe('value TimestamptzTemporal @default(now()) @onCreate(now())'); + ).toBe('value DateTime(as: temporal) @default(now()) @onCreate(now())'); }); it('writes a generator through the generator function an extension contributes', () => { diff --git a/packages/3-targets/postgres/1-target/test/require-temporal.test.ts b/packages/3-targets/postgres/1-target/test/require-temporal.test.ts index 75ac79fcd273..5d653b46bda5 100644 --- a/packages/3-targets/postgres/1-target/test/require-temporal.test.ts +++ b/packages/3-targets/postgres/1-target/test/require-temporal.test.ts @@ -64,7 +64,7 @@ describe('requireTemporal', () => { code: 'RUNTIME.TEMPORAL_UNAVAILABLE', message: "Mutation default generator 'instantNow' cannot produce a value because this runtime has no global Temporal implementation.", - fix: 'Run on a runtime with Temporal available, install a Temporal polyfill before creating the client, or author the column with `temporal.CreatedAtString` / `temporal.UpdatedAtString` to store ISO 8601 text instead.', + fix: 'Run on a runtime with Temporal available, install a Temporal polyfill before creating the client, or write the column as a convenience type at its default, which needs no Temporal: `DateTime` reads a JavaScript `Date`, and `Date`, `Time` and `Duration` read ISO 8601 strings.', meta: { generatorId: 'instantNow' }, }), ); diff --git a/packages/3-targets/postgres/1-target/test/sql-expression-places.test.ts b/packages/3-targets/postgres/1-target/test/sql-expression-places.test.ts index afac442e47e2..57b2656b606a 100644 --- a/packages/3-targets/postgres/1-target/test/sql-expression-places.test.ts +++ b/packages/3-targets/postgres/1-target/test/sql-expression-places.test.ts @@ -52,7 +52,7 @@ const fieldContext = { ...modelContext, field, typeResolution: undefined, - typeConstructor: undefined, + contributedType: undefined, }; const blockContext = blockSpecContext({ symbols: symbolTable, dataTypes: postgresDataTypeSupport }); diff --git a/packages/3-targets/postgres/1-target/test/temporal-unavailable.test.ts b/packages/3-targets/postgres/1-target/test/temporal-unavailable.test.ts index 4888a971f74f..ac7b8a22c953 100644 --- a/packages/3-targets/postgres/1-target/test/temporal-unavailable.test.ts +++ b/packages/3-targets/postgres/1-target/test/temporal-unavailable.test.ts @@ -102,7 +102,7 @@ describe('Temporal-backed codecs in a runtime without Temporal', () => { expect(decoded).toBe('infinity'); }); - it('fails the instantNow generator with the capability error, naming its *String alternative', async () => { + it('fails the instantNow generator with the capability error, naming the convenience default', async () => { const outcome = await withoutTemporal(async () => { await Promise.resolve(); try { @@ -113,7 +113,9 @@ describe('Temporal-backed codecs in a runtime without Temporal', () => { return { threw: true, code: structured.code, - namesTheStringAlias: (structured.fix ?? '').includes('temporal.UpdatedAtString'), + namesTheConvenienceDefault: (structured.fix ?? '').includes( + '`DateTime` reads a JavaScript `Date`', + ), meta: structured.meta, }; } @@ -122,7 +124,7 @@ describe('Temporal-backed codecs in a runtime without Temporal', () => { expect(outcome).toEqual({ threw: true, code: 'RUNTIME.TEMPORAL_UNAVAILABLE', - namesTheStringAlias: true, + namesTheConvenienceDefault: true, meta: { generatorId: 'instantNow' }, }); }); diff --git a/packages/3-targets/postgres/1-target/test/type-choice-inferred-marks.test.ts b/packages/3-targets/postgres/1-target/test/type-choice-inferred-marks.test.ts new file mode 100644 index 000000000000..da4491c54b53 --- /dev/null +++ b/packages/3-targets/postgres/1-target/test/type-choice-inferred-marks.test.ts @@ -0,0 +1,69 @@ +import { + type AuthoringTypeChoiceDescriptor, + type AuthoringTypeConstructorDescriptor, + isAuthoringTypeChoiceOption, + resolveAuthoringTypeChoice, +} from '@internal/framework-components/authoring'; +import { describe, expect, it } from 'vitest'; +import { createPostgresBuiltinCodecLookup } from '../src/core/codec-registry'; +import { + postgresConvenienceDateTimeTypes, + postgresScalarAndNativeTypeConstructors, +} from '../src/core/type-constructors'; + +const codecLookup = createPostgresBuiltinCodecLookup(); + +const constructors: ReadonlyMap = new Map( + Object.entries(postgresScalarAndNativeTypeConstructors).flatMap(([name, member]) => + member.kind === 'typeConstructor' ? [[name, member]] : [], + ), +); + +function dataTypeOf(constructorName: string): string | undefined { + const codecId = constructors.get(constructorName)?.output.codecId; + return codecId === undefined ? undefined : codecLookup.descriptorFor(codecId)?.dataType; +} + +/** For each `storage` value, the explicit type a call writing only that value becomes. */ +function convenienceDefaults(choice: AuthoringTypeChoiceDescriptor): readonly string[] { + const storage = choice.args.find( + (argument) => isAuthoringTypeChoiceOption(argument) && argument.name === 'storage', + ); + const values = + storage !== undefined && isAuthoringTypeChoiceOption(storage) ? storage.values : []; + return values.map( + (value) => + resolveAuthoringTypeChoice({ + helperPath: 'choice', + descriptor: choice, + args: choice.args.map((argument) => (argument === storage ? value : undefined)), + constructorNamed: (name) => constructors.get(name), + }).type, + ); +} + +const defaults = Object.values(postgresConvenienceDateTimeTypes).flatMap(convenienceDefaults); + +describe('the explicit type contract infer writes for a date or time column', () => { + it.each(defaults)( + 'is %s, the type the convenience default for its data type gives', + (constructorName) => { + const dataType = dataTypeOf(constructorName); + const marked = [...constructors].flatMap(([name, descriptor]) => + descriptor.inferred === true && dataTypeOf(name) === dataType ? [name] : [], + ); + expect({ dataType, marked }).toEqual({ dataType, marked: [constructorName] }); + }, + ); + + it('covers every date and time data type', () => { + expect(defaults.map(dataTypeOf).sort()).toEqual([ + 'pg/date', + 'pg/interval', + 'pg/time', + 'pg/timestamp', + 'pg/timestamptz', + 'pg/timetz', + ]); + }); +}); diff --git a/packages/3-targets/postgres/1-target/test/type-constructors.test.ts b/packages/3-targets/postgres/1-target/test/type-constructors.test.ts index 4d34642beee3..9acf121d5465 100644 --- a/packages/3-targets/postgres/1-target/test/type-constructors.test.ts +++ b/packages/3-targets/postgres/1-target/test/type-constructors.test.ts @@ -3,6 +3,7 @@ import { collectScalarTypeConstructors, findAuthoringTypeConstructorCall, instantiateAuthoringTypeAlias, + instantiateAuthoringTypeCall, instantiateAuthoringTypeConstructor, validateAuthoringTypeParams, } from '@internal/framework-components/authoring'; @@ -111,21 +112,18 @@ describe('the type constructors the target contributes', () => { ), ).toEqual({ CreatedAt: - 'A timestamp the client sets to now when a row is created. Its value is a Temporal.Instant.', - UpdatedAt: - 'A timestamp the client sets to now when a row is created and every time it is updated. Its value is a Temporal.Instant.', - CreatedAtString: - 'A timestamp the client sets to now when a row is created. Its value is a string, the timestamp as Postgres writes it, such as 2024-01-02 03:04:05+00.', - UpdatedAtString: - 'A timestamp the client sets to now when a row is created and every time it is updated. Its value is a string, the timestamp as Postgres writes it, such as 2024-01-02 03:04:05+00.', - CreatedAtJsDate: 'A timestamp the client sets to now when a row is created. Its value is a JavaScript Date.', - UpdatedAtJsDate: + UpdatedAt: 'A timestamp the client sets to now when a row is created and every time it is updated. Its value is a JavaScript Date.', }); }); - it('contribute the temporal type aliases, one pair per timestamptz codec', () => { + it('contribute the temporal type aliases on the type bare DateTime becomes', () => { + const dateTime = instantiateAuthoringTypeCall( + postgresScalarAndNativeTypeConstructors, + 'DateTime', + [], + ); expect( Object.fromEntries( Object.entries(postgresAuthoringTypes.temporal).map(([name, { kind, output }]) => [ @@ -134,40 +132,10 @@ describe('the type constructors the target contributes', () => { ]), ), ).toEqual({ - CreatedAt: { - kind: 'typeAlias', - type: { codecId: 'pg/timestamptz-temporal@1' }, - onCreate: now, - }, - UpdatedAt: { - kind: 'typeAlias', - type: { codecId: 'pg/timestamptz-temporal@1' }, - onCreate: now, - onUpdate: now, - }, - CreatedAtString: { - kind: 'typeAlias', - type: { codecId: 'pg/timestamptz-string@1' }, - onCreate: now, - }, - UpdatedAtString: { - kind: 'typeAlias', - type: { codecId: 'pg/timestamptz-string@1' }, - onCreate: now, - onUpdate: now, - }, - CreatedAtJsDate: { - kind: 'typeAlias', - type: { codecId: 'pg/timestamptz-date@1' }, - onCreate: now, - }, - UpdatedAtJsDate: { - kind: 'typeAlias', - type: { codecId: 'pg/timestamptz-date@1' }, - onCreate: now, - onUpdate: now, - }, + CreatedAt: { kind: 'typeAlias', type: dateTime, onCreate: now }, + UpdatedAt: { kind: 'typeAlias', type: dateTime, onCreate: now, onUpdate: now }, }); + expect(dateTime).toEqual({ codecId: 'pg/timestamptz-date@1', typeParams: { precision: 3 } }); }); }); @@ -198,11 +166,11 @@ const INFERRED = [ 'DateString', 'TimeString', 'Timetz', - 'TimestampString', - 'TimestamptzString', + 'TimestampJsDate', + 'TimestamptzJsDate', 'Bit', 'VarBit', - 'IntervalParts', + 'IntervalString', 'pg.enum', ]; @@ -256,7 +224,6 @@ describe('postgresScalarAuthoringTypes', () => { ['BigInt', 'pg/int8@1', 'int8'], ['Float', 'pg/float8@1', 'float8'], ['Decimal', 'pg/numeric@1', 'numeric'], - ['DateTime', 'pg/timestamptz-temporal@1', 'timestamptz'], ['Json', 'pg/json@1', 'json'], ['Jsonb', 'pg/jsonb@1', 'jsonb'], ['Bytes', 'pg/bytea@1', 'bytea'], @@ -297,14 +264,12 @@ describe('postgresNativeAuthoringTypes', () => { Numeric: { codecId: 'pg/numeric@1' }, TimestampTemporal: { codecId: 'pg/timestamp-temporal@1' }, TimestamptzTemporal: { codecId: 'pg/timestamptz-temporal@1' }, - Time: { codecId: 'pg/time-temporal@1' }, TimeTemporal: { codecId: 'pg/time-temporal@1' }, Timetz: { codecId: 'pg/timetz@1' }, Uuid: { codecId: 'pg/uuid@1' }, Inet: { codecId: 'pg/inet@1' }, SmallInt: { codecId: 'pg/int2@1' }, Real: { codecId: 'pg/float4@1' }, - Date: { codecId: 'pg/date-temporal@1' }, DateTemporal: { codecId: 'pg/date-temporal@1' }, DateString: { codecId: 'pg/date-string@1' }, TimestampString: { codecId: 'pg/timestamp-string@1' }, @@ -385,7 +350,7 @@ describe('postgresNativeAuthoringTypes', () => { ['TimestampTemporal', [-1]], ['TimestampTemporal', [7]], ['TimestamptzTemporal', [7]], - ['Time', [7]], + ['TimeTemporal', [7]], ['Timetz', [7]], ['TimestamptzJsDate', [7]], ['Bit', [0]], @@ -433,13 +398,19 @@ describe('postgresNativeAuthoringTypes', () => { } }); - it.each([ - ['DateTemporal', 'Date'], - ['TimeTemporal', 'Time'], - ] as const)('%s is the same type as %s', (name, sameAs) => { - expect(postgresNativeAuthoringTypes[name]).toEqual({ - ...postgresNativeAuthoringTypes[sameAs], - documentation: expect.stringMatching(/\S/), + it('gives DateTime, Date, Time and Duration to the convenience types, not to explicit types', () => { + expect( + Object.fromEntries( + ['DateTime', 'Date', 'Time', 'Duration'].map((name) => [ + name, + (everyPostgresConstructor as Record)[name]?.kind, + ]), + ), + ).toEqual({ + DateTime: 'typeChoice', + Date: 'typeChoice', + Time: 'typeChoice', + Duration: 'typeChoice', }); }); @@ -448,6 +419,7 @@ describe('postgresNativeAuthoringTypes', () => { expect(everyPostgresConstructor).not.toHaveProperty('TimestamptzDate'); expect(postgresNativeAuthoringTypes).toHaveProperty('TimestamptzJsDate', { kind: 'typeConstructor', + inferred: true, documentation: 'An instant stored as PostgreSQL timestamptz and represented as a JavaScript Date.', args: [{ kind: 'value', dataType: 'pg/int4', name: 'precision', optional: true }], @@ -456,7 +428,6 @@ describe('postgresNativeAuthoringTypes', () => { typeParams: { precision: { kind: 'arg', index: 0 } }, }, }); - expect(postgresScalarAuthoringTypes.DateTime.output.codecId).toBe('pg/timestamptz-temporal@1'); expect(postgresNativeAuthoringTypes.TimestamptzTemporal.output.codecId).toBe( 'pg/timestamptz-temporal@1', ); @@ -467,7 +438,6 @@ describe('precision is a value of pg/int4, bounded by the data type', () => { it.each([ 'TimestampTemporal', 'TimestamptzTemporal', - 'Time', 'TimeTemporal', 'Timetz', 'TimestampString', diff --git a/packages/3-targets/postgres/2-adapter/src/core/control-adapter.ts b/packages/3-targets/postgres/2-adapter/src/core/control-adapter.ts index 925caca41f17..bea8e729a018 100644 --- a/packages/3-targets/postgres/2-adapter/src/core/control-adapter.ts +++ b/packages/3-targets/postgres/2-adapter/src/core/control-adapter.ts @@ -104,7 +104,7 @@ import { postgresError } from '@internal/target-postgres/errors'; import { MARKER_LOCK_KEY, MARKER_LOCK_SQL } from '@internal/target-postgres/marker-lock'; import { introspectedTypeText } from '@internal/target-postgres/native-type-normalizer'; import { POLICY_COMMAND_SQL, policyRoleList } from '@internal/target-postgres/rls-canonicalize'; -import { escapeLiteral, quoteIdentifier } from '@internal/target-postgres/sql-utils'; +import { escapeLiteral, inlineLiteral, quoteIdentifier } from '@internal/target-postgres/sql-utils'; import { PostgresDatabaseSchemaNode, PostgresNamespaceSchemaNode, @@ -1720,20 +1720,6 @@ function groupBy(items: readonly T[], key: K): Map( return result; } -/** - * A value as DDL writes it: `ARRAY[...]` of each element's literal, cast to the list type, for a list written to a list type, and otherwise the value's literal, cast to the column type where PostgreSQL would read it as another type. - */ +/** A value as DDL writes it, with a `Date` written as its ISO 8601 text, which an invalid `Date` has none of. */ function pgInlineLiteral(wire: unknown, typeText: string, where: LiteralDefaultColumn): string { - if (Array.isArray(wire) && typeText.endsWith('[]')) { - if (wire.length === 0) return "'{}'"; - return `ARRAY[${wire.map((element) => pgLiteralText(element, where)).join(', ')}]::${typeText}`; - } - const text = pgLiteralText(wire, where); - return pgLiteralNeedsCast(wire, typeText) ? `${text}::${typeText}` : text; -} - -function pgLiteralNeedsCast(wire: unknown, typeText: string): boolean { - if (typeof wire === 'number') return !Number.isFinite(wire); - if (typeof wire === 'string' || wire instanceof Date) return !pgIsTextLikeNativeType(typeText); - return typeof wire === 'object' && wire !== null; -} - -/** A value's literal without a cast: SQL NULL, a bare number or boolean, or quoted text. */ -function pgLiteralText(wire: unknown, where: LiteralDefaultColumn): string { - if (wire === null) return 'NULL'; - if (typeof wire === 'boolean') return wire ? 'true' : 'false'; - if (typeof wire === 'number') return Number.isFinite(wire) ? String(wire) : `'${String(wire)}'`; - if (typeof wire === 'bigint') return String(wire); - if (wire instanceof Date) { - if (Number.isNaN(wire.getTime())) { + const dateAsText = (value: unknown) => { + if (!(value instanceof Date)) return value; + if (Number.isNaN(value.getTime())) { throw adapterError( 'CONTRACT.DEFAULT_INVALID', `Column "${where.table}"."${where.column}" has an invalid Date default`, { meta: { table: where.table, column: where.column, reason: 'invalid-date-default' } }, ); } - return `'${escapeLiteral(wire.toISOString())}'`; - } - if (typeof wire === 'string') return `'${escapeLiteral(wire)}'`; - if (wire instanceof Uint8Array) { - const hex = Array.from(wire) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - return `'\\x${hex}'`; - } - if (typeof wire === 'object') return `'${escapeLiteral(JSON.stringify(wire))}'`; - throw adapterError( - 'CONTRACT.PACK_CONTRIBUTION_INVALID', - `pgRenderDdlExecuteRequest: unexpected wire type "${typeof wire}"`, - { meta: { wireType: typeof wire } }, + return value.toISOString(); + }; + return inlineLiteral( + Array.isArray(wire) && typeText.endsWith('[]') ? wire.map(dateAsText) : dateAsText(wire), + typeText, ); } diff --git a/packages/3-targets/postgres/2-adapter/test/migrations/add-date-time-column-postcheck.integration.test.ts b/packages/3-targets/postgres/2-adapter/test/migrations/add-date-time-column-postcheck.integration.test.ts new file mode 100644 index 000000000000..09485eca05b2 --- /dev/null +++ b/packages/3-targets/postgres/2-adapter/test/migrations/add-date-time-column-postcheck.integration.test.ts @@ -0,0 +1,72 @@ +/** + * Adding a date or time column with no declared precision passes its postcheck: the check compares `format_type` with the text PostgreSQL prints for the type as written, though the type's normal form counts the omitted precision as 6. + */ +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { col } from '@internal/sql-relational-core/contract-free'; +import { postgresDataTypes } from '@internal/target-postgres/data-types'; +import { AddColumnCall } from '@internal/target-postgres/op-factory-call'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + controlAdapter, + createDriver, + createTestDatabase, + type PostgresControlDriver, + resetDatabase, + testTimeout, +} from './fixtures/runner-fixtures'; + +describe('the ADD COLUMN postcheck of a date or time column', { concurrent: false }, () => { + let database: Awaited>; + let driver: PostgresControlDriver | undefined; + + beforeAll(async () => { + database = await createTestDatabase(); + }, testTimeout); + + afterAll(async () => { + if (database) await database.close(); + }, testTimeout); + + beforeEach(async () => { + driver = await createDriver(database.connectionString); + await resetDatabase(driver); + }, testTimeout); + + afterEach(async () => { + if (driver) { + await driver.close(); + driver = undefined; + } + }, testTimeout); + + async function addColumn(type: string) { + await driver!.query('DROP TABLE IF EXISTS "t"'); + await driver!.query('CREATE TABLE "t" (id int4 PRIMARY KEY)'); + const op = await new AddColumnCall( + UNBOUND_NAMESPACE_ID, + 't', + col('c', type), + postgresDataTypes, + ).toOp(controlAdapter); + for (const step of op.execute) await driver!.query(step.sql, step.params ?? []); + const results = await Promise.all( + op.postcheck.map((step) => driver!.query<{ result: boolean }>(step.sql, step.params ?? [])), + ); + return results.every((result) => result.rows[0]?.result === true); + } + + it('holds with and without a declared precision', { timeout: testTimeout * 2 }, async () => { + const types = [ + 'timestamptz', + 'timestamptz(6)', + 'timestamp', + 'time', + 'timetz', + 'interval', + 'timestamptz(3)', + ]; + const results: Record = {}; + for (const type of types) results[type] = await addColumn(type); + expect(results).toEqual(Object.fromEntries(types.map((type) => [type, true]))); + }); +}); diff --git a/packages/3-targets/postgres/9-facade/test/contract-builder/default-encoding.test.ts b/packages/3-targets/postgres/9-facade/test/contract-builder/default-encoding.test.ts index afeb4479cbf4..6bbe6afaac70 100644 --- a/packages/3-targets/postgres/9-facade/test/contract-builder/default-encoding.test.ts +++ b/packages/3-targets/postgres/9-facade/test/contract-builder/default-encoding.test.ts @@ -30,11 +30,11 @@ function fromUntypedCaller(value: unknown): never { } describe('postgres defineContract encodes literal defaults through the column codec', () => { - describe('field.column(type.DateTime())', () => { + describe('field.column(type.TimestamptzTemporal())', () => { it('refuses a string, naming the model and field and carrying the codec message', () => { expect(() => storedDefault(({ field, type }) => - field.column(type.DateTime()).default(fromUntypedCaller('2024-01-01')), + field.column(type.TimestamptzTemporal()).default(fromUntypedCaller('2024-01-01')), ), ).toThrow( expect.objectContaining({ @@ -55,7 +55,9 @@ describe('postgres defineContract encodes literal defaults through the column co it('stores the text the codec produces for a Temporal.Instant', () => { expect( storedDefault(({ field, type }) => - field.column(type.DateTime()).default(Temporal.Instant.from('2024-01-01T00:00:00Z')), + field + .column(type.TimestamptzTemporal()) + .default(Temporal.Instant.from('2024-01-01T00:00:00Z')), ), ).toEqual({ kind: 'literal', value: '2024-01-01T00:00:00Z' }); }); @@ -226,7 +228,7 @@ describe('postgres defineContract encodes literal defaults through the column co fields: { id: field.column(type.Char(36)).id().onCreate(gen.uuid()), at: field - .column(type.DateTime()) + .column(type.TimestamptzTemporal()) .default(Temporal.Instant.from('2024-01-01T00:00:00Z')), }, }), diff --git a/packages/3-targets/postgres/9-facade/test/contract-builder/default-input-type.test-d.ts b/packages/3-targets/postgres/9-facade/test/contract-builder/default-input-type.test-d.ts index 4de81d521246..992366300efc 100644 --- a/packages/3-targets/postgres/9-facade/test/contract-builder/default-input-type.test-d.ts +++ b/packages/3-targets/postgres/9-facade/test/contract-builder/default-input-type.test-d.ts @@ -38,14 +38,14 @@ test('.default() takes the input type of the field codec', () => { fields: { id: field.column(type.Char(36)).id().onCreate(gen.uuid()), instant: field - .column(type.DateTime()) + .column(type.TimestamptzTemporal()) .default(Temporal.Instant.from('2024-01-01T00:00:00Z')), big: field.column(type.BigInt()).default(1n), bytes: field.column(type.Bytes()).default(new Uint8Array([120])), text: field.column(type.String()).default('x'), jsDate: field.column(type.TimestamptzJsDate()).default(new Date()), isoString: field.column(type.TimestamptzString()).default('2024-01-01T00:00:00Z'), - generatedNow: field.column(type.DateTime()).default(now()), + generatedNow: field.column(type.TimestamptzTemporal()).default(now()), rawSql: field.column(type.String()).default(sql`'x'`), optionalThenDefault: field.column(type.BigInt()).optional().default(1n), list: field.column(type.BigInt()).many().default([1n, 2n]), @@ -72,7 +72,7 @@ test('.default() takes the input type of the field codec', () => { fields: { id: field.column(type.Char(36)).id().onCreate(gen.uuid()), // @ts-expect-error pg/timestamptz-temporal@1 takes a Temporal.Instant, not a string - instant: field.column(type.DateTime()).default('2024-01-01'), + instant: field.column(type.TimestamptzTemporal()).default('2024-01-01'), // @ts-expect-error pg/text@1 takes a string, not a number text: field.column(type.String()).default(1), // @ts-expect-error pg/int8@1 takes a bigint, not a number diff --git a/packages/3-targets/postgres/9-facade/test/contract-builder/define-contract.test-d.ts b/packages/3-targets/postgres/9-facade/test/contract-builder/define-contract.test-d.ts index d8c88d3813e1..1d50bc4ee2cb 100644 --- a/packages/3-targets/postgres/9-facade/test/contract-builder/define-contract.test-d.ts +++ b/packages/3-targets/postgres/9-facade/test/contract-builder/define-contract.test-d.ts @@ -17,8 +17,10 @@ test('limits Date authoring to the JsDate types', () => { expectTypeOf(t.temporal).not.toHaveProperty('CreatedAtDate'); expectTypeOf(t.temporal).not.toHaveProperty('UpdatedAtDate'); expectTypeOf(t).toHaveProperty('TimestamptzJsDate'); - expectTypeOf(t.temporal).toHaveProperty('CreatedAtJsDate'); - expectTypeOf(t.temporal).toHaveProperty('UpdatedAtJsDate'); + expectTypeOf(t.temporal).not.toHaveProperty('CreatedAtJsDate'); + expectTypeOf(t.temporal).not.toHaveProperty('UpdatedAtJsDate'); + expectTypeOf(t.temporal).toHaveProperty('CreatedAt'); + expectTypeOf(t.temporal).toHaveProperty('UpdatedAt'); return {}; }); }); diff --git a/packages/3-targets/postgres/9-facade/test/contract-builder/define-contract.test.ts b/packages/3-targets/postgres/9-facade/test/contract-builder/define-contract.test.ts index 76b7a1e24908..6a528c631816 100644 --- a/packages/3-targets/postgres/9-facade/test/contract-builder/define-contract.test.ts +++ b/packages/3-targets/postgres/9-facade/test/contract-builder/define-contract.test.ts @@ -36,8 +36,8 @@ describe('postgres defineContract wrap', () => { id: f.column(type.Char(36)).id().onCreate(gen.uuid()), at: f.column(type.TimestamptzJsDate()), precise: f.column(type.TimestamptzJsDate(3)).onCreate(gen.now()).onUpdate(gen.now()), - created: f.column(type.temporal.CreatedAtJsDate()), - updated: f.column(type.temporal.UpdatedAtJsDate()), + created: f.column(type.temporal.CreatedAt()), + updated: f.column(type.temporal.UpdatedAt()), }, }), }, @@ -54,8 +54,9 @@ describe('postgres defineContract wrap', () => { created: { codecId: 'pg/timestamptz-date@1', dataType: 'pg/timestamptz', + typeParams: { precision: 3 }, }, - updated: { codecId: 'pg/timestamptz-date@1' }, + updated: { codecId: 'pg/timestamptz-date@1', typeParams: { precision: 3 } }, }, }, }, @@ -68,7 +69,7 @@ describe('postgres defineContract wrap', () => { ); expect(result.execution?.mutations?.defaults).toContainEqual({ ref: { namespace: 'public', entry: 'Event', field: 'created' }, - onCreate: { kind: 'generator', id: 'timestampNow' }, + onCreate: { kind: 'generator', id: 'timestampNow', params: { precision: 3 } }, }); }); diff --git a/packages/3-targets/postgres/9-facade/test/contract-builder/removed-field-presets.test-d.ts b/packages/3-targets/postgres/9-facade/test/contract-builder/removed-field-presets.test-d.ts index 4fbd7310f503..9d86743e9cd9 100644 --- a/packages/3-targets/postgres/9-facade/test/contract-builder/removed-field-presets.test-d.ts +++ b/packages/3-targets/postgres/9-facade/test/contract-builder/removed-field-presets.test-d.ts @@ -28,9 +28,9 @@ const contract = defineContract({}, ({ field, model, type, gen }) => ({ fields: { id: field.column(type.Char(36)).id().onCreate(gen.uuid()), code: field.column(type.Char(12)), - seenAt: field.column(type.Time(3)).onUpdate(gen.now()), - createdAt: field.column(type.temporal.CreatedAt()), - updatedAt: field.column(type.temporal.UpdatedAt()), + seenAt: field.column(type.TimeTemporal(3)).onUpdate(gen.now()), + createdAt: field.column(type.TimestamptzTemporal()).onCreate(gen.now()), + updatedAt: field.column(type.TimestamptzTemporal()).onCreate(gen.now()).onUpdate(gen.now()), payload: field.column(type.Jsonb()).optional(), slug: field.column(type.Char(16)).onCreate(gen.nanoid(16)), title: field.column(type.String()), diff --git a/packages/3-targets/postgres/9-facade/test/contract-builder/type-helpers.test-d.ts b/packages/3-targets/postgres/9-facade/test/contract-builder/type-helpers.test-d.ts index 286f8cbfd3d7..ec4b7003bf0f 100644 --- a/packages/3-targets/postgres/9-facade/test/contract-builder/type-helpers.test-d.ts +++ b/packages/3-targets/postgres/9-facade/test/contract-builder/type-helpers.test-d.ts @@ -44,12 +44,12 @@ type GeneratesOnCreate = Builder extends { readonly __state: infer Stat test('a field typed by temporal.CreatedAt is optional on create', () => { defineContract({}, ({ type, field }) => { const createdAt = field.column(type.temporal.CreatedAt()); - const updatedAt = field.column(type.temporal.UpdatedAtString()).optional(); + const updatedAt = field.column(type.temporal.UpdatedAt()).optional(); const plain = field.column(type.TimestamptzTemporal()); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); - expectTypeOf(type.temporal.CreatedAtJsDate().codecId).toEqualTypeOf<'pg/timestamptz-date@1'>(); + expectTypeOf(type.temporal.CreatedAt().codecId).toEqualTypeOf<'pg/timestamptz-date@1'>(); return { models: {} }; }); }); diff --git a/packages/3-targets/postgres/9-facade/test/convenience-type-helpers.test.ts b/packages/3-targets/postgres/9-facade/test/convenience-type-helpers.test.ts new file mode 100644 index 000000000000..6cf0e13c5117 --- /dev/null +++ b/packages/3-targets/postgres/9-facade/test/convenience-type-helpers.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { defineContract } from '../src/exports/contract-builder'; + +describe('a convenience type helper', () => { + it('refuses a precision its chosen type refuses, naming the argument of the convenience type', () => { + expect(() => + defineContract({}, ({ type, field, model, gen }) => ({ + models: { + Order: model('Order', { + fields: { + id: field.column(type.Char(36)).id().onCreate(gen.uuid()), + at: field.column(type.DateTime({ precision: 7 })), + }, + }), + }, + })), + ).toThrow('Argument "precision" of DateTime is invalid: precision must be at most 6 (was 7)'); + }); +}); diff --git a/packages/3-targets/postgres/9-facade/test/native-type-parity.test.ts b/packages/3-targets/postgres/9-facade/test/native-type-parity.test.ts index 2d381f352797..10199b5ef6b7 100644 --- a/packages/3-targets/postgres/9-facade/test/native-type-parity.test.ts +++ b/packages/3-targets/postgres/9-facade/test/native-type-parity.test.ts @@ -188,15 +188,15 @@ const parityCases: readonly ParityCase[] = [ expected: { dataType: 'pg/timestamptz', codecId: 'pg/timestamptz-temporal@1', typeParams: {} }, }, { - title: 'Time(3)', - bare: 'Time(3)', - alias: 'Time(3)', + title: 'TimeTemporal(3)', + bare: 'TimeTemporal(3)', + alias: 'TimeTemporal(3)', expected: { dataType: 'pg/time', codecId: 'pg/time-temporal@1', typeParams: { precision: 3 } }, }, { - title: 'Time — bare', - bare: 'Time', - alias: 'Time', + title: 'TimeTemporal — bare', + bare: 'TimeTemporal', + alias: 'TimeTemporal', expected: { dataType: 'pg/time', codecId: 'pg/time-temporal@1', typeParams: {} }, }, // The representation-explicit spellings, which must carry precision exactly as their unsuffixed @@ -294,9 +294,9 @@ const parityCases: readonly ParityCase[] = [ expected: { dataType: 'pg/float4', codecId: 'pg/float4@1', typeParams: {} }, }, { - title: 'Date — bare', - bare: 'Date', - alias: 'Date', + title: 'DateTemporal — bare', + bare: 'DateTemporal', + alias: 'DateTemporal', expected: { dataType: 'pg/date', codecId: 'pg/date-temporal@1', typeParams: {} }, }, ]; @@ -359,14 +359,14 @@ describe('native types as bare scalar types — parity with the live bare-type p }).toEqual({ types: undefined, viaAlias: expected, viaNamed: expected, direct: expected }); }); - it('lowers the Date UpdatedAt alias identically to explicit Date clock phases', () => { + it('lowers the UpdatedAt alias identically to explicit Date clock phases', () => { const shorthand = emit(`model sample { id Int @id - at temporal.UpdatedAtJsDate + at temporal.UpdatedAt }`); const explicit = emit(`model sample { id Int @id - at TimestamptzJsDate @onCreate(now()) @onUpdate(now()) + at TimestamptzJsDate(3) @onCreate(now()) @onUpdate(now()) }`); expect(shorthand.ok).toBe(true); expect(explicit.ok).toBe(true); @@ -379,14 +379,14 @@ describe('native types as bare scalar types — parity with the live bare-type p }); }); - it('lowers the Date CreatedAt alias identically to an explicit create clock phase', () => { + it('lowers the CreatedAt alias identically to an explicit create clock phase', () => { const result = emit(`model sample { id Int @id - at temporal.CreatedAtJsDate + at temporal.CreatedAt }`); const explicit = emit(`model sample { id Int @id - at TimestamptzJsDate @onCreate(now()) + at TimestamptzJsDate(3) @onCreate(now()) }`); expect(result.ok).toBe(true); expect(explicit.ok).toBe(true); @@ -413,7 +413,7 @@ describe('native types as bare scalar types — parity with the live bare-type p { code: 'PSL_UNRESOLVED_REFERENCE', message: - 'temporal.timestamptzJsDate(3, onCreate: now) was a field preset, and Prisma 8 removed field presets. Write TimestamptzJsDate(3) @onCreate(now()).', + 'temporal.timestamptzJsDate(3, onCreate: now) was a field preset, and Prisma 8 removed field presets. Write DateTime @onCreate(now()).', }, ]); }); diff --git a/packages/3-targets/postgres/9-facade/test/no-temporal-global/temporal-field-types.test-d.ts b/packages/3-targets/postgres/9-facade/test/no-temporal-global/temporal-field-types.test-d.ts index b1a1a3ce83fc..a4664ecc3d85 100644 --- a/packages/3-targets/postgres/9-facade/test/no-temporal-global/temporal-field-types.test-d.ts +++ b/packages/3-targets/postgres/9-facade/test/no-temporal-global/temporal-field-types.test-d.ts @@ -13,7 +13,6 @@ const contract = defineContract({}, ({ type, field, model, gen }) => ({ local: field.column(type.TimestampTemporal()), day: field.column(type.DateTemporal()), opens: field.column(type.TimeTemporal(0)), - legacyDay: field.column(type.Date()), utcAt: field.column(type.TimestampInstant(6)), utcDate: field.column(type.TimestampJsDate(3)), span: field.column(type.IntervalString(3)), @@ -30,7 +29,6 @@ test('a Temporal field is typed precisely in a project without the global Tempor expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); expectTypeOf().not.toBeAny(); expectTypeOf().toEqualTypeOf(); }); @@ -53,3 +51,128 @@ test('a timestamp read as a Date reads a Date, and an interval read as a string expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); + +const convenience = defineContract({}, ({ type, field, model, gen }) => ({ + models: { + Order: model('Order', { + fields: { + id: field.column(type.Char(36)).id().onCreate(gen.uuid()), + paidAt: field.column(type.DateTime()), + settledAt: field.column(type.DateTime({ as: 'temporal' })), + textAt: field.column(type.DateTime({ as: 'string', precision: 6 })), + legacyAt: field.column(type.DateTime({ storage: 'timestamp' })), + utcAt: field.column(type.DateTime({ as: 'temporal', storage: 'timestamp' })), + shipBy: field.column(type.Date()), + shipOn: field.column(type.Date({ as: 'temporal' })), + opensAt: field.column(type.Time({ precision: 0 })), + opensAtT: field.column(type.Time({ as: 'temporal' })), + localAt: field.column(type.Time({ storage: 'timetz' })), + period: field.column(type.Duration()), + parts: field.column(type.Duration({ as: 'parts' })), + textAtExplicit: field.column(type.TimestamptzString(6)), + opensAtExplicit: field.column(type.TimeString(0)), + localAtExplicit: field.column(type.Timetz()), + partsExplicit: field.column(type.IntervalParts()), + }, + }), + }, +})); + +type OrderRow = DefaultModelRow; + +test('a convenience type is typed as the value of the explicit type it chooses', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toBeAny(); + expectTypeOf().toEqualTypeOf(); +}); + +test('a convenience type helper refuses a combination with no explicit type', () => { + defineContract({}, ({ type, field, model, gen }) => ({ + models: { + Order: model('Order', { + fields: { + id: field.column(type.Char(36)).id().onCreate(gen.uuid()), + // @ts-expect-error Temporal.PlainTime cannot hold a timetz offset + at: field.column(type.Time({ as: 'temporal', storage: 'timetz' })), + // @ts-expect-error a calendar date is never a JavaScript Date + day: field.column(type.Date({ as: 'jsDate' })), + // @ts-expect-error a calendar date has no precision + dayAt: field.column(type.Date({ precision: 3 })), + // @ts-expect-error the options are as and storage + stamp: field.column(type.DateTime({ value: 'string' })), + // @ts-expect-error a precision is written by name + opens: field.column(type.Time(3)), + }, + }), + }, + })); +}); + +test('each convenience type call has the type of the explicit type helper it chooses', () => { + defineContract({}, ({ type }) => { + expectTypeOf(type.DateTime()).toEqualTypeOf(type.TimestamptzJsDate(3)); + expectTypeOf(type.DateTime({ precision: 6 })).toEqualTypeOf(type.TimestamptzJsDate(6)); + expectTypeOf(type.DateTime({ as: 'jsDate', storage: 'timestamptz' })).toEqualTypeOf( + type.TimestamptzJsDate(3), + ); + expectTypeOf(type.DateTime({ as: 'temporal' })).toEqualTypeOf(type.TimestamptzTemporal()); + expectTypeOf(type.DateTime({ as: 'temporal', precision: 3 })).toEqualTypeOf( + type.TimestamptzTemporal(3), + ); + expectTypeOf(type.DateTime({ as: 'string' })).toEqualTypeOf(type.TimestamptzString()); + expectTypeOf( + type.DateTime({ as: 'string', storage: 'timestamptz', precision: 6 }), + ).toEqualTypeOf(type.TimestamptzString(6)); + expectTypeOf(type.DateTime({ storage: 'timestamp' })).toEqualTypeOf(type.TimestampJsDate(3)); + expectTypeOf(type.DateTime({ as: 'jsDate', storage: 'timestamp', precision: 0 })).toEqualTypeOf( + type.TimestampJsDate(0), + ); + expectTypeOf(type.DateTime({ as: 'temporal', storage: 'timestamp' })).toEqualTypeOf( + type.TimestampInstant(), + ); + expectTypeOf( + type.DateTime({ as: 'temporal', storage: 'timestamp', precision: 6 }), + ).toEqualTypeOf(type.TimestampInstant(6)); + expectTypeOf(type.DateTime({ as: 'string', storage: 'timestamp' })).toEqualTypeOf( + type.TimestampString(), + ); + expectTypeOf(type.DateTime({ as: 'string', storage: 'timestamp', precision: 3 })).toEqualTypeOf( + type.TimestampString(3), + ); + expectTypeOf(type.Date()).toEqualTypeOf(type.DateString()); + expectTypeOf(type.Date({ as: 'string', storage: 'date' })).toEqualTypeOf(type.DateString()); + expectTypeOf(type.Date({ as: 'temporal' })).toEqualTypeOf(type.DateTemporal()); + expectTypeOf(type.Time()).toEqualTypeOf(type.TimeString()); + expectTypeOf(type.Time({ precision: 0 })).toEqualTypeOf(type.TimeString(0)); + expectTypeOf(type.Time({ as: 'temporal' })).toEqualTypeOf(type.TimeTemporal()); + expectTypeOf(type.Time({ as: 'temporal', storage: 'time', precision: 3 })).toEqualTypeOf( + type.TimeTemporal(3), + ); + expectTypeOf(type.Time({ as: 'string', storage: 'timetz' })).toEqualTypeOf(type.Timetz()); + expectTypeOf(type.Time({ storage: 'timetz', precision: 2 })).toEqualTypeOf(type.Timetz(2)); + expectTypeOf(type.Duration()).toEqualTypeOf(type.IntervalString()); + expectTypeOf(type.Duration({ precision: 3 })).toEqualTypeOf(type.IntervalString(3)); + expectTypeOf(type.Duration({ as: 'parts' })).toEqualTypeOf(type.IntervalParts()); + expectTypeOf(type.Duration({ as: 'parts', storage: 'interval', precision: 6 })).toEqualTypeOf( + type.IntervalParts(6), + ); + const widened: number = 4; + expectTypeOf(type.DateTime({ as: 'string', precision: widened })).toEqualTypeOf( + type.TimestamptzString(widened), + ); + expectTypeOf(type.DateTime({ precision: widened })).not.toEqualTypeOf( + type.TimestamptzString(widened), + ); + return { models: {} }; + }); +}); diff --git a/packages/3-targets/postgres/9-facade/test/scalar-type-parity.test.ts b/packages/3-targets/postgres/9-facade/test/scalar-type-parity.test.ts index c61dc8382f4c..1b682fdfb588 100644 --- a/packages/3-targets/postgres/9-facade/test/scalar-type-parity.test.ts +++ b/packages/3-targets/postgres/9-facade/test/scalar-type-parity.test.ts @@ -28,7 +28,7 @@ const REPRESENTATIVE_SCHEMA = `model sample { unbounded UnboundedInt ratio Float price Decimal - createdAt DateTime + createdAt TimestamptzTemporal payload Json document Jsonb raw Bytes @@ -68,7 +68,6 @@ describe('postgres scalar types derived from the unified namespace', () => { Float: { codecId: 'pg/float8@1' }, Inet: { codecId: 'pg/inet@1' }, Decimal: { codecId: 'pg/numeric@1' }, - DateTime: { codecId: 'pg/timestamptz-temporal@1' }, Json: { codecId: 'pg/json@1' }, Jsonb: { codecId: 'pg/jsonb@1' }, Bytes: { codecId: 'pg/bytea@1' }, @@ -77,13 +76,11 @@ describe('postgres scalar types derived from the unified namespace', () => { Numeric: { codecId: 'pg/numeric@1' }, TimestampTemporal: { codecId: 'pg/timestamp-temporal@1' }, TimestamptzTemporal: { codecId: 'pg/timestamptz-temporal@1' }, - Time: { codecId: 'pg/time-temporal@1' }, TimeTemporal: { codecId: 'pg/time-temporal@1' }, Timetz: { codecId: 'pg/timetz@1' }, Uuid: { codecId: 'pg/uuid@1' }, SmallInt: { codecId: 'pg/int2@1' }, Real: { codecId: 'pg/float4@1' }, - Date: { codecId: 'pg/date-temporal@1' }, DateTemporal: { codecId: 'pg/date-temporal@1' }, DateString: { codecId: 'pg/date-string@1' }, TimestampString: { codecId: 'pg/timestamp-string@1' }, @@ -96,6 +93,10 @@ describe('postgres scalar types derived from the unified namespace', () => { IntervalString: { codecId: 'pg/interval-string@1' }, TimestampJsDate: { codecId: 'pg/timestamp-date@1' }, TimestampInstant: { codecId: 'pg/timestamp-instant@1' }, + DateTime: { codecId: 'pg/timestamptz-date@1', typeParams: { precision: 3 } }, + Date: { codecId: 'pg/date-string@1' }, + Time: { codecId: 'pg/time-string@1' }, + Duration: { codecId: 'pg/interval-string@1' }, }); }); @@ -112,6 +113,7 @@ describe('postgres scalar types derived from the unified namespace', () => { 'DateTemporal', 'DateTime', 'Decimal', + 'Duration', 'Float', 'Inet', 'Int', diff --git a/projects/datetime-convenience-types/design-notes.md b/projects/datetime-convenience-types/design-notes.md index ddc9bc11c81d..4e0257d47cab 100644 --- a/projects/datetime-convenience-types/design-notes.md +++ b/projects/datetime-convenience-types/design-notes.md @@ -39,7 +39,7 @@ Record of the design discussion between the operator and the orchestrator on 202 **The Prisma 7 reader gives back `Date`.** Prisma 7's `DateTime` is a `timestamp(3)` column read as a `Date`. Mapping it to `DateTime(storage: timestamp)` keeps an upgraded app's code compiling. This reverses the choice made when inferred and Prisma 7 columns moved to string types, which rejected `Date` only because no `timestamp` `Date` codec existed. -**TypeScript follows the `type.*` helpers.** The field-preset removal project gives every PSL type constructor a `type.*` helper. This project makes the builder's compile-time resolver evaluate a codec chosen by arguments, so `type.DateTime(...)` types precisely once the helpers land, instead of building a separate TypeScript surface now. +**TypeScript helpers take one object.** `type.DateTime({ as: 'string', precision: 6 })`, with one overload per row of the table, so a call is typed as the explicit type it picks and an unlisted combination does not compile (settled 2026-10-23). Explicit constructors stay positional. **PostgreSQL only.** A convenience name must mean the same on every target. SQLite's `DateTime` and MongoDB's `Date` already use the names for explicit types, and MongoDB's `Date` is a moment in time. Resolving them is TML-3714. @@ -66,6 +66,6 @@ None. Settled on 2026-10-22: ## References -- ADR 279 (draft) and ADR 241, 246, 254, 257, 275. +- ADR 279 and ADR 241, 254, 257, 275. - Architecture overview: "Domain-first surfaces", "Explicit over implicit". - The research report (private artifact, 2026-10-21). diff --git a/projects/datetime-convenience-types/plan.md b/projects/datetime-convenience-types/plan.md index a880bc868de7..45090e7cf6ab 100644 --- a/projects/datetime-convenience-types/plan.md +++ b/projects/datetime-convenience-types/plan.md @@ -32,7 +32,7 @@ Renames outright, with no deprecated aliases before RC: `Timestamptz` → `Times ### Slice 4 — The convenience types -A type constructor's `codecId` may be a `select` over `option` arguments; every reader of a constructor's output resolves it. The four types with the spec's choices and defaults; an unlisted combination is an error at the argument. `DateTime`, `Date` and `Time` take over their names (breaking, with upgrade instructions). The TypeScript builder's type-level resolver evaluates the choice. Hover text and completion list each type's choices. ADR 279 moves to Accepted, with notes in ADR 246, 254, 257 and 275. +A new framework declaration, the type choice, picks one explicit type constructor from a table by its `as:` and `storage:` options; every reader of a field's codec resolves the call first. The four types with the spec's choices and defaults; an unlisted combination is an error at the argument. `DateTime`, `Date` and `Time` take over their names (breaking, with upgrade instructions). The TypeScript `type.*` helpers take one object and are typed per row. Hover text and completion list each type's choices. ADR 254, 257 and 275 get notes pointing to ADR 279. ### Slice 5 — The tools write convenience types @@ -42,9 +42,13 @@ A type constructor's `codecId` may be a `select` over `option` arguments; every - Slices 1 and 4 both touch what string date and time values look like. The convenience-types journey (`convenience-date-time-types-without-temporal.e2e.test.ts`) asserts today's display text for `textAt` (`2024-02-29 10:00:00.123456+00`) and `localAt` (`09:30:00+02`). Whichever of the two slices merges second changes them to `2024-02-29T10:00:00.123456Z` and `09:30:00+02:00`. +## Cross-slice notes + +- Slices 1 and 4 both touch what string date and time values look like. The convenience-types journey (`convenience-date-time-types-without-temporal.e2e.test.ts`) asserts today's display text for `textAt` (`2024-02-29 10:00:00.123456+00`) and `localAt` (`09:30:00+02`). Whichever of the two slices merges second changes them to `2024-02-29T10:00:00.123456Z` and `09:30:00+02:00`. + ## Dependencies -- **Field-preset removal project, slice 2.** Delivers the `type.*` helpers that expose slice 4's types in TypeScript, and the contributed aliases built on `DateTime`. Not blocking; coordinate so its `temporal.CreatedAt` and `temporal.UpdatedAt` land on `DateTime` once slice 4 merges. +- **Field-preset removal project.** Its remaining slices remove the presets; slice 5 here rebuilds its contributed `temporal.CreatedAt` and `temporal.UpdatedAt` on `DateTime`. - **PostgreSQL and SQLite rename project** (not started). Must not remove `DateTime` and must take slice 3's names. Its spec gets a note in this project's shaping PR. - **Release.** Users get the convenience types with the first `@prisma/orm-postgres` release after slice 4 merges. diff --git a/projects/datetime-convenience-types/slices/04-convenience-types/spec.md b/projects/datetime-convenience-types/slices/04-convenience-types/spec.md index be848506e868..a0b1982bede8 100644 --- a/projects/datetime-convenience-types/slices/04-convenience-types/spec.md +++ b/projects/datetime-convenience-types/slices/04-convenience-types/spec.md @@ -29,9 +29,9 @@ model Order { 2. **Every reader resolves the field's call.** The PSL column resolution, the TypeScript builder's runtime and type-level resolution (`type.*` helpers), the contract-prisma7 values path, and every place that today reads a constructor's fixed `output.codecId` without the call's arguments: `@default` literal admission (`receivingDataType`) and the `@onCreate`/`@onUpdate` generator lookup (`generatorCallArm`) must use the codec the field's call resolves to, including through a `types {}` alias. The control stack checks every reachable explicit constructor at assembly. 3. **Bare names are scalars.** `DateTime`, `Date`, `Time` and `Duration` work bare and called; the scalar view treats them like any all-optional constructor. 4. **`inferred` marks stay on the explicit constructors** in this slice; a type choice is never inferred. `contract print` and `contract infer` keep writing explicit names until slice 5. -5. **The four PostgreSQL types and their tables** are exactly ADR 279 item 5 and the project spec's decision 3, with defaults: `DateTime` → `jsDate`, `timestamptz`, precision 3 for `jsDate` else none; `Date` → `string`; `Time` → `string`, `time`; `Duration` → `string`. +5. **The four PostgreSQL types and their tables** are exactly ADR 279's convenience types table and the project spec's decision 3, with defaults: `DateTime` → `jsDate`, `timestamptz`, precision 3 for `jsDate` else none; `Date` → `string`; `Time` → `string`, `time`; `Duration` → `string`. 6. **The old meanings move to explicit names.** Every existing Prisma 8 PostgreSQL schema in the repository that writes `DateTime`, `Date` or `Time` (examples, apps, integration fixtures, test PSL strings, docs) is rewritten mechanically to the explicit type it means today (`TimestamptzTemporal`, `DateTemporal`, `TimeTemporal`), so no committed `contract.json` or hash changes. The `Date` and `Time` second names and `DateTime`'s current constructor are removed. The upgrade instruction gives users the same rewrite and says the bare names now choose different values. -7. **TypeScript builder.** `type.DateTime(...)`, `type.Date(...)`, `type.Time(...)`, `type.Duration(...)` exist through the `type.*` helpers, positional, with a type-level resolution that yields the explicit type's codec and application type for literal arguments (a type test per table row). +7. **TypeScript builder.** `type.DateTime(...)`, `type.Date(...)`, `type.Time(...)`, `type.Duration(...)` exist through the `type.*` helpers and take one object (`type.DateTime({ as: 'string', precision: 6 })`), with one overload per row and a type-level resolution that yields the explicit type's codec and application type for literal arguments (a type test per table row). 8. **Language server.** Hover on a convenience type shows each argument's choices as PSL writes them (bare names) and which explicit type each combination becomes; completion of a bare convenience type inserts the bare name. 9. **ADR updates in the same PR:** ADR 279 to Accepted, its "How it works" describing the type-choice descriptor; notes in ADR 254 (a type constructor may resolve to another constructor), ADR 257 (unsuffixed names belong to convenience types), ADR 275 (the contributed `temporal.*` aliases are built on `DateTime`; the `String`/`JsDate` variants are not needed). diff --git a/projects/datetime-convenience-types/slices/05-tools-write-convenience-types/spec.md b/projects/datetime-convenience-types/slices/05-tools-write-convenience-types/spec.md index 465889326062..cfc3936b2b85 100644 --- a/projects/datetime-convenience-types/slices/05-tools-write-convenience-types/spec.md +++ b/projects/datetime-convenience-types/slices/05-tools-write-convenience-types/spec.md @@ -1,6 +1,6 @@ # Slice 5 — `contract infer`, `contract print`, the Prisma 7 reader and `orm init` write the convenience types -**Linear:** [TML-3713](https://linear.app/prisma-company/issue/TML-3713) · **Parent:** [project spec](../../spec.md), decision 10 · **Design record:** ADR 279, items 8 and 10 · **Builds on:** slice 4 (the type choices) and slice 2 (the Prisma 7 `now()` mapping) +**Linear:** [TML-3713](https://linear.app/prisma-company/issue/TML-3713) · **Parent:** [project spec](../../spec.md), decision 10 · **Design record:** ADR 279, "What the tools write" · **Builds on:** slice 4 (the type choices) and slice 2 (the Prisma 7 `now()` mapping) ## At a glance @@ -39,7 +39,8 @@ A Prisma 7 schema read by `prisma7Schema(...)`: `DateTime` becomes `TimestampJsD ## Chosen design 1. **Infer always takes the default path where it can** (operator ruling, 2026-10-23: "infer should always choose the default path, where possible, since that's the simplest to see in your contract, as a user, and ought to represent the well-trodden path"). For each date and time column, infer picks the codec of the convenience type's default value for that column type, and writes the convenience call with only the arguments that differ from its defaults. The explicit type is written only where no convenience call reproduces the column. - - Consequence the docs state: a `Date` holds milliseconds, so a `timestamptz` or `timestamp` column with more than 3 digits of precision reads with the extra digits dropped, and `infinity` is refused on read. The upgrade and infer docs say to write `DateTime(as: temporal)` or `DateTime(as: string)` to keep them. + - Consequence the docs state: a `Date` holds milliseconds, so a `timestamptz` or `timestamp` column with more than 3 digits of precision reads with the extra digits dropped, and `infinity` is refused on read. The upgrade and infer docs say to write `DateTime(as: temporal)` or `DateTime(as: string)` to keep the digits, and `DateTime(as: string)` for a column whose rows hold `infinity`. + - The one exception (operator ruling, 2026-10-27): when the default-path type cannot read the column's own default, such as `infinity` for a `Date`, infer takes the first row of the same convenience type and storage whose type can, because every row created with that default would otherwise fail to read. 2. **An omitted precision equals precision 6** on `pg/timestamptz`, `pg/timestamp`, `pg/time`, `pg/timetz` and `pg/interval` when columns are compared (the data type's normal form, ADR 254), because PostgreSQL stores microseconds either way. That lets infer write `DateTime(precision: 6)` for a column with no declared precision without `db verify` or the planner seeing a difference. The contract keeps parameters as written; stored contracts and hashes do not change. If this normal-form change would change any existing contract, hash or plan in the repository's fixtures, stop and report. 3. **The `inferred` marks move to the default explicit types:** `pg/timestamptz` → `TimestamptzJsDate`, `pg/timestamp` → `TimestampJsDate`, `pg/interval` → `IntervalString`; `DateString`, `TimeString`, `Timetz` keep theirs. Both infer and the Prisma 7 reader follow the marks, which gives the Prisma 7 table above. The guard test that forbids Temporal codecs in these maps keeps passing and gains `pg/timestamp-instant@1`. 4. **Print and infer write convenience calls through one framework helper** beside `resolveAuthoringTypeChoice`: given an explicit constructor call, find the type choice row (at the same namespace level) that names that constructor, build named arguments from the row (options from its `when`, passed-on arguments by name), drop arguments equal to the defaults, re-resolve to prove it reproduces the same codec and parameters, and otherwise return nothing so the caller writes the explicit call. Arguments are always written by name (positional `DateTime(6)` would land in `as`). @@ -60,7 +61,7 @@ A Prisma 7 schema read by `prisma7Schema(...)`: `DateTime` becomes `TimestampJsD | --- | --- | | `timestamptz` with no declared precision | `DateTime(precision: 6)`, by decision 2 | | `timestamp` column read as `Temporal.PlainDateTime` | Explicit `TimestampTemporal` | -| An inferred `infinity` default on a `timestamptz` column | The `Date` codec cannot read it; infer prints it as a raw `sql` default and says so (the existing fallback), pinned by `infer-roundtrip-fidelity.prisma7-defaults.e2e.test.ts` | +| An inferred `infinity` default on a `timestamptz` or `timestamp` column | The `Date` codec cannot read it, so infer writes the first row that can: `DateTime(as: string, ...)` with the default as written (decision 1's exception) | | `time` with no precision | `Time` (string default; omitted precision equals 6 by decision 2) | | `examples/prisma7-adoption` reading `updatedAt.replace(' ', 'T')` | Code updated to read a `Date` | | Two type choices naming one constructor | The helper takes the first in namespace order; no PostgreSQL constructor is named twice | diff --git a/projects/datetime-convenience-types/spec.md b/projects/datetime-convenience-types/spec.md index 127b70ed326d..258c3d2312b8 100644 --- a/projects/datetime-convenience-types/spec.md +++ b/projects/datetime-convenience-types/spec.md @@ -49,25 +49,24 @@ The reasoning and the alternatives rejected are in [`design-notes.md`](./design- 9. **Temporal fields are never typed `any`.** The Temporal codecs and generators take their types from `temporal-spec`, a types-only package, instead of an undeclared global. No polyfill code is added anywhere. A missing global `Temporal` at runtime is still reported on first use, as today; a startup check was considered and dropped, because Temporal values are now an explicit opt-in. 10. **`contract infer` and `contract print` write convenience types**, with arguments only where they differ from the defaults; a column no convenience type covers (`Temporal.PlainDateTime`) is written as its explicit type. **The Prisma 7 reader** maps Prisma 7's `DateTime` to `TimestampJsDate(3)`, printed `DateTime(storage: timestamp)`, so an upgraded app reads `Date` values again, and maps Prisma 7's `@default(now())` to `@default(now()) @onCreate(now())`, as Prisma 7 behaved ([TML-3719](https://linear.app/prisma-company/issue/TML-3719)). 11. **PostgreSQL only.** A convenience name means the same thing on every target; SQLite and MongoDB follow in [TML-3714](https://linear.app/prisma-company/issue/TML-3714). -12. **TypeScript:** this project ships the PSL types and makes the TypeScript builder's compile-time resolver evaluate a codec chosen by arguments. The `type.DateTime(...)` helper itself arrives with the `type.*` helpers of the field-preset removal project. +12. **TypeScript:** `type.DateTime(...)`, `type.Date(...)`, `type.Time(...)` and `type.Duration(...)` take their arguments as one object, `type.DateTime({ as: 'string', precision: 6 })`, with one overload per row, so a call is typed as the explicit type it picks and a combination with no row does not compile. Explicit constructors stay positional. Settled 2026-10-23 with the option names `as:` and `storage:`. ## Non-goals - SQLite and MongoDB convenience types ([TML-3714](https://linear.app/prisma-company/issue/TML-3714)). - Convenience types for other scalars, such as an `Int` choosing between `Int2`, `Int4` and `Int8` ([TML-3715](https://linear.app/prisma-company/issue/TML-3715)). -- The `type.*` helpers in the TypeScript builder (field-preset removal project, its second slice). -- `temporal.CreatedAt` and `temporal.UpdatedAt`. They belong to the field-preset removal project; this project only says they are built on `DateTime` (ADR 279). +- The rest of the field-preset removal. This project rebuilds only the contributed `temporal.CreatedAt` and `temporal.UpdatedAt` aliases on `DateTime` (slice 5). - A `Temporal.PlainDateTime` convenience form. It stays reachable as the explicit `TimestampTemporal`. - Bundling a Temporal polyfill. - Any change to how values are stored: the contract form of a value (ADR 254) and every existing codec's stored form stay as they are. ## Place in the larger world -- **ADR 254, data types and casts.** Codecs, data types and stored forms are unchanged. A type constructor gains the ability to choose its codec, which amends the definition there. The text codecs today keep PostgreSQL's year text (` BC`); decision 8 changes that to the ISO form, which amends the "Date and time types" section. +- **ADR 254, data types and casts.** Codecs, data types and stored forms are unchanged. A type constructor still names exactly one codec; a type choice is a second kind of declaration that resolves to a type constructor. The text codecs today keep PostgreSQL's year text (` BC`); decision 8 changes that to the ISO form, which amends the "Date and time types" section. - **ADR 257, scalar names.** The explicit layer follows it. The `Temporal` suffix applies the suffix rule to every codec of a multi-codec column. Its one-release deprecation rule is not applied before RC: renamed names become errors at once. -- **ADR 246 and ADR 275.** The `option` argument kind and the `select` template, which ADR 275 removes with the presets, are kept for type constructors. +- **ADR 275.** The `option` argument kind and the `select` template stay removed with the presets; a type choice has its own option arguments. The contributed `temporal.*` aliases are built on `DateTime`. - **The PostgreSQL and SQLite rename project** (`projects/target-named-scalars-sql/`, not started) must keep `DateTime` and take this project's renames instead of its own `DateTime` and `Timestamptz` rows. -- **The field-preset removal project** (`projects/remove-field-presets/`, slice 1 merged): its `type.*` helpers carry the convenience types into TypeScript, and its contributed aliases are built on `DateTime`; its `String` and `JsDate` alias variants are not needed. +- **The field-preset removal project** (`projects/remove-field-presets/`, slice 1 merged): its contributed aliases are built on `DateTime` (slice 5), and their `String` and `JsDate` variants are removed. - **`prisma orm init`** writes `TimestamptzString` and `temporal.updatedAtString()` today. It moves to the convenience types in slice 5. ## Cross-cutting requirements @@ -95,7 +94,7 @@ PostgreSQL target and adapter only: data-type-bound codecs, type constructors, t ## ADR pointer -ADR 279 (draft in this project's shaping PR, status Proposed until slice 4 merges). It amends ADR 254, 257 and 275 and brings back ADR 246 for type constructors; each amended ADR gets a note in the slice that implements the change. +ADR 279, Accepted. It builds on ADR 241, 254 and 257 and amends ADR 275; ADR 254, 257 and 275 get a note in the slice that implements the change. ## Project DoD @@ -104,7 +103,7 @@ Inherits the team floor in [`drive/calibration/dod.md`](../../drive/calibration/ - [ ] Every row of the decision 3 table works end to end: a PSL schema emits, `migration plan` produces the expected column DDL, and a query on PostgreSQL reads and writes the expected value type, with and without a global `Temporal` as each row requires. - [ ] A new project from `prisma orm init`, and a Prisma 7 project adopted with `prisma7Schema(...)`, run their first query on Node with no polyfill and read `Date` values for their timestamps; a create through Prisma 8 on an adopted project, on a server whose `TimeZone` is not UTC, stores UTC. - [ ] The report's use-case table (see References) re-run against the shipped surface, with each row's Prisma 8 cell updated. -- [ ] ADR 279 accepted; ADR 246, 254, 257 and 275 carry notes; `docs/reference/scalar-types.md` describes both layers. +- [ ] ADR 279 accepted; ADR 254, 257 and 275 carry notes; `docs/reference/scalar-types.md` describes both layers. - [ ] TML-3714 and TML-3715 remain open with their context. ## Open questions @@ -113,7 +112,7 @@ Inherits the team floor in [`drive/calibration/dod.md`](../../drive/calibration/ ## References -- ADR 279 (draft) and its amended ADRs: 241, 246, 254, 257, 275. +- ADR 279 and the ADRs it builds on or amends: 241, 254, 257, 275. - The research report comparing Prisma 7 and Prisma 8 date and time fields on PostgreSQL (2026-10-21, published as a private artifact; ask the operator). - [TML-3418](https://linear.app/prisma-company/issue/TML-3418), the server-dependent string bug, now slice 1. - The pull request that moved inferred and Prisma 7 date columns to string types: prisma/orm#30553. diff --git a/skills/prisma-8/references/contract.md b/skills/prisma-8/references/contract.md index 01da72685aaf..fe15dc099479 100644 --- a/skills/prisma-8/references/contract.md +++ b/skills/prisma-8/references/contract.md @@ -117,7 +117,9 @@ model Post { Then run `pnpm prisma contract emit` (or rely on the Vite plugin — see `references/build.md`). Specify cascade behaviour explicitly with `onDelete` / `onUpdate`; the default is `Restrict`. -**Temporal columns.** On PostgreSQL, `DateTemporal`, `TimestampTemporal(p)`, `TimestampInstant(p)`, `TimestamptzTemporal(p)` and `TimeTemporal(p)` (and `Date`, `DateTime` and `Time`, which today mean the same as `DateTemporal`, `TimestamptzTemporal` and `TimeTemporal`) read and write `Temporal` values (`Temporal.PlainDate`, `PlainDateTime`, `Instant` for both instant types, `PlainTime`), never JavaScript `Date`. They need a global `Temporal` at query time: Node.js 26.8.2 and later ship `globalThis.Temporal`; 26.8.1 and earlier — including every 22 and 24 release — do not, and the first read or write of such a column throws `RUNTIME.TEMPORAL_UNAVAILABLE`. On those runtimes either `import 'temporal-polyfill/full/global'` before the first query (add `temporal-polyfill` as a dependency) or author the column as `DateString` / `TimestampString(p)` / `TimestamptzString(p)` / `TimeString(p)`, which carry ISO 8601 text, the same on every server (`2024-01-01T00:00:00Z` for a `timestamptz`, the instant in UTC), and need no `Temporal`, or as `TimestamptzJsDate(p)` / `TimestampJsDate(p)`, which read a JavaScript `Date`. `IntervalParts(p)` reads `{ months, days, micros }` and `IntervalString(p)` an ISO 8601 duration such as `P1Y2M`. `prisma contract emit`, `prisma db init` and the other commands need no global `Temporal`: the Postgres target loads `temporal-polyfill` as a fallback `Temporal` for the control plane, and sets no global. `temporal-polyfill` is a required peer dependency of `@prisma/orm-postgres`. npm, pnpm and bun install it automatically. With Yarn, add `temporal-polyfill` (`^1.0.4`) to the project's dependencies, or the commands fail because Node.js cannot find the package. That fallback is held once per process. An application that loads control-plane code in its own process, such as server code under `vite dev` with the Prisma Vite plugin or a script that calls the control client, decodes dates without its own `Temporal`, and then fails in production. An application that uses the Temporal codecs must load its own `Temporal`. A TypeScript contract file that constructs a `Temporal` value must load an implementation too. +**Date and time types.** On PostgreSQL, write a field with a convenience type named for what it means: `DateTime` (a moment in time, a `timestamptz(3)` read as a JavaScript `Date`), `Date` (a calendar date, read as a string such as `"2024-02-29"`), `Time` (a time of day, read as a string) or `Duration` (an ISO 8601 duration string such as `"P1M"`). None needs `Temporal`. Optional arguments choose another explicit type: `as`, the value the application reads (`jsDate`, `temporal` or `string` for `DateTime`; `string` or `temporal` for `Date` and `Time`; `string` or `parts` for `Duration`), `storage`, the database type that stores it (`timestamptz` or `timestamp` for `DateTime`; `time` or, with `as: string`, `timetz` for `Time`), and `precision`: `DateTime(as: temporal)`, `DateTime(storage: timestamp)`, `Time(precision: 0)`. The contract stores only the explicit type, and `contract print` writes it; the full table is [Scalar types](https://github.com/prisma/orm/blob/main/docs/reference/scalar-types.md). `Time(3)` is refused, because the first argument is `as`; write `Time(precision: 3)`. In a TypeScript contract the helpers take one object: `type.DateTime({ as: 'string', precision: 6 })`, `type.Time({ storage: 'timetz' })`. + +**Temporal columns.** On PostgreSQL, `DateTemporal`, `TimestampTemporal(p)`, `TimestampInstant(p)`, `TimestamptzTemporal(p)` and `TimeTemporal(p)` (and a convenience type with `as: temporal`) read and write `Temporal` values (`Temporal.PlainDate`, `PlainDateTime`, `Instant` for both instant types, `PlainTime`), never JavaScript `Date`. They need a global `Temporal` at query time: Node.js 26.8.2 and later ship `globalThis.Temporal`; 26.8.1 and earlier — including every 22 and 24 release — do not, and the first read or write of such a column throws `RUNTIME.TEMPORAL_UNAVAILABLE`. On those runtimes either `import 'temporal-polyfill/full/global'` before the first query (add `temporal-polyfill` as a dependency) or author the column as `DateString` / `TimestampString(p)` / `TimestamptzString(p)` / `TimeString(p)`, which carry ISO 8601 text, the same on every server (`2024-01-01T00:00:00Z` for a `timestamptz`, the instant in UTC), and need no `Temporal`, or as `TimestamptzJsDate(p)` / `TimestampJsDate(p)`, which read a JavaScript `Date`. `IntervalParts(p)` reads `{ months, days, micros }` and `IntervalString(p)` an ISO 8601 duration such as `P1Y2M`. `prisma contract emit`, `prisma db init` and the other commands need no global `Temporal`: the Postgres target loads `temporal-polyfill` as a fallback `Temporal` for the control plane, and sets no global. `temporal-polyfill` is a required peer dependency of `@prisma/orm-postgres`. npm, pnpm and bun install it automatically. With Yarn, add `temporal-polyfill` (`^1.0.4`) to the project's dependencies, or the commands fail because Node.js cannot find the package. That fallback is held once per process. An application that loads control-plane code in its own process, such as server code under `vite dev` with the Prisma Vite plugin or a script that calls the control client, decodes dates without its own `Temporal`, and then fails in production. An application that uses the Temporal codecs must load its own `Temporal`. A TypeScript contract file that constructs a `Temporal` value must load an implementation too. `@@index` also accepts `expression:` (instead of a fields list), `where:` (partial-index predicate), `unique:`, `type:`/`options:` (target-registered access method), and `name:` xor `map:`. Raw SQL in `expression:` and `where:` is a `sql` literal; a quoted string is refused with `PSL_VALUE_TYPE_INCOMPATIBLE`, whose message gives the rewrite: @@ -156,7 +158,7 @@ model User { } ``` -An alias is expanded into each field that uses it: the column stores the alias's codec and type params, and the contract stores no alias. An alias may carry `@default`, `@onCreate` and `@onUpdate`, which every field typed by it takes: `UpdatedAt = TimestampString(3) @onCreate(now()) @onUpdate(now())`. It may not carry `@id` or `@unique`; write those on the field. A field may add attributes its alias does not set (`updatedAt UpdatedAt @default(now())`), but repeating one the alias sets is `PSL_DUPLICATE_ATTRIBUTE`. An alias may not name another alias. The targets contribute `temporal.CreatedAt` (generated on create) and `temporal.UpdatedAt` (on create and on update), written bare: `createdAt temporal.CreatedAt`. On Postgres they are `TimestamptzTemporal` columns, with `temporal.CreatedAtString` / `UpdatedAtString` and `CreatedAtJsDate` / `UpdatedAtJsDate` variants; on SQLite `DateTime`; on Mongo `Date`. They take no arguments; for a precision, write the type and attributes, as in `TimestamptzTemporal(3) @onCreate(now())`. The SQL targets also contribute an alias for the column of each identifier format the client generates: `sql.UuidString`, `sql.Ulid`, `sql.Nanoid`, `sql.Cuid2` and `sql.Ksuid`, which Postgres also names at the top level, as `Ulid`. Each stores `Char(n)` of its format's length and carries no generator, so the key writes `id Ulid @id @onCreate(ulid())` and a foreign key writes `authorId Ulid`. On Postgres, a UUID is better stored in the native column: `id Uuid @id @onCreate(uuid(7))`. +An alias is expanded into each field that uses it: the column stores the alias's codec and type params, and the contract stores no alias. An alias may carry `@default`, `@onCreate` and `@onUpdate`, which every field typed by it takes: `UpdatedAt = TimestampString(3) @onCreate(now()) @onUpdate(now())`. It may not carry `@id` or `@unique`; write those on the field. A field may add attributes its alias does not set (`updatedAt UpdatedAt @default(now())`), but repeating one the alias sets is `PSL_DUPLICATE_ATTRIBUTE`. An alias may not name another alias. The targets contribute `temporal.CreatedAt` (generated on create) and `temporal.UpdatedAt` (on create and on update), written bare: `createdAt temporal.CreatedAt`. On Postgres they are `DateTime` with its defaults, a `timestamptz(3)` column read as a JavaScript `Date`; on SQLite `DateTime`; on Mongo `Date`. They take no arguments; for another kind of value or a precision, write the type and attributes, as in `DateTime(as: string) @onCreate(now())`. The SQL targets also contribute an alias for the column of each identifier format the client generates: `sql.UuidString`, `sql.Ulid`, `sql.Nanoid`, `sql.Cuid2` and `sql.Ksuid`, which Postgres also names at the top level, as `Ulid`. Each stores `Char(n)` of its format's length and carries no generator, so the key writes `id Ulid @id @onCreate(ulid())` and a foreign key writes `authorId Ulid`. On Postgres, a UUID is better stored in the native column: `id Uuid @id @onCreate(uuid(7))`. Values the client generates are written `@onCreate(fn())` and `@onUpdate(fn())`: `id String @id @onCreate(uuid(7))`, `slug String @onCreate(nanoid(12))`, `updatedAt DateTime @onCreate(now()) @onUpdate(now())`. `@default(...)` is a database default only (`now()`, `autoincrement()`, a literal, or `` sql`...` ``); `@default(uuid())` is refused with `PSL_GENERATOR_IN_DEFAULT`. In the TS builder, write `.onCreate(gen.uuid(7))` and `.onUpdate(gen.now())`. Column defaults and generators can sit on one field: `@default(now()) @onCreate(now())`. diff --git a/skills/prisma-8/references/debug.md b/skills/prisma-8/references/debug.md index 5153cc62db05..50878f8ebc7a 100644 --- a/skills/prisma-8/references/debug.md +++ b/skills/prisma-8/references/debug.md @@ -111,7 +111,7 @@ The single source of truth: read the envelope, find the row by `code`, follow th | `BUDGET.ROWS_EXCEEDED` / `BUDGET.TIME_EXCEEDED` | Runtime, when the `budgets` middleware is active | Tune `budgets({ maxRows, maxLatencyMs, ... })` or rewrite the query. See `references/runtime.md`. | | `LINT.SELECT_STAR` / `LINT.NO_LIMIT` / `LINT.DELETE_WITHOUT_WHERE` / `LINT.UPDATE_WITHOUT_WHERE` / `LINT.READ_ONLY_MUTATION` | Runtime, when the `lints` middleware is active | Fix the query (add a `WHERE` / `LIMIT` / explicit columns), or relax the lint config. See `references/runtime.md`. | | `PLAN.HASH_MISMATCH` | Runtime, executing a precompiled plan | The contract the plan was built against does not match the runtime contract. Re-emit, rebuild, redeploy. | -| `RUNTIME.TEMPORAL_UNAVAILABLE` | Runtime, first read or write touching a Temporal-backed column (`DateTemporal`, `TimestampTemporal(p)`, `TimestampInstant(p)`, `TimestamptzTemporal(p)`, `TimeTemporal(p)`, and `Date`, `DateTime` and `Time`) or a `temporal.UpdatedAt` clock | No global `Temporal`. Node.js 26.8.2 and later ship `globalThis.Temporal`; 26.8.1 and earlier — including every 22 and 24 — do not. Either `import 'temporal-polyfill/full/global'` before the first query, or author the column as `DateString` / `TimestampString(p)` / `TimestamptzString(p)` / `TimeString(p)` to read ISO 8601 text. | +| `RUNTIME.TEMPORAL_UNAVAILABLE` | Runtime, first read or write touching a Temporal-backed column (`DateTemporal`, `TimestampTemporal(p)`, `TimestampInstant(p)`, `TimestamptzTemporal(p)`, `TimeTemporal(p)`, and `DateTime`, `Date` or `Time` with `as: temporal`) | No global `Temporal`. Node.js 26.8.2 and later ship `globalThis.Temporal`; 26.8.1 and earlier — including every 22 and 24 — do not. Either `import 'temporal-polyfill/full/global'` before the first query, or write the field as a convenience type at its default (`DateTime`, `Date`, `Time`), which reads a JavaScript `Date` or a string. | | `ORM.RELATION_MUTATION_UNSUPPORTED` | ORM nested `create` / `connect` on an N:M relation whose junction has required payload columns | Write the junction table directly or use the SQL builder (`meta.junction`). | | `RUNTIME.ABORTED` (`details.phase` = `encode\|decode\|stream\|beforeEncode\|onRow`) | Runtime, when an `AbortSignal` fires mid-execute | Cancellation, not a bug; surface to the caller. | | `SqlQueryError` (no dotted code) | Raw-SQL paths surfacing a driver error | Inspect `sqlState` + `constraint` + `table` + `column`. Postgres `23505` = unique violation, `23503` = foreign-key violation, etc. Fix the data or the schema. | diff --git a/skills/prisma-8/references/queries-postgres.md b/skills/prisma-8/references/queries-postgres.md index 236cd7d3f974..4316fcae5974 100644 --- a/skills/prisma-8/references/queries-postgres.md +++ b/skills/prisma-8/references/queries-postgres.md @@ -430,7 +430,7 @@ await db.orm.public.User.with(createdSince(since)).all(); await db.orm.public.Post.with(createdSince(since)).deleteAll(); ``` -Pick the builder whose codec matches the field's codec in `contract.d.ts`: a PSL `DateTime` is `field.column(type.DateTime())` (`pg/timestamptz-temporal@1`), a `String` is `field.column(type.String())` (`pg/text@1`), a `Uuid` is `field.column(type.Uuid())` (`pg/uuid@1`). `field.column(columnType)` is the explicit form. A package that offers a fragment and does not import the facade writes `{ codecId: 'pg/text@1', nullable: false }`, with a codec of the contract. For a list field such as `String[]`, add `.many()` to the builder or `many: { elementNullable: false }` to the object; for a `String?[]`, whose elements may be null, add `.many({ elementsNullable: true })` or `many: { elementNullable: true }`. Without it the declaration does not match a list field, and with it the element nullability must match too. A list of value objects such as `Address[]` is stored as one `jsonb` value and is still a list: `field.column(jsonbColumn).many()`. The body may call `where`, `orderBy`, `limit` and `offset`. The result keeps the collection's class and records the filter, so `update` and `delete` are allowed after a fragment that filters. `updateAll`, `deleteAll` and their `AndCount` forms change every matching row and throw `ORM.ARGUMENT_INVALID` when the chain has a `limit`, `offset`, `cursor`, `distinct` or `distinctOn`, so do not put a limit in a fragment that will be followed by one of them. `update` and `delete` change the one row `first()` returns, so the order, offset, cursor, `distinct` and `distinctOn` choose it, and after `limit(0)` they change nothing and return `null`; `update` with a relation callback throws on an order, a limit, an offset, a cursor, `distinct` or `distinctOn`, because it finds its row by the filter alone. A model without the field, or with the field under another codec or nullability, is a compile error, and a run-time `ORM.FIELD_UNKNOWN`. A custom collection class carries no namespace in its type, so at compile time it matches only a field that every model of that name has, with the same codec and nullability, and its real namespace is checked at run time. The codec, the nullability, whether the field is a list and the nullability of its elements are compared; the column type's parameters are not, so `field.column(type.Char(36))` (`char(36)`) also matches a `char(10)` field. +Pick the builder whose codec matches the field's codec in `contract.d.ts`: a PSL `TimestamptzTemporal` is `field.column(type.TimestamptzTemporal())` (`pg/timestamptz-temporal@1`), a `String` is `field.column(type.String())` (`pg/text@1`), a `Uuid` is `field.column(type.Uuid())` (`pg/uuid@1`). `field.column(columnType)` is the explicit form. A package that offers a fragment and does not import the facade writes `{ codecId: 'pg/text@1', nullable: false }`, with a codec of the contract. For a list field such as `String[]`, add `.many()` to the builder or `many: { elementNullable: false }` to the object; for a `String?[]`, whose elements may be null, add `.many({ elementsNullable: true })` or `many: { elementNullable: true }`. Without it the declaration does not match a list field, and with it the element nullability must match too. A list of value objects such as `Address[]` is stored as one `jsonb` value and is still a list: `field.column(jsonbColumn).many()`. The body may call `where`, `orderBy`, `limit` and `offset`. The result keeps the collection's class and records the filter, so `update` and `delete` are allowed after a fragment that filters. `updateAll`, `deleteAll` and their `AndCount` forms change every matching row and throw `ORM.ARGUMENT_INVALID` when the chain has a `limit`, `offset`, `cursor`, `distinct` or `distinctOn`, so do not put a limit in a fragment that will be followed by one of them. `update` and `delete` change the one row `first()` returns, so the order, offset, cursor, `distinct` and `distinctOn` choose it, and after `limit(0)` they change nothing and return `null`; `update` with a relation callback throws on an order, a limit, an offset, a cursor, `distinct` or `distinctOn`, because it finds its row by the filter alone. A model without the field, or with the field under another codec or nullability, is a compile error, and a run-time `ORM.FIELD_UNKNOWN`. A custom collection class carries no namespace in its type, so at compile time it matches only a field that every model of that name has, with the same codec and nullability, and its real namespace is checked at run time. The codec, the nullability, whether the field is a list and the nullability of its elements are compared; the column type's parameters are not, so `field.column(type.Char(36))` (`char(36)`) also matches a `char(10)` field. For a filter used inside a larger `where`, a plain function of the row works too. Type the field with `CodecField`, the type of any field with that codec: diff --git a/skills/prisma-8/references/quickstart.md b/skills/prisma-8/references/quickstart.md index bfb7a79b1199..9073ccbbd4f6 100644 --- a/skills/prisma-8/references/quickstart.md +++ b/skills/prisma-8/references/quickstart.md @@ -219,7 +219,7 @@ Then run the snippet from *Your first arc* above against the `User` model. When ## Workflow — Brownfield-DB (existing database, no contract) -The concept: against an existing database with no PN contract, `contract infer` walks the live schema (tables, columns, indexes — including expression and partial ones — constraints, and RLS enablement + policies) and writes a PSL contract that describes it. Where authoring would generate a CHECK constraint the database does not carry (the element-non-null check on a list column), infer writes the list with nullable elements (`String?[]`), which derives no such check, so the contract declares exactly what the database enforces. The reverse gap is closed too: a hand-written CHECK constraint the database enforces that authoring would never have generated comes back as `@@check(expression: , map: "")`, so it is a declared object from the first pull instead of an invisible extra a later destructive plan could drop. That inferred check warns (`PN_EXACT_NAME_BODY_COMPARISON`) the next time you run `contract emit` — expected, not a defect: the warning fires on any `map:` body regardless of who wrote it, and the comparison stays sound because both sides are Postgres's own reprint. The result is a *starting point*, not the final contract — review and clean it up, then `db sign` to record the current contract hash as the marker (instead of letting `db init` try to recreate the schema from scratch). By default `db sign` also sets the `db` ref and stores the contract snapshot, so the next `migration plan` chains from the adopted schema — even when the database is named with `--db`. `--advance-ref ` writes another ref instead and `--no-advance-ref` writes none; in either case the next plan needs `--from` or a `db` ref you set yourself. +The concept: against an existing database with no PN contract, `contract infer` walks the live schema (tables, columns, indexes — including expression and partial ones — constraints, and RLS enablement + policies) and writes a PSL contract that describes it. Where authoring would generate a CHECK constraint the database does not carry (the element-non-null check on a list column), infer writes the list with nullable elements (`String?[]`), which derives no such check, so the contract declares exactly what the database enforces. The reverse gap is closed too: a hand-written CHECK constraint the database enforces that authoring would never have generated comes back as `@@check(expression: , map: "")`, so it is a declared object from the first pull instead of an invisible extra a later destructive plan could drop. That inferred check warns (`PN_EXACT_NAME_BODY_COMPARISON`) the next time you run `contract emit` — expected, not a defect: the warning fires on any `map:` body regardless of who wrote it, and the comparison stays sound because both sides are Postgres's own reprint. Date and time columns come back as the convenience types with their defaults, and only the arguments that differ: `timestamptz(3)` as `DateTime` (a JavaScript `Date`), a `timestamptz` with no declared precision as `DateTime(precision: 6)`, `timestamp(3)` as `DateTime(storage: timestamp)`, `date` as `Date`, `time` as `Time` and `interval` as `Duration` (ISO strings). A `Date` holds milliseconds, so a timestamp column whose values use microseconds reads with them dropped: change such a field to `DateTime(as: temporal)` or `DateTime(as: string)` to keep them. The result is a *starting point*, not the final contract — review and clean it up, then `db sign` to record the current contract hash as the marker (instead of letting `db init` try to recreate the schema from scratch). By default `db sign` also sets the `db` ref and stores the contract snapshot, so the next `migration plan` chains from the adopted schema — even when the database is named with `--db`. `--advance-ref ` writes another ref instead and `--no-advance-ref` writes none; in either case the next plan needs `--from` or a `db` ref you set yourself. ```bash mkdir my-app && cd my-app diff --git a/test/integration/test/authoring/attribute-specs.lsp-consumability.test.ts b/test/integration/test/authoring/attribute-specs.lsp-consumability.test.ts index dd6d926c1dcc..9c660e72e13d 100644 --- a/test/integration/test/authoring/attribute-specs.lsp-consumability.test.ts +++ b/test/integration/test/authoring/attribute-specs.lsp-consumability.test.ts @@ -207,7 +207,7 @@ describe('mongo attribute specs are consumable from a resolved language-server p model, field, typeResolution: undefined, - typeConstructor: undefined, + contributedType: undefined, defaultFunctionRegistry: interpretation.context.controlMutationDefaults.defaultFunctionRegistry, generatorFunctions: interpretation.context.generatorFunctions, diff --git a/test/integration/test/authoring/defaults-on-existing-tables.integration.test.ts b/test/integration/test/authoring/defaults-on-existing-tables.integration.test.ts index 61e482f4ceb5..c22ff32e6117 100644 --- a/test/integration/test/authoring/defaults-on-existing-tables.integration.test.ts +++ b/test/integration/test/authoring/defaults-on-existing-tables.integration.test.ts @@ -168,7 +168,7 @@ const POSTGRES_BEFORE = ` model Box { id Int @id tags String[] @default(["x"]) - at DateTime @default(timestamptz\`2020-01-01T00:00:00Z\`) + at TimestamptzTemporal @default(timestamptz\`2020-01-01T00:00:00Z\`) hundreds Numeric(5, -2) @default(100) code Char(5) @default("z") token Uuid @default("11111111-1111-1111-1111-111111111111") @@ -179,7 +179,7 @@ const POSTGRES_AFTER = ` model Box { id Int @id tags String[] @default(["a", "b"]) - at DateTime @default(timestamptz\`2024-01-01T01:00:00+01:00\`) + at TimestamptzTemporal @default(timestamptz\`2024-01-01T01:00:00+01:00\`) hundreds Numeric(5, -2) @default(12300) code Char(5) @default("ab") token Uuid @default("A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11") diff --git a/test/integration/test/authoring/lsp-emit-parity.integration.test.ts b/test/integration/test/authoring/lsp-emit-parity.integration.test.ts index 5c28681651e2..881a9ff7982e 100644 --- a/test/integration/test/authoring/lsp-emit-parity.integration.test.ts +++ b/test/integration/test/authoring/lsp-emit-parity.integration.test.ts @@ -29,7 +29,7 @@ model User { name String? posts Post[] createdAt TimestamptzString @default(now()) - updatedAt temporal.UpdatedAtString + updatedAt TimestamptzString @onCreate(now()) @onUpdate(now()) } model Post { @@ -39,7 +39,7 @@ model Post { author User @relation(fields: [authorId], references: [id]) authorId Int createdAt TimestamptzString @default(now()) - updatedAt temporal.UpdatedAtString + updatedAt TimestamptzString @onCreate(now()) @onUpdate(now()) } `; diff --git a/test/integration/test/authoring/parity/callback-mode-scalars/schema.prisma b/test/integration/test/authoring/parity/callback-mode-scalars/schema.prisma index 8005abf00965..eca801bf9e73 100644 --- a/test/integration/test/authoring/parity/callback-mode-scalars/schema.prisma +++ b/test/integration/test/authoring/parity/callback-mode-scalars/schema.prisma @@ -12,7 +12,7 @@ model User { score Float? profile Jsonb? embedding Embedding? - createdAt DateTime @default(now()) + createdAt TimestamptzTemporal @default(now()) @@map("user") } diff --git a/test/integration/test/authoring/parity/core-surface/schema.prisma b/test/integration/test/authoring/parity/core-surface/schema.prisma index 5407e408d2b0..b15c4a213b28 100644 --- a/test/integration/test/authoring/parity/core-surface/schema.prisma +++ b/test/integration/test/authoring/parity/core-surface/schema.prisma @@ -14,7 +14,7 @@ model User { id Int @id @default(autoincrement()) email Email @unique role Role - createdAt DateTime @default(now()) + createdAt TimestamptzTemporal @default(now()) isActive Boolean @default(true) profile Jsonb? @@map("user") diff --git a/test/integration/test/authoring/parity/default-date-time-types/contract.ts b/test/integration/test/authoring/parity/default-date-time-types/contract.ts index 70f69e8bade1..47019751ddb5 100644 --- a/test/integration/test/authoring/parity/default-date-time-types/contract.ts +++ b/test/integration/test/authoring/parity/default-date-time-types/contract.ts @@ -14,7 +14,7 @@ export const contract = defineContract({}, ({ field, model, type }) => ({ fields: { id: field.column(int4Column).id(), instant: field - .column(type.DateTime()) + .column(type.TimestamptzTemporal()) .default(Temporal.Instant.from('2024-01-01T00:00:00.5Z')), local: field .column(timestampTemporalColumn) diff --git a/test/integration/test/authoring/parity/default-date-time-types/schema.prisma b/test/integration/test/authoring/parity/default-date-time-types/schema.prisma index e5cad60f1dcc..15835e7aa9c1 100644 --- a/test/integration/test/authoring/parity/default-date-time-types/schema.prisma +++ b/test/integration/test/authoring/parity/default-date-time-types/schema.prisma @@ -2,10 +2,10 @@ model T { id Int @id - instant DateTime @default(timestamptz`2024-01-01T01:00:00.500+01:00`) + instant TimestamptzTemporal @default(timestamptz`2024-01-01T01:00:00.500+01:00`) local TimestampTemporal @default(timestamp`2024-01-01 12:34:56.500`) - day Date @default(date`0044-03-15 BC`) - clock Time @default(time`12:34`) + day DateTemporal @default(date`0044-03-15 BC`) + clock TimeTemporal @default(time`12:34`) zoned Timetz @default(timetz`12:34:56+02`) big BigInt @default(9007199254740993) bytes Bytes @default(bytea`\x010203`) diff --git a/test/integration/test/authoring/parity/default-sql-literal/schema.prisma b/test/integration/test/authoring/parity/default-sql-literal/schema.prisma index c2c412f7137c..8141f3abdea7 100644 --- a/test/integration/test/authoring/parity/default-sql-literal/schema.prisma +++ b/test/integration/test/authoring/parity/default-sql-literal/schema.prisma @@ -3,8 +3,8 @@ model T { id String @id @default(sql`gen_random_uuid()`) digest String @default(sql`md5(random()::text)`) - expires DateTime @default(sql"(now() + '00:03:00'::interval)") - createdAt DateTime @default(sql`(now() + interval '1 hour')`) + expires TimestamptzTemporal @default(sql"(now() + '00:03:00'::interval)") + createdAt TimestamptzTemporal @default(sql`(now() + interval '1 hour')`) tags String[] @default(sql`'{}'::text[]`) escaped String @default(sql`E'\n'`) diff --git a/test/integration/test/authoring/parity/generator-on-update-time/schema.prisma b/test/integration/test/authoring/parity/generator-on-update-time/schema.prisma index 8e5eba67ee9c..46bd5d96fd38 100644 --- a/test/integration/test/authoring/parity/generator-on-update-time/schema.prisma +++ b/test/integration/test/authoring/parity/generator-on-update-time/schema.prisma @@ -2,6 +2,6 @@ model Event { id Int @id - seenAt Time(3) @onUpdate(now()) + seenAt TimeTemporal(3) @onUpdate(now()) @@map("event") } diff --git a/test/integration/test/authoring/parity/sql-expressions/schema.prisma b/test/integration/test/authoring/parity/sql-expressions/schema.prisma index 39965a39094d..f2e75f3a904a 100644 --- a/test/integration/test/authoring/parity/sql-expressions/schema.prisma +++ b/test/integration/test/authoring/parity/sql-expressions/schema.prisma @@ -7,7 +7,7 @@ namespace public { title String email String body String - archivedAt DateTime? + archivedAt TimestamptzTemporal? slug String @default(sql` lower( 'post-' || md5(random()::text) diff --git a/test/integration/test/authoring/parity/temporal-type-aliases.real-packs.test.ts b/test/integration/test/authoring/parity/temporal-type-aliases.real-packs.test.ts index 389f96fd032f..ad720fb15a2d 100644 --- a/test/integration/test/authoring/parity/temporal-type-aliases.real-packs.test.ts +++ b/test/integration/test/authoring/parity/temporal-type-aliases.real-packs.test.ts @@ -73,33 +73,25 @@ function succeeded( } describe('the temporal type aliases with real packs', () => { - it('store on Postgres exactly what the matching generator attributes store', () => { + it('store on Postgres exactly what DateTime with the matching generators stores', () => { const throughAliases = succeeded( interpretPostgres(`model Event { - id Int @id - createdAt temporal.CreatedAt - updatedAt temporal.UpdatedAt - createdAtString temporal.CreatedAtString - updatedAtString temporal.UpdatedAtString - createdAtJsDate temporal.CreatedAtJsDate - updatedAtJsDate temporal.UpdatedAtJsDate + id Int @id + createdAt temporal.CreatedAt + updatedAt temporal.UpdatedAt } `), ); const throughAttributes = succeeded( interpretPostgres(`model Event { - id Int @id - createdAt TimestamptzTemporal @onCreate(now()) - updatedAt TimestamptzTemporal @onCreate(now()) @onUpdate(now()) - createdAtString TimestamptzString @onCreate(now()) - updatedAtString TimestamptzString @onCreate(now()) @onUpdate(now()) - createdAtJsDate TimestamptzJsDate @onCreate(now()) - updatedAtJsDate TimestamptzJsDate @onCreate(now()) @onUpdate(now()) + id Int @id + createdAt DateTime @onCreate(now()) + updatedAt DateTime @onCreate(now()) @onUpdate(now()) } `), ); expect(throughAliases).toEqual(throughAttributes); - expect(throughAliases.execution?.mutations.defaults).toHaveLength(6); + expect(throughAliases.execution?.mutations.defaults).toHaveLength(2); }); it('store on SQLite exactly what the matching generator attributes store', () => { @@ -123,7 +115,7 @@ describe('the temporal type aliases with real packs', () => { expect(throughAliases.execution?.mutations.defaults).toHaveLength(2); }); - it('give the TypeScript builder on Postgres what they give PSL', () => { + it('give the TypeScript builder on Postgres what DateTime with the matching generators gives it', () => { const throughAliases = defineContract({}, ({ type, field, model }) => ({ models: { Event: model('Event', { @@ -131,24 +123,28 @@ describe('the temporal type aliases with real packs', () => { id: field.column(type.Int()).id(), createdAt: field.column(type.temporal.CreatedAt()), updatedAt: field.column(type.temporal.UpdatedAt()), - createdAtString: field.column(type.temporal.CreatedAtString()), - updatedAtString: field.column(type.temporal.UpdatedAtString()), - createdAtJsDate: field.column(type.temporal.CreatedAtJsDate()), - updatedAtJsDate: field.column(type.temporal.UpdatedAtJsDate()), }, }).sql({ table: 'Event' }), }, })); + const throughGenerators = defineContract({}, ({ type, field, model, gen }) => ({ + models: { + Event: model('Event', { + fields: { + id: field.column(type.Int()).id(), + createdAt: field.column(type.DateTime()).onCreate(gen.now()), + updatedAt: field.column(type.DateTime()).onCreate(gen.now()).onUpdate(gen.now()), + }, + }).sql({ table: 'Event' }), + }, + })); + expect(throughAliases).toEqual(throughGenerators); expect(throughAliases).toEqual( succeeded( interpretPostgres(`model Event { - id Int @id - createdAt temporal.CreatedAt - updatedAt temporal.UpdatedAt - createdAtString temporal.CreatedAtString - updatedAtString temporal.UpdatedAtString - createdAtJsDate temporal.CreatedAtJsDate - updatedAtJsDate temporal.UpdatedAtJsDate + id Int @id + createdAt temporal.CreatedAt + updatedAt temporal.UpdatedAt } `), ), diff --git a/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts b/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts index 88deffca187a..5b8d8a9e3ec9 100644 --- a/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts +++ b/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts @@ -3,7 +3,10 @@ import sqliteAdapter from '@internal/adapter-sqlite/control'; import pgvectorControl from '@internal/extension-pgvector/control'; import pgvectorPack from '@internal/extension-pgvector/pack'; import sqlFamilyControl from '@internal/family-sql/control'; -import type { AuthoringTypeConstructorDescriptor } from '@internal/framework-components/authoring'; +import { + type AuthoringTypeNamespace, + isAuthoringTypeConstructorDescriptor, +} from '@internal/framework-components/authoring'; import { createControlStack } from '@internal/framework-components/control'; import { defineContract, @@ -70,9 +73,10 @@ const SAMPLE_ARGUMENTS: Readonly> = { length: 10, precisi /** Every PSL type constructor of a namespace, called with no arguments and, when it takes any, with a sample value for each. */ function constructorCalls( - constructors: Readonly>, + constructors: AuthoringTypeNamespace, ): readonly { readonly column: string; readonly name: string; readonly args: readonly number[] }[] { return Object.entries(constructors).flatMap(([name, descriptor]) => { + if (!isAuthoringTypeConstructorDescriptor(descriptor)) return []; const declared = descriptor.args ?? []; const sampled = declared.map((arg) => { const value = arg.name === undefined ? undefined : SAMPLE_ARGUMENTS[arg.name]; @@ -349,6 +353,53 @@ ${calls.map((call) => ` ${call.column} ${pslFieldType(call)}`).join('\n')} expect(interpreted.value).toEqual(tsContract); }); + it('give the same column for each convenience date and time type and the same arguments', () => { + const tsContract = defineContract( + { extensions: { pgvector: pgvectorPack } }, + ({ type, field, model }) => ({ + models: { + Sample: model('Sample', { + fields: { + id: field.column(int4Column).id(), + paid: field.column(type.DateTime()), + settled: field.column(type.DateTime({ as: 'temporal' })), + legacy: field.column( + type.DateTime({ as: 'jsDate', storage: 'timestamp', precision: 6 }), + ), + text: field.column(type.DateTime({ as: 'string', storage: 'timestamp' })), + ship: field.column(type.Date()), + shipT: field.column(type.Date({ as: 'temporal' })), + opens: field.column(type.Time({ precision: 0 })), + local: field.column(type.Time({ as: 'string', storage: 'timetz' })), + period: field.column(type.Duration({ precision: 3 })), + parts: field.column(type.Duration({ as: 'parts' })), + }, + }).sql({ table: 'Sample' }), + }, + }), + ); + + const interpreted = interpretWithRealPacks(`model Sample { + id Int @id + paid DateTime + settled DateTime(as: temporal) + legacy DateTime(jsDate, timestamp, 6) + text DateTime(as: string, storage: timestamp) + ship Date + shipT Date(temporal) + opens Time(precision: 0) + local Time(storage: timetz) + period Duration(precision: 3) + parts Duration(as: parts) +} +`); + + expect(interpreted.ok).toBe(true); + if (!interpreted.ok) return; + + expect(interpreted.value).toEqual(tsContract); + }); + it('give the same column for every SQLite constructor and the same arguments', () => { const calls = constructorCalls(sqliteScalarTypeConstructors); const tsContract = defineSqliteContract({}, ({ type, field, model }) => ({ diff --git a/test/integration/test/authoring/preset-replacements/expected/postgres.contract.json b/test/integration/test/authoring/preset-replacements/expected/postgres.contract.json index f68312cd8fad..b7fcf0a54b81 100644 --- a/test/integration/test/authoring/preset-replacements/expected/postgres.contract.json +++ b/test/integration/test/authoring/preset-replacements/expected/postgres.contract.json @@ -3237,7 +3237,7 @@ "storageHash": "d44d725316bd8067730874483a24dfcaeef20dc26199e383a7b408b314483b10" }, "execution": { - "executionHash": "ceb90e72bf9e61b35dd8432c4b5d055abb98d2f8718136762e9a3e1c488a8e79", + "executionHash": "25bb2ac1c0dec5bb0c70bb42d1fd88c55a14ef0795982ee25194e84990ad77dd", "mutations": { "defaults": [ { @@ -3571,11 +3571,17 @@ { "onCreate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 3 + } }, "onUpdate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "TimestampBothPhases", @@ -3586,7 +3592,10 @@ { "onCreate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "ref": { "entry": "TimestampNamedPrecision", @@ -3611,7 +3620,10 @@ { "onCreate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "TimestamptzJsDateOnCreate", diff --git a/test/integration/test/authoring/preset-replacements/preset-replacement-rows.ts b/test/integration/test/authoring/preset-replacements/preset-replacement-rows.ts index 19c2f101b8b9..21067fdea29c 100644 --- a/test/integration/test/authoring/preset-replacements/preset-replacement-rows.ts +++ b/test/integration/test/authoring/preset-replacements/preset-replacement-rows.ts @@ -272,8 +272,8 @@ export const postgresRows: readonly SqlRow[] = [ }, { model: 'DateTimeColumn', - psl: { preset: 'dateTime()', replacement: 'DateTime' }, - ts: ({ field, type }) => field.column(type.DateTime()), + psl: { preset: 'dateTime()', replacement: 'TimestamptzTemporal' }, + ts: ({ field, type }) => field.column(type.TimestamptzTemporal()), }, { model: 'UuidNative', @@ -294,33 +294,51 @@ export const postgresRows: readonly SqlRow[] = [ }, { model: 'CreatedAt', - psl: { preset: 'temporal.createdAt()', replacement: 'temporal.CreatedAt' }, - ts: ({ field, type }) => field.column(type.temporal.CreatedAt()), + psl: { preset: 'temporal.createdAt()', replacement: 'TimestamptzTemporal @onCreate(now())' }, + ts: ({ field, type, gen }) => field.column(type.TimestamptzTemporal()).onCreate(gen.now()), }, { model: 'UpdatedAt', - psl: { preset: 'temporal.updatedAt()', replacement: 'temporal.UpdatedAt' }, - ts: ({ field, type }) => field.column(type.temporal.UpdatedAt()), + psl: { + preset: 'temporal.updatedAt()', + replacement: 'TimestamptzTemporal @onCreate(now()) @onUpdate(now())', + }, + ts: ({ field, type, gen }) => + field.column(type.TimestamptzTemporal()).onCreate(gen.now()).onUpdate(gen.now()), }, { model: 'CreatedAtString', - psl: { preset: 'temporal.createdAtString()', replacement: 'temporal.CreatedAtString' }, - ts: ({ field, type }) => field.column(type.temporal.CreatedAtString()), + psl: { + preset: 'temporal.createdAtString()', + replacement: 'TimestamptzString @onCreate(now())', + }, + ts: ({ field, type, gen }) => field.column(type.TimestamptzString()).onCreate(gen.now()), }, { model: 'UpdatedAtString', - psl: { preset: 'temporal.updatedAtString()', replacement: 'temporal.UpdatedAtString' }, - ts: ({ field, type }) => field.column(type.temporal.UpdatedAtString()), + psl: { + preset: 'temporal.updatedAtString()', + replacement: 'TimestamptzString @onCreate(now()) @onUpdate(now())', + }, + ts: ({ field, type, gen }) => + field.column(type.TimestamptzString()).onCreate(gen.now()).onUpdate(gen.now()), }, { model: 'CreatedAtJsDate', - psl: { preset: 'temporal.createdAtJsDate()', replacement: 'temporal.CreatedAtJsDate' }, - ts: ({ field, type }) => field.column(type.temporal.CreatedAtJsDate()), + psl: { + preset: 'temporal.createdAtJsDate()', + replacement: 'TimestamptzJsDate @onCreate(now())', + }, + ts: ({ field, type, gen }) => field.column(type.TimestamptzJsDate()).onCreate(gen.now()), }, { model: 'UpdatedAtJsDate', - psl: { preset: 'temporal.updatedAtJsDate()', replacement: 'temporal.UpdatedAtJsDate' }, - ts: ({ field, type }) => field.column(type.temporal.UpdatedAtJsDate()), + psl: { + preset: 'temporal.updatedAtJsDate()', + replacement: 'TimestamptzJsDate @onCreate(now()) @onUpdate(now())', + }, + ts: ({ field, type, gen }) => + field.column(type.TimestamptzJsDate()).onCreate(gen.now()).onUpdate(gen.now()), }, { model: 'TimestampBothPhases', @@ -378,14 +396,14 @@ export const postgresRows: readonly SqlRow[] = [ model: 'TimestamptzJsDateOnCreate', psl: { preset: 'temporal.timestamptzJsDate(3, onCreate: now)', - replacement: 'TimestamptzJsDate(3) @onCreate(now())', + replacement: 'DateTime @onCreate(now())', }, ts: ({ field, type, gen }) => field.column(type.TimestamptzJsDate(3)).onCreate(gen.now()), }, { model: 'DateColumn', - psl: { preset: 'temporal.date(onCreate: now)', replacement: 'Date @onCreate(now())' }, - ts: ({ field, type, gen }) => field.column(type.Date()).onCreate(gen.now()), + psl: { preset: 'temporal.date(onCreate: now)', replacement: 'DateTemporal @onCreate(now())' }, + ts: ({ field, type, gen }) => field.column(type.DateTemporal()).onCreate(gen.now()), }, { model: 'DateStringBothPhases', @@ -398,13 +416,16 @@ export const postgresRows: readonly SqlRow[] = [ }, { model: 'TimeOnUpdate', - psl: { preset: 'temporal.time(3, onUpdate: now)', replacement: 'Time(3) @onUpdate(now())' }, - ts: ({ field, type, gen }) => field.column(type.Time(3)).onUpdate(gen.now()), + psl: { + preset: 'temporal.time(3, onUpdate: now)', + replacement: 'TimeTemporal(3) @onUpdate(now())', + }, + ts: ({ field, type, gen }) => field.column(type.TimeTemporal(3)).onUpdate(gen.now()), }, { model: 'TimeWithoutPrecision', - psl: { preset: 'temporal.time(onCreate: now)', replacement: 'Time @onCreate(now())' }, - ts: ({ field, type, gen }) => field.column(type.Time()).onCreate(gen.now()), + psl: { preset: 'temporal.time(onCreate: now)', replacement: 'TimeTemporal @onCreate(now())' }, + ts: ({ field, type, gen }) => field.column(type.TimeTemporal()).onCreate(gen.now()), }, { model: 'TimeStringOnCreate', @@ -425,16 +446,22 @@ export const postgresRows: readonly SqlRow[] = [ }, { model: 'OptionalCreatedAt', - psl: { preset: 'temporal.createdAt()?', replacement: 'temporal.CreatedAt?' }, - ts: ({ field, type }) => field.column(type.temporal.CreatedAt()).optional(), + psl: { preset: 'temporal.createdAt()?', replacement: 'TimestamptzTemporal? @onCreate(now())' }, + ts: ({ field, type, gen }) => + field.column(type.TimestamptzTemporal()).onCreate(gen.now()).optional(), }, { model: 'MappedUpdatedAt', psl: { preset: 'temporal.updatedAt() @map("updated_at")', - replacement: 'temporal.UpdatedAt @map("updated_at")', + replacement: 'TimestamptzTemporal @onCreate(now()) @onUpdate(now()) @map("updated_at")', }, - ts: ({ field, type }) => field.column(type.temporal.UpdatedAt()).column('updated_at'), + ts: ({ field, type, gen }) => + field + .column(type.TimestamptzTemporal()) + .onCreate(gen.now()) + .onUpdate(gen.now()) + .column('updated_at'), }, { model: 'UpdateOnlyWithOnCreate', diff --git a/test/integration/test/authoring/preset-replacements/preset-replacements.test.ts b/test/integration/test/authoring/preset-replacements/preset-replacements.test.ts index 67b777cfb9e5..1220c7346856 100644 --- a/test/integration/test/authoring/preset-replacements/preset-replacements.test.ts +++ b/test/integration/test/authoring/preset-replacements/preset-replacements.test.ts @@ -76,6 +76,14 @@ function refusalOf(targetCase: TargetCase, row: Row) { }; } +/** The field written with `rewrite` in place of `call` and the `?` or `[]` after it, which the rewrite writes itself. */ +function withRewrite(preset: string, call: string, rewrite: string): string { + const start = preset.indexOf(call); + const end = start + call.length; + const modifier = /^(?:\?|\[\])?/.exec(preset.slice(end))?.[0] ?? ''; + return `${preset.slice(0, start)}${rewrite}${preset.slice(end + modifier.length)}`; +} + function describeTarget>( targetCase: TargetCase, ): void { @@ -91,7 +99,7 @@ function describeTarget>( ); const rewritten = { ...row, - psl: { replacement: preset.replace(call ?? '', rewrite ?? '') }, + psl: { replacement: withRewrite(preset, call ?? '', rewrite ?? '') }, }; expect(await emitted(targetCase, 'psl', [rewritten], 'replacement')).toEqual( await emitted(targetCase, 'psl', [row], 'replacement'), diff --git a/test/integration/test/authoring/preset-replacements/typescript-preset-refusals.test.ts b/test/integration/test/authoring/preset-replacements/typescript-preset-refusals.test.ts index 60ee79eddd78..32a8e0237739 100644 --- a/test/integration/test/authoring/preset-replacements/typescript-preset-refusals.test.ts +++ b/test/integration/test/authoring/preset-replacements/typescript-preset-refusals.test.ts @@ -56,11 +56,24 @@ const generated = ([code = '']) => code.replace('(n)', `(${length})`).replace('gen.fn()', `gen.${generator}`); +/** The column with `precision` for `p`, or without the precision when it is empty: `type.X(p)` for an explicit type, `precision: p` in the options of a type choice. */ +function withPrecision(column: string, precision: string): string { + if (precision !== '') { + return column + .replace('(p)', `(${precision})`) + .replace('precision: p', `precision: ${precision}`); + } + return column + .replace('(p)', '()') + .replace('({ precision: p })', '()') + .replace(', precision: p', ''); +} + /** The column the rewrite names, with `precision` for `p` (`undefined` when the preset took no precision, so the rewrite must name none), and the rewrite's `.onCreate(...)` and `.onUpdate(...)` for the phases the preset set. */ const withPhases = (precision: string | undefined, phases: readonly ('onCreate' | 'onUpdate')[]): Write => ([column = '', ...modifiers]) => { - if (precision === undefined && column.includes('(p)')) { + if (precision === undefined && /\(p\)|precision: p/.test(column)) { throw new Error(`the rewrite ${column} names a precision the preset did not take`); } const modifiersOfPhases = phases.map((phase) => { @@ -68,7 +81,7 @@ const withPhases = if (modifier === undefined) throw new Error(`the rewrite names no .${phase}(...)`); return modifier; }); - return column.replace('(p)', `(${precision ?? ''})`) + modifiersOfPhases.join(''); + return withPrecision(column, precision ?? '') + modifiersOfPhases.join(''); }; const BOTH = ['onCreate', 'onUpdate'] as const; diff --git a/test/integration/test/authoring/side-by-side/postgres/contract.prisma b/test/integration/test/authoring/side-by-side/postgres/contract.prisma index 1a95722b5382..45efed1084c2 100644 --- a/test/integration/test/authoring/side-by-side/postgres/contract.prisma +++ b/test/integration/test/authoring/side-by-side/postgres/contract.prisma @@ -14,7 +14,7 @@ model Post { id Int @id @default(autoincrement()) authorId Int title String - publishedAt DateTime? + publishedAt TimestamptzTemporal? author User @relation(fields: [authorId], references: [id]) @@map("posts") diff --git a/test/integration/test/cli-journeys/control-plane-commands-without-temporal.e2e.test.ts b/test/integration/test/cli-journeys/control-plane-commands-without-temporal.e2e.test.ts index 492da66eb7d3..986aa7605fc3 100644 --- a/test/integration/test/cli-journeys/control-plane-commands-without-temporal.e2e.test.ts +++ b/test/integration/test/cli-journeys/control-plane-commands-without-temporal.e2e.test.ts @@ -43,7 +43,7 @@ const EMIT_SCHEMA = `// use prisma-8 model Event { id Int @id - createdAt DateTime @default(timestamptz\`2024-01-01T00:00:00Z\`) + createdAt TimestamptzTemporal @default(timestamptz\`2024-01-01T00:00:00Z\`) } `; @@ -51,9 +51,9 @@ const ONE_INSTANT_SCHEMA = `// use prisma-8 model Event { id Int @id - a DateTime @default(timestamptz\`2024-01-01T00:00:00Z\`) - b DateTime @default(timestamptz\`2024-01-01T00:00:00.000Z\`) - c DateTime @default(timestamptz\`2024-01-01T01:00:00+01:00\`) + a TimestamptzTemporal @default(timestamptz\`2024-01-01T00:00:00Z\`) + b TimestamptzTemporal @default(timestamptz\`2024-01-01T00:00:00.000Z\`) + c TimestamptzTemporal @default(timestamptz\`2024-01-01T01:00:00+01:00\`) } `; @@ -372,11 +372,9 @@ withTempDir(({ createTempDir }) => { const field = readFileSync(join(ctx.testDir, 'contract.prisma'), 'utf-8') .split('\n') .find((line) => line.trim().startsWith('localAt')); - expect(field?.trim().split(/\s+/)).toEqual([ - 'localAt', - 'TimestampString(3)', - '@default(timestamp`2024-01-01T00:00:00`)', - ]); + expect(field?.trim().replace(/\s+/g, ' ')).toBe( + 'localAt DateTime(storage: timestamp) @default(timestamp`2024-01-01T00:00:00`)', + ); }, timeouts.spinUpPpgDev, ); diff --git a/test/integration/test/cli-journeys/convenience-date-time-types-move.e2e.test.ts b/test/integration/test/cli-journeys/convenience-date-time-types-move.e2e.test.ts new file mode 100644 index 000000000000..138d6d27585f --- /dev/null +++ b/test/integration/test/cli-journeys/convenience-date-time-types-move.e2e.test.ts @@ -0,0 +1,142 @@ +/** + * Journey: a schema that wrote the Temporal types under their explicit names moves its fields to the convenience types' defaults, as the upgrade instruction describes. `Date` and `Time` keep the `date` and `time(0)` columns and change only the codec: `migration plan` writes a migration with no operations, and `db update` applies no operations and moves the marker. `DateTime` makes the `timestamptz` column `timestamptz(3)`, which can round stored values, so both `migration plan` and `db update` ask for `--delete Event.at` before they alter it. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'pathe'; +import stripAnsi from 'strip-ansi'; +import { describe, expect, it } from 'vitest'; +import { withTempDir } from '../utils/cli-test-helpers'; +import { + type JourneyContext, + runContractEmit, + runDbInit, + runDbUpdate, + runDbVerify, + runMigrate, + runMigrationPlan, + setupJourney, + timeouts, + useDevDatabase, +} from '../utils/journey-test-helpers'; + +const schema = (day: string, opens: string, at: string) => `// use prisma-8 + +model Event { + id Int @id + day ${day} + opens ${opens} + at ${at} + + @@map("event") +} +`; + +const EXPLICIT = schema('DateTemporal', 'TimeTemporal(0)', 'TimestamptzTemporal'); +const CODEC_ONLY = schema('Date', 'Time(precision: 0)', 'TimestamptzTemporal'); +const PRECISION = schema('Date', 'Time(precision: 0)', 'DateTime'); + +function output(result: { readonly stdout: string; readonly stderr: string }): string { + return `${stripAnsi(result.stderr)}\n${stripAnsi(result.stdout)}`; +} + +async function emit(ctx: JourneyContext, text: string): Promise { + writeFileSync(join(ctx.testDir, 'contract.prisma'), text, 'utf-8'); + const emitted = await runContractEmit(ctx); + expect(emitted.exitCode, output(emitted)).toBe(0); + const contract: { readonly storage: { readonly storageHash: string } } = JSON.parse( + readFileSync(join(ctx.testDir, 'contract.json'), 'utf-8'), + ); + return contract.storage.storageHash; +} + +async function expectVerified(ctx: JourneyContext): Promise { + const verify = await runDbVerify(ctx); + expect(verify.exitCode, output(verify)).toBe(0); +} + +withTempDir(({ createTempDir }) => { + describe('Journey: moving explicit Temporal fields to the convenience types', () => { + describe('with migrations', () => { + const db = useDevDatabase(); + + it( + 'writes a migration with no operations for a codec-only change, and a lossy-change consent for the precision', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + const explicit = await emit(ctx, EXPLICIT); + const first = await runMigrationPlan(ctx, ['--name', 'initial']); + expect(first.exitCode, output(first)).toBe(0); + const applied = await runMigrate(ctx); + expect(applied.exitCode, output(applied)).toBe(0); + + const codecOnly = await emit(ctx, CODEC_ONLY); + const empty = await runMigrationPlan(ctx, ['--from', explicit, '--name', 'codecs']); + expect(empty.exitCode, output(empty)).toBe(0); + const appliedEmpty = await runMigrate(ctx); + expect(appliedEmpty.exitCode, output(appliedEmpty)).toBe(0); + expect(output(appliedEmpty)).toContain('Applied 1 migration(s) (0 operation(s))'); + await expectVerified(ctx); + + await emit(ctx, PRECISION); + const unconsented = await runMigrationPlan(ctx, ['--from', codecOnly, '--name', 'at']); + expect(unconsented.exitCode).toBe(2); + expect(output(unconsented)).toContain('--delete Event.at'); + const precision = await runMigrationPlan(ctx, [ + '--from', + codecOnly, + '--name', + 'precision', + '--delete', + 'Event.at', + ]); + expect(precision.exitCode, output(precision)).toBe(0); + expect(output(precision)).toContain( + 'ALTER TABLE "public"."event" ALTER COLUMN "at" TYPE timestamptz(3)', + ); + const appliedPrecision = await runMigrate(ctx); + expect(appliedPrecision.exitCode, output(appliedPrecision)).toBe(0); + await expectVerified(ctx); + }, + timeouts.spinUpPpgDev * 3, + ); + }); + + describe('with db update', () => { + const db = useDevDatabase(); + + it( + 'applies no operations for a codec-only change, and alters the precision with --delete', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + await emit(ctx, EXPLICIT); + const init = await runDbInit(ctx); + expect(init.exitCode, output(init)).toBe(0); + + await emit(ctx, CODEC_ONLY); + const codecOnly = await runDbUpdate(ctx); + expect(codecOnly.exitCode, output(codecOnly)).toBe(0); + expect(output(codecOnly)).toContain('(no operations)'); + await expectVerified(ctx); + + await emit(ctx, PRECISION); + const unconsented = await runDbUpdate(ctx); + expect(unconsented.exitCode).toBe(2); + expect(output(unconsented)).toContain('Run the command again with --delete Event.at'); + const precision = await runDbUpdate(ctx, ['--delete', 'Event.at']); + expect(precision.exitCode, output(precision)).toBe(0); + expect(output(precision)).toContain('Alter type of "event"."at" to timestamptz(3)'); + await expectVerified(ctx); + }, + timeouts.spinUpPpgDev * 3, + ); + }); + }); +}); diff --git a/test/integration/test/cli-journeys/convenience-date-time-types-without-temporal.e2e.test.ts b/test/integration/test/cli-journeys/convenience-date-time-types-without-temporal.e2e.test.ts new file mode 100644 index 000000000000..b3169ca39a8b --- /dev/null +++ b/test/integration/test/cli-journeys/convenience-date-time-types-without-temporal.e2e.test.ts @@ -0,0 +1,159 @@ +/** + * Journey: a schema written with the convenience date and time types `DateTime`, `Date`, `Time` and `Duration`, at their defaults and with the choices that need no `Temporal`, emits, creates its table, and is written and read by an application that runs with no global `Temporal`. Each field reads the kind of value its convenience type chooses: a JavaScript `Date` for `DateTime`, a string for `Date`, `Time` and `Duration`, and the `{ months, days, micros }` object for `Duration(as: parts)`. The client generates `updatedAt` with `now()` in that process. + */ +import { writeFileSync } from 'node:fs'; +import { join } from 'pathe'; +import stripAnsi from 'strip-ansi'; +import { describe, expect, it } from 'vitest'; +import { withTempDir } from '../utils/cli-test-helpers'; +import { emittedColumns } from '../utils/emitted-columns'; +import { + type EngineCommandResult, + runContractEmit, + runDbInit, + setupJourney, + timeouts, + useDevDatabase, +} from '../utils/journey-test-helpers'; +import { + childOutput, + NO_GLOBAL_TEMPORAL, + runNodeWithoutTemporal, +} from '../utils/node-without-temporal'; + +const SCHEMA = `// use prisma-8 + +model Order { + id Int @id + paidAt DateTime + legacyAt DateTime(storage: timestamp) + textAt DateTime(as: string, precision: 6) + shipBy Date + opensAt Time(precision: 0) + localAt Time(storage: timetz) + period Duration + parts Duration(as: parts) + settledAt DateTime(as: temporal)? + updatedAt DateTime @onCreate(now()) @onUpdate(now()) + + @@map("order") +} +`; + +const APP_SCRIPT = ` +import { readFileSync } from 'node:fs'; + +const { default: postgres } = await import('@prisma/orm-postgres/runtime'); +const { contractFromJson } = await import('@prisma/orm-postgres/static'); +const db = postgres({ + contract: contractFromJson(JSON.parse(readFileSync('contract.json', 'utf8'))), + url: process.env.JOURNEY_DB_URL, +}); +const described = (row) => + Object.fromEntries( + Object.entries(row).map(([name, value]) => [ + name, + value instanceof Date + ? { kind: 'Date', value: value.toISOString() } + : value !== null && typeof value === 'object' + ? { + kind: 'object', + value: Object.fromEntries(Object.entries(value).map(([k, v]) => [k, String(v)])), + } + : { kind: value === null ? 'null' : typeof value, value }, + ]), + ); +try { + const orders = db.orm.public.Order; + await orders.create({ + id: 1, + paidAt: new Date('2024-02-29T10:00:00.123Z'), + legacyAt: new Date('2024-02-29T10:00:00.5Z'), + textAt: '2024-02-29T10:00:00.123456Z', + shipBy: '2024-02-29', + opensAt: '09:30:00', + localAt: '09:30:00+02:00', + period: 'P1M', + parts: { months: 1, days: 2, micros: 3n }, + settledAt: null, + }); + const read = await orders.where({ id: 1 }).first(); + process.stdout.write(JSON.stringify(described(read))); +} finally { + await db.close(); +} +`; + +const COLUMN_CODECS = { + paidAt: 'pg/timestamptz-date@1', + legacyAt: 'pg/timestamp-date@1', + textAt: 'pg/timestamptz-string@1', + shipBy: 'pg/date-string@1', + opensAt: 'pg/time-string@1', + localAt: 'pg/timetz@1', + period: 'pg/interval-string@1', + parts: 'pg/interval@1', + settledAt: 'pg/timestamptz-temporal@1', + updatedAt: 'pg/timestamptz-date@1', +}; + +function output(run: EngineCommandResult): string { + return `${stripAnsi(run.stderr)}\n${stripAnsi(run.stdout)}`; +} + +withTempDir(({ createTempDir }) => { + describe('Journey: the convenience date and time types in an application with no Temporal', () => { + const db = useDevDatabase(); + + it( + 'emits each field as the explicit type it chooses, and reads the value that type gives', + 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 columns = emittedColumns(ctx.testDir); + expect( + Object.fromEntries( + Object.keys(COLUMN_CODECS).map((name) => [name, columns[name]?.codecId]), + ), + ).toEqual(COLUMN_CODECS); + const init = await runDbInit(ctx); + expect(init.exitCode, `db init\n${output(init)}`).toBe(0); + + const script = join(ctx.testDir, 'app.mjs'); + writeFileSync(script, APP_SCRIPT, 'utf-8'); + const before = Date.now(); + const run = await runNodeWithoutTemporal([script], { + cwd: ctx.testDir, + env: { JOURNEY_DB_URL: db.connectionString }, + }); + const after = Date.now(); + expect(run, childOutput(run)).toMatchObject({ exitCode: 0, ...NO_GLOBAL_TEMPORAL }); + + const read = JSON.parse(run.stdout); + expect(read).toEqual({ + id: { kind: 'number', value: 1 }, + paidAt: { kind: 'Date', value: '2024-02-29T10:00:00.123Z' }, + legacyAt: { kind: 'Date', value: '2024-02-29T10:00:00.500Z' }, + textAt: { kind: 'string', value: '2024-02-29T10:00:00.123456Z' }, + shipBy: { kind: 'string', value: '2024-02-29' }, + opensAt: { kind: 'string', value: '09:30:00' }, + localAt: { kind: 'string', value: '09:30:00+02:00' }, + period: { kind: 'string', value: 'P1M' }, + parts: { kind: 'object', value: { months: '1', days: '2', micros: '3' } }, + settledAt: { kind: 'null', value: null }, + updatedAt: { kind: 'Date', value: read.updatedAt.value }, + }); + const updatedAt = new Date(read.updatedAt.value).getTime(); + expect(updatedAt).toBeGreaterThanOrEqual(before - 1_000); + expect(updatedAt).toBeLessThanOrEqual(after + 1_000); + }, + timeouts.spinUpPpgDev * 2, + ); + }); +}); diff --git a/test/integration/test/cli-journeys/data-type-defaults.e2e.test.ts b/test/integration/test/cli-journeys/data-type-defaults.e2e.test.ts index 648fd0e12113..1feda961f436 100644 --- a/test/integration/test/cli-journeys/data-type-defaults.e2e.test.ts +++ b/test/integration/test/cli-journeys/data-type-defaults.e2e.test.ts @@ -36,7 +36,7 @@ model Account { 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)\`) + expires TimestamptzTemporal @default(sql\`(now() + '3 days'::interval)\`) } `; diff --git a/test/integration/test/cli-journeys/date-time-default-canonical-form.e2e.test.ts b/test/integration/test/cli-journeys/date-time-default-canonical-form.e2e.test.ts index 1b602e624422..f2eb7d0ec2d7 100644 --- a/test/integration/test/cli-journeys/date-time-default-canonical-form.e2e.test.ts +++ b/test/integration/test/cli-journeys/date-time-default-canonical-form.e2e.test.ts @@ -124,14 +124,14 @@ withTempDir(({ createTempDir }) => { const infer = await runContractInfer(ctx); expect(infer.exitCode, `contract infer\n${output(infer)}`).toBe(0); expect(printedDefaultFields(ctx)).toEqual([ - 'a TimestamptzString @default(timestamptz`2024-01-01T00:00:00Z`)', - 'b TimestamptzString @default(timestamptz`2024-01-01T00:00:00Z`)', - 'c TimestamptzString @default(timestamptz`2024-01-01T00:00:00Z`)', - 'clock TimeString @default(time`12:34:00`)', - 'day DateString @default(date`2024-01-01`)', - 'history TimestamptzString[] @default([timestamptz`2024-01-01T00:00:00Z`, timestamptz`2024-06-30T12:34:56Z`])', - 'localAt TimestampString(3) @default(timestamp`2024-01-01T12:34:56.5`)', - 'zoned Timetz @default(timetz`12:34:56+02:00`)', + 'a DateTime(precision: 6) @default(timestamptz`2024-01-01T00:00:00Z`)', + 'b DateTime(precision: 6) @default(timestamptz`2024-01-01T00:00:00Z`)', + 'c DateTime(precision: 6) @default(timestamptz`2024-01-01T00:00:00Z`)', + 'clock Time @default(time`12:34:00`)', + 'day Date @default(date`2024-01-01`)', + 'history DateTime(precision: 6)[] @default([timestamptz`2024-01-01T00:00:00Z`, timestamptz`2024-06-30T12:34:56Z`])', + 'localAt DateTime(storage: timestamp) @default(timestamp`2024-01-01T12:34:56.5`)', + 'zoned Time(storage: timetz) @default(timetz`12:34:56+02:00`)', ]); const emitInferred = await runContractEmit(ctx); @@ -178,8 +178,8 @@ withTempDir(({ createTempDir }) => { model Created { id Int @id - instant DateTime @default(timestamptz\`0044-03-15 00:00:00+00 BC\`) - day Date @default(date\`0044-03-15 BC\`) + instant TimestamptzTemporal @default(timestamptz\`0044-03-15 00:00:00+00 BC\`) + day DateTemporal @default(date\`0044-03-15 BC\`) local TimestampTemporal @default(timestamp\`-000043-03-15T12:00:00\`) @@map("created") @@ -187,8 +187,8 @@ model Created { `; const altered = (defaults: boolean) => `model Altered { id Int @id - instant DateTime ${defaults ? '@default(timestamptz`-000043-03-15T00:00:00Z`)' : ''} - day Date ${defaults ? '@default(date`-000043-03-15`)' : ''} + instant TimestamptzTemporal ${defaults ? '@default(timestamptz`-000043-03-15T00:00:00Z`)' : ''} + day DateTemporal ${defaults ? '@default(date`-000043-03-15`)' : ''} local TimestampTemporal ${defaults ? '@default(timestamp`0044-03-15 12:00:00 BC`)' : ''} @@map("altered") diff --git a/test/integration/test/cli-journeys/defaults-read-by-codec.e2e.test.ts b/test/integration/test/cli-journeys/defaults-read-by-codec.e2e.test.ts index 2d7bfb98127e..1aeaf4758d1a 100644 --- a/test/integration/test/cli-journeys/defaults-read-by-codec.e2e.test.ts +++ b/test/integration/test/cli-journeys/defaults-read-by-codec.e2e.test.ts @@ -37,7 +37,7 @@ model Sample { float8 Float @default(1.5) decimal Decimal @default(1.50) money Numeric(10, 2) @default(3.10) - instant DateTime @default(${tagged('timestamptz', '2024-01-01T00:00:00Z')}) + instant TimestamptzTemporal @default(${tagged('timestamptz', '2024-01-01T00:00:00Z')}) doc Json @default(${tagged('json', '{"a": 1}')}) docb Jsonb @default(${tagged('json', '{"b": [1, 2]}')}) bytes Bytes @default(${tagged('bytea', '\\x6869')}) @@ -46,13 +46,13 @@ model Sample { initials Char(3) @default("ab") local TimestampTemporal(3) @default(${tagged('timestamp', '2024-01-01T12:34:56.5')}) precise TimestamptzTemporal(6) @default(${tagged('timestamptz', '2024-01-01T00:00:00.123456Z')}) - clock Time @default(${tagged('time', '12:34:56')}) + clock TimeTemporal @default(${tagged('time', '12:34:56')}) zoned Timetz @default(${tagged('timetz', '12:34:56+02:00')}) token Uuid @default("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") host Inet @default("10.0.0.1/32") small SmallInt @default(7) ratio Real @default(2.5) - day Date @default(${tagged('date', '2024-01-01')}) + day DateTemporal @default(${tagged('date', '2024-01-01')}) dayText DateString @default(${tagged('date', '2024-01-01')}) localText TimestampString @default(${tagged('timestamp', '2024-01-01T00:00:00')}) jsDate TimestamptzJsDate @default(${tagged('timestamptz', '2024-01-01T00:00:00.5Z')}) @@ -72,8 +72,8 @@ model Sample { model Edge { id Int @id - dayBc Date @default(${tagged('date', '0044-03-15 BC')}) - dayFar Date @default(${tagged('date', '+012026-01-02')}) + dayBc DateTemporal @default(${tagged('date', '0044-03-15 BC')}) + dayFar DateTemporal @default(${tagged('date', '+012026-01-02')}) localBc TimestampTemporal(3) @default(${tagged('timestamp', '0044-03-15 12:00:00 BC')}) instantFar TimestamptzTemporal(6) @default(${tagged('timestamptz', '+012026-01-02T03:04:05Z')}) forever TimestamptzString @default(${tagged('timestamptz', 'infinity')}) diff --git a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.brace-array-defaults.e2e.test.ts b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.brace-array-defaults.e2e.test.ts index a2c9159fe933..cf4e40fee763 100644 --- a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.brace-array-defaults.e2e.test.ts +++ b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.brace-array-defaults.e2e.test.ts @@ -69,7 +69,7 @@ withTempDir(({ createTempDir }) => { flags Boolean?[]? @default([true, false]) roles pg.enum(Role)?[]? @default(["USER"]) names VarChar?[]? @default(["x"]) - days DateString?[]? @default([date\`2024-01-01\`]) + days Date?[]? @default([date\`2024-01-01\`]) @@map("brace_defaults") } 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 3827eaa9d92f..4dd442c202f8 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 @@ -206,38 +206,38 @@ withTempDir(({ createTempDir }) => { } model NumberDefaults { - id Int @id(map: "number_defaults_pkey") - negInt Int @default(-1) - negSmallInt SmallInt @default(-2) - 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.5) - 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 TimestampString(3) @default(timestamp\`2024-01-01T00:00:00\`) - jsonNull Jsonb? @default(json\`null\`) + id Int @id(map: "number_defaults_pkey") + negInt Int @default(-1) + negSmallInt SmallInt @default(-2) + 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.5) + 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 DateTime(storage: timestamp) @default(timestamp\`2024-01-01T00:00:00\`) + jsonNull Jsonb? @default(json\`null\`) @@map("number_defaults") } model SqlDefaults { - id Int @id(map: "sql_defaults_pkey") - textNull VarChar(32)? @default(sql\`NULL::character varying\`) - floatNaN Float @default(NaN) - floatNegInf Float @default(-Infinity) - realNaN Real @default(NaN) - decimalNaN Numeric @default(NaN) - timeWithZone Timetz @default(timetz\`12:34:56Z\`) - stampInfinity TimestampString(3) @default(timestamp\`infinity\`) - dayNegInfinity DateString @default(date\`-infinity\`) - instantBc TimestamptzString(6) @default(timestamptz\`-000043-03-15T00:00:00Z\`) + id Int @id(map: "sql_defaults_pkey") + textNull VarChar(32)? @default(sql\`NULL::character varying\`) + floatNaN Float @default(NaN) + floatNegInf Float @default(-Infinity) + realNaN Real @default(NaN) + decimalNaN Numeric @default(NaN) + timeWithZone Time(storage: timetz) @default(timetz\`12:34:56Z\`) + stampInfinity DateTime(as: string, storage: timestamp, precision: 3) @default(timestamp\`infinity\`) + dayNegInfinity Date @default(date\`-infinity\`) + instantBc DateTime(precision: 6) @default(timestamptz\`-000043-03-15T00:00:00Z\`) @@map("sql_defaults") } @@ -305,7 +305,7 @@ withTempDir(({ createTempDir }) => { }); it( - 'infer prints each element as the string its codec reads, which emit accepts', + 'infer prints each element as the literal its codec reads, which emit accepts', async () => { const ctx = setupJourney({ connectionString: db.connectionString, @@ -318,8 +318,8 @@ withTempDir(({ createTempDir }) => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. model RawListDefaults { - id Int @id(map: "raw_list_defaults_pkey") - timestamps TimestampString(3)?[]? @default([timestamp\`2024-01-01T00:00:00\`]) + id Int @id(map: "raw_list_defaults_pkey") + timestamps DateTime(storage: timestamp)?[]? @default([timestamp\`2024-01-01T00:00:00\`]) @@map("raw_list_defaults") } diff --git a/test/integration/test/cli-journeys/prisma7-dates-without-temporal.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-dates-without-temporal.e2e.test.ts index 91f48629d210..fdbc27bb9c4e 100644 --- a/test/integration/test/cli-journeys/prisma7-dates-without-temporal.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-dates-without-temporal.e2e.test.ts @@ -77,6 +77,15 @@ registerHooks({ const { default: postgres } = await import('@prisma/orm-postgres/runtime'); const { contractFromJson } = await import('@prisma/orm-postgres/static'); +const dateFields = ['createdAt', 'updatedAt', 'happensAt']; +const withDates = (values) => + Object.fromEntries( + Object.entries(values).map(([name, value]) => [ + name, + dateFields.includes(name) ? new Date(value) : value, + ]), + ); +const readsDates = (row) => dateFields.every((name) => row[name] instanceof Date); const writes = JSON.parse(process.env.APP_WRITES); const db = postgres({ contract: contractFromJson(JSON.parse(readFileSync('contract.json', 'utf8'))), @@ -84,10 +93,10 @@ const db = postgres({ }); try { const events = db.orm.public.Event; - const created = await events.create(writes.create); + const created = await events.create(withDates(writes.create)); const readAfterCreate = await events.where({ id: created.id }).first(); await new Promise((resolve) => setTimeout(resolve, 20)); - const updated = await events.where({ id: created.id }).update(writes.update); + const updated = await events.where({ id: created.id }).update(withDates(writes.update)); const readAfterUpdate = await events.where({ id: created.id }).first(); const [session] = await db.query( db.raw.sql\`SELECT EXTRACT(TIMEZONE FROM CURRENT_TIMESTAMP)::int AS "utcOffsetSeconds"\` @@ -100,6 +109,7 @@ try { readAfterCreate, updated, readAfterUpdate, + readsDates: [created, readAfterCreate, updated, readAfterUpdate].every(readsDates), sessionUtcOffsetSeconds: session.utcOffsetSeconds, temporalPolyfill: [...loaded].filter((url) => url.includes('temporal-polyfill')), timezoneOffset: new Date().getTimezoneOffset(), @@ -114,16 +124,16 @@ const TOKYO_UTC_OFFSET_SECONDS = 9 * 60 * 60; const TOKYO_UTC_OFFSET_MILLIS = TOKYO_UTC_OFFSET_SECONDS * 1000; const DATE_COLUMN_CODECS = { - createdAt: 'pg/timestamp-string@1', - updatedAt: 'pg/timestamp-string@1', - happensAt: 'pg/timestamptz-string@1', + createdAt: 'pg/timestamp-date@1', + updatedAt: 'pg/timestamp-date@1', + happensAt: 'pg/timestamptz-date@1', day: 'pg/date-string@1', opensAt: 'pg/time-string@1', }; -/** The written values, each in its ISO 8601 stored form, so a read returns it unchanged. */ +/** The written values, the timestamps as the JSON text of the JavaScript `Date` the application writes and reads, the others in their ISO 8601 stored form, so a read returns each unchanged. */ const WRITTEN = { - happensAt: '2024-01-15T10:00:00.123456Z', + happensAt: '2024-01-15T10:00:00.123Z', day: '2024-01-15', opensAt: '08:30:15.25', }; @@ -143,6 +153,7 @@ interface AppResult { readonly readAfterCreate: EventRow; readonly updated: EventRow; readonly readAfterUpdate: EventRow; + readonly readsDates: boolean; readonly sessionUtcOffsetSeconds: number; readonly temporalPolyfill: readonly string[]; readonly timezoneOffset: number; @@ -152,8 +163,9 @@ function output(run: EngineCommandResult): string { return `${stripAnsi(run.stderr)}\n${stripAnsi(run.stdout)}`; } +/** The instant of a `Date`'s JSON text, or of the ISO 8601 text of a `timestamp`, read as UTC. */ function utcMillis(timestampText: string): number { - return new Date(`${timestampText}Z`).getTime(); + return new Date(timestampText.endsWith('Z') ? timestampText : `${timestampText}Z`).getTime(); } function expectNow(timestampText: string, before: number, after: number): void { @@ -183,6 +195,7 @@ async function runApp( expect(run, childOutput(run)).toMatchObject({ exitCode: 0, ...NO_GLOBAL_TEMPORAL }); const result: AppResult = JSON.parse(run.stdout); expect(result.temporalPolyfill).toEqual([]); + expect(result.readsDates, 'every timestamp reads as a JavaScript Date').toBe(true); expect(result.timezoneOffset, 'the child runs at UTC+3').toBe(-180); expect( result.sessionUtcOffsetSeconds, @@ -191,7 +204,7 @@ async function runApp( return { result, before, after }; } -function expectStringCodecs(ctx: JourneyContext): void { +function expectDateCodecs(ctx: JourneyContext): void { const columns = emittedColumns(ctx.testDir); expect( Object.fromEntries( @@ -236,20 +249,20 @@ withTempDir(({ createTempDir }) => { expect(infer.exitCode, `contract infer\n${output(infer)}`).toBe(0); const emit = await runContractEmit(ctx); expect(emit.exitCode, `contract emit\n${output(emit)}`).toBe(0); - expectStringCodecs(ctx); + expectDateCodecs(ctx); const sign = await runDbSign(ctx); expect(sign.exitCode, `db sign\n${output(sign)}`).toBe(0); const { result, before, after } = await runApp(ctx, db.connectionString, { - create: { title: 'a', updatedAt: '2024-01-15T09:00:00.5', ...WRITTEN }, - update: { title: 'b', updatedAt: '2024-01-15T11:00:00.25' }, + create: { title: 'a', updatedAt: '2024-01-15T09:00:00.500Z', ...WRITTEN }, + update: { title: 'b', updatedAt: '2024-01-15T11:00:00.250Z' }, }); expect(result.created).toEqual({ id: result.created.id, title: 'a', createdAt: result.created.createdAt, - updatedAt: '2024-01-15T09:00:00.5', + updatedAt: '2024-01-15T09:00:00.500Z', ...WRITTEN, }); expectNow(result.created.createdAt, before, after); @@ -257,7 +270,7 @@ withTempDir(({ createTempDir }) => { expect(result.updated).toEqual({ ...result.created, title: 'b', - updatedAt: '2024-01-15T11:00:00.25', + updatedAt: '2024-01-15T11:00:00.250Z', }); expect(result.readAfterUpdate).toEqual(result.updated); }, @@ -276,7 +289,7 @@ withTempDir(({ createTempDir }) => { const ctx = setupPrisma7Project(createTempDir, db.connectionString); const emit = await runContractEmit(ctx); expect(emit.exitCode, `contract emit\n${output(emit)}`).toBe(0); - expectStringCodecs(ctx); + expectDateCodecs(ctx); const sign = await runDbSign(ctx); expect(sign.exitCode, `db sign\n${output(sign)}`).toBe(0); diff --git a/test/integration/test/cli-journeys/sql-default-stored-form.e2e.test.ts b/test/integration/test/cli-journeys/sql-default-stored-form.e2e.test.ts index b6376fa3bbbe..5abae4545cca 100644 --- a/test/integration/test/cli-journeys/sql-default-stored-form.e2e.test.ts +++ b/test/integration/test/cli-journeys/sql-default-stored-form.e2e.test.ts @@ -71,7 +71,7 @@ function writeContract(ctx: JourneyContext, text: string): void { const cases = [ { name: 'an interval', - type: 'DateTime', + type: 'TimestamptzTemporal', written: "sql`now() - interval '1 day'`", stored: "(now() - '1 day'::interval)", literal: "sql`(now() - '1 day'::interval)`", @@ -158,7 +158,7 @@ withTempDir(({ createTempDir }) => { describe('two pending migrations, the first of which writes the default', () => { const db = useDevDatabase(); - const withTag = (literal: string) => `${schema('DateTime', literal)} + const withTag = (literal: string) => `${schema('TimestamptzTemporal', literal)} model Tag { id Int @id } @@ -172,7 +172,7 @@ model Tag { createTempDir, contractMode: 'psl', }); - writeContract(ctx, schema('DateTime', "sql`now() - interval '1 day'`")); + writeContract(ctx, schema('TimestamptzTemporal', "sql`now() - interval '1 day'`")); expect((await runContractEmit(ctx)).exitCode).toBe(0); expect((await planMigrationAndSelfEmit(ctx, ['--name', 'first'])).exitCode).toBe(0); const firstHash = storageHash(ctx); @@ -230,7 +230,7 @@ model Tag { createTempDir, contractMode: 'psl', }); - writeContract(ctx, schema('DateTime', "sql`now() - interval '1 day'`")); + writeContract(ctx, schema('TimestamptzTemporal', "sql`now() - interval '1 day'`")); expect((await runContractEmit(ctx)).exitCode).toBe(0); const failed = await runDbInit(ctx); const shown = shownOutput(failed); @@ -254,7 +254,7 @@ model Tag { createTempDir, contractMode: 'psl', }); - writeContract(ctx, schema('DateTime', "sql`now() - interval '1 day'`")); + writeContract(ctx, schema('TimestamptzTemporal', "sql`now() - interval '1 day'`")); expect((await runContractEmit(ctx)).exitCode).toBe(0); const failed = await runDbUpdate(ctx); const shown = shownOutput(failed); @@ -279,7 +279,7 @@ model Tag { createTempDir, contractMode: 'psl', }); - writeContract(ctx, schema('DateTime', "sql`(now() + '1 day'::interval)`")); + writeContract(ctx, schema('TimestamptzTemporal', "sql`(now() + '1 day'::interval)`")); expect((await runContractEmit(ctx)).exitCode).toBe(0); expect((await planMigrationAndSelfEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); expect((await runMigrate(ctx)).exitCode).toBe(0); diff --git a/test/integration/test/cli.init-templates.e2e.test.ts b/test/integration/test/cli.init-templates.e2e.test.ts index c0f03c960027..e847f0882263 100644 --- a/test/integration/test/cli.init-templates.e2e.test.ts +++ b/test/integration/test/cli.init-templates.e2e.test.ts @@ -120,8 +120,8 @@ const CELLS: ReadonlyArray<{ target: 'postgres', authoring: 'psl', hashes: { - storageHash: 'a51b4833fdaffe730572670ccc27dd89af7e0726ab3de7f428d21702d687cc15', - executionHash: '3730158042e7c88d23f35529e89ad90532db0cd5c528c13e701f93cb45aa8a9c', + storageHash: '920a87b50729a867e21706dd0fa5c3739e273a84ed89eb2f3525633b2d6ef85c', + executionHash: '0ff7f5c55037616d53a67c7c581c1178658b41c7a674a85dd59fc3b8f9dd8a49', profileHash: POSTGRES_PROFILE_HASH, }, }, @@ -129,8 +129,8 @@ const CELLS: ReadonlyArray<{ target: 'postgres', authoring: 'typescript', hashes: { - storageHash: 'cc9d4ebfe093981ab7ea8fdc2332c00214f9c7d0267e723ca3983ffb5e68cbe6', - executionHash: '91162cb5749f09907ced481b9863bd25166b8a213775335fede5c23e58eb135c', + storageHash: 'd901307c194c817cfa0a28ababa391bd1b2342a2ebb5f8ac3280534f2295f96f', + executionHash: 'c478b201602051bfaeddbe71eb44664a34764bf959a3d00c206e7dcf71393533', profileHash: POSTGRES_PROFILE_HASH, }, }, diff --git a/test/integration/test/contract-builder.types.test-d.ts b/test/integration/test/contract-builder.types.test-d.ts index c519af348d9b..f11239e7826b 100644 --- a/test/integration/test/contract-builder.types.test-d.ts +++ b/test/integration/test/contract-builder.types.test-d.ts @@ -212,7 +212,7 @@ test('integrated callback authoring exposes composition-shaped type helpers', () pgvector: pgvectorPack, }, }, - ({ type, field, model }) => { + ({ type, field, model, gen }) => { const Embedding = type.pgvector.Vector(1536); expectTypeOf(Embedding.codecId).toEqualTypeOf<'pg/vector@1'>(); @@ -229,7 +229,7 @@ test('integrated callback authoring exposes composition-shaped type helpers', () score: field.column(type.Float()).optional(), profile: field.column(type.Jsonb()).optional(), embedding: field.column(Embedding).optional(), - createdAt: field.column(type.temporal.CreatedAt()), + createdAt: field.column(type.TimestamptzTemporal()).onCreate(gen.now()), }, }).sql({ table: 'user', diff --git a/test/integration/test/contract-parity/fixtures/postgres/contract.ts b/test/integration/test/contract-parity/fixtures/postgres/contract.ts index 465b4279205b..a366b949b8bb 100644 --- a/test/integration/test/contract-parity/fixtures/postgres/contract.ts +++ b/test/integration/test/contract-parity/fixtures/postgres/contract.ts @@ -2,7 +2,11 @@ * The type constructors, id generators, parameterized codecs and relation shapes the planner golden fixture leaves out, so the parity test compares them with their emitted types. */ -import { int4Column, textColumn } from '@internal/adapter-postgres/column-types'; +import { + int4Column, + textColumn, + timestamptzTemporalColumn, +} from '@internal/adapter-postgres/column-types'; import { arktypeJson } from '@internal/extension-arktype-json/column-types'; import arktypeJsonPack from '@internal/extension-arktype-json/pack'; import { @@ -54,8 +58,8 @@ const User = model('User', { shortNanoid: field.column(type.Char(10)), cuid2: field.column(type.Char(24)), ksuid: field.column(type.Char(27)), - createdAt: field.column(type.temporal.CreatedAt()), - updatedAt: field.column(type.temporal.UpdatedAt()), + createdAt: field.column(timestamptzTemporalColumn).onCreate(gen.now()), + updatedAt: field.column(timestamptzTemporalColumn).onCreate(gen.now()).onUpdate(gen.now()), timestamp: field.column(type.TimestampTemporal(3)), timestamptz: field.column(type.TimestamptzTemporal(6)).onCreate(gen.now()), timestampString: field.column(type.TimestampString(3)), diff --git a/test/integration/test/contract-parity/fixtures/postgres/generated/contract.d.ts b/test/integration/test/contract-parity/fixtures/postgres/generated/contract.d.ts index fdd9f50e7a46..d31f5f821140 100644 --- a/test/integration/test/contract-parity/fixtures/postgres/generated/contract.d.ts +++ b/test/integration/test/contract-parity/fixtures/postgres/generated/contract.d.ts @@ -26,7 +26,7 @@ import type { export type StorageHash = StorageHashBase<'4e5aa929c76c9181dd640f59692e6743a0626b5ab9023a02e5960ec96509c225'>; export type ExecutionHash = - ExecutionHashBase<'3212cd023cdce9916f261da80be0ba8357cd4263dde560c3b8164673d80e9d4b'>; + ExecutionHashBase<'4a96a7146e13898bf97aa37dba3cc7c9cae8e2599993a25be7fe3bdb78b45b9e'>; export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; @@ -2244,7 +2244,11 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'instantNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; readonly ref: { readonly entry: 'users'; readonly field: 'timestamptz'; diff --git a/test/integration/test/contract-parity/fixtures/postgres/generated/contract.json b/test/integration/test/contract-parity/fixtures/postgres/generated/contract.json index 230eecb4ff9a..c6e1edf26ec2 100644 --- a/test/integration/test/contract-parity/fixtures/postgres/generated/contract.json +++ b/test/integration/test/contract-parity/fixtures/postgres/generated/contract.json @@ -2127,7 +2127,7 @@ "storageHash": "4e5aa929c76c9181dd640f59692e6743a0626b5ab9023a02e5960ec96509c225" }, "execution": { - "executionHash": "3212cd023cdce9916f261da80be0ba8357cd4263dde560c3b8164673d80e9d4b", + "executionHash": "4a96a7146e13898bf97aa37dba3cc7c9cae8e2599993a25be7fe3bdb78b45b9e", "mutations": { "defaults": [ { @@ -2345,7 +2345,10 @@ { "onCreate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "ref": { "entry": "users", diff --git a/test/integration/test/control-plane-libraries-without-temporal.e2e.test.ts b/test/integration/test/control-plane-libraries-without-temporal.e2e.test.ts index ddeb3f4b3668..b83f1a85afb2 100644 --- a/test/integration/test/control-plane-libraries-without-temporal.e2e.test.ts +++ b/test/integration/test/control-plane-libraries-without-temporal.e2e.test.ts @@ -30,7 +30,7 @@ const SCHEMA = `// use prisma-8 model Event { id Int @id - createdAt DateTime @default(timestamptz\`2024-01-01T00:00:00Z\`) + createdAt TimestamptzTemporal @default(timestamptz\`2024-01-01T00:00:00Z\`) @@map("event") } diff --git a/test/integration/test/convenience-date-time-types/convenience-types.integration.test.ts b/test/integration/test/convenience-date-time-types/convenience-types.integration.test.ts new file mode 100644 index 000000000000..0744ad17bfc6 --- /dev/null +++ b/test/integration/test/convenience-date-time-types/convenience-types.integration.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { readPsl } from '../psl-print/print-and-read-back'; + +const model = (...fields: readonly string[]) => + [ + '// use prisma-8', + '', + 'model Order {', + ' id Int @id', + ...fields.map((f) => ` ${f}`), + '}', + '', + ].join('\n'); + +const rows: readonly (readonly [convenience: string, explicit: string])[] = [ + ['DateTime', 'TimestamptzJsDate(3)'], + ['DateTime(as: temporal)', 'TimestamptzTemporal'], + ['DateTime(as: string)', 'TimestamptzString'], + ['DateTime(storage: timestamp)', 'TimestampJsDate(3)'], + ['DateTime(as: temporal, storage: timestamp)', 'TimestampInstant'], + ['DateTime(as: string, storage: timestamp)', 'TimestampString'], + ['DateTime(as: string, precision: 6)', 'TimestamptzString(6)'], + ['DateTime(precision: 6)', 'TimestamptzJsDate(6)'], + ['DateTime(temporal, timestamptz, 3)', 'TimestamptzTemporal(3)'], + ['Date', 'DateString'], + ['Date(as: temporal)', 'DateTemporal'], + ['Time', 'TimeString'], + ['Time(precision: 0)', 'TimeString(0)'], + ['Time(as: temporal)', 'TimeTemporal'], + ['Time(storage: timetz)', 'Timetz'], + ['Duration', 'IntervalString'], + ['Duration(precision: 3)', 'IntervalString(3)'], + ['Duration(as: parts)', 'IntervalParts'], + ['DateTime[]', 'TimestamptzJsDate(3)[]'], + ['DateTime?', 'TimestamptzJsDate(3)?'], +]; + +describe('a convenience date and time type', () => { + it.each(rows)('%s emits the contract %s emits', async (convenience, explicit) => { + expect(await readPsl(model(`at ${convenience}`))).toEqual( + await readPsl(model(`at ${explicit}`)), + ); + }); + + it('chooses the generator now() writes for the type it becomes', async () => { + expect( + await readPsl( + model( + 'createdAt DateTime @onCreate(now())', + 'updatedAt DateTime @onCreate(now()) @onUpdate(now())', + 'day Date @onCreate(now())', + 'clock Time(precision: 0) @onCreate(now())', + 'instant DateTime(as: temporal) @onCreate(now())', + ), + ), + ).toEqual( + await readPsl( + model( + 'createdAt TimestamptzJsDate(3) @onCreate(now())', + 'updatedAt TimestamptzJsDate(3) @onCreate(now()) @onUpdate(now())', + 'day DateString @onCreate(now())', + 'clock TimeString(0) @onCreate(now())', + 'instant TimestamptzTemporal @onCreate(now())', + ), + ), + ); + }); + + it('reads a @default as the type it becomes, through a types {} alias too', async () => { + const convenience = `// use prisma-8 + +types { + Stamp = DateTime(storage: timestamp) @default(timestamp\`2024-01-01 00:00:00\`) +} + +model Order { + id Int @id + placed Stamp + day Date @default(date\`2024-02-29\`) +} +`; + expect(await readPsl(convenience)).toEqual( + await readPsl( + model( + 'placed TimestampJsDate(3) @default(timestamp`2024-01-01 00:00:00`)', + 'day DateString @default(date`2024-02-29`)', + ), + ), + ); + }); + + it('refuses a default of another date and time type, naming the column’s', async () => { + await expect( + readPsl(model('at DateTime(storage: timestamp) @default(timestamptz`2024-01-01T00:00:00Z`)')), + ).rejects.toThrow(/PSL_VALUE_TYPE_INCOMPATIBLE.*Expected timestamp`\.\.\.`/); + }); + + it.each([ + [ + 'Time(as: temporal, storage: timetz)', + 'Argument \\"storage\\" of Time cannot be timetz with as: temporal; write one of: time.', + ], + ['Date(as: jsDate)', 'Argument \\"as\\" of Date is jsDate; write one of: string, temporal.'], + [ + 'Duration(as: parts, storage: date)', + 'Argument \\"storage\\" of Duration is date; write one of: interval.', + ], + ])('refuses %s at the argument, naming the accepted choices', async (written, message) => { + await expect(readPsl(model(`at ${written}`))).rejects.toThrow(message); + }); + + it('refuses a precision written in the place of the value option, saying to write it by name', async () => { + await expect(readPsl(model('at Time(3)'))).rejects.toThrow( + 'Field \\"Order.at\\" type \\"Time\\" argument #1 is 3, but argument \\"as\\" takes one of these names, written without quotes: string, temporal. Write precision by name, as in precision: 3.', + ); + }); + + it('refuses a precision the type it becomes does not take, naming the arguments it takes', async () => { + await expect(readPsl(model('at Date(precision: 3)'))).rejects.toThrow( + 'Field \\"Order.at\\" type \\"Date\\" has no argument \\"precision\\"; its arguments are as and storage.', + ); + }); +}); diff --git a/test/integration/test/convenience-date-time-types/sub-millisecond-defaults.test.ts b/test/integration/test/convenience-date-time-types/sub-millisecond-defaults.test.ts new file mode 100644 index 000000000000..46d9308c32ea --- /dev/null +++ b/test/integration/test/convenience-date-time-types/sub-millisecond-defaults.test.ts @@ -0,0 +1,62 @@ +import { defineContract } from '@internal/postgres/contract-builder'; +import { describe, expect, it } from 'vitest'; +import { authorSqlContractFromPsl } from '../scalar-lists/psl-list-authoring'; + +const MICROSECONDS = '2024-01-01T01:00:00.123456Z'; + +describe('a default with digits below a millisecond on a field read as a Date', () => { + it('is refused in PSL, naming the types that hold it', async () => { + const authored = await authorSqlContractFromPsl(`model Event { + id Int @id + at DateTime(precision: 6) @default(timestamptz\`${MICROSECONDS}\`) +}`); + expect(authored.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_DEFAULT_LITERAL', + message: expect.stringContaining('TimestamptzString'), + }), + ]); + }); + + it('cannot be written in the TypeScript builder, whose Date default is stored at the millisecond', () => { + const contract = defineContract({}, ({ type, field, model }) => ({ + models: { + Event: model('Event', { + fields: { + id: field.column(type.Int()).id(), + at: field + .column(type.DateTime({ precision: 6 })) + .default(new Date('2024-01-01T01:00:00.123Z')), + }, + }).sql({ table: 'event' }), + }, + })); + expect( + contract.storage.namespaces['public']?.entries.table?.['event']?.columns['at']?.default, + ).toEqual({ + kind: 'literal', + value: '2024-01-01T01:00:00.123Z', + }); + }); + + it('in a list, is reported at that element, though a later element is refused too', async () => { + const authored = await authorSqlContractFromPsl(`model Event { + id Int @id + at DateTime(precision: 6)[] @default([timestamptz\`2024-01-01T01:00:00.123Z\`, timestamptz\`${MICROSECONDS}\`, "soon"]) +}`); + expect(authored.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_DEFAULT_LITERAL', + message: expect.stringMatching(/^Field "Event\.at" at element 2: .*TimestamptzString/), + }), + ]); + }); + + it('is accepted at a millisecond', async () => { + const authored = await authorSqlContractFromPsl(`model Event { + id Int @id + at DateTime(precision: 6) @default(timestamptz\`2024-01-01T01:00:00.123Z\`) +}`); + expect(authored.diagnostics).toEqual([]); + }); +}); diff --git a/test/integration/test/date-time-defaults/_fixture-before-canonical-form/contract-with-tags.prisma b/test/integration/test/date-time-defaults/_fixture-before-canonical-form/contract-with-tags.prisma index a188c9678979..9d2c9a0186d9 100644 --- a/test/integration/test/date-time-defaults/_fixture-before-canonical-form/contract-with-tags.prisma +++ b/test/integration/test/date-time-defaults/_fixture-before-canonical-form/contract-with-tags.prisma @@ -2,14 +2,14 @@ model Event { id Int @id - a DateTime @default(timestamptz`2024-01-01T00:00:00Z`) - b DateTime @default(timestamptz`2024-01-01T00:00:00.000Z`) - c DateTime @default(timestamptz`2024-01-01T01:00:00+01:00`) + a TimestamptzTemporal @default(timestamptz`2024-01-01T00:00:00Z`) + b TimestamptzTemporal @default(timestamptz`2024-01-01T00:00:00.000Z`) + c TimestamptzTemporal @default(timestamptz`2024-01-01T01:00:00+01:00`) localAt TimestampTemporal(3) @default(timestamp`2024-01-01 12:34:56.500`) - day Date @default(date`2024-01-01`) - clock Time @default(time`12:34`) + day DateTemporal @default(date`2024-01-01`) + clock TimeTemporal @default(time`12:34`) zoned Timetz @default(timetz`12:34:56+02`) - history DateTime[] @default([timestamptz`2024-01-01T00:00:00.000Z`, timestamptz`2024-06-30 12:34:56+00`]) + history TimestamptzTemporal[] @default([timestamptz`2024-01-01T00:00:00.000Z`, timestamptz`2024-06-30 12:34:56+00`]) @@map("event") } diff --git a/test/integration/test/date-time-defaults/_fixture-before-canonical-form/contract.prisma b/test/integration/test/date-time-defaults/_fixture-before-canonical-form/contract.prisma index 400a07fa0e3e..b67412775120 100644 --- a/test/integration/test/date-time-defaults/_fixture-before-canonical-form/contract.prisma +++ b/test/integration/test/date-time-defaults/_fixture-before-canonical-form/contract.prisma @@ -2,14 +2,14 @@ model Event { id Int @id - a DateTime @default("2024-01-01T00:00:00Z") - b DateTime @default("2024-01-01T00:00:00.000Z") - c DateTime @default("2024-01-01T01:00:00+01:00") + a TimestamptzTemporal @default("2024-01-01T00:00:00Z") + b TimestamptzTemporal @default("2024-01-01T00:00:00.000Z") + c TimestamptzTemporal @default("2024-01-01T01:00:00+01:00") localAt TimestampTemporal(3) @default("2024-01-01 12:34:56.500") - day Date @default("2024-01-01") - clock Time @default("12:34") + day DateTemporal @default("2024-01-01") + clock TimeTemporal @default("12:34") zoned Timetz @default("12:34:56+02") - history DateTime[] @default(["2024-01-01T00:00:00.000Z", "2024-06-30 12:34:56+00"]) + history TimestamptzTemporal[] @default(["2024-01-01T00:00:00.000Z", "2024-06-30 12:34:56+00"]) @@map("event") } diff --git a/test/integration/test/date-time-defaults/psl-date-time-defaults.integration.test.ts b/test/integration/test/date-time-defaults/psl-date-time-defaults.integration.test.ts index e3647df39d7d..8af9ed90e38c 100644 --- a/test/integration/test/date-time-defaults/psl-date-time-defaults.integration.test.ts +++ b/test/integration/test/date-time-defaults/psl-date-time-defaults.integration.test.ts @@ -18,10 +18,10 @@ const literal = (value: unknown) => ({ kind: 'literal', value }); /** The tag that writes a value of each Postgres column type these tests use. */ const TAG_OF: Readonly> = { - DateTime: 'timestamptz', + TimestamptzTemporal: 'timestamptz', TimestampTemporal: 'timestamp', - Date: 'date', - Time: 'time', + DateTemporal: 'date', + TimeTemporal: 'time', Timetz: 'timetz', }; @@ -31,9 +31,9 @@ describe('a PSL date or time default is stored in its data type canonical form', await postgresDefaults( `model Event { id Int @id - a DateTime @default(timestamptz\`2024-01-01T00:00:00Z\`) - b DateTime @default(timestamptz\`2024-01-01T00:00:00.000Z\`) - c DateTime @default(timestamptz\`2024-01-01T01:00:00+01:00\`) + a TimestamptzTemporal @default(timestamptz\`2024-01-01T00:00:00Z\`) + b TimestamptzTemporal @default(timestamptz\`2024-01-01T00:00:00.000Z\`) + c TimestamptzTemporal @default(timestamptz\`2024-01-01T01:00:00+01:00\`) }`, ['a', 'b', 'c'], ), @@ -53,9 +53,9 @@ describe('a PSL date or time default is stored in its data type canonical form', jsDate TimestamptzJsDate @default(timestamptz\`2024-01-01 00:00:00.500+00\`) local TimestampTemporal @default(timestamp\`2024-01-01 12:34:56.500\`) localText TimestampString @default(timestamp\`2024-01-01 12:34:56\`) - day Date @default(date\`0044-03-15 BC\`) + day DateTemporal @default(date\`0044-03-15 BC\`) dayText DateString @default(date\`12026-01-02\`) - clock Time @default(time\`12:34\`) + clock TimeTemporal @default(time\`12:34\`) zoned Timetz @default(timetz\`12:34:56+02\`) history TimestampTemporal(3)[] @default([timestamp\`2024-01-01 00:00:00\`, timestamp\`2024-06-30 12:34:56.789000\`]) }`, @@ -92,17 +92,17 @@ describe('a PSL date or time default is stored in its data type canonical form', 'pg/timestamp holds no UTC offset, but "2024-01-01T00:00:00Z" has one. Leave it out, as in "2024-01-01T12:34:56".', ], [ - 'DateTime', + 'TimestamptzTemporal', '2024-01-01 00:00:00', 'pg/timestamptz needs a UTC offset, but "2024-01-01 00:00:00" has none. Add Z for UTC or an offset such as +02:00, as in "2024-01-01T12:34:56Z".', ], [ - 'Date', + 'DateTemporal', '2024-02-30', '"2024-02-30" is not a date that exists. Write a real date, as in "2024-01-01".', ], [ - 'Time', + 'TimeTemporal', '25:00:00', '"25:00:00" is not a time of day that exists: hours run from 00 to 23, and minutes and seconds from 00 to 59. Write one, as in "12:34:56".', ], @@ -112,7 +112,7 @@ describe('a PSL date or time default is stored in its data type canonical form', 'pg/timetz needs a UTC offset, but "12:34:56" has none. Add Z for UTC or an offset such as +02:00, as in "12:34:56+02:00".', ], [ - 'DateTime', + 'TimestamptzTemporal', '2024-01-01T00:00:00.1234567Z', '"2024-01-01T00:00:00.1234567Z" has 7 digits after the decimal point, but pg/timestamptz holds microseconds, so at most 6. Round it, as in "2024-01-01T12:34:56.123456Z".', ], @@ -157,10 +157,10 @@ describe('a PSL date or time default is stored in its data type canonical form', describe('a quoted string on a Postgres date, time, interval or bytes column', () => { it.each([ - ['DateTime', '2024-01-01T00:00:00Z', 'timestamptz'], + ['TimestamptzTemporal', '2024-01-01T00:00:00Z', 'timestamptz'], ['TimestampTemporal', '2024-01-01 12:34:56', 'timestamp'], - ['Date', '2024-01-01', 'date'], - ['Time', '12:34:56', 'time'], + ['DateTemporal', '2024-01-01', 'date'], + ['TimeTemporal', '12:34:56', 'time'], ['Timetz', '12:34:56+02:00', 'timetz'], ['IntervalParts', 'P1D', 'interval'], ['Bytes', '\\x68656c6c6f', 'bytea'], @@ -192,9 +192,9 @@ describe('a quoted string on a Postgres date, time, interval or bytes column', ( describe('infinity on a column whose codec holds a Temporal value or a Date', () => { it.each([ - ['DateTime', 'timestamptz', 'pg/timestamptz-temporal@1', 'pg/timestamptz-string@1'], + ['TimestamptzTemporal', 'timestamptz', 'pg/timestamptz-temporal@1', 'pg/timestamptz-string@1'], ['TimestamptzJsDate', 'timestamptz', 'pg/timestamptz-date@1', 'pg/timestamptz-string@1'], - ['Date', 'date', 'pg/date-temporal@1', 'pg/date-string@1'], + ['DateTemporal', 'date', 'pg/date-temporal@1', 'pg/date-string@1'], ])( 'is refused on a %s column, naming the text codec that holds it', async (type, tag, codecId, textCodecId) => { diff --git a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/schema.parity.psl b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/schema.parity.psl index 5407e408d2b0..0964438a54de 100644 --- a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/schema.parity.psl +++ b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/schema.parity.psl @@ -14,7 +14,7 @@ model User { id Int @id @default(autoincrement()) email Email @unique role Role - createdAt DateTime @default(now()) + createdAt DateTime(as: temporal) @default(now()) isActive Boolean @default(true) profile Jsonb? @@map("user") diff --git a/test/integration/test/infer-roundtrip-runtime.integration.test.ts b/test/integration/test/infer-roundtrip-runtime.integration.test.ts index 5b4cc05480c0..09f26fb10151 100644 --- a/test/integration/test/infer-roundtrip-runtime.integration.test.ts +++ b/test/integration/test/infer-roundtrip-runtime.integration.test.ts @@ -59,6 +59,42 @@ async function inferAndEmit( return ctx; } +async function withRuntime( + contract: Contract, + connectionString: string, + use: (session: { + readonly runtime: PostgresRuntimeImpl; + readonly context: ReturnType; + readonly client: Client; + }) => Promise, +): Promise { + const stack = createSqlExecutionStack({ + target: postgresTarget, + adapter: postgresAdapter, + driver: postgresDriver, + }); + const context = createExecutionContext({ contract, stack }); + const stackInstance = instantiateExecutionStack(stack); + const adapter = stackInstance.adapter; + const driver = stackInstance.driver; + if (!adapter || !driver) { + throw new Error('Adapter or driver descriptor missing from execution stack'); + } + const client = new Client({ connectionString }); + await client.connect(); + try { + await driver.connect({ kind: 'pgClient', client, ownedByDriver: false }); + const runtime = new PostgresRuntimeImpl({ context, adapter, driver, closeRefusal: undefined }); + try { + await use({ runtime, context, client }); + } finally { + await runtime.close(); + } + } finally { + await client.end(); + } +} + withTempDir(({ createTempDir }) => { describe('Runtime: unbounded numeric crashes ExecutionContext construction', () => { let database: Awaited>; @@ -163,7 +199,7 @@ withTempDir(({ createTempDir }) => { ); }); - describe('Runtime: inferred date and time columns decode as ISO 8601 in both top-level select and include()', () => { + describe('Runtime: inferred date and time columns decode as the convenience defaults read them, in both top-level select and include()', () => { let database: Awaited>; beforeAll(async () => { @@ -199,35 +235,15 @@ withTempDir(({ createTempDir }) => { const contract = readEmittedContract(ctx); const stored = { notedOn: '2024-01-15', - loggedAt: '2024-01-15T10:00:00.5', - seenAt: '2024-01-15T10:00:00Z', + loggedAt: new Date('2024-01-15T10:00:00.500Z'), + seenAt: new Date('2024-01-15T10:00:00Z'), opensAt: '08:30:00', }; - const stack = createSqlExecutionStack({ - target: postgresTarget, - adapter: postgresAdapter, - driver: postgresDriver, - }); - const context = createExecutionContext({ contract, stack }); - const stackInstance = instantiateExecutionStack(stack); - const adapter = stackInstance.adapter; - const driver = stackInstance.driver; - if (!adapter || !driver) { - throw new Error('Adapter or driver descriptor missing from execution stack'); - } - - const client = new Client({ connectionString: database.connectionString }); - await client.connect(); - try { - await driver.connect({ kind: 'pgClient', client, ownedByDriver: false }); - const runtime = new PostgresRuntimeImpl({ - context, - adapter, - driver, - closeRefusal: undefined, - }); - try { + await withRuntime( + contract, + database.connectionString, + async ({ runtime, context, client }) => { const records = new Collection({ runtime, context }, 'Record', { namespaceId: 'public', }); @@ -265,12 +281,47 @@ withTempDir(({ createTempDir }) => { code: 'RUNTIME.DECODE_FAILED', cause: refusal, }); - } finally { - await runtime.close(); - } - } finally { - await client.end(); - } + }, + ); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Runtime: a timestamp column whose default a Date cannot hold', () => { + let database: Awaited>; + + beforeAll(async () => { + database = await createDevDatabase(); + await withClient(database.connectionString, (client) => + client.query(` + CREATE TABLE horizon ( + id int4 PRIMARY KEY, + ends_at timestamp(3) NOT NULL DEFAULT 'infinity', + began_at timestamptz NOT NULL DEFAULT '-infinity' + ); + INSERT INTO horizon (id) VALUES (1); + `), + ); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) await database.close(); + }, timeouts.spinUpPpgDev); + + it( + 'reads a row created with the default, which infer types as a string', + async () => { + const ctx = await inferAndEmit(database.connectionString, createTempDir); + const contract = readEmittedContract(ctx); + await withRuntime(contract, database.connectionString, async ({ runtime, context }) => { + const horizons = new Collection({ runtime, context }, 'Horizon', { + namespaceId: 'public', + }); + expect(await horizons.select('id', 'endsAt', 'beganAt').all()).toEqual([ + { id: 1, endsAt: 'infinity', beganAt: '-infinity' }, + ]); + }); }, timeouts.spinUpPpgDev, ); diff --git a/test/integration/test/ports/engines/queries/aggregation/count/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/aggregation/count/_fixture/contract.prisma index edad9cc86a50..bf7d41561094 100644 --- a/test/integration/test/ports/engines/queries/aggregation/count/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/aggregation/count/_fixture/contract.prisma @@ -8,6 +8,6 @@ model TestModel { float Float? bytes Bytes? bool Boolean? - dt DateTime? + dt TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/aggregation/many_count_relation/_fixture/compound/contract.prisma b/test/integration/test/ports/engines/queries/aggregation/many_count_relation/_fixture/compound/contract.prisma index eccba00f25f4..c239bafef02b 100644 --- a/test/integration/test/ports/engines/queries/aggregation/many_count_relation/_fixture/compound/contract.prisma +++ b/test/integration/test/ports/engines/queries/aggregation/many_count_relation/_fixture/compound/contract.prisma @@ -29,7 +29,7 @@ model Vote { userId Int objectiveId Int followerId Int - createdAt DateTime @default(now()) + createdAt TimestamptzTemporal @default(now()) user User @relation(fields: [userId], references: [id]) userObjective UserToObjective @relation(fields: [objectiveId, followerId], references: [userId, objectiveId], onDelete: NoAction, onUpdate: NoAction) diff --git a/test/integration/test/ports/engines/queries/data_types/bigint/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/data_types/bigint/_fixture/contract.prisma index 9ab9e5e5b23a..94972ebb0ebf 100644 --- a/test/integration/test/ports/engines/queries/data_types/bigint/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/data_types/bigint/_fixture/contract.prisma @@ -8,6 +8,6 @@ model TestModel { float Float? bytes Bytes? bool Boolean? - dt DateTime? + dt TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/data_types/bool/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/data_types/bool/_fixture/contract.prisma index 9ab9e5e5b23a..94972ebb0ebf 100644 --- a/test/integration/test/ports/engines/queries/data_types/bool/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/data_types/bool/_fixture/contract.prisma @@ -8,6 +8,6 @@ model TestModel { float Float? bytes Bytes? bool Boolean? - dt DateTime? + dt TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/data_types/bytes/_fixture/scalars/contract.prisma b/test/integration/test/ports/engines/queries/data_types/bytes/_fixture/scalars/contract.prisma index 9ab9e5e5b23a..94972ebb0ebf 100644 --- a/test/integration/test/ports/engines/queries/data_types/bytes/_fixture/scalars/contract.prisma +++ b/test/integration/test/ports/engines/queries/data_types/bytes/_fixture/scalars/contract.prisma @@ -8,6 +8,6 @@ model TestModel { float Float? bytes Bytes? bool Boolean? - dt DateTime? + dt TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/data_types/datetime/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/data_types/datetime/_fixture/contract.prisma index 9ab9e5e5b23a..94972ebb0ebf 100644 --- a/test/integration/test/ports/engines/queries/data_types/datetime/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/data_types/datetime/_fixture/contract.prisma @@ -8,6 +8,6 @@ model TestModel { float Float? bytes Bytes? bool Boolean? - dt DateTime? + dt TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/data_types/float/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/data_types/float/_fixture/contract.prisma index edad9cc86a50..bf7d41561094 100644 --- a/test/integration/test/ports/engines/queries/data_types/float/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/data_types/float/_fixture/contract.prisma @@ -8,6 +8,6 @@ model TestModel { float Float? bytes Bytes? bool Boolean? - dt DateTime? + dt TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/data_types/int/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/data_types/int/_fixture/contract.prisma index edad9cc86a50..bf7d41561094 100644 --- a/test/integration/test/ports/engines/queries/data_types/int/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/data_types/int/_fixture/contract.prisma @@ -8,6 +8,6 @@ model TestModel { float Float? bytes Bytes? bool Boolean? - dt DateTime? + dt TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/data_types/string/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/data_types/string/_fixture/contract.prisma index edad9cc86a50..bf7d41561094 100644 --- a/test/integration/test/ports/engines/queries/data_types/string/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/data_types/string/_fixture/contract.prisma @@ -8,6 +8,6 @@ model TestModel { float Float? bytes Bytes? bool Boolean? - dt DateTime? + dt TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/data_types/through_relation/_fixture/common/contract.prisma b/test/integration/test/ports/engines/queries/data_types/through_relation/_fixture/common/contract.prisma index 9667510bb971..21d5bdfadd21 100644 --- a/test/integration/test/ports/engines/queries/data_types/through_relation/_fixture/common/contract.prisma +++ b/test/integration/test/ports/engines/queries/data_types/through_relation/_fixture/common/contract.prisma @@ -15,6 +15,6 @@ model Child { float Float bytes Bytes bool Boolean - dt DateTime + dt TimestamptzTemporal @@map("child") } diff --git a/test/integration/test/ports/engines/queries/data_types/through_relation/_fixture/lists/contract.prisma b/test/integration/test/ports/engines/queries/data_types/through_relation/_fixture/lists/contract.prisma index 6798c6fef79d..eef6d42b96b8 100644 --- a/test/integration/test/ports/engines/queries/data_types/through_relation/_fixture/lists/contract.prisma +++ b/test/integration/test/ports/engines/queries/data_types/through_relation/_fixture/lists/contract.prisma @@ -15,7 +15,7 @@ model Child { float Float[] bytes Bytes[] bool Boolean[] - dt DateTime[] + dt TimestamptzTemporal[] empty Int[] unset Int[] @@map("child") diff --git a/test/integration/test/ports/engines/queries/distinct/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/distinct/_fixture/contract.prisma index 6256f224ab4f..f16eb5f9dcde 100644 --- a/test/integration/test/ports/engines/queries/distinct/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/distinct/_fixture/contract.prisma @@ -5,7 +5,7 @@ model User { first_name String last_name String email String @unique - birthday DateTime? + birthday TimestamptzTemporal? posts Post[] @@map("user") } diff --git a/test/integration/test/ports/engines/queries/filters/bigint_filter/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/filters/bigint_filter/_fixture/contract.prisma index edad9cc86a50..bf7d41561094 100644 --- a/test/integration/test/ports/engines/queries/filters/bigint_filter/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/bigint_filter/_fixture/contract.prisma @@ -8,6 +8,6 @@ model TestModel { float Float? bytes Bytes? bool Boolean? - dt DateTime? + dt TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/bytes_filter/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/filters/bytes_filter/_fixture/contract.prisma index edad9cc86a50..bf7d41561094 100644 --- a/test/integration/test/ports/engines/queries/filters/bytes_filter/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/bytes_filter/_fixture/contract.prisma @@ -8,6 +8,6 @@ model TestModel { float Float? bytes Bytes? bool Boolean? - dt DateTime? + dt TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/list/contract.prisma b/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/list/contract.prisma index 2fd9e1eb13f5..a70f0afe84a3 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/list/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/list/contract.prisma @@ -27,8 +27,8 @@ model TestModel { bool_list Boolean[] bool_list2 Boolean[] - dt DateTime? - dt_list DateTime[] - dt_list2 DateTime[] + dt TimestamptzTemporal? + dt_list TimestamptzTemporal[] + dt_list2 TimestamptzTemporal[] @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/mixed/contract.prisma b/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/mixed/contract.prisma index 9cb562e15cc7..db95b9e4d5ff 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/mixed/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/mixed/contract.prisma @@ -14,7 +14,7 @@ model TestModel { bytes2 Bytes[] bool Boolean? bool2 Boolean[] - dt DateTime? - dt2 DateTime[] + dt TimestamptzTemporal? + dt2 TimestamptzTemporal[] @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/bigint_filter/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/filters/field_reference/bigint_filter/_fixture/contract.prisma index 1f51080b5f31..3946f3a2ab05 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/bigint_filter/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/field_reference/bigint_filter/_fixture/contract.prisma @@ -14,7 +14,7 @@ model TestModel { bytes2 Bytes? bool Boolean? bool2 Boolean? - dt DateTime? - dt2 DateTime? + dt TimestamptzTemporal? + dt2 TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/bytes_filter/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/filters/field_reference/bytes_filter/_fixture/contract.prisma index 1f51080b5f31..3946f3a2ab05 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/bytes_filter/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/field_reference/bytes_filter/_fixture/contract.prisma @@ -14,7 +14,7 @@ model TestModel { bytes2 Bytes? bool Boolean? bool2 Boolean? - dt DateTime? - dt2 DateTime? + dt TimestamptzTemporal? + dt2 TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/datetime_filter/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/filters/field_reference/datetime_filter/_fixture/contract.prisma index 1f51080b5f31..3946f3a2ab05 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/datetime_filter/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/field_reference/datetime_filter/_fixture/contract.prisma @@ -14,7 +14,7 @@ model TestModel { bytes2 Bytes? bool Boolean? bool2 Boolean? - dt DateTime? - dt2 DateTime? + dt TimestamptzTemporal? + dt2 TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/failure/_fixture/common/contract.prisma b/test/integration/test/ports/engines/queries/filters/field_reference/failure/_fixture/common/contract.prisma index 1f51080b5f31..3946f3a2ab05 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/failure/_fixture/common/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/field_reference/failure/_fixture/common/contract.prisma @@ -14,7 +14,7 @@ model TestModel { bytes2 Bytes? bool Boolean? bool2 Boolean? - dt DateTime? - dt2 DateTime? + dt TimestamptzTemporal? + dt2 TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/float_filter/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/filters/field_reference/float_filter/_fixture/contract.prisma index 1f51080b5f31..3946f3a2ab05 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/float_filter/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/field_reference/float_filter/_fixture/contract.prisma @@ -14,7 +14,7 @@ model TestModel { bytes2 Bytes? bool Boolean? bool2 Boolean? - dt DateTime? - dt2 DateTime? + dt TimestamptzTemporal? + dt2 TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/having_filter/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/filters/field_reference/having_filter/_fixture/contract.prisma index 1f51080b5f31..3946f3a2ab05 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/having_filter/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/field_reference/having_filter/_fixture/contract.prisma @@ -14,7 +14,7 @@ model TestModel { bytes2 Bytes? bool Boolean? bool2 Boolean? - dt DateTime? - dt2 DateTime? + dt TimestamptzTemporal? + dt2 TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/int_filter/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/filters/field_reference/int_filter/_fixture/contract.prisma index 1f51080b5f31..3946f3a2ab05 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/int_filter/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/field_reference/int_filter/_fixture/contract.prisma @@ -14,7 +14,7 @@ model TestModel { bytes2 Bytes? bool Boolean? bool2 Boolean? - dt DateTime? - dt2 DateTime? + dt TimestamptzTemporal? + dt2 TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/string_filter/_fixture/contract.prisma b/test/integration/test/ports/engines/queries/filters/field_reference/string_filter/_fixture/contract.prisma index 1f51080b5f31..3946f3a2ab05 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/string_filter/_fixture/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/field_reference/string_filter/_fixture/contract.prisma @@ -14,7 +14,7 @@ model TestModel { bytes2 Bytes? bool Boolean? bool2 Boolean? - dt DateTime? - dt2 DateTime? + dt TimestamptzTemporal? + dt2 TimestamptzTemporal? @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/list_filters/_fixture/base/contract.prisma b/test/integration/test/ports/engines/queries/filters/list_filters/_fixture/base/contract.prisma index d64a52cc7109..e2c2e20ca273 100644 --- a/test/integration/test/ports/engines/queries/filters/list_filters/_fixture/base/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/list_filters/_fixture/base/contract.prisma @@ -8,6 +8,6 @@ model TestModel { float Float[] bytes Bytes[] bool Boolean[] - dt DateTime[] + dt TimestamptzTemporal[] @@map("testModel") } diff --git a/test/integration/test/ports/engines/queries/filters/many_relation/_fixture/25103/contract.prisma b/test/integration/test/ports/engines/queries/filters/many_relation/_fixture/25103/contract.prisma index 221c67d010b4..4f48d0874734 100644 --- a/test/integration/test/ports/engines/queries/filters/many_relation/_fixture/25103/contract.prisma +++ b/test/integration/test/ports/engines/queries/filters/many_relation/_fixture/25103/contract.prisma @@ -2,5 +2,5 @@ model Contact { id String @id identities Identity[] @@map("contact") } model Identity { id String @id contactId String contact Contact @relation(fields: [contactId], references: [id]) subscriptions Subscription[] @@map("identity") } -model Subscription { id String @id identityId String audienceId String optedOutAt DateTime? audience Audience @relation(fields: [audienceId], references: [id]) identity Identity @relation(fields: [identityId], references: [id]) @@map("subscription") } -model Audience { id String @id deletedAt DateTime? subscriptions Subscription[] @@map("audience") } +model Subscription { id String @id identityId String audienceId String optedOutAt TimestamptzTemporal? audience Audience @relation(fields: [audienceId], references: [id]) identity Identity @relation(fields: [identityId], references: [id]) @@map("subscription") } +model Audience { id String @id deletedAt TimestamptzTemporal? subscriptions Subscription[] @@map("audience") } diff --git a/test/integration/test/ports/prisma/functional/blog-update/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/blog-update/_fixture/contract.prisma index d8a4b473c838..842b949695af 100644 --- a/test/integration/test/ports/prisma/functional/blog-update/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/blog-update/_fixture/contract.prisma @@ -42,8 +42,8 @@ model User { name String? posts Post[] profile Profile? - wakesUpAt DateTime? @default(now()) - lastLoginAt DateTime? @default(now()) + wakesUpAt TimestamptzTemporal? @default(now()) + lastLoginAt TimestamptzTemporal? @default(now()) @@map("user") } @@ -53,22 +53,22 @@ model Profile { notrequired String? user User @relation(fields: [userId], references: [id]) userId String @unique - goesToBedAt DateTime? @default(now()) - goesToOfficeAt DateTime? @default(now()) + goesToBedAt TimestamptzTemporal? @default(now()) + goesToOfficeAt TimestamptzTemporal? @default(now()) @@map("profile") } model Post { id String @id @onCreate(cuid(2)) - createdAt DateTime @default(now()) - updatedAt temporal.UpdatedAt + createdAt TimestamptzTemporal @default(now()) + updatedAt TimestamptzTemporal @onCreate(now()) @onUpdate(now()) published Boolean title String content String? optional String? authorId String? @map("author") author User? @relation(fields: [authorId], references: [id]) - lastReviewedAt DateTime? @default(now()) - lastPublishedAt DateTime? @default(now()) + lastReviewedAt TimestamptzTemporal? @default(now()) + lastPublishedAt TimestamptzTemporal? @default(now()) @@map("post") } diff --git a/test/integration/test/ports/prisma/functional/create-default-date/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/create-default-date/_fixture/contract.prisma index 56c2998ad5de..e21af13cfdff 100644 --- a/test/integration/test/ports/prisma/functional/create-default-date/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/create-default-date/_fixture/contract.prisma @@ -10,6 +10,6 @@ model Visit { id Int @id @default(autoincrement()) - visitTime DateTime @default(now()) + visitTime TimestamptzTemporal @default(now()) @@map("visit") } diff --git a/test/integration/test/ports/prisma/functional/extended-where/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/extended-where/_fixture/contract.prisma index 44d36578e37f..77122a88539f 100644 --- a/test/integration/test/ports/prisma/functional/extended-where/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/extended-where/_fixture/contract.prisma @@ -46,7 +46,7 @@ model Profile { userId String @unique email String @unique alias String @unique - createdAt DateTime @default(now()) + createdAt TimestamptzTemporal @default(now()) @@map("profile") } diff --git a/test/integration/test/ports/prisma/functional/interactive-transactions/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/interactive-transactions/_fixture/contract.prisma index 0c8f519a4291..6fd0716a2839 100644 --- a/test/integration/test/ports/prisma/functional/interactive-transactions/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/interactive-transactions/_fixture/contract.prisma @@ -32,8 +32,8 @@ model User { model Post { id String @id - createdAt DateTime @default(now()) - updatedAt temporal.UpdatedAt + createdAt TimestamptzTemporal @default(now()) + updatedAt TimestamptzTemporal @onCreate(now()) @onUpdate(now()) published Boolean title String content String? diff --git a/test/integration/test/ports/prisma/functional/issues-12572/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/issues-12572/_fixture/contract.prisma index 70645160dde3..949e81ecf87c 100644 --- a/test/integration/test/ports/prisma/functional/issues-12572/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/issues-12572/_fixture/contract.prisma @@ -14,7 +14,7 @@ model User { id String @id @onCreate(cuid(2)) - createdAt DateTime @default(now()) - updatedAt temporal.UpdatedAt + createdAt TimestamptzTemporal @default(now()) + updatedAt TimestamptzTemporal @onCreate(now()) @onUpdate(now()) @@map("user") } diff --git a/test/integration/test/ports/prisma/functional/issues-14954-date-batch/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/issues-14954-date-batch/_fixture/contract.prisma index c166d924de56..14210346bf65 100644 --- a/test/integration/test/ports/prisma/functional/issues-14954-date-batch/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/issues-14954-date-batch/_fixture/contract.prisma @@ -11,6 +11,6 @@ // idForProvider(postgresql) is `String @id @default(cuid(2))`. model Resource { id String @id @onCreate(cuid(2)) - date DateTime @unique + date TimestamptzTemporal @unique @@map("resource") } diff --git a/test/integration/test/ports/prisma/functional/issues-18970-invalid-date/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/issues-18970-invalid-date/_fixture/contract.prisma index ccd9d3264a45..21c841a0f00d 100644 --- a/test/integration/test/ports/prisma/functional/issues-18970-invalid-date/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/issues-18970-invalid-date/_fixture/contract.prisma @@ -10,6 +10,6 @@ model User { id String @id @onCreate(cuid(2)) - date DateTime? + date TimestamptzTemporal? @@map("user") } diff --git a/test/integration/test/ports/prisma/functional/issues-23902/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/issues-23902/_fixture/contract.prisma index 9b3261797d37..04d63eeeb1a2 100644 --- a/test/integration/test/ports/prisma/functional/issues-23902/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/issues-23902/_fixture/contract.prisma @@ -36,8 +36,8 @@ model User { model Post { id String @id @onCreate(cuid(2)) - createdAt DateTime @default(now()) - updatedAt temporal.UpdatedAt + createdAt TimestamptzTemporal @default(now()) + updatedAt TimestamptzTemporal @onCreate(now()) @onUpdate(now()) title String content String? published Boolean @default(false) diff --git a/test/integration/test/ports/prisma/functional/issues-28192-pg-historical-dates/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/issues-28192-pg-historical-dates/_fixture/contract.prisma index 05c765d1e5d8..c2e77f6020a9 100644 --- a/test/integration/test/ports/prisma/functional/issues-28192-pg-historical-dates/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/issues-28192-pg-historical-dates/_fixture/contract.prisma @@ -18,7 +18,7 @@ // types in Prisma 8 PSL. model TestData { id String @id @onCreate(cuid(2)) - date Date + date DateTemporal timestamp TimestampTemporal timestamptz TimestamptzTemporal @@map("testData") diff --git a/test/integration/test/ports/prisma/functional/issues-29309-datetime-cursor/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/issues-29309-datetime-cursor/_fixture/contract.prisma index bbbf88ef1d90..d689c695dc3f 100644 --- a/test/integration/test/ports/prisma/functional/issues-29309-datetime-cursor/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/issues-29309-datetime-cursor/_fixture/contract.prisma @@ -17,7 +17,7 @@ model Event { appId Int - createdAt Date + createdAt DateTemporal value Int @@id([appId, createdAt]) diff --git a/test/integration/test/ports/prisma/functional/issues-29331-query-plan-cache-bloat/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/issues-29331-query-plan-cache-bloat/_fixture/contract.prisma index 3f6087fc54d7..12c1297486fd 100644 --- a/test/integration/test/ports/prisma/functional/issues-29331-query-plan-cache-bloat/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/issues-29331-query-plan-cache-bloat/_fixture/contract.prisma @@ -22,13 +22,13 @@ model ContactAnalytics { id String @id @onCreate(cuid(2)) contactId String contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) - date1 DateTime? - date2 DateTime? - date3 DateTime? - date4 DateTime? - date5 DateTime? - date6 DateTime? - date7 DateTime? + date1 TimestamptzTemporal? + date2 TimestamptzTemporal? + date3 TimestamptzTemporal? + date4 TimestamptzTemporal? + date5 TimestamptzTemporal? + date6 TimestamptzTemporal? + date7 TimestamptzTemporal? val1 Int? val2 Int? val3 Int? diff --git a/test/integration/test/ports/prisma/functional/legacy-aggregations/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/legacy-aggregations/_fixture/contract.prisma index 6cba7a946249..cafdb70dd486 100644 --- a/test/integration/test/ports/prisma/functional/legacy-aggregations/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/legacy-aggregations/_fixture/contract.prisma @@ -33,8 +33,8 @@ model User { model Post { id String @id @onCreate(cuid(2)) - createdAt DateTime @default(now()) - updatedAt temporal.UpdatedAt + createdAt TimestamptzTemporal @default(now()) + updatedAt TimestamptzTemporal @onCreate(now()) @onUpdate(now()) published Boolean title String content String? diff --git a/test/integration/test/ports/prisma/functional/methods-count/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/methods-count/_fixture/contract.prisma index 8202ebc86b59..9ea23f2e3b16 100644 --- a/test/integration/test/ports/prisma/functional/methods-count/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/methods-count/_fixture/contract.prisma @@ -33,8 +33,8 @@ model User { model Post { id String @id @onCreate(cuid(2)) - createdAt DateTime @default(now()) - updatedAt temporal.UpdatedAt + createdAt TimestamptzTemporal @default(now()) + updatedAt TimestamptzTemporal @onCreate(now()) @onUpdate(now()) published Boolean title String content String? diff --git a/test/integration/test/ports/prisma/functional/multiple-types/_fixture/contract.prisma b/test/integration/test/ports/prisma/functional/multiple-types/_fixture/contract.prisma index 3409574f954b..4f793701717d 100644 --- a/test/integration/test/ports/prisma/functional/multiple-types/_fixture/contract.prisma +++ b/test/integration/test/ports/prisma/functional/multiple-types/_fixture/contract.prisma @@ -22,7 +22,7 @@ model TestModel { float Float? bytes Bytes? bool Boolean? - dt DateTime? + dt TimestamptzTemporal? dec Decimal? @@map("testModel") } diff --git a/test/integration/test/prisma7-source/refused-defaults-migrate.integration.test.ts b/test/integration/test/prisma7-source/refused-defaults-migrate.integration.test.ts new file mode 100644 index 000000000000..5f07cdb2e556 --- /dev/null +++ b/test/integration/test/prisma7-source/refused-defaults-migrate.integration.test.ts @@ -0,0 +1,171 @@ +/** + * A Prisma 7 default whose value the column's codec cannot hold exactly, such as a timestamp with digits below a millisecond read as a JavaScript `Date`, is kept as the SQL the target writes for it. A database Prisma 8 migrates from that contract holds every digit and verifies clean. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import postgresAdapter from '@internal/adapter-postgres/control'; +import postgresControlDriver from '@internal/driver-postgres/control'; +import sql, { INIT_ADDITIVE_POLICY } from '@internal/family-sql/control'; +import { APP_SPACE_ID, createControlStack } from '@internal/framework-components/control'; +import { buildFabricatedMigrationEdge } from '@internal/migration-tools/aggregate'; +import postgres from '@internal/target-postgres/control'; +import { prisma7PostgresBinding } from '@internal/target-postgres/prisma7-binding'; +import { createDevDatabase, type DevDatabase, timeouts, withClient } from '@repo/test-utils'; +import { join } from 'pathe'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { postgresFrameworkComponents } from '../scalar-lists/psl-list-authoring'; +import { loadPrisma7Schema } from './load-prisma7-schema'; + +const SCHEMA = `datasource db { + provider = "postgresql" +} + +model Stamp { + id Int @id + tsMicro DateTime @default("2024-01-02T03:04:05.123456Z") @db.Timestamp(6) + ts6l DateTime[] @default(["2024-01-02T03:04:05.123Z", "2024-01-02T03:04:05.123456Z"]) @db.Timestamp(6) +} +`; + +const controlStack = createControlStack({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresControlDriver, + extensions: [], +}); +const familyInstance = sql.create(controlStack); + +describe('a Prisma 7 default a Date cannot hold, migrated by Prisma 8', { + concurrent: false, +}, () => { + let database: DevDatabase | undefined; + const directory = mkdtempSync(join(tmpdir(), 'prisma7-refused-defaults-')); + + beforeAll(async () => { + database = await createDevDatabase(); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) await database.close(); + rmSync(directory, { recursive: true, force: true }); + }, timeouts.spinUpPpgDev); + + it( + 'keeps every digit, single and in a list, and verifies clean', + async () => { + if (!database) throw new Error('database not initialised'); + const schemaPath = join(directory, 'schema.prisma'); + writeFileSync(schemaPath, SCHEMA); + const contract = await loadPrisma7Schema(schemaPath); + const driver = await postgresControlDriver.create(database.connectionString); + try { + const planned = postgres.createPlanner(postgresAdapter.create(controlStack)).plan({ + contract, + schema: await familyInstance.introspect({ driver }), + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + origin: null, + statements: [], + frameworkComponents: postgresFrameworkComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (planned.kind !== 'success') throw new Error(JSON.stringify(planned)); + const run = await postgres.createRunner(familyInstance).execute({ + driver, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan: planned.plan, + migrationEdges: [ + buildFabricatedMigrationEdge({ + currentMarkerStorageHash: planned.plan.origin?.storageHash, + destinationStorageHash: planned.plan.destination.storageHash, + operationCount: planned.plan.operations.length, + }), + ], + driver, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents: postgresFrameworkComponents, + }, + ], + }); + expect(run.ok ? 'ok' : JSON.stringify(run.failure).slice(0, 1500)).toBe('ok'); + const verified = familyInstance.verifySchema({ + contract, + schema: await familyInstance.introspect({ driver, contract }), + strict: true, + frameworkComponents: postgresFrameworkComponents, + }); + expect(verified.schema.issues).toEqual([]); + } finally { + await driver.close(); + } + const row = await withClient(database.connectionString, async (client) => { + await client.query('INSERT INTO "Stamp" (id) VALUES (1)'); + const read = await client.query<{ micro: string; list: string }>( + `SELECT "tsMicro"::text AS micro, "ts6l"::text AS list FROM "Stamp"`, + ); + return read.rows[0]; + }); + expect(row).toEqual({ + micro: '2024-01-02 03:04:05.123456', + list: '{"2024-01-02 03:04:05.123","2024-01-02 03:04:05.123456"}', + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'writes a year before 1 or after 9999 as PostgreSQL reads it', + async () => { + if (!database) throw new Error('database not initialised'); + const timestamp = { + codecId: 'pg/timestamp-date@1', + dataType: 'pg/timestamp', + typeParams: { precision: 6 }, + }; + const timestamptz = { + codecId: 'pg/timestamptz-date@1', + dataType: 'pg/timestamptz', + typeParams: { precision: 6 }, + }; + const expressions = { + bc: prisma7PostgresBinding.literalDefaultExpression( + '-000043-03-15T00:00:00.123456', + timestamp, + ), + late: prisma7PostgresBinding.literalDefaultExpression( + '+010000-01-01T00:00:00.123456Z', + timestamptz, + ), + list: prisma7PostgresBinding.literalDefaultExpression( + ['-000043-03-15T00:00:00.123456', null], + timestamp, + ), + }; + const read = await withClient(database.connectionString, async (client) => { + await client.query("SET TIME ZONE 'UTC'"); + const result = await client.query<{ bc: string; late: string; list: string }>( + `SELECT (${expressions.bc})::text AS bc, (${expressions.late})::text AS late, (${expressions.list})::text AS list`, + ); + return result.rows[0]; + }); + expect({ expressions, read }).toEqual({ + expressions: { + bc: "'0044-03-15T00:00:00.123456 BC'::timestamp(6)", + late: "'10000-01-01T00:00:00.123456Z'::timestamptz(6)", + list: "ARRAY['0044-03-15T00:00:00.123456 BC', NULL]::timestamp(6)[]", + }, + read: { + bc: '0044-03-15 00:00:00.123456 BC', + late: '10000-01-01 00:00:00.123456+00', + list: '{"0044-03-15 00:00:00.123456 BC",NULL}', + }, + }); + }, + timeouts.spinUpPpgDev, + ); +}); diff --git a/test/integration/test/psl-infer/convenience-types-infer.integration.test.ts b/test/integration/test/psl-infer/convenience-types-infer.integration.test.ts new file mode 100644 index 000000000000..ee37adff6bc2 --- /dev/null +++ b/test/integration/test/psl-infer/convenience-types-infer.integration.test.ts @@ -0,0 +1,189 @@ +/** + * `contract infer` writes each date and time column as the convenience type whose default path reads it, with only the arguments that differ from the defaults. The inferred schema emits, verifies clean against the database it was read from, and plans no migration. + */ +import postgresAdapter from '@internal/adapter-postgres/control'; +import postgresControlDriver from '@internal/driver-postgres/control'; +import sql, { INIT_ADDITIVE_POLICY } from '@internal/family-sql/control'; +import { APP_SPACE_ID, createControlStack } from '@internal/framework-components/control'; +import { buildFabricatedMigrationEdge } from '@internal/migration-tools/aggregate'; +import { printPsl } from '@internal/psl-printer'; +import postgres from '@internal/target-postgres/control'; +import { createDevDatabase, type DevDatabase, timeouts, withClient } from '@repo/test-utils'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + authorSqlContractFromPsl, + postgresFrameworkComponents, +} from '../scalar-lists/psl-list-authoring'; + +const controlStack = createControlStack({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresControlDriver, + extensions: [], +}); +const familyInstance = sql.create(controlStack); + +const EVENT_TABLE = `CREATE TABLE event ( + id int4 PRIMARY KEY, + at timestamptz, + paid_at timestamptz(3), + legacy timestamp(3), + day date, + opens time(0), + local_at timetz, + span interval, + micro timestamptz(6) DEFAULT '2024-01-01 01:00:00.123456+00', + milli timestamptz(3) DEFAULT '2024-01-01 01:00:00.123+00' +)`; + +function fieldLines(printed: string): readonly string[] { + return printed + .split('\n') + .map((line) => line.trim().replace(/\s+/g, ' ')) + .filter((line) => /^(at|paidAt|legacy|day|opens|localAt|span|micro|milli) /.test(line)); +} + +describe('contract infer over date and time columns', { concurrent: false }, () => { + let database: DevDatabase | undefined; + + beforeAll(async () => { + database = await createDevDatabase(); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) await database.close(); + }, timeouts.spinUpPpgDev); + + it( + 'writes the convenience types, which emit, verify clean and plan no migration', + async () => { + if (!database) throw new Error('database not initialised'); + await withClient(database.connectionString, async (client) => { + await client.query(EVENT_TABLE); + }); + const driver = await postgresControlDriver.create(database.connectionString); + try { + const schema = await familyInstance.introspect({ driver }); + const inferred = printPsl(await familyInstance.inferPslContract(schema), { + pslBlockDescriptors: controlStack.authoringContributions.pslBlockDescriptors, + }); + expect(fieldLines(inferred)).toEqual([ + 'at DateTime(precision: 6)?', + 'paidAt DateTime? @map("paid_at")', + 'legacy DateTime(storage: timestamp)?', + 'day Date?', + 'opens Time(precision: 0)?', + 'localAt Time(storage: timetz)? @map("local_at")', + 'span Duration?', + "micro DateTime(precision: 6)? @default(sql`'2024-01-01 01:00:00.123456+00'::timestamp with time zone`)", + 'milli DateTime? @default(timestamptz`2024-01-01T01:00:00.123Z`)', + ]); + + const emitted = await authorSqlContractFromPsl( + inferred.replace(/^\/\/ use prisma-8\n/, ''), + ); + expect(emitted.diagnostics).toEqual([]); + const contract = emitted.contract; + if (!contract) throw new Error('the inferred schema produced no contract'); + + const verified = familyInstance.verifySchema({ + contract, + schema: await familyInstance.introspect({ driver, contract }), + strict: true, + frameworkComponents: postgresFrameworkComponents, + }); + expect(verified.schema.issues).toEqual([]); + + const planned = postgres.createPlanner(postgresAdapter.create(controlStack)).plan({ + contract, + schema: await familyInstance.introspect({ driver, contract }), + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + origin: null, + statements: [], + frameworkComponents: postgresFrameworkComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + expect(planned.kind === 'success' ? planned.plan.operations : planned).toEqual([]); + } finally { + await driver.close(); + } + }, + timeouts.spinUpPpgDev, + ); + it( + 'applies a microsecond default written as a raw expression with every digit', + async () => { + if (!database) throw new Error('database not initialised'); + await withClient(database.connectionString, async (client) => { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + }); + const authored = await authorSqlContractFromPsl(`model Stamp { + id Int @id + micro DateTime(precision: 6) @default(sql\`'2024-01-01 01:00:00.123456+00'::timestamp with time zone\`) + @@map("stamp") +}`); + expect(authored.diagnostics).toEqual([]); + const contract = authored.contract; + if (!contract) throw new Error('authoring produced no contract'); + const driver = await postgresControlDriver.create(database.connectionString); + try { + const planned = postgres.createPlanner(postgresAdapter.create(controlStack)).plan({ + contract, + schema: await familyInstance.introspect({ driver }), + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + origin: null, + statements: [], + frameworkComponents: postgresFrameworkComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (planned.kind !== 'success') throw new Error(JSON.stringify(planned)); + const run = await postgres.createRunner(familyInstance).execute({ + driver, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan: planned.plan, + migrationEdges: [ + buildFabricatedMigrationEdge({ + currentMarkerStorageHash: planned.plan.origin?.storageHash, + destinationStorageHash: planned.plan.destination.storageHash, + operationCount: planned.plan.operations.length, + }), + ], + driver, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents: postgresFrameworkComponents, + }, + ], + }); + expect(run.ok ? 'ok' : JSON.stringify(run.failure).slice(0, 1500)).toBe('ok'); + } finally { + await driver.close(); + } + const stored = await withClient(database.connectionString, async (client) => { + const columnDefault = await client.query<{ value: string }>( + `SELECT column_default AS "value" FROM information_schema.columns + WHERE table_name = 'stamp' AND column_name = 'micro'`, + ); + await client.query('INSERT INTO stamp (id) VALUES (1)'); + const row = await client.query<{ value: string }>( + `SELECT (micro AT TIME ZONE 'UTC')::text AS "value" FROM stamp`, + ); + return { columnDefault: columnDefault.rows[0]?.value, row: row.rows[0]?.value }; + }); + expect(stored).toEqual({ + columnDefault: "'2024-01-01 01:00:00.123456+00'::timestamp with time zone", + row: '2024-01-01 01:00:00.123456', + }); + }, + timeouts.spinUpPpgDev, + ); +}); diff --git a/test/integration/test/psl-infer/marked-types-round-trip.integration.test.ts b/test/integration/test/psl-infer/marked-types-round-trip.integration.test.ts index 1df6f154073a..b374d98c2d15 100644 --- a/test/integration/test/psl-infer/marked-types-round-trip.integration.test.ts +++ b/test/integration/test/psl-infer/marked-types-round-trip.integration.test.ts @@ -10,6 +10,7 @@ import sql, { INIT_ADDITIVE_POLICY } from '@internal/family-sql/control'; import { type AuthoringTypeNamespace, isAuthoringTypeAliasDescriptor, + isAuthoringTypeChoiceDescriptor, isAuthoringTypeConstructorDescriptor, } from '@internal/framework-components/authoring'; import { dataTypeParams } from '@internal/framework-components/codec'; @@ -38,7 +39,7 @@ const familyInstance = sql.create(controlStack); /** The data types whose constructor the stack marks `inferred`, found by walking every constructor. */ function dataTypesWithMarkedConstructor(namespace: AuthoringTypeNamespace): readonly string[] { return Object.values(namespace).flatMap((value) => { - if (isAuthoringTypeAliasDescriptor(value)) return []; + if (isAuthoringTypeAliasDescriptor(value) || isAuthoringTypeChoiceDescriptor(value)) return []; if (!isAuthoringTypeConstructorDescriptor(value)) return dataTypesWithMarkedConstructor(value); if (value.inferred !== true) return []; const dataType = controlStack.codecLookup.descriptorFor(value.output.codecId)?.dataType; @@ -72,16 +73,16 @@ const AUTHORED_TYPES = [ 'TimeString(3)', 'Timetz', 'Timetz(2)', - 'TimestampString', - 'TimestampString(3)', - 'TimestamptzString', - 'TimestamptzString(6)', + 'TimestampJsDate', + 'TimestampJsDate(3)', + 'TimestamptzJsDate', + 'TimestamptzJsDate(6)', 'Bit', 'Bit(8)', 'VarBit', 'VarBit(16)', - 'IntervalParts', - 'IntervalParts(3)', + 'IntervalString', + 'IntervalString(3)', ] as const; const AUTHORED_SCHEMA = `model Sample { diff --git a/test/integration/test/psl-print/date-time-types-print.integration.test.ts b/test/integration/test/psl-print/date-time-types-print.integration.test.ts index 15c65f9b93a1..980935388750 100644 --- a/test/integration/test/psl-print/date-time-types-print.integration.test.ts +++ b/test/integration/test/psl-print/date-time-types-print.integration.test.ts @@ -18,19 +18,20 @@ model Event { span IntervalString(3) day Date clock Time - clock3 Time(3) + clock3 Time(precision: 3) zonedText TimestamptzString(3) localText TimestampString(3) dayText DateString clockText TimeString(3) offset Timetz + lasts Duration } `; function fieldTypes(printed: string): Record { return Object.fromEntries( printed.split('\n').flatMap((line) => { - const match = /^\s+(\w+)\s+(\S+)/.exec(line); + const match = /^\s+(\w+)\s+([^@]+?)\s*(?:@|$)/.exec(line); return match?.[1] === undefined || match[2] === undefined || match[1] === 'model' ? [] : [[match[1], match[2]]]; @@ -39,30 +40,31 @@ function fieldTypes(printed: string): Record { } describe('contract print spells each date and time column', () => { - it('as the type contract infer writes, or else as the explicit type that reads the column back', async () => { + it('as the convenience type it becomes, with the arguments that differ from the defaults, or else as its explicit type', async () => { const contract = await readPsl(SCHEMA); expect(fieldTypes(printContract(contract).text)).toEqual({ id: 'Int', - instant: 'TimestamptzTemporal', - zoned: 'TimestamptzTemporal', - zoned3: 'TimestamptzTemporal(3)', + instant: 'DateTime', + zoned: 'DateTime(as: temporal)', + zoned3: 'DateTime(as: temporal, precision: 3)', local: 'TimestampTemporal', local3: 'TimestampTemporal(3)', - utcDate: 'TimestampJsDate(3)', - utcAt: 'TimestampInstant(6)', - dayT: 'DateTemporal', - clockT: 'TimeTemporal(0)', - period: 'IntervalParts', - span: 'IntervalString(3)', - day: 'DateTemporal', - clock: 'TimeTemporal', - clock3: 'TimeTemporal(3)', - zonedText: 'TimestamptzString(3)', - localText: 'TimestampString(3)', - dayText: 'DateString', - clockText: 'TimeString(3)', - offset: 'Timetz', + utcDate: 'DateTime(storage: timestamp)', + utcAt: 'DateTime(as: temporal, storage: timestamp, precision: 6)', + dayT: 'Date(as: temporal)', + clockT: 'Time(as: temporal, precision: 0)', + period: 'Duration(as: parts)', + span: 'Duration(precision: 3)', + day: 'Date', + clock: 'Time', + clock3: 'Time(precision: 3)', + zonedText: 'DateTime(as: string, precision: 3)', + localText: 'DateTime(as: string, storage: timestamp, precision: 3)', + dayText: 'Date', + clockText: 'Time(precision: 3)', + offset: 'Time(storage: timetz)', + lasts: 'Duration', }); expect(await printAndReadBack(contract)).toEqual(contract); }); diff --git a/test/integration/test/psl-print/generated-values-roundtrip.integration.test.ts b/test/integration/test/psl-print/generated-values-roundtrip.integration.test.ts index b481dacd16c6..50ecd8d90dfd 100644 --- a/test/integration/test/psl-print/generated-values-roundtrip.integration.test.ts +++ b/test/integration/test/psl-print/generated-values-roundtrip.integration.test.ts @@ -84,7 +84,7 @@ describe('every generated value the stack reads prints as PSL that reads back', aliases: temporalTypeAliases(stack.authoringContributions.type), now: nowGeneratedTypes(stack), }).toMatchObject({ - aliases: expect.arrayContaining(['temporal.CreatedAt', 'temporal.UpdatedAtJsDate']), + aliases: expect.arrayContaining(['temporal.CreatedAt', 'temporal.UpdatedAt']), now: expect.arrayContaining(['TimestamptzTemporal @onCreate(now()) @onUpdate(now())']), }); }); @@ -144,8 +144,8 @@ describe('every generated value the stack reads prints as PSL that reads back', model Event { id String @id @onCreate(uuid(7)) code VarChar(32) @onCreate(nanoid()) - seenAt Time(3) @onUpdate(now()) - createdAt DateTime @default(now()) @onCreate(now()) + seenAt TimeTemporal(3) @onUpdate(now()) + createdAt TimestamptzTemporal @default(now()) @onCreate(now()) } `); const fieldLines = printContract(authored) @@ -180,8 +180,8 @@ model Event { fieldLines: [ 'id String @id @onCreate(uuid(7))', 'code VarChar(32) @onCreate(nanoid())', - 'seenAt TimeTemporal(3) @onUpdate(now())', - 'createdAt TimestamptzTemporal @default(now()) @onCreate(now())', + 'seenAt Time(as: temporal, precision: 3) @onUpdate(now())', + 'createdAt DateTime(as: temporal) @default(now()) @onCreate(now())', ], roundTrip: serializedWithoutCapabilities(authored), }); diff --git a/test/integration/test/psl-print/prisma7-convenience-types-print.integration.test.ts b/test/integration/test/psl-print/prisma7-convenience-types-print.integration.test.ts new file mode 100644 index 000000000000..759be33d45a9 --- /dev/null +++ b/test/integration/test/psl-print/prisma7-convenience-types-print.integration.test.ts @@ -0,0 +1,77 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import type { Contract } from '@internal/contract/types'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { prisma7Contract } from '@internal/sql-contract-prisma7/provider'; +import { prisma7PostgresBinding } from '@internal/target-postgres/prisma7-binding'; +import { blindCast } from '@internal/utils/casts'; +import { join } from 'pathe'; +import { afterAll, describe, expect, it } from 'vitest'; +import { + composePostgresStack, + printAndReadBack, + printContract, + serializedWithoutCapabilities, + sourceContext, +} from './print-and-read-back'; + +const PRISMA7_SCHEMA = `datasource db { + provider = "postgresql" +} + +model Event { + id Int @id + at DateTime + zoned DateTime @db.Timestamptz(3) + zoned6 DateTime @db.Timestamptz(6) + day DateTime @db.Date + clock DateTime @db.Time(0) + offset DateTime @db.Timetz(3) + touched DateTime @updatedAt +} +`; + +const directory = mkdtempSync(join(tmpdir(), 'prisma7-convenience-')); + +afterAll(() => { + rmSync(directory, { recursive: true, force: true }); +}); + +async function readPrisma7(schema: string): Promise> { + const schemaPath = join(directory, 'schema.prisma'); + writeFileSync(schemaPath, schema); + const result = await prisma7Contract(schemaPath, { binding: prisma7PostgresBinding }).source.load( + sourceContext(composePostgresStack(), [schemaPath]), + ); + if (!result.ok) throw new Error(JSON.stringify(result.failure.diagnostics)); + return blindCast, 'the Prisma 7 source yields a SQL contract'>(result.value); +} + +function fieldLines(printed: string): Record { + return Object.fromEntries( + printed.split('\n').flatMap((line) => { + const match = /^\s+(\w+)\s+(.+?)\s*$/.exec(line.replace(/\s+/g, ' ')); + return match?.[1] === undefined || match[2] === undefined || match[1] === 'model' + ? [] + : [[match[1], match[2]]]; + }), + ); +} + +describe('a Prisma 7 schema printed after prisma7Schema reads it', () => { + it('writes each DateTime field as the convenience type its column and value become', async () => { + const contract = await readPrisma7(PRISMA7_SCHEMA); + expect(fieldLines(printContract(contract).text)).toMatchObject({ + at: 'DateTime(storage: timestamp)', + zoned: 'DateTime', + zoned6: 'DateTime(precision: 6)', + day: 'Date', + clock: 'Time(precision: 0)', + offset: 'Time(storage: timetz, precision: 3)', + touched: 'DateTime(storage: timestamp) @onCreate(now()) @onUpdate(now())', + }); + const printed = await printAndReadBack(contract); + expect(serializedWithoutCapabilities(printed)).toEqual(serializedWithoutCapabilities(contract)); + expect(printed.storage.storageHash).toBe(contract.storage.storageHash); + }); +}); diff --git a/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts index b1ddc9bf2d63..c5e0c4266f02 100644 --- a/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts +++ b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts @@ -178,7 +178,7 @@ describe('PSL scalar-list end-to-end', { concurrent: false }, () => { model Reading { id Int @id - dates DateTime[] + dates TimestamptzTemporal[] payloads Bytes[] amounts Amount[] }`); diff --git a/test/integration/test/sql-expression-defaults/default-stored-form.integration.test.ts b/test/integration/test/sql-expression-defaults/default-stored-form.integration.test.ts index 3a6dd1e2c7d7..deba80697b4a 100644 --- a/test/integration/test/sql-expression-defaults/default-stored-form.integration.test.ts +++ b/test/integration/test/sql-expression-defaults/default-stored-form.integration.test.ts @@ -29,7 +29,7 @@ const EXPLANATION = `The database reads this default back as sql\`${STORED}\`; t async function contractWithDefault(expression: string): Promise> { const authored = await authorSqlContractFromPsl(`model Event { id Int @id - at DateTime @default(sql\`${expression}\`) + at TimestamptzTemporal @default(sql\`${expression}\`) }`); expect(authored.diagnostics).toEqual([]); if (authored.contract === undefined) throw new Error('authoring produced no contract'); diff --git a/test/integration/test/sql-orm-client/fixtures/soft-delete/contract.prisma b/test/integration/test/sql-orm-client/fixtures/soft-delete/contract.prisma index 5b879ee6875b..176996849795 100644 --- a/test/integration/test/sql-orm-client/fixtures/soft-delete/contract.prisma +++ b/test/integration/test/sql-orm-client/fixtures/soft-delete/contract.prisma @@ -24,8 +24,8 @@ model Post { title String views Int userId Int @map("user_id") - createdAt DateTime @default(now()) @map("created_at") - deletedAt DateTime? @map("deleted_at") + createdAt TimestamptzTemporal @default(now()) @map("created_at") + deletedAt TimestamptzTemporal? @map("deleted_at") user User @relation(fields: [userId], references: [id]) comments Comment[] @@ -37,7 +37,7 @@ model Comment { id Int @id @default(autoincrement()) body String postId Int @map("post_id") - deletedAt DateTime? @map("deleted_at") + deletedAt TimestamptzTemporal? @map("deleted_at") post Post @relation(fields: [postId], references: [id]) diff --git a/test/integration/test/temporal-defaults/_fixture-aliases/contract.prisma b/test/integration/test/temporal-defaults/_fixture-aliases/contract.prisma index 7f925bd59fbe..4291955c1a99 100644 --- a/test/integration/test/temporal-defaults/_fixture-aliases/contract.prisma +++ b/test/integration/test/temporal-defaults/_fixture-aliases/contract.prisma @@ -1,11 +1,9 @@ // use prisma-8 model Reading { - id Int @id - label String - createdAt temporal.CreatedAt - updatedAt temporal.UpdatedAt - createdAtText temporal.CreatedAtString - updatedAtText temporal.UpdatedAtString + id Int @id + label String + createdAt temporal.CreatedAt + updatedAt temporal.UpdatedAt @@map("reading") } diff --git a/test/integration/test/temporal-defaults/_fixture-aliases/generated/contract.d.ts b/test/integration/test/temporal-defaults/_fixture-aliases/generated/contract.d.ts index 49794a4a1845..9cb4994f883f 100644 --- a/test/integration/test/temporal-defaults/_fixture-aliases/generated/contract.d.ts +++ b/test/integration/test/temporal-defaults/_fixture-aliases/generated/contract.d.ts @@ -23,9 +23,9 @@ import type { } from '@internal/contract/types'; export type StorageHash = - StorageHashBase<'0390a420cce478c0e3a83722ef67ca5c5cee0029919f8b950ef83fc5e6b97fa4'>; + StorageHashBase<'28b63df973d82396c9d1242834b6c0b55bb341a68354a8c69ace7a77828fcfde'>; export type ExecutionHash = - ExecutionHashBase<'094fab307f2b28070b48b8e4b97fb2c86739cfacf1c3412304356e5c5a43e05e'>; + ExecutionHashBase<'ffef81acb63dde0c1f9cecb125bbe5f651bfcb4dceae4abe22902f9e2350038f'>; export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; @@ -306,11 +306,9 @@ export type StorageColumnInputTypes = ColumnTypesFromStorage< export namespace Models { export type public_Reading = { createdAt: FieldOutputTypes['public']['Reading']['createdAt']; - createdAtText: FieldOutputTypes['public']['Reading']['createdAtText']; id: FieldOutputTypes['public']['Reading']['id']; label: FieldOutputTypes['public']['Reading']['label']; updatedAt: FieldOutputTypes['public']['Reading']['updatedAt']; - updatedAtText: FieldOutputTypes['public']['Reading']['updatedAtText']; readonly [RelationKeys]?: never; }; } @@ -343,14 +341,9 @@ type ContractBase = Omit< columns: { readonly createdAt: { readonly dataType: 'pg/timestamptz'; - readonly codecId: 'pg/timestamptz-temporal@1'; - readonly nullable: false; - readonly many: false; - }; - readonly createdAtText: { - readonly dataType: 'pg/timestamptz'; - readonly codecId: 'pg/timestamptz-string@1'; + readonly codecId: 'pg/timestamptz-date@1'; readonly nullable: false; + readonly typeParams: { readonly precision: 3 }; readonly many: false; }; readonly id: { @@ -367,14 +360,9 @@ type ContractBase = Omit< }; readonly updatedAt: { readonly dataType: 'pg/timestamptz'; - readonly codecId: 'pg/timestamptz-temporal@1'; - readonly nullable: false; - readonly many: false; - }; - readonly updatedAtText: { - readonly dataType: 'pg/timestamptz'; - readonly codecId: 'pg/timestamptz-string@1'; + readonly codecId: 'pg/timestamptz-date@1'; readonly nullable: false; + readonly typeParams: { readonly precision: 3 }; readonly many: false; }; }; @@ -406,14 +394,8 @@ type ContractBase = Omit< readonly nullable: false; readonly type: { readonly kind: 'scalar'; - readonly codecId: 'pg/timestamptz-temporal@1'; - }; - }; - readonly createdAtText: { - readonly nullable: false; - readonly type: { - readonly kind: 'scalar'; - readonly codecId: 'pg/timestamptz-string@1'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly typeParams: { readonly precision: 3 }; }; }; readonly id: { @@ -428,14 +410,8 @@ type ContractBase = Omit< readonly nullable: false; readonly type: { readonly kind: 'scalar'; - readonly codecId: 'pg/timestamptz-temporal@1'; - }; - }; - readonly updatedAtText: { - readonly nullable: false; - readonly type: { - readonly kind: 'scalar'; - readonly codecId: 'pg/timestamptz-string@1'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly typeParams: { readonly precision: 3 }; }; }; }; @@ -445,11 +421,9 @@ type ContractBase = Omit< readonly namespaceId: 'public'; readonly fields: { readonly createdAt: { readonly column: 'createdAt' }; - readonly createdAtText: { readonly column: 'createdAtText' }; readonly id: { readonly column: 'id' }; readonly label: { readonly column: 'label' }; readonly updatedAt: { readonly column: 'updatedAt' }; - readonly updatedAtText: { readonly column: 'updatedAtText' }; }; }; }; @@ -490,7 +464,11 @@ type ContractBase = Omit< readonly mutations: { readonly defaults: readonly [ { - readonly onCreate: { readonly id: 'instantNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; readonly ref: { readonly entry: 'reading'; readonly field: 'createdAt'; @@ -498,28 +476,19 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'timestamptzStringNow'; readonly kind: 'generator' }; - readonly ref: { - readonly entry: 'reading'; - readonly field: 'createdAtText'; - readonly namespace: 'public'; + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; }; - }, - { - readonly onCreate: { readonly id: 'instantNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'instantNow'; readonly kind: 'generator' }; - readonly ref: { - readonly entry: 'reading'; - readonly field: 'updatedAt'; - readonly namespace: 'public'; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; }; - }, - { - readonly onCreate: { readonly id: 'timestamptzStringNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'timestamptzStringNow'; readonly kind: 'generator' }; readonly ref: { readonly entry: 'reading'; - readonly field: 'updatedAtText'; + readonly field: 'updatedAt'; readonly namespace: 'public'; }; }, diff --git a/test/integration/test/temporal-defaults/_fixture-aliases/generated/contract.json b/test/integration/test/temporal-defaults/_fixture-aliases/generated/contract.json index f656b2a3f2e6..c89a51e26092 100644 --- a/test/integration/test/temporal-defaults/_fixture-aliases/generated/contract.json +++ b/test/integration/test/temporal-defaults/_fixture-aliases/generated/contract.json @@ -18,15 +18,11 @@ "createdAt": { "nullable": false, "type": { - "codecId": "pg/timestamptz-temporal@1", - "kind": "scalar" - } - }, - "createdAtText": { - "nullable": false, - "type": { - "codecId": "pg/timestamptz-string@1", - "kind": "scalar" + "codecId": "pg/timestamptz-date@1", + "kind": "scalar", + "typeParams": { + "precision": 3 + } } }, "id": { @@ -46,15 +42,11 @@ "updatedAt": { "nullable": false, "type": { - "codecId": "pg/timestamptz-temporal@1", - "kind": "scalar" - } - }, - "updatedAtText": { - "nullable": false, - "type": { - "codecId": "pg/timestamptz-string@1", - "kind": "scalar" + "codecId": "pg/timestamptz-date@1", + "kind": "scalar", + "typeParams": { + "precision": 3 + } } } }, @@ -64,9 +56,6 @@ "createdAt": { "column": "createdAt" }, - "createdAtText": { - "column": "createdAtText" - }, "id": { "column": "id" }, @@ -75,9 +64,6 @@ }, "updatedAt": { "column": "updatedAt" - }, - "updatedAtText": { - "column": "updatedAtText" } }, "namespaceId": "public", @@ -96,14 +82,12 @@ "reading": { "columns": { "createdAt": { - "codecId": "pg/timestamptz-temporal@1", + "codecId": "pg/timestamptz-date@1", "dataType": "pg/timestamptz", - "nullable": false - }, - "createdAtText": { - "codecId": "pg/timestamptz-string@1", - "dataType": "pg/timestamptz", - "nullable": false + "nullable": false, + "typeParams": { + "precision": 3 + } }, "id": { "codecId": "pg/int4@1", @@ -116,14 +100,12 @@ "nullable": false }, "updatedAt": { - "codecId": "pg/timestamptz-temporal@1", + "codecId": "pg/timestamptz-date@1", "dataType": "pg/timestamptz", - "nullable": false - }, - "updatedAtText": { - "codecId": "pg/timestamptz-string@1", - "dataType": "pg/timestamptz", - "nullable": false + "nullable": false, + "typeParams": { + "precision": 3 + } } }, "foreignKeys": [], @@ -141,16 +123,19 @@ "kind": "postgres-schema" } }, - "storageHash": "0390a420cce478c0e3a83722ef67ca5c5cee0029919f8b950ef83fc5e6b97fa4" + "storageHash": "28b63df973d82396c9d1242834b6c0b55bb341a68354a8c69ace7a77828fcfde" }, "execution": { - "executionHash": "094fab307f2b28070b48b8e4b97fb2c86739cfacf1c3412304356e5c5a43e05e", + "executionHash": "ffef81acb63dde0c1f9cecb125bbe5f651bfcb4dceae4abe22902f9e2350038f", "mutations": { "defaults": [ { "onCreate": { - "id": "instantNow", - "kind": "generator" + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "reading", @@ -160,44 +145,24 @@ }, { "onCreate": { - "id": "timestamptzStringNow", - "kind": "generator" - }, - "ref": { - "entry": "reading", - "field": "createdAtText", - "namespace": "public" - } - }, - { - "onCreate": { - "id": "instantNow", - "kind": "generator" + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 3 + } }, "onUpdate": { - "id": "instantNow", - "kind": "generator" + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "reading", "field": "updatedAt", "namespace": "public" } - }, - { - "onCreate": { - "id": "timestamptzStringNow", - "kind": "generator" - }, - "onUpdate": { - "id": "timestamptzStringNow", - "kind": "generator" - }, - "ref": { - "entry": "reading", - "field": "updatedAtText", - "namespace": "public" - } } ] } diff --git a/test/integration/test/temporal-defaults/_fixture-attributes/contract.prisma b/test/integration/test/temporal-defaults/_fixture-attributes/contract.prisma index 2b67697fa903..1e0ef91abaaf 100644 --- a/test/integration/test/temporal-defaults/_fixture-attributes/contract.prisma +++ b/test/integration/test/temporal-defaults/_fixture-attributes/contract.prisma @@ -3,11 +3,11 @@ model Clock { id Int @id label String - date Date @onCreate(now()) @onUpdate(now()) + date DateTemporal @onCreate(now()) @onUpdate(now()) dateString DateString @onCreate(now()) @onUpdate(now()) - time0 Time(0) @onCreate(now()) @onUpdate(now()) - time3 Time(3) @onCreate(now()) @onUpdate(now()) - time6 Time(6) @onCreate(now()) @onUpdate(now()) + time0 TimeTemporal(0) @onCreate(now()) @onUpdate(now()) + time3 TimeTemporal(3) @onCreate(now()) @onUpdate(now()) + time6 TimeTemporal(6) @onCreate(now()) @onUpdate(now()) timeString0 TimeString(0) @onCreate(now()) @onUpdate(now()) timeString3 TimeString(3) @onCreate(now()) @onUpdate(now()) timeString6 TimeString(6) @onCreate(now()) @onUpdate(now()) @@ -15,12 +15,14 @@ model Clock { timetz3 Timetz(3) @onCreate(now()) @onUpdate(now()) timetz6 Timetz(6) @onCreate(now()) @onUpdate(now()) timestamp0 TimestampTemporal(0) @onCreate(now()) @onUpdate(now()) + timestamp1 TimestampTemporal(1) @onCreate(now()) @onUpdate(now()) timestamp3 TimestampTemporal(3) @onCreate(now()) @onUpdate(now()) timestamp6 TimestampTemporal(6) @onCreate(now()) @onUpdate(now()) timestampString0 TimestampString(0) @onCreate(now()) @onUpdate(now()) timestampString3 TimestampString(3) @onCreate(now()) @onUpdate(now()) timestampString6 TimestampString(6) @onCreate(now()) @onUpdate(now()) timestamptz0 TimestamptzTemporal(0) @onCreate(now()) @onUpdate(now()) + timestamptz2 TimestamptzTemporal(2) @onCreate(now()) @onUpdate(now()) timestamptz3 TimestamptzTemporal(3) @onCreate(now()) @onUpdate(now()) timestamptz6 TimestamptzTemporal(6) @onCreate(now()) @onUpdate(now()) timestamptzString0 TimestamptzString(0) @onCreate(now()) @onUpdate(now()) @@ -29,7 +31,15 @@ model Clock { timestamptzString3 TimestamptzString(3) @onCreate(now()) @onUpdate(now()) timestamptzString6 TimestamptzString(6) @onCreate(now()) @onUpdate(now()) timestamptzJsDate0 TimestamptzJsDate(0) @onCreate(now()) @onUpdate(now()) + timestamptzJsDate1 TimestamptzJsDate(1) @onCreate(now()) @onUpdate(now()) + timestamptzJsDate2 TimestamptzJsDate(2) @onCreate(now()) @onUpdate(now()) timestamptzJsDate3 TimestamptzJsDate(3) @onCreate(now()) @onUpdate(now()) timestamptzJsDate6 TimestamptzJsDate(6) @onCreate(now()) @onUpdate(now()) + timestampJsDate0 TimestampJsDate(0) @onCreate(now()) @onUpdate(now()) + timestampJsDate1 TimestampJsDate(1) @onCreate(now()) @onUpdate(now()) + timestampJsDate2 TimestampJsDate(2) @onCreate(now()) @onUpdate(now()) + timestampInstant0 TimestampInstant(0) @onCreate(now()) @onUpdate(now()) + timestampInstant2 TimestampInstant(2) @onCreate(now()) @onUpdate(now()) + dateTime0 DateTime(precision: 0) @onCreate(now()) @onUpdate(now()) @@map("clock") } diff --git a/test/integration/test/temporal-defaults/_fixture-attributes/generated/contract.d.ts b/test/integration/test/temporal-defaults/_fixture-attributes/generated/contract.d.ts index 45dc1e666356..cb5fffad07d9 100644 --- a/test/integration/test/temporal-defaults/_fixture-attributes/generated/contract.d.ts +++ b/test/integration/test/temporal-defaults/_fixture-attributes/generated/contract.d.ts @@ -23,9 +23,9 @@ import type { } from '@internal/contract/types'; export type StorageHash = - StorageHashBase<'e912a0ac1c484ed92ae8cb217638a889ad671c1f4a9bfb3e469ebfb14916c6dd'>; + StorageHashBase<'738a9bc34c15df72b535e7a13fcf0e72883a57d137b88ff175eeed75af13f33b'>; export type ExecutionHash = - ExecutionHashBase<'51be7e21b1611b41897bee6131dc8d5bfc6abd6380ca0bd6ba5a86d1c2f479c4'>; + ExecutionHashBase<'4eaa44c328b6ff71b05bc0c8397058c76293da5aad12e41c649f4c2f63fb67f6'>; export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; @@ -307,6 +307,7 @@ export namespace Models { export type public_Clock = { date: FieldOutputTypes['public']['Clock']['date']; dateString: FieldOutputTypes['public']['Clock']['dateString']; + dateTime0: FieldOutputTypes['public']['Clock']['dateTime0']; id: FieldOutputTypes['public']['Clock']['id']; label: FieldOutputTypes['public']['Clock']['label']; time0: FieldOutputTypes['public']['Clock']['time0']; @@ -316,15 +317,24 @@ export namespace Models { timeString3: FieldOutputTypes['public']['Clock']['timeString3']; timeString6: FieldOutputTypes['public']['Clock']['timeString6']; timestamp0: FieldOutputTypes['public']['Clock']['timestamp0']; + timestamp1: FieldOutputTypes['public']['Clock']['timestamp1']; timestamp3: FieldOutputTypes['public']['Clock']['timestamp3']; timestamp6: FieldOutputTypes['public']['Clock']['timestamp6']; + timestampInstant0: FieldOutputTypes['public']['Clock']['timestampInstant0']; + timestampInstant2: FieldOutputTypes['public']['Clock']['timestampInstant2']; + timestampJsDate0: FieldOutputTypes['public']['Clock']['timestampJsDate0']; + timestampJsDate1: FieldOutputTypes['public']['Clock']['timestampJsDate1']; + timestampJsDate2: FieldOutputTypes['public']['Clock']['timestampJsDate2']; timestampString0: FieldOutputTypes['public']['Clock']['timestampString0']; timestampString3: FieldOutputTypes['public']['Clock']['timestampString3']; timestampString6: FieldOutputTypes['public']['Clock']['timestampString6']; timestamptz0: FieldOutputTypes['public']['Clock']['timestamptz0']; + timestamptz2: FieldOutputTypes['public']['Clock']['timestamptz2']; timestamptz3: FieldOutputTypes['public']['Clock']['timestamptz3']; timestamptz6: FieldOutputTypes['public']['Clock']['timestamptz6']; timestamptzJsDate0: FieldOutputTypes['public']['Clock']['timestamptzJsDate0']; + timestamptzJsDate1: FieldOutputTypes['public']['Clock']['timestamptzJsDate1']; + timestamptzJsDate2: FieldOutputTypes['public']['Clock']['timestamptzJsDate2']; timestamptzJsDate3: FieldOutputTypes['public']['Clock']['timestamptzJsDate3']; timestamptzJsDate6: FieldOutputTypes['public']['Clock']['timestamptzJsDate6']; timestamptzString0: FieldOutputTypes['public']['Clock']['timestamptzString0']; @@ -377,6 +387,13 @@ type ContractBase = Omit< readonly nullable: false; readonly many: false; }; + readonly dateTime0: { + readonly dataType: 'pg/timestamptz'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 0 }; + readonly many: false; + }; readonly id: { readonly dataType: 'pg/int4'; readonly codecId: 'pg/int4@1'; @@ -438,6 +455,13 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; readonly many: false; }; + readonly timestamp1: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-temporal@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 1 }; + readonly many: false; + }; readonly timestamp3: { readonly dataType: 'pg/timestamp'; readonly codecId: 'pg/timestamp-temporal@1'; @@ -452,6 +476,41 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 6 }; readonly many: false; }; + readonly timestampInstant0: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-instant@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 0 }; + readonly many: false; + }; + readonly timestampInstant2: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-instant@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 2 }; + readonly many: false; + }; + readonly timestampJsDate0: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 0 }; + readonly many: false; + }; + readonly timestampJsDate1: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 1 }; + readonly many: false; + }; + readonly timestampJsDate2: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 2 }; + readonly many: false; + }; readonly timestampString0: { readonly dataType: 'pg/timestamp'; readonly codecId: 'pg/timestamp-string@1'; @@ -480,6 +539,13 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; readonly many: false; }; + readonly timestamptz2: { + readonly dataType: 'pg/timestamptz'; + readonly codecId: 'pg/timestamptz-temporal@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 2 }; + readonly many: false; + }; readonly timestamptz3: { readonly dataType: 'pg/timestamptz'; readonly codecId: 'pg/timestamptz-temporal@1'; @@ -501,6 +567,20 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; readonly many: false; }; + readonly timestamptzJsDate1: { + readonly dataType: 'pg/timestamptz'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 1 }; + readonly many: false; + }; + readonly timestamptzJsDate2: { + readonly dataType: 'pg/timestamptz'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 2 }; + readonly many: false; + }; readonly timestamptzJsDate3: { readonly dataType: 'pg/timestamptz'; readonly codecId: 'pg/timestamptz-date@1'; @@ -604,6 +684,14 @@ type ContractBase = Omit< readonly nullable: false; readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/date-string@1' }; }; + readonly dateTime0: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly typeParams: { readonly precision: 0 }; + }; + }; readonly id: { readonly nullable: false; readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; @@ -668,6 +756,14 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; }; }; + readonly timestamp1: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-temporal@1'; + readonly typeParams: { readonly precision: 1 }; + }; + }; readonly timestamp3: { readonly nullable: false; readonly type: { @@ -684,6 +780,46 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 6 }; }; }; + readonly timestampInstant0: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-instant@1'; + readonly typeParams: { readonly precision: 0 }; + }; + }; + readonly timestampInstant2: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-instant@1'; + readonly typeParams: { readonly precision: 2 }; + }; + }; + readonly timestampJsDate0: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-date@1'; + readonly typeParams: { readonly precision: 0 }; + }; + }; + readonly timestampJsDate1: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-date@1'; + readonly typeParams: { readonly precision: 1 }; + }; + }; + readonly timestampJsDate2: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-date@1'; + readonly typeParams: { readonly precision: 2 }; + }; + }; readonly timestampString0: { readonly nullable: false; readonly type: { @@ -716,6 +852,14 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; }; }; + readonly timestamptz2: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamptz-temporal@1'; + readonly typeParams: { readonly precision: 2 }; + }; + }; readonly timestamptz3: { readonly nullable: false; readonly type: { @@ -740,6 +884,22 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; }; }; + readonly timestamptzJsDate1: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly typeParams: { readonly precision: 1 }; + }; + }; + readonly timestamptzJsDate2: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly typeParams: { readonly precision: 2 }; + }; + }; readonly timestamptzJsDate3: { readonly nullable: false; readonly type: { @@ -828,6 +988,7 @@ type ContractBase = Omit< readonly fields: { readonly date: { readonly column: 'date' }; readonly dateString: { readonly column: 'dateString' }; + readonly dateTime0: { readonly column: 'dateTime0' }; readonly id: { readonly column: 'id' }; readonly label: { readonly column: 'label' }; readonly time0: { readonly column: 'time0' }; @@ -837,15 +998,24 @@ type ContractBase = Omit< readonly timeString3: { readonly column: 'timeString3' }; readonly timeString6: { readonly column: 'timeString6' }; readonly timestamp0: { readonly column: 'timestamp0' }; + readonly timestamp1: { readonly column: 'timestamp1' }; readonly timestamp3: { readonly column: 'timestamp3' }; readonly timestamp6: { readonly column: 'timestamp6' }; + readonly timestampInstant0: { readonly column: 'timestampInstant0' }; + readonly timestampInstant2: { readonly column: 'timestampInstant2' }; + readonly timestampJsDate0: { readonly column: 'timestampJsDate0' }; + readonly timestampJsDate1: { readonly column: 'timestampJsDate1' }; + readonly timestampJsDate2: { readonly column: 'timestampJsDate2' }; readonly timestampString0: { readonly column: 'timestampString0' }; readonly timestampString3: { readonly column: 'timestampString3' }; readonly timestampString6: { readonly column: 'timestampString6' }; readonly timestamptz0: { readonly column: 'timestamptz0' }; + readonly timestamptz2: { readonly column: 'timestamptz2' }; readonly timestamptz3: { readonly column: 'timestamptz3' }; readonly timestamptz6: { readonly column: 'timestamptz6' }; readonly timestamptzJsDate0: { readonly column: 'timestamptzJsDate0' }; + readonly timestamptzJsDate1: { readonly column: 'timestamptzJsDate1' }; + readonly timestamptzJsDate2: { readonly column: 'timestamptzJsDate2' }; readonly timestamptzJsDate3: { readonly column: 'timestamptzJsDate3' }; readonly timestamptzJsDate6: { readonly column: 'timestamptzJsDate6' }; readonly timestamptzString0: { readonly column: 'timestamptzString0' }; @@ -913,6 +1083,23 @@ type ContractBase = Omit< readonly namespace: 'public'; }; }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'dateTime0'; + readonly namespace: 'public'; + }; + }, { readonly onCreate: { readonly id: 'plainTimeNow'; @@ -1016,8 +1203,16 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamp0'; @@ -1025,8 +1220,33 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly onUpdate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestamp1'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; + readonly onUpdate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamp3'; @@ -1034,14 +1254,107 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; + readonly onUpdate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamp6'; readonly namespace: 'public'; }; }, + { + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestampInstant0'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestampInstant2'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestampJsDate0'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestampJsDate1'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestampJsDate2'; + readonly namespace: 'public'; + }; + }, { readonly onCreate: { readonly id: 'timestampStringNow'; @@ -1094,8 +1407,16 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'instantNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'instantNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptz0'; @@ -1103,8 +1424,33 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'instantNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'instantNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestamptz2'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptz3'; @@ -1112,8 +1458,16 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'instantNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'instantNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptz6'; @@ -1121,8 +1475,16 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptzJsDate0'; @@ -1130,8 +1492,50 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestamptzJsDate1'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestamptzJsDate2'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptzJsDate3'; @@ -1139,8 +1543,16 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptzJsDate6'; diff --git a/test/integration/test/temporal-defaults/_fixture-attributes/generated/contract.json b/test/integration/test/temporal-defaults/_fixture-attributes/generated/contract.json index b857e938d057..10e456cc02d1 100644 --- a/test/integration/test/temporal-defaults/_fixture-attributes/generated/contract.json +++ b/test/integration/test/temporal-defaults/_fixture-attributes/generated/contract.json @@ -29,6 +29,16 @@ "kind": "scalar" } }, + "dateTime0": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz-date@1", + "kind": "scalar", + "typeParams": { + "precision": 0 + } + } + }, "id": { "nullable": false, "type": { @@ -113,6 +123,16 @@ } } }, + "timestamp1": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-temporal@1", + "kind": "scalar", + "typeParams": { + "precision": 1 + } + } + }, "timestamp3": { "nullable": false, "type": { @@ -133,6 +153,56 @@ } } }, + "timestampInstant0": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-instant@1", + "kind": "scalar", + "typeParams": { + "precision": 0 + } + } + }, + "timestampInstant2": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-instant@1", + "kind": "scalar", + "typeParams": { + "precision": 2 + } + } + }, + "timestampJsDate0": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-date@1", + "kind": "scalar", + "typeParams": { + "precision": 0 + } + } + }, + "timestampJsDate1": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-date@1", + "kind": "scalar", + "typeParams": { + "precision": 1 + } + } + }, + "timestampJsDate2": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-date@1", + "kind": "scalar", + "typeParams": { + "precision": 2 + } + } + }, "timestampString0": { "nullable": false, "type": { @@ -173,6 +243,16 @@ } } }, + "timestamptz2": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz-temporal@1", + "kind": "scalar", + "typeParams": { + "precision": 2 + } + } + }, "timestamptz3": { "nullable": false, "type": { @@ -203,6 +283,26 @@ } } }, + "timestamptzJsDate1": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz-date@1", + "kind": "scalar", + "typeParams": { + "precision": 1 + } + } + }, + "timestamptzJsDate2": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz-date@1", + "kind": "scalar", + "typeParams": { + "precision": 2 + } + } + }, "timestamptzJsDate3": { "nullable": false, "type": { @@ -313,6 +413,9 @@ "dateString": { "column": "dateString" }, + "dateTime0": { + "column": "dateTime0" + }, "id": { "column": "id" }, @@ -340,12 +443,30 @@ "timestamp0": { "column": "timestamp0" }, + "timestamp1": { + "column": "timestamp1" + }, "timestamp3": { "column": "timestamp3" }, "timestamp6": { "column": "timestamp6" }, + "timestampInstant0": { + "column": "timestampInstant0" + }, + "timestampInstant2": { + "column": "timestampInstant2" + }, + "timestampJsDate0": { + "column": "timestampJsDate0" + }, + "timestampJsDate1": { + "column": "timestampJsDate1" + }, + "timestampJsDate2": { + "column": "timestampJsDate2" + }, "timestampString0": { "column": "timestampString0" }, @@ -358,6 +479,9 @@ "timestamptz0": { "column": "timestamptz0" }, + "timestamptz2": { + "column": "timestamptz2" + }, "timestamptz3": { "column": "timestamptz3" }, @@ -367,6 +491,12 @@ "timestamptzJsDate0": { "column": "timestamptzJsDate0" }, + "timestamptzJsDate1": { + "column": "timestamptzJsDate1" + }, + "timestamptzJsDate2": { + "column": "timestamptzJsDate2" + }, "timestamptzJsDate3": { "column": "timestamptzJsDate3" }, @@ -423,6 +553,14 @@ "dataType": "pg/date", "nullable": false }, + "dateTime0": { + "codecId": "pg/timestamptz-date@1", + "dataType": "pg/timestamptz", + "nullable": false, + "typeParams": { + "precision": 0 + } + }, "id": { "codecId": "pg/int4@1", "dataType": "pg/int4", @@ -489,6 +627,14 @@ "precision": 0 } }, + "timestamp1": { + "codecId": "pg/timestamp-temporal@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 1 + } + }, "timestamp3": { "codecId": "pg/timestamp-temporal@1", "dataType": "pg/timestamp", @@ -505,6 +651,46 @@ "precision": 6 } }, + "timestampInstant0": { + "codecId": "pg/timestamp-instant@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 0 + } + }, + "timestampInstant2": { + "codecId": "pg/timestamp-instant@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 2 + } + }, + "timestampJsDate0": { + "codecId": "pg/timestamp-date@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 0 + } + }, + "timestampJsDate1": { + "codecId": "pg/timestamp-date@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 1 + } + }, + "timestampJsDate2": { + "codecId": "pg/timestamp-date@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 2 + } + }, "timestampString0": { "codecId": "pg/timestamp-string@1", "dataType": "pg/timestamp", @@ -537,6 +723,14 @@ "precision": 0 } }, + "timestamptz2": { + "codecId": "pg/timestamptz-temporal@1", + "dataType": "pg/timestamptz", + "nullable": false, + "typeParams": { + "precision": 2 + } + }, "timestamptz3": { "codecId": "pg/timestamptz-temporal@1", "dataType": "pg/timestamptz", @@ -561,6 +755,22 @@ "precision": 0 } }, + "timestamptzJsDate1": { + "codecId": "pg/timestamptz-date@1", + "dataType": "pg/timestamptz", + "nullable": false, + "typeParams": { + "precision": 1 + } + }, + "timestamptzJsDate2": { + "codecId": "pg/timestamptz-date@1", + "dataType": "pg/timestamptz", + "nullable": false, + "typeParams": { + "precision": 2 + } + }, "timestamptzJsDate3": { "codecId": "pg/timestamptz-date@1", "dataType": "pg/timestamptz", @@ -657,10 +867,10 @@ "kind": "postgres-schema" } }, - "storageHash": "e912a0ac1c484ed92ae8cb217638a889ad671c1f4a9bfb3e469ebfb14916c6dd" + "storageHash": "738a9bc34c15df72b535e7a13fcf0e72883a57d137b88ff175eeed75af13f33b" }, "execution": { - "executionHash": "51be7e21b1611b41897bee6131dc8d5bfc6abd6380ca0bd6ba5a86d1c2f479c4", + "executionHash": "4eaa44c328b6ff71b05bc0c8397058c76293da5aad12e41c649f4c2f63fb67f6", "mutations": { "defaults": [ { @@ -693,6 +903,27 @@ "namespace": "public" } }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "ref": { + "entry": "clock", + "field": "dateTime0", + "namespace": "public" + } + }, { "onCreate": { "id": "plainTimeNow", @@ -822,11 +1053,17 @@ { "onCreate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "onUpdate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "ref": { "entry": "clock", @@ -837,11 +1074,38 @@ { "onCreate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 1 + } }, "onUpdate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 1 + } + }, + "ref": { + "entry": "clock", + "field": "timestamp1", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "plainDateTimeNow", + "kind": "generator", + "params": { + "precision": 3 + } + }, + "onUpdate": { + "id": "plainDateTimeNow", + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "clock", @@ -852,11 +1116,17 @@ { "onCreate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "onUpdate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "ref": { "entry": "clock", @@ -864,6 +1134,111 @@ "namespace": "public" } }, + { + "onCreate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "onUpdate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "ref": { + "entry": "clock", + "field": "timestampInstant0", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "onUpdate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "ref": { + "entry": "clock", + "field": "timestampInstant2", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "ref": { + "entry": "clock", + "field": "timestampJsDate0", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 1 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 1 + } + }, + "ref": { + "entry": "clock", + "field": "timestampJsDate1", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "ref": { + "entry": "clock", + "field": "timestampJsDate2", + "namespace": "public" + } + }, { "onCreate": { "id": "timestampStringNow", @@ -930,11 +1305,17 @@ { "onCreate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "onUpdate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "ref": { "entry": "clock", @@ -945,11 +1326,38 @@ { "onCreate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 2 + } }, "onUpdate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 2 + } + }, + "ref": { + "entry": "clock", + "field": "timestamptz2", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 3 + } + }, + "onUpdate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "clock", @@ -960,11 +1368,17 @@ { "onCreate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "onUpdate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "ref": { "entry": "clock", @@ -975,11 +1389,17 @@ { "onCreate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "onUpdate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "ref": { "entry": "clock", @@ -990,11 +1410,59 @@ { "onCreate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 1 + } }, "onUpdate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 1 + } + }, + "ref": { + "entry": "clock", + "field": "timestamptzJsDate1", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "ref": { + "entry": "clock", + "field": "timestamptzJsDate2", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 3 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "clock", @@ -1005,11 +1473,17 @@ { "onCreate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "onUpdate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "ref": { "entry": "clock", diff --git a/test/integration/test/temporal-defaults/_fixture-now/contract.prisma b/test/integration/test/temporal-defaults/_fixture-now/contract.prisma index 6fc2cda4c860..61e1e2561165 100644 --- a/test/integration/test/temporal-defaults/_fixture-now/contract.prisma +++ b/test/integration/test/temporal-defaults/_fixture-now/contract.prisma @@ -3,11 +3,11 @@ model Clock { id Int @id label String - date Date @onCreate(now()) @onUpdate(now()) + date DateTemporal @onCreate(now()) @onUpdate(now()) dateString DateString @onCreate(now()) @onUpdate(now()) - time0 Time(0) @onCreate(now()) @onUpdate(now()) - time3 Time(3) @onCreate(now()) @onUpdate(now()) - time6 Time(6) @onCreate(now()) @onUpdate(now()) + time0 TimeTemporal(0) @onCreate(now()) @onUpdate(now()) + time3 TimeTemporal(3) @onCreate(now()) @onUpdate(now()) + time6 TimeTemporal(6) @onCreate(now()) @onUpdate(now()) timeString0 TimeString(0) @onCreate(now()) @onUpdate(now()) timeString3 TimeString(3) @onCreate(now()) @onUpdate(now()) timeString6 TimeString(6) @onCreate(now()) @onUpdate(now()) @@ -15,12 +15,14 @@ model Clock { timetz3 Timetz(3) @onCreate(now()) @onUpdate(now()) timetz6 Timetz(6) @onCreate(now()) @onUpdate(now()) timestamp0 TimestampTemporal(0) @onCreate(now()) @onUpdate(now()) + timestamp1 TimestampTemporal(1) @onCreate(now()) @onUpdate(now()) timestamp3 TimestampTemporal(3) @onCreate(now()) @onUpdate(now()) timestamp6 TimestampTemporal(6) @onCreate(now()) @onUpdate(now()) timestampString0 TimestampString(0) @onCreate(now()) @onUpdate(now()) timestampString3 TimestampString(3) @onCreate(now()) @onUpdate(now()) timestampString6 TimestampString(6) @onCreate(now()) @onUpdate(now()) timestamptz0 TimestamptzTemporal(0) @onCreate(now()) @onUpdate(now()) + timestamptz2 TimestamptzTemporal(2) @onCreate(now()) @onUpdate(now()) timestamptz3 TimestamptzTemporal(3) @onCreate(now()) @onUpdate(now()) timestamptz6 TimestamptzTemporal(6) @onCreate(now()) @onUpdate(now()) timestamptzString0 TimestamptzString(0) @onCreate(now()) @onUpdate(now()) @@ -29,7 +31,15 @@ model Clock { timestamptzString3 TimestamptzString(3) @onCreate(now()) @onUpdate(now()) timestamptzString6 TimestamptzString(6) @onCreate(now()) @onUpdate(now()) timestamptzJsDate0 TimestamptzJsDate(0) @onCreate(now()) @onUpdate(now()) + timestamptzJsDate1 TimestamptzJsDate(1) @onCreate(now()) @onUpdate(now()) + timestamptzJsDate2 TimestamptzJsDate(2) @onCreate(now()) @onUpdate(now()) timestamptzJsDate3 TimestamptzJsDate(3) @onCreate(now()) @onUpdate(now()) timestamptzJsDate6 TimestamptzJsDate(6) @onCreate(now()) @onUpdate(now()) + timestampJsDate0 TimestampJsDate(0) @onCreate(now()) @onUpdate(now()) + timestampJsDate1 TimestampJsDate(1) @onCreate(now()) @onUpdate(now()) + timestampJsDate2 TimestampJsDate(2) @onCreate(now()) @onUpdate(now()) + timestampInstant0 TimestampInstant(0) @onCreate(now()) @onUpdate(now()) + timestampInstant2 TimestampInstant(2) @onCreate(now()) @onUpdate(now()) + dateTime0 DateTime(precision: 0) @onCreate(now()) @onUpdate(now()) @@map("clock") } diff --git a/test/integration/test/temporal-defaults/_fixture-now/generated/contract.d.ts b/test/integration/test/temporal-defaults/_fixture-now/generated/contract.d.ts index 45dc1e666356..cb5fffad07d9 100644 --- a/test/integration/test/temporal-defaults/_fixture-now/generated/contract.d.ts +++ b/test/integration/test/temporal-defaults/_fixture-now/generated/contract.d.ts @@ -23,9 +23,9 @@ import type { } from '@internal/contract/types'; export type StorageHash = - StorageHashBase<'e912a0ac1c484ed92ae8cb217638a889ad671c1f4a9bfb3e469ebfb14916c6dd'>; + StorageHashBase<'738a9bc34c15df72b535e7a13fcf0e72883a57d137b88ff175eeed75af13f33b'>; export type ExecutionHash = - ExecutionHashBase<'51be7e21b1611b41897bee6131dc8d5bfc6abd6380ca0bd6ba5a86d1c2f479c4'>; + ExecutionHashBase<'4eaa44c328b6ff71b05bc0c8397058c76293da5aad12e41c649f4c2f63fb67f6'>; export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; @@ -307,6 +307,7 @@ export namespace Models { export type public_Clock = { date: FieldOutputTypes['public']['Clock']['date']; dateString: FieldOutputTypes['public']['Clock']['dateString']; + dateTime0: FieldOutputTypes['public']['Clock']['dateTime0']; id: FieldOutputTypes['public']['Clock']['id']; label: FieldOutputTypes['public']['Clock']['label']; time0: FieldOutputTypes['public']['Clock']['time0']; @@ -316,15 +317,24 @@ export namespace Models { timeString3: FieldOutputTypes['public']['Clock']['timeString3']; timeString6: FieldOutputTypes['public']['Clock']['timeString6']; timestamp0: FieldOutputTypes['public']['Clock']['timestamp0']; + timestamp1: FieldOutputTypes['public']['Clock']['timestamp1']; timestamp3: FieldOutputTypes['public']['Clock']['timestamp3']; timestamp6: FieldOutputTypes['public']['Clock']['timestamp6']; + timestampInstant0: FieldOutputTypes['public']['Clock']['timestampInstant0']; + timestampInstant2: FieldOutputTypes['public']['Clock']['timestampInstant2']; + timestampJsDate0: FieldOutputTypes['public']['Clock']['timestampJsDate0']; + timestampJsDate1: FieldOutputTypes['public']['Clock']['timestampJsDate1']; + timestampJsDate2: FieldOutputTypes['public']['Clock']['timestampJsDate2']; timestampString0: FieldOutputTypes['public']['Clock']['timestampString0']; timestampString3: FieldOutputTypes['public']['Clock']['timestampString3']; timestampString6: FieldOutputTypes['public']['Clock']['timestampString6']; timestamptz0: FieldOutputTypes['public']['Clock']['timestamptz0']; + timestamptz2: FieldOutputTypes['public']['Clock']['timestamptz2']; timestamptz3: FieldOutputTypes['public']['Clock']['timestamptz3']; timestamptz6: FieldOutputTypes['public']['Clock']['timestamptz6']; timestamptzJsDate0: FieldOutputTypes['public']['Clock']['timestamptzJsDate0']; + timestamptzJsDate1: FieldOutputTypes['public']['Clock']['timestamptzJsDate1']; + timestamptzJsDate2: FieldOutputTypes['public']['Clock']['timestamptzJsDate2']; timestamptzJsDate3: FieldOutputTypes['public']['Clock']['timestamptzJsDate3']; timestamptzJsDate6: FieldOutputTypes['public']['Clock']['timestamptzJsDate6']; timestamptzString0: FieldOutputTypes['public']['Clock']['timestamptzString0']; @@ -377,6 +387,13 @@ type ContractBase = Omit< readonly nullable: false; readonly many: false; }; + readonly dateTime0: { + readonly dataType: 'pg/timestamptz'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 0 }; + readonly many: false; + }; readonly id: { readonly dataType: 'pg/int4'; readonly codecId: 'pg/int4@1'; @@ -438,6 +455,13 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; readonly many: false; }; + readonly timestamp1: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-temporal@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 1 }; + readonly many: false; + }; readonly timestamp3: { readonly dataType: 'pg/timestamp'; readonly codecId: 'pg/timestamp-temporal@1'; @@ -452,6 +476,41 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 6 }; readonly many: false; }; + readonly timestampInstant0: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-instant@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 0 }; + readonly many: false; + }; + readonly timestampInstant2: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-instant@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 2 }; + readonly many: false; + }; + readonly timestampJsDate0: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 0 }; + readonly many: false; + }; + readonly timestampJsDate1: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 1 }; + readonly many: false; + }; + readonly timestampJsDate2: { + readonly dataType: 'pg/timestamp'; + readonly codecId: 'pg/timestamp-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 2 }; + readonly many: false; + }; readonly timestampString0: { readonly dataType: 'pg/timestamp'; readonly codecId: 'pg/timestamp-string@1'; @@ -480,6 +539,13 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; readonly many: false; }; + readonly timestamptz2: { + readonly dataType: 'pg/timestamptz'; + readonly codecId: 'pg/timestamptz-temporal@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 2 }; + readonly many: false; + }; readonly timestamptz3: { readonly dataType: 'pg/timestamptz'; readonly codecId: 'pg/timestamptz-temporal@1'; @@ -501,6 +567,20 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; readonly many: false; }; + readonly timestamptzJsDate1: { + readonly dataType: 'pg/timestamptz'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 1 }; + readonly many: false; + }; + readonly timestamptzJsDate2: { + readonly dataType: 'pg/timestamptz'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly nullable: false; + readonly typeParams: { readonly precision: 2 }; + readonly many: false; + }; readonly timestamptzJsDate3: { readonly dataType: 'pg/timestamptz'; readonly codecId: 'pg/timestamptz-date@1'; @@ -604,6 +684,14 @@ type ContractBase = Omit< readonly nullable: false; readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/date-string@1' }; }; + readonly dateTime0: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly typeParams: { readonly precision: 0 }; + }; + }; readonly id: { readonly nullable: false; readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; @@ -668,6 +756,14 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; }; }; + readonly timestamp1: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-temporal@1'; + readonly typeParams: { readonly precision: 1 }; + }; + }; readonly timestamp3: { readonly nullable: false; readonly type: { @@ -684,6 +780,46 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 6 }; }; }; + readonly timestampInstant0: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-instant@1'; + readonly typeParams: { readonly precision: 0 }; + }; + }; + readonly timestampInstant2: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-instant@1'; + readonly typeParams: { readonly precision: 2 }; + }; + }; + readonly timestampJsDate0: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-date@1'; + readonly typeParams: { readonly precision: 0 }; + }; + }; + readonly timestampJsDate1: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-date@1'; + readonly typeParams: { readonly precision: 1 }; + }; + }; + readonly timestampJsDate2: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamp-date@1'; + readonly typeParams: { readonly precision: 2 }; + }; + }; readonly timestampString0: { readonly nullable: false; readonly type: { @@ -716,6 +852,14 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; }; }; + readonly timestamptz2: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamptz-temporal@1'; + readonly typeParams: { readonly precision: 2 }; + }; + }; readonly timestamptz3: { readonly nullable: false; readonly type: { @@ -740,6 +884,22 @@ type ContractBase = Omit< readonly typeParams: { readonly precision: 0 }; }; }; + readonly timestamptzJsDate1: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly typeParams: { readonly precision: 1 }; + }; + }; + readonly timestamptzJsDate2: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/timestamptz-date@1'; + readonly typeParams: { readonly precision: 2 }; + }; + }; readonly timestamptzJsDate3: { readonly nullable: false; readonly type: { @@ -828,6 +988,7 @@ type ContractBase = Omit< readonly fields: { readonly date: { readonly column: 'date' }; readonly dateString: { readonly column: 'dateString' }; + readonly dateTime0: { readonly column: 'dateTime0' }; readonly id: { readonly column: 'id' }; readonly label: { readonly column: 'label' }; readonly time0: { readonly column: 'time0' }; @@ -837,15 +998,24 @@ type ContractBase = Omit< readonly timeString3: { readonly column: 'timeString3' }; readonly timeString6: { readonly column: 'timeString6' }; readonly timestamp0: { readonly column: 'timestamp0' }; + readonly timestamp1: { readonly column: 'timestamp1' }; readonly timestamp3: { readonly column: 'timestamp3' }; readonly timestamp6: { readonly column: 'timestamp6' }; + readonly timestampInstant0: { readonly column: 'timestampInstant0' }; + readonly timestampInstant2: { readonly column: 'timestampInstant2' }; + readonly timestampJsDate0: { readonly column: 'timestampJsDate0' }; + readonly timestampJsDate1: { readonly column: 'timestampJsDate1' }; + readonly timestampJsDate2: { readonly column: 'timestampJsDate2' }; readonly timestampString0: { readonly column: 'timestampString0' }; readonly timestampString3: { readonly column: 'timestampString3' }; readonly timestampString6: { readonly column: 'timestampString6' }; readonly timestamptz0: { readonly column: 'timestamptz0' }; + readonly timestamptz2: { readonly column: 'timestamptz2' }; readonly timestamptz3: { readonly column: 'timestamptz3' }; readonly timestamptz6: { readonly column: 'timestamptz6' }; readonly timestamptzJsDate0: { readonly column: 'timestamptzJsDate0' }; + readonly timestamptzJsDate1: { readonly column: 'timestamptzJsDate1' }; + readonly timestamptzJsDate2: { readonly column: 'timestamptzJsDate2' }; readonly timestamptzJsDate3: { readonly column: 'timestamptzJsDate3' }; readonly timestamptzJsDate6: { readonly column: 'timestamptzJsDate6' }; readonly timestamptzString0: { readonly column: 'timestamptzString0' }; @@ -913,6 +1083,23 @@ type ContractBase = Omit< readonly namespace: 'public'; }; }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'dateTime0'; + readonly namespace: 'public'; + }; + }, { readonly onCreate: { readonly id: 'plainTimeNow'; @@ -1016,8 +1203,16 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamp0'; @@ -1025,8 +1220,33 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly onUpdate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestamp1'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; + readonly onUpdate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamp3'; @@ -1034,14 +1254,107 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; + readonly onUpdate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamp6'; readonly namespace: 'public'; }; }, + { + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestampInstant0'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestampInstant2'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestampJsDate0'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestampJsDate1'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestampJsDate2'; + readonly namespace: 'public'; + }; + }, { readonly onCreate: { readonly id: 'timestampStringNow'; @@ -1094,8 +1407,16 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'instantNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'instantNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptz0'; @@ -1103,8 +1424,33 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'instantNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'instantNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestamptz2'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptz3'; @@ -1112,8 +1458,16 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'instantNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'instantNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptz6'; @@ -1121,8 +1475,16 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 0 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptzJsDate0'; @@ -1130,8 +1492,50 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 1 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestamptzJsDate1'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 2 }; + }; + readonly ref: { + readonly entry: 'clock'; + readonly field: 'timestamptzJsDate2'; + readonly namespace: 'public'; + }; + }, + { + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptzJsDate3'; @@ -1139,8 +1543,16 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'timestampNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; + readonly onUpdate: { + readonly id: 'timestampNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 6 }; + }; readonly ref: { readonly entry: 'clock'; readonly field: 'timestamptzJsDate6'; diff --git a/test/integration/test/temporal-defaults/_fixture-now/generated/contract.json b/test/integration/test/temporal-defaults/_fixture-now/generated/contract.json index b857e938d057..10e456cc02d1 100644 --- a/test/integration/test/temporal-defaults/_fixture-now/generated/contract.json +++ b/test/integration/test/temporal-defaults/_fixture-now/generated/contract.json @@ -29,6 +29,16 @@ "kind": "scalar" } }, + "dateTime0": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz-date@1", + "kind": "scalar", + "typeParams": { + "precision": 0 + } + } + }, "id": { "nullable": false, "type": { @@ -113,6 +123,16 @@ } } }, + "timestamp1": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-temporal@1", + "kind": "scalar", + "typeParams": { + "precision": 1 + } + } + }, "timestamp3": { "nullable": false, "type": { @@ -133,6 +153,56 @@ } } }, + "timestampInstant0": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-instant@1", + "kind": "scalar", + "typeParams": { + "precision": 0 + } + } + }, + "timestampInstant2": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-instant@1", + "kind": "scalar", + "typeParams": { + "precision": 2 + } + } + }, + "timestampJsDate0": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-date@1", + "kind": "scalar", + "typeParams": { + "precision": 0 + } + } + }, + "timestampJsDate1": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-date@1", + "kind": "scalar", + "typeParams": { + "precision": 1 + } + } + }, + "timestampJsDate2": { + "nullable": false, + "type": { + "codecId": "pg/timestamp-date@1", + "kind": "scalar", + "typeParams": { + "precision": 2 + } + } + }, "timestampString0": { "nullable": false, "type": { @@ -173,6 +243,16 @@ } } }, + "timestamptz2": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz-temporal@1", + "kind": "scalar", + "typeParams": { + "precision": 2 + } + } + }, "timestamptz3": { "nullable": false, "type": { @@ -203,6 +283,26 @@ } } }, + "timestamptzJsDate1": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz-date@1", + "kind": "scalar", + "typeParams": { + "precision": 1 + } + } + }, + "timestamptzJsDate2": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz-date@1", + "kind": "scalar", + "typeParams": { + "precision": 2 + } + } + }, "timestamptzJsDate3": { "nullable": false, "type": { @@ -313,6 +413,9 @@ "dateString": { "column": "dateString" }, + "dateTime0": { + "column": "dateTime0" + }, "id": { "column": "id" }, @@ -340,12 +443,30 @@ "timestamp0": { "column": "timestamp0" }, + "timestamp1": { + "column": "timestamp1" + }, "timestamp3": { "column": "timestamp3" }, "timestamp6": { "column": "timestamp6" }, + "timestampInstant0": { + "column": "timestampInstant0" + }, + "timestampInstant2": { + "column": "timestampInstant2" + }, + "timestampJsDate0": { + "column": "timestampJsDate0" + }, + "timestampJsDate1": { + "column": "timestampJsDate1" + }, + "timestampJsDate2": { + "column": "timestampJsDate2" + }, "timestampString0": { "column": "timestampString0" }, @@ -358,6 +479,9 @@ "timestamptz0": { "column": "timestamptz0" }, + "timestamptz2": { + "column": "timestamptz2" + }, "timestamptz3": { "column": "timestamptz3" }, @@ -367,6 +491,12 @@ "timestamptzJsDate0": { "column": "timestamptzJsDate0" }, + "timestamptzJsDate1": { + "column": "timestamptzJsDate1" + }, + "timestamptzJsDate2": { + "column": "timestamptzJsDate2" + }, "timestamptzJsDate3": { "column": "timestamptzJsDate3" }, @@ -423,6 +553,14 @@ "dataType": "pg/date", "nullable": false }, + "dateTime0": { + "codecId": "pg/timestamptz-date@1", + "dataType": "pg/timestamptz", + "nullable": false, + "typeParams": { + "precision": 0 + } + }, "id": { "codecId": "pg/int4@1", "dataType": "pg/int4", @@ -489,6 +627,14 @@ "precision": 0 } }, + "timestamp1": { + "codecId": "pg/timestamp-temporal@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 1 + } + }, "timestamp3": { "codecId": "pg/timestamp-temporal@1", "dataType": "pg/timestamp", @@ -505,6 +651,46 @@ "precision": 6 } }, + "timestampInstant0": { + "codecId": "pg/timestamp-instant@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 0 + } + }, + "timestampInstant2": { + "codecId": "pg/timestamp-instant@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 2 + } + }, + "timestampJsDate0": { + "codecId": "pg/timestamp-date@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 0 + } + }, + "timestampJsDate1": { + "codecId": "pg/timestamp-date@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 1 + } + }, + "timestampJsDate2": { + "codecId": "pg/timestamp-date@1", + "dataType": "pg/timestamp", + "nullable": false, + "typeParams": { + "precision": 2 + } + }, "timestampString0": { "codecId": "pg/timestamp-string@1", "dataType": "pg/timestamp", @@ -537,6 +723,14 @@ "precision": 0 } }, + "timestamptz2": { + "codecId": "pg/timestamptz-temporal@1", + "dataType": "pg/timestamptz", + "nullable": false, + "typeParams": { + "precision": 2 + } + }, "timestamptz3": { "codecId": "pg/timestamptz-temporal@1", "dataType": "pg/timestamptz", @@ -561,6 +755,22 @@ "precision": 0 } }, + "timestamptzJsDate1": { + "codecId": "pg/timestamptz-date@1", + "dataType": "pg/timestamptz", + "nullable": false, + "typeParams": { + "precision": 1 + } + }, + "timestamptzJsDate2": { + "codecId": "pg/timestamptz-date@1", + "dataType": "pg/timestamptz", + "nullable": false, + "typeParams": { + "precision": 2 + } + }, "timestamptzJsDate3": { "codecId": "pg/timestamptz-date@1", "dataType": "pg/timestamptz", @@ -657,10 +867,10 @@ "kind": "postgres-schema" } }, - "storageHash": "e912a0ac1c484ed92ae8cb217638a889ad671c1f4a9bfb3e469ebfb14916c6dd" + "storageHash": "738a9bc34c15df72b535e7a13fcf0e72883a57d137b88ff175eeed75af13f33b" }, "execution": { - "executionHash": "51be7e21b1611b41897bee6131dc8d5bfc6abd6380ca0bd6ba5a86d1c2f479c4", + "executionHash": "4eaa44c328b6ff71b05bc0c8397058c76293da5aad12e41c649f4c2f63fb67f6", "mutations": { "defaults": [ { @@ -693,6 +903,27 @@ "namespace": "public" } }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "ref": { + "entry": "clock", + "field": "dateTime0", + "namespace": "public" + } + }, { "onCreate": { "id": "plainTimeNow", @@ -822,11 +1053,17 @@ { "onCreate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "onUpdate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "ref": { "entry": "clock", @@ -837,11 +1074,38 @@ { "onCreate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 1 + } }, "onUpdate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 1 + } + }, + "ref": { + "entry": "clock", + "field": "timestamp1", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "plainDateTimeNow", + "kind": "generator", + "params": { + "precision": 3 + } + }, + "onUpdate": { + "id": "plainDateTimeNow", + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "clock", @@ -852,11 +1116,17 @@ { "onCreate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "onUpdate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "ref": { "entry": "clock", @@ -864,6 +1134,111 @@ "namespace": "public" } }, + { + "onCreate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "onUpdate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "ref": { + "entry": "clock", + "field": "timestampInstant0", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "onUpdate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "ref": { + "entry": "clock", + "field": "timestampInstant2", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 0 + } + }, + "ref": { + "entry": "clock", + "field": "timestampJsDate0", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 1 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 1 + } + }, + "ref": { + "entry": "clock", + "field": "timestampJsDate1", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "ref": { + "entry": "clock", + "field": "timestampJsDate2", + "namespace": "public" + } + }, { "onCreate": { "id": "timestampStringNow", @@ -930,11 +1305,17 @@ { "onCreate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "onUpdate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "ref": { "entry": "clock", @@ -945,11 +1326,38 @@ { "onCreate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 2 + } }, "onUpdate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 2 + } + }, + "ref": { + "entry": "clock", + "field": "timestamptz2", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 3 + } + }, + "onUpdate": { + "id": "instantNow", + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "clock", @@ -960,11 +1368,17 @@ { "onCreate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "onUpdate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "ref": { "entry": "clock", @@ -975,11 +1389,17 @@ { "onCreate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "onUpdate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 0 + } }, "ref": { "entry": "clock", @@ -990,11 +1410,59 @@ { "onCreate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 1 + } }, "onUpdate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 1 + } + }, + "ref": { + "entry": "clock", + "field": "timestamptzJsDate1", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 2 + } + }, + "ref": { + "entry": "clock", + "field": "timestamptzJsDate2", + "namespace": "public" + } + }, + { + "onCreate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 3 + } + }, + "onUpdate": { + "id": "timestampNow", + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "clock", @@ -1005,11 +1473,17 @@ { "onCreate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "onUpdate": { "id": "timestampNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 6 + } }, "ref": { "entry": "clock", diff --git a/test/integration/test/temporal-defaults/_fixture-timestamp/generated/contract.d.ts b/test/integration/test/temporal-defaults/_fixture-timestamp/generated/contract.d.ts index bca36943d459..4e0e3e3f6aae 100644 --- a/test/integration/test/temporal-defaults/_fixture-timestamp/generated/contract.d.ts +++ b/test/integration/test/temporal-defaults/_fixture-timestamp/generated/contract.d.ts @@ -25,7 +25,7 @@ import type { export type StorageHash = StorageHashBase<'a3de0e95bb70b3a6106b3f84c9f7162b697f7f18d34c390e26200f08d60c159f'>; export type ExecutionHash = - ExecutionHashBase<'75fabf32f466f30de3808c2f1f8b7a60d87724063dfb332b032468a6621ad80a'>; + ExecutionHashBase<'a10097483cd46a82fb13f13a0605fc0c0c6454f2f99eea3e3ed6db4590cea32f'>; export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; @@ -464,8 +464,16 @@ type ContractBase = Omit< readonly mutations: { readonly defaults: readonly [ { - readonly onCreate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'plainDateTimeNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; + readonly onUpdate: { + readonly id: 'plainDateTimeNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; readonly ref: { readonly entry: 'stamp'; readonly field: 'updatedAt'; @@ -473,8 +481,16 @@ type ContractBase = Omit< }; }, { - readonly onCreate: { readonly id: 'instantNow'; readonly kind: 'generator' }; - readonly onUpdate: { readonly id: 'instantNow'; readonly kind: 'generator' }; + readonly onCreate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; + readonly onUpdate: { + readonly id: 'instantNow'; + readonly kind: 'generator'; + readonly params: { readonly precision: 3 }; + }; readonly ref: { readonly entry: 'stamp'; readonly field: 'updatedAtTz'; diff --git a/test/integration/test/temporal-defaults/_fixture-timestamp/generated/contract.json b/test/integration/test/temporal-defaults/_fixture-timestamp/generated/contract.json index ff63a5e3d4ed..051a82dd75e9 100644 --- a/test/integration/test/temporal-defaults/_fixture-timestamp/generated/contract.json +++ b/test/integration/test/temporal-defaults/_fixture-timestamp/generated/contract.json @@ -126,17 +126,23 @@ "storageHash": "a3de0e95bb70b3a6106b3f84c9f7162b697f7f18d34c390e26200f08d60c159f" }, "execution": { - "executionHash": "75fabf32f466f30de3808c2f1f8b7a60d87724063dfb332b032468a6621ad80a", + "executionHash": "a10097483cd46a82fb13f13a0605fc0c0c6454f2f99eea3e3ed6db4590cea32f", "mutations": { "defaults": [ { "onCreate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 3 + } }, "onUpdate": { "id": "plainDateTimeNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "stamp", @@ -147,11 +153,17 @@ { "onCreate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 3 + } }, "onUpdate": { "id": "instantNow", - "kind": "generator" + "kind": "generator", + "params": { + "precision": 3 + } }, "ref": { "entry": "stamp", diff --git a/test/integration/test/temporal-defaults/_fixture/contract.prisma b/test/integration/test/temporal-defaults/_fixture/contract.prisma index 7f925bd59fbe..8362f7c8e5d3 100644 --- a/test/integration/test/temporal-defaults/_fixture/contract.prisma +++ b/test/integration/test/temporal-defaults/_fixture/contract.prisma @@ -3,9 +3,9 @@ model Reading { id Int @id label String - createdAt temporal.CreatedAt - updatedAt temporal.UpdatedAt - createdAtText temporal.CreatedAtString - updatedAtText temporal.UpdatedAtString + createdAt TimestamptzTemporal @onCreate(now()) + updatedAt TimestamptzTemporal @onCreate(now()) @onUpdate(now()) + createdAtText TimestamptzString @onCreate(now()) + updatedAtText TimestamptzString @onCreate(now()) @onUpdate(now()) @@map("reading") } diff --git a/test/integration/test/temporal-defaults/now-generators-readback.integration.test.ts b/test/integration/test/temporal-defaults/now-generators-readback.integration.test.ts index afa3488a35f4..0f7598ff5e62 100644 --- a/test/integration/test/temporal-defaults/now-generators-readback.integration.test.ts +++ b/test/integration/test/temporal-defaults/now-generators-readback.integration.test.ts @@ -28,13 +28,15 @@ const expectedAtLastMillisecond = { timetz0: '23:59:59Z', timetz3: '23:59:59.999Z', timetz6: '23:59:59.999Z', - timestamp0: '2024-01-03T00:00:00', + timestamp0: '2024-01-02T23:59:59', + timestamp1: '2024-01-02T23:59:59.9', timestamp3: '2024-01-02T23:59:59.999', timestamp6: '2024-01-02T23:59:59.999', timestampString0: '2024-01-02T23:59:59', timestampString3: '2024-01-02T23:59:59.999', timestampString6: '2024-01-02T23:59:59.999', - timestamptz0: '2024-01-03T00:00:00Z', + timestamptz0: '2024-01-02T23:59:59Z', + timestamptz2: '2024-01-02T23:59:59.99Z', timestamptz3: '2024-01-02T23:59:59.999Z', timestamptz6: '2024-01-02T23:59:59.999Z', timestamptzString0: '2024-01-02T23:59:59Z', @@ -42,9 +44,17 @@ const expectedAtLastMillisecond = { timestamptzString2: '2024-01-02T23:59:59.99Z', timestamptzString3: '2024-01-02T23:59:59.999Z', timestamptzString6: '2024-01-02T23:59:59.999Z', - timestamptzJsDate0: '2024-01-03T00:00:00.000Z', + timestamptzJsDate0: '2024-01-02T23:59:59.000Z', + timestamptzJsDate1: '2024-01-02T23:59:59.900Z', + timestamptzJsDate2: '2024-01-02T23:59:59.990Z', timestamptzJsDate3: '2024-01-02T23:59:59.999Z', timestamptzJsDate6: '2024-01-02T23:59:59.999Z', + timestampJsDate0: '2024-01-02T23:59:59.000Z', + timestampJsDate1: '2024-01-02T23:59:59.900Z', + timestampJsDate2: '2024-01-02T23:59:59.990Z', + timestampInstant0: '2024-01-02T23:59:59Z', + timestampInstant2: '2024-01-02T23:59:59.99Z', + dateTime0: '2024-01-02T23:59:59.000Z', }; describe('the "now" generator of every Postgres date and time codec', () => { diff --git a/test/integration/test/temporal-defaults/type-aliases-parity.test.ts b/test/integration/test/temporal-defaults/type-aliases-parity.test.ts index e407a81c97d0..b6ebb0792c9c 100644 --- a/test/integration/test/temporal-defaults/type-aliases-parity.test.ts +++ b/test/integration/test/temporal-defaults/type-aliases-parity.test.ts @@ -1,15 +1,32 @@ import { describe, expect, it } from 'vitest'; -import presets from './_fixture/generated/contract.json' with { type: 'json' }; import aliases from './_fixture-aliases/generated/contract.json' with { type: 'json' }; +const dateTime = { + codecId: 'pg/timestamptz-date@1', + dataType: 'pg/timestamptz', + nullable: false, + typeParams: { precision: 3 }, +}; +const now = { kind: 'generator', id: 'timestampNow', params: { precision: 3 } }; + describe('columns typed by the temporal type aliases', () => { - it('emit the same storage and execution hashes as the temporal presets they replace', () => { + it('store the type bare DateTime becomes, with now on create and on update', () => { + const { columns } = aliases.storage.namespaces.public.entries.table.reading; expect({ - storageHash: aliases.storage.storageHash, - executionHash: aliases.execution?.executionHash, + createdAt: columns.createdAt, + updatedAt: columns.updatedAt, + defaults: aliases.execution?.mutations.defaults, }).toEqual({ - storageHash: presets.storage.storageHash, - executionHash: presets.execution?.executionHash, + createdAt: dateTime, + updatedAt: dateTime, + defaults: [ + { ref: { namespace: 'public', entry: 'reading', field: 'createdAt' }, onCreate: now }, + { + ref: { namespace: 'public', entry: 'reading', field: 'updatedAt' }, + onCreate: now, + onUpdate: now, + }, + ], }); }); }); diff --git a/test/integration/test/upgrade-instructions/convenience-date-time-types/detection.test.ts b/test/integration/test/upgrade-instructions/convenience-date-time-types/detection.test.ts new file mode 100644 index 000000000000..ec9259248563 --- /dev/null +++ b/test/integration/test/upgrade-instructions/convenience-date-time-types/detection.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'pathe'; +import { describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, '../../../../..'); + +function patternsOf(id: string): readonly RegExp[] { + const guide = readFileSync( + join(repoRoot, 'upgrade-instructions/pending/convenience-date-time-types/app/instructions.md'), + 'utf-8', + ); + const block = guide.split(/\n {2}- id: /).find((entry) => entry.startsWith(`${id}\n`)) ?? ''; + return [...block.matchAll(/^ {8}- '(.*)'$/gm)].map( + ([, pattern]) => new RegExp((pattern ?? '').replaceAll("''", "'")), + ); +} + +const matches = (id: string, content: string) => + patternsOf(id).some((pattern) => pattern.test(content)); + +describe('the convenience date and time type detection', () => { + it('matches a Prisma 8 field or alias typed DateTime, Date or Time on any target, and nothing else', () => { + const psl = (body: string) => + matches('psl-date-time-types-are-convenience-types', `// use prisma-8\n\n${body}\n`); + expect({ + dateTime: psl('model A {\n at DateTime\n}'), + date: psl('model A {\n day Date?\n}'), + time: psl('model A {\n opens Time(3)\n}'), + list: psl('model A {\n days Date[]\n}'), + alias: psl('types {\n Stamp = DateTime @onCreate(now())\n}'), + explicit: psl('model A {\n at TimestamptzTemporal\n day DateTemporal\n}'), + stringType: psl('model A {\n day DateString\n}'), + comment: psl('// a DateTime field\nmodel A {\n id Int @id\n}'), + modelName: psl('model Date {\n id Int @id\n}'), + sqliteDateTime: psl('model A {\n at DateTime @default(datetime`2024-01-01T00:00:00Z`)\n}'), + mysqlDateTime: psl('model A {\n id Int @id\n at DateTime @onCreate(now())\n}'), + mongoDate: psl('model A {\n id ObjectId @id @map("_id")\n at Date\n}'), + prisma7: psl( + 'datasource db {\n provider = "postgresql"\n}\nmodel A {\n at DateTime @db.Timestamptz(3)\n}', + ), + }).toEqual({ + dateTime: true, + date: true, + time: true, + list: true, + alias: true, + explicit: false, + stringType: false, + comment: false, + modelName: false, + sqliteDateTime: true, + mysqlDateTime: true, + mongoDate: true, + prisma7: false, + }); + }); + + it('matches the TypeScript helpers of the three names only', () => { + const ts = (content: string) => matches('ts-date-time-helpers-are-convenience-types', content); + expect({ + dateTime: ts('field.column(type.DateTime())'), + date: ts('field.column(type.Date())'), + time: ts('field.column(type.Time(3))'), + explicit: ts('field.column(type.TimeTemporal(3))'), + sqliteDateTime: ts('field.column(type.DateTime())'), + jsDate: ts('const at = new Date()'), + }).toEqual({ + dateTime: true, + date: true, + time: true, + explicit: false, + sqliteDateTime: true, + jsDate: false, + }); + }); +}); diff --git a/test/integration/test/upgrade-instructions/tools-write-convenience-types/detection.test.ts b/test/integration/test/upgrade-instructions/tools-write-convenience-types/detection.test.ts new file mode 100644 index 000000000000..ce8af1a3f9c6 --- /dev/null +++ b/test/integration/test/upgrade-instructions/tools-write-convenience-types/detection.test.ts @@ -0,0 +1,100 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'pathe'; +import { describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, '../../../../..'); + +function patternsOf(id: string): readonly RegExp[] { + const guide = readFileSync( + join( + repoRoot, + 'upgrade-instructions/pending/tools-write-convenience-types/app/instructions.md', + ), + 'utf-8', + ); + const block = guide.split(/\n {2}- id: /).find((entry) => entry.startsWith(`${id}\n`)) ?? ''; + return [...block.matchAll(/^ {8}- '(.*)'$/gm)].map( + ([, pattern]) => new RegExp((pattern ?? '').replaceAll("''", "'")), + ); +} + +const matches = (id: string, content: string) => + patternsOf(id).some((pattern) => pattern.test(content)); + +describe('the detection of the changes the tools writing convenience types make', () => { + it('matches a config that reads a Prisma 7 schema, and no other', () => { + expect({ + prisma7: matches( + 'prisma7-schema-reads-dates', + "export default defineConfig({ contract: prisma7Schema('prisma/schema.prisma') });", + ), + prisma8: matches( + 'prisma7-schema-reads-dates', + "export default defineConfig({ contract: prismaSchema('prisma/schema.prisma') });", + ), + }).toEqual({ prisma7: true, prisma8: false }); + }); + + it('matches each created-at and updated-at alias in PSL and TypeScript, and not the presets', () => { + const detects = (content: string) => matches('temporal-type-aliases-on-datetime', content); + expect({ + createdAt: detects(' createdAt temporal.CreatedAt\n'), + updatedAtString: detects(' updatedAt temporal.UpdatedAtString\n'), + createdAtJsDate: detects('createdAt: field.column(type.temporal.CreatedAtJsDate()),'), + preset: detects(' updatedAt temporal.updatedAtString()\n'), + longerName: detects(' updatedAt temporal.UpdatedAtStringly\n'), + otherNamespace: detects(' updatedAt mytemporal.UpdatedAt\n'), + }).toEqual({ + createdAt: true, + updatedAtString: true, + createdAtJsDate: true, + preset: false, + longerName: false, + otherNamespace: false, + }); + }); + + it('matches a timestamp default with digits below a millisecond, and not one without', () => { + const detects = (content: string) => matches('date-default-below-a-millisecond', content); + expect({ + micro: detects( + ' at DateTime(precision: 6) @default(timestamptz`2024-01-01T01:00:00.123456Z`)\n', + ), + local: detects( + ' at DateTime(storage: timestamp) @default(timestamp`2024-01-01T01:00:00.1234`)\n', + ), + mysql: detects(' at DateTime(6) @default(datetime`2024-01-01T01:00:00.123456`)\n'), + milli: detects(' at DateTime @default(timestamptz`2024-01-01T01:00:00.123Z`)\n'), + zeros: detects(' at DateTime @default(timestamptz`2024-01-01T01:00:00.123000Z`)\n'), + }).toEqual({ micro: true, local: true, mysql: true, milli: false, zeros: false }); + }); + + it('matches a client now generator or a Prisma 7 config, and not a database default', () => { + const detects = (content: string) => matches('date-now-takes-column-precision', content); + expect({ + prisma7: detects( + "export default defineConfig({ contract: prisma7Schema('schema.prisma') });", + ), + attribute: detects(' updatedAt DateTime @onCreate(now()) @onUpdate(now())\n'), + alias: detects(' createdAt temporal.CreatedAt\n'), + typescript: detects('createdAt: field.column(type.DateTime()).onCreate(gen.now()),'), + temporalPreset: detects(' createdAt temporal.timestamptz(0, onCreate: now)\n'), + jsDatePreset: detects("createdAt: field.temporal.timestamptzJsDate(3, 'now'),"), + jsDatePresetPsl: detects(' createdAt temporal.timestamptzJsDate(3, onCreate: now)\n'), + stringPreset: detects(' createdAt temporal.timestamptzString(0)\n'), + databaseDefault: detects(' createdAt DateTime @default(now())\n'), + }).toEqual({ + prisma7: true, + attribute: true, + alias: true, + typescript: true, + temporalPreset: true, + jsDatePreset: true, + jsDatePresetPsl: true, + stringPreset: false, + databaseDefault: false, + }); + }); +}); diff --git a/upgrade-instructions/pending/convenience-date-time-types/app/instructions.md b/upgrade-instructions/pending/convenience-date-time-types/app/instructions.md new file mode 100644 index 000000000000..37b3ccc45ab0 --- /dev/null +++ b/upgrade-instructions/pending/convenience-date-time-types/app/instructions.md @@ -0,0 +1,70 @@ +--- +changes: + - id: psl-date-time-types-are-convenience-types + summary: | + In a Prisma 8 schema on PostgreSQL, `DateTime`, `Date` and `Time` are now convenience types that choose an explicit type from their arguments, and their defaults read different values: `DateTime` is a `timestamptz(3)` column read as a JavaScript `Date`, and `Date` and `Time` are read as strings. To keep what a field reads today, and its contract, rename `DateTime` to `TimestamptzTemporal`, `Date` to `DateTemporal` and `Time` to `TimeTemporal`, keeping the arguments: `Time(3)` becomes `TimeTemporal(3)`. A Prisma 7 schema, which has a `datasource` block, and SQLite and MongoDB schemas are not affected. + detection: + glob: "**/*.prisma" + matches: + - '(?.` and then `prisma db migrate`, or `prisma db update --delete .`, which accepts the rounding. A JavaScript `Date` holds milliseconds only, so to keep microseconds, keep the `Temporal.Instant` field: write `DateTime(as: temporal)`, which is exactly `TimestamptzTemporal`. + +## `ts-date-time-helpers-are-convenience-types` + +Rename only in contracts on PostgreSQL; `type.DateTime(...)` in a SQLite or MySQL contract keeps its meaning. + +```ts +field.column(type.TimestamptzTemporal()); // was type.DateTime() +field.column(type.DateTemporal()); // was type.Date() +field.column(type.TimeTemporal(3)); // was type.Time(3) +``` diff --git a/upgrade-instructions/pending/tools-write-convenience-types/app/instructions.md b/upgrade-instructions/pending/tools-write-convenience-types/app/instructions.md new file mode 100644 index 000000000000..0a90a36b9060 --- /dev/null +++ b/upgrade-instructions/pending/tools-write-convenience-types/app/instructions.md @@ -0,0 +1,115 @@ +--- +changes: + - id: prisma7-schema-reads-dates + summary: | + A contract from `prisma7Schema(...)` on PostgreSQL now reads a Prisma 7 `DateTime` field as a JavaScript `Date`, as Prisma 7 did, instead of a string: a plain `DateTime` is `TimestampJsDate(3)` and a `@db.Timestamptz(p)` field is `TimestamptzJsDate(p)`. The columns do not change, but the contract's codecs and storage hash do. Re-emit with `prisma contract emit`, run `prisma db sign`, and change application code that read those fields as strings to read a `Date`. + detection: + glob: "**/prisma.config.{ts,mts,cts,js,mjs}" + matches: + - '\bprisma7Schema\s*\(' + - id: temporal-type-aliases-on-datetime + summary: | + On PostgreSQL, `temporal.CreatedAt` and `temporal.UpdatedAt` are now built on `DateTime`, a `timestamptz(3)` column read as a JavaScript `Date`, instead of a `timestamptz` column read as a `Temporal.Instant`, and the `temporal.CreatedAtString`, `temporal.UpdatedAtString`, `temporal.CreatedAtJsDate` and `temporal.UpdatedAtJsDate` aliases are removed, in PSL and as `type.temporal.*()` in a TypeScript contract. Write the type and the generators instead, as the table in the body shows, to keep each field and its contract. + detection: + glob: "**/*.{prisma,ts,mts,cts,tsx}" + matches: + - '(?.` and apply it with `prisma db migrate`, or run `prisma db update --delete .`. + +If you followed the 8.0.0-rc.21 guide's `remove-field-presets` change, a field it rewrote from `temporal.createdAt()` or `temporal.updatedAt()` to `temporal.CreatedAt` or `temporal.UpdatedAt` is such a field: write it from the table above to keep its contract. + +The detection also matches `temporal.CreatedAt` and `temporal.UpdatedAt` in SQLite and MongoDB projects. There they keep their meaning and need no change. + +## `date-default-below-a-millisecond` + +A JavaScript `Date` holds milliseconds. A column read as a `Date` now refuses a default with more digits, instead of storing it and reading it back at the millisecond. For a field such as + +```prisma +at DateTime(precision: 6) @default(timestamptz`2024-01-01T01:00:00.123456Z`) +``` + +write one of: + +```prisma +at DateTime(as: string, precision: 6) @default(timestamptz`2024-01-01T01:00:00.123456Z`) +at DateTime(precision: 6) @default(sql`'2024-01-01 01:00:00.123456+00'::timestamptz`) +``` + +The first reads the field as a string, which keeps every digit. The second keeps the field a `Date` and the database default as written. Run `prisma contract emit` again. A Prisma 7 schema read by `prisma7Schema(...)` needs no change: such a default is kept as the SQL Prisma 7 created. + +## `date-now-takes-column-precision` + +A JavaScript `Date` holds milliseconds. On a column with fewer fraction digits, such as a Prisma 7 `@db.Timestamp(0) @updatedAt` field, the client used to generate a time such as `10:00:00.700` and PostgreSQL stored `10:00:01`, so the value a create returned before a read differed from the stored one. The `now` generator of a column read as a `Date`, such as `TimestamptzJsDate(0) @onCreate(now())`, now receives the column's precision, as the string and time generators already do, and writes `10:00:00`. The same holds for a timestamp column read as a `Temporal` value, such as `TimestamptzTemporal(0) @onCreate(now())` or `TimestampInstant(0) @onCreate(now())`: its `instantNow` or `plainDateTimeNow` generator now receives the precision too. Before, such a column stored `10:00:01`, a time after the write, and at `23:59:59.700` the next day's date. + +The contract records the precision beside the generator, for example `{ "kind": "generator", "id": "timestampNow", "params": { "precision": 3 } }`. For each project with such a field, including every project whose `prisma.config.ts` uses `prisma7Schema(...)`, run `prisma contract emit` and commit the new `contract.json` and `contract.d.ts`. The storage hash does not change, and the database marker holds only the storage and profile hashes, so there is no migration to plan and no need to run `prisma db sign`. SQLite and MongoDB contracts do not change.