diff --git a/.agents/rules/README.md b/.agents/rules/README.md index 953a14ea1255..f3db988b631a 100644 --- a/.agents/rules/README.md +++ b/.agents/rules/README.md @@ -79,7 +79,7 @@ Rules below are listed by bare filename; the canonical file is `.agents/rules/(Prisma 7)"] --> parser["@internal/psl-parser
grammar: 'prisma7'"] + schema["schema.prisma
(Prisma 7)"] --> parser["@internal/psl-parser
one grammar for every PSL document"] parser --> interpreter["@internal/sql-contract-prisma7
rules of the Prisma 7 language for the SQL family"] binding["@internal/target-postgres
prisma7PostgresBinding: what Postgres creates"] --> interpreter interpreter --> contract["Contract"] @@ -94,7 +94,7 @@ flowchart LR facade -. wires .-> binding ``` -**The parser is shared, and the earlier grammar is opt-in.** `@internal/psl-parser` reads both languages. The two additions the earlier language needs, attributes on enum members and field lines inside a `view` block, are read only under the `grammar: 'prisma7'` parse option. The default grammar is unchanged, so a Prisma 8 schema keeps rejecting exactly what it rejected before, and nothing here reaches a user who never adopts the reader. +**The parser is shared, and it has one grammar.** `@internal/psl-parser` parses every PSL document the same way, whichever Prisma version wrote it. The two constructs the earlier language needs, attributes on enum members and field lines inside a `view` block, parse in every document. Each reader decides what it accepts. The Prisma 8 readers report an attribute on an enum member, and report a `view` block like any other block whose keyword no composed pack claims, so a Prisma 8 PSL contract that uses either fails. The Prisma 7 reader reads enum member attributes and refuses a `view` with the error shown above. **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. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 7bb5a29dcc33..c053944127f2 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -281,6 +281,80 @@ A Mongo model's collection attachment is wrong: the model declares `indexes`, `c A model declares an empty unique constraint (a unique with no fields), raised during SQL contract lowering (meta: `modelName`). Also raised when a CHECK constraint reaches SQLite migration DDL rendering: the SQLite target does not support CHECK constraints, and `sql.checkConstraint` is a Postgres-only capability. A `@@check` is refused earlier, by the PSL capability gate; a `check()` declared through the TypeScript builder is not, because capabilities reach the contract only after it is built, so this is where a SQLite `check()` is refused (meta: `constraintName`, and `tableName` where available). +### CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE + +`prisma contract print --output` was asked to write over a file the project needs: the `prisma.config.ts` in the directory of the config that defines the `orm` section, or one of the files `contract emit` writes (the JSON `contract.output` names, and the `.d.ts` beside it). Writing there would put PSL where the CLI reads its config when `--config` names no other file, or the next `contract emit` would write over the printed PSL. The check compares the files the paths name: a path through a symbolic link, or one that differs only in case on a volume that ignores case, counts as the same file. Raised before the source is read, so nothing is written. Pick another `--output` path. Payload: `output` and `file`, both relative to the invocation directory. + +### CONTRACT.PRINT_OUTPUT_IS_SOURCE + +`prisma contract print --output` was asked to write over a file it reads: the resolved `--output` path is one of the contract source's inputs, or sits inside a directory of source files. Writing there would destroy the source the printed contract is made from. The same code is raised when the path names a new file that a glob input of the source would match once written, because the next `contract emit` would read the printed file together with the source files; `source` is then the glob. Pick another `--output` path, outside the files the config names. Raised before the source is read, so nothing is written and the source file is untouched. Payload: `output` and `source`, both relative to the invocation directory. + +### CONTRACT.PRINT_UNSUPPORTED + +`contract print` cannot write the loaded contract as Prisma 8 PSL that reads back as the same contract, so it writes nothing. The message names what it stopped on. Raised when the configured family cannot print a contract (no meta), or when the target's descriptor has no `buildPslContract` hook (meta: `targetId`). The Postgres printer raises it in each case below; each case is one function in its `psl-print/refusals.ts`, in this order. Every case is a contract that passes validation. The printer takes a validated contract and does not check its structure again. + +- Column types and defaults: + - no PSL type in the configured stack produces a column's codec, native type and type parameters, including a column that has no value for an argument its type constructor requires. Add the extension that contributes the type to the config (meta: `coordinate`, `nativeType`, `codecId`); + - a string type argument holds a quote, backslash or line break, which the PSL source reads back differently (meta: `coordinate`, `argument`); + - a domain enum column defaults to a value that is not a member of the enum (meta: `coordinate`, `pslTypeName`); + - a column's literal default has no PSL literal that reads back as the stored value, including when the column's codec has no data type in the stack (meta: `coordinate`, `pslTypeName`). +- Generated values: + - a column pairs the wall-clock-now generator with a different generator (meta: `coordinate`, `onCreate`, `onUpdate`); + - a column generates a value on update other than through a temporal preset (meta: `coordinate`, `onCreate`, `onUpdate`); + - a column is generated by a generator no PSL default function of the Postgres adapter produces (meta: `coordinate`, `onCreate`, `onUpdate`); + - a column has both an id generator and a database default (meta: `coordinate`, `onCreate`); + - a generated value names a column no field is stored in (meta: `coordinate`). +- Fields and columns: + - a field and its column disagree where PSL writes them once: the field is optional and the column is not nullable or the reverse, a column of a single-table variant is not nullable, one of them is a list and the other is not, a scalar field's codec or type parameters differ from its column's, or they do not name the enum and value set the PSL source derives for a field typed by an enum (meta: `coordinate`); + - a model or value-object field's type is a union of types (meta: `coordinate`, `kind`), or a field is a dictionary (meta: `coordinate`); + - a value-object field carries type parameters or a value set, which the PSL source does not keep on a value-object field (meta: `coordinate`); + - a value-object field uses a codec that no Postgres codec in the configured stack names a native type for (meta: `coordinate`, `codecId`); + - a value-object field uses a codec that names a native type only from type parameters (meta: `coordinate`, `codecId`); + - a field is stored in no column (meta: `namespaceId`, `modelName`, `field`); + - a model stores a column under a field name the model does not declare (meta: `namespaceId`, `modelName`, `field`); + - a column is typed by a named type the contract does not declare, or its native type or codec is not the named type's (meta: `coordinate`, `typeRef`); + - a column has its own control policy (meta: `coordinate`, `control`); + - a table has no model stored in it (meta: `namespaceId`, `table`), or a column is stored by no field, other than the primary key columns that link a multi-table variant to its base (meta: `namespaceId`, `table`, `column`). +- Models: + - a model has an owner (meta: `namespaceId`, `modelName`, `owner`); + - a multi-table variant is linked to its base other than through the base's primary key columns as its unnamed primary key and an unnamed foreign key that cascades on delete (meta: `namespaceId`, `modelName`); + - one model name is declared in more than one namespace (meta: `modelName`, `namespaces`); + - a domain enum is declared outside the default namespace (meta: `namespaceId`, `names`); + - a value object is declared outside the default namespace (meta: `namespaceId`, `names`). +- Keys, checks and indexes: + - a check or index has a prefix, but its name is not that prefix followed by the hash of its content (meta: `namespaceId`, `table`, `name`, `prefix`); + - a managed table lacks a check the PSL source derives for an enum or list column, or has a check with that check's name but not its prefix and expression (meta: `namespaceId`, `table`, `name`); + - an index has options but no type (meta: `namespaceId`, `table`, `index`), or an option whose value is not a string (meta: `namespaceId`, `table`, `index`, `key`). +- Relations: + - a to-one relation has no foreign key behind it (meta: `model`, `field`); + - a foreign key has no relation that travels it (meta: `namespaceId`, `table`, `columns`); + - a relation targets a model in another contract space, which the printer does not write yet (meta: `model`, `field`, `space`); + - a many-to-many relation goes through a table whose model has no relation back to the relation's model (meta: `model`, `field`); + - a one-to-many or one-to-one relation has no foreign key of its own, and the model it targets has no relation back that holds the foreign key (meta: `model`, `field`); + - a relation names no fields to join on (meta: `model`, `field`). +- Enums and value sets: + - a value set is not the value set of an enum or native enum of that name holding exactly its values, or an enum has no value set holding its members (meta: `namespaceId`, `name`); + - an enum and a native enum would derive the same value set (meta: `namespaceId`, `name`); + - a native enum has no value set holding its members (meta: `namespaceId`, `typeName`); + - a native enum has its own control policy (meta: `namespaceId`, `typeName`, `control`). +- Namespaces, meta and roots: + - a storage or domain namespace holds nothing PSL writes, or the contract lacks a namespace the PSL source would create, such as the default namespace (meta: `plane`, `namespaceId`); + - a namespace is named `unbound` and is not the late-binding namespace, which PSL writes as `namespace unbound` (meta: `namespaceId`); + - the contract has top-level `meta` entries (meta: `keys`); + - the contract has roots other than one per model that is not a variant, keyed by its table name (meta: `root`). +- Row-level security: + - a table has row-level security enabled but no model (meta: `namespaceId`, `table`); + - a policy is on a table with no model (meta: `namespaceId`, `table`, `name`); + - a policy is on a table without row-level security (meta: `namespaceId`, `table`, `name`); + - a wire-named policy's name is not its block name followed by the hash of its content (meta: `namespaceId`, `table`, `name`); + - a role is declared outside the unbound namespace (meta: `namespaceId`, `name`); + - a row-level security setting or role is filed under a key the PSL source would not file it under (meta: `namespaceId`, `kind`, `name`); + - a row-level security setting, role or policy records a namespace other than the one it is stored in (meta: `namespaceId`, `kind`, `name`). +- Names and storage entries: + - a table or column is named `__proto__`, which the PSL source loses when it reads the name from `@@map` or `@map` (meta: `kind`, `name`); + - a name PSL writes as an identifier is not one, or is `__proto__`: a namespace, model, field, value object, enum, enum member, native enum, named type, policy, role or index option key. `NaN` and `Infinity` are number words, not identifiers (meta: `kind`, `name`); + - a namespace holds a storage entity kind other than tables, value sets, native enums, row-level security settings, policies and roles (meta: `namespaceId`, `kind`, `names`). + ### CONTRACT.DATA_TYPE_DUPLICATE Two components in the composed stack register the same data type id, which has exactly one owner. Raised while assembling the stack's data types. Payload: `dataType`, `contributedBy`, `owner`. @@ -483,11 +557,11 @@ The TypeScript contract module imports something outside the contract-source imp ### CONTRACT.SOURCE_DIAGNOSTIC -One finding a contract source reported with a code that is not yet dotted, such as the Prisma 8 PSL interpreter's `PSL_UNSUPPORTED_FIELD_TYPE` or a parser's `PSL_PARSE_ERROR`. This is its only producer case: it exists until those codes convert to dotted ones, and a source code that is already dotted, such as `PSL.PRISMA7_VIEW_UNSUPPORTED`, is reported under its own code instead. Never raised on its own; carried, one per such source diagnostic, in the `diagnostics` list of a `CONTRACT.SOURCE_LOAD_FAILED` error during `contract emit`, printed under it in the terminal and serialized as the envelope's `diagnostics` in JSON. `summary` is `:: : ` (the location is omitted when the source gave none). `where` carries `path` and `line`. Payload: `code` (the source's own diagnostic code). Fix: edit the schema at each location the findings name, then run `prisma contract emit` again. One such source code is `PSL_UNKNOWN_DEFAULT_FUNCTION`, reported by the Prisma 8 PSL interpreter for a `@default` function the composed stack does not register; its message lists the supported functions, and for the removed `dbgenerated(...)` it is `` Default function "dbgenerated" was removed. Write the SQL as a tagged literal: @default(sql``). Supported functions: . `` +One finding a contract source reported with a code that is not yet dotted, such as the Prisma 8 PSL interpreter's `PSL_UNSUPPORTED_FIELD_TYPE` or a parser's `PSL_PARSE_ERROR`. This is its only producer case: it exists until those codes convert to dotted ones, and a source code that is already dotted, such as `PSL.PRISMA7_VIEW_UNSUPPORTED`, is reported under its own code instead. Never raised on its own; carried, one per such source diagnostic, in the `diagnostics` list of a `CONTRACT.SOURCE_LOAD_FAILED` error during `contract emit` or `contract print`, printed under it in the terminal and serialized as the envelope's `diagnostics` in JSON. `summary` is `:: : ` (the location is omitted when the source gave none). `where` carries `path` and `line`. Payload: `code` (the source's own diagnostic code). Fix: edit the schema at each location the findings name, then run the command again. One such source code is `PSL_UNKNOWN_DEFAULT_FUNCTION`, reported by the Prisma 8 PSL interpreter for a `@default` function the composed stack does not register; its message lists the supported functions, and for the removed `dbgenerated(...)` it is `` Default function "dbgenerated" was removed. Write the SQL as a tagged literal: @default(sql``). Supported functions: . `` ### CONTRACT.SOURCE_LOAD_FAILED -Loading the contract source failed: bundling or evaluating the TypeScript contract module (esbuild bundle error, or the module threw on import), the contract source provider returning a failure or a malformed result during `contract emit`, or `format` failing to read the PSL source file. The underlying failure is attached as `cause` where one exists. Payload: `path`, `stage` (`bundle` or `import`) at the TS-loader site; `diagnostics`, `issues`, `providerMeta` at the emit provider site; none at the format read site. At the emit provider site the error also carries a `diagnostics` list with one finding per source diagnostic: under the source's own code when it is dotted (for example `PSL.PRISMA7_VIEW_UNSUPPORTED`), otherwise as `CONTRACT.SOURCE_DIAGNOSTIC`. +Loading the contract source failed: bundling or evaluating the TypeScript contract module (esbuild bundle error, or the module threw on import), the contract source provider returning a failure or a malformed result during `contract emit` or `contract print`, or `format` failing to read the PSL source file. The underlying failure is attached as `cause` where one exists. Payload: `path`, `stage` (`bundle` or `import`) at the TS-loader site; `diagnostics`, `issues`, `providerMeta` at the emit provider site; none at the format read site. At the emit provider site the error also carries a `diagnostics` list with one finding per source diagnostic: under the source's own code when it is dotted (for example `PSL.PRISMA7_VIEW_UNSUPPORTED`), otherwise as `CONTRACT.SOURCE_DIAGNOSTIC`. ### CONTRACT.TABLE_AMBIGUOUS @@ -621,55 +695,55 @@ The Prisma 7 schema gives a contract that Prisma 8 rejects, for a cause the sour ### PSL.PRISMA7_ENUM_NAMESPACE_MISMATCH -A field uses an enum declared under a different `@@schema`; a Postgres enum lives in one schema and a Prisma 8 column references the enum of its own namespace. Declare the enum in the model's schema, or move the model. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +A field uses an enum declared under a different `@@schema`; a Postgres enum lives in one schema and a Prisma 8 column references the enum of its own namespace. Declare the enum in the model's schema, or move the model. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_IGNORED_FIELD_REFERENCED -An `@ignore`d field is used by `@id`, `@unique`, `@@id`, `@@unique`, `@@index`, or a relation's `fields:`, and Prisma 7 still creates that key, index, or foreign key over its column. Remove `@ignore` from the field. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +An `@ignore`d field is used by `@id`, `@unique`, `@@id`, `@@unique`, `@@index`, or a relation's `fields:`, and Prisma 7 still creates that key, index, or foreign key over its column. Remove `@ignore` from the field. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_INDEX_ARGUMENT_UNSUPPORTED -An index argument Prisma 8 indexes cannot carry (`sort`, `length`, `ops`, an unknown index type), or an indexed field that is not a column. Remove the argument. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +An index argument Prisma 8 indexes cannot carry (`sort`, `length`, `ops`, an unknown index type), or an indexed field that is not a column. Remove the argument. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_JSON_NULL_DEFAULT_UNSUPPORTED -A `Json` default of `"null"`, or a `Json[]` default holding it: the contract cannot tell the JSON value null apart from SQL `NULL`. Remove the `@default` or give it another JSON value. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +A `Json` default of `"null"`, or a `Json[]` default holding it: the contract cannot tell the JSON value null apart from SQL `NULL`. Remove the `@default` or give it another JSON value. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_JUNCTION_ID_UNSUPPORTED -An implicit many-to-many relation on a model without a single-field `@id`. Give the model a single-field `@id`, or write the junction model out. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +An implicit many-to-many relation on a model without a single-field `@id`. Give the model a single-field `@id`, or write the junction model out. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_JUNCTION_NAME_COLLISION -A model in the same schema as an implicit many-to-many junction has the junction model's name (`PostToTag`, or the relation name). Rename the model and keep its table with `@@map`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +A model in the same schema as an implicit many-to-many junction has the junction model's name (`PostToTag`, or the relation name). Rename the model and keep its table with `@@map`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_NATIVE_TYPE_UNSUPPORTED -A `@db.*` type with no Prisma 8 codec (`Citext`, `Bit`, `VarBit`, `Xml`, `Oid`, `Money`, or an unknown spelling). Add `@ignore` to the field when no key, index, or relation uses it, or `@@ignore` to the model. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +A `@db.*` type with no Prisma 8 codec (`Citext`, `Bit`, `VarBit`, `Xml`, `Oid`, `Money`, or an unknown spelling). Add `@ignore` to the field when no key, index, or relation uses it, or `@@ignore` to the model. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_OPTIONAL_GENERATED_FIELD_UNSUPPORTED -An ORM-side generator such as `@default(uuid())`, or `@updatedAt`, on an optional field. Remove the generator or `@updatedAt` and keep the `?`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +An ORM-side generator such as `@default(uuid())`, or `@updatedAt`, on an optional field. Remove the generator or `@updatedAt` and keep the `?`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_PROVIDER_MISMATCH -The Prisma 7 schema has no `datasource` block, or its `provider` is not one the target accepts (`postgresql` or `postgres` for Postgres). Use the source only with a schema for the configured target. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +The Prisma 7 schema has no `datasource` block, or its `provider` is not one the target accepts (`postgresql` or `postgres` for Postgres). Use the source only with a schema for the configured target. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_REFERENTIAL_ACTION_UNSUPPORTED -`SetNull` on a relation over a required foreign key field, or `SetDefault` over a required field with no column default. Make the fields optional, give them a column default, or choose another action. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +`SetNull` on a relation over a required foreign key field, or `SetDefault` over a required field with no column default. Make the fields optional, give them a column default, or choose another action. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_RELATION_MODE_UNSUPPORTED -`relationMode = "prisma"`, or the older `referentialIntegrity = "prisma"`. Remove it or set `relationMode = "foreignKeys"`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +`relationMode = "prisma"`, or the older `referentialIntegrity = "prisma"`. Remove it or set `relationMode = "foreignKeys"`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_RELATION_NAME_SHARED -Two or more implicit many-to-many relations in the same schema use the same relation name; Prisma 7 creates one table for all of them, wired to only one. Give each relation its own name. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +Two or more implicit many-to-many relations in the same schema use the same relation name; Prisma 7 creates one table for all of them, wired to only one. Give each relation its own name. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_RELATION_UNRESOLVED -A relation field that cannot be paired: no matching side, an ambiguous unnamed pair, a singular back-relation over a non-unique foreign key, a `fields`/`references` mismatch, or a required relation field over an optional foreign key field. Name both sides with `@relation("name")`, add the missing `fields`/`references`, or make the relation field optional. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +A relation field that cannot be paired: no matching side, an ambiguous unnamed pair, a singular back-relation over a non-unique foreign key, a `fields`/`references` mismatch, or a required relation field over an optional foreign key field. Name both sides with `@relation("name")`, add the missing `fields`/`references`, or make the relation field optional. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_SCHEMA_READ_FAILED @@ -677,31 +751,31 @@ The schema path could not be read, or the schema directory holds no `.prisma` fi ### PSL.PRISMA7_TABLE_COLLISION -Two models map to the same table in one schema, or a model maps to the table of an implicit many-to-many relation. Give each model its own table with `@@map`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +Two models map to the same table in one schema, or a model maps to the table of an implicit many-to-many relation. Give each model its own table with `@@map`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_UNKNOWN_ATTRIBUTE -An attribute Prisma 7 for the target does not have, or one the source does not read (`@@fulltext`, `@shardKey`). Remove it. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +An attribute Prisma 7 for the target does not have, or one the source does not read (`@@fulltext`, `@shardKey`). Remove it. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_UNKNOWN_DEFAULT -A `@default` value the source cannot read, or one the column's data type or codec refuses. The message is `Field ".": @default `. Every reason below carries ` at element ` after the value it is about when that value is one element of a list. The reasons that come from reading the value are: `holds text that this contract source does not read: `; `holds a literal, which this stack does not register.`; `holds a value, which this target has no data type for.`; `holds a value, which has no cast from; it casts from .` (or `it casts from nothing`); and `holds a value that does not read: `. The rest do not involve the value's type — an unknown function, an enum member on a non-enum field or a non-member, and a `dbgenerated(...)` argument list that is not a single positional string with text in it. Write a value of a type the column's type is or casts from, an enum member, or a supported function. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +A `@default` value the source cannot read, or one the column's data type or codec refuses. The message is `Field ".": @default `. Every reason below carries ` at element ` after the value it is about when that value is one element of a list. The reasons that come from reading the value are: `holds text that this contract source does not read: `; `holds a literal, which this stack does not register.`; `holds a value, which this target has no data type for.`; `holds a value, which has no cast from; it casts from .` (or `it casts from nothing`); and `holds a value that does not read: `. The rest do not involve the value's type — an unknown function, an enum member on a non-enum field or a non-member, and a `dbgenerated(...)` argument list that is not a single positional string with text in it. Write a value of a type the column's type is or casts from, an enum member, or a supported function. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_UNSUPPORTED_TYPE -`Unsupported("...")` or an unknown field type. Add `@@ignore` to the model, or correct the type name. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +`Unsupported("...")` or an unknown field type. Add `@@ignore` to the model, or correct the type name. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_UPDATED_AT_TYPE_UNSUPPORTED -`@updatedAt` on a column whose codec has no "now" generator in the target, such as `@db.Date`. Remove `@updatedAt`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +`@updatedAt` on a column whose codec has no "now" generator in the target, such as `@db.Date`. Remove `@updatedAt`. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_UPDATED_AT_WITH_DEFAULT_UNSUPPORTED -`@updatedAt` combined with `@default`. Remove the `@default`; `@updatedAt` still sets the value on create and update. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +`@updatedAt` combined with `@default`. Remove the `@default`; `@updatedAt` still sets the value on create and update. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL.PRISMA7_VIEW_UNSUPPORTED -A `view` block; Prisma 8 has no views. Remove the view, or replace it with a model over the underlying table. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. +A `view` block; Prisma 8 has no views. Remove the view, or replace it with a model over the underlying table. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit` or `contract print`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `:: `, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none. ### PSL_BACKTICK_STRING_REQUIRES_TAG @@ -747,6 +821,10 @@ A list column declares `@default(autoincrement())`: `Field "." is A `` @default(sql`...`) `` body fails the SQL family's body check: `Default SQL must not contain semicolons, SQL comment tokens, dollar-quoting, or subqueries.` (the rule the migration planners apply at DDL time, run at authoring time so it has a source span), or is exactly `now()` or `autoincrement()`: `` Write @default(now()) instead of sql`now()`; now() is a Prisma default function, not raw SQL. `` The message names the tag as written (`sql`, `pg.sql` or `sqlite.sql`). Reported at the literal. +### PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE + +An enum member carries an attribute, as in `USER @map("user")`: `enum "": member "" carries @, but an enum member takes no attributes`. Reported by the SQL and Mongo PSL readers, once per attribute, at the attribute. Remove the attribute. To store a value other than the member's name, write it as the member's value, as in `USER = "user"`. + ### PSL_PRESET_ON_VARIANT_FIELD A Mongo field preset that sets execution defaults, such as `temporal.createdAt()`, is declared on a field of a polymorphic variant model (one with `@@base`): `Preset "" on variant "" field "": execution defaults apply to every document in collection "", so declare them on the base model.` Execution defaults are keyed by collection and field, so a default on one variant would also fill that field on the base model and every sibling variant. Declare the field on the base model; variants inherit it. Reported at the preset. diff --git a/examples/prisma7-adoption/README.md b/examples/prisma7-adoption/README.md index 3b20f6a83314..b5d500223797 100644 --- a/examples/prisma7-adoption/README.md +++ b/examples/prisma7-adoption/README.md @@ -63,7 +63,7 @@ Two rules to know before you start: ### 4. Transfer migration ownership, then 5. remove Prisma 7 -Out of scope here. When the last route has moved, follow the guide's phase 4 (`prisma migration plan --name baseline`, `prisma db sign`, `prisma migration ref set db _baseline`) and phase 5. +Out of scope here. When the last route has moved, `prisma contract print --output prisma/contract.prisma` writes the Prisma 8 PSL that produces the same contract this example emits from `prisma/schema.prisma`; point `contract` in `prisma.config.ts` at the written file and run `prisma contract emit` again to confirm the contract is unchanged. Then follow the guide's phase 4 (`prisma migration plan --name baseline`, `prisma db sign`, `prisma migration ref set db _baseline`) and phase 5. ## What a Prisma 7 user meets along the way diff --git a/packages/0-shared/publish-surface/src/shells.ts b/packages/0-shared/publish-surface/src/shells.ts index dd9c8fc3f461..b2cecb1600f9 100644 --- a/packages/0-shared/publish-surface/src/shells.ts +++ b/packages/0-shared/publish-surface/src/shells.ts @@ -423,7 +423,7 @@ export const publicShells: ReadonlyMap = new Map< dir: 'packages/2-sql/2-authoring/contract-psl', name: '@internal/sql-contract-psl', entry: 'contract-psl', - subpaths: ['.', 'attribute-specs', 'default-table-name', 'provider'], + subpaths: ['.', 'attribute-specs', 'default-table-name', 'map-names', 'provider'], }, { dir: 'packages/2-sql/2-authoring/contract-ts', diff --git a/packages/0-shared/publish-surface/test/shells.test.ts b/packages/0-shared/publish-surface/test/shells.test.ts index 2490600d166c..c816e8bccfa7 100644 --- a/packages/0-shared/publish-surface/test/shells.test.ts +++ b/packages/0-shared/publish-surface/test/shells.test.ts @@ -104,6 +104,7 @@ describe('publicShells', () => { './contract-psl', './contract-psl/attribute-specs', './contract-psl/default-table-name', + './contract-psl/map-names', './contract-psl/provider', ], ); diff --git a/packages/1-framework/1-core/config/README.md b/packages/1-framework/1-core/config/README.md index 7ee5e51fb26f..48ead16ad8f1 100644 --- a/packages/1-framework/1-core/config/README.md +++ b/packages/1-framework/1-core/config/README.md @@ -12,7 +12,7 @@ This package owns the shared config contract used by tooling and authoring packa - contract source provider + diagnostics protocol - provider-declared input metadata for tooling integrations - `defineConfig()` normalization/defaulting -- `validateConfig()` structural/runtime-shape validation +- `collectConfigIssues()` structural/runtime-shape validation ## Responsibilities @@ -31,7 +31,7 @@ This package owns the shared config contract used by tooling and authoring packa ```ts import { defineConfig } from '@internal/config/config-types'; -import { validateConfig } from '@internal/config/config-validation'; +import { collectConfigIssues } from '@internal/config/config-validation'; const config = defineConfig({ family: sqlFamilyDescriptor, @@ -39,6 +39,7 @@ const config = defineConfig({ adapter: postgresAdapterDescriptor, contract: { source: { + format: 'psl', inputs: ['./prisma/schema.prisma'], load: async (_context) => /* Result */ null as never, @@ -46,9 +47,11 @@ const config = defineConfig({ }, }); -validateConfig(config); +const issues = collectConfigIssues(config); ``` +Every source states the language of its inputs in `source.format`: `'psl'` or `'typescript'`. `collectConfigIssues` reports a missing `format` and any other value as an issue on `contract.source.format`. Tooling that reads the inputs itself, such as `contract format` and the language server, reads only a `'psl'` source's inputs. + Declare `source.inputs` only for source files that are not already covered by the config module graph, such as PSL schema paths or TypeScript contract paths passed as strings. Do not include emitted artifact paths derived from `contract.output` (for example `contract.json` or the diff --git a/packages/1-framework/1-core/config/package.json b/packages/1-framework/1-core/config/package.json index 9ad6797d7cdf..becfa5348ce3 100644 --- a/packages/1-framework/1-core/config/package.json +++ b/packages/1-framework/1-core/config/package.json @@ -20,7 +20,8 @@ "@internal/framework-components": "workspace:8.0.0-rc.12", "@internal/utils": "workspace:8.0.0-rc.12", "arktype": "^2.2.2", - "pathe": "^2.0.3" + "pathe": "^2.0.3", + "tinyglobby": "catalog:" }, "devDependencies": { "@repo/tsconfig": "workspace:8.0.0-rc.12", diff --git a/packages/1-framework/1-core/config/src/contract-source-types.ts b/packages/1-framework/1-core/config/src/contract-source-types.ts index 06f1604f5fd7..76e727100049 100644 --- a/packages/1-framework/1-core/config/src/contract-source-types.ts +++ b/packages/1-framework/1-core/config/src/contract-source-types.ts @@ -62,7 +62,11 @@ export interface ContractSourceContext { readonly reportWarning?: (diagnostic: ContractSourceDiagnostic) => void; } -/** Lets format-aware tooling avoid file-extension sniffing and opaque loader introspection. */ +/** + * The language a contract source's inputs are written in. Every source states + * one. Tooling that reads the inputs itself, such as `contract format` and the + * language server, checks this instead of guessing from file extensions. + */ export type ContractSourceFormat = 'psl' | 'typescript'; export interface ContractSourceProviderBase { @@ -85,17 +89,4 @@ export interface TypeScriptContractSourceProvider extends ContractSourceProvider readonly format: 'typescript'; } -/** - * Third-party or unspecified source formats. Absent (or unrecognized) - * `format` means format-aware tooling must leave the source untouched. - * Narrowing to a known format flows only through capability guards owned by - * the authoring layer. - */ -export interface OpaqueContractSourceProvider extends ContractSourceProviderBase { - readonly format?: string; -} - -export type ContractSourceProvider = - | PslContractSourceProvider - | TypeScriptContractSourceProvider - | OpaqueContractSourceProvider; +export type ContractSourceProvider = PslContractSourceProvider | TypeScriptContractSourceProvider; diff --git a/packages/1-framework/1-core/config/src/default-contract-output-path.ts b/packages/1-framework/1-core/config/src/default-contract-output-path.ts new file mode 100644 index 000000000000..0c548a050f94 --- /dev/null +++ b/packages/1-framework/1-core/config/src/default-contract-output-path.ts @@ -0,0 +1,25 @@ +import { extname } from 'pathe'; +import { isDynamicPattern } from 'tinyglobby'; + +function staticPrefixDirectory(pattern: string): string { + const staticSegments: string[] = []; + for (const segment of pattern.replaceAll('\\', '/').split('/')) { + if (isDynamicPattern(segment)) break; + staticSegments.push(segment); + } + return staticSegments.join('/'); +} + +/** + * Where `contract emit` writes the contract JSON for a config that names + * `contractPath` and sets no output: beside the contract file and named after + * it, or `contract.json` in the static prefix directory of a glob. + */ +export function defaultContractOutputPath(contractPath: string): string { + if (isDynamicPattern(contractPath)) { + const prefix = staticPrefixDirectory(contractPath); + return prefix.length === 0 ? 'contract.json' : `${prefix}/contract.json`; + } + const extension = extname(contractPath); + return `${extension.length === 0 ? contractPath : contractPath.slice(0, -extension.length)}.json`; +} diff --git a/packages/1-framework/1-core/config/src/exports/config-types.ts b/packages/1-framework/1-core/config/src/exports/config-types.ts index 973e772dfe3e..15e7cd235546 100644 --- a/packages/1-framework/1-core/config/src/exports/config-types.ts +++ b/packages/1-framework/1-core/config/src/exports/config-types.ts @@ -13,7 +13,7 @@ export type { ContractSourceFormat, ContractSourceProvider, ContractSourceProviderBase, - OpaqueContractSourceProvider, PslContractSourceProvider, TypeScriptContractSourceProvider, } from '../contract-source-types'; +export { defaultContractOutputPath } from '../default-contract-output-path'; diff --git a/packages/1-framework/1-core/config/test/config-types.test-d.ts b/packages/1-framework/1-core/config/test/config-types.test-d.ts index 0838461b7e1a..1f52ac2a02d8 100644 --- a/packages/1-framework/1-core/config/test/config-types.test-d.ts +++ b/packages/1-framework/1-core/config/test/config-types.test-d.ts @@ -14,7 +14,6 @@ import type { ContractSourceDiagnostic, ContractSourceFormat, ContractSourceProvider, - OpaqueContractSourceProvider, PslContractSourceProvider, TypeScriptContractSourceProvider, } from '../src/contract-source-types'; @@ -125,7 +124,7 @@ test('accepts contract source providers with declared inputs', () => { }; const result = defineConfig(config); - expectTypeOf(result.contract!.source.format).toEqualTypeOf(); + expectTypeOf(result.contract!.source.format).toEqualTypeOf(); expectTypeOf(result.contract!.source.inputs).toEqualTypeOf(); expectTypeOf(result.contract!.source.load).toEqualTypeOf(); }); @@ -146,33 +145,19 @@ test('source diagnostics require a filename but not a span', () => { void missingFilename; }); -test('contract source providers form a format-keyed union', () => { +test('a contract source is PSL or TypeScript, and nothing else', () => { expectTypeOf().toEqualTypeOf< - PslContractSourceProvider | TypeScriptContractSourceProvider | OpaqueContractSourceProvider + PslContractSourceProvider | TypeScriptContractSourceProvider >(); + expectTypeOf().toEqualTypeOf<'psl' | 'typescript'>(); expectTypeOf().toEqualTypeOf<'psl'>(); expectTypeOf().toEqualTypeOf<'typescript'>(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toExtend(); - expectTypeOf().toExtend(); -}); - -test('provider literals remain assignable to the union without casts', () => { - const load: ContractSourceProvider['load'] = async (_context) => ok({} as never); - - const psl: ContractSourceProvider = { - format: 'psl', - inputs: ['./schema.prisma'], - load, - }; - const typescript: ContractSourceProvider = { format: 'typescript', load }; - const absent: ContractSourceProvider = { load }; - const thirdParty: ContractSourceProvider = { format: 'made-up-format', load }; - - expectTypeOf(psl).toExtend(); - expectTypeOf(typescript).toExtend(); - expectTypeOf(absent).toExtend(); - expectTypeOf(thirdParty).toExtend(); + // @ts-expect-error every contract source states its format + const untagged: ContractSourceProvider = { inputs: [], load: async () => ok({} as never) }; + void untagged; + // @ts-expect-error a source format the framework does not have + const other: ContractSourceProvider = { format: 'other', inputs: [], load: async () => ok({}) }; + void other; }); test('carries an optional formatter section', () => { diff --git a/packages/1-framework/1-core/config/test/default-contract-output-path.test.ts b/packages/1-framework/1-core/config/test/default-contract-output-path.test.ts new file mode 100644 index 000000000000..bae548a2c10f --- /dev/null +++ b/packages/1-framework/1-core/config/test/default-contract-output-path.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { defaultContractOutputPath } from '../src/default-contract-output-path'; + +describe('defaultContractOutputPath', () => { + it('names the JSON after the contract file, beside it', () => { + expect(defaultContractOutputPath('./prisma/contract.prisma')).toBe('./prisma/contract.json'); + expect(defaultContractOutputPath('src/schema.ts')).toBe('src/schema.json'); + }); + + it('appends .json to a path with no extension', () => { + expect(defaultContractOutputPath('./prisma/contract')).toBe('./prisma/contract.json'); + }); + + it('uses contract.json in the static prefix directory of a glob', () => { + expect(defaultContractOutputPath('./prisma/**/*.prisma')).toBe('./prisma/contract.json'); + expect(defaultContractOutputPath('.\\prisma\\**\\*.prisma')).toBe('./prisma/contract.json'); + }); + + it('uses contract.json when a glob has no static prefix', () => { + expect(defaultContractOutputPath('*.prisma')).toBe('contract.json'); + }); +}); diff --git a/packages/1-framework/1-core/config/test/define-config.test.ts b/packages/1-framework/1-core/config/test/define-config.test.ts index 37d51a8eab83..1ad2b63d66d0 100644 --- a/packages/1-framework/1-core/config/test/define-config.test.ts +++ b/packages/1-framework/1-core/config/test/define-config.test.ts @@ -20,6 +20,7 @@ const mockHook = { function createSourceProvider(overrides: Record = {}) { return { + format: 'typescript', load: async () => ok({ targetFamily: 'sql' } as Contract), ...overrides, }; diff --git a/packages/1-framework/1-core/framework-components/README.md b/packages/1-framework/1-core/framework-components/README.md index 484b8a5504b8..2376da865629 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 field presets +- **Authoring types** (`./authoring`): Declarative authoring contribution types, template resolution, and validation for type constructors and field presets. `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 - **Codec base interface** (`./codec`): The cross-family `Codec` base type that SQL `Codec` and Mongo `MongoCodec` extend - **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`, `ValidationContext`, `GenerateContractTypesOptions`, `TypeRenderEntry`, `TypeRenderer`, `ParameterizedCodecDescriptor`, and related types diff --git a/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts b/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts index 3ee4806052fc..f945c9615057 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts @@ -1,3 +1,4 @@ +import type { Contract, ControlPolicy } from '@internal/contract/types'; import type { ControlTargetDescriptor } from './control-descriptors'; import type { ControlFamilyInstance } from './control-instances'; import type { MigrationPlanOperation, TargetMigrationsCapability } from './control-migration-types'; @@ -53,6 +54,37 @@ export function hasPslContractInfer( ); } +/** + * Settings a PSL contract source takes from the config, because a PSL file + * cannot carry them. Emitting the PSL file produces the same contract only + * when the config sets each of them on the new source. + */ +export interface PslSourceSettings { + readonly defaultControlPolicy?: ControlPolicy; +} + +/** The PSL document built from a contract, and the settings its PSL source must carry. */ +export interface PslContractDocument { + readonly document: PslDocumentAst; + readonly sourceSettings: PslSourceSettings; +} + +/** + * Capability declaring that a family can build the PSL document AST of a contract. `contract` must be one the family's contract serializer accepted. `prisma contract print` renders the document as text. + */ +export interface PslContractBuildCapable { + buildPslContract(contract: TContract): PslContractDocument; +} + +export function hasPslContractBuild( + instance: ControlFamilyInstance, +): instance is ControlFamilyInstance & PslContractBuildCapable { + return ( + 'buildPslContract' in instance && + typeof Reflect.get(instance, 'buildPslContract') === 'function' + ); +} + /** * Capability declaring that a family can render a textual preview of migration * operations for the CLI's "DDL preview" output. SQL families emit diff --git a/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts b/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts index c23fc3064c35..1bcd517517d3 100644 --- a/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts +++ b/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts @@ -197,6 +197,12 @@ export interface PslTypesBlock { */ export const UNSPECIFIED_PSL_NAMESPACE_ID = '__unspecified__'; +/** + * The name the unbound namespace is written under in PSL: `namespace unbound { … }`. Readers map it + * to the IR's `__unbound__` namespace id, and writers map that id back to it. + */ +export const UNBOUND_PSL_NAMESPACE_NAME = 'unbound'; + /** A value in {@link PslNamespace.entries}: a built-in entity node or an extension-contributed {@link PslExtensionBlock}. */ export type PslNamespaceEntry = PslModel | PslCompositeType | PslExtensionBlock; 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 51d430a7fbeb..91b4a779d2d5 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,8 @@ +export type { + AuthoringTypeConstructorCall, + AuthoringTypeConstructorOutput, +} from '../shared/authoring-type-constructor-call'; +export { findAuthoringTypeConstructorCall } from '../shared/authoring-type-constructor-call'; export { checkUncomposedNamespace, getAuthoringFieldPreset, diff --git a/packages/1-framework/1-core/framework-components/src/exports/control.ts b/packages/1-framework/1-core/framework-components/src/exports/control.ts index 4cd1b0f9c0b0..8f210c5a0339 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/control.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/control.ts @@ -10,13 +10,17 @@ export type { DiffSubjectGranularity, MigratableTargetDescriptor, OperationPreviewCapable, + PslContractBuildCapable, + PslContractDocument, PslContractInferCapable, + PslSourceSettings, SchemaSubjectClassifierCapable, SchemaViewCapable, } from '../control/control-capabilities'; export { hasMigrations, hasOperationPreview, + hasPslContractBuild, hasPslContractInfer, hasSchemaSubjectClassifier, hasSchemaView, 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 new file mode 100644 index 000000000000..b2c77c7594d6 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/shared/authoring-type-constructor-call.ts @@ -0,0 +1,93 @@ +import { canonicalizeJson } from '../utils/canonicalize-json'; +import { + type AuthoringTypeConstructorDescriptor, + type AuthoringTypeNamespace, + instantiateAuthoringTypeConstructor, + isAuthoringArgRef, + isAuthoringTypeConstructorDescriptor, +} from './framework-authoring'; + +/** What a type constructor call produces for a column. */ +export type AuthoringTypeConstructorOutput = ReturnType; + +/** A type constructor, by its path in an authoring namespace, and the arguments of one call to it. */ +export interface AuthoringTypeConstructorCall { + readonly path: readonly string[]; + readonly args: readonly unknown[]; +} + +function* typeConstructors( + namespace: AuthoringTypeNamespace, + path: readonly string[] = [], +): Generator<{ + readonly path: readonly string[]; + readonly descriptor: AuthoringTypeConstructorDescriptor; +}> { + for (const [name, value] of Object.entries(namespace)) { + if (isAuthoringTypeConstructorDescriptor(value)) { + yield { path: [...path, name], descriptor: value }; + } else { + yield* typeConstructors(value, [...path, name]); + } + } +} + +function produces( + produced: AuthoringTypeConstructorOutput, + wanted: AuthoringTypeConstructorOutput, +): boolean { + const wantedFields: Readonly> = { + ...wanted, + typeParams: wanted.typeParams ?? {}, + }; + return Object.entries({ ...produced, typeParams: produced.typeParams ?? {} }).every( + ([key, value]) => canonicalizeJson(value) === canonicalizeJson(wantedFields[key]), + ); +} + +/** + * The arguments that make `descriptor` produce exactly `output`, when each of its type parameters + * comes straight from one argument; `undefined` when it cannot produce `output`, including when + * `output` has no value for an argument the constructor requires. + */ +function argumentsFor( + descriptor: AuthoringTypeConstructorDescriptor, + output: AuthoringTypeConstructorOutput, +): readonly unknown[] | undefined { + if (descriptor.entityRefArg !== undefined || descriptor.output.codecId !== output.codecId) { + return undefined; + } + const args: unknown[] = []; + for (const [key, template] of Object.entries(descriptor.output.typeParams ?? {})) { + const value = output.typeParams?.[key]; + if (value !== undefined && isAuthoringArgRef(template) && template.path === undefined) { + args[template.index] = value; + } + } + 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) + ? args + : undefined; + } catch { + return undefined; + } +} + +/** + * The first call, in namespace order, to a type constructor in `namespace` that produces exactly + * `output`. The inverse of {@link instantiateAuthoringTypeConstructor} for constructors whose type + * parameters each come straight from one argument. A constructor whose argument names another + * entity is never chosen. `undefined` when no call produces `output`. + */ +export function findAuthoringTypeConstructorCall( + namespace: AuthoringTypeNamespace, + output: AuthoringTypeConstructorOutput, +): AuthoringTypeConstructorCall | undefined { + for (const { path, descriptor } of typeConstructors(namespace)) { + const args = argumentsFor(descriptor, output); + if (args !== undefined) return { path, args }; + } + return undefined; +} diff --git a/packages/1-framework/1-core/framework-components/test/authoring-type-constructor-call.test.ts b/packages/1-framework/1-core/framework-components/test/authoring-type-constructor-call.test.ts new file mode 100644 index 000000000000..2bb3f267f31a --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/authoring-type-constructor-call.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { findAuthoringTypeConstructorCall } from '../src/shared/authoring-type-constructor-call'; +import type { AuthoringTypeNamespace } from '../src/shared/framework-authoring'; + +const namespace = { + Text: { + kind: 'typeConstructor', + output: { codecId: 'test/text@1', nativeType: 'text' }, + }, + Decimal: { + kind: 'typeConstructor', + args: [ + { kind: 'number', name: 'precision', integer: true, optional: true }, + { kind: 'number', name: 'scale', integer: true, optional: true }, + ], + output: { + codecId: 'test/numeric@1', + nativeType: 'numeric', + typeParams: { + precision: { kind: 'arg', index: 0 }, + scale: { kind: 'arg', index: 1 }, + }, + }, + }, + vector: { + Vector: { + kind: 'typeConstructor', + args: [{ kind: 'number', name: 'length', integer: true }], + output: { + codecId: 'test/vector@1', + nativeType: 'vector', + typeParams: { length: { kind: 'arg', index: 0 } }, + }, + }, + }, + Ref: { + kind: 'typeConstructor', + entityRefArg: { index: 0, entityKind: 'native_enum' }, + output: { codecId: 'test/enum@1', nativeType: 'text' }, + }, +} as const satisfies AuthoringTypeNamespace; + +describe('findAuthoringTypeConstructorCall', () => { + it('finds a constructor that takes no arguments', () => { + expect( + findAuthoringTypeConstructorCall(namespace, { codecId: 'test/text@1', nativeType: 'text' }), + ).toEqual({ path: ['Text'], args: [] }); + }); + + it('finds a nested constructor, with the arguments its type parameters come from', () => { + expect( + findAuthoringTypeConstructorCall(namespace, { + codecId: 'test/vector@1', + nativeType: 'vector', + typeParams: { length: 3 }, + }), + ).toEqual({ path: ['vector', 'Vector'], args: [3] }); + }); + + it('compares type parameters whatever order their keys are in', () => { + expect( + findAuthoringTypeConstructorCall(namespace, { + codecId: 'test/numeric@1', + nativeType: 'numeric', + typeParams: { scale: 2, precision: 10 }, + }), + ).toEqual({ path: ['Decimal'], args: [10, 2] }); + }); + + it('leaves out optional arguments the type parameters do not name', () => { + expect( + findAuthoringTypeConstructorCall(namespace, { + codecId: 'test/numeric@1', + nativeType: 'numeric', + }), + ).toEqual({ path: ['Decimal'], args: [] }); + }); + + it('finds nothing when no constructor produces the native type', () => { + expect( + findAuthoringTypeConstructorCall(namespace, { + codecId: 'test/text@1', + nativeType: 'varchar', + }), + ).toBeUndefined(); + }); + + it('finds nothing when the type parameters differ from what the constructor produces', () => { + expect( + findAuthoringTypeConstructorCall(namespace, { + codecId: 'test/text@1', + nativeType: 'text', + typeParams: { length: 3 }, + }), + ).toBeUndefined(); + }); + + it('finds nothing when an argument before a given one has no type parameter to come from', () => { + expect( + findAuthoringTypeConstructorCall(namespace, { + codecId: 'test/numeric@1', + nativeType: 'numeric', + typeParams: { scale: 2 }, + }), + ).toBeUndefined(); + }); + + it('finds nothing when a required argument has no type parameter to come from', () => { + expect( + findAuthoringTypeConstructorCall(namespace, { + codecId: 'test/vector@1', + nativeType: 'vector', + }), + ).toBeUndefined(); + }); + + it('never calls a constructor whose argument names another entity', () => { + expect( + findAuthoringTypeConstructorCall(namespace, { codecId: 'test/enum@1', nativeType: 'text' }), + ).toBeUndefined(); + }); + + it('takes the first constructor in namespace order when two produce the same output', () => { + const twice = { + First: { kind: 'typeConstructor', output: { codecId: 'test/text@1', nativeType: 'text' } }, + Second: { kind: 'typeConstructor', output: { codecId: 'test/text@1', nativeType: 'text' } }, + } as const satisfies AuthoringTypeNamespace; + + expect( + findAuthoringTypeConstructorCall(twice, { codecId: 'test/text@1', nativeType: 'text' }), + ).toEqual({ path: ['First'], args: [] }); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/control-capabilities.test.ts b/packages/1-framework/1-core/framework-components/test/control-capabilities.test.ts index 6b6d11de1c9a..d315a9efb954 100644 --- a/packages/1-framework/1-core/framework-components/test/control-capabilities.test.ts +++ b/packages/1-framework/1-core/framework-components/test/control-capabilities.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { hasOperationPreview, + hasPslContractBuild, hasPslContractInfer, hasSchemaView, } from '../src/control/control-capabilities'; @@ -48,6 +49,21 @@ describe('hasPslContractInfer', () => { }); }); +describe('hasPslContractBuild', () => { + it('returns true when instance exposes buildPslContract function', () => { + const instance = { + ...baseInstance, + buildPslContract: () => ({ document: SYNTHETIC_AST, sourceSettings: {} }), + } as ControlFamilyInstance<'sql', unknown>; + + expect(hasPslContractBuild(instance)).toBe(true); + }); + + it('returns false when instance does not declare buildPslContract', () => { + expect(hasPslContractBuild(baseInstance)).toBe(false); + }); +}); + describe('hasSchemaView', () => { it('returns true when instance exposes toSchemaView function', () => { const instance = { diff --git a/packages/1-framework/1-core/framework-components/test/psl-contract-build.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/psl-contract-build.types.test-d.ts new file mode 100644 index 000000000000..029b0beddc0b --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/psl-contract-build.types.test-d.ts @@ -0,0 +1,24 @@ +import type { Contract, ControlPolicy } from '@internal/contract/types'; +import { expectTypeOf, test } from 'vitest'; +import type { + PslContractBuildCapable, + PslContractDocument, + PslSourceSettings, +} from '../src/control/control-capabilities'; +import type { PslDocumentAst } from '../src/control/psl-ast'; + +test('building the PSL document of a contract returns the document and the settings its PSL source must carry', () => { + expectTypeOf().parameters.toEqualTypeOf< + [Contract] + >(); + expectTypeOf< + PslContractBuildCapable['buildPslContract'] + >().returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ + readonly document: PslDocumentAst; + readonly sourceSettings: PslSourceSettings; + }>(); + expectTypeOf().toEqualTypeOf<{ + readonly defaultControlPolicy?: ControlPolicy; + }>(); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index be7f90496704..cfe0695ac5ba 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -39,7 +39,7 @@ Interpretation/validation (for example `@internal/sql-contract-psl`) is responsi ## Public API -- `parse(source, filename, options?)` in `src/parse.ts` (also at `@internal/psl-parser/syntax`) — the CST parser: returns the `DocumentAst`, a `PslSources` registry for resolving nodes to their named `SourceFile`, and syntactic diagnostics. The recursive-descent / lossless-CST path supersedes the legacy `parsePslDocument`. +- `parse(source, filename)` in `src/parse.ts` (also at `@internal/psl-parser/syntax`) — the CST parser: returns the `DocumentAst`, a `PslSources` registry for resolving nodes to their named `SourceFile`, and syntactic diagnostics. The recursive-descent / lossless-CST path supersedes the legacy `parsePslDocument`. Every document parses the same way: a `view` body parses as fields, and an `enum` member may carry attributes. Each reader decides whether it accepts them. - `buildSymbolTable({ documents, sources, pslBlockDescriptors })` in `src/symbol-table.ts` — a pure, fault-tolerant pass over an ordered `readonly DocumentAst[]` that returns `{ symbolTable, diagnostics }`, with a scope-aware `SymbolTable` (top-level namespaces / named types / blocks / models / composite-types as keyed records discriminated by `kind`, namespace members and block fields nested under their owner, declaration symbols carrying their CST AST `node` plus declaration `span`, and namespace symbols retaining every authored node and span in `declarations`) plus its own source-associated diagnostics (the same `ParseDiagnostic` shape as parser errors: `filename`, `code`, `message`, and a file-local `range`). Duplicate names are first-wins across documents and kinds within one scope (`PSL_DUPLICATE_DECLARATION`); repeated namespaces reopen the same scope, retaining distinct members and diagnosing duplicate member names across declarations and documents. Every supplied document root must be registered in the shared `sources`, even for empty documents. An empty collection returns an empty scope. Single-file callers pass `documents: [document]`; no file discovery is performed. `pslBlockDescriptors` is supplied from authoring contributions so generic/extension blocks can be reconstructed once into `BlockSymbol.block`. The pass also **resolves** the field/named-type read set once: each `FieldSymbol` carries the split type (`typeName`/`typeNamespaceId`/`typeContractSpaceId`), `optional`/`list`, `typeConstructor?`, rendered `attributes`, and `malformedType?` (set, with a `PSL_INVALID_QUALIFIED_TYPE` diagnostic, when the type is over-qualified); `NamedTypeSymbol` carries the resolved binding (`baseType`/`typeConstructor`/`isConstructor`). Interpreters consume this resolved shape directly — there is no per-package field/attribute view layer. - `createBinder({ sources, symbolTable, typeConstructors, attributeSpecs })` in `src/binder.ts` — the name resolver. It returns `{ binder, diagnostics }`, mirroring `buildSymbolTable`: resolution runs eagerly over the symbol table at creation, and the returned diagnostics are complete when the factory returns. Queries are map reads and say nothing about when resolution ran. See [the binder section below](#binder). - `referencedModel` / `modelAttributeContext` / `fieldAttributeContext` in `src/binder-context.ts` — build the ADR 249 parse-time attribute contexts from a binder. `resolveReferencedModel` becomes one map read (`symbolForNode(typeReferenceNode(field))`, narrowed to a model) instead of a resolver each consumer supplies for itself, and the context carries the binder itself so the reference combinators stop raising their own existence diagnostics. See [attribute contexts and the single voice](#attribute-contexts-and-the-single-voice). @@ -50,6 +50,9 @@ Interpretation/validation (for example `@internal/sql-contract-psl`) is responsi (a `BlockSymbol`) and run the framework's standalone `validateExtensionBlock` over it, building the ref-resolution context from the symbol table. - `parseQuotedStringLiteral` / `getPositionalArgument` in `src/attribute-helpers.ts`. +- `isPslIdentifier(text)` in `src/tokenizer.ts`: whether the tokenizer reads the text as one identifier. Code that writes PSL checks a name with it before writing the name where PSL reads an identifier. +- `NAME_THE_PSL_SOURCE_LOSES` in `src/name-the-psl-source-loses.ts`: the name `__proto__`, which is lost when a PSL file is read: the parser keeps block members, and the PSL contract sources keep other names, as keys of plain objects. Code that writes PSL refuses this name wherever a PSL source reads a name, `@map` and `@@map` included. +- Rules both PSL readers apply, at `@internal/psl-parser/interpret`: `claimedBlockKeywords` and `unsupportedBlockDiagnostic` (`src/unclaimed-blocks.ts`) report a generic block whose keyword no composed descriptor claims; `enumMemberAttributeDiagnostics` (`src/enum-member-attributes.ts`) reports an attribute on an enum member; `src/relation-backrelations.ts` holds the back-relation pairing rules. - Legacy AST/span types live in `@internal/framework-components/psl-ast` and are re-exported from this package's root entry. The attribute kit's `PslDiagnostic` lives in `src/diagnostic.ts`; framework contribution diagnostics retain their separate external contract. - Subpath exports: - `@internal/psl-parser/syntax` diff --git a/packages/1-framework/2-authoring/psl-parser/src/enum-member-attributes.ts b/packages/1-framework/2-authoring/psl-parser/src/enum-member-attributes.ts new file mode 100644 index 000000000000..51545b6713c6 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/enum-member-attributes.ts @@ -0,0 +1,18 @@ +import { diagnosticSource, type PslDiagnostic } from './diagnostic'; +import { nodePslSpan } from './resolve'; +import type { PslSources } from './source-file'; +import type { BlockSymbol } from './symbol-table'; + +export function enumMemberAttributeDiagnostics( + enumBlock: BlockSymbol, + sources: PslSources, +): PslDiagnostic[] { + const source = diagnosticSource(sources, enumBlock.node.syntax); + return Array.from(enumBlock.node.entries()).flatMap((member) => + Array.from(member.attributes(), (attribute) => ({ + code: 'PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE', + message: `enum "${enumBlock.block.name}": member "${member.key()?.name() ?? '?'}" carries @${attribute.name()?.path().join('.') ?? '?'}, but an enum member takes no attributes`, + ...source.at(nodePslSpan(attribute.syntax, sources)), + })), + ); +} 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 f8ea51b4f8d6..112a17eaaa31 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 @@ -130,6 +130,7 @@ export type { ResolvedEntityReference, } from '../entity-reference'; export { findBlockDescriptor, validateExtensionBlockFromSymbol } from '../extension-block'; +export { NAME_THE_PSL_SOURCE_LOSES } from '../name-the-psl-source-loses'; export { keywordPslSpan, nodePslSpan, @@ -156,3 +157,4 @@ export type { TopLevelScope, } from '../symbol-table'; export { buildSymbolTable } from '../symbol-table'; +export { isPslIdentifier } from '../tokenizer'; 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 4d6b80f0c5ed..e0a1ae22b87d 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 @@ -1,4 +1,5 @@ export { mapPslHelperArgs } from '../authoring-arguments'; +export { enumMemberAttributeDiagnostics } from '../enum-member-attributes'; export { instantiatePslFieldPreset, reportUncomposedNamespace, @@ -13,3 +14,4 @@ export { fkRelationPairKey, requiredOneToOneBackrelationDiagnostic, } from '../relation-backrelations'; +export { claimedBlockKeywords, unsupportedBlockDiagnostic } from '../unclaimed-blocks'; diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts index c5af24201186..96797ed28fb7 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts @@ -1,4 +1,4 @@ -export type { ParseDiagnostic, ParseOptions, ParseResult } from '../parse'; +export type { ParseDiagnostic, ParseResult } from '../parse'; export { parse } from '../parse'; export type { Position, Range } from '../source-file'; export { PslSources, SourceFile } from '../source-file'; diff --git a/packages/1-framework/2-authoring/psl-parser/src/format/emit.ts b/packages/1-framework/2-authoring/psl-parser/src/format/emit.ts index 74ba5f8f2574..932712fcd4c2 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/format/emit.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/format/emit.ts @@ -103,7 +103,8 @@ class LineWriter { } } -// Qualified-name separators hug; argument/object colons keep the usual value space. +// Qualified-name separators hug. `[` hugs a type (`String[]`) but keeps the space before a list +// value after `:`, `,` or `=`. function spaceBetween( prev: TokenKind | undefined, cur: TokenKind, @@ -115,8 +116,9 @@ function spaceBetween( if (prev === 'Ident' && cur === 'StringLiteral') return false; switch (cur) { - case 'LParen': case 'LBracket': + return prev === 'Colon' || prev === 'Comma' || prev === 'Equals'; + case 'LParen': case 'RParen': case 'RBracket': case 'Comma': @@ -236,24 +238,20 @@ type MemberCategory = 'regular' | 'blockAttribute' | 'nestedBlock'; interface BlockMember { readonly category: MemberCategory; - /** Stays on the source line it shares with the member that follows it. */ - readonly keepsSourceLine: boolean; - emit(trailing: string | undefined, endLine: boolean): number; + emit(trailing: string | undefined): number; } function leafMember( writer: LineWriter, category: MemberCategory, print: () => number, - keepsSourceLine = false, ): BlockMember { return { category, - keepsSourceLine, - emit(trailing, endLine) { + emit(trailing) { const continuation = print(); if (trailing !== undefined) writer.comment(trailing); - else if (endLine) writer.newline(); + else writer.newline(); return continuation; }, }; @@ -295,11 +293,6 @@ function emitCompositeType( }); } -/** - * With the `prisma7` grammar a `view` body holds `FieldDeclaration` members, so they print through - * the model field path. With the `psl` grammar the same source line reads as several entries with - * no `=`; those entries keep the source line they share, so `id Int` does not become two lines. - */ function emitGenericBlock( writer: LineWriter, block: GenericBlockDeclarationAst, @@ -310,13 +303,7 @@ function emitGenericBlock( const field = FieldDeclarationAst.cast(node); if (field) return leafMember(writer, 'regular', () => emitField(writer, field, alignment)); const entry = KeyValuePairAst.cast(node); - if (entry) - return leafMember( - writer, - 'regular', - () => emitKeyValue(writer, entry), - entry.equals() === undefined, - ); + if (entry) return leafMember(writer, 'regular', () => emitKeyValue(writer, entry)); const attribute = ModelAttributeAst.cast(node); if (attribute) return leafMember(writer, 'blockAttribute', () => emitBlockAttribute(writer, attribute)); @@ -361,7 +348,6 @@ type BlockEmitter = (writer: LineWriter, trailing: string | undefined) => void; function nestedBlockMember(writer: LineWriter, block: BlockEmitter): BlockMember { return { category: 'nestedBlock', - keepsSourceLine: false, emit(trailing) { block(writer, trailing); return 0; @@ -398,12 +384,15 @@ function emitBlockBody( const children = Array.from(node.children()); const openIndex = children.findIndex((el) => !(el instanceof SyntaxNode) && el.kind === 'LBrace'); - streamHeader(writer, node); - const headerComment = sameLineCommentAfter(children, openIndex); - if (headerComment !== undefined) writer.comment(headerComment); + const headerComments = streamHeader(writer, node); + const openingComment = sameLineCommentAfter(children, openIndex); + if (openingComment !== undefined) headerComments.push(openingComment); + const [firstComment, ...otherComments] = headerComments; + if (firstComment !== undefined) writer.comment(firstComment); else writer.newline(); writer.indent(); + for (const comment of otherComments) writer.comment(comment); walkRegion(writer, children, 'RBrace', classify); writer.unindent(); @@ -412,7 +401,9 @@ function emitBlockBody( else writer.newline(); } -function streamHeader(writer: LineWriter, node: SyntaxNode): void { +/** Writes the block header through `{` and returns the source comments found before the `{`. */ +function streamHeader(writer: LineWriter, node: SyntaxNode): string[] { + const comments: string[] = []; let done = false; const walk = (parent: SyntaxNode): void => { for (const child of parent.children()) { @@ -421,7 +412,11 @@ function streamHeader(writer: LineWriter, node: SyntaxNode): void { walk(child); continue; } - if (child.kind === 'Whitespace' || child.kind === 'Newline' || child.kind === 'Comment') { + if (child.kind === 'Comment') { + comments.push(child.text); + continue; + } + if (child.kind === 'Whitespace' || child.kind === 'Newline') { continue; } const space = spaceBetween(writer.prevKind(), child.kind, false); @@ -433,6 +428,7 @@ function streamHeader(writer: LineWriter, node: SyntaxNode): void { } }; walk(node); + return comments; } function walkRegion( @@ -467,8 +463,7 @@ function walkRegion( } const trailing = sameLineTrailingComment(elements, i); - const endLine = !member.keepsSourceLine || !memberFollowsOnSameLine(elements, i); - closeContinuation(writer, member.emit(trailing.text, endLine)); + closeContinuation(writer, member.emit(trailing.text)); if (trailing.index !== undefined) i = trailing.index; sawContent = true; lastWasRegular = member.category !== 'blockAttribute'; @@ -508,17 +503,6 @@ function walkRegion( } } -function memberFollowsOnSameLine(elements: readonly SyntaxElement[], memberIndex: number): boolean { - for (let i = memberIndex + 1; i < elements.length; i++) { - const element = elements[i]; - if (element === undefined) continue; - if (element instanceof SyntaxNode) return true; - if (element.kind === 'Whitespace') continue; - return false; - } - return false; -} - function separationBlankWanted( writer: LineWriter, category: MemberCategory, diff --git a/packages/1-framework/2-authoring/psl-parser/src/name-the-psl-source-loses.ts b/packages/1-framework/2-authoring/psl-parser/src/name-the-psl-source-loses.ts new file mode 100644 index 000000000000..f1d919ab6585 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/name-the-psl-source-loses.ts @@ -0,0 +1,4 @@ +/** + * A name that is lost when a PSL file is read. The parser keeps block members, and the PSL contract sources keep other names, as keys of plain objects, where assigning this key sets the object's prototype instead of adding a key. Code that writes PSL must not write this name anywhere a PSL source reads a name, including inside `@map` and `@@map`. + */ +export const NAME_THE_PSL_SOURCE_LOSES = '__proto__'; diff --git a/packages/1-framework/2-authoring/psl-parser/src/parse.ts b/packages/1-framework/2-authoring/psl-parser/src/parse.ts index 3d628ecc70a3..a6afee6185f1 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/parse.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/parse.ts @@ -17,16 +17,6 @@ export interface ParseResult { readonly sources: PslSources; } -export type PslGrammar = 'psl' | 'prisma7'; - -export interface ParseOptions { - /** - * `prisma7` also reads two Prisma 7 constructs: `@` attributes after an `enum` member, and field - * lines in a `view` body. Defaults to `psl`, which reads neither. - */ - readonly grammar?: PslGrammar; -} - const TRIVIA_KINDS: ReadonlySet = new Set([ 'Whitespace', 'Newline', @@ -43,7 +33,7 @@ export interface DiagnosticMark { } /** - * The fault-tolerant parser substrate the grammars drive. Trivia is flushed + * The fault-tolerant parser substrate the parse functions below drive. Trivia is flushed * into the enclosing open node, so every child node spans exactly its first * through last significant token. */ @@ -525,19 +515,19 @@ type MemberParser = (cursor: Cursor) => void; * Parses a full PSL document. Never throws — malformed input yields diagnostics * and a recovered tree, not an exception. */ -export function parse(source: string, filename: string, options: ParseOptions = {}): ParseResult { +export function parse(source: string, filename: string): ParseResult { const cursor = new Cursor(filename, source); - const green = parseDocument(cursor, options.grammar ?? 'psl'); + const green = parseDocument(cursor); const root = createSyntaxTree(green); const document = DocumentAst.cast(root) ?? new DocumentAst(root); const sources = new PslSources([[document.syntax, cursor.sourceFile]]); return { document, diagnostics: cursor.diagnostics, sources }; } -function parseDocument(cursor: Cursor, grammar: PslGrammar): GreenNode { +function parseDocument(cursor: Cursor): GreenNode { cursor.startNode('Document'); while (cursor.peekKind() !== 'Eof') { - parseDeclaration(cursor, false, grammar); + parseDeclaration(cursor, false); } cursor.flushTrivia(); // attach trailing trivia so the round-trip stays lossless return cursor.finishNode(); @@ -560,7 +550,7 @@ function keywordIs(cursor: Cursor, keyword: string): boolean { * Recovery runs via the `if (!node)` tail rather than as a `??` arm, because it * appends raw tokens to the open parent instead of returning a child node. */ -function parseDeclaration(cursor: Cursor, insideNamespace: boolean, grammar: PslGrammar): void { +function parseDeclaration(cursor: Cursor, insideNamespace: boolean): void { const name = cursor.peekKind(1) === 'Ident' ? cursor.peekToken(1).text : ''; if (insideNamespace && keywordIs(cursor, 'namespace')) { cursor.diagnostic( @@ -584,10 +574,10 @@ function parseDeclaration(cursor: Cursor, insideNamespace: boolean, grammar: Psl const node = parseModel(cursor) ?? - parseNamespace(cursor, grammar) ?? + parseNamespace(cursor) ?? parseCompositeType(cursor) ?? parseTypesBlock(cursor) ?? - parseGenericBlock(cursor, grammar); + parseGenericBlock(cursor); if (!node) { parseUnsupportedTopLevel(cursor); } @@ -638,15 +628,8 @@ export function parseModel(cursor: Cursor): GreenNode | undefined { * {` with no name) routed to its dedicated parser. The generic keyword set is * open, so a bare identifier with no brace (e.g. `oops`) is read as an unfinished * custom declaration rather than unsupported content. - * - * With the `prisma7` grammar, a `view` block stays a generic block (so interpreters keep rejecting - * the keyword), but its body is read like a model body, so the field lines parse as - * `FieldDeclaration` nodes with spans instead of mangled entries. */ -export function parseGenericBlock( - cursor: Cursor, - grammar: PslGrammar = 'psl', -): GreenNode | undefined { +export function parseGenericBlock(cursor: Cursor): GreenNode | undefined { if (cursor.peekKind() !== 'Ident') return undefined; const keyword = cursor.peekToken().text; if (RESERVED_BLOCK_KEYWORDS.has(keyword)) return undefined; @@ -657,7 +640,7 @@ export function parseGenericBlock( parseIdentifier(cursor); } if (cursor.peekKind() === 'LBrace') { - parseBlockBody(cursor, genericBlockMemberParser(keyword, grammar)); + parseBlockBody(cursor, genericBlockMemberParser(keyword)); } else { cursor.diagnostic( 'PSL_INVALID_DECLARATION', @@ -669,9 +652,9 @@ export function parseGenericBlock( return cursor.finishNode(); } -export function parseNamespace(cursor: Cursor, grammar: PslGrammar = 'psl'): GreenNode | undefined { +export function parseNamespace(cursor: Cursor): GreenNode | undefined { if (!keywordIs(cursor, 'namespace')) return undefined; - return parseBlock(cursor, 'Namespace', true, (inner) => parseDeclaration(inner, true, grammar)); + return parseBlock(cursor, 'Namespace', true, (inner) => parseDeclaration(inner, true)); } export function parseCompositeType(cursor: Cursor): GreenNode | undefined { @@ -744,13 +727,13 @@ function parseNamedTypeMember(cursor: Cursor): void { } /** - * With the `prisma7` grammar, a `view` body is read like a model body and an `enum` member may - * carry `@` attributes (`USER @map("user")`). Every other generic block, and every generic block - * with the `psl` grammar, reads plain `key = value` entries. + * A `view` body is read like a model body. Every other generic block reads `key = value` entries + * and bare keys, and in an `enum` block those may carry `@` attributes (`USER @map("user")`). Each + * interpreter decides whether it accepts the block and its members. */ -function genericBlockMemberParser(keyword: string, grammar: PslGrammar): MemberParser { - if (grammar === 'prisma7' && keyword === 'view') return parseModelMember; - if (grammar === 'prisma7' && keyword === 'enum') return parseEnumMember; +function genericBlockMemberParser(keyword: string): MemberParser { + if (keyword === 'view') return parseModelMember; + if (keyword === 'enum') return parseEnumMember; return parseKeyValueMember; } @@ -819,9 +802,9 @@ export function parseNamedType(cursor: Cursor): GreenNode | undefined { /** * A generic-block entry is either `key = value` or a bare `key` (committing a - * `KeyValuePair` carrying only the key). With `memberAttributes` (enum blocks parsed with the - * `prisma7` grammar) any number of `@` attributes may follow, as in `USER @map("user")`. A - * `key =` with no following expression is flagged. + * `KeyValuePair` carrying only the key). With `memberAttributes` (enum blocks) any number of `@` + * attributes may follow, as in `USER @map("user")`. A `key =` with no following expression is + * flagged. */ export function parseKeyValue( cursor: Cursor, diff --git a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts index 88171bd9645e..57c079c1b8bf 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts @@ -225,7 +225,7 @@ export class GenericBlockDeclarationAst implements BracedBlock { yield* filterChildren(this.syntax, KeyValuePairAst.cast); } - /** Field lines of a block parsed with the model-member grammar (a Prisma 7 `view`). Empty for every other generic block. */ + /** Field lines of a `view` body. Empty for every other generic block. */ *fields(): Iterable { yield* filterChildren(this.syntax, FieldDeclarationAst.cast); } @@ -278,7 +278,7 @@ export class KeyValuePairAst implements AstNode { return undefined; } - /** `@` attributes after the key or value (a Prisma 7 enum member's `@map`). */ + /** `@` attributes after the key or value, as in `USER @map("user")`. */ *attributes(): Iterable { yield* filterChildren(this.syntax, FieldAttributeAst.cast); } diff --git a/packages/1-framework/2-authoring/psl-parser/src/tokenizer.ts b/packages/1-framework/2-authoring/psl-parser/src/tokenizer.ts index 45d37ed6266e..5526220e9297 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/tokenizer.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/tokenizer.ts @@ -81,6 +81,15 @@ export class Tokenizer { } } +/** + * Whether the tokenizer reads `text` as one identifier: a letter or `_`, then letters, `_`, ASCII + * digits or `-`, and not one of the number words `NaN` and `Infinity`. + */ +export function isPslIdentifier(text: string): boolean { + const token = scan(text, 0); + return token.kind === 'Ident' && token.text === text; +} + function scan(source: string, pos: number): Token { if (pos >= source.length) { return { kind: 'Eof', text: '' }; diff --git a/packages/1-framework/2-authoring/psl-parser/src/unclaimed-blocks.ts b/packages/1-framework/2-authoring/psl-parser/src/unclaimed-blocks.ts new file mode 100644 index 000000000000..0b3b576e2fee --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/unclaimed-blocks.ts @@ -0,0 +1,31 @@ +import { + type AuthoringPslBlockDescriptorNamespace, + isAuthoringPslBlockDescriptor, +} from '@internal/framework-components/authoring'; +import { diagnosticSource, type PslDiagnostic } from './diagnostic'; +import { keywordPslSpan } from './resolve'; +import type { PslSources } from './source-file'; +import type { BlockSymbol } from './symbol-table'; + +/** A generic block is read only when a composed descriptor claims its keyword. */ +export function claimedBlockKeywords( + descriptors: AuthoringPslBlockDescriptorNamespace | undefined, +): ReadonlySet { + const keywords = new Set(); + for (const [keyword, value] of Object.entries(descriptors ?? {})) { + if (isAuthoringPslBlockDescriptor(value)) { + keywords.add(keyword); + } + } + return keywords; +} + +export function unsupportedBlockDiagnostic(block: BlockSymbol, sources: PslSources): PslDiagnostic { + return { + code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK', + message: `Unsupported top-level block "${block.keyword}"`, + ...diagnosticSource(sources, block.node.syntax).at( + keywordPslSpan(block.node.syntax, block.keyword, sources), + ), + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures.test.ts b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures.test.ts index 5ec82392f109..49b7874ee840 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures.test.ts @@ -10,7 +10,7 @@ const cases = readdirSync(fixturesDir, { withFileTypes: true }) .map((entry) => entry.name) .sort(); -const authoredCaseCount = 23; +const authoredCaseCount = 25; describe('format side-by-side fixture pairs', () => { it('discovers at least the authored set of fixture cases', () => { diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/expected.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/expected.prisma new file mode 100644 index 000000000000..a2f5edba9512 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/expected.prisma @@ -0,0 +1,22 @@ +datasource db { // the main database + provider = "postgresql" +} + +model User { // one row per account + id Int @id +} + +enum Role { // account roles + USER +} + +types { // shared aliases + Email = String +} + +namespace billing { // invoicing + model Invoice { // one row per invoice + // issued monthly + id Int @id + } +} diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/input.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/input.prisma new file mode 100644 index 000000000000..6e0626e453b8 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/input.prisma @@ -0,0 +1,27 @@ +datasource db // the main database +{ + provider = "postgresql" +} + +model User // one row per account +{ + id Int @id +} + +enum Role // account roles +{ + USER +} + +types // shared aliases +{ + Email = String +} + +namespace billing // invoicing +{ + model Invoice // one row per invoice + { // issued monthly + id Int @id + } +} diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/expected.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/expected.prisma new file mode 100644 index 000000000000..7f95db3c2630 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/expected.prisma @@ -0,0 +1,13 @@ +datasource db { + extensions = [pgvector, postgis] +} + +model Post { + id Int @id + authorId Int + author User @relation(fields: [authorId], references: [id]) + tags String[] + + @@index(fields: [authorId, id]) + @@index([[authorId], [id]]) +} diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/input.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/input.prisma new file mode 100644 index 000000000000..0bd125c5072c --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/input.prisma @@ -0,0 +1,12 @@ +datasource db { +extensions=[pgvector,postgis] +} + +model Post { +id Int @id +authorId Int +author User @relation(fields:[authorId], references:[id]) +tags String[] +@@index(fields:[authorId,id]) +@@index([[authorId],[id]]) +} diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/expected.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/expected.prisma index 494940b86444..4fa17c61a6c6 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/expected.prisma +++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/expected.prisma @@ -1,4 +1,4 @@ view ActiveUsers { - id Int + id Int email String } diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/input.prisma b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/input.prisma index 4fa17c61a6c6..515316f07c8f 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/input.prisma +++ b/packages/1-framework/2-authoring/psl-parser/test/format/fixtures/view-block-plain-fields/input.prisma @@ -1,4 +1,4 @@ view ActiveUsers { - id Int - email String +id Int +email String } diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/prisma7-constructs.test.ts b/packages/1-framework/2-authoring/psl-parser/test/format/prisma7-constructs.test.ts deleted file mode 100644 index f75ebb09f6b8..000000000000 --- a/packages/1-framework/2-authoring/psl-parser/test/format/prisma7-constructs.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { format } from '../../src/exports/format'; -import { emitDocument } from '../../src/format/emit'; -import { parse } from '../../src/parse'; - -describe('format given a Prisma 7 construct in a Prisma 8 document', () => { - it('refuses a view block whose field lines carry attributes', () => { - expect(() => format('view ActiveUsers {\n id Int @unique\n email String\n}\n')).toThrow( - expect.objectContaining({ - code: 'PSL.PARSE_FAILED', - message: 'Cannot format PSL with parse errors: Invalid block entry', - }), - ); - }); - - it('refuses an enum member with an attribute', () => { - expect(() => format('enum Role {\n USER @map("user")\n ADMIN\n}\n')).toThrow( - expect.objectContaining({ - code: 'PSL.PARSE_FAILED', - message: 'Cannot format PSL with parse errors: Invalid block entry', - }), - ); - }); -}); - -describe('emitDocument given a view block parsed with the Prisma 7 grammar', () => { - it('keeps each field on one line with its type, aligned like a model field', () => { - const { document } = parse('view ActiveUsers {\n id Int\n email String\n}\n', 'test.psl', { - grammar: 'prisma7', - }); - expect(emitDocument(document, ' ', '\n')).toEqual( - 'view ActiveUsers {\n id Int\n email String\n}\n', - ); - }); - - it('keeps a field attribute on the same line as its field', () => { - const { document } = parse('view ActiveUsers {\n id Int @unique\n}\n', 'test.psl', { - grammar: 'prisma7', - }); - expect(emitDocument(document, ' ', '\n')).toEqual('view ActiveUsers {\n id Int @unique\n}\n'); - }); -}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/format/view-and-enum-member.test.ts b/packages/1-framework/2-authoring/psl-parser/test/format/view-and-enum-member.test.ts new file mode 100644 index 000000000000..0798d95833fc --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/format/view-and-enum-member.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { format } from '../../src/exports/format'; + +describe('format given a view block', () => { + it('aligns field lines and their attributes like a model body', () => { + expect( + format('view ActiveUsers {\n id Int @unique\n email String @map("user_email")\n}\n'), + ).toBe('view ActiveUsers {\n id Int @unique\n email String @map("user_email")\n}\n'); + }); + + it('separates block attributes from the fields with a blank line', () => { + expect(format('view ActiveUsers {\n id Int\n @@map("active_users")\n}\n')).toBe( + 'view ActiveUsers {\n id Int\n\n @@map("active_users")\n}\n', + ); + }); +}); + +describe('format given enum members', () => { + it('keeps an attribute on its member, leaving its validity to the interpreter', () => { + expect(format('enum Role {\n USER @map("user")\n ADMIN\n}\n')).toBe( + 'enum Role {\n USER @map("user")\n ADMIN\n}\n', + ); + }); + + it('puts members written on one line on separate lines', () => { + expect(format('enum Role { USER ADMIN }\n')).toBe('enum Role {\n USER\n ADMIN\n}\n'); + }); +}); + +describe('format given datasource, generator, enum and model blocks', () => { + it('formats every block in one document', () => { + const schema = [ + 'datasource db {', + ' provider = "postgresql"', + ' url = env("DATABASE_URL")', + '}', + 'generator client {', + ' provider = "prisma-client-js"', + '}', + 'enum Role {', + ' USER @map("user")', + ' ADMIN @map("admin")', + '}', + 'model User {', + ' id Int @id @default(autoincrement())', + ' role Role @default(USER)', + ' @@map("users")', + '}', + '', + ].join('\n'); + expect(format(schema)).toBe( + [ + 'datasource db {', + ' provider = "postgresql"', + ' url = env("DATABASE_URL")', + '}', + '', + 'generator client {', + ' provider = "prisma-client-js"', + '}', + '', + 'enum Role {', + ' USER @map("user")', + ' ADMIN @map("admin")', + '}', + '', + 'model User {', + ' id Int @id @default(autoincrement())', + ' role Role @default(USER)', + '', + ' @@map("users")', + '}', + '', + ].join('\n'), + ); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/interpret.test.ts b/packages/1-framework/2-authoring/psl-parser/test/interpret.test.ts index 1eadd93e33df..9786518899e3 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/interpret.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/interpret.test.ts @@ -42,20 +42,6 @@ describe('hasPslInterpreter', () => { expect(hasPslInterpreter(source)).toBe(false); }); - it('rejects an opaque provider with an unknown format carrying interpret', () => { - const provider = { format: 'made-up-format', load, interpret: () => [] }; - const source: ContractSourceProvider = provider; - - expect(hasPslInterpreter(source)).toBe(false); - }); - - it('rejects a provider without a format', () => { - const provider = { load, interpret: () => [] }; - const source: ContractSourceProvider = provider; - - expect(hasPslInterpreter(source)).toBe(false); - }); - it('rejects a psl provider without an interpret method', () => { const source: ContractSourceProvider = { format: 'psl', load }; diff --git a/packages/1-framework/2-authoring/psl-parser/test/is-psl-identifier.test.ts b/packages/1-framework/2-authoring/psl-parser/test/is-psl-identifier.test.ts new file mode 100644 index 000000000000..627c6bba67e5 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/is-psl-identifier.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { isPslIdentifier } from '../src/tokenizer'; + +describe('isPslIdentifier', () => { + it.each(['name', '_hidden', 'naïve', 'kebab-case', 'v2', 'Infinityx'])('accepts %s', (text) => { + expect(isPslIdentifier(text)).toBe(true); + }); + + it.each([ + ['the empty string', ''], + ['a leading digit', '2nd'], + ['a leading hyphen', '-name'], + ['a space', 'two words'], + ['a digit outside ASCII', 'v٢'], + ['a number keyword', 'NaN'], + ['the infinity keyword', 'Infinity'], + ])('rejects %s', (_, text) => { + expect(isPslIdentifier(text)).toBe(false); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/name-the-psl-source-loses.test.ts b/packages/1-framework/2-authoring/psl-parser/test/name-the-psl-source-loses.test.ts new file mode 100644 index 000000000000..e10dd0038b03 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/name-the-psl-source-loses.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { NAME_THE_PSL_SOURCE_LOSES } from '../src/name-the-psl-source-loses'; +import { parse } from '../src/parse'; +import { buildSymbolTable } from '../src/symbol-table'; + +function blockMemberNames(source: string, blockName: string): readonly string[] { + const { document, sources } = parse(source, 'test.psl'); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + return Object.keys(symbolTable.topLevel.blocks[blockName]?.block.parameters ?? {}); +} + +describe('NAME_THE_PSL_SOURCE_LOSES', () => { + it('names a block member the parser does not keep', () => { + expect( + blockMemberNames(`enum Status {\n ${NAME_THE_PSL_SOURCE_LOSES}\n Active\n}`, 'Status'), + ).toEqual(['Active']); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/parse-enum-member-attributes.test.ts b/packages/1-framework/2-authoring/psl-parser/test/parse-enum-member-attributes.test.ts new file mode 100644 index 000000000000..0cb12374d6c6 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/parse-enum-member-attributes.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/parse'; +import type { FieldAttributeAst } from '../src/syntax/ast/attributes'; +import { GenericBlockDeclarationAst } from '../src/syntax/ast/declarations'; +import { StringLiteralExprAst } from '../src/syntax/ast/expressions'; +import type { GreenElement } from '../src/syntax/green'; +import { printTree } from './support'; + +function greenText(element: GreenElement): string { + if (element.type === 'token') return element.text; + return element.children.map(greenText).join(''); +} + +function onlyGenericBlock(source: string): GenericBlockDeclarationAst { + const result = parse(source, 'test.psl'); + expect(result.diagnostics).toEqual([]); + expect(greenText(result.document.syntax.green)).toBe(source); + const [declaration] = Array.from(result.document.declarations()); + expect(declaration).toBeInstanceOf(GenericBlockDeclarationAst); + if (!(declaration instanceof GenericBlockDeclarationAst)) throw new Error('unreachable'); + return declaration; +} + +function attributeArgs(attribute: FieldAttributeAst) { + return Array.from(attribute.argList()?.args() ?? [], (arg) => ({ + name: arg.name()?.token()?.text, + value: StringLiteralExprAst.cast(arg.value()?.syntax ?? attribute.syntax)?.value(), + })); +} + +describe('enum member attributes', () => { + it('parses positional and named attribute arguments on members with spans', () => { + const source = 'enum Role {\n USER @map("user")\n ADMIN @map(name: "admin") @deprecated\n}'; + const block = onlyGenericBlock(source); + const [user, admin] = Array.from(block.entries()); + + expect(user?.key()?.token()?.text).toBe('USER'); + expect(user?.value()).toBeUndefined(); + const userAttributes = Array.from(user?.attributes() ?? []); + expect(userAttributes).toHaveLength(1); + expect(userAttributes[0]?.name()?.path()).toEqual(['map']); + expect(attributeArgs(userAttributes[0]!)).toEqual([{ name: undefined, value: 'user' }]); + expect(userAttributes[0]?.syntax.offset).toBe(source.indexOf('@map("user")')); + expect(userAttributes[0]?.syntax.textLength).toBe('@map("user")'.length); + + const adminAttributes = Array.from(admin?.attributes() ?? []); + expect(adminAttributes.map((attribute) => attribute.name()?.path())).toEqual([ + ['map'], + ['deprecated'], + ]); + expect(attributeArgs(adminAttributes[0]!)).toEqual([{ name: 'name', value: 'admin' }]); + expect(adminAttributes[1]?.argList()).toBeUndefined(); + }); + + it('parses a member attribute list as FieldAttribute children of the KeyValuePair', () => { + const result = parse('enum Role {\n USER @map("user")\n}', 'test.psl'); + expect(printTree(result.document.syntax.green)).toMatchInlineSnapshot(` + "Document + GenericBlockDeclaration + Ident "enum" + Whitespace " " + Identifier + Ident "Role" + Whitespace " " + LBrace "{" + Newline "\\n" + Whitespace " " + KeyValuePair + Identifier + Ident "USER" + Whitespace " " + FieldAttribute + At "@" + QualifiedName + Identifier + Ident "map" + AttributeArgList + LParen "(" + AttributeArg + StringLiteralExpr + StringLiteral "\\"user\\"" + RParen ")" + Newline "\\n" + RBrace "}"" + `); + }); + + it('parses an attribute after an enum member value', () => { + const block = onlyGenericBlock('enum Role {\n Admin = "admin" @map("ADMIN")\n}'); + const [admin] = Array.from(block.entries()); + expect(Array.from(admin?.attributes() ?? [], (a) => a.name()?.path())).toEqual([['map']]); + }); + + it('parses a bare enum member with no attributes', () => { + const block = onlyGenericBlock('enum Role {\n ADMIN\n USER\n}'); + expect(Array.from(block.entries(), (entry) => Array.from(entry.attributes()).length)).toEqual([ + 0, 0, + ]); + }); + + it('reports an attribute on an entry outside an enum block as an invalid block entry', () => { + const result = parse('datasource db {\n provider = "postgresql" @map("x")\n}', 'test.psl'); + expect(result.diagnostics.map((d) => d.code)).toEqual(['PSL_INVALID_EXTENSION_BLOCK_MEMBER']); + const [block] = Array.from(result.document.declarations()); + expect(block).toBeInstanceOf(GenericBlockDeclarationAst); + if (!(block instanceof GenericBlockDeclarationAst)) throw new Error('unreachable'); + for (const entry of block.entries()) { + expect(Array.from(entry.attributes())).toEqual([]); + } + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/parse-prisma7.test.ts b/packages/1-framework/2-authoring/psl-parser/test/parse-prisma7.test.ts deleted file mode 100644 index 5ebccb2413bb..000000000000 --- a/packages/1-framework/2-authoring/psl-parser/test/parse-prisma7.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * The `prisma7` grammar reads two Prisma 7 constructs so the Prisma 7 - * interpreter can walk them with spans: attributes on enum members, and field - * lines inside `view` blocks. The default grammar reads neither. - */ -import { describe, expect, it } from 'vitest'; -import { parse } from '../src/parse'; -import type { FieldAttributeAst } from '../src/syntax/ast/attributes'; -import { GenericBlockDeclarationAst } from '../src/syntax/ast/declarations'; -import { StringLiteralExprAst } from '../src/syntax/ast/expressions'; -import type { GreenElement } from '../src/syntax/green'; -import { printTree } from './support'; - -const prisma7 = { grammar: 'prisma7' } as const; - -function greenText(element: GreenElement): string { - if (element.type === 'token') return element.text; - return element.children.map(greenText).join(''); -} - -function onlyGenericBlock(source: string): GenericBlockDeclarationAst { - const result = parse(source, 'test.psl', prisma7); - expect(result.diagnostics).toEqual([]); - expect(greenText(result.document.syntax.green)).toBe(source); - const [declaration] = Array.from(result.document.declarations()); - expect(declaration).toBeInstanceOf(GenericBlockDeclarationAst); - if (!(declaration instanceof GenericBlockDeclarationAst)) throw new Error('unreachable'); - return declaration; -} - -function attributeArgs(attribute: FieldAttributeAst) { - return Array.from(attribute.argList()?.args() ?? [], (arg) => ({ - name: arg.name()?.token()?.text, - value: StringLiteralExprAst.cast(arg.value()?.syntax ?? attribute.syntax)?.value(), - })); -} - -describe('enum member attributes', () => { - describe('given the prisma7 grammar', () => { - it('parses positional and named attribute arguments on members with spans', () => { - const source = 'enum Role {\n USER @map("user")\n ADMIN @map(name: "admin") @deprecated\n}'; - const block = onlyGenericBlock(source); - const [user, admin] = Array.from(block.entries()); - - expect(user?.key()?.token()?.text).toBe('USER'); - expect(user?.value()).toBeUndefined(); - const userAttributes = Array.from(user?.attributes() ?? []); - expect(userAttributes).toHaveLength(1); - expect(userAttributes[0]?.name()?.path()).toEqual(['map']); - expect(attributeArgs(userAttributes[0]!)).toEqual([{ name: undefined, value: 'user' }]); - expect(userAttributes[0]?.syntax.offset).toBe(source.indexOf('@map("user")')); - expect(userAttributes[0]?.syntax.textLength).toBe('@map("user")'.length); - - const adminAttributes = Array.from(admin?.attributes() ?? []); - expect(adminAttributes.map((attribute) => attribute.name()?.path())).toEqual([ - ['map'], - ['deprecated'], - ]); - expect(attributeArgs(adminAttributes[0]!)).toEqual([{ name: 'name', value: 'admin' }]); - expect(adminAttributes[1]?.argList()).toBeUndefined(); - }); - - it('parses a member attribute list as FieldAttribute children of the KeyValuePair', () => { - const result = parse('enum Role {\n USER @map("user")\n}', 'test.psl', prisma7); - expect(printTree(result.document.syntax.green)).toMatchInlineSnapshot(` - "Document - GenericBlockDeclaration - Ident "enum" - Whitespace " " - Identifier - Ident "Role" - Whitespace " " - LBrace "{" - Newline "\\n" - Whitespace " " - KeyValuePair - Identifier - Ident "USER" - Whitespace " " - FieldAttribute - At "@" - QualifiedName - Identifier - Ident "map" - AttributeArgList - LParen "(" - AttributeArg - StringLiteralExpr - StringLiteral "\\"user\\"" - RParen ")" - Newline "\\n" - RBrace "}"" - `); - }); - - it('keeps the invalid-member diagnostic for an entry attribute outside an enum block', () => { - const result = parse( - 'datasource db {\n provider = "postgresql" @map("x")\n}', - 'test.psl', - prisma7, - ); - expect(result.diagnostics.map((d) => d.code)).toEqual(['PSL_INVALID_EXTENSION_BLOCK_MEMBER']); - const [block] = Array.from(result.document.declarations()); - expect(block).toBeInstanceOf(GenericBlockDeclarationAst); - if (!(block instanceof GenericBlockDeclarationAst)) throw new Error('unreachable'); - for (const entry of block.entries()) { - expect(Array.from(entry.attributes())).toEqual([]); - } - }); - - it('parses a bare enum block into the same tree as the default grammar', () => { - const source = 'enum Role {\n ADMIN\n USER\n}'; - const block = onlyGenericBlock(source); - for (const entry of block.entries()) { - expect(Array.from(entry.attributes())).toEqual([]); - } - expect(printTree(parse(source, 'test.psl', prisma7).document.syntax.green)).toBe( - printTree(parse(source, 'test.psl').document.syntax.green), - ); - }); - }); - - describe('given the default grammar', () => { - it('reports an attribute after an enum member as an invalid block entry at the attribute', () => { - const result = parse('enum Role {\n USER @map("user")\n}', 'test.psl'); - expect(result.diagnostics).toEqual([ - { - filename: 'test.psl', - code: 'PSL_INVALID_EXTENSION_BLOCK_MEMBER', - message: 'Invalid block entry', - range: { start: { line: 1, character: 7 }, end: { line: 1, character: 8 } }, - }, - ]); - }); - - it('reports an attribute after an enum member value as an invalid block entry', () => { - const result = parse('enum Role {\n Admin = "admin" @map("ADMIN")\n}', 'test.psl'); - expect(result.diagnostics).toEqual([ - { - filename: 'test.psl', - code: 'PSL_INVALID_EXTENSION_BLOCK_MEMBER', - message: 'Invalid block entry', - range: { start: { line: 1, character: 18 }, end: { line: 1, character: 19 } }, - }, - ]); - }); - - it('reports an attribute after an enum member inside a namespace block as an invalid block entry', () => { - const result = parse( - 'namespace auth {\n enum Role {\n USER @map("user")\n }\n}', - 'test.psl', - ); - expect(result.diagnostics).toEqual([ - { - filename: 'test.psl', - code: 'PSL_INVALID_EXTENSION_BLOCK_MEMBER', - message: 'Invalid block entry', - range: { start: { line: 2, character: 9 }, end: { line: 2, character: 10 } }, - }, - ]); - }); - }); -}); - -describe('view blocks', () => { - const source = - 'view ActiveUsers {\n id Int @unique\n email String @db.VarChar(255)\n posts Post[]\n\n @@map("active_users")\n}'; - - describe('given the prisma7 grammar', () => { - it('parses a view with the model body grammar and keeps the view keyword', () => { - const block = onlyGenericBlock(source); - expect(block.keyword()?.text).toBe('view'); - expect(block.name()?.token()?.text).toBe('ActiveUsers'); - const fields = Array.from(block.fields()); - expect(fields.map((field) => field.name()?.token()?.text)).toEqual(['id', 'email', 'posts']); - expect(fields[0]?.typeAnnotation()?.syntax.offset).toBe(source.indexOf('Int')); - expect(Array.from(fields[1]!.attributes()).map((a) => a.name()?.path())).toEqual([ - ['db', 'VarChar'], - ]); - expect(Array.from(block.attributes()).map((a) => a.name()?.path())).toEqual([['map']]); - expect(Array.from(block.entries())).toEqual([]); - }); - - it('parses a view body as FieldDeclaration children', () => { - const result = parse('view ActiveUsers {\n id Int @unique\n}', 'test.psl', prisma7); - expect(printTree(result.document.syntax.green)).toMatchInlineSnapshot(` - "Document - GenericBlockDeclaration - Ident "view" - Whitespace " " - Identifier - Ident "ActiveUsers" - Whitespace " " - LBrace "{" - Newline "\\n" - Whitespace " " - FieldDeclaration - Identifier - Ident "id" - Whitespace " " - TypeAnnotation - QualifiedName - Identifier - Ident "Int" - Whitespace " " - FieldAttribute - At "@" - QualifiedName - Identifier - Ident "unique" - Newline "\\n" - RBrace "}"" - `); - }); - - it('reports a malformed view member with the model-member diagnostic', () => { - const result = parse('view ActiveUsers {\n 123\n id Int\n}', 'test.psl', prisma7); - expect(result.diagnostics.map((d) => d.code)).toEqual(['PSL_INVALID_MODEL_MEMBER']); - }); - }); - - describe('given the default grammar', () => { - it('reports an attribute on a view field line as an invalid block entry', () => { - const result = parse('view ActiveUsers {\n id Int @unique\n}', 'test.psl'); - expect(result.diagnostics).toEqual([ - { - filename: 'test.psl', - code: 'PSL_INVALID_EXTENSION_BLOCK_MEMBER', - message: 'Invalid block entry', - range: { start: { line: 1, character: 9 }, end: { line: 1, character: 10 } }, - }, - ]); - }); - - it('reads the words of a plain view field line as bare entries', () => { - const result = parse('view ActiveUsers {\n id Int\n}', 'test.psl'); - expect(result.diagnostics).toEqual([]); - const [block] = Array.from(result.document.declarations()); - expect(block).toBeInstanceOf(GenericBlockDeclarationAst); - if (!(block instanceof GenericBlockDeclarationAst)) throw new Error('unreachable'); - expect({ - entries: Array.from(block.entries(), (entry) => entry.key()?.token()?.text), - fields: Array.from(block.fields()), - }).toEqual({ entries: ['id', 'Int'], fields: [] }); - }); - }); -}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/parse-view-body.test.ts b/packages/1-framework/2-authoring/psl-parser/test/parse-view-body.test.ts new file mode 100644 index 000000000000..b97c08e08a45 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/parse-view-body.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/parse'; +import { GenericBlockDeclarationAst } from '../src/syntax/ast/declarations'; +import type { GreenElement } from '../src/syntax/green'; +import { printTree } from './support'; + +function greenText(element: GreenElement): string { + if (element.type === 'token') return element.text; + return element.children.map(greenText).join(''); +} + +function onlyGenericBlock(source: string): GenericBlockDeclarationAst { + const result = parse(source, 'test.psl'); + expect(result.diagnostics).toEqual([]); + expect(greenText(result.document.syntax.green)).toBe(source); + const [declaration] = Array.from(result.document.declarations()); + expect(declaration).toBeInstanceOf(GenericBlockDeclarationAst); + if (!(declaration instanceof GenericBlockDeclarationAst)) throw new Error('unreachable'); + return declaration; +} + +describe('a view block body', () => { + const source = + 'view ActiveUsers {\n id Int @unique\n email String @db.VarChar(255)\n posts Post[]\n\n @@map("active_users")\n}'; + + it('parses as fields and block attributes, keeping the view a generic block', () => { + const block = onlyGenericBlock(source); + expect(block.keyword()?.text).toBe('view'); + expect(block.name()?.token()?.text).toBe('ActiveUsers'); + const fields = Array.from(block.fields()); + expect(fields.map((field) => field.name()?.token()?.text)).toEqual(['id', 'email', 'posts']); + expect(fields[0]?.typeAnnotation()?.syntax.offset).toBe(source.indexOf('Int')); + expect(Array.from(fields[1]!.attributes()).map((a) => a.name()?.path())).toEqual([ + ['db', 'VarChar'], + ]); + expect(Array.from(block.attributes()).map((a) => a.name()?.path())).toEqual([['map']]); + expect(Array.from(block.entries())).toEqual([]); + }); + + it('parses each field line as a FieldDeclaration child', () => { + const result = parse('view ActiveUsers {\n id Int @unique\n}', 'test.psl'); + expect(printTree(result.document.syntax.green)).toMatchInlineSnapshot(` + "Document + GenericBlockDeclaration + Ident "view" + Whitespace " " + Identifier + Ident "ActiveUsers" + Whitespace " " + LBrace "{" + Newline "\\n" + Whitespace " " + FieldDeclaration + Identifier + Ident "id" + Whitespace " " + TypeAnnotation + QualifiedName + Identifier + Ident "Int" + Whitespace " " + FieldAttribute + At "@" + QualifiedName + Identifier + Ident "unique" + Newline "\\n" + RBrace "}"" + `); + }); + + it('reports a malformed member with the model-member diagnostic', () => { + const result = parse('view ActiveUsers {\n 123\n id Int\n}', 'test.psl'); + expect(result.diagnostics.map((d) => d.code)).toEqual(['PSL_INVALID_MODEL_MEMBER']); + }); + + it('parses the same way inside a namespace block', () => { + const result = parse( + 'namespace app {\n view ActiveUsers {\n id Int @unique\n }\n}', + 'test.psl', + ); + expect(result.diagnostics).toEqual([]); + expect(printTree(result.document.syntax.green)).toContain('FieldDeclaration'); + }); +}); + +describe('a generic block that is not a view or an enum', () => { + it('reads two bare words on one line as two entries, not as a field', () => { + const block = onlyGenericBlock('native_enum Level { low high }'); + expect({ + entries: Array.from(block.entries(), (entry) => entry.key()?.token()?.text), + fields: Array.from(block.fields()), + }).toEqual({ entries: ['low', 'high'], fields: [] }); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/reader-block-checks.test.ts b/packages/1-framework/2-authoring/psl-parser/test/reader-block-checks.test.ts new file mode 100644 index 000000000000..c0bb007fd911 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/reader-block-checks.test.ts @@ -0,0 +1,88 @@ +import type { AuthoringPslBlockDescriptorNamespace } from '@internal/framework-components/authoring'; +import { describe, expect, it } from 'vitest'; +import { enumMemberAttributeDiagnostics } from '../src/enum-member-attributes'; +import { parse } from '../src/parse'; +import { buildSymbolTable } from '../src/symbol-table'; +import { claimedBlockKeywords, unsupportedBlockDiagnostic } from '../src/unclaimed-blocks'; + +const descriptors: AuthoringPslBlockDescriptorNamespace = { + enum: { + kind: 'pslBlock', + keyword: 'enum', + discriminator: 'enum', + name: { required: true }, + parameters: {}, + variadicParameters: true, + }, + pack: { + policy: { + kind: 'pslBlock', + keyword: 'policy', + discriminator: 'policy', + name: { required: true }, + parameters: {}, + }, + }, +}; + +function blocksOf(source: string) { + const { document, sources } = parse(source, 'schema.prisma'); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: descriptors, + }); + return { blocks: symbolTable.topLevel.blocks, sources }; +} + +describe('claimedBlockKeywords', () => { + it('claims the keywords of top-level descriptors, not of nested descriptor namespaces', () => { + expect([...claimedBlockKeywords(descriptors)]).toEqual(['enum']); + }); + + it('claims nothing when no descriptors are composed', () => { + expect([...claimedBlockKeywords(undefined)]).toEqual([]); + }); +}); + +describe('unsupportedBlockDiagnostic', () => { + it('reports the block at its keyword', () => { + const { blocks, sources } = blocksOf( + 'model User {\n id Int\n}\n\nview Active {\n id Int\n}\n', + ); + expect(unsupportedBlockDiagnostic(blocks['Active']!, sources)).toEqual({ + filename: 'schema.prisma', + code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK', + message: 'Unsupported top-level block "view"', + range: { start: { line: 4, character: 0 }, end: { line: 4, character: 4 } }, + }); + }); +}); + +describe('enumMemberAttributeDiagnostics', () => { + it('reports each attribute on an enum member at the attribute', () => { + const { blocks, sources } = blocksOf( + 'enum Role {\n USER @map("user") @deprecated\n ADMIN\n}\n', + ); + expect(enumMemberAttributeDiagnostics(blocks['Role']!, sources)).toEqual([ + { + filename: 'schema.prisma', + code: 'PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE', + message: 'enum "Role": member "USER" carries @map, but an enum member takes no attributes', + range: { start: { line: 1, character: 7 }, end: { line: 1, character: 19 } }, + }, + { + filename: 'schema.prisma', + code: 'PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE', + message: + 'enum "Role": member "USER" carries @deprecated, but an enum member takes no attributes', + range: { start: { line: 1, character: 20 }, end: { line: 1, character: 31 } }, + }, + ]); + }); + + it('reports nothing for members without attributes', () => { + const { blocks, sources } = blocksOf('enum Role {\n USER\n ADMIN\n}\n'); + expect(enumMemberAttributeDiagnostics(blocks['Role']!, sources)).toEqual([]); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-printer/README.md b/packages/1-framework/2-authoring/psl-printer/README.md index d4714f52d171..ddfd92500173 100644 --- a/packages/1-framework/2-authoring/psl-printer/README.md +++ b/packages/1-framework/2-authoring/psl-printer/README.md @@ -13,6 +13,7 @@ Prints Prisma Schema Language (PSL) from `PslDocumentAst` (`@internal/framework- - Convert structured AST (`model`, `field`, `enum`, `types`) into valid PSL output. - Preserve `@map` / `@@map` and relation attributes from AST nodes. - Generate deterministic output so snapshot-based tests remain stable. +- Open every printed file with the `// use prisma-8` marker. A caller adds one line saying where the file came from with the `description` option; `contract infer` and `contract print` each pass their own. ## Related Docs diff --git a/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts b/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts index 57eab736154d..ab89596bf75a 100644 --- a/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts +++ b/packages/1-framework/2-authoring/psl-printer/src/ast-to-print-document.ts @@ -1,6 +1,7 @@ import type { PslAttribute, PslAttributeArgument, + PslCompositeType, PslDocumentAst, PslExtensionBlock, PslField, @@ -17,15 +18,16 @@ import type { PrintDocument, PrintNamespaceSection } from './print-document'; import { escapePslString } from './serialize-print-document'; import type { PrinterField, PrinterModel, PrinterNamedType } from './types'; -// `contract infer` produces a starting-point PSL contract from a live database -// schema; the user is expected to edit it (rename models/fields, tighten types, -// add `@id` where introspection couldn't infer one, etc.) and then run -// `contract emit` to produce the canonical artifacts. The header invites that -// workflow rather than warning against it. -const DEFAULT_AST_PRINT_HEADER = - '// use prisma-8\n// Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.'; +const PRISMA_8_MARKER = '// use prisma-8'; -export function astDocumentToPrintDocument(ast: PslDocumentAst): PrintDocument { +function headerCommentFor(description: string | undefined): string { + return description === undefined ? PRISMA_8_MARKER : `${PRISMA_8_MARKER}\n// ${description}`; +} + +export function astDocumentToPrintDocument( + ast: PslDocumentAst, + description?: string, +): PrintDocument { // FK dependencies are resolved across the whole document — a model in one // namespace can reference a model in another, and the topo-sort needs to // see every model to produce a stable order. After sorting, we re-bucket by @@ -54,15 +56,20 @@ export function astDocumentToPrintDocument(ast: PslDocumentAst): PrintDocument { // model or block print it once instead of emitting a duplicate declaration // that would not parse back. type Section = { + readonly compositeTypes: Map; readonly models: Map; readonly blocks: Map; }; const sectionsByName = new Map(); for (const namespace of ast.namespaces) { const section: Section = sectionsByName.get(namespace.name) ?? { + compositeTypes: new Map(), models: new Map(), blocks: new Map(), }; + for (const compositeType of namespace.compositeTypes) { + section.compositeTypes.set(compositeType.name, compositeType); + } for (const model of namespace.models) { section.models.set(model.name, model); } @@ -79,6 +86,9 @@ export function astDocumentToPrintDocument(ast: PslDocumentAst): PrintDocument { const unranked = sortedModels.length; const namespaceSections: PrintNamespaceSection[] = [...sectionsByName].map(([name, section]) => ({ name, + compositeTypes: [...section.compositeTypes.values()] + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + .map((compositeType) => modelToPrinterModel(compositeType)), models: [...section.models.values()] .sort((a, b) => (modelOrder.get(a.name) ?? unranked) - (modelOrder.get(b.name) ?? unranked)) .map((m) => modelToPrinterModel(m)), @@ -98,7 +108,7 @@ export function astDocumentToPrintDocument(ast: PslDocumentAst): PrintDocument { }); return { - headerComment: DEFAULT_AST_PRINT_HEADER, + headerComment: headerCommentFor(description), namedTypes, namespaces: namespaceSections, }; @@ -181,7 +191,7 @@ function unescapePslString(value: string): string { return result; } -function modelToPrinterModel(model: PslModel): PrinterModel { +function modelToPrinterModel(model: PslModel | PslCompositeType): PrinterModel { let mapName: string | undefined; const modelAttrStrings: string[] = []; @@ -204,7 +214,7 @@ function modelToPrinterModel(model: PslModel): PrinterModel { mapName, fields: printerFields, modelAttributes: modelAttrStrings, - comment: model.comment, + comment: model.kind === 'model' ? model.comment : undefined, }; } diff --git a/packages/1-framework/2-authoring/psl-printer/src/print-document.ts b/packages/1-framework/2-authoring/psl-printer/src/print-document.ts index bbf2cfccceda..6027b123eb32 100644 --- a/packages/1-framework/2-authoring/psl-printer/src/print-document.ts +++ b/packages/1-framework/2-authoring/psl-printer/src/print-document.ts @@ -19,6 +19,8 @@ import type { PrinterModel, PrinterNamedType } from './types'; */ export type PrintNamespaceSection = { readonly name: string; + /** Value-object `type` blocks, printed before the models in the same namespace. */ + readonly compositeTypes: readonly PrinterModel[]; readonly models: readonly PrinterModel[]; readonly extensionBlocks: readonly PslExtensionBlock[]; }; diff --git a/packages/1-framework/2-authoring/psl-printer/src/print-psl.ts b/packages/1-framework/2-authoring/psl-printer/src/print-psl.ts index e31c4ab9d3b1..04afef19c1b9 100644 --- a/packages/1-framework/2-authoring/psl-printer/src/print-psl.ts +++ b/packages/1-framework/2-authoring/psl-printer/src/print-psl.ts @@ -30,10 +30,15 @@ export interface PrintPslOptions { * emitted as-is. */ readonly codecLookup?: CodecLookup; + /** + * One line saying where the file came from. The printed file opens with the + * `// use prisma-8` marker, then this line as a comment. + */ + readonly description?: string; } export function printPslFromAst(ast: PslDocumentAst, options: PrintPslOptions = {}): string { - const doc = astDocumentToPrintDocument(ast); + const doc = astDocumentToPrintDocument(ast, options.description); return serializePrintDocument(doc, { ...ifDefined('pslBlockDescriptors', options.pslBlockDescriptors), ...ifDefined('codecLookup', options.codecLookup), diff --git a/packages/1-framework/2-authoring/psl-printer/src/serialize-print-document.ts b/packages/1-framework/2-authoring/psl-printer/src/serialize-print-document.ts index 3d9dc49230ed..2bd34659efd5 100644 --- a/packages/1-framework/2-authoring/psl-printer/src/serialize-print-document.ts +++ b/packages/1-framework/2-authoring/psl-printer/src/serialize-print-document.ts @@ -110,8 +110,11 @@ function serializeNamespaceContents( codecLookup: CodecLookup | undefined, ): string[] { const sections: string[] = []; + for (const compositeType of namespace.compositeTypes) { + sections.push(serializeModel(compositeType, 'type')); + } for (const model of namespace.models) { - sections.push(serializeModel(model)); + sections.push(serializeModel(model, 'model')); } for (const extensionBlock of namespace.extensionBlocks) { sections.push(serializeExtensionBlock(extensionBlock, blockDispatchMap, codecLookup)); @@ -308,13 +311,13 @@ function serializeTypesBlock(namedTypes: readonly PrinterNamedType[]): string { return lines.join('\n'); } -function serializeModel(model: import('./types').PrinterModel): string { +function serializeModel(model: import('./types').PrinterModel, keyword: 'model' | 'type'): string { const lines: string[] = []; if (model.comment) { lines.push(model.comment); } - lines.push(`model ${model.name} {`); + lines.push(`${keyword} ${model.name} {`); const idFields = model.fields.filter((f) => f.isId); const scalarFields = model.fields.filter((f) => !f.isId && !f.isRelation); diff --git a/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts b/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts index 95653932068f..be60d9f40f1a 100644 --- a/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts +++ b/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts @@ -1,4 +1,5 @@ import type { + PslCompositeType, PslDocumentAst, PslModel, PslNamedTypeDeclaration, @@ -70,6 +71,61 @@ describe('printPslFromAst', () => { expect(printPslFromAst(ast)).toContain('@@map("foo")'); }); + it('prints a value-object type block inside its namespace, before the models', () => { + const address: PslCompositeType = { + kind: 'compositeType', + name: 'Address', + fields: [ + { + kind: 'field', + name: 'street', + typeName: 'String', + optional: false, + list: false, + attributes: [], + span: span(0), + }, + { + kind: 'field', + name: 'tags', + typeName: 'String', + optional: true, + list: true, + attributes: [], + span: span(0), + }, + ], + attributes: [], + span: span(0), + }; + const shop: PslModel = { + kind: 'model', + name: 'Shop', + fields: [ + { + kind: 'field', + name: 'home', + typeName: 'Address', + optional: true, + list: false, + attributes: [], + span: span(0), + }, + ], + attributes: [], + span: span(0), + }; + const ast: PslDocumentAst = { + kind: 'document', + sourceId: 't', + namespaces: [makeNs('public', [shop], [address], 0)], + span: span(0), + }; + expect(printPslFromAst(ast)).toContain( + 'namespace public {\n type Address {\n street String\n tags String[]?\n }\n\n model Shop {\n home Address?\n }\n}', + ); + }); + it('prints types block', () => { const named: PslNamedTypeDeclaration = { kind: 'namedType', @@ -306,4 +362,57 @@ describe('printPslFromAst', () => { expect(out).toMatch(/labels\s+String\[\]\?/); expect(out).toMatch(/tags\s+String\[\]\s*$/m); }); + + describe('the header comment', () => { + const headerAst: PslDocumentAst = { + kind: 'document', + sourceId: 'header.prisma', + namespaces: [ + makeNs( + UNSPECIFIED_PSL_NAMESPACE_ID, + [ + { + kind: 'model', + name: 'X', + fields: [ + { + kind: 'field', + name: 'id', + typeName: 'Int', + optional: false, + list: false, + attributes: [attr('field', 'id', [], 1)], + span: span(0), + }, + ], + attributes: [], + span: span(0), + }, + ], + [], + 0, + ), + ], + span: span(0), + }; + + function headerOf(printed: string): string { + return printed.split('\n\n')[0] ?? ''; + } + + it('opens with only the prisma-8 marker when the caller names no description', () => { + expect(headerOf(printPslFromAst(headerAst))).toBe('// use prisma-8'); + }); + + it('opens with the prisma-8 marker, then the description the caller names', () => { + const printed = printPslFromAst(headerAst, { + description: 'Printed from prisma/schema.prisma by `prisma contract print`.', + }); + + expect(headerOf(printed)).toBe( + '// use prisma-8\n// Printed from prisma/schema.prisma by `prisma contract print`.', + ); + expect(printed).toContain('model X {'); + }); + }); }); diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 03be849812b7..ff6204a5c872 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -420,6 +420,81 @@ prisma db schema --json prisma db schema -v ``` +### `prisma contract print` + +Load the contract from the source the config names and print the Prisma 8 PSL that reads back as the same contract, or write it to a file with `--output`. The source can be a Prisma 7 schema (`prisma7Schema(...)`), a TypeScript contract, or a PSL contract. The common use is cutover: a project on `prisma7Schema(...)` is ready to stop reading the Prisma 7 file and author in Prisma 8 PSL instead. + +**Command:** +```bash +prisma contract print [--config ] [--output ] [--json] [-v] [-q] [--color/--no-color] +``` + +Options: +- `--config `: Optional. Path to `prisma.config.ts` (defaults to `./prisma.config.ts` if present) +- `--output `: Write the PSL to this file instead of printing it +- `--json`: Output a JSON result envelope (includes the PSL as `psl.text`, or `psl.path` with `--output`, the `source` files it read, and `sourceSettings`) +- `-q, --quiet`: Quiet mode (errors only) +- `-v, --verbose`: Verbose output (debug info, timings) +- `-vv, --trace`: Trace output (deep internals, stack traces) +- `--color/--no-color`: Force/disable color output + +The command needs no database connection: it reads the source files, not the server. Without `--output`, it prints the PSL and writes no file. In a terminal the PSL is shown on screen. A pipe receives the JSON result, as with every command, unless you pass `--format human`, which sends the PSL alone to standard output: + +```bash +prisma contract print --format human > printed.prisma +``` + +With `--output`, an existing file at that path is overwritten with a warning. + +The printed PSL opens with two comment lines: the `// use prisma-8` marker, and a line naming the source files it was printed from: + +```prisma +// use prisma-8 +// Printed from prisma/schema.prisma by `prisma contract print`. +``` + +The printed PSL reads back as the identical contract. Where PSL has no form for part of the contract, the command refuses, names that part, and prints and writes nothing. It exits `2` in these cases: +- `CONTRACT.PRINT_UNSUPPORTED`: part of the contract cannot be written as PSL that reads back the same. The full list of cases is under that code in `docs/reference/error-reference.md`. A column type an extension contributes, such as pgvector's `Vector`, prints only when that extension is in the config. +- `CONTRACT.SOURCE_LOAD_FAILED`: the source cannot be read, reported exactly as `contract emit` reports it. +- The loaded contract fails the structure check `contract emit` applies, as a hand-written TypeScript contract can. The command runs the same check before it prints, so it reports the same error as `contract emit`. +- `CONTRACT.PRINT_OUTPUT_IS_SOURCE`: the `--output` path is a source file the config reads, sits inside a directory of source files, or names a new file that a glob input of the source would match once written. Pick another path. +- `CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE`: the `--output` path is the `prisma.config.ts` in the directory of the config that defines the `orm` section, or one of the files `contract emit` writes (`contract.json` and `contract.d.ts`, or whatever `contract.output` names). Pick another path. + +These checks compare the files the paths name, not the text of the paths: a path through a symbolic link, or one that differs only in case on a volume that ignores case (the macOS default), counts as the same file. + +A PSL file cannot carry the contract's default control policy. When the contract has one, the command prints a warning, names it in the JSON result (`sourceSettings.defaultControlPolicy`) and in the next step, and the config must set it on the new PSL source. Without it, the emitted contract has no default control policy, and everything that sets no control policy of its own is treated as managed. The facade `defineConfig` has no option for it, so build the PSL source with `prismaContract`, which comes from `@prisma/orm-family-sql` (add that package to the project's dependencies). For Postgres, with the PSL written to `prisma/contract.prisma`: + +```typescript +// prisma.config.ts +import { definePrismaConfig } from 'prisma/config'; +import { prismaContract } from '@prisma/orm-family-sql/contract-psl/provider'; +import { defineConfig as ormConfig } from '@prisma/orm-postgres/config'; +import { PG_INT_CODEC_ID, PG_TEXT_CODEC_ID } from '@prisma/orm-postgres/target/codec-ids'; +import postgresPack from '@prisma/orm-postgres/target/pack'; +import { postgresCreateNamespace } from '@prisma/orm-postgres/target/types'; + +export default definePrismaConfig({ + orm: ormConfig({ + contract: prismaContract('./prisma/contract.prisma', { + target: postgresPack, + createNamespace: postgresCreateNamespace, + enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, + defaultControlPolicy: 'external', + }), + db: { connection: process.env['DATABASE_URL']! }, + }), +}); +``` + +To switch to the written file, point `contract` in `prisma.config.ts` at it and run `prisma contract emit`. Without an explicit `output`, the facade names the emitted files after the contract path it is given, so switching `contract: './prisma/schema.prisma'` to `contract: './prisma/contract.prisma'` moves `schema.json` and `schema.d.ts` to `contract.json` and `contract.d.ts` and leaves the old files on disk; when the printed file would move them, the next step names both pairs of files. The next step writes every path relative to the directory of `prisma.config.ts`, because the config resolves its paths against that directory, not against the directory the command ran in. For a project leaving a Prisma 7 schema, the switch is the first step of the cutover; the rest takes migration ownership of the database Prisma 7 built: + +```bash +prisma contract emit +prisma migration plan --name baseline +prisma db sign +prisma migration ref set db _baseline +``` + ### `prisma contract infer` Inspect the live database schema and write an inferred PSL contract to disk. Use this for brownfield adoption when you want a starting `contract.prisma` before running `contract emit` and `db sign`. @@ -1213,7 +1288,7 @@ How it composes: - Long-lived hosts (Vite dev server, watch CLIs) must call `disposeEmitQueue` on shutdown to drop the per-output queue state, otherwise the module-global queue map leaks one entry per unique output path. -- `loadContractSource(config, { signal, onWarning })` runs only the resolve-source step: it builds the control stack, runs `contract.source.load`, and returns the contract or the source's `{ summary, diagnostics }` without writing anything. `prisma orm init` uses it to check a Prisma 7 schema before it changes the project; `executeContractEmit` calls it and turns a refusal into the same error as before. +- `loadContractSource(config, { signal, onWarning })` runs only the resolve-source step: it builds the control stack, runs `contract.source.load`, and returns the contract or the source's `{ summary, diagnostics }` without writing anything. `onWarning` receives each warning the source reports. `prisma orm init` uses it to check a Prisma 7 schema before it changes the project. `executeContractEmit`, `contract print` and `ControlClient.emit` load the source through the same step, so each reports a bad source with the same error. The `validateContractDeps` warning is returned in `ContractEmitResult.validationWarning` rather than written to stderr by the operation — callers (CLI, Vite plugin) decide diff --git a/packages/1-framework/3-tooling/cli/src/commands/init/errors.ts b/packages/1-framework/3-tooling/cli/src/commands/init/errors.ts index 52b517e6919c..09e146e5c74c 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/init/errors.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/init/errors.ts @@ -1,5 +1,5 @@ import { docsUrlFor } from '@internal/utils/structured-error'; -import { formatSourceDiagnostic } from '../../control-api/operations/contract-emit'; +import { formatSourceDiagnostic } from '../../control-api/operations/load-contract-source'; import { CliStructuredError } from '../../utils/cli-errors'; /** diff --git a/packages/1-framework/3-tooling/cli/src/control-api/client.ts b/packages/1-framework/3-tooling/cli/src/control-api/client.ts index c5365708c9bb..d411d99d08c4 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/client.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/client.ts @@ -1,5 +1,4 @@ import type { ContractSourceDiagnostic } from '@internal/config/config-types'; -import { expandContractInputs } from '@internal/config-loader'; import type { Contract, ContractMarkerRecord, LedgerEntryRecord } from '@internal/contract/types'; import { emit as emitContractArtifacts } from '@internal/emitter'; import { CliStructuredError } from '@internal/errors/control'; @@ -42,6 +41,7 @@ import { enrichContract } from './contract-enrichment'; import { executeDbInit } from './operations/db-init'; import { executeDbUpdate } from './operations/db-update'; import { type ExecuteDbVerifyResult, executeDbVerify } from './operations/db-verify'; +import { loadContractSourceWithStack } from './operations/load-contract-source'; import { executeMigrate } from './operations/migrate'; import type { RenderContractDtsOptions, RenderContractDtsResult } from './render-contract-dts'; @@ -632,7 +632,6 @@ class ControlClientImpl implements ControlClient { throw new InternalError('Family instance was not initialized. This is a bug.'); } - let contractRaw: unknown; const sourceWarnings: ContractSourceDiagnostic[] = []; onProgress?.({ action: 'emit', @@ -641,60 +640,27 @@ class ControlClientImpl implements ControlClient { label: 'Resolving contract source...', }); - try { - const stack = this.stack!; - const sourceContext = { - composedExtensions: stack.extensions.map((p) => p.id), - composedExtensionContracts: stack.extensionContracts, - authoringContributions: stack.authoringContributions, - codecLookup: stack.codecLookup, - controlMutationDefaults: stack.controlMutationDefaults, - dataTypeLookup: stack.dataTypeLookup, - resolvedInputs: await expandContractInputs(contractConfig.source.inputs), - capabilities: stack.capabilities, - reportWarning: (diagnostic: ContractSourceDiagnostic) => { - sourceWarnings.push(diagnostic); - }, - }; - const providerResult = await contractConfig.source.load(sourceContext); - if (!providerResult.ok) { - onProgress?.({ - action: 'emit', - kind: 'spanEnd', - spanId: 'resolveSource', - outcome: 'error', - }); - - return notOk({ - code: 'CONTRACT_SOURCE_INVALID', - summary: providerResult.failure.summary, - why: providerResult.failure.summary, - meta: providerResult.failure.meta, - diagnostics: providerResult.failure, - }); - } - contractRaw = providerResult.value; - - onProgress?.({ - action: 'emit', - kind: 'spanEnd', - spanId: 'resolveSource', - outcome: 'ok', - }); - } catch (error) { - onProgress?.({ - action: 'emit', - kind: 'spanEnd', - spanId: 'resolveSource', - outcome: 'error', - }); - - const message = error instanceof Error ? error.message : String(error); + const loaded = await loadContractSourceWithStack({ + stack: this.stack!, + source: contractConfig.source, + reportWarning: (diagnostic) => { + sourceWarnings.push(diagnostic); + }, + }); + onProgress?.({ + action: 'emit', + kind: 'spanEnd', + spanId: 'resolveSource', + outcome: loaded.ok ? 'ok' : 'error', + }); + if (!loaded.ok) { + const { error, sourceDiagnostics } = loaded.failure; return notOk({ code: 'CONTRACT_SOURCE_INVALID', - summary: 'Failed to resolve contract source', - why: message, - meta: undefined, + summary: sourceDiagnostics?.summary ?? error.message, + why: error.why, + meta: sourceDiagnostics?.meta, + ...ifDefined('diagnostics', sourceDiagnostics), }); } @@ -707,20 +673,7 @@ class ControlClientImpl implements ControlClient { }); try { - // Blind cast: `contractRaw` is the unverified provider - // payload — `enrichContract` only adds capability + extension - // metadata onto whatever shape it receives. The structural - // check happens immediately afterwards via - // `familyInstance.deserializeContract`, which is the - // seam-of-record and the only thing that may surface - // structural errors to the caller. - const enrichedIR = enrichContract( - blindCast< - Contract, - 'Provider payload is enriched before target serialization and family validation' - >(contractRaw), - this.frameworkComponents ?? [], - ); + const enrichedIR = enrichContract(loaded.value, this.frameworkComponents ?? []); const rawContractJson = this.options.target.contractSerializer.serializeContract(enrichedIR); let deserializedContract: Contract; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts index 98e6066e7323..77c8ae6e5c0e 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts @@ -1,30 +1,29 @@ import { mkdir } from 'node:fs/promises'; -import type { ContractSourceDiagnostic, PrismaNextConfig } from '@internal/config/config-types'; -import { expandContractInputs } from '@internal/config-loader'; +import type { ContractSourceDiagnostic } from '@internal/config/config-types'; import type { Contract } from '@internal/contract/types'; import { emit, getEmittedArtifactPaths } from '@internal/emitter'; import { type ControlStack, createControlStack } from '@internal/framework-components/control'; import { abortable } from '@internal/utils/abortable'; -import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import type { JsonObject } from '@internal/utils/json'; -import { notOk, ok, type Result } from '@internal/utils/result'; -import type { Diagnostic } from '@internal/utils/structured-error'; -import { isStructuredErrorCode } from '@internal/utils/structured-error'; import { dirname, join } from 'pathe'; -import { errorContractConfigMissing, errorRuntime } from '../../utils/cli-errors'; +import { errorContractConfigMissing } from '../../utils/cli-errors'; import { queueEmitByOutput } from '../../utils/emit-queue'; -import { assertFrameworkComponentsCompatible } from '../../utils/framework-components'; import { createProjectSpecifierResolver } from '../../utils/project-import-root'; import { publishContractArtifactPair } from '../../utils/publish-contract-artifact-pair'; import { validateContractDeps } from '../../utils/validate-contract-deps'; -import { enrichContract } from '../contract-enrichment'; import type { ContractEmitOptions, ContractEmitResult, ControlActionName, OnControlProgress, } from '../types'; +import { + loadContractSourceWithStack, + requireContractConfig, + requireSourceProvider, +} from './load-contract-source'; +import { validateLoadedContract } from './validate-loaded-contract'; const EMIT_ACTION: ControlActionName = 'emit'; @@ -34,10 +33,6 @@ type ContractEmitDependencies = { const defaultContractEmitDependencies: ContractEmitDependencies = { emit }; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - function startSpan(onProgress: OnControlProgress | undefined, spanId: string, label: string): void { onProgress?.({ action: EMIT_ACTION, kind: 'spanStart', spanId, label }); } @@ -50,289 +45,6 @@ function endSpan( onProgress?.({ action: EMIT_ACTION, kind: 'spanEnd', spanId, outcome }); } -function failedToResolveContractSource( - why: string, - fix: string, - meta?: Record, - cause?: unknown, - diagnostics?: readonly Diagnostic[], -) { - return errorRuntime('CONTRACT.SOURCE_LOAD_FAILED', 'Failed to resolve contract source', { - why, - fix, - ...ifDefined('diagnostics', diagnostics), - ...ifDefined('meta', meta), - ...ifDefined('cause', cause), - }); -} - -interface DiagnosticLocation { - readonly sourceId: string | undefined; - readonly line: number | undefined; - readonly character: number | undefined; -} - -function diagnosticLocation(diagnostic: Record): DiagnosticLocation { - const sourceId = typeof diagnostic['sourceId'] === 'string' ? diagnostic['sourceId'] : undefined; - const span = isRecord(diagnostic['span']) ? diagnostic['span'] : undefined; - const start = span && isRecord(span['start']) ? span['start'] : undefined; - const line = start && typeof start['line'] === 'number' ? start['line'] : undefined; - // biome-ignore lint/plugin/no-family-vocabulary: a text position in the source file; the span calls it column - const character = start && typeof start['column'] === 'number' ? start['column'] : undefined; - return { sourceId, line, character }; -} - -function formatLocation({ sourceId, line, character }: DiagnosticLocation): string | undefined { - if (sourceId === undefined) return undefined; - return line !== undefined && character !== undefined - ? `${sourceId}:${line}:${character}` - : sourceId; -} - -/** One source diagnostic as a line of text: `:: `. */ -export function formatSourceDiagnostic(raw: unknown): string { - if (!isRecord(raw)) return String(raw); - const code = typeof raw['code'] === 'string' ? raw['code'] : 'diagnostic'; - const message = typeof raw['message'] === 'string' ? raw['message'] : ''; - const location = formatLocation(diagnosticLocation(raw)); - return [location, code, message].filter((part) => part !== undefined && part !== '').join(' '); -} - -/** - * The finding the CLI prints under the error, one per source diagnostic. The - * terminal renderer prints a finding's code and summary and nothing of its - * `where`, so the summary starts with the location. A source code that is not - * yet dotted is wrapped as `CONTRACT.SOURCE_DIAGNOSTIC` and named in the summary. - */ -function sourceDiagnosticToFinding(raw: unknown): Diagnostic | undefined { - if (!isRecord(raw)) return undefined; - const code = typeof raw['code'] === 'string' ? raw['code'] : 'diagnostic'; - const message = typeof raw['message'] === 'string' ? raw['message'] : ''; - const location = diagnosticLocation(raw); - const formatted = formatLocation(location); - const locatedSummary = (text: string) => - formatted === undefined ? text : `${formatted} ${text}`; - const finding = { - severity: 'error', - nextActions: [], - ...ifDefined( - 'where', - location.sourceId === undefined - ? undefined - : { path: location.sourceId, ...ifDefined('line', location.line) }, - ), - } as const; - return isStructuredErrorCode(code) - ? { code, summary: locatedSummary(message), ...finding } - : { - code: 'CONTRACT.SOURCE_DIAGNOSTIC', - summary: locatedSummary(`${code}: ${message}`), - ...finding, - meta: { code }, - }; -} - -function sourceDiagnosticsToFindings(diagnostics: readonly unknown[]): Diagnostic[] { - const findings: Diagnostic[] = []; - for (const raw of diagnostics) { - const finding = sourceDiagnosticToFinding(raw); - if (finding !== undefined) findings.push(finding); - } - return findings; -} - -/** What a contract source reported when it could not produce a contract. */ -export interface ContractSourceFailure { - readonly summary: string; - readonly diagnostics: readonly unknown[]; - readonly meta: unknown; -} - -type ValidatedProviderResult = - | { readonly kind: 'ok'; readonly value: unknown } - | { readonly kind: 'failed'; readonly failure: ContractSourceFailure } - | { readonly kind: 'malformed'; readonly error: ReturnType }; - -function diagnosticLocationSuffix(diagnostic: Record): string { - const formatted = formatLocation(diagnosticLocation(diagnostic)); - return formatted === undefined ? '' : ` (${formatted})`; -} - -function mapDiagnosticsToIssues( - diagnostics: readonly unknown[], -): ReadonlyArray<{ readonly kind: string; readonly message: string }> { - const issues: { readonly kind: string; readonly message: string }[] = []; - for (const raw of diagnostics) { - if (!isRecord(raw)) continue; - const code = typeof raw['code'] === 'string' ? raw['code'] : 'diagnostic'; - const message = typeof raw['message'] === 'string' ? raw['message'] : ''; - issues.push({ kind: code, message: `${message}${diagnosticLocationSuffix(raw)}` }); - } - return issues; -} - -function validateProviderResult(providerResult: unknown): ValidatedProviderResult { - if (!isRecord(providerResult) || typeof providerResult['ok'] !== 'boolean') { - return { - kind: 'malformed', - error: failedToResolveContractSource( - 'Contract source provider returned malformed result shape.', - 'Ensure contract.source.load resolves to ok(Contract) or notOk({ summary, diagnostics }).', - ), - }; - } - - if (providerResult['ok']) { - if (!('value' in providerResult)) { - return { - kind: 'malformed', - error: failedToResolveContractSource( - 'Contract source provider returned malformed success result: missing value.', - 'Ensure contract.source.load success payload is ok(Contract).', - ), - }; - } - return { kind: 'ok', value: providerResult['value'] }; - } - - const failure = providerResult['failure']; - if ( - !isRecord(failure) || - typeof failure['summary'] !== 'string' || - !Array.isArray(failure['diagnostics']) - ) { - return { - kind: 'malformed', - error: failedToResolveContractSource( - 'Contract source provider returned malformed failure result: expected summary and diagnostics.', - 'Ensure contract.source.load failure payload is notOk({ summary, diagnostics, meta? }).', - ), - }; - } - if ( - failure['diagnostics'].some( - (diagnostic: unknown) => !isRecord(diagnostic) || typeof diagnostic['sourceId'] !== 'string', - ) - ) { - return { - kind: 'malformed', - error: failedToResolveContractSource( - 'Contract source provider returned malformed failure result: each diagnostic must include a string sourceId.', - 'Include the source filename in each diagnostic returned by contract.source.load.', - ), - }; - } - return { - kind: 'failed', - failure: { - summary: failure['summary'], - diagnostics: failure['diagnostics'], - meta: failure['meta'], - }, - }; -} - -function sourceFailureError(failure: ContractSourceFailure) { - return failedToResolveContractSource( - failure.summary, - 'Edit the schema where each finding points, then run contract emit again.', - { - diagnostics: failure.diagnostics, - issues: mapDiagnosticsToIssues(failure.diagnostics), - ...ifDefined('providerMeta', failure.meta), - }, - undefined, - sourceDiagnosticsToFindings(failure.diagnostics), - ); -} - -type ContractSourceConfig = NonNullable; - -function requireContractConfig(config: PrismaNextConfig): ContractSourceConfig { - if (!config.contract) { - throw errorContractConfigMissing({ - why: 'Config.contract is required for emit. Define it in your config: contract: { source: ..., output: ... }', - }); - } - return config.contract; -} - -function requireSourceProvider(contractConfig: ContractSourceConfig): void { - if (typeof contractConfig.source?.load !== 'function') { - throw errorContractConfigMissing({ - why: 'Contract config must include a valid source provider object', - }); - } -} - -async function resolveContractSource( - contractConfig: ContractSourceConfig, - stack: ControlStack, - signal: AbortSignal, - reportWarning: ((diagnostic: ContractSourceDiagnostic) => void) | undefined, -): Promise> { - const sourceContext = { - ...ifDefined('reportWarning', reportWarning), - composedExtensions: stack.extensions.map((p) => p.id), - composedExtensionContracts: stack.extensionContracts, - authoringContributions: stack.authoringContributions, - codecLookup: stack.codecLookup, - controlMutationDefaults: stack.controlMutationDefaults, - dataTypeLookup: stack.dataTypeLookup, - resolvedInputs: await abortable(signal)(expandContractInputs(contractConfig.source.inputs)), - capabilities: stack.capabilities, - }; - - let providerResult: Awaited>; - try { - providerResult = await abortable(signal)(contractConfig.source.load(sourceContext)); - } catch (error) { - if (signal.aborted || (isRecord(error) && error['name'] === 'AbortError')) { - throw error; - } - throw failedToResolveContractSource( - error instanceof Error ? error.message : String(error), - 'Ensure contract.source.load resolves to ok(Contract) or returns structured diagnostics.', - undefined, - error, - ); - } - - const validated = validateProviderResult(providerResult); - switch (validated.kind) { - case 'malformed': - throw validated.error; - case 'failed': - return notOk(validated.failure); - case 'ok': - return ok(validated.value); - } -} - -/** - * Runs the config's contract source the way `executeContractEmit` does and - * stops there: nothing is emitted or written. A source that reports - * diagnostics is a `notOk`; a malformed or throwing source raises the same - * error emit raises. - */ -export async function loadContractSource( - config: PrismaNextConfig, - options: { - readonly signal?: AbortSignal; - /** Receives each warning the source reports. */ - readonly onWarning?: (diagnostic: ContractSourceDiagnostic) => void; - } = {}, -): Promise> { - const contractConfig = requireContractConfig(config); - requireSourceProvider(contractConfig); - return resolveContractSource( - contractConfig, - createControlStack(config), - options.signal ?? new AbortController().signal, - options.onWarning, - ); -} - /** * Canonical contract emit operation. * @@ -389,51 +101,33 @@ export async function executeContractEmit( const { jsonPath: outputJsonPath, dtsPath: outputDtsPath } = outputPaths; return queueEmitByOutput(outputJsonPath, async () => { - const stack = createControlStack(config); - startSpan(onProgress, 'resolveSource', 'Resolving contract source...'); const sourceWarnings: ContractSourceDiagnostic[] = []; - let resolved: Result; + let stack: ControlStack; + let contract: Contract; try { - resolved = await resolveContractSource(contractConfig, stack, signal, (diagnostic) => { - sourceWarnings.push(diagnostic); + stack = createControlStack(config); + const loaded = await loadContractSourceWithStack({ + stack, + source: contractConfig.source, + signal, + reportWarning: (diagnostic) => { + sourceWarnings.push(diagnostic); + }, }); + if (!loaded.ok) throw loaded.failure.error; + contract = loaded.value; } catch (error) { endSpan(onProgress, 'resolveSource', 'error'); throw error; } - if (!resolved.ok) { - endSpan(onProgress, 'resolveSource', 'error'); - throw sourceFailureError(resolved.failure); - } endSpan(onProgress, 'resolveSource', 'ok'); startSpan(onProgress, 'emit', 'Emitting contract...'); let emitResult: Awaited>; try { const familyInstance = config.family.create(stack); - const rawComponents = [config.target, config.adapter, ...(config.extensions ?? [])]; - const frameworkComponents = assertFrameworkComponentsCompatible( - config.family.familyId, - config.target.targetId, - rawComponents, - ); - // Blind cast: `validateProviderResult` upstream has already - // pinned `resolved.value` to the provider's loose - // `Contract` envelope, but the local `Contract` type at this - // call site is the precise structural interface. The cast just - // defers the structural check by one statement so `enrichContract` - // can decorate first; the subsequent serialize→deserialize round-trip - // re-narrows the envelope into the precise type. - const enrichedIR = enrichContract( - blindCast< - Contract, - 'Provider payload is enriched before target serialization and family validation' - >(resolved.value), - frameworkComponents, - ); - const rawContractJson = config.target.contractSerializer.serializeContract(enrichedIR); - const deserializedContract = familyInstance.deserializeContract(rawContractJson); + const deserializedContract = validateLoadedContract({ config, familyInstance, contract }); // Each target's descriptor ships a `contractSerializer` SPI; the // framework canonicalizer threads its `serializeContract` so the // on-disk JSON envelope is constructed by target-owned code diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-print.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-print.ts new file mode 100644 index 000000000000..dc2e5d024807 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-print.ts @@ -0,0 +1,85 @@ +import type { ContractSourceDiagnostic, PrismaNextConfig } from '@internal/config/config-types'; +import type { PslSourceSettings } from '@internal/framework-components/control'; +import { createControlStack, hasPslContractBuild } from '@internal/framework-components/control'; +import { printPsl } from '@internal/psl-printer'; +import { ifDefined } from '@internal/utils/defined'; +import { errorRuntime } from '../../utils/cli-errors'; +import { loadContractSourceWithStack } from './load-contract-source'; +import { validateLoadedContract } from './validate-loaded-contract'; + +type ContractConfig = NonNullable; + +export interface ContractPrintOptions { + readonly config: PrismaNextConfig; + readonly contractConfig: ContractConfig; + /** The line under the `// use prisma-8` marker saying where the file came from. */ + readonly description: string; + readonly signal?: AbortSignal; +} + +export interface ContractPrintResult { + readonly psl: string; + readonly sourceSettings: PslSourceSettings; + readonly sourceWarnings: readonly ContractSourceDiagnostic[]; +} + +export interface ContractPrintDependencies { + readonly printPsl: typeof printPsl; +} + +const defaultContractPrintDependencies: ContractPrintDependencies = { printPsl }; + +/** + * Loads the configured contract, validates it as `contract emit` does, and prints it as Prisma 8 + * PSL. One control stack serves the whole operation: the source is loaded against it, the family + * instance is created from it, and its block descriptors and codec lookup render the text. + * + * @throws {CliStructuredError} `CONTRACT.SOURCE_LOAD_FAILED` when the source cannot produce a + * contract, and `CONTRACT.PRINT_UNSUPPORTED` when the family or target cannot print it + * @throws the family instance's error for a contract whose structure it rejects, as `contract emit` + * reports it + * @throws {DOMException} `AbortError` if cancelled via `signal` + */ +export async function executeContractPrint( + options: ContractPrintOptions, + dependencies: ContractPrintDependencies = defaultContractPrintDependencies, +): Promise { + const { config, contractConfig, description, signal } = options; + const stack = createControlStack(config); + const sourceWarnings: ContractSourceDiagnostic[] = []; + const loaded = await loadContractSourceWithStack({ + stack, + source: contractConfig.source, + ...ifDefined('signal', signal), + reportWarning: (diagnostic) => { + sourceWarnings.push(diagnostic); + }, + }); + if (!loaded.ok) { + throw loaded.failure.error; + } + + const familyInstance = config.family.create(stack); + const contract = validateLoadedContract({ config, familyInstance, contract: loaded.value }); + if (!hasPslContractBuild(familyInstance)) { + throw errorRuntime( + 'CONTRACT.PRINT_UNSUPPORTED', + 'contract print is not supported for this family', + { + why: 'The configured family cannot print a contract as PSL, so nothing was written.', + fix: 'Use a family and target that can print a contract as PSL.', + }, + ); + } + const { document, sourceSettings } = familyInstance.buildPslContract(contract); + + return { + psl: dependencies.printPsl(document, { + pslBlockDescriptors: stack.authoringContributions.pslBlockDescriptors, + codecLookup: stack.codecLookup, + description, + }), + sourceSettings, + sourceWarnings, + }; +} diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/format.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/format.ts index 9b57dddd1913..16b8807a796d 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/format.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/format.ts @@ -1,10 +1,11 @@ -import { readFile, writeFile } from 'node:fs/promises'; +import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; import { EOL } from 'node:os'; import type { PrismaNextConfig } from '@internal/config/config-types'; import { expandContractInputs } from '@internal/config-loader'; import { type FormatOptions, format } from '@internal/psl-parser/format'; import { notOk, ok, type Result } from '@internal/utils/result'; import { isStructuredError } from '@internal/utils/structured-error'; +import { join } from 'pathe'; import { type CliStructuredError, errorRuntime, errorUnexpected } from '../../utils/cli-errors'; export interface FormatOperationOptions { @@ -78,6 +79,20 @@ async function formatOneFile( return ok(inputPath); } +/** The input itself, or every `.prisma` file under it when the input is a directory. */ +async function pslFilesOf(inputPath: string): Promise { + const isDirectory = await stat(inputPath).then( + (stats) => stats.isDirectory(), + () => false, + ); + if (!isDirectory) return [inputPath]; + const entries = await readdir(inputPath, { recursive: true }); + return entries + .filter((entry) => entry.endsWith('.prisma')) + .sort() + .map((entry) => join(inputPath, entry)); +} + export async function executeFormat( options: FormatOperationOptions, ): Promise> { @@ -101,7 +116,8 @@ export async function executeFormat( const paths: string[] = []; const failures: CliStructuredError[] = []; - for (const inputPath of resolvedInputs) { + const files = (await Promise.all(resolvedInputs.map(pslFilesOf))).flat(); + for (const inputPath of files) { const outcome = await formatOneFile(inputPath, formatOptions); if (outcome.ok) { paths.push(outcome.value); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/load-contract-source.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/load-contract-source.ts new file mode 100644 index 000000000000..19ad6a1fc745 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/load-contract-source.ts @@ -0,0 +1,328 @@ +import type { + ContractSourceDiagnostic, + ContractSourceDiagnostics, + ContractSourceProvider, + PrismaNextConfig, +} from '@internal/config/config-types'; +import { expandContractInputs } from '@internal/config-loader'; +import type { Contract } from '@internal/contract/types'; +import type { CliStructuredError } from '@internal/errors/control'; +import { type ControlStack, createControlStack } from '@internal/framework-components/control'; +import { abortable } from '@internal/utils/abortable'; +import { ifDefined } from '@internal/utils/defined'; +import type { Result } from '@internal/utils/result'; +import { notOk, ok } from '@internal/utils/result'; +import type { Diagnostic } from '@internal/utils/structured-error'; +import { isStructuredErrorCode } from '@internal/utils/structured-error'; +import { errorContractConfigMissing, errorRuntime } from '../../utils/cli-errors'; + +/** + * Why the configured source produced no contract: the error to report, and + * the diagnostics the source returned, when it returned any. + */ +export interface ContractSourceLoadFailure { + readonly error: CliStructuredError; + readonly sourceDiagnostics?: ContractSourceDiagnostics; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function failedToResolveContractSource( + why: string, + fix: string, + meta?: Record, + cause?: unknown, + diagnostics?: readonly Diagnostic[], +) { + return errorRuntime('CONTRACT.SOURCE_LOAD_FAILED', 'Failed to resolve contract source', { + why, + fix, + ...ifDefined('diagnostics', diagnostics), + ...ifDefined('meta', meta), + ...ifDefined('cause', cause), + }); +} + +interface DiagnosticLocation { + readonly sourceId: string | undefined; + readonly line: number | undefined; + readonly character: number | undefined; +} + +function diagnosticLocation(diagnostic: Record): DiagnosticLocation { + const sourceId = typeof diagnostic['sourceId'] === 'string' ? diagnostic['sourceId'] : undefined; + const span = isRecord(diagnostic['span']) ? diagnostic['span'] : undefined; + const start = span && isRecord(span['start']) ? span['start'] : undefined; + const line = start && typeof start['line'] === 'number' ? start['line'] : undefined; + // biome-ignore lint/plugin/no-family-vocabulary: a text position in the source file; the span calls it column + const character = start && typeof start['column'] === 'number' ? start['column'] : undefined; + return { sourceId, line, character }; +} + +function formatLocation({ sourceId, line, character }: DiagnosticLocation): string | undefined { + if (sourceId === undefined) return undefined; + return line !== undefined && character !== undefined + ? `${sourceId}:${line}:${character}` + : sourceId; +} + +/** One source diagnostic as a line of text: `:: `. */ +export function formatSourceDiagnostic(raw: unknown): string { + if (!isRecord(raw)) return String(raw); + const code = typeof raw['code'] === 'string' ? raw['code'] : 'diagnostic'; + const message = typeof raw['message'] === 'string' ? raw['message'] : ''; + const location = formatLocation(diagnosticLocation(raw)); + return [location, code, message].filter((part) => part !== undefined && part !== '').join(' '); +} + +/** + * The finding the CLI prints under the error, one per source diagnostic. The + * terminal renderer prints a finding's code and summary and nothing of its + * `where`, so the summary starts with the location. A source code that is not + * yet dotted is wrapped as `CONTRACT.SOURCE_DIAGNOSTIC` and named in the summary. + */ +function sourceDiagnosticToFinding(raw: unknown): Diagnostic | undefined { + if (!isRecord(raw)) return undefined; + const code = typeof raw['code'] === 'string' ? raw['code'] : 'diagnostic'; + const message = typeof raw['message'] === 'string' ? raw['message'] : ''; + const location = diagnosticLocation(raw); + const formatted = formatLocation(location); + const locatedSummary = (text: string) => + formatted === undefined ? text : `${formatted} ${text}`; + const finding = { + severity: 'error', + nextActions: [], + ...ifDefined( + 'where', + location.sourceId === undefined + ? undefined + : { path: location.sourceId, ...ifDefined('line', location.line) }, + ), + } as const; + return isStructuredErrorCode(code) + ? { code, summary: locatedSummary(message), ...finding } + : { + code: 'CONTRACT.SOURCE_DIAGNOSTIC', + summary: locatedSummary(`${code}: ${message}`), + ...finding, + meta: { code }, + }; +} + +function sourceDiagnosticsToFindings(diagnostics: readonly unknown[]): Diagnostic[] { + const findings: Diagnostic[] = []; + for (const raw of diagnostics) { + const finding = sourceDiagnosticToFinding(raw); + if (finding !== undefined) findings.push(finding); + } + return findings; +} + +function diagnosticLocationSuffix(diagnostic: Record): string { + const formatted = formatLocation(diagnosticLocation(diagnostic)); + return formatted === undefined ? '' : ` (${formatted})`; +} + +function mapDiagnosticsToIssues( + diagnostics: readonly unknown[], +): ReadonlyArray<{ readonly kind: string; readonly message: string }> { + const issues: { readonly kind: string; readonly message: string }[] = []; + for (const raw of diagnostics) { + if (!isRecord(raw)) continue; + const code = typeof raw['code'] === 'string' ? raw['code'] : 'diagnostic'; + const message = typeof raw['message'] === 'string' ? raw['message'] : ''; + issues.push({ kind: code, message: `${message}${diagnosticLocationSuffix(raw)}` }); + } + return issues; +} + +type ContractSourceLoadResult = Result; + +function failedWith(error: CliStructuredError): ContractSourceLoadResult { + return notOk({ error }); +} + +/** + * Checks the shape of what `load` returned. A source may be plain JavaScript, + * so its declared type is not trusted. + */ +function validateProviderResult( + providerResult: Result, +): ContractSourceLoadResult { + const raw: unknown = providerResult; + if (!isRecord(raw) || typeof raw['ok'] !== 'boolean') { + return failedWith( + failedToResolveContractSource( + 'Contract source provider returned malformed result shape.', + 'Ensure contract.source.load resolves to ok(Contract) or notOk({ summary, diagnostics }).', + ), + ); + } + + if (providerResult.ok) { + const value: unknown = providerResult.value; + if (value === undefined || value === null) { + return failedWith( + failedToResolveContractSource( + 'Contract source provider returned malformed success result: missing value.', + 'Ensure contract.source.load success payload is ok(Contract).', + ), + ); + } + return ok(providerResult.value); + } + + const failure: unknown = providerResult.failure; + if ( + !isRecord(failure) || + typeof failure['summary'] !== 'string' || + !Array.isArray(failure['diagnostics']) + ) { + return failedWith( + failedToResolveContractSource( + 'Contract source provider returned malformed failure result: expected summary and diagnostics.', + 'Ensure contract.source.load failure payload is notOk({ summary, diagnostics, meta? }).', + ), + ); + } + if ( + failure['diagnostics'].some( + (diagnostic: unknown) => !isRecord(diagnostic) || typeof diagnostic['sourceId'] !== 'string', + ) + ) { + return failedWith( + failedToResolveContractSource( + 'Contract source provider returned malformed failure result: each diagnostic must include a string sourceId.', + 'Include the source filename in each diagnostic returned by contract.source.load.', + ), + ); + } + return notOk({ + error: failedToResolveContractSource( + failure['summary'], + 'Edit the source where each finding points, then run the command again.', + { + diagnostics: failure['diagnostics'], + issues: mapDiagnosticsToIssues(failure['diagnostics']), + ...ifDefined('providerMeta', failure['meta']), + }, + undefined, + sourceDiagnosticsToFindings(failure['diagnostics']), + ), + sourceDiagnostics: providerResult.failure, + }); +} + +/** + * Asks the configured contract source for the contract, with a source context + * built from `stack`, and turns every failure into `CONTRACT.SOURCE_LOAD_FAILED`. + * Every command that loads a contract source goes through here, so each + * reports a bad source the same way. + * + * @throws {DOMException} `AbortError` if cancelled via `signal` + */ +export async function loadContractSourceWithStack(inputs: { + readonly stack: ControlStack; + readonly source: ContractSourceProvider; + readonly signal?: AbortSignal; + readonly reportWarning?: (diagnostic: ContractSourceDiagnostic) => void; +}): Promise { + const { stack, source } = inputs; + const signal = inputs.signal ?? new AbortController().signal; + const unlessAborted = abortable(signal); + + const sourceContext = { + ...ifDefined('reportWarning', inputs.reportWarning), + composedExtensions: stack.extensions.map((p) => p.id), + composedExtensionContracts: stack.extensionContracts, + authoringContributions: stack.authoringContributions, + codecLookup: stack.codecLookup, + controlMutationDefaults: stack.controlMutationDefaults, + dataTypeLookup: stack.dataTypeLookup, + resolvedInputs: await unlessAborted(expandContractInputs(source.inputs)), + capabilities: stack.capabilities, + }; + + let providerResult: Result; + try { + providerResult = await unlessAborted(source.load(sourceContext)); + } catch (error) { + if (signal.aborted || (isRecord(error) && error['name'] === 'AbortError')) { + throw error; + } + return failedWith( + failedToResolveContractSource( + error instanceof Error ? error.message : String(error), + 'Ensure contract.source.load resolves to ok(Contract) or returns structured diagnostics.', + undefined, + error, + ), + ); + } + + return validateProviderResult(providerResult); +} + +type ContractConfig = NonNullable; + +/** @throws {CliStructuredError} `CONFIG.CONTRACT_MISSING` when the config has no contract section */ +export function requireContractConfig(config: PrismaNextConfig): ContractConfig { + if (!config.contract) { + throw errorContractConfigMissing({ + why: 'Config.contract is required for emit. Define it in your config: contract: { source: ..., output: ... }', + }); + } + return config.contract; +} + +/** @throws {CliStructuredError} `CONFIG.CONTRACT_MISSING` when the contract source has no `load` function */ +export function requireSourceProvider(contractConfig: ContractConfig): void { + if (typeof contractConfig.source?.load !== 'function') { + throw errorContractConfigMissing({ + why: 'Contract config must include a valid source provider object', + }); + } +} + +/** What a contract source reported when it could not produce a contract. */ +export interface ContractSourceFailure { + readonly summary: string; + readonly diagnostics: readonly unknown[]; + readonly meta: unknown; +} + +/** + * Runs the config's contract source and stops there: nothing is emitted or + * written. A source that reports diagnostics is a `notOk` carrying them. + * + * @throws {CliStructuredError} the error `contract emit` raises, when the + * config has no contract source or the source is malformed or throws + * @throws {DOMException} `AbortError` if cancelled via `signal` + */ +export async function loadContractSource( + config: PrismaNextConfig, + options: { + readonly signal?: AbortSignal; + /** Receives each warning the source reports. */ + readonly onWarning?: (diagnostic: ContractSourceDiagnostic) => void; + } = {}, +): Promise> { + const contractConfig = requireContractConfig(config); + requireSourceProvider(contractConfig); + const loaded = await loadContractSourceWithStack({ + stack: createControlStack(config), + source: contractConfig.source, + ...ifDefined('signal', options.signal), + ...ifDefined('reportWarning', options.onWarning), + }); + if (loaded.ok) return loaded; + const { error, sourceDiagnostics } = loaded.failure; + if (sourceDiagnostics === undefined) throw error; + return notOk({ + summary: sourceDiagnostics.summary, + diagnostics: sourceDiagnostics.diagnostics, + meta: sourceDiagnostics.meta, + }); +} diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/validate-loaded-contract.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/validate-loaded-contract.ts new file mode 100644 index 000000000000..f474015e1f1b --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/validate-loaded-contract.ts @@ -0,0 +1,31 @@ +import type { PrismaNextConfig } from '@internal/config/config-types'; +import type { Contract } from '@internal/contract/types'; +import type { ControlFamilyInstance } from '@internal/framework-components/control'; +import { assertFrameworkComponentsCompatible } from '../../utils/framework-components'; +import { enrichContract } from '../contract-enrichment'; + +/** + * Validates a contract its source loaded, as `contract emit` and `contract print` both do before + * they write anything: the contract is enriched with what the configured components declare, + * serialized by the target, and read back through the family instance, which checks its structure. + * Returns the contract that comes back. + * + * @throws {CliStructuredError} when a configured component does not match the family and target + * @throws the family instance's error for a contract whose structure it rejects + */ +export function validateLoadedContract(input: { + readonly config: PrismaNextConfig; + readonly familyInstance: Pick, 'deserializeContract'>; + readonly contract: Contract; +}): Contract { + const { config } = input; + const frameworkComponents = assertFrameworkComponentsCompatible( + config.family.familyId, + config.target.targetId, + [config.target, config.adapter, ...(config.extensions ?? [])], + ); + const enriched = enrichContract(input.contract, frameworkComponents); + return input.familyInstance.deserializeContract( + config.target.contractSerializer.serializeContract(enriched), + ); +} diff --git a/packages/1-framework/3-tooling/cli/src/exports/control-api.ts b/packages/1-framework/3-tooling/cli/src/exports/control-api.ts index 8794617b3582..f51d097e1532 100644 --- a/packages/1-framework/3-tooling/cli/src/exports/control-api.ts +++ b/packages/1-framework/3-tooling/cli/src/exports/control-api.ts @@ -24,11 +24,7 @@ export { enrichContract } from '../control-api/contract-enrichment'; // (TML-3173, consolidate-clis slice 1b). export { mapCaughtMigrationError } from '../control-api/operations/caught-errors'; export { mapContractAtError } from '../control-api/operations/contract-at-errors'; -export { - type ContractSourceFailure, - executeContractEmit, - loadContractSource, -} from '../control-api/operations/contract-emit'; +export { executeContractEmit } from '../control-api/operations/contract-emit'; export { type ResolveContractRefToSnapshotOptions, type ResolveContractRefToSnapshotSuccess, @@ -73,6 +69,10 @@ export { refuseMissingInvariantPath, refuseUnknownInvariants, } from '../control-api/operations/invariants'; +export { + type ContractSourceFailure, + loadContractSource, +} from '../control-api/operations/load-contract-source'; export { type ExecuteMigrateShowPlanOptions, executeMigrateShowPlan, diff --git a/packages/1-framework/3-tooling/cli/src/orm/cli.ts b/packages/1-framework/3-tooling/cli/src/orm/cli.ts index 1ec3a1085ae7..d118ee2790ae 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/cli.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/cli.ts @@ -7,6 +7,7 @@ import { createControlClient } from '../control-api/client'; import type { CreateControlClient } from '../control-api/types'; import { contractEmitCommand } from './contract/emit'; import { contractInferCommand } from './contract/infer'; +import { contractPrintCommand } from './contract/print'; import { createDbInitCommand } from './db/init'; import { createDbSchemaCommand } from './db/schema'; import { createDbSignCommand } from './db/sign'; @@ -95,6 +96,7 @@ export const BIN_GROUPS = { */ export function createBinCommands(createClient: CreateControlClient): MountedTree { return { + 'contract print': contractPrintCommand, 'contract emit': contractEmitCommand, 'contract format': formatCommand, 'contract infer': contractInferCommand, diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/emit.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/emit.ts index 776a36644561..8009d089ff67 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/emit.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/emit.ts @@ -4,10 +4,8 @@ import type { Block, Presentations } from '@prisma/cli-engine'; import { flag } from '@prisma/cli-engine'; import { ok } from '@prisma/cli-engine/protocol'; import { dirname, relative, resolve } from 'pathe'; -import { - executeContractEmit as executeContractEmitOperation, - formatSourceDiagnostic, -} from '../../control-api/operations/contract-emit'; +import { executeContractEmit as executeContractEmitOperation } from '../../control-api/operations/contract-emit'; +import { formatSourceDiagnostic } from '../../control-api/operations/load-contract-source'; import { defineOrmCommand } from '../define-command'; import { controlProgressReporter } from '../progress'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/infer.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/infer.ts index 10ecf6b5f3f8..701774ef1587 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/infer.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/infer.ts @@ -153,6 +153,8 @@ export function createContractInferCommand({ } pslContent = printPsl(pslContractAst, { pslBlockDescriptors: client.getPslBlockDescriptors(), + description: + 'Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.', }); } catch (error) { if (CliStructuredError.is(error)) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/paths.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/paths.ts index 5dd58ee482f6..735f7b7af019 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/paths.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/paths.ts @@ -1,5 +1,6 @@ +import { realpath, stat } from 'node:fs/promises'; import type { PrismaNextConfig } from '@internal/config/config-types'; -import { dirname, join, resolve } from 'pathe'; +import { basename, dirname, join, normalize, resolve } from 'pathe'; /** The file `contract infer` writes when nothing names another. */ const INFERRED_CONTRACT_FILENAME = 'contract.prisma'; @@ -23,3 +24,60 @@ export function inferredContractPathFor(inputs: { } return join(inputs.cwd, INFERRED_CONTRACT_FILENAME); } + +/** + * The nearest part of `path` that exists, with symbolic links resolved, and + * the names below it that do not exist yet. + */ +async function existingPart( + path: string, +): Promise<{ readonly existing: string; readonly missing: readonly string[] }> { + const missing: string[] = []; + let candidate = path; + for (;;) { + try { + return { existing: normalize(await realpath(candidate)), missing }; + } catch { + const parent = dirname(candidate); + if (parent === candidate) { + return { existing: candidate, missing }; + } + missing.unshift(basename(candidate)); + candidate = parent; + } + } +} + +function withCaseSwapped(path: string): string { + return [...path] + .map((character) => { + const upper = character.toUpperCase(); + return character === upper ? character.toLowerCase() : upper; + }) + .join(''); +} + +/** Whether `existingPath` names the same file with the case of its letters swapped. */ +async function volumeIgnoresCase(existingPath: string): Promise { + const swapped = withCaseSwapped(existingPath); + if (swapped === existingPath) { + return false; + } + try { + const [original, variant] = await Promise.all([stat(existingPath), stat(swapped)]); + return original.dev === variant.dev && original.ino === variant.ino; + } catch { + return false; + } +} + +/** + * A key that is the same for two paths naming the same file. Symbolic links + * are resolved, in the path itself or in its nearest existing parent, and the + * key is lower case when that parent is on a volume that ignores case. + */ +export async function filePathKey(path: string): Promise { + const { existing, missing } = await existingPart(path); + const resolved = join(existing, ...missing); + return (await volumeIgnoresCase(existing)) ? resolved.toLowerCase() : resolved; +} diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts new file mode 100644 index 000000000000..8586726be9d8 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts @@ -0,0 +1,398 @@ +import { existsSync } from 'node:fs'; +import { defaultContractOutputPath } from '@internal/config/config-types'; +import { + expandContractInputs, + globContractInputMatching, + ormConfigSection, +} from '@internal/config-loader'; +import { getEmittedArtifactPaths } from '@internal/emitter'; +import type { CliStructuredError } from '@internal/errors/control'; +import type { PslSourceSettings } from '@internal/framework-components/control'; +import { printPsl as printPslFromAst } from '@internal/psl-printer'; +import type { Block, Presentations } from '@prisma/cli-engine'; +import { flag } from '@prisma/cli-engine'; +import type { NextAction } from '@prisma/cli-engine/protocol'; +import { notOk, ok } from '@prisma/cli-engine/protocol'; +import { relative, resolve } from 'pathe'; +import { + type ContractPrintResult, + executeContractPrint, +} from '../../control-api/operations/contract-print'; +import { formatSourceDiagnostic } from '../../control-api/operations/load-contract-source'; +import { errorContractConfigMissing, errorRuntime } from '../../utils/cli-errors'; +import { chooseAction, runCommandAction } from '../../utils/next-actions'; +import { publishTextArtifact } from '../../utils/publish-text-artifact'; +import { defineOrmCommand } from '../define-command'; +import { baseDirFor } from '../migration/paths'; +import { normalizeError } from '../normalize-error'; +import { filePathKey } from './paths'; + +interface PrintDocument { + readonly ok: true; + readonly summary: string; + readonly target: { readonly familyId: string; readonly id: string }; + /** The file the PSL was written to, or the PSL itself when no --output was given. */ + readonly psl: { readonly path: string } | { readonly text: string }; + readonly source: readonly string[]; + /** + * What a PSL file cannot carry. The config sets each on the PSL source, or + * the emitted contract loses it. + */ + readonly sourceSettings: PslSourceSettings; + readonly timings: { readonly total: number }; +} + +/** + * What the next step tells the user to put in the config. Every path is + * relative to the directory of the config, which is what the config resolves + * its own paths against. + */ +interface ConfigSwitch { + readonly contractPath: string; + readonly emittedFilesMove: EmittedFilesMove | undefined; +} + +/** The emitted files `contract emit` writes now, and after the config switches to the printed file. */ +interface EmittedFilesMove { + readonly before: { readonly json: string; readonly dts: string }; + readonly after: { readonly json: string; readonly dts: string }; +} + +function sourceSettingsClause(policy: string | undefined): string { + return policy === undefined + ? '' + : `, through a PSL source that sets defaultControlPolicy: '${policy}'`; +} + +function switchToPrintedActions( + document: PrintDocument, + configSwitch: ConfigSwitch | undefined, +): readonly NextAction[] { + const policyClause = sourceSettingsClause(document.sourceSettings.defaultControlPolicy); + const emit = runCommandAction('Emit the printed contract', '{bin} contract emit'); + if (configSwitch === undefined) { + return [ + chooseAction( + `Write the PSL to a file with --output , then point contract in prisma.config.ts at that file${policyClause}`, + ), + emit, + ]; + } + const { contractPath: path, emittedFilesMove } = configSwitch; + return [ + chooseAction(`Point contract in prisma.config.ts at ${path}${policyClause}`), + ...(emittedFilesMove === undefined + ? [] + : [ + chooseAction( + `With contract: './${path}' and no output in prisma.config.ts, contract emit writes ${emittedFilesMove.after.json} and ${emittedFilesMove.after.dts}, not ${emittedFilesMove.before.json} and ${emittedFilesMove.before.dts}`, + ), + ]), + emit, + ]; +} + +function defaultControlPolicyWarning(policy: string): string { + return `The contract's default control policy is '${policy}', and a PSL file cannot carry it. Set defaultControlPolicy: '${policy}' on the PSL source in prisma.config.ts. Without it, the emitted contract has no default control policy, and everything that sets no control policy of its own is treated as managed.`; +} + +/** The lines of `text`, without the empty line after its final newline. */ +function linesOf(text: string): readonly string[] { + return text.replace(/\n$/, '').split('\n'); +} + +function printPresentations( + document: PrintDocument, + configSwitch: ConfigSwitch | undefined, +): Presentations { + return { + stdout: () => ('text' in document.psl ? linesOf(document.psl.text) : []), + next: () => switchToPrintedActions(document, configSwitch), + human: (): readonly Block[] => + 'path' in document.psl + ? [ + { + kind: 'summary', + status: 'ok', + text: [ + { text: 'Contract written to ' }, + { text: document.psl.path, tone: 'identifier' }, + ], + }, + ] + : [ + { kind: 'summary', status: 'ok', text: [{ text: 'Contract printed as Prisma 8 PSL' }] }, + { kind: 'drawing', lines: linesOf(document.psl.text) }, + ], + json: () => document, + }; +} + +export interface ContractPrintCommandDeps { + readonly printPsl: typeof printPslFromAst; +} + +function printDescription(sourcePaths: readonly string[]): string { + const origin = sourcePaths.length === 0 ? '' : ` from ${sourcePaths.join(', ')}`; + return `Printed${origin} by \`prisma contract print\`.`; +} + +async function isSameFile(outputKey: string, path: string): Promise { + return outputKey === (await filePathKey(path)); +} + +async function isSameFileOrInside(outputKey: string, path: string): Promise { + const key = await filePathKey(path); + return outputKey === key || outputKey.startsWith(`${key.replace(/\/$/, '')}/`); +} + +/** + * The refusal for an output path that would write over a file the project + * needs: a contract source input, or a file inside a directory of inputs; the + * config file; or a file `contract emit` writes. `undefined` when the path + * touches none of them. + */ +async function outputPathRefusal(inputs: { + readonly cwd: string; + readonly outputPath: string; + readonly sourceInputs: readonly string[]; + readonly configPath: string; + readonly emittedJsonPath: string | undefined; +}): Promise { + const { cwd } = inputs; + const output = relative(cwd, inputs.outputPath); + const outputKey = await filePathKey(inputs.outputPath); + + for (const input of await expandContractInputs(inputs.sourceInputs)) { + if (await isSameFileOrInside(outputKey, resolve(cwd, input))) { + const source = relative(cwd, resolve(cwd, input)); + return errorRuntime( + 'CONTRACT.PRINT_OUTPUT_IS_SOURCE', + 'contract print would write over its own contract source', + { + why: `The output path ${output} is the contract source ${source}, or sits inside it, so printing would destroy the source it reads.`, + fix: 'Pick another --output path, outside the source files the config names.', + meta: { output, source }, + }, + ); + } + } + + const matchingGlob = + globContractInputMatching(inputs.sourceInputs, inputs.outputPath) ?? + globContractInputMatching(inputs.sourceInputs, outputKey); + if (matchingGlob !== undefined) { + const source = relative(cwd, matchingGlob); + return errorRuntime( + 'CONTRACT.PRINT_OUTPUT_IS_SOURCE', + 'contract print would write a file its own contract source reads', + { + why: `The contract source reads every file that matches ${source}. Once written, ${output} would match, so contract emit would read the printed file together with the source files.`, + fix: 'Pick another --output path, one the source inputs in the config do not match.', + meta: { output, source }, + }, + ); + } + + const { configPath } = inputs; + if (await isSameFile(outputKey, configPath)) { + const file = relative(cwd, configPath); + return errorRuntime( + 'CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE', + 'contract print would write over the config file', + { + why: `The output path ${output} is ${file}, the file the CLI reads its config from unless --config names another.`, + fix: 'Pick another --output path.', + meta: { output, file }, + }, + ); + } + + if (inputs.emittedJsonPath === undefined) { + return undefined; + } + const emitted = getEmittedArtifactPaths(inputs.emittedJsonPath); + for (const emittedPath of [emitted.jsonPath, emitted.dtsPath]) { + if (await isSameFile(outputKey, emittedPath)) { + const file = relative(cwd, emittedPath); + return errorRuntime( + 'CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE', + 'contract print would write over an emitted contract file', + { + why: `The output path ${output} is ${file}, a file contract emit writes, so the next contract emit would write over the printed PSL.`, + fix: 'Pick another --output path.', + meta: { output, file }, + }, + ); + } + } + return undefined; +} + +function emittedFilesMoveFor(inputs: { + readonly configDir: string; + readonly outputPath: string; + readonly emittedJsonPath: string | undefined; +}): EmittedFilesMove | undefined { + if (inputs.emittedJsonPath === undefined) { + return undefined; + } + const before = getEmittedArtifactPaths(inputs.emittedJsonPath); + const after = getEmittedArtifactPaths(defaultContractOutputPath(inputs.outputPath)); + if (after.jsonPath === before.jsonPath) { + return undefined; + } + return { + before: { + json: relative(inputs.configDir, before.jsonPath), + dts: relative(inputs.configDir, before.dtsPath), + }, + after: { + json: relative(inputs.configDir, after.jsonPath), + dts: relative(inputs.configDir, after.dtsPath), + }, + }; +} + +export function createContractPrintCommand({ printPsl }: ContractPrintCommandDeps) { + return defineOrmCommand({ + help: { + summary: 'Print the configured contract as Prisma 8 PSL', + description: + 'Loads the contract from contract.source in your config, whatever kind\n' + + 'of source that is, and prints it as Prisma 8 PSL, or writes it to the\n' + + 'file --output names. Emitting that PSL produces the same contract: same\n' + + 'hashes, same domain. If the contract holds something PSL cannot\n' + + 'express, the command refuses, names it, and prints nothing. A pipe\n' + + 'receives the JSON result unless you pass --format human. The command\n' + + 'does not change your config: point it at the written file, then run\n' + + '`contract emit`. An existing file at the --output path is overwritten,\n' + + 'with a warning.', + examples: [ + 'contract print', + 'contract print --format human > printed.prisma', + 'contract print --output ./src/prisma/contract.prisma', + 'contract print --json', + ], + }, + args: { + flags: { + output: flag.string({ + brief: 'Write the PSL to this file instead of standard output', + placeholder: 'path', + }), + }, + }, + needs: { config: ormConfigSection }, + handler: async (args, ctx) => { + const startedAt = Date.now(); + const contractConfig = ctx.config.contract; + if (contractConfig?.source === undefined) { + return notOk( + normalizeError( + errorContractConfigMissing({ + why: 'Config.contract.source is required for contract print. Define contract in your config so the command has a source to print.', + }), + ), + ); + } + const sourceInputs = contractConfig.source.inputs ?? []; + const sourcePaths = sourceInputs.map((input) => relative(ctx.cwd, input)); + const emittedJsonPath = + contractConfig.output === undefined ? undefined : resolve(ctx.cwd, contractConfig.output); + + const configDir = baseDirFor(ctx.config); + const outputPath = + args.flags.output === undefined ? undefined : resolve(ctx.cwd, args.flags.output); + if (outputPath !== undefined) { + const refusal = await outputPathRefusal({ + cwd: ctx.cwd, + outputPath, + sourceInputs, + configPath: resolve(configDir, 'prisma.config.ts'), + emittedJsonPath, + }); + if (refusal !== undefined) { + return notOk(normalizeError(refusal)); + } + } + + let printed: ContractPrintResult; + try { + printed = await executeContractPrint( + { + config: ctx.config, + contractConfig, + description: printDescription(sourcePaths), + signal: ctx.signal, + }, + { printPsl }, + ); + } catch (error) { + return notOk(normalizeError(error)); + } + ctx.signal.throwIfAborted(); + + for (const warning of printed.sourceWarnings) { + ctx.report({ + kind: 'message', + severity: 'warn', + text: `warning ${formatSourceDiagnostic(warning)}`, + }); + } + if (outputPath !== undefined) { + if (existsSync(outputPath)) { + ctx.report({ + kind: 'message', + severity: 'warn', + text: `Overwriting existing file: ${relative(ctx.cwd, outputPath)}`, + }); + } + await publishTextArtifact({ + path: outputPath, + content: printed.psl, + publicationToken: String(process.hrtime.bigint()), + }); + } + + const { defaultControlPolicy } = printed.sourceSettings; + if (defaultControlPolicy !== undefined) { + ctx.report({ + kind: 'message', + severity: 'warn', + text: defaultControlPolicyWarning(defaultControlPolicy), + }); + } + + const document: PrintDocument = { + ok: true, + summary: 'Contract printed successfully', + target: { familyId: ctx.config.family.familyId, id: ctx.config.target.targetId }, + psl: + outputPath === undefined + ? { text: printed.psl } + : { path: relative(ctx.cwd, outputPath) }, + source: sourcePaths, + sourceSettings: printed.sourceSettings, + timings: { total: Date.now() - startedAt }, + }; + + return ok( + ctx.present( + { data: document }, + printPresentations( + document, + outputPath === undefined + ? undefined + : { + contractPath: relative(configDir, outputPath), + emittedFilesMove: emittedFilesMoveFor({ configDir, outputPath, emittedJsonPath }), + }, + ), + ), + ); + }, + }); +} + +export const contractPrintCommand = createContractPrintCommand({ printPsl: printPslFromAst }); diff --git a/packages/1-framework/3-tooling/cli/src/orm/family.ts b/packages/1-framework/3-tooling/cli/src/orm/family.ts index 7b1b48731ae1..5573c716b301 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/family.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/family.ts @@ -4,6 +4,7 @@ import type { AnyCommand, RedirectSpec } from '@prisma/cli-engine'; import { defineCommandFamily } from '@prisma/cli-engine'; import { contractEmitCommand } from './contract/emit'; import { contractInferCommand } from './contract/infer'; +import { contractPrintCommand } from './contract/print'; import { dbInitCommand } from './db/init'; import { dbSchemaCommand } from './db/schema'; import { dbSignCommand } from './db/sign'; @@ -32,6 +33,7 @@ import { refSetCommand } from './ref/set'; const DOCS_BASE_URL = `${DOCS_BASE}/`; const commands: Readonly> = { + 'contract print': contractPrintCommand, 'contract emit': contractEmitCommand, 'contract format': formatCommand, 'contract infer': contractInferCommand, diff --git a/packages/1-framework/3-tooling/cli/src/orm/init-prisma7-check.ts b/packages/1-framework/3-tooling/cli/src/orm/init-prisma7-check.ts index 5d780eec0adf..a787dfd2c72a 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/init-prisma7-check.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/init-prisma7-check.ts @@ -21,7 +21,7 @@ import { targetEntrypoint, targetPackageName, } from '../commands/init/templates/code-templates'; -import { loadContractSource } from '../control-api/operations/contract-emit'; +import { loadContractSource } from '../control-api/operations/load-contract-source'; import { chooseAction } from '../utils/next-actions'; import { installProjectDependencies } from './init-packages'; import { normalizeError } from './normalize-error'; diff --git a/packages/1-framework/3-tooling/cli/test/config-types.test.ts b/packages/1-framework/3-tooling/cli/test/config-types.test.ts index 701f8644700f..3ef8412e1c43 100644 --- a/packages/1-framework/3-tooling/cli/test/config-types.test.ts +++ b/packages/1-framework/3-tooling/cli/test/config-types.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'; describe('defineConfig', () => { const createSourceProvider = (inputs: readonly string[] | undefined = undefined) => ({ + format: 'typescript' as const, ...(!inputs ? {} : { inputs }), load: async () => ok({ targetFamily: 'sql' } as Contract), }); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts index cc093ae30f00..542a76cfb412 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts @@ -49,6 +49,7 @@ function createSourceProvider( inputs?: readonly string[], ): ContractSourceProvider { return { + format: 'typescript', ...ifDefined('inputs', inputs), load, }; @@ -624,6 +625,34 @@ describe('ControlClient progress emission', () => { } }); + it('rejects a malformed source result with the check contract emit runs', async () => { + const { mockFamily, mockTarget, mockAdapter } = createMockComponents(); + const client = createControlClient({ + family: mockFamily, + target: mockTarget, + adapter: mockAdapter, + }); + + const result = await client.emit({ + contractConfig: { + source: createSourceProvider( + async () => ({}) as unknown as Awaited>, + ), + output: '/tmp/contract.json', + }, + }); + + await client.close(); + + expect(result.ok).toBe(false); + expect(result.ok ? undefined : result.failure).toEqual({ + code: 'CONTRACT_SOURCE_INVALID', + summary: 'Failed to resolve contract source', + why: 'Contract source provider returned malformed result shape.', + meta: undefined, + }); + }); + it('emits error outcome when emit throws', async () => { const events: ControlProgressEvent[] = []; const { mockFamily, mockTarget, mockAdapter } = createMockComponents(); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts index 3cdb267c3704..08823a3c3ec2 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/contract-emit.test.ts @@ -32,10 +32,11 @@ function mockConfigWithContract(contractOverrides: Record) { } function createSourceProvider(load: () => Promise): { + readonly format: 'typescript'; readonly inputs?: readonly string[]; load: () => Promise; } { - return { load }; + return { format: 'typescript', load }; } function createMockContract(): Contract { @@ -191,6 +192,18 @@ describe('executeContractEmit', () => { expectedCode: 'CONTRACT.SOURCE_LOAD_FAILED', expectedSubstring: 'malformed success result', }, + { + label: 'rejects a success result whose value is undefined', + source: createSourceProvider(async () => ({ ok: true, value: undefined }) as unknown), + expectedCode: 'CONTRACT.SOURCE_LOAD_FAILED', + expectedSubstring: 'malformed success result', + }, + { + label: 'rejects a success result whose value is null', + source: createSourceProvider(async () => ({ ok: true, value: null }) as unknown), + expectedCode: 'CONTRACT.SOURCE_LOAD_FAILED', + expectedSubstring: 'malformed success result', + }, ])('source provider validation', ({ label, source, expectedCode, expectedSubstring }) => { it(label, async () => { await expect( @@ -212,7 +225,7 @@ describe('executeContractEmit', () => { describe('a source that fails with diagnostics', () => { const sourceDiagnostics = [ { - code: 'PSL.PRISMA7_VIEW_UNSUPPORTED', + code: 'PSL.FIXTURE_VIEW_UNSUPPORTED', message: 'View "ActiveUsers" is not supported; Prisma 8 has no views.', sourceId: 'prisma/schema.prisma', span: { @@ -220,7 +233,7 @@ describe('executeContractEmit', () => { end: { offset: 90, line: 9, column: 11 }, }, }, - { code: 'PSL.PRISMA7_SCHEMA_READ_FAILED', message: 'ENOENT', sourceId: 'prisma/schema' }, + { code: 'PSL.FIXTURE_SCHEMA_READ_FAILED', message: 'ENOENT', sourceId: 'prisma/schema' }, { code: 'PSL_PARSE_ERROR', message: 'Unexpected token', sourceId: 'prisma/models.prisma' }, ]; @@ -228,7 +241,7 @@ describe('executeContractEmit', () => { const source = createSourceProvider(async () => ({ ok: false, failure: { - summary: 'Prisma 7 schema interpretation failed', + summary: 'Source interpretation failed', diagnostics: sourceDiagnostics, meta: { schemaPath: 'prisma/schema.prisma' }, }, @@ -244,15 +257,15 @@ describe('executeContractEmit', () => { it('fails with CONTRACT.SOURCE_LOAD_FAILED', async () => { expect(await emitFailure()).toMatchObject({ code: 'CONTRACT.SOURCE_LOAD_FAILED', - why: 'Prisma 7 schema interpretation failed', - fix: 'Edit the schema where each finding points, then run contract emit again.', + why: 'Source interpretation failed', + fix: 'Edit the source where each finding points, then run the command again.', }); }); it('reports a dotted source code as the finding code, and wraps an undotted one', async () => { expect(await emitFailure()).toHaveProperty('diagnostics', [ { - code: 'PSL.PRISMA7_VIEW_UNSUPPORTED', + code: 'PSL.FIXTURE_VIEW_UNSUPPORTED', severity: 'error', summary: 'prisma/schema.prisma:9:1 View "ActiveUsers" is not supported; Prisma 8 has no views.', @@ -260,7 +273,7 @@ describe('executeContractEmit', () => { where: { path: 'prisma/schema.prisma', line: 9 }, }, { - code: 'PSL.PRISMA7_SCHEMA_READ_FAILED', + code: 'PSL.FIXTURE_SCHEMA_READ_FAILED', severity: 'error', summary: 'prisma/schema ENOENT', nextActions: [], @@ -282,11 +295,11 @@ describe('executeContractEmit', () => { diagnostics: sourceDiagnostics, issues: [ { - kind: 'PSL.PRISMA7_VIEW_UNSUPPORTED', + kind: 'PSL.FIXTURE_VIEW_UNSUPPORTED', message: 'View "ActiveUsers" is not supported; Prisma 8 has no views. (prisma/schema.prisma:9:1)', }, - { kind: 'PSL.PRISMA7_SCHEMA_READ_FAILED', message: 'ENOENT (prisma/schema)' }, + { kind: 'PSL.FIXTURE_SCHEMA_READ_FAILED', message: 'ENOENT (prisma/schema)' }, { kind: 'PSL_PARSE_ERROR', message: 'Unexpected token (prisma/models.prisma)' }, ], providerMeta: { schemaPath: 'prisma/schema.prisma' }, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/contract-print.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/contract-print.test.ts new file mode 100644 index 000000000000..f7a0e28cc206 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/contract-print.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import type { PrismaNextConfig } from '@internal/config/config-types'; +import { ok } from '@internal/utils/result'; +import { structuredError } from '@internal/utils/structured-error'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { executeContractEmit } from '../../src/control-api/operations/contract-emit'; +import { executeContractPrint } from '../../src/control-api/operations/contract-print'; + +const malformed = structuredError( + 'CONTRACT.VALIDATION_FAILED', + 'Contract structural validation failed: domain.namespaces.public.models.User.fields must be an object', + { meta: { path: 'domain.namespaces.public.models.User.fields' } }, +); + +const DESCRIPTOR = { version: '0.0.1', familyId: 'sql', targetId: 'postgres' }; + +function configWithMalformedContract(output: string) { + const familyInstance = { + deserializeContract: vi.fn(() => { + throw malformed; + }), + buildPslContract: vi.fn(), + }; + const config = { + family: { + kind: 'family', + id: 'sql', + familyId: 'sql', + version: '0.0.1', + emission: {}, + create: () => familyInstance, + }, + target: { + ...DESCRIPTOR, + kind: 'target', + id: 'postgres', + contractSerializer: { serializeContract: (contract: unknown) => contract }, + }, + adapter: { ...DESCRIPTOR, kind: 'adapter', id: 'postgres' }, + extensions: [], + contract: { + source: { + format: 'typescript', + load: async () => ok({ domain: { namespaces: {} }, capabilities: {}, extensions: {} }), + }, + output, + }, + } as unknown as PrismaNextConfig; + return { config, familyInstance }; +} + +describe('executeContractPrint', () => { + let dir = ''; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'contract-print-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('rejects a malformed contract with the error contract emit reports, before printing', async () => { + const { config, familyInstance } = configWithMalformedContract(join(dir, 'contract.json')); + const contractConfig = config.contract; + if (contractConfig === undefined) throw new Error('the config has a contract section'); + + await expect(executeContractEmit({ config, cwd: dir })).rejects.toBe(malformed); + await expect( + executeContractPrint({ config, contractConfig, description: 'printed' }), + ).rejects.toBe(malformed); + expect(familyInstance.buildPslContract).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/format.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/format.test.ts index ed8e8deffe22..7a4aa677b201 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/format.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/format.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type * as configLoader from '@internal/config-loader'; @@ -100,14 +100,14 @@ describe('executeFormat', () => { expect(await readFile(inputPath, 'utf-8')).toBe(original); }); - it('leaves an absent-format source untouched', async () => { + it('leaves a .prisma input of a typescript source untouched', async () => { const inputPath = join(tmpDir, 'schema.prisma'); await writeFile(inputPath, MESSY_PSL, 'utf-8'); const result = await executeFormat({ config: mockConfig({ contract: { - source: { inputs: [inputPath], load: () => {} }, + source: { format: 'typescript', inputs: [inputPath], load: () => {} }, output: join(tmpDir, 'contract.json'), }, }), @@ -157,6 +157,23 @@ describe('executeFormat', () => { expect(await readFile(b, 'utf-8')).toBe(FORMATTED_PSL); }); + it('formats every .prisma file under an input that is a directory', async () => { + const dir = join(tmpDir, 'schema'); + await mkdir(join(dir, 'nested'), { recursive: true }); + const a = join(dir, 'a.prisma'); + const b = join(dir, 'nested', 'b.prisma'); + await writeFile(a, MESSY_PSL, 'utf-8'); + await writeFile(b, MESSY_PSL, 'utf-8'); + await writeFile(join(dir, 'notes.txt'), 'model X{', 'utf-8'); + + const result = await executeFormat({ config: pslConfig(dir), cwd: tmpDir, eol: '\n' }); + + expect(result.ok && result.value).toEqual({ formatted: true, paths: [a, b] }); + expect(await readFile(a, 'utf-8')).toBe(FORMATTED_PSL); + expect(await readFile(b, 'utf-8')).toBe(FORMATTED_PSL); + expect(await readFile(join(dir, 'notes.txt'), 'utf-8')).toBe('model X{'); + }); + it('keeps formatting remaining members after one fails to read', async () => { const a = join(tmpDir, 'a.prisma'); const missing = join(tmpDir, 'missing.prisma'); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/load-contract-source.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/load-contract-source.test.ts index fccf98adc8ff..625b53dd27d1 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/load-contract-source.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/load-contract-source.test.ts @@ -4,10 +4,8 @@ import { tmpdir } from 'node:os'; import type * as configLoader from '@internal/config-loader'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - executeContractEmit, - loadContractSource, -} from '../../src/control-api/operations/contract-emit'; +import { executeContractEmit } from '../../src/control-api/operations/contract-emit'; +import { loadContractSource } from '../../src/control-api/operations/load-contract-source'; const VIEW_DIAGNOSTIC = { code: 'PSL.PRISMA7_VIEW_UNSUPPORTED', @@ -34,7 +32,7 @@ function configWithSource(output: string, load: () => Promise) { targetId: 'test-target', }, extensions: [], - contract: { source: { load }, output }, + contract: { source: { format: 'typescript', load }, output }, } as unknown as configLoader.PrismaNextConfig; } diff --git a/packages/1-framework/3-tooling/cli/test/control-api/testing/fixture-client.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/testing/fixture-client.test.ts index b773f94b0bd1..3eda8ff486bb 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/testing/fixture-client.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/testing/fixture-client.test.ts @@ -74,7 +74,10 @@ describe('createFixtureControlClient', () => { const emit = await client.emit({ contractConfig: { - source: { load: async () => notOk({ summary: '', diagnostics: [] }) }, + source: { + format: 'typescript', + load: async () => notOk({ summary: '', diagnostics: [] }), + }, output: 'contract.json', }, }); @@ -216,7 +219,10 @@ describe('createFixtureControlClient', () => { expect(client.toOperationPreview([])).toBeUndefined(); const emit = await client.emit({ contractConfig: { - source: { load: async () => notOk({ summary: '', diagnostics: [] }) }, + source: { + format: 'typescript', + load: async () => notOk({ summary: '', diagnostics: [] }), + }, output: 'contract.json', }, }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-infer.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-infer.test.ts index d3a574947aad..c009ac9839a3 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/contract-infer.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-infer.test.ts @@ -122,6 +122,20 @@ describe('contract infer', () => { }); }); + it('describes the written file as inferred from the database', async () => { + const dir = await projectDir(); + + await harness(ormConfig(dir)).run(['contract', 'infer', '--json'], { cwd: dir }); + + expect(mocks.printPsl).toHaveBeenCalledWith( + { kind: 'psl-document' }, + expect.objectContaining({ + description: + 'Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.', + }), + ); + }); + it('writes the printed PSL beside the emitted contract', async () => { const dir = await projectDir(); diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-print-support.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-print-support.ts new file mode 100644 index 000000000000..0ec7411332d7 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-print-support.ts @@ -0,0 +1,128 @@ +import { rm } from 'node:fs/promises'; +import { ok } from '@internal/utils/result'; +import type { ErroredEnvelope, MountedTree, StreamEvent } from '@prisma/cli-engine'; +import { createTestCli } from '@prisma/cli-engine/testing'; +import { join } from 'pathe'; +import { afterEach, beforeEach, type Mock, vi } from 'vitest'; +import { BIN_GROUPS } from '../../src/orm/cli'; +import { createContractPrintCommand } from '../../src/orm/contract/print'; +import { createTestProjectDir } from '../utils/test-project-dir'; + +export const PSL = 'model User {\n id Int @id\n}\n'; + +/** + * The command is mounted from the factory with a printer double injected, and + * the config's family creates an instance whose `buildPslContract` is a + * double, so no module mocking is involved and the doubles are scoped to the + * test files that use them. + */ +export const mocks: Readonly< + Record< + 'createFamilyInstance' | 'deserializeContract' | 'buildPslContract' | 'printPsl' | 'load', + Mock + > +> = { + createFamilyInstance: vi.fn(), + deserializeContract: vi.fn(), + buildPslContract: vi.fn(), + printPsl: vi.fn(), + load: vi.fn(), +}; + +const commands: MountedTree = { + 'contract print': createContractPrintCommand({ printPsl: mocks.printPsl }), +}; +const groups = BIN_GROUPS; + +const dirs: string[] = []; + +export async function projectDir(): Promise { + const dir = createTestProjectDir('orm-print'); + dirs.push(dir); + return dir; +} + +/** The contract the family instance returns once it has validated the loaded one. */ +export const VALIDATED_CONTRACT = { roots: {}, domain: {}, validated: true }; + +const DESCRIPTOR = { + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + create: () => ({}), +}; + +export function ormConfig( + dir: string, + overrides: Record = {}, +): Record { + return { + family: { + kind: 'family', + id: 'sql', + familyId: 'sql', + version: '1.0.0', + emission: {}, + create: mocks.createFamilyInstance, + }, + target: { + ...DESCRIPTOR, + kind: 'target', + id: 'postgres', + contractSerializer: { serializeContract: (contract: unknown) => contract }, + }, + adapter: { ...DESCRIPTOR, kind: 'adapter', id: 'pg' }, + driver: { ...DESCRIPTOR, kind: 'driver', id: 'pg-driver' }, + contract: { + source: { format: 'psl', inputs: ['./prisma/schema.prisma'], load: mocks.load }, + output: join(dir, 'generated', 'contract.json'), + }, + ...overrides, + }; +} + +export function harness(config: Record) { + return createTestCli({ commands, groups, config: { orm: config } }); +} + +/** A CLI whose `prisma.config.ts` sits in `configDir`, whatever directory a run is invoked from. */ +export function harnessWithConfigIn(configDir: string, config: Record) { + return createTestCli({ + commands, + groups, + loadConfig: async () => ({ + files: [{ path: join(configDir, 'prisma.config.ts'), sections: { orm: config } }], + diagnostics: [], + }), + }); +} + +export function erroredEnvelope(run: { readonly json: readonly StreamEvent[] }): ErroredEnvelope { + const terminal = run.json.at(-1); + if (terminal === undefined || terminal.kind !== 'result' || terminal.envelope.ok) { + throw new Error('the run did not settle as an errored envelope'); + } + return terminal.envelope; +} + +/** Resets the doubles before each test and removes the project directories after it. */ +export function useContractPrintDoubles(): void { + afterEach(async () => { + for (const dir of dirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + mocks.buildPslContract + .mockReset() + .mockReturnValue({ document: { kind: 'psl-document' }, sourceSettings: {} }); + mocks.deserializeContract.mockReset().mockReturnValue(VALIDATED_CONTRACT); + mocks.createFamilyInstance.mockReset().mockReturnValue({ + deserializeContract: mocks.deserializeContract, + buildPslContract: mocks.buildPslContract, + }); + mocks.printPsl.mockReset().mockReturnValue(PSL); + mocks.load.mockReset().mockResolvedValue(ok({ roots: {}, domain: {} })); + }); +} diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-print.output-path.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-print.output-path.test.ts new file mode 100644 index 000000000000..a5de9b961326 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-print.output-path.test.ts @@ -0,0 +1,230 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readdir, readFile, symlink, writeFile } from 'node:fs/promises'; +import { join } from 'pathe'; +import { describe, expect, it } from 'vitest'; +import { + erroredEnvelope, + harness, + harnessWithConfigIn, + mocks, + ormConfig, + PSL, + projectDir, + useContractPrintDoubles, +} from './contract-print-support'; + +useContractPrintDoubles(); + +describe('contract print output path', () => { + it('refuses to write over the schema it reads and leaves that file untouched', async () => { + const dir = await projectDir(); + const schema = 'model User {\n id Int @id\n}\n'; + await mkdir(join(dir, 'prisma'), { recursive: true }); + await writeFile(join(dir, 'prisma', 'schema.prisma'), schema, 'utf-8'); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'prisma/schema.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.PRINT_OUTPUT_IS_SOURCE', + meta: { output: 'prisma/schema.prisma', source: 'prisma/schema.prisma' }, + }); + expect(await readFile(join(dir, 'prisma', 'schema.prisma'), 'utf-8')).toBe(schema); + expect(mocks.load).not.toHaveBeenCalled(); + }); + + it('leaves the source untouched when the output path differs from it only in case', async () => { + const dir = await projectDir(); + const schema = 'model User {\n id Int @id\n}\n'; + await mkdir(join(dir, 'prisma'), { recursive: true }); + await writeFile(join(dir, 'prisma', 'schema.prisma'), schema, 'utf-8'); + const volumeIgnoresCase = existsSync(join(dir, 'PRISMA', 'SCHEMA.PRISMA')); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'prisma/Schema.prisma', '--json'], + { cwd: dir }, + ); + + expect(await readFile(join(dir, 'prisma', 'schema.prisma'), 'utf-8')).toBe(schema); + if (volumeIgnoresCase) { + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.PRINT_OUTPUT_IS_SOURCE', + meta: { output: 'prisma/Schema.prisma', source: 'prisma/schema.prisma' }, + }); + } else { + expect(run.exitCode).toBe(0); + expect(await readFile(join(dir, 'prisma', 'Schema.prisma'), 'utf-8')).toBe(PSL); + } + }); + + it('refuses an output path that reaches the source through a symbolic link', async () => { + const dir = await projectDir(); + const schema = 'model User {\n id Int @id\n}\n'; + await mkdir(join(dir, 'prisma'), { recursive: true }); + await writeFile(join(dir, 'prisma', 'schema.prisma'), schema, 'utf-8'); + await symlink(join(dir, 'prisma'), join(dir, 'linked'), 'dir'); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'linked/schema.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.PRINT_OUTPUT_IS_SOURCE', + meta: { output: 'linked/schema.prisma', source: 'prisma/schema.prisma' }, + }); + expect(await readFile(join(dir, 'prisma', 'schema.prisma'), 'utf-8')).toBe(schema); + }); + + it('refuses to write over a file a glob input of the contract source matches', async () => { + const dir = await projectDir(); + const schema = 'model User {\n id Int @id\n}\n'; + await mkdir(join(dir, 'prisma', 'models'), { recursive: true }); + await writeFile(join(dir, 'prisma', 'models', 'user.prisma'), schema, 'utf-8'); + const config = ormConfig(dir, { + contract: { + source: { format: 'psl', inputs: ['./prisma/**/*.prisma'], load: mocks.load }, + output: join(dir, 'generated', 'contract.json'), + }, + }); + + const run = await harness(config).run( + ['contract', 'print', '--output', 'prisma/models/user.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.PRINT_OUTPUT_IS_SOURCE', + meta: { output: 'prisma/models/user.prisma', source: 'prisma/models/user.prisma' }, + }); + expect(await readFile(join(dir, 'prisma', 'models', 'user.prisma'), 'utf-8')).toBe(schema); + }); + + it('refuses a new file that a glob input of the contract source would match', async () => { + const dir = await projectDir(); + await mkdir(join(dir, 'prisma', 'models'), { recursive: true }); + await writeFile(join(dir, 'prisma', 'models', 'user.prisma'), 'model User {}\n', 'utf-8'); + const config = ormConfig(dir, { + contract: { + source: { format: 'psl', inputs: ['./prisma/**/*.prisma'], load: mocks.load }, + output: join(dir, 'generated', 'contract.json'), + }, + }); + + const run = await harness(config).run( + ['contract', 'print', '--output', 'prisma/contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.PRINT_OUTPUT_IS_SOURCE', + why: 'The contract source reads every file that matches prisma/**/*.prisma. Once written, prisma/contract.prisma would match, so contract emit would read the printed file together with the source files.', + meta: { output: 'prisma/contract.prisma', source: 'prisma/**/*.prisma' }, + }); + expect(await readdir(join(dir, 'prisma'))).toEqual(['models']); + expect(mocks.load).not.toHaveBeenCalled(); + }); + + it('writes a new file that no glob input of the contract source would match', async () => { + const dir = await projectDir(); + const config = ormConfig(dir, { + contract: { + source: { format: 'psl', inputs: ['./prisma/**/*.prisma'], load: mocks.load }, + output: join(dir, 'generated', 'contract.json'), + }, + }); + + const run = await harness(config).run( + ['contract', 'print', '--output', 'printed/contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(0); + expect(await readFile(join(dir, 'printed', 'contract.prisma'), 'utf-8')).toBe(PSL); + }); + + it('refuses to write over the config file in the directory of the config, from a directory below it', async () => { + const dir = await projectDir(); + const configText = 'export default {};\n'; + await mkdir(join(dir, 'src'), { recursive: true }); + await writeFile(join(dir, 'prisma.config.ts'), configText, 'utf-8'); + + const run = await harnessWithConfigIn(dir, ormConfig(dir)).run( + ['contract', 'print', '--output', '../prisma.config.ts', '--json'], + { cwd: join(dir, 'src') }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE', + meta: { output: '../prisma.config.ts', file: '../prisma.config.ts' }, + }); + expect(await readFile(join(dir, 'prisma.config.ts'), 'utf-8')).toBe(configText); + }); + + it('refuses to write over the config file', async () => { + const dir = await projectDir(); + const configText = 'export default {};\n'; + await writeFile(join(dir, 'prisma.config.ts'), configText, 'utf-8'); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'prisma.config.ts', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE', + summary: 'contract print would write over the config file', + meta: { output: 'prisma.config.ts', file: 'prisma.config.ts' }, + }); + expect(await readFile(join(dir, 'prisma.config.ts'), 'utf-8')).toBe(configText); + expect(mocks.load).not.toHaveBeenCalled(); + }); + + it.each(['generated/contract.json', 'generated/contract.d.ts'])( + 'refuses to write over the emitted contract file %s', + async (output) => { + const dir = await projectDir(); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', output, '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE', + summary: 'contract print would write over an emitted contract file', + meta: { output, file: output }, + }); + expect(await readdir(dir)).not.toContain('generated'); + }, + ); + + it('refuses an output path inside a directory the contract source reads', async () => { + const dir = await projectDir(); + const config = ormConfig(dir, { + contract: { + source: { format: 'psl', inputs: ['./prisma'], load: mocks.load }, + output: join(dir, 'generated', 'contract.json'), + }, + }); + + const run = await harness(config).run( + ['contract', 'print', '--output', 'prisma/nested/contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.PRINT_OUTPUT_IS_SOURCE' }); + expect(await readdir(dir)).not.toContain('prisma'); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-print.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-print.test.ts new file mode 100644 index 000000000000..671d078e0b83 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-print.test.ts @@ -0,0 +1,461 @@ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { ok } from '@internal/utils/result'; +import { structuredError } from '@internal/utils/structured-error'; +import { join } from 'pathe'; +import stripAnsi from 'strip-ansi'; +import { describe, expect, it } from 'vitest'; +import { + erroredEnvelope, + harness, + harnessWithConfigIn, + mocks, + ormConfig, + PSL, + projectDir, + useContractPrintDoubles, + VALIDATED_CONTRACT, +} from './contract-print-support'; + +useContractPrintDoubles(); + +describe('contract print', () => { + it('shows the contract in the terminal and writes no file when no --output is given', async () => { + const dir = await projectDir(); + + const run = await harness(ormConfig(dir)).run(['contract', 'print'], { + cwd: dir, + isTty: { stdout: true, stderr: true }, + }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.presentation.human).toEqual([ + { kind: 'summary', status: 'ok', text: [{ text: 'Contract printed as Prisma 8 PSL' }] }, + { kind: 'drawing', lines: ['model User {', ' id Int @id', '}'] }, + ]); + expect(stripAnsi(run.stderr)).toContain('model User {'); + expect(run.presented?.presentation.next).toEqual([ + { + kind: 'user-choice', + label: + 'Write the PSL to a file with --output , then point contract in prisma.config.ts at that file', + }, + { kind: 'run-command', label: 'Emit the printed contract', command: '{bin} contract emit' }, + ]); + expect(await readdir(dir)).toEqual([]); + }); + + it('pipes the PSL alone to standard output with --format human', async () => { + const dir = await projectDir(); + + const run = await harness(ormConfig(dir)).run(['contract', 'print', '--format', 'human'], { + cwd: dir, + isTty: { stdout: false, stderr: true }, + }); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toBe(PSL); + expect(await readdir(dir)).toEqual([]); + }); + + it('carries the printed text in the JSON result when no --output is given', async () => { + const dir = await projectDir(); + + const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ + ok: true, + summary: 'Contract printed successfully', + target: { familyId: 'sql', id: 'postgres' }, + psl: { text: PSL }, + source: ['prisma/schema.prisma'], + sourceSettings: {}, + timings: { total: expect.any(Number) }, + }); + expect(await readdir(dir)).toEqual([]); + }); + + it('settles as a completed envelope carrying the written path and the schema it read', async () => { + const dir = await projectDir(); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'generated/contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ + ok: true, + summary: 'Contract printed successfully', + target: { familyId: 'sql', id: 'postgres' }, + psl: { path: 'generated/contract.prisma' }, + source: ['prisma/schema.prisma'], + sourceSettings: {}, + timings: { total: expect.any(Number) }, + }); + }); + + it('describes the written file as printed from the source it read', async () => { + const dir = await projectDir(); + + await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'generated/contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(mocks.printPsl).toHaveBeenCalledWith( + { kind: 'psl-document' }, + expect.objectContaining({ + description: 'Printed from prisma/schema.prisma by `prisma contract print`.', + }), + ); + expect(await readFile(join(dir, 'generated', 'contract.prisma'), 'utf-8')).toBe(PSL); + }); + + it('loads the source, creates the family instance and renders the text with one control stack', async () => { + const dir = await projectDir(); + + await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); + + const [stack] = mocks.createFamilyInstance.mock.calls[0] ?? []; + const [sourceContext] = mocks.load.mock.calls[0] ?? []; + const [, printOptions] = mocks.printPsl.mock.calls[0] ?? []; + expect(sourceContext.codecLookup).toBe(stack.codecLookup); + expect(sourceContext.authoringContributions).toBe(stack.authoringContributions); + expect(printOptions.codecLookup).toBe(stack.codecLookup); + expect(printOptions.pslBlockDescriptors).toBe(stack.authoringContributions.pslBlockDescriptors); + expect(mocks.buildPslContract).toHaveBeenCalledWith(VALIDATED_CONTRACT); + }); + + it('publishes through a staged rename, leaving no temporary file behind', async () => { + const dir = await projectDir(); + + await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'generated/contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(await readdir(join(dir, 'generated'))).toEqual(['contract.prisma']); + }); + + it('writes nothing when the run is cancelled while the source loads', async () => { + const dir = await projectDir(); + const controller = new AbortController(); + mocks.load.mockImplementation(async () => { + controller.abort(); + return ok({ roots: {}, domain: {} }); + }); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'generated/contract.prisma', '--json'], + { + cwd: dir, + abort: controller.signal, + }, + ); + + expect(run.exitCode).not.toBe(0); + expect(await readdir(dir)).not.toContain('generated'); + }); + + it('resolves a relative --output against the invocation directory', async () => { + const dir = await projectDir(); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'schema/live.prisma', '--json'], + { cwd: dir }, + ); + + expect(await readFile(join(dir, 'schema', 'live.prisma'), 'utf-8')).toBe(PSL); + expect(run.presented?.data).toMatchObject({ psl: { path: 'schema/live.prisma' } }); + }); + + it('overwrites an existing contract with a warning and no prompt', async () => { + const dir = await projectDir(); + const run1 = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'contract.prisma', '--json'], + { cwd: dir }, + ); + await writeFile(join(dir, 'contract.prisma'), 'model Stale {}\n', 'utf-8'); + + const run2 = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(run1.events).not.toContainEqual(expect.objectContaining({ severity: 'warn' })); + expect(run2.exitCode).toBe(0); + expect(run2.events).toContainEqual({ + kind: 'message', + severity: 'warn', + text: 'Overwriting existing file: contract.prisma', + }); + expect(await readFile(join(dir, 'contract.prisma'), 'utf-8')).toBe(PSL); + }); + + it('ships the written path and the next step as blocks', async () => { + const dir = await projectDir(); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'generated/contract.prisma'], + { cwd: dir, isTty: { stdout: true, stderr: true } }, + ); + + expect(run.presented?.presentation.human).toEqual([ + { + kind: 'summary', + status: 'ok', + text: [ + { text: 'Contract written to ' }, + { text: 'generated/contract.prisma', tone: 'identifier' }, + ], + }, + ]); + expect(run.presented?.presentation.next).toEqual([ + { + kind: 'user-choice', + label: 'Point contract in prisma.config.ts at generated/contract.prisma', + }, + { kind: 'run-command', label: 'Emit the printed contract', command: '{bin} contract emit' }, + ]); + expect(run.presented?.presentation.stdout).toEqual([]); + expect(stripAnsi(run.stderr)).toContain('Contract written to generated/contract.prisma'); + expect(run.stdout).toBe(''); + }); + + it('warns that the config must set the default control policy, because a PSL file cannot carry it', async () => { + const dir = await projectDir(); + mocks.buildPslContract.mockReturnValue({ + document: { kind: 'psl-document' }, + sourceSettings: { defaultControlPolicy: 'external' }, + }); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'generated/contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.events).toContainEqual({ + kind: 'message', + severity: 'warn', + text: "The contract's default control policy is 'external', and a PSL file cannot carry it. Set defaultControlPolicy: 'external' on the PSL source in prisma.config.ts. Without it, the emitted contract has no default control policy, and everything that sets no control policy of its own is treated as managed.", + }); + expect(run.presented?.data).toMatchObject({ + sourceSettings: { defaultControlPolicy: 'external' }, + }); + expect(run.presented?.data).not.toHaveProperty('defaultControlPolicy'); + expect(run.presented?.presentation.next).toEqual([ + { + kind: 'user-choice', + label: + "Point contract in prisma.config.ts at generated/contract.prisma, through a PSL source that sets defaultControlPolicy: 'external'", + }, + { kind: 'run-command', label: 'Emit the printed contract', command: '{bin} contract emit' }, + ]); + }); + + it('reports each warning the contract source reports, with its location', async () => { + const dir = await projectDir(); + mocks.load.mockImplementation( + async (context: { reportWarning?: (diagnostic: unknown) => void }) => { + context.reportWarning?.({ + code: 'PSL_DEPRECATED_SCALAR_NAME', + message: 'Scalar type "Int" is deprecated; use "Int32".', + sourceId: 'prisma/schema.prisma', + span: { + start: { offset: 30, line: 3, column: 9 }, + end: { offset: 33, line: 3, column: 12 }, + }, + severity: 'warning', + }); + return ok({ roots: {}, domain: {} }); + }, + ); + + const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(0); + expect(run.events).toContainEqual({ + kind: 'message', + severity: 'warn', + text: 'warning prisma/schema.prisma:3:9 PSL_DEPRECATED_SCALAR_NAME Scalar type "Int" is deprecated; use "Int32".', + }); + }); + + it('prints no warning when the contract has no default control policy', async () => { + const dir = await projectDir(); + + const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); + + expect(run.events).not.toContainEqual(expect.objectContaining({ severity: 'warn' })); + }); + + it('says where contract emit writes after the switch when the printed file names other emitted files', async () => { + const dir = await projectDir(); + const config = ormConfig(dir, { + contract: { + source: { format: 'psl', inputs: ['./prisma/schema.prisma'], load: mocks.load }, + output: join(dir, 'prisma', 'schema.json'), + }, + }); + + const run = await harness(config).run( + ['contract', 'print', '--output', 'prisma/contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.presentation.next).toEqual([ + { + kind: 'user-choice', + label: 'Point contract in prisma.config.ts at prisma/contract.prisma', + }, + { + kind: 'user-choice', + label: + "With contract: './prisma/contract.prisma' and no output in prisma.config.ts, contract emit writes prisma/contract.json and prisma/contract.d.ts, not prisma/schema.json and prisma/schema.d.ts", + }, + { kind: 'run-command', label: 'Emit the printed contract', command: '{bin} contract emit' }, + ]); + }); + + it('names the next-step paths relative to the directory of the config, not the invocation directory', async () => { + const dir = await projectDir(); + await mkdir(join(dir, 'src'), { recursive: true }); + const config = ormConfig(dir, { + contract: { + source: { format: 'psl', inputs: ['./prisma/schema.prisma'], load: mocks.load }, + output: join(dir, 'prisma', 'schema.json'), + }, + }); + + const run = await harnessWithConfigIn(dir, config).run( + ['contract', 'print', '--output', 'prisma/contract.prisma', '--json'], + { cwd: join(dir, 'src') }, + ); + + expect(run.exitCode).toBe(0); + expect(await readFile(join(dir, 'src', 'prisma', 'contract.prisma'), 'utf-8')).toBe(PSL); + expect(run.presented?.data).toMatchObject({ psl: { path: 'prisma/contract.prisma' } }); + expect(run.presented?.presentation.next).toEqual([ + { + kind: 'user-choice', + label: 'Point contract in prisma.config.ts at src/prisma/contract.prisma', + }, + { + kind: 'user-choice', + label: + "With contract: './src/prisma/contract.prisma' and no output in prisma.config.ts, contract emit writes src/prisma/contract.json and src/prisma/contract.d.ts, not prisma/schema.json and prisma/schema.d.ts", + }, + { kind: 'run-command', label: 'Emit the printed contract', command: '{bin} contract emit' }, + ]); + }); + + it('prints a PSL source the same way as any other source', async () => { + const dir = await projectDir(); + const config = ormConfig(dir, { + contract: { + source: { format: 'psl', inputs: ['./contract.prisma'], load: mocks.load }, + output: join(dir, 'generated', 'contract.json'), + }, + }); + + const run = await harness(config).run( + ['contract', 'print', '--output', 'generated/contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(0); + expect(mocks.load).toHaveBeenCalled(); + expect(mocks.printPsl).toHaveBeenCalledWith( + { kind: 'psl-document' }, + expect.objectContaining({ + description: 'Printed from contract.prisma by `prisma contract print`.', + }), + ); + expect(await readFile(join(dir, 'generated', 'contract.prisma'), 'utf-8')).toBe(PSL); + }); + + it('reports what the source reported when it cannot load the schema', async () => { + const dir = await projectDir(); + mocks.load.mockResolvedValue({ + ok: false, + failure: { + summary: 'Prisma 8 does not support views', + diagnostics: [{ code: 'PSL.FIXTURE_VIEW_UNSUPPORTED', message: 'a view is not a model' }], + }, + }); + + const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.SOURCE_LOAD_FAILED' }); + expect(await readdir(dir)).not.toContain('generated'); + }); + + it('errors when the family cannot print the contract as PSL', async () => { + const dir = await projectDir(); + mocks.createFamilyInstance.mockReturnValue({ deserializeContract: mocks.deserializeContract }); + + const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.PRINT_UNSUPPORTED' }); + expect(await readdir(dir)).not.toContain('generated'); + }); + + it('writes no file at the output path when the target refuses part of the contract', async () => { + const dir = await projectDir(); + mocks.buildPslContract.mockImplementation(() => { + throw structuredError( + 'CONTRACT.PRINT_UNSUPPORTED', + 'contract print: field "public".Shop.location has a union type, which cannot be written in Prisma 8 PSL.', + { + why: 'A PSL field names one scalar, enum, or value-object type; a union of types has no PSL form.', + fix: 'Give the field a single type.', + meta: { coordinate: '"public".Shop.location', kind: 'union' }, + }, + ); + }); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + summary: expect.stringContaining('"public".Shop.location'), + }); + expect(await readdir(dir)).not.toContain('contract.prisma'); + }); + + it('reports the code, summary and next actions of a refusal the target raised', async () => { + const dir = await projectDir(); + mocks.buildPslContract.mockImplementation(() => { + throw structuredError( + 'CONTRACT.PRINT_UNSUPPORTED', + 'contract print: field "public".Shop.location has a union type, which cannot be written in Prisma 8 PSL.', + { + why: 'A PSL field names one scalar, enum, or value-object type; a union of types has no PSL form.', + fix: 'Give the field a single type.', + meta: { coordinate: '"public".Shop.location', kind: 'union' }, + }, + ); + }); + + const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + summary: expect.stringContaining('"public".Shop.location'), + why: 'A PSL field names one scalar, enum, or value-object type; a union of types has no PSL form.', + nextActions: [{ kind: 'user-choice', label: 'Give the field a single type.' }], + meta: { coordinate: '"public".Shop.location', kind: 'union' }, + }); + expect(await readdir(dir)).not.toContain('generated'); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/init-prisma7-check.test.ts b/packages/1-framework/3-tooling/cli/test/orm/init-prisma7-check.test.ts index 56f38f06b9cd..c9e348d06584 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/init-prisma7-check.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/init-prisma7-check.test.ts @@ -102,6 +102,7 @@ async function targetConfigWith( function refusingSource(schemaPath: string) { return { source: { + format: 'psl', inputs: [schemaPath], load: async () => ({ ok: false, @@ -308,6 +309,7 @@ describe('the Prisma 7 check before init changes the project', () => { () => targetConfigWith((schemaPath) => ({ source: { + format: 'psl', inputs: [schemaPath], load: async () => { throw new Error('source exploded'); diff --git a/packages/1-framework/3-tooling/cli/test/orm/normalize-error.test.ts b/packages/1-framework/3-tooling/cli/test/orm/normalize-error.test.ts index b341f1b0109f..f1333f56ca5a 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/normalize-error.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/normalize-error.test.ts @@ -144,7 +144,7 @@ describe('normalizeError', () => { describe('a prisma/prisma error carrying accompanying findings', () => { const finding = { - code: 'PSL.PRISMA7_VIEW_UNSUPPORTED' as const, + code: 'PSL.FIXTURE_VIEW_UNSUPPORTED' as const, severity: 'error' as const, summary: 'schema.prisma:9:1 View "ActiveUsers" is not supported', nextActions: [], diff --git a/packages/1-framework/3-tooling/config-loader/src/expand-contract-inputs.ts b/packages/1-framework/3-tooling/config-loader/src/expand-contract-inputs.ts index af59ff0a3968..e047c780defb 100644 --- a/packages/1-framework/3-tooling/config-loader/src/expand-contract-inputs.ts +++ b/packages/1-framework/3-tooling/config-loader/src/expand-contract-inputs.ts @@ -1,4 +1,4 @@ -import { resolve } from 'pathe'; +import { matchesGlob, resolve } from 'pathe'; import { glob, isDynamicPattern } from 'tinyglobby'; /** @@ -34,3 +34,14 @@ export async function expandContractInputs( const canonical = new Set([...literals, ...globMatches].map((entry) => resolve(entry))); return Array.from(canonical).sort(); } + +/** + * The first glob entry of `patterns` that `path` matches, whether or not a + * file exists at `path`. Both must be absolute. + */ +export function globContractInputMatching( + patterns: readonly string[], + path: string, +): string | undefined { + return patterns.find((pattern) => isDynamicPattern(pattern) && matchesGlob(path, pattern)); +} diff --git a/packages/1-framework/3-tooling/config-loader/src/exports/index.ts b/packages/1-framework/3-tooling/config-loader/src/exports/index.ts index b7c6c007d9fc..a45ee4656385 100644 --- a/packages/1-framework/3-tooling/config-loader/src/exports/index.ts +++ b/packages/1-framework/3-tooling/config-loader/src/exports/index.ts @@ -1,5 +1,5 @@ export type { PrismaNextConfig } from '@internal/config/config-types'; -export { expandContractInputs } from '../expand-contract-inputs'; +export { expandContractInputs, globContractInputMatching } from '../expand-contract-inputs'; export type { ConfigFile, ConfigFiles, LoadedConfig } from '../load'; export { buildLoadedConfig, diff --git a/packages/1-framework/3-tooling/config-loader/src/orm-section.ts b/packages/1-framework/3-tooling/config-loader/src/orm-section.ts index dc0107a08da0..5846181df85f 100644 --- a/packages/1-framework/3-tooling/config-loader/src/orm-section.ts +++ b/packages/1-framework/3-tooling/config-loader/src/orm-section.ts @@ -61,13 +61,14 @@ const targetLikeFields = { ...descriptorFields, targetId: 'string' } as const; /** * The contract source provider: `load` closes over the authored contract, - * and `inputs` are paths the schema resolves against the config file. Other - * keys a provider carries pass through. + * `inputs` are paths the schema resolves against the config file, and + * `format` names the language the inputs are written in. Other keys a + * provider carries pass through. */ const contractSource = { load: 'Function', 'inputs?': 'path[]', - 'format?': 'string', + format: "'psl' | 'typescript'", } as const; /** Each subsection's own shape, without the rules that relate subsections to one another. */ diff --git a/packages/1-framework/3-tooling/config-loader/test/build-loaded-config.test.ts b/packages/1-framework/3-tooling/config-loader/test/build-loaded-config.test.ts index 6631e1dc0efb..082bcc343a98 100644 --- a/packages/1-framework/3-tooling/config-loader/test/build-loaded-config.test.ts +++ b/packages/1-framework/3-tooling/config-loader/test/build-loaded-config.test.ts @@ -12,7 +12,12 @@ function sectionsWithDiagnostics(raw: Record): readonly unknown describe('buildLoadedConfig', () => { it('resolves the contract paths of an orm section against the directory', () => { const { config } = buildLoadedConfig( - { contract: { source: { load, inputs: ['prisma/schema.prisma'] }, output: 'src/prisma' } }, + { + contract: { + source: { format: 'psl', load, inputs: ['prisma/schema.prisma'] }, + output: 'src/prisma', + }, + }, '/project', ); @@ -39,7 +44,9 @@ describe('buildLoadedConfig', () => { }); it('tags a diagnostic with the section it concerns', () => { - expect(sectionsWithDiagnostics({ contract: { source: { load } } })).not.toContain('contract'); + expect( + sectionsWithDiagnostics({ contract: { source: { format: 'typescript', load } } }), + ).not.toContain('contract'); expect(sectionsWithDiagnostics({ contract: 'prisma/schema.prisma' })).toContain('contract'); expect(sectionsWithDiagnostics({})).toContain('family'); }); diff --git a/packages/1-framework/3-tooling/config-loader/test/expand-contract-inputs.test.ts b/packages/1-framework/3-tooling/config-loader/test/expand-contract-inputs.test.ts index af047b825e9c..2476b2f77676 100644 --- a/packages/1-framework/3-tooling/config-loader/test/expand-contract-inputs.test.ts +++ b/packages/1-framework/3-tooling/config-loader/test/expand-contract-inputs.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; -import { expandContractInputs } from '../src/expand-contract-inputs'; +import { expandContractInputs, globContractInputMatching } from '../src/expand-contract-inputs'; describe('expandContractInputs', () => { const tempDirs: string[] = []; @@ -104,3 +104,29 @@ describe('expandContractInputs', () => { expect(result).toEqual([first, second]); }); }); + +describe('globContractInputMatching', () => { + it('returns the glob input a path that does not exist yet would match', () => { + expect( + globContractInputMatching( + ['/app/prisma/schema.prisma', '/app/prisma/**/*.prisma'], + '/app/prisma/contract.prisma', + ), + ).toBe('/app/prisma/**/*.prisma'); + }); + + it('returns nothing for a path no glob input matches', () => { + expect( + globContractInputMatching(['/app/prisma/**/*.prisma'], '/app/printed/contract.prisma'), + ).toBeUndefined(); + expect( + globContractInputMatching(['/app/prisma/**/*.prisma'], '/app/prisma/contract.json'), + ).toBeUndefined(); + }); + + it('does not treat an input with no wildcard as a glob', () => { + expect( + globContractInputMatching(['/app/prisma/contract.prisma'], '/app/prisma/contract.prisma'), + ).toBeUndefined(); + }); +}); diff --git a/packages/1-framework/3-tooling/config-loader/test/load.test.ts b/packages/1-framework/3-tooling/config-loader/test/load.test.ts index 6c38a8861ab7..3fb9016bc6cc 100644 --- a/packages/1-framework/3-tooling/config-loader/test/load.test.ts +++ b/packages/1-framework/3-tooling/config-loader/test/load.test.ts @@ -53,6 +53,7 @@ const config = { }, contract: { source: { + format: 'psl', inputs: ['./schema.prisma'], load: async () => ({ ok: true, value: { targetFamily: 'sql' } }), }, @@ -338,6 +339,7 @@ describe('loadConfig', () => { const noContractSource = VALID_CONFIG_SOURCE.replace( ` contract: { source: { + format: 'psl', inputs: ['./schema.prisma'], load: async () => ({ ok: true, value: { targetFamily: 'sql' } }), }, diff --git a/packages/1-framework/3-tooling/config-loader/test/orm-section.test.ts b/packages/1-framework/3-tooling/config-loader/test/orm-section.test.ts index 57baf3cf6564..01440742fc9a 100644 --- a/packages/1-framework/3-tooling/config-loader/test/orm-section.test.ts +++ b/packages/1-framework/3-tooling/config-loader/test/orm-section.test.ts @@ -48,7 +48,7 @@ describe('the orm section', () => { }); it('supplies the default contract output next to the default source directory', () => { - const raw = validRaw({ contract: { source: { load: () => ({}) } } }); + const raw = validRaw({ contract: { source: { format: 'typescript', load: () => ({}) } } }); const result = validateOrmSection(raw, provenanceFor(raw)); @@ -90,7 +90,11 @@ describe('the orm section', () => { it('resolves glob pattern inputs against the config file and keeps the pattern', () => { const raw = validRaw({ contract: { - source: { load: () => ({}), inputs: ['./prisma/**/*.prisma', './extra.prisma'] }, + source: { + format: 'psl', + load: () => ({}), + inputs: ['./prisma/**/*.prisma', './extra.prisma'], + }, }, }); @@ -184,11 +188,22 @@ describe('the orm section', () => { it('reports contract problems under contract fields', () => { expect( - fields(validRaw({ contract: { source: { inputs: ['a', 1], load: () => ({}) }, output: 3 } })), + fields( + validRaw({ + contract: { source: { format: 'psl', inputs: ['a', 1], load: () => ({}) }, output: 3 }, + }), + ), ).toEqual(['contract.source.inputs.1', 'contract.output']); expect(fields(validRaw({ contract: {} }))).toEqual(['contract.source']); }); + it.each([ + ['no format', { load: () => ({}) }], + ['a format other than psl or typescript', { format: 'prisma7', load: () => ({}) }], + ])('reports a contract source with %s', (_label, source) => { + expect(fields(validRaw({ contract: { source } }))).toEqual(['contract.source.format']); + }); + it('reports migrations and formatter problems', () => { expect(fields(validRaw({ migrations: { dir: 42 } }))).toEqual(['migrations.dir']); expect(fields(validRaw({ formatter: { indent: 0, newline: 'CR' } }))).toEqual([ @@ -232,7 +247,7 @@ describe('the orm section', () => { it('keeps fields the schema does not name, on descriptors and on the contract source', () => { const raw = validRaw({ family: { ...(validRaw()['family'] as object), manifest: { note: 1 } }, - contract: { source: { load: () => ({}), dialect: 'sql' } }, + contract: { source: { format: 'typescript', load: () => ({}), dialect: 'sql' } }, }); const result = validateOrmSection(raw, provenanceFor(raw)); diff --git a/packages/1-framework/3-tooling/emitter/src/model-types-emission.ts b/packages/1-framework/3-tooling/emitter/src/model-types-emission.ts index 05a82661cf95..97897c58704c 100644 --- a/packages/1-framework/3-tooling/emitter/src/model-types-emission.ts +++ b/packages/1-framework/3-tooling/emitter/src/model-types-emission.ts @@ -6,6 +6,7 @@ import type { CrossReference, } from '@internal/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { UNBOUND_PSL_NAMESPACE_NAME } from '@internal/framework-components/psl-ast'; import { type ModelFieldTypeResolvers, resolveModelFieldType, @@ -45,7 +46,7 @@ type ModelIndex = { }; function namespacedMemberName(namespaceId: string, modelName: string): string { - const segment = namespaceId === UNBOUND_NAMESPACE_ID ? 'unbound' : namespaceId; + const segment = namespaceId === UNBOUND_NAMESPACE_ID ? UNBOUND_PSL_NAMESPACE_NAME : namespaceId; return `${segment}_${modelName}`; } diff --git a/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts b/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts index 750b5dd3edda..a2263e974d1e 100644 --- a/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts +++ b/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts @@ -95,6 +95,7 @@ function createLoadedConfig({ return { contract: { source: { + format: 'typescript', ...(inputs === undefined ? {} : { inputs: inputs.map((input) => toAbsolutePath(input)) as SourceInputs }), diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/README.md b/packages/2-mongo-family/2-authoring/contract-prisma6/README.md index 60afaf41a29c..0e826580b248 100644 --- a/packages/2-mongo-family/2-authoring/contract-prisma6/README.md +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/README.md @@ -4,7 +4,7 @@ Reads a Prisma 6 MongoDB `schema.prisma` as a Prisma 8 contract source. During t ## Responsibilities -- `prisma6Contract(path, options)` returns a `ContractConfig` (format `prisma6`) whose `source.load` reads the input and parses every `.prisma` file with `@internal/psl-parser` (the `prisma7` grammar, which reads Prisma 6 schemas; no `// use prisma-8` directive is needed). A file input reads that file; a directory input reads every regular `.prisma` file under it, nested directories and symbolic links included, sorted by path. The default `output` is `contract.json` beside the input; `options.output` overrides it. +- `prisma6Contract(path, options)` returns a `ContractConfig` (format `psl`, because a Prisma 6 schema is PSL text) whose `source.load` reads the input and parses every `.prisma` file with `@internal/psl-parser` (no `// use prisma-8` directive is needed). A file input reads that file; a directory input reads every regular `.prisma` file under it, nested directories and symbolic links included, sorted by path. The default `output` is `contract.json` beside the input; `options.output` overrides it. - The reader builds the Mongo contract directly from the family's building blocks (`buildMongoNamespace`, `MongoStorage`, `MongoIndex`, `buildMongoExecutionSection`) and checks it as `contract emit` does. It never rewrites the schema into Prisma 8 PSL. Every construct it does not support is a diagnostic with a span; nothing is changed silently, and a schema with any diagnostic gives no contract. - The contract carries no `$jsonSchema` validators: Prisma 6 databases have none, and verify reports a declared validator that the database lacks. - `Prisma6TargetBinding` (`src/target-binding.ts`) declares the target facts: the datasource providers, the codec for each Prisma 6 scalar, the ObjectId codec, and the timestamp generator. The Mongo target's binding is `prisma6MongoBinding` in `@internal/target-mongo/prisma6-binding`. diff --git a/packages/2-mongo-family/2-authoring/contract-prisma6/src/provider.ts b/packages/2-mongo-family/2-authoring/contract-prisma6/src/provider.ts index f89e15cc8614..eae5676ddf09 100644 --- a/packages/2-mongo-family/2-authoring/contract-prisma6/src/provider.ts +++ b/packages/2-mongo-family/2-authoring/contract-prisma6/src/provider.ts @@ -83,7 +83,7 @@ export function prisma6Contract( ): ContractConfig { return { source: { - format: 'prisma6', + format: 'psl', inputs: [schemaPath], async load(context) { const [absolutePath] = context.resolvedInputs; @@ -120,9 +120,7 @@ export function prisma6Contract( cause: message, }); } - const { document, sources, diagnostics } = parse(schema, file.sourceId, { - grammar: 'prisma7', - }); + const { document, sources, diagnostics } = parse(schema, file.sourceId); const sourceFile = sources.sourceFileFor(document.syntax); seedDiagnostics.push(...mapParseDiagnostics(diagnostics, sourceFile, file.sourceId)); documents.push({ document, sources, sourceFile, sourceId: file.sourceId }); 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 81fdd276d388..e0de5218158f 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 @@ -25,7 +25,6 @@ import type { import { instantiateAuthoringEntityType, isAuthoringEntityTypeDescriptor, - isAuthoringPslBlockDescriptor, isAuthoringTypeConstructorDescriptor, } from '@internal/framework-components/authoring'; import type { CodecLookup } from '@internal/framework-components/codec'; @@ -59,13 +58,18 @@ import { createPslDiagnosticCollector, type DiagnosticSource, diagnosticSource, - keywordPslSpan, mapPslDiagnostics, nodePslSpan, type PslDiagnostic, type PslDiagnosticCollector, } from '@internal/psl-parser'; -import { fkRelationPairKey, type InvalidFkPairing } from '@internal/psl-parser/interpret'; +import { + claimedBlockKeywords, + enumMemberAttributeDiagnostics, + fkRelationPairKey, + type InvalidFkPairing, + unsupportedBlockDiagnostic, +} from '@internal/psl-parser/interpret'; import type { DocumentAst, PslSources } from '@internal/psl-parser/syntax'; import { assertDefined } from '@internal/utils/assertions'; import { blindCast } from '@internal/utils/casts'; @@ -1129,6 +1133,7 @@ function processEnumDeclarations(input: { for (const enumSymbol of input.enumSymbols) { const sourceFile = input.sources.sourceFileFor(enumSymbol.node.syntax); const decl = enumSymbol.block; + input.diagnostics.push(...enumMemberAttributeDiagnostics(enumSymbol, input.sources)); const handle = instantiateAuthoringEntityType( 'enum', enumDescriptor, @@ -1216,23 +1221,15 @@ export function interpretPslDocumentToMongoContract( }); } - const legitimateBlockKeywords = new Set([ + const blockKeywords = new Set([ 'enum', - ...Object.entries(input.authoringContributions?.pslBlockDescriptors ?? {}) - .filter(([, descriptor]) => isAuthoringPslBlockDescriptor(descriptor)) - .map(([keyword]) => keyword), + ...claimedBlockKeywords(input.authoringContributions?.pslBlockDescriptors), ]); for (const block of Object.values(topLevel.blocks)) { - if (legitimateBlockKeywords.has(block.keyword)) continue; - diagnostics.push({ - code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK', - message: `Unsupported top-level block "${block.keyword}"`, - ...diagnosticSource(sources, block.node.syntax).at( - keywordPslSpan(block.node.syntax, block.keyword, sources), - ), - }); + if (!blockKeywords.has(block.keyword)) { + diagnostics.push(unsupportedBlockDiagnostic(block, sources)); + } } - const topLevelEnumSymbols = Object.values(topLevel.blocks).filter((b) => b.keyword === 'enum'); const builtEnums = processEnumDeclarations({ diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts index 20278dee1653..a1106cafdf91 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts @@ -169,6 +169,18 @@ describe('interpretPslDocumentToMongoContract', () => { defaultFunctionRegistry: new Map(), }, codecLookup: mongoCodecLookup, + authoringContributions: { + pslBlockDescriptors: { + enum: { + kind: 'pslBlock', + keyword: 'enum', + discriminator: 'enum', + name: { required: true }, + parameters: {}, + variadicParameters: true, + }, + }, + }, }); expect(result.ok).toBe(false); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.unsupported-blocks.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.unsupported-blocks.test.ts new file mode 100644 index 000000000000..140b26bca21e --- /dev/null +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.unsupported-blocks.test.ts @@ -0,0 +1,68 @@ +import { emptyCodecLookup } from '@internal/framework-components/codec'; +import { buildSymbolTable } from '@internal/psl-parser'; +import { parse } from '@internal/psl-parser/syntax'; +import { describe, expect, it } from 'vitest'; +import { interpretPslDocumentToMongoContract } from '../src/interpreter'; + +function interpret(schema: string) { + const { document, sources } = parse(schema, 'schema.prisma'); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + return interpretPslDocumentToMongoContract({ + documents: [document], + symbolTable, + sources, + scalarTypeCodecIds: new Map([ + ['String', 'mongo/string@1'], + ['ObjectId', 'mongo/objectId@1'], + ]), + controlMutationDefaults: { dataTypeEntries: {}, defaultFunctionRegistry: new Map() }, + codecLookup: emptyCodecLookup, + }); +} + +const userModel = 'model User {\n id ObjectId @id @map("_id")\n}\n'; + +describe('interpretPslDocumentToMongoContract given a block no composed descriptor claims', () => { + it.each([ + ['plain fields', 'view ActiveUsers {\n id String\n}\n'], + ['field attributes', 'view ActiveUsers {\n id String @unique\n\n @@map("active")\n}\n'], + ])('reports a view block with %s as an unsupported top-level block', (_, view) => { + const result = interpret(`${view}${userModel}`); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual([ + { + code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK', + message: 'Unsupported top-level block "view"', + sourceId: 'schema.prisma', + span: { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 4, line: 1, column: 5 }, + }, + }, + ]); + }); + + it('reports a generator block as an unsupported top-level block', () => { + const result = interpret(`generator client {\n provider = "prisma-client"\n}\n${userModel}`); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual([ + { + code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK', + message: 'Unsupported top-level block "generator"', + sourceId: 'schema.prisma', + span: { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 9, line: 1, column: 10 }, + }, + }, + ]); + }); +}); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/provider.interpret.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/provider.interpret.test.ts index 947e5747dfe7..908425c9d4c8 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/provider.interpret.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/provider.interpret.test.ts @@ -366,6 +366,16 @@ it('preserves unlocated and foreign-file contribution diagnostics at the public ...context, authoringContributions: { ...context.authoringContributions, + pslBlockDescriptors: { + enum: { + kind: 'pslBlock' as const, + keyword: 'enum', + discriminator: 'enum', + name: { required: true }, + parameters: {}, + variadicParameters: true, + }, + }, entityTypes: { ...context.authoringContributions.entityTypes, enum: { diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts index 5c8f42fe20a7..219ad4f36967 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts @@ -181,7 +181,7 @@ model User { }); }); - it('fails with an invalid block entry diagnostic at an enum member attribute and produces no contract', async () => { + it('reports the attribute on the enum member and produces no contract', async () => { const tempDir = await mkdtemp(join(tmpdir(), 'mongo-psl-provider-')); tempDirs.push(tempDir); const schemaPath = join(tempDir, 'schema.prisma'); @@ -221,15 +221,16 @@ model User { expect(result.ok).toBe(false); if (result.ok) return; expect(result.failure).toEqual({ - summary: 'Schema has 1 error', + summary: 'PSL to Mongo contract interpretation failed', diagnostics: [ { - code: 'PSL_INVALID_EXTENSION_BLOCK_MEMBER', - message: 'Invalid block entry', + code: 'PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE', + message: + 'enum "Role": member "USER" carries @map, but an enum member takes no attributes', sourceId: schemaPath, span: { start: { offset: 36, line: 3, column: 9 }, - end: { offset: 37, line: 3, column: 10 }, + end: { offset: 48, line: 3, column: 21 }, }, }, ], diff --git a/packages/2-sql/2-authoring/contract-prisma7/src/provider.ts b/packages/2-sql/2-authoring/contract-prisma7/src/provider.ts index df8b455e4f80..2d0b9d3527ff 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/src/provider.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/src/provider.ts @@ -92,7 +92,7 @@ export function prisma7Contract( ): ContractConfig { return { source: { - format: 'prisma7', + format: 'psl', inputs: [schemaPath], async load(context) { const [absolutePath] = context.resolvedInputs; @@ -153,9 +153,7 @@ export function prisma7Contract( }, }); } - const { document, sources, diagnostics } = parse(schema, file.sourceId, { - grammar: 'prisma7', - }); + const { document, sources, diagnostics } = parse(schema, file.sourceId); const sourceFile = sources.sourceFileFor(document.syntax); seedDiagnostics.push(...mapParseDiagnostics(diagnostics, sourceFile, file.sourceId)); documents.push({ document, sources, sourceFile, sourceId: file.sourceId }); diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures.test.ts index b47a1c391e77..73cf04376090 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures.test.ts @@ -49,9 +49,11 @@ describe('Prisma 7 fixtures', () => { 'dbgenerated-without-expression-optional', 'defaults', 'enum-default-member', + 'enum-default-member-unmapped-type', 'enum-default-quoted-string', 'enum-namespace-mismatch', 'enum-native', + 'enum-unreferenced', 'explicit-relations', 'generator-optional', 'generators', diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/expected-contract.json b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/expected-contract.json new file mode 100644 index 000000000000..6f175e6f8680 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/expected-contract.json @@ -0,0 +1,115 @@ +{ + "target": "postgres", + "targetFamily": "sql", + "domain": { + "namespaces": { + "public": { + "models": { + "User": { + "storage": { + "table": "User", + "namespaceId": "public", + "fields": { + "id": { + "column": "id" + }, + "role": { + "column": "role" + } + } + }, + "fields": { + "id": { + "type": { + "kind": "scalar", + "codecId": "pg/int4@1" + }, + "nullable": false + }, + "role": { + "type": { + "kind": "scalar", + "codecId": "pg/enum@1", + "typeParams": { + "typeName": "Role" + } + }, + "nullable": false + } + }, + "relations": {} + } + } + } + } + }, + "roots": { + "User": { + "namespace": "public", + "model": "User" + } + }, + "extensions": {}, + "capabilities": {}, + "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", + "meta": {}, + "storage": { + "storageHash": "36455e48de9a1a70dfa7ab8d5a616215845612beca41290115894f73c8a1ce8f", + "namespaces": { + "public": { + "id": "public", + "kind": "postgres-schema", + "entries": { + "table": { + "User": { + "columns": { + "id": { + "nativeType": "int4", + "codecId": "pg/int4@1", + "nullable": false + }, + "role": { + "nativeType": "Role", + "codecId": "pg/enum@1", + "nullable": false, + "typeParams": { + "typeName": "Role" + }, + "default": { + "kind": "literal", + "value": "user" + }, + "valueSet": { + "plane": "storage", + "entityKind": "valueSet", + "namespaceId": "public", + "entityName": "Role" + } + } + }, + "uniques": [], + "indexes": [], + "foreignKeys": [], + "primaryKey": { + "columns": ["id"] + } + } + }, + "native_enum": { + "Role": { + "kind": "postgres-enum", + "typeName": "Role", + "members": ["user", "ADMIN"] + } + }, + "valueSet": { + "Role": { + "kind": "valueSet", + "values": ["user", "ADMIN"] + } + } + } + } + } + } +} diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/migration.sql b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/migration.sql new file mode 100644 index 000000000000..5e1060951537 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/migration.sql @@ -0,0 +1,13 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('user', 'ADMIN'); + +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL, + "role" "Role" NOT NULL DEFAULT 'user', + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/schema.prisma b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/schema.prisma new file mode 100644 index 000000000000..ea35e66d1a75 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/schema.prisma @@ -0,0 +1,13 @@ +datasource db { + provider = "postgresql" +} + +enum Role { + USER @map("user") + ADMIN +} + +model User { + id Int @id + role Role @default(USER) +} diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/expected-contract.json b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/expected-contract.json new file mode 100644 index 000000000000..633df363f6f0 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/expected-contract.json @@ -0,0 +1,84 @@ +{ + "target": "postgres", + "targetFamily": "sql", + "domain": { + "namespaces": { + "public": { + "models": { + "User": { + "storage": { + "table": "User", + "namespaceId": "public", + "fields": { + "id": { + "column": "id" + } + } + }, + "fields": { + "id": { + "type": { + "kind": "scalar", + "codecId": "pg/int4@1" + }, + "nullable": false + } + }, + "relations": {} + } + } + } + } + }, + "roots": { + "User": { + "namespace": "public", + "model": "User" + } + }, + "extensions": {}, + "capabilities": {}, + "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", + "meta": {}, + "storage": { + "storageHash": "6130cfbb76a6f392328da91d18689700c40f8c8919ea8f62248e1b01c7254333", + "namespaces": { + "public": { + "id": "public", + "kind": "postgres-schema", + "entries": { + "table": { + "User": { + "columns": { + "id": { + "nativeType": "int4", + "codecId": "pg/int4@1", + "nullable": false + } + }, + "uniques": [], + "indexes": [], + "foreignKeys": [], + "primaryKey": { + "columns": ["id"] + } + } + }, + "native_enum": { + "user_role": { + "kind": "postgres-enum", + "typeName": "user_role", + "members": ["user", "ADMIN"] + } + }, + "valueSet": { + "Role": { + "kind": "valueSet", + "values": ["user", "ADMIN"] + } + } + } + } + } + } +} diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/migration.sql b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/migration.sql new file mode 100644 index 000000000000..7de2b0190a05 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/migration.sql @@ -0,0 +1,12 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateEnum +CREATE TYPE "user_role" AS ENUM ('user', 'ADMIN'); + +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/schema.prisma b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/schema.prisma new file mode 100644 index 000000000000..3a42989bb768 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/schema.prisma @@ -0,0 +1,19 @@ +datasource db { + provider = "postgresql" +} + +generator client { + provider = "prisma-client" + output = "../generated/prisma" +} + +enum Role { + USER @map("user") + ADMIN + + @@map("user_role") +} + +model User { + id Int @id +} diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts index f48434c98ca0..14d79b08ced8 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/provider.test.ts @@ -49,9 +49,9 @@ function scratchDir(name: string): string { } describe('prisma7Contract', () => { - it('declares the prisma7 format and the input path', () => { + it('declares the psl format and the input path', () => { expect(prisma7Contract('prisma/schema.prisma', postgres)).toMatchObject({ - source: { format: 'prisma7', inputs: ['prisma/schema.prisma'] }, + source: { format: 'psl', inputs: ['prisma/schema.prisma'] }, }); }); diff --git a/packages/2-sql/2-authoring/contract-psl/README.md b/packages/2-sql/2-authoring/contract-psl/README.md index 5ae5b6480675..1b9166fa92f8 100644 --- a/packages/2-sql/2-authoring/contract-psl/README.md +++ b/packages/2-sql/2-authoring/contract-psl/README.md @@ -116,6 +116,8 @@ Contract-level default (specifier options bag): - `@internal/sql-contract-psl/provider` - `prismaContract(schemaPath, { output?, target, createNamespace, composedExtensionPackRefs?, defaultControlPolicy?, enumInferenceCodecs? })` — scalar column descriptors are derived from the composed stack's authoring type namespace at load time. - Provider input is fully preassembled by composition layers (for example `@internal/family-sql/control` helpers). +- `@internal/sql-contract-psl/map-names` + - `pslModelMapName(modelName, tableName)` and `pslFieldMapName(fieldName, columnName)` — the `@@map` or `@map` name a model or field must carry to read back with that table or column name, or `undefined` when the name this source derives already matches. Code that writes PSL uses them to decide when to write the attribute. ## Architecture diff --git a/packages/2-sql/2-authoring/contract-psl/package.json b/packages/2-sql/2-authoring/contract-psl/package.json index 8aaea758d54c..6be41591d406 100644 --- a/packages/2-sql/2-authoring/contract-psl/package.json +++ b/packages/2-sql/2-authoring/contract-psl/package.json @@ -62,6 +62,10 @@ "types": "./dist/default-table-name.d.mts", "import": "./dist/default-table-name.mjs" }, + "./map-names": { + "types": "./dist/map-names.d.mts", + "import": "./dist/map-names.mjs" + }, "./provider": { "types": "./dist/provider.d.mts", "import": "./dist/provider.mjs" diff --git a/packages/2-sql/2-authoring/contract-psl/src/exports/map-names.ts b/packages/2-sql/2-authoring/contract-psl/src/exports/map-names.ts new file mode 100644 index 000000000000..f6fcab212da8 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/src/exports/map-names.ts @@ -0,0 +1 @@ +export { pslFieldMapName, pslModelMapName } from '../psl-name-mapping'; diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index f3800e2d56c3..93a60ede057e 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -29,7 +29,6 @@ import { instantiateAuthoringEntityType, isAuthoringEntityTypeDescriptor, isAuthoringModelAttributeDescriptor, - isAuthoringPslBlockDescriptor, } from '@internal/framework-components/authoring'; import type { CodecLookup, DataTypeLookup } from '@internal/framework-components/codec'; import type { @@ -43,6 +42,10 @@ import type { MutationDefaultGeneratorDescriptor, } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + UNBOUND_PSL_NAMESPACE_NAME, + UNSPECIFIED_PSL_NAMESPACE_ID, +} from '@internal/framework-components/psl-ast'; import type { Binder } from '@internal/psl-parser'; import { type BlockSymbol, @@ -52,7 +55,6 @@ import { diagnosticSource, type FieldSymbol, findBlockDescriptor, - keywordPslSpan, type ModelAttributeSpecFactory, type ModelSymbol, type NamedTypeSymbol, @@ -63,7 +65,13 @@ import { type ResolvedAttribute, type SymbolTable, } from '@internal/psl-parser'; -import { fkRelationPairKey, type InvalidFkPairing } from '@internal/psl-parser/interpret'; +import { + claimedBlockKeywords, + enumMemberAttributeDiagnostics, + fkRelationPairKey, + type InvalidFkPairing, + unsupportedBlockDiagnostic, +} from '@internal/psl-parser/interpret'; import type { DocumentAst, PslSources } from '@internal/psl-parser/syntax'; import { isAuthoredIndexInput } from '@internal/sql-contract/index-naming'; import type { @@ -189,17 +197,6 @@ function compareStrings(left: string, right: string): -1 | 0 | 1 { return 0; } -/** - * Name of the framework-parser synthesised bucket for top-level - * declarations. Re-declared here so the per-target dispatch does not - * have to import from `@internal/framework-components/psl-ast` - * (which would cross a layer that the interpreter does not otherwise - * import from). The value is part of the framework parser's contract; - * if it changes there, the matching test in this package's - * `interpreter.diagnostics.test.ts` flips first. - */ -const UNSPECIFIED_PSL_NAMESPACE_NAME = '__unspecified__'; - /** * Per-target namespace-block validation: walk the AST's namespace buckets and * emit diagnostics for syntactic constructs the target does not accept. @@ -250,10 +247,10 @@ function resolveNamespaceIdForSqlTarget(input: { if (input.targetId !== 'postgres') { return undefined; } - if (input.bucketName === UNSPECIFIED_PSL_NAMESPACE_NAME) { + if (input.bucketName === UNSPECIFIED_PSL_NAMESPACE_ID) { return 'public'; } - if (input.bucketName === 'unbound') { + if (input.bucketName === UNBOUND_PSL_NAMESPACE_NAME) { return UNBOUND_NAMESPACE_ID; } return input.bucketName; @@ -581,6 +578,7 @@ function processEnumDeclarations(input: ProcessEnumDeclarationsInput): { for (const symbol of input.enumBlocks) { const decl = symbol.block; + input.diagnostics.push(...enumMemberAttributeDiagnostics(symbol, input.source.sources)); const handle = instantiateAuthoringEntityType( 'enum', enumDescriptor, @@ -603,21 +601,6 @@ function processEnumDeclarations(input: ProcessEnumDeclarationsInput): { return { enumHandles, enumTypeDescriptors }; } -/** Generic top-level blocks are supported only when a composed descriptor claims their keyword. */ -function composedBlockKeywords( - authoringContributions: AuthoringContributions | undefined, -): ReadonlySet { - const keywords = new Set(); - const descriptors: AuthoringPslBlockDescriptorNamespace = - authoringContributions?.pslBlockDescriptors ?? {}; - for (const [keyword, value] of Object.entries(descriptors)) { - if (isAuthoringPslBlockDescriptor(value)) { - keywords.add(keyword); - } - } - return keywords; -} - interface BuildModelNodeInput { readonly model: ModelSymbol; readonly mapping: ModelNameMapping; @@ -1311,7 +1294,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult // Target namespace: use the colon-prefix namespace qualifier, or `__unbound__` when the // no-namespace form is used (e.g. `supabase:User` → AC3). - const crossTargetNamespaceId = fieldTypeNamespaceId ?? '__unbound__'; + const crossTargetNamespaceId = fieldTypeNamespaceId ?? UNBOUND_NAMESPACE_ID; // Target table name: resolved from the extension contract. The get() check above // guarantees extContractForSpace is defined here; if the model or namespace is not @@ -1393,8 +1376,8 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult const normalizedQualifier = fieldTypeNamespaceId === undefined ? undefined - : fieldTypeNamespaceId === 'unbound' - ? '__unbound__' + : fieldTypeNamespaceId === UNBOUND_PSL_NAMESPACE_NAME + ? UNBOUND_NAMESPACE_ID : fieldTypeNamespaceId; if ( normalizedQualifier !== undefined && @@ -1661,9 +1644,15 @@ function patchModelDomainFields( }; } else if (rf.many && rf.scalarCodecId) { needsPatch = true; + const builtType = model.fields[rf.field.name]?.type; + const typeParams = builtType?.kind === 'scalar' ? builtType.typeParams : undefined; patchedFields[rf.field.name] = { nullable: rf.field.optional, - type: { kind: 'scalar', codecId: rf.scalarCodecId }, + type: { + kind: 'scalar', + codecId: rf.scalarCodecId, + ...ifDefined('typeParams', typeParams), + }, many: true as const, }; } @@ -2184,7 +2173,7 @@ export function interpretPslDocumentToSqlContract( }; collectScope( - UNSPECIFIED_PSL_NAMESPACE_NAME, + UNSPECIFIED_PSL_NAMESPACE_ID, Object.values(topLevel.models), Object.values(topLevel.compositeTypes), ); @@ -2215,15 +2204,11 @@ export function interpretPslDocumentToSqlContract( } const isEnumBlock = (block: BlockSymbol): boolean => block.keyword === 'enum'; - const legitimateBlockKeywords = composedBlockKeywords(input.authoringContributions); + const legitimateBlockKeywords = claimedBlockKeywords( + input.authoringContributions?.pslBlockDescriptors, + ); const reportUnsupportedTopLevelBlock = (block: BlockSymbol): void => { - diagnostics.push({ - code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK', - message: `Unsupported top-level block "${block.keyword}"`, - ...diagnosticSource(input.sources, block.node.syntax).at( - keywordPslSpan(block.node.syntax, block.keyword, input.sources), - ), - }); + diagnostics.push(unsupportedBlockDiagnostic(block, input.sources)); }; const topLevelEnums: BlockSymbol[] = []; @@ -2379,7 +2364,7 @@ export function interpretPslDocumentToSqlContract( namespaceExtensionEntities.set(nsId, merged); }; for (const ns of namespaceSymbols) { - if (ns.name === UNSPECIFIED_PSL_NAMESPACE_NAME) continue; + if (ns.name === UNSPECIFIED_PSL_NAMESPACE_ID) continue; const nsId = resolveNamespaceIdForSqlTarget({ bucketName: ns.name, targetId: input.target.targetId, @@ -2407,7 +2392,7 @@ export function interpretPslDocumentToSqlContract( if (Object.keys(topLevelExtensionBlocks).length > 0) { const topLevelNsId = resolveNamespaceIdForSqlTarget({ - bucketName: UNSPECIFIED_PSL_NAMESPACE_NAME, + bucketName: UNSPECIFIED_PSL_NAMESPACE_ID, targetId: input.target.targetId, }) ?? defaultNamespaceId; mergeNamespaceExtensionEntities( @@ -2587,6 +2572,13 @@ export function interpretPslDocumentToSqlContract( for (const unique of modelNode.uniques ?? []) { uniqueColumnSets.push(unique.columns); } + // A unique index constrains its columns exactly as a unique constraint + // does, so a singular back-relation over those columns is just as sound. + for (const index of modelNode.indexes ?? []) { + if (index.unique === true && index.columns !== undefined && index.where === undefined) { + uniqueColumnSets.push(index.columns); + } + } modelUniqueColumnSets.set(modelNode.modelName, uniqueColumnSets); } applyBackrelationCandidates({ diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-name-mapping.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-name-mapping.ts new file mode 100644 index 000000000000..a108f3c2bac1 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-name-mapping.ts @@ -0,0 +1,18 @@ +import { defaultTableName } from './default-table-name'; + +/** + * The `@@map` name a model must carry for its table to read back as + * `tableName`, or `undefined` when the name this source derives from the model + * name already matches it. + */ +export function pslModelMapName(modelName: string, tableName: string): string | undefined { + return tableName === defaultTableName(modelName) ? undefined : tableName; +} + +/** + * The `@map` name a field must carry for its column to read back as + * `columnName`, or `undefined` when the field name already matches it. + */ +export function pslFieldMapName(fieldName: string, columnName: string): string | undefined { + return columnName === fieldName ? undefined : columnName; +} diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts index abdab5312568..85ec65f6013f 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts @@ -350,10 +350,11 @@ function relationsForModel( /** * A set of columns is unique when it exactly matches one of the model's unique - * column sets — its primary key or any single- or multi-column `@unique` / - * `@@unique` constraint. Set equality (not subset) is required: a singular - * back-relation means at most one child per parent, which a unique constraint - * covering exactly the FK columns guarantees. + * column sets — its primary key, any single- or multi-column `@unique` / + * `@@unique` constraint, or a unique index over plain columns with no `where` + * clause. Set equality (not subset) is required: a singular back-relation means + * at most one child per parent, which a unique constraint covering exactly the + * FK columns guarantees. */ function fkColumnsAreUnique( localColumns: readonly string[], diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts index a1501857d372..f974e29016ab 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts @@ -127,6 +127,33 @@ function interpret(schema: string, overrides?: Partial { + it('reports an attribute on an enum member, naming the attribute, because a Prisma 8 enum member carries none', () => { + const result = interpret(` +enum Priority { + @@type("pg/text@1") + Low = "low" @map("LOW") + High = "high" +} + +model Post { + id Int @id + priority Priority +} +`); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE', + message: + 'enum "Priority": member "Low" carries @map, but an enum member takes no attributes', + span: expect.objectContaining({ start: expect.objectContaining({ line: 4 }) }), + }), + ]); + }); +}); + // --------------------------------------------------------------------------- // PSL ↔ TS parity: enum emits contract equal to TS enumType authoring // --------------------------------------------------------------------------- diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.list-type-params.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.list-type-params.test.ts new file mode 100644 index 000000000000..a452ee5a2e53 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.list-type-params.test.ts @@ -0,0 +1,54 @@ +import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; +import { interpretPslDocumentToSqlContract } from '../src/interpreter'; +import { fixtureDataTypeSupport } from './fixture-data-types'; +import { + modelsOf, + postgresNativeScalarTypeDescriptors, + postgresScalarAuthoringTypes, + postgresTarget, + symbolTableInputFromParseArgs, +} from './fixtures'; + +const authoringTypes = { + ...postgresScalarAuthoringTypes, + VarCharish: { + kind: 'typeConstructor', + args: [{ kind: 'number', name: 'length', integer: true, minimum: 1 }], + output: { + codecId: 'sql/varchar@1', + nativeType: 'character varying', + typeParams: { length: { kind: 'arg', index: 0 } }, + }, + }, +} satisfies AuthoringTypeNamespace; + +describe('interpretPslDocumentToSqlContract list fields with type parameters', () => { + it('gives a list field the same type parameters as the scalar field of that type', () => { + const document = symbolTableInputFromParseArgs({ + schema: 'model Doc {\n id Int @id\n one VarCharish(12)\n many VarCharish(12)[]\n}\n', + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...document, + target: postgresTarget, + scalarColumnDescriptors: postgresNativeScalarTypeDescriptors, + authoringContributions: { type: authoringTypes, dataTypes: fixtureDataTypeSupport.entries }, + dataTypeLookup: fixtureDataTypeSupport.lookup, + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(modelsOf(result.value)['Doc']?.fields).toMatchObject({ + one: { type: { kind: 'scalar', codecId: 'sql/varchar@1', typeParams: { length: 12 } } }, + many: { + type: { kind: 'scalar', codecId: 'sql/varchar@1', typeParams: { length: 12 } }, + many: true, + }, + }); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.view-block.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.view-block.test.ts new file mode 100644 index 000000000000..9bde6ebf8891 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.view-block.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; +import { interpretPslDocumentToSqlContract } from '../src/interpreter'; +import { fixtureDataTypeSupport } from './fixture-data-types'; +import { + createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, + postgresNativeScalarTypeDescriptors, + postgresScalarAuthoringTypes, + postgresTarget, + symbolTableInputFromParseArgs, +} from './fixtures'; + +function interpret(schema: string) { + return interpretPslDocumentToSqlContract({ + ...symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }), + target: postgresTarget, + codecLookup: postgresCodecLookup, + scalarColumnDescriptors: postgresNativeScalarTypeDescriptors, + authoringContributions: { + type: postgresScalarAuthoringTypes, + dataTypes: fixtureDataTypeSupport.entries, + }, + dataTypeLookup: fixtureDataTypeSupport.lookup, + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, + controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), + }); +} + +describe('interpretPslDocumentToSqlContract given a view block', () => { + it.each([ + ['plain fields', 'view ActiveUsers {\n id Int\n}\n'], + ['field attributes', 'view ActiveUsers {\n id Int @unique\n\n @@map("active")\n}\n'], + ])('reports a view block with %s as an unsupported top-level block', (_, view) => { + const result = interpret(`${view}model User {\n id Int @id\n}\n`); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual([ + { + code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK', + message: 'Unsupported top-level block "view"', + sourceId: 'schema.prisma', + span: { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 4, line: 1, column: 5 }, + }, + }, + ]); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-psl/test/provider.enum-member-attributes.test.ts b/packages/2-sql/2-authoring/contract-psl/test/provider.enum-member-attributes.test.ts index 6563a7736870..e9abbe1c433f 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/provider.enum-member-attributes.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/provider.enum-member-attributes.test.ts @@ -54,7 +54,7 @@ describe('prismaContract given an attribute on an enum member', () => { tempDirs.length = 0; }); - it('fails with an invalid block entry diagnostic at the attribute and produces no contract', async () => { + it('reports the attribute on the enum member and produces no contract', async () => { const tempDir = await mkdtemp(join(tmpdir(), 'psl-provider-enum-')); tempDirs.push(tempDir); const schemaPath = join(tempDir, 'schema.prisma'); @@ -80,15 +80,16 @@ model User { expect(result.ok).toBe(false); if (result.ok) return; expect(result.failure).toEqual({ - summary: 'Schema has 1 error', + summary: 'PSL to SQL contract interpretation failed', diagnostics: [ { - code: 'PSL_INVALID_EXTENSION_BLOCK_MEMBER', - message: 'Invalid block entry', + code: 'PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE', + message: + 'enum "Role": member "USER" carries @map, but an enum member takes no attributes', sourceId: schemaPath, span: { start: { offset: 58, line: 4, column: 9 }, - end: { offset: 59, line: 4, column: 10 }, + end: { offset: 70, line: 4, column: 21 }, }, }, ], diff --git a/packages/2-sql/2-authoring/contract-psl/tsdown.config.ts b/packages/2-sql/2-authoring/contract-psl/tsdown.config.ts index a7561a94aee0..c0a74b7a3738 100644 --- a/packages/2-sql/2-authoring/contract-psl/tsdown.config.ts +++ b/packages/2-sql/2-authoring/contract-psl/tsdown.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ 'attribute-specs': 'src/exports/attribute-specs.ts', 'default-table-name': 'src/exports/default-table-name.ts', index: 'src/exports/index.ts', + 'map-names': 'src/exports/map-names.ts', provider: 'src/exports/provider.ts', resolution: 'src/exports/resolution.ts', }, diff --git a/packages/2-sql/4-lanes/relational-core/test/fixtures/contract.d.ts b/packages/2-sql/4-lanes/relational-core/test/fixtures/contract.d.ts deleted file mode 100644 index 7d395c983ed8..000000000000 --- a/packages/2-sql/4-lanes/relational-core/test/fixtures/contract.d.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Contract type definitions -// Example: This pattern allows multiple contracts (e.g., authDataContract.d.ts, salesDataContract.d.ts) -// without namespace collisions. Each contract can have its own namespace name. - -import type { Contract as BaseContract, StorageHashBase } from '@internal/contract/types'; -import type { ContractWithTypeMaps } from '@internal/sql-contract/types'; - -// Minimal CodecTypes for testing - matches adapter-postgres structure -type CodecTypes = { - readonly 'pg/int4@1': { readonly output: number }; - readonly 'pg/text@1': { readonly output: string }; - readonly 'pg/timestamptz-string@1': { readonly output: string }; -}; - -// Contract type representing the contract data structure -// This type matches the structure of contract.json and can be used as a return type -export type Contract = ContractWithTypeMaps; - readonly tables: { - readonly user: { - readonly columns: { - readonly id: { readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; nullable: false }; - readonly email: { readonly nativeType: 'text'; readonly codecId: 'pg/text@1'; nullable: false }; - readonly createdAt: { readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz-string@1'; nullable: false }; - }; - readonly primaryKey: { readonly columns: readonly ['id'] }; - readonly uniques: ReadonlyArray; - readonly indexes: ReadonlyArray; - readonly foreignKeys: ReadonlyArray; - }; - }; - readonly types: { - readonly Vector1536: { - readonly codecId: 'pg/vector@1'; - readonly nativeType: 'vector'; - readonly typeParams: { readonly length: 1536 }; - }; - readonly Vector768: { - readonly codecId: 'pg/vector@1'; - readonly nativeType: 'vector'; - readonly typeParams: { readonly length: 768 }; - }; - }; - readonly namespaces: { readonly __unbound__: { readonly id: '__unbound__' } }; - }, - { - readonly User: { - readonly storage: { - readonly table: 'user'; - readonly fields: { - readonly id: { readonly column: 'id' }; - readonly email: { readonly column: 'email' }; - readonly createdAt: { readonly column: 'createdAt' }; - }; - }; - readonly fields: { - readonly id: { - readonly nullable: false; - readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; - }; - readonly email: { - readonly nullable: false; - readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; - }; - readonly createdAt: { - readonly nullable: false; - readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz-string@1' }; - }; - }; - readonly relations: Record; - }; - } ->, TypeMaps>; - -// Codec type map and scalar mapping imported from adapter - used for type inference in lanes -export type { CodecTypes }; - -// Operation types (empty for now, can be extended by extension packs) -export type OperationTypes = Record; - -export type FieldOutputTypes = { - readonly User: { - readonly id: number; - readonly email: string; - readonly createdAt: string; - }; -}; -export type TypeMaps = { readonly codecTypes: CodecTypes; readonly operationTypes: OperationTypes; readonly queryOperationTypes: Record; readonly fieldOutputTypes: FieldOutputTypes }; - -// Direct model exports for easy importing: import type { User } from './contract.d' -export type User = Contract['models']['User']; diff --git a/packages/2-sql/4-lanes/relational-core/test/fixtures/contract.json b/packages/2-sql/4-lanes/relational-core/test/fixtures/contract.json deleted file mode 100644 index 2b6bcce90427..000000000000 --- a/packages/2-sql/4-lanes/relational-core/test/fixtures/contract.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "schemaVersion": "1", - "target": "postgres", - "targetFamily": "sql", - "storageHash": "test-core", - "profileHash": "test-profile", - "roots": {}, - "meta": {}, - "storage": { - "storageHash": "test-core", - "tables": { - "user": { - "columns": { - "id": { - "nativeType": "int4", - "codecId": "pg/int4@1", - "nullable": false - }, - "email": { - "nativeType": "text", - "codecId": "pg/text@1", - "nullable": false - }, - "createdAt": { - "nativeType": "timestamptz", - "codecId": "pg/timestamptz-string@1", - "nullable": false - } - }, - "primaryKey": { - "columns": [ - "id" - ] - }, - "uniques": [], - "indexes": [], - "foreignKeys": [] - } - } - }, - "extensions": {}, - "capabilities": { - "postgres": { - "returning": true - } - }, - "domain": { - "namespaces": { - "__unbound__": { - "models": { - "User": { - "storage": { - "table": "user", - "fields": { - "id": { - "column": "id" - }, - "email": { - "column": "email" - }, - "createdAt": { - "column": "createdAt" - } - }, - "namespaceId": "__unbound__" - }, - "fields": { - "id": { - "nullable": false, - "type": { - "kind": "scalar", - "codecId": "pg/int4@1" - } - }, - "email": { - "nullable": false, - "type": { - "kind": "scalar", - "codecId": "pg/text@1" - } - }, - "createdAt": { - "nullable": false, - "type": { - "kind": "scalar", - "codecId": "pg/timestamptz-string@1" - } - } - }, - "relations": {} - } - } - } - } - } -} diff --git a/packages/2-sql/9-family/README.md b/packages/2-sql/9-family/README.md index 9d455b5fde13..f187bcef33ef 100644 --- a/packages/2-sql/9-family/README.md +++ b/packages/2-sql/9-family/README.md @@ -103,6 +103,9 @@ Family instances implement domain actions: - **`toSchemaView(schema)`**: Projects `SqlSchemaIR` into `CoreSchemaView` for human-readable display. Always displays native database types (e.g., `int4`, `text`) rather than mapped codec IDs (e.g., `pg/int4@1`) to reflect actual database state. - **`emitContract({ contract })`**: Emits contract JSON and DTS as strings. Handles stripping mappings and validation internally. Uses preassembled state (operation registry, type imports, extension IDs). +- **`inferPslContract(schemaIR)`**: Infers a PSL contract AST from an introspected schema, for `contract infer`. Delegates to the target descriptor's optional `inferPslContract` hook; throws `CONTRACT.INFER_UNSUPPORTED` when the target has none. +- **`buildPslContract(contract)`**: Builds the PSL document AST that reads back as the same contract, for `contract print`. Delegates to the target descriptor's optional `buildPslContract` hook, and passes it the stack's authoring contributions, codecs and data types (`SqlPslBuildContext`), the parts the PSL source reads the file back with. Returns the document with `sourceSettings`: the settings the config must set on the new PSL source because a PSL file cannot carry them. Today that is only the contract's `defaultControlPolicy`, present when the contract has one. Throws `CONTRACT.PRINT_UNSUPPORTED` when the target has no hook, or when the contract holds something PSL cannot express. `contract` must be one the target's contract serializer accepted. + The descriptor is "pure data + factory" - it only provides the hook and factory method. All family-specific logic lives on the instance. ## Package Structure @@ -133,6 +136,8 @@ 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), the `PslTypeMap` types, and `toEnumMemberName` +- **`./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/9-family/package.json b/packages/2-sql/9-family/package.json index 535019bcd45e..66b6d4f13b22 100644 --- a/packages/2-sql/9-family/package.json +++ b/packages/2-sql/9-family/package.json @@ -63,6 +63,7 @@ "./ir": "./dist/ir.mjs", "./migration": "./dist/migration.mjs", "./pack": "./dist/pack.mjs", + "./psl-build": "./dist/psl-build.mjs", "./psl-infer": "./dist/psl-infer.mjs", "./runtime": "./dist/runtime.mjs", "./verify": "./dist/verify.mjs", diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index 983afa612be3..395365f05f98 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -11,6 +11,8 @@ import type { MigrationPlanOperation, OperationPreview, OperationPreviewCapable, + PslContractBuildCapable, + PslContractDocument, PslContractInferCapable, SchemaDiffIssue, SchemaViewCapable, @@ -207,6 +209,7 @@ export interface SqlControlFamilyInstance extends ControlFamilyInstance<'sql', SqlSchemaIRNode>, SchemaViewCapable, PslContractInferCapable, + PslContractBuildCapable>, OperationPreviewCapable, SqlFamilyInstanceState { /** @@ -584,6 +587,12 @@ export function createSqlFamilyInstance( SqlControlTargetDescriptor, 'reading the optional target-descriptor inferPslContract hook' >(target).inferPslContract; + // The hook that builds the PSL document of a contract is read off the descriptor the same way. + // Absent for targets without `contract print`. + const targetBuildPslContract = blindCast< + SqlControlTargetDescriptor, + 'reading the optional target-descriptor buildPslContract hook' + >(target).buildPslContract; // The full-tree node diff the verify VERDICT derives from. Read lazily so // construction-only stub descriptors (schema-view tests) keep working; the // throw happens at verify time. @@ -1015,6 +1024,28 @@ export function createSqlFamilyInstance( return targetInferPslContract(schemaIR, describedContracts); }, + buildPslContract(contract: Contract): PslContractDocument { + if (!targetBuildPslContract) { + throw sqlFamilyError( + 'CONTRACT.PRINT_UNSUPPORTED', + `Target "${target.targetId}" does not support contract print (no buildPslContract on its descriptor).`, + { + why: 'The target descriptor does not provide the buildPslContract hook, so the contract cannot be printed as a Prisma 8 PSL file.', + fix: 'Use a target whose descriptor provides buildPslContract, or write the Prisma 8 PSL file by hand.', + meta: { targetId: target.targetId }, + }, + ); + } + return { + document: targetBuildPslContract(contract, { + authoringContributions: stack.authoringContributions, + codecLookup: stack.codecLookup, + dataTypeLookup: stack.dataTypeLookup, + }), + sourceSettings: ifDefined('defaultControlPolicy', contract.defaultControlPolicy), + }; + }, + lowerAst( ast: AnyQueryAst | DdlNode, context: LowererContext, diff --git a/packages/2-sql/9-family/src/core/control-target-descriptor.ts b/packages/2-sql/9-family/src/core/control-target-descriptor.ts index ee0d0b588110..f66a43035af4 100644 --- a/packages/2-sql/9-family/src/core/control-target-descriptor.ts +++ b/packages/2-sql/9-family/src/core/control-target-descriptor.ts @@ -1,5 +1,7 @@ import type { Contract } from '@internal/contract/types'; +import type { CodecLookup, DataTypeLookup } from '@internal/framework-components/codec'; import type { + AssembledAuthoringContributions, ContractSerializer, DiffSubjectGranularity, MigratableTargetDescriptor, @@ -29,6 +31,15 @@ export interface SqlDescribedContractSpace { readonly contract: Contract; } +/** + * The parts of the composed stack a target builds a PSL document with. The PSL contract source reads the document with the same parts, so the target writes a column type as a type constructor the stack contributes, and a literal default through the data type of the column's codec, and both read back. + */ +export interface SqlPslBuildContext { + readonly authoringContributions: Pick; + readonly codecLookup: CodecLookup; + readonly dataTypeLookup: DataTypeLookup; +} + export interface SqlControlTargetDescriptor< TTargetId extends string, TTargetDetails, @@ -62,6 +73,10 @@ export interface SqlControlTargetDescriptor< schema: SqlSchemaIRNode, describedContracts?: readonly SqlDescribedContractSpace[], ) => PslDocumentAst; + /** + * Builds the PSL document of a contract, which `contract print` renders as text. `contract` must be one `contractSerializer.deserializeContract` accepted; the hook does not check its structure again. Like {@link inferPslContract} it produces a PSL document, but from an assembled contract and the stack parts in `context`. The document reads back as the same contract; the hook throws `CONTRACT.PRINT_UNSUPPORTED` for a contract PSL cannot express. The implementation sits in the target, though most of it inverts this family's PSL reader; only its column types, defaults, native enums, derived checks and row-level security are dialect logic. Optional: targets without `contract print` omit it, and the family instance throws when it is absent. + */ + readonly buildPslContract?: (contract: TContract, context: SqlPslBuildContext) => PslDocumentAst; /** * The full-tree node diff the family verify verdict derives from — * expected-tree derivation, pre-diff normalization, the generic differ, diff --git a/packages/2-sql/9-family/src/core/errors.ts b/packages/2-sql/9-family/src/core/errors.ts index 5e8cdd081772..d3b9d8a8bb91 100644 --- a/packages/2-sql/9-family/src/core/errors.ts +++ b/packages/2-sql/9-family/src/core/errors.ts @@ -2,6 +2,7 @@ import type { StructuredError, StructuredErrorOptions } from '@internal/utils/st import { structuredError } from '@internal/utils/structured-error'; type SqlFamilyErrorCode = + | 'CONTRACT.PRINT_UNSUPPORTED' | 'CONTRACT.FOREIGN_KEY_INVALID' | 'CONTRACT.INFER_UNSUPPORTED' | 'CONTRACT.MARKER_ROW_CORRUPT' diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts b/packages/2-sql/9-family/src/core/psl-build/default-mapping.ts similarity index 100% rename from packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts rename to packages/2-sql/9-family/src/core/psl-build/default-mapping.ts diff --git a/packages/2-sql/9-family/src/core/psl-build/psl-names.ts b/packages/2-sql/9-family/src/core/psl-build/psl-names.ts new file mode 100644 index 000000000000..95d507b9ac4b --- /dev/null +++ b/packages/2-sql/9-family/src/core/psl-build/psl-names.ts @@ -0,0 +1,75 @@ +const PSL_RESERVED_WORDS = new Set(['model', 'enum', 'types', 'type', 'generator', 'datasource']); + +const IDENTIFIER_PART_PATTERN = /[A-Za-z0-9]+/g; + +export function hasSeparators(input: string): boolean { + return /[^A-Za-z0-9]/.test(input); +} + +function extractIdentifierParts(input: string): string[] { + return input.match(IDENTIFIER_PART_PATTERN) ?? []; +} + +function createSyntheticIdentifier(input: string): string { + let hash = 2166136261; + + for (const char of input) { + hash ^= char.codePointAt(0) ?? 0; + hash = Math.imul(hash, 16777619); + } + + return `x${(hash >>> 0).toString(16)}`; +} + +function sanitizeIdentifierCharacters(input: string): string { + const sanitized = input.replace(/[^\w]/g, ''); + return sanitized.length > 0 ? sanitized : createSyntheticIdentifier(input); +} + +function capitalize(word: string): string { + return word.charAt(0).toUpperCase() + word.slice(1); +} + +export function snakeToPascalCase(input: string): string { + const parts = extractIdentifierParts(input); + if (parts.length === 0) { + return capitalize(sanitizeIdentifierCharacters(input)); + } + return parts.map(capitalize).join(''); +} + +export function snakeToCamelCase(input: string): string { + const parts = extractIdentifierParts(input); + if (parts.length === 0) { + return sanitizeIdentifierCharacters(input); + } + const [firstPart = input, ...rest] = parts; + return firstPart.charAt(0).toLowerCase() + firstPart.slice(1) + rest.map(capitalize).join(''); +} + +export function needsEscaping(name: string): boolean { + return PSL_RESERVED_WORDS.has(name.toLowerCase()) || /^\d/.test(name); +} + +export function escapeName(name: string): string { + return `_${name}`; +} + +export function escapeIfNeeded(name: string): string { + return needsEscaping(name) ? escapeName(name) : name; +} + +const VALID_IDENTIFIER_PATTERN = /^[A-Za-z_]\w*$/; + +/** + * PSL member name for a native-enum value. The value itself always prints + * explicitly (`member = "value"`), so the returned name never needs a map: + * a value that already is a valid, non-reserved identifier is kept verbatim + * (case included); anything else is camelCased/escaped like a field name. + */ +export function toEnumMemberName(value: string): string { + if (VALID_IDENTIFIER_PATTERN.test(value) && !needsEscaping(value)) { + return value; + } + return escapeIfNeeded(snakeToCamelCase(value)); +} diff --git a/packages/2-sql/9-family/src/core/psl-build/type-map.ts b/packages/2-sql/9-family/src/core/psl-build/type-map.ts new file mode 100644 index 000000000000..5ffd45ab486d --- /dev/null +++ b/packages/2-sql/9-family/src/core/psl-build/type-map.ts @@ -0,0 +1,21 @@ +/** A PSL type name and the arguments of its call, if it takes any. */ +export type PslTypeReference = { + readonly name: string; + readonly args?: readonly string[]; +}; + +export type PslTypeResolution = + | { + readonly pslType: PslTypeReference; + readonly nativeType: string; + readonly typeParams?: Record; + } + | { + readonly unsupported: true; + readonly nativeType: string; + }; + +/** Which PSL type a column of a native type is written as, or that none is. */ +export interface PslTypeMap { + resolve(nativeType: string, annotations?: Record): PslTypeResolution; +} diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts index aa280fca6642..8a4670deee4a 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts @@ -1,72 +1,19 @@ import { defaultTableName } from '@internal/sql-contract-psl/default-table-name'; import pluralizeLib from 'pluralize'; - -const PSL_RESERVED_WORDS = new Set(['model', 'enum', 'types', 'type', 'generator', 'datasource']); - -const IDENTIFIER_PART_PATTERN = /[A-Za-z0-9]+/g; +import { + escapeIfNeeded, + escapeName, + hasSeparators, + needsEscaping, + snakeToCamelCase, + snakeToPascalCase, +} from '../psl-build/psl-names'; type NameResult = { readonly name: string; readonly map?: string; }; -function hasSeparators(input: string): boolean { - return /[^A-Za-z0-9]/.test(input); -} - -function extractIdentifierParts(input: string): string[] { - return input.match(IDENTIFIER_PART_PATTERN) ?? []; -} - -function createSyntheticIdentifier(input: string): string { - let hash = 2166136261; - - for (const char of input) { - hash ^= char.codePointAt(0) ?? 0; - hash = Math.imul(hash, 16777619); - } - - return `x${(hash >>> 0).toString(16)}`; -} - -function sanitizeIdentifierCharacters(input: string): string { - const sanitized = input.replace(/[^\w]/g, ''); - return sanitized.length > 0 ? sanitized : createSyntheticIdentifier(input); -} - -function capitalize(word: string): string { - return word.charAt(0).toUpperCase() + word.slice(1); -} - -function snakeToPascalCase(input: string): string { - const parts = extractIdentifierParts(input); - if (parts.length === 0) { - return capitalize(sanitizeIdentifierCharacters(input)); - } - return parts.map(capitalize).join(''); -} - -function snakeToCamelCase(input: string): string { - const parts = extractIdentifierParts(input); - if (parts.length === 0) { - return sanitizeIdentifierCharacters(input); - } - const [firstPart = input, ...rest] = parts; - return firstPart.charAt(0).toLowerCase() + firstPart.slice(1) + rest.map(capitalize).join(''); -} - -function needsEscaping(name: string): boolean { - return PSL_RESERVED_WORDS.has(name.toLowerCase()) || /^\d/.test(name); -} - -function escapeName(name: string): string { - return `_${name}`; -} - -function escapeIfNeeded(name: string): string { - return needsEscaping(name) ? escapeName(name) : name; -} - export function toModelName(tableName: string): NameResult { let name: string; @@ -130,21 +77,6 @@ export function toEnumName(pgTypeName: string): NameResult { return { name }; } -const VALID_IDENTIFIER_PATTERN = /^[A-Za-z_]\w*$/; - -/** - * PSL member name for a native-enum value. The value itself always prints - * explicitly (`member = "value"`), so the returned name never needs a map: - * a value that already is a valid, non-reserved identifier is kept verbatim - * (case included); anything else is camelCased/escaped like a field name. - */ -export function toEnumMemberName(value: string): string { - if (VALID_IDENTIFIER_PATTERN.test(value) && !needsEscaping(value)) { - return value; - } - return escapeIfNeeded(snakeToCamelCase(value)); -} - export function pluralize(word: string): string { return pluralizeLib.plural(word); } diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts index 32da30e45982..b909273fb313 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts @@ -1,25 +1,6 @@ import type { ColumnDefault } from '@internal/contract/types'; -import type { DefaultMappingOptions } from './default-mapping'; - -export type PslTypeReference = { - readonly name: string; - readonly args?: readonly string[]; -}; - -export type PslTypeResolution = - | { - readonly pslType: PslTypeReference; - readonly nativeType: string; - readonly typeParams?: Record; - } - | { - readonly unsupported: true; - readonly nativeType: string; - }; - -export interface PslTypeMap { - resolve(nativeType: string, annotations?: Record): PslTypeResolution; -} +import type { DefaultMappingOptions } from '../psl-build/default-mapping'; +import type { PslTypeMap } from '../psl-build/type-map'; export interface EnumInfo { readonly typeNames: ReadonlySet; diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index e36cc2a7d358..36e7fe0fd360 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -18,6 +18,7 @@ export type { SqlControlFamilyInstance } from '../core/control-instance'; export type { SqlControlTargetDescriptor, SqlDescribedContractSpace, + SqlPslBuildContext, } from '../core/control-target-descriptor'; export type { ContractToSchemaIROptions, diff --git a/packages/2-sql/9-family/src/exports/psl-build.ts b/packages/2-sql/9-family/src/exports/psl-build.ts new file mode 100644 index 000000000000..5febaf8795ff --- /dev/null +++ b/packages/2-sql/9-family/src/exports/psl-build.ts @@ -0,0 +1,13 @@ +/** + * PSL building blocks both directions use: `contract infer` writes a database as PSL, and + * `contract print` writes a contract as PSL. They carry no dialect knowledge; the target that owns + * the dialect maps imports them. + */ + +export type { + DefaultMappingOptions, + DefaultMappingResult, +} from '../core/psl-build/default-mapping'; +export { mapDefault } from '../core/psl-build/default-mapping'; +export { toEnumMemberName } from '../core/psl-build/psl-names'; +export type { PslTypeMap, PslTypeReference, PslTypeResolution } from '../core/psl-build/type-map'; diff --git a/packages/2-sql/9-family/src/exports/psl-infer.ts b/packages/2-sql/9-family/src/exports/psl-infer.ts index 591b4c495b24..7bfd5a9614aa 100644 --- a/packages/2-sql/9-family/src/exports/psl-infer.ts +++ b/packages/2-sql/9-family/src/exports/psl-infer.ts @@ -1,24 +1,16 @@ /** - * Shape-neutral database→PSL inference utilities. + * Database→PSL inference utilities for `contract infer`. * - * These leaf transforms (name normalization, relation inference, generic - * default mapping, the printer-config types) carry no dialect knowledge, so - * they live in the SQL family and are imported by the target that owns the - * dialect maps and walks its own schema tree (Postgres). The framework owns - * `PslDocumentAst` + `printPsl`; the target owns the Postgres type/default - * maps and reads raw defaults through the `parseRawDefault` printer option. + * These leaf transforms (name normalization, relation inference, the printer-config types) carry no + * dialect knowledge, so they live in the SQL family and are imported by the target that owns the + * dialect maps and walks its own schema tree (Postgres). The parts `contract print` uses too are + * under `@internal/family-sql/psl-build`. */ -export type { - DefaultMappingOptions, - DefaultMappingResult, -} from '../core/psl-contract-infer/default-mapping'; -export { mapDefault } from '../core/psl-contract-infer/default-mapping'; export { deriveBackRelationFieldName, deriveRelationFieldName, pluralize, - toEnumMemberName, toEnumName, toFieldName, toModelName, @@ -26,9 +18,6 @@ export { export type { EnumInfo, PslPrinterOptions, - PslTypeMap, - PslTypeReference, - PslTypeResolution, RelationField, } from '../core/psl-contract-infer/printer-config'; export type { InferredRelations } from '../core/psl-contract-infer/relation-inference'; diff --git a/packages/2-sql/9-family/test/control-instance.build-psl-contract.test.ts b/packages/2-sql/9-family/test/control-instance.build-psl-contract.test.ts new file mode 100644 index 000000000000..3ec2459059f7 --- /dev/null +++ b/packages/2-sql/9-family/test/control-instance.build-psl-contract.test.ts @@ -0,0 +1,111 @@ +import type { Contract, ControlPolicy } from '@internal/contract/types'; +import type { + ControlFamilyDescriptor, + ControlStack, + ControlTargetDescriptor, +} from '@internal/framework-components/control'; +import { createControlStack, hasPslContractBuild } from '@internal/framework-components/control'; +import type { PslDocumentAst } from '@internal/framework-components/psl-ast'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { describe, expect, it, vi } from 'vitest'; +import { createSqlFamilyInstance } from '../src/core/control-instance'; + +const DOCUMENT: PslDocumentAst = { + kind: 'document', + sourceId: 'built', + namespaces: [], + span: { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 0, line: 1, column: 1 }, + }, +}; + +function stackWithBuilder( + buildPslContract: ((contract: Contract) => PslDocumentAst) | undefined, +): ControlStack<'sql', 'postgres'> { + return createControlStack({ + family: { + kind: 'family', + id: 'sql', + familyId: 'sql', + version: '0.0.1', + create: (() => ({})) as unknown as ControlFamilyDescriptor<'sql'>['create'], + } as unknown as ControlFamilyDescriptor<'sql'>, + target: { + kind: 'target', + id: 'postgres', + version: '0.0.1', + familyId: 'sql', + targetId: 'postgres', + create: () => ({ familyId: 'sql', targetId: 'postgres' }), + ...(buildPslContract === undefined ? {} : { buildPslContract }), + } as ControlTargetDescriptor<'sql', 'postgres'>, + adapter: { + kind: 'adapter', + id: 'postgres', + version: '0.0.1', + familyId: 'sql', + targetId: 'postgres', + create: (() => ({ familyId: 'sql', targetId: 'postgres' })) as unknown as ( + stack: unknown, + ) => never, + }, + extensions: [], + }); +} + +function contractWith(defaultControlPolicy?: ControlPolicy): Contract { + return { + target: 'postgres', + ...(defaultControlPolicy === undefined ? {} : { defaultControlPolicy }), + } as unknown as Contract; +} + +describe('sql family buildPslContract', () => { + it("returns the target's document and no source settings for a contract without a default control policy", () => { + const builder = vi.fn(() => DOCUMENT); + const instance = createSqlFamilyInstance(stackWithBuilder(builder)); + const contract = contractWith(); + + expect(instance.buildPslContract(contract)).toEqual({ document: DOCUMENT, sourceSettings: {} }); + expect(builder).toHaveBeenCalledWith(contract, expect.anything()); + }); + + it('passes the target hook the stack parts the PSL source reads with', () => { + const builder = vi.fn(() => DOCUMENT); + const stack = stackWithBuilder(builder); + const contract = contractWith(); + + createSqlFamilyInstance(stack).buildPslContract(contract); + + expect(builder).toHaveBeenCalledWith(contract, { + authoringContributions: stack.authoringContributions, + codecLookup: stack.codecLookup, + dataTypeLookup: stack.dataTypeLookup, + }); + }); + + it('returns the default control policy as a setting the PSL source must carry', () => { + const instance = createSqlFamilyInstance(stackWithBuilder(() => DOCUMENT)); + + expect(instance.buildPslContract(contractWith('external'))).toEqual({ + document: DOCUMENT, + sourceSettings: { defaultControlPolicy: 'external' }, + }); + }); + + it('passes the capability check contract print makes, even when the target has no hook', () => { + expect(hasPslContractBuild(createSqlFamilyInstance(stackWithBuilder(undefined)))).toBe(true); + }); + + it('raises CONTRACT.PRINT_UNSUPPORTED when the target descriptor has no buildPslContract', () => { + const instance = createSqlFamilyInstance(stackWithBuilder(undefined)); + + expect(() => instance.buildPslContract(contractWith())).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + meta: { targetId: 'postgres' }, + }), + ); + }); +}); diff --git a/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts b/packages/2-sql/9-family/test/psl-build/default-mapping.test.ts similarity index 99% rename from packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts rename to packages/2-sql/9-family/test/psl-build/default-mapping.test.ts index ee01e6828107..a0d50490b7e3 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts +++ b/packages/2-sql/9-family/test/psl-build/default-mapping.test.ts @@ -15,7 +15,7 @@ import { type DefaultMappingOptions, type DefaultMappingResult, mapDefault, -} from '../../src/core/psl-contract-infer/default-mapping'; +} from '../../src/core/psl-build/default-mapping'; /** The backtick fencing a tagged literal, as an escape so no quoted string in this file holds one. */ const BACKTICK = '\u0060'; diff --git a/packages/2-sql/9-family/test/psl-build/psl-names.test.ts b/packages/2-sql/9-family/test/psl-build/psl-names.test.ts new file mode 100644 index 000000000000..ec1f5ead0355 --- /dev/null +++ b/packages/2-sql/9-family/test/psl-build/psl-names.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { toEnumMemberName } from '../../src/core/psl-build/psl-names'; + +describe('toEnumMemberName', () => { + it('keeps a valid identifier value verbatim, preserving case', () => { + expect(toEnumMemberName('aal1')).toBe('aal1'); + expect(toEnumMemberName('DRAFT')).toBe('DRAFT'); + expect(toEnumMemberName('camelCase')).toBe('camelCase'); + }); + + it('camelCases values with separators', () => { + expect(toEnumMemberName('high-priority')).toBe('highPriority'); + expect(toEnumMemberName('in review')).toBe('inReview'); + }); + + it('escapes digit-prefixed values', () => { + expect(toEnumMemberName('2nd')).toBe('_2nd'); + }); + + it('escapes PSL reserved words', () => { + expect(toEnumMemberName('enum')).toBe('_enum'); + expect(toEnumMemberName('model')).toBe('_model'); + }); + + it('synthesizes a deterministic identifier when no ASCII identifier characters remain', () => { + expect(toEnumMemberName('$$$')).toMatch(/^x[0-9a-f]+$/); + expect(toEnumMemberName('東京')).toBe(toEnumMemberName('東京')); + }); +}); diff --git a/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts index 9d719a4d811a..e6200194da45 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts @@ -3,7 +3,6 @@ import { deriveBackRelationFieldName, deriveRelationFieldName, pluralize, - toEnumMemberName, toEnumName, toFieldName, toModelName, @@ -197,30 +196,3 @@ describe('deriveBackRelationFieldName', () => { expect(deriveBackRelationFieldName('Sessions', false)).toBe('sessions'); }); }); - -describe('toEnumMemberName', () => { - it('keeps a valid identifier value verbatim, preserving case', () => { - expect(toEnumMemberName('aal1')).toBe('aal1'); - expect(toEnumMemberName('DRAFT')).toBe('DRAFT'); - expect(toEnumMemberName('camelCase')).toBe('camelCase'); - }); - - it('camelCases values with separators', () => { - expect(toEnumMemberName('high-priority')).toBe('highPriority'); - expect(toEnumMemberName('in review')).toBe('inReview'); - }); - - it('escapes digit-prefixed values', () => { - expect(toEnumMemberName('2nd')).toBe('_2nd'); - }); - - it('escapes PSL reserved words', () => { - expect(toEnumMemberName('enum')).toBe('_enum'); - expect(toEnumMemberName('model')).toBe('_model'); - }); - - it('synthesizes a deterministic identifier when no ASCII identifier characters remain', () => { - expect(toEnumMemberName('$$$')).toMatch(/^x[0-9a-f]+$/); - expect(toEnumMemberName('東京')).toBe(toEnumMemberName('東京')); - }); -}); diff --git a/packages/2-sql/9-family/tsdown.config.ts b/packages/2-sql/9-family/tsdown.config.ts index 1be12f71b9ee..a8099136c15b 100644 --- a/packages/2-sql/9-family/tsdown.config.ts +++ b/packages/2-sql/9-family/tsdown.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'src/exports/runtime.ts', 'src/exports/verify.ts', 'src/exports/diff.ts', + 'src/exports/psl-build.ts', 'src/exports/psl-infer.ts', ], }); diff --git a/packages/3-extensions/mongo/README.md b/packages/3-extensions/mongo/README.md index 96816d41726a..ee28c60621c2 100644 --- a/packages/3-extensions/mongo/README.md +++ b/packages/3-extensions/mongo/README.md @@ -58,6 +58,8 @@ export default defineConfig({ `prisma6Schema` reads a Prisma 6 MongoDB `schema.prisma` as the contract source, so a project that still runs Prisma 6 can adopt Prisma 8 without a second schema file. It accepts one file or a directory of `.prisma` files (every file under it, nested directories included, as Prisma 6 reads a schema directory). `contract emit` writes `contract.json` and `contract.d.ts` into the directory that holds the schema file or the schema directory: `prisma6Schema('prisma/schema.prisma')` and `prisma6Schema('prisma/schema')` both write `prisma/contract.json` and `prisma/contract.d.ts`. The `output` directory on `defineConfig` overrides that, as for every other source. +`prisma contract format` treats this source as PSL: when `prisma6Schema` names one file, the command rewrites that file in place with the Prisma 8 formatter. To keep Prisma 6's own formatting, do not run `prisma contract format`; format the schema with Prisma 6's `prisma format` instead. + ```typescript // prisma.config.ts import { definePrismaConfig } from 'prisma/config'; diff --git a/packages/3-extensions/mongo/src/config/define-config.ts b/packages/3-extensions/mongo/src/config/define-config.ts index 1d460709c69d..e058ed79650f 100644 --- a/packages/3-extensions/mongo/src/config/define-config.ts +++ b/packages/3-extensions/mongo/src/config/define-config.ts @@ -1,6 +1,9 @@ import mongoAdapter from '@internal/adapter-mongo/control'; import type { ContractConfig, PrismaNextConfig } from '@internal/config/config-types'; -import { defineConfig as coreDefineConfig } from '@internal/config/config-types'; +import { + defineConfig as coreDefineConfig, + defaultContractOutputPath, +} from '@internal/config/config-types'; import mongoDriver from '@internal/driver-mongo/control'; import { mongoFamilyDescriptor } from '@internal/family-mongo/control'; import type { ControlExtensionDescriptor } from '@internal/framework-components/control'; @@ -10,7 +13,6 @@ import { MONGO_INT32_CODEC_ID, MONGO_STRING_CODEC_ID } from '@internal/target-mo import { mongoTargetDescriptor } from '@internal/target-mongo/control'; import { ifDefined } from '@internal/utils/defined'; import { extname, join } from 'pathe'; -import { isDynamicPattern } from 'tinyglobby'; export interface MongoConfigOptions { /** A contract file path (`.prisma` or `.ts`), or a ready `ContractConfig` such as `prisma6Schema(...)`. */ @@ -25,27 +27,6 @@ export interface MongoConfigOptions { }; } -function staticPrefixDirectory(pattern: string): string { - const staticSegments: string[] = []; - for (const segment of pattern.replaceAll('\\', '/').split('/')) { - if (isDynamicPattern(segment)) break; - staticSegments.push(segment); - } - return staticSegments.join('/'); -} - -function deriveOutputPath(contractPath: string): string { - if (isDynamicPattern(contractPath)) { - const prefix = staticPrefixDirectory(contractPath); - return prefix.length === 0 ? 'contract.json' : `${prefix}/contract.json`; - } - const ext = extname(contractPath); - if (ext.length === 0) { - return `${contractPath}.json`; - } - return `${contractPath.slice(0, -ext.length)}.json`; -} - function contractConfigFromPath(contractPath: string, output: string): ContractConfig { return extname(contractPath) === '.ts' ? typescriptContractFromPath(contractPath, output) @@ -61,14 +42,14 @@ function resolveContractConfig(options: MongoConfigOptions): ContractConfig { if (typeof options.contract === 'string') { return contractConfigFromPath( options.contract, - explicitOutput ?? deriveOutputPath(options.contract), + explicitOutput ?? defaultContractOutputPath(options.contract), ); } const firstInput = options.contract.source.inputs?.[0]; const output = explicitOutput ?? options.contract.output ?? - (firstInput !== undefined ? deriveOutputPath(firstInput) : undefined); + (firstInput !== undefined ? defaultContractOutputPath(firstInput) : undefined); return { ...options.contract, ...ifDefined('output', output) }; } diff --git a/packages/3-extensions/mongo/test/config/define-config.prisma6.test.ts b/packages/3-extensions/mongo/test/config/define-config.prisma6.test.ts index 4d6a1a25f976..73d6dec603bf 100644 --- a/packages/3-extensions/mongo/test/config/define-config.prisma6.test.ts +++ b/packages/3-extensions/mongo/test/config/define-config.prisma6.test.ts @@ -8,7 +8,7 @@ describe('defineConfig with a ContractConfig', () => { const config = defineConfig({ contract }); expect(config.contract?.source).toBe(contract.source); - expect(config.contract?.source.format).toBe('prisma6'); + expect(config.contract?.source.format).toBe('psl'); expect(config.contract?.source.inputs).toEqual(['prisma/schema.prisma']); expect(config.contract?.output).toBe('prisma/contract.json'); }); diff --git a/packages/3-extensions/postgres/README.md b/packages/3-extensions/postgres/README.md index a8366dc4fb9c..2b86f23938b6 100644 --- a/packages/3-extensions/postgres/README.md +++ b/packages/3-extensions/postgres/README.md @@ -71,10 +71,40 @@ The returned client exposes `sql`, `context`, `stack`, `contract`, and `connect( Simplified `defineConfig` that pre-wires all Postgres internals (family, target, adapter, driver, contract providers). Pass a contract path (`.prisma` or `.ts`) or a ready `ContractConfig`, and optional db/migrations/extensions config. +#### A PSL source with a default control policy + +`defineConfig` has no option for the contract's `defaultControlPolicy`, and a PSL file cannot carry it. To set one on a PSL contract, such as the file `prisma contract print --output` writes for a contract that has one, build the source with `prismaContract` from `@prisma/orm-family-sql` (add that package to the project's dependencies) and pass it the Postgres settings `defineConfig` passes for a `.prisma` path: + +```typescript +// prisma.config.ts +import { definePrismaConfig } from 'prisma/config'; +import { prismaContract } from '@prisma/orm-family-sql/contract-psl/provider'; +import { defineConfig as ormConfig } from '@prisma/orm-postgres/config'; +import { PG_INT_CODEC_ID, PG_TEXT_CODEC_ID } from '@prisma/orm-postgres/target/codec-ids'; +import postgresPack from '@prisma/orm-postgres/target/pack'; +import { postgresCreateNamespace } from '@prisma/orm-postgres/target/types'; + +export default definePrismaConfig({ + orm: ormConfig({ + contract: prismaContract('./prisma/contract.prisma', { + target: postgresPack, + createNamespace: postgresCreateNamespace, + enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, + defaultControlPolicy: 'external', + }), + db: { connection: process.env['DATABASE_URL']! }, + }), +}); +``` + +`contract emit` then writes `prisma/contract.json` and `prisma/contract.d.ts`. + #### `prisma7Schema(path)`: adopt a Prisma 7 schema during the transition `prisma7Schema` reads a Prisma 7 `schema.prisma` as the contract source, so a project that still runs Prisma 7 can adopt Prisma 8 without a second schema file. It accepts one file or a directory of `.prisma` files (every file under it, nested directories included, as Prisma 7 reads a schema directory) and produces the same `ContractConfig` as a `.prisma` path does. `contract emit` writes `contract.json` and `contract.d.ts` into the directory that holds the schema file or the schema directory, whatever the file is named: `prisma7Schema('prisma/schema.prisma')` and `prisma7Schema('prisma/schema')` both write `prisma/contract.json` and `prisma/contract.d.ts`, never inside the schema directory. This differs from a Prisma 8 PSL source, which defaults to `.json` beside the schema (`prisma/schema.prisma` writes `prisma/schema.json`). The `output` directory on `defineConfig` sets either explicitly, as for every other source: with `output: 'generated/prisma8'`, `contract emit` writes `generated/prisma8/contract.json` and `generated/prisma8/contract.d.ts`. +`prisma contract format` treats this source as PSL: when `prisma7Schema` names one file, the command rewrites that file in place with the Prisma 8 formatter. To keep Prisma 7's own formatting, do not run `prisma contract format`; format the schema with Prisma 7's `prisma format` instead. + ```typescript // prisma.config.ts import { definePrismaConfig } from 'prisma/config'; @@ -99,6 +129,8 @@ What the project needs around that file: During the transition Prisma 7 keeps owning the database and its migrations. Prisma 8 reads the schema and verifies it against what Prisma 7 built; it does not migrate. After every Prisma 7 migration, run `prisma contract emit` and then `prisma db sign` so the recorded contract matches the database again; `prisma db verify` reports nothing when they match. A database last migrated on Prisma 5 or earlier must migrate on Prisma 7 first: since Prisma 6.0.0 the implicit many-to-many junction tables carry a primary key on `(A, B)` instead of a unique index, and the source describes that shape. +When the project is ready to stop reading the Prisma 7 file, `prisma contract print --output ` writes the Prisma 8 PSL that produces the same contract, with a header naming the schema it came from; without `--output` it prints the PSL instead. Point `contract` at the written file, run `prisma contract emit`, then take migration ownership: `prisma migration plan --name baseline`, `prisma db sign`, and `prisma migration ref set db _baseline`. Without an explicit `output`, `defineConfig` names the emitted files after the contract path it is given, so a written file with another name than the emitted files (for example `contract: './prisma/app.prisma'` where `contract emit` wrote `prisma/contract.json`) moves them to `prisma/app.json` and `prisma/app.d.ts` and leaves the old files on disk; `contract print` names both pairs in its next step when that happens. The command needs no database connection, and refuses rather than approximating, so the contract before and after the switch is the same one. Not every Prisma 7 schema prints yet: a schema that declares one model name in two `@@schema` namespaces exits `2` with `CONTRACT.PRINT_UNSUPPORTED` and prints and writes nothing. + A construct is either described exactly or refused. There is no approximate lowering and no silent change. The source reads scalars and `@db.*` native types, `@map` and `@@map`, `@@schema`, enums as native enum types (with member `@map`), `@ignore` and `@@ignore`, defaults and ORM-side generators, `@updatedAt`, `@id`, `@@id`, `@unique`, `@@unique`, `@@index`, and explicit and implicit relations. Everything else is a hard error naming the file, the line, and what to change: views, `Unsupported(...)`, `@db.*` types Prisma 8 has no codec for, `relationMode = "prisma"`, and the handful of shapes in the table below that Prisma 8 cannot yet express. Prisma 7 still owns the database, so every edit below is a Prisma 7 schema change that Prisma 7's next migration applies; the table says what that migration does where it does anything: | Code | What it means | What to change | diff --git a/packages/3-extensions/postgres/src/config/define-config.ts b/packages/3-extensions/postgres/src/config/define-config.ts index f4d5aeacfdc0..fa5734321804 100644 --- a/packages/3-extensions/postgres/src/config/define-config.ts +++ b/packages/3-extensions/postgres/src/config/define-config.ts @@ -1,6 +1,9 @@ import postgresAdapter from '@internal/adapter-postgres/control'; import type { ContractConfig, PrismaNextConfig } from '@internal/config/config-types'; -import { defineConfig as coreDefineConfig } from '@internal/config/config-types'; +import { + defineConfig as coreDefineConfig, + defaultContractOutputPath, +} from '@internal/config/config-types'; import postgresDriver from '@internal/driver-postgres/control'; import sql from '@internal/family-sql/control'; import type { ControlExtensionDescriptor } from '@internal/framework-components/control'; @@ -12,7 +15,6 @@ import postgresPackRef from '@internal/target-postgres/pack'; import { postgresCreateNamespace } from '@internal/target-postgres/types'; import { ifDefined } from '@internal/utils/defined'; import { extname, join } from 'pathe'; -import { isDynamicPattern } from 'tinyglobby'; export interface PostgresConfigOptions { /** A contract file path (`.prisma` or `.ts`), or a ready `ContractConfig` such as `prisma7Schema(...)`. */ @@ -27,27 +29,6 @@ export interface PostgresConfigOptions { }; } -function staticPrefixDirectory(pattern: string): string { - const staticSegments: string[] = []; - for (const segment of pattern.replaceAll('\\', '/').split('/')) { - if (isDynamicPattern(segment)) break; - staticSegments.push(segment); - } - return staticSegments.join('/'); -} - -function deriveOutputPath(contractPath: string): string { - if (isDynamicPattern(contractPath)) { - const prefix = staticPrefixDirectory(contractPath); - return prefix.length === 0 ? 'contract.json' : `${prefix}/contract.json`; - } - const ext = extname(contractPath); - if (ext.length === 0) { - return `${contractPath}.json`; - } - return `${contractPath.slice(0, -ext.length)}.json`; -} - function contractConfigFromPath(contractPath: string, output: string): ContractConfig { return extname(contractPath) === '.ts' ? typescriptContractFromPath(contractPath, output) @@ -65,14 +46,14 @@ function resolveContractConfig(options: PostgresConfigOptions): ContractConfig { if (typeof options.contract === 'string') { return contractConfigFromPath( options.contract, - explicitOutput ?? deriveOutputPath(options.contract), + explicitOutput ?? defaultContractOutputPath(options.contract), ); } const firstInput = options.contract.source.inputs?.[0]; const output = explicitOutput ?? options.contract.output ?? - (firstInput !== undefined ? deriveOutputPath(firstInput) : undefined); + (firstInput !== undefined ? defaultContractOutputPath(firstInput) : undefined); return { ...options.contract, ...ifDefined('output', output) }; } diff --git a/packages/3-extensions/postgres/test/config/define-config.prisma7.test.ts b/packages/3-extensions/postgres/test/config/define-config.prisma7.test.ts index 6355533ca2ef..95239f3ef6dd 100644 --- a/packages/3-extensions/postgres/test/config/define-config.prisma7.test.ts +++ b/packages/3-extensions/postgres/test/config/define-config.prisma7.test.ts @@ -8,7 +8,7 @@ describe('defineConfig with a ContractConfig', () => { const config = defineConfig({ contract }); expect(config.contract?.source).toBe(contract.source); - expect(config.contract?.source.format).toBe('prisma7'); + expect(config.contract?.source.format).toBe('psl'); expect(config.contract?.source.inputs).toEqual(['prisma/schema.prisma']); expect(config.contract?.output).toBe('prisma/contract.json'); }); diff --git a/packages/3-extensions/sqlite/src/config/define-config.ts b/packages/3-extensions/sqlite/src/config/define-config.ts index a0b1e23cc84e..f58ed70df787 100644 --- a/packages/3-extensions/sqlite/src/config/define-config.ts +++ b/packages/3-extensions/sqlite/src/config/define-config.ts @@ -1,6 +1,9 @@ import sqliteAdapter from '@internal/adapter-sqlite/control'; import type { PrismaNextConfig } from '@internal/config/config-types'; -import { defineConfig as coreDefineConfig } from '@internal/config/config-types'; +import { + defineConfig as coreDefineConfig, + defaultContractOutputPath, +} from '@internal/config/config-types'; import sqliteDriver from '@internal/driver-sqlite/control'; import sql from '@internal/family-sql/control'; import type { ControlExtensionDescriptor } from '@internal/framework-components/control'; @@ -11,7 +14,6 @@ import sqlite, { sqliteCreateNamespace } from '@internal/target-sqlite/control'; import sqlitePackRef from '@internal/target-sqlite/pack'; import { ifDefined } from '@internal/utils/defined'; import { extname, join } from 'pathe'; -import { isDynamicPattern } from 'tinyglobby'; export interface SqliteConfigOptions { readonly contract: string; @@ -25,33 +27,12 @@ export interface SqliteConfigOptions { }; } -function staticPrefixDirectory(pattern: string): string { - const staticSegments: string[] = []; - for (const segment of pattern.replaceAll('\\', '/').split('/')) { - if (isDynamicPattern(segment)) break; - staticSegments.push(segment); - } - return staticSegments.join('/'); -} - -function deriveOutputPath(contractPath: string): string { - if (isDynamicPattern(contractPath)) { - const prefix = staticPrefixDirectory(contractPath); - return prefix.length === 0 ? 'contract.json' : `${prefix}/contract.json`; - } - const ext = extname(contractPath); - if (ext.length === 0) { - return `${contractPath}.json`; - } - return `${contractPath.slice(0, -ext.length)}.json`; -} - export function defineConfig(options: SqliteConfigOptions): PrismaNextConfig<'sql', 'sqlite'> { const extensions = options.extensions ?? []; const output = options.output !== undefined ? join(options.output, 'contract.json') - : deriveOutputPath(options.contract); + : defaultContractOutputPath(options.contract); const ext = extname(options.contract); const contractConfig = diff --git a/packages/3-extensions/supabase/scripts/generate-contract.ts b/packages/3-extensions/supabase/scripts/generate-contract.ts index fdaf0e802a46..aa21dd2a1543 100644 --- a/packages/3-extensions/supabase/scripts/generate-contract.ts +++ b/packages/3-extensions/supabase/scripts/generate-contract.ts @@ -478,7 +478,11 @@ async function main(): Promise { 'generate-contract: postgres target descriptor has no authoring.pslBlockDescriptors', ); } - const pslContent = printPsl(merged, { pslBlockDescriptors }); + const pslContent = printPsl(merged, { + pslBlockDescriptors, + description: + 'Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.', + }); const contractPrismaPath = join(packageRoot, 'src', 'contract', 'contract.prisma'); writeFileSync(contractPrismaPath, pslContent, 'utf8'); diff --git a/packages/3-targets/3-targets/postgres/README.md b/packages/3-targets/3-targets/postgres/README.md index e7b26d94bdf0..fc549f894f6c 100644 --- a/packages/3-targets/3-targets/postgres/README.md +++ b/packages/3-targets/3-targets/postgres/README.md @@ -25,6 +25,7 @@ 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 native type's usual type when the stack's constructor of that name does, or else another, such as `TimestamptzString(3)` or an extension's `pgvector.Vector(3)`. A value-object field's type and a literal default go through the stack the same way: the stack's codec names the native type, and the data type of the column's codec writes the literal. 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, the native enum block, and the type map. 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, so each relation with a foreign key writes `index: false`. Execution generators join back onto their columns: an id generator prints as its `@default` call, and a wall-clock-now pair prints as the `temporal.*` preset of the column's codec. - **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"`. This package spans multiple planes: diff --git a/packages/3-targets/3-targets/postgres/package.json b/packages/3-targets/3-targets/postgres/package.json index 50bbe1f390c9..6271ff1fd25a 100644 --- a/packages/3-targets/3-targets/postgres/package.json +++ b/packages/3-targets/3-targets/postgres/package.json @@ -24,6 +24,7 @@ "@internal/migration-tools": "workspace:8.0.0-rc.12", "@internal/psl-parser": "workspace:8.0.0-rc.12", "@internal/sql-contract": "workspace:8.0.0-rc.12", + "@internal/sql-contract-psl": "workspace:8.0.0-rc.12", "@internal/sql-errors": "workspace:8.0.0-rc.12", "@internal/sql-operations": "workspace:8.0.0-rc.12", "@internal/sql-relational-core": "workspace:8.0.0-rc.12", @@ -38,7 +39,6 @@ "devDependencies": { "@internal/psl-printer": "workspace:8.0.0-rc.12", "@internal/publish-surface": "workspace:8.0.0-rc.12", - "@internal/sql-contract-psl": "workspace:8.0.0-rc.12", "@repo/test-utils": "workspace:8.0.0-rc.12", "@repo/tsconfig": "workspace:8.0.0-rc.12", "@repo/tsdown": "workspace:8.0.0-rc.12", diff --git a/packages/3-targets/3-targets/postgres/src/core/authoring.ts b/packages/3-targets/3-targets/postgres/src/core/authoring.ts index a3497b877298..3d33e4f6daef 100644 --- a/packages/3-targets/3-targets/postgres/src/core/authoring.ts +++ b/packages/3-targets/3-targets/postgres/src/core/authoring.ts @@ -149,17 +149,28 @@ export interface RoleExtensionBlock extends PslExtensionBlock { } /** - * Maps a `policy_` keyword to the RLS operation it authors. The keyword - * IS the operation (per the project's rejection of a `policy { operation = … }` - * conditional block). + * The block keyword that authors each row-level security operation. The keyword is the operation, so + * a policy block has no `operation` parameter. The reader looks a block's operation up here, and + * `contract infer` and `contract print` look up the keyword to write. */ -const POLICY_KEYWORD_OPERATION: Readonly> = { - policy_select: 'select', - policy_insert: 'insert', - policy_update: 'update', - policy_delete: 'delete', - policy_all: 'all', -}; +export const POLICY_BLOCK_KEYWORDS = { + select: 'policy_select', + insert: 'policy_insert', + update: 'policy_update', + delete: 'policy_delete', + all: 'policy_all', +} as const satisfies Readonly>; + +function isRlsPolicyOperation(value: string): value is RlsPolicyOperation { + return Object.hasOwn(POLICY_BLOCK_KEYWORDS, value); +} + +/** The operation a `policy_` block keyword authors, or `undefined` for another keyword. */ +function policyOperationOfKeyword(keyword: string): RlsPolicyOperation | undefined { + return Object.keys(POLICY_BLOCK_KEYWORDS) + .filter(isRlsPolicyOperation) + .find((operation) => POLICY_BLOCK_KEYWORDS[operation] === keyword); +} function readValueParam(block: PslExtensionBlock, key: string): string | undefined { const param = block.parameters[key]; @@ -172,11 +183,21 @@ function readListRefParams(block: PslExtensionBlock, key: string): string[] { return param.items.flatMap((item) => (item.kind === 'ref' ? [item.identifier] : [])); } +const JSON_STRING_ESCAPES: ReadonlyMap = new Map([ + ['"', '"'], + ['\\', '\\'], + ['/', '/'], + ['b', '\b'], + ['f', '\f'], + ['n', '\n'], + ['r', '\r'], + ['t', '\t'], +]); + /** - * Unwraps a quoted PSL string argument, inverting the printer's - * `escapePslString` escapes (`\\`, `\"`, `\n`, `\r`). An unknown escape - * sequence is kept verbatim, matching the printer-side `unescapePslString` - * convention. + * Unwraps a quoted policy expression. The PSL printer writes a block value as a JSON string, so + * each JSON escape is decoded, `\uXXXX` included. The PSL tokenizer lets a backslash escape any + * character; a backslash sequence that is not a JSON escape is kept as written. */ function unwrapQuotedString(raw: string): string { if (!(raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2)) { @@ -189,18 +210,19 @@ function unwrapQuotedString(raw: string): string { result += inner[i]; continue; } - const next = inner[i + 1]; - if (next === '\\' || next === '"') { - result += next; - } else if (next === 'n') { - result += '\n'; - } else if (next === 'r') { - result += '\r'; + const next = inner.charAt(i + 1); + const escaped = JSON_STRING_ESCAPES.get(next); + const hex = inner.slice(i + 2, i + 6); + if (escaped !== undefined) { + result += escaped; + i++; + } else if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(hex)) { + result += String.fromCharCode(Number.parseInt(hex, 16)); + i += 5; } else { - result += '\\'; - result += next; + result += `\\${next}`; + i++; } - i++; } return result; } @@ -252,7 +274,7 @@ function lowerRlsPolicyFromBlock( ctx: AuthoringEntityContext, ): PostgresRlsPolicy | undefined { const prefix = block.name; - const operation = POLICY_KEYWORD_OPERATION[block.keyword] ?? 'select'; + const operation = policyOperationOfKeyword(block.keyword) ?? 'select'; // The interpreter resolves the descriptor-declared `target` model ref to // its storage table name before invoking this factory (an unresolved or // missing required ref is the interpreter's diagnostic), so a lookup miss @@ -591,9 +613,9 @@ export const postgresAuthoringPslBlockDescriptors = { // (a wrong predicate for the operation is a load-time diagnostic there), // since the generic descriptor validator is not wired into the SQL-family // interpreter. - policy_select: { + [POLICY_BLOCK_KEYWORDS.select]: { kind: 'pslBlock', - keyword: 'policy_select', + keyword: POLICY_BLOCK_KEYWORDS.select, documentation: 'Defines a row-level security policy controlling which rows can be selected.', discriminator: 'policy', name: { required: true }, @@ -606,9 +628,9 @@ export const postgresAuthoringPslBlockDescriptors = { requiresModelAttribute: policyRequiresRls, attributes: policyBlockAttributes, }, - policy_delete: { + [POLICY_BLOCK_KEYWORDS.delete]: { kind: 'pslBlock', - keyword: 'policy_delete', + keyword: POLICY_BLOCK_KEYWORDS.delete, documentation: 'Defines a row-level security policy controlling which rows can be deleted.', discriminator: 'policy', name: { required: true }, @@ -621,9 +643,9 @@ export const postgresAuthoringPslBlockDescriptors = { requiresModelAttribute: policyRequiresRls, attributes: policyBlockAttributes, }, - policy_insert: { + [POLICY_BLOCK_KEYWORDS.insert]: { kind: 'pslBlock', - keyword: 'policy_insert', + keyword: POLICY_BLOCK_KEYWORDS.insert, documentation: 'Defines a row-level security policy checking rows being inserted.', discriminator: 'policy', name: { required: true }, @@ -636,9 +658,9 @@ export const postgresAuthoringPslBlockDescriptors = { requiresModelAttribute: policyRequiresRls, attributes: policyBlockAttributes, }, - policy_update: { + [POLICY_BLOCK_KEYWORDS.update]: { kind: 'pslBlock', - keyword: 'policy_update', + keyword: POLICY_BLOCK_KEYWORDS.update, documentation: 'Defines a row-level security policy controlling row visibility and checks for updates.', discriminator: 'policy', @@ -653,9 +675,9 @@ export const postgresAuthoringPslBlockDescriptors = { requiresModelAttribute: policyRequiresRls, attributes: policyBlockAttributes, }, - policy_all: { + [POLICY_BLOCK_KEYWORDS.all]: { kind: 'pslBlock', - keyword: 'policy_all', + keyword: POLICY_BLOCK_KEYWORDS.all, documentation: 'Defines a row-level security policy applying to all operations.', discriminator: 'policy', name: { required: true }, diff --git a/packages/3-targets/3-targets/postgres/src/core/errors.ts b/packages/3-targets/3-targets/postgres/src/core/errors.ts index 4293967263f5..7aa9cdcb6ebe 100644 --- a/packages/3-targets/3-targets/postgres/src/core/errors.ts +++ b/packages/3-targets/3-targets/postgres/src/core/errors.ts @@ -4,6 +4,7 @@ import { structuredError } from '@internal/utils/structured-error'; export type PostgresTargetErrorCode = | 'CONTRACT.CODEC_DESCRIPTOR_MISSING' + | 'CONTRACT.PRINT_UNSUPPORTED' | 'CONTRACT.DEFAULT_INVALID' | 'CONTRACT.ENTITY_KIND_INVALID' | 'CONTRACT.IDENTIFIER_INVALID' diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-build/index-attributes.ts b/packages/3-targets/3-targets/postgres/src/core/psl-build/index-attributes.ts new file mode 100644 index 000000000000..0ca4a646e636 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-build/index-attributes.ts @@ -0,0 +1,135 @@ +import type { + PslAttributeArgument, + PslModelAttribute, +} from '@internal/framework-components/psl-ast'; +import { escapePslString } from '@internal/sql-relational-core/ast'; +import { computeIndexContentHash, parseWireName } from '@internal/sql-schema-ir/naming'; +import { assertDefined } from '@internal/utils/assertions'; +import { buildAttribute, namedArg, positionalArg } from './psl-literals'; + +export function buildModelConstraintAttribute( + name: 'id' | 'unique', + fields: readonly string[], + constraintName?: string, +): PslModelAttribute { + const args: PslAttributeArgument[] = [positionalArg(`[${fields.join(', ')}]`)]; + if (constraintName !== undefined) { + args.push(namedArg('map', `"${escapePslString(constraintName)}"`)); + } + return buildAttribute('model', name, args); +} + +/** + * The parts of an index `@@index` reads. Both the schema IR node and the + * contract's storage node carry them, and both sides print indexes. + */ +export interface IndexAttributeSource { + readonly name: string; + readonly unique: boolean; + readonly columns?: readonly string[]; + readonly expression?: string; + readonly where?: string; + readonly type?: string; + readonly options?: Record; +} + +/** How an attribute names its object: `name:` with a wire prefix, or `map:` with the exact name. */ +export type AttributeNaming = + | { readonly kind: 'wire'; readonly prefix: string } + | { readonly kind: 'exact' }; + +/** + * The naming of a live index, re-detected rather than trusted: wire when the + * live name parses as a wire name AND that hash recomputes from the + * introspected content; otherwise exact. + */ +function detectIndexNaming(index: IndexAttributeSource): AttributeNaming { + const parsed = parseWireName(index.name); + const recomputed = computeIndexContentHash({ + ...(index.columns !== undefined ? { columns: index.columns } : {}), + ...(index.expression !== undefined ? { expression: index.expression } : {}), + ...(index.where !== undefined ? { where: index.where } : {}), + unique: index.unique, + ...(index.type !== undefined ? { type: index.type } : {}), + ...(index.options !== undefined ? { options: index.options } : {}), + }); + return parsed !== undefined && parsed.hash === recomputed + ? { kind: 'wire', prefix: parsed.prefix } + : { kind: 'exact' }; +} + +function namingArg(naming: AttributeNaming, exactName: string): PslAttributeArgument { + return naming.kind === 'wire' + ? namedArg('name', `"${escapePslString(naming.prefix)}"`) + : namedArg('map', `"${escapePslString(exactName)}"`); +} + +/** + * Emits one `@@index` attribute at full fidelity, named as `naming` says; + * without it, the naming is re-detected from the live name and content. + */ +export function buildIndexAttribute( + index: IndexAttributeSource, + fieldNames: readonly string[] | undefined, + naming: AttributeNaming = detectIndexNaming(index), +): PslModelAttribute { + const args: PslAttributeArgument[] = []; + if (fieldNames !== undefined) { + args.push(positionalArg(`[${fieldNames.join(', ')}]`)); + } else { + assertDefined( + index.expression, + `buildIndexAttribute: index "${index.name}" carries neither columns nor expression; SqlIndexIR enforces exactly one`, + ); + args.push(namedArg('expression', `"${escapePslString(index.expression)}"`)); + } + + args.push(namingArg(naming, index.name)); + + if (index.where !== undefined) { + args.push(namedArg('where', `"${escapePslString(index.where)}"`)); + } + if (index.unique) { + args.push(namedArg('unique', 'true')); + } + if (index.type !== undefined || index.options !== undefined) { + args.push(namedArg('type', `"${escapePslString(index.type ?? 'btree')}"`)); + } + if (index.options !== undefined) { + const entries = Object.entries(index.options ?? {}) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, value]) => `${key}: "${escapePslString(String(value))}"`); + args.push(namedArg('options', `{ ${entries.join(', ')} }`)); + } + return buildAttribute('model', 'index', args); +} + +/** + * The parts of a check constraint `@@check` reads. Both the schema IR node and + * the contract's storage node carry them, and both sides print checks. + */ +export interface CheckAttributeSource { + readonly name: string; + readonly expression: string; +} + +/** + * Emits one `@@check` attribute, named as `naming` says. Without it, a live, + * non-derived check is written in the `map:` form. Re-detecting `name:` is not + * attempted there: the live expression is Postgres's own reprint, but a + * wire-named check's hash was taken over the author's original text, so + * recomputing the hash from the reprint would essentially never match. `map:` + * plus the verbatim reprint is correct regardless — a reprint compared against + * a later reprint of the same expression is stable, so the emitted contract + * signs the live database with zero pending operations. `buildIntrospectedPolicyBlocks` + * makes the same call for `@@map` on adopted RLS policies, for the same reason. + */ +export function buildCheckAttribute( + check: CheckAttributeSource, + naming: AttributeNaming = { kind: 'exact' }, +): PslModelAttribute { + return buildAttribute('model', 'check', [ + namedArg('expression', `"${escapePslString(check.expression)}"`), + namingArg(naming, check.name), + ]); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-build/native-enum-block.ts b/packages/3-targets/3-targets/postgres/src/core/psl-build/native-enum-block.ts new file mode 100644 index 000000000000..59cfc33cf140 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-build/native-enum-block.ts @@ -0,0 +1,51 @@ +import { toEnumMemberName } from '@internal/family-sql/psl-build'; +import type { + PslExtensionBlock, + PslExtensionBlockParamValue, +} from '@internal/framework-components/psl-ast'; +import { NAME_THE_PSL_SOURCE_LOSES } from '@internal/psl-parser'; +import { escapePslString } from '@internal/sql-relational-core/ast'; +import { SYNTHETIC_SPAN } from './psl-literals'; +import { createUniqueFieldName } from './unique-name'; + +/** One `native_enum { … }` block, with `@@map` when the type name differs from the block name. */ +export function buildNativeEnumBlock( + name: string, + typeName: string, + values: readonly string[], +): PslExtensionBlock { + const usedMemberNames = new Set([NAME_THE_PSL_SOURCE_LOSES]); + const parameters = Object.fromEntries( + values.map((value): [string, PslExtensionBlockParamValue] => { + const memberName = createUniqueFieldName(toEnumMemberName(value), usedMemberNames); + usedMemberNames.add(memberName); + return [memberName, { kind: 'value', raw: JSON.stringify(value), span: SYNTHETIC_SPAN }]; + }), + ); + + return { + kind: 'native_enum', + keyword: 'native_enum', + name, + parameters, + blockAttributes: + name === typeName + ? [] + : [ + { + name: 'map', + args: [ + { + kind: 'positional', + value: `"${escapePslString(typeName)}"`, + span: SYNTHETIC_SPAN, + }, + ], + span: SYNTHETIC_SPAN, + }, + ], + attributes: + name === typeName ? {} : { map: { args: { name: typeName }, span: SYNTHETIC_SPAN } }, + span: SYNTHETIC_SPAN, + }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts b/packages/3-targets/3-targets/postgres/src/core/psl-build/postgres-type-map.ts similarity index 95% rename from packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-build/postgres-type-map.ts index e417a617d008..681cdaea0414 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-build/postgres-type-map.ts @@ -1,4 +1,4 @@ -import type { PslTypeMap, PslTypeResolution } from '@internal/family-sql/psl-infer'; +import type { PslTypeMap, PslTypeResolution } from '@internal/family-sql/psl-build'; const POSTGRES_TO_PSL: Record = { text: 'String', @@ -53,11 +53,11 @@ const PARAMETERIZED_NATIVE_TYPES: Record = { }; /** - * Every PSL type name this map prints for a column whose native type it recognises. A column's + * Every PSL type name this map writes for a column whose native type it recognises. A column's * literal default has to be written in the form the codec bound to its type name reads back, so * `infer-default-codec.ts` names a codec for each of these. */ -export const PRINTED_PSL_TYPE_NAMES: ReadonlySet = new Set([ +export const INFERRED_PSL_TYPE_NAMES: ReadonlySet = new Set([ ...Object.values(POSTGRES_TO_PSL), ...Object.values(PRESERVED_NATIVE_TYPES), ...Object.values(PARAMETERIZED_NATIVE_TYPES), diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/psl-literals.ts b/packages/3-targets/3-targets/postgres/src/core/psl-build/psl-literals.ts similarity index 67% rename from packages/3-targets/3-targets/postgres/src/core/psl-infer/psl-literals.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-build/psl-literals.ts index ff7198af1f72..990440d0a599 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/psl-literals.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-build/psl-literals.ts @@ -1,5 +1,3 @@ -import { type ColumnDefault, isColumnDefault } from '@internal/contract/types'; -import type { PslPrinterOptions } from '@internal/family-sql/psl-infer'; import type { PslAttribute, PslAttributeArgument, @@ -58,22 +56,3 @@ export function positionalArg(value: string): PslAttributeArgument { export function namedArg(name: string, value: string): PslAttributeArgument { return { kind: 'named', name, value, span: SYNTHETIC_SPAN }; } - -/** - * Resolves a `SqlColumnIR.default` value into a normalized {@link ColumnDefault}. - * - * `SqlSchemaIR` types the column default as `string` (a raw database default - * expression). Some legacy fixtures and tests still pass already-normalized - * `ColumnDefault` objects in the same slot, so we accept either shape - * defensively at runtime. - */ -export function parseColumnDefault( - value: unknown, - nativeType: string | undefined, - rawDefaultParser: PslPrinterOptions['parseRawDefault'], -): ColumnDefault | undefined { - if (typeof value === 'string') { - return rawDefaultParser ? rawDefaultParser(value, nativeType) : undefined; - } - return isColumnDefault(value) ? value : undefined; -} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-build/unique-name.ts b/packages/3-targets/3-targets/postgres/src/core/psl-build/unique-name.ts new file mode 100644 index 000000000000..b49b7e78224a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-build/unique-name.ts @@ -0,0 +1,15 @@ +/** `desiredName`, or it with the lowest number from 2 up that makes it unused. */ +export function createUniqueFieldName( + desiredName: string, + usedFieldNames: ReadonlySet, +): string { + if (!usedFieldNames.has(desiredName)) { + return desiredName; + } + + let counter = 2; + while (usedFieldNames.has(`${desiredName}${counter}`)) { + counter++; + } + return `${desiredName}${counter}`; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-default-codec.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-default-codec.ts index f76b8d26a5dc..f4b2823868ce 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-default-codec.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-default-codec.ts @@ -1,11 +1,13 @@ /** - * The codec `contract emit` binds to a printed column, and the data type that codec represents. + * The codec `contract emit` binds to a column `contract infer` writes, and the data type that codec + * represents. * - * `contract emit` binds a codec to each PSL type constructor the printer names, so a default has to - * be written in the form that codec reads back. The binding itself lives in the adapter's authoring - * type namespaces, which sit above this package; the table below restates it for the type names the - * printer emits, and `adapter-postgres/test/printed-type-codecs.test.ts` fails if the two disagree - * or if the printer gains a type name this table does not cover. + * `contract emit` binds a codec to each PSL type constructor `contract infer` names, so a default has + * to be written in the form that codec reads back. The binding itself lives in the adapter's + * authoring type namespaces, which sit above this package, and `contract infer` has no stack to ask; + * the table below restates it for the type names `contract infer` writes, and + * `adapter-postgres/test/inferred-type-codecs.test.ts` fails if the two disagree or if the type map + * gains a type name this table does not cover. */ import type { ColumnDefaultLiteralInputValue, JsonValue } from '@internal/contract/types'; @@ -18,8 +20,8 @@ import { blindCast } from '@internal/utils/casts'; import { PG_TEXT_CODEC_ID } from '../codec-ids'; import { postgresCodecDescriptorRegistry } from '../registry'; -/** The codec `contract emit` binds to each PSL type name the type map prints. */ -export const CODEC_ID_BY_PRINTED_TYPE: ReadonlyMap = new Map([ +/** The codec `contract emit` binds to each PSL type name the type map writes. */ +export const CODEC_ID_BY_INFERRED_TYPE: ReadonlyMap = new Map([ ['String', 'pg/text@1'], ['Boolean', 'pg/bool@1'], ['Int', 'pg/int4@1'], @@ -47,11 +49,11 @@ export const CODEC_ID_BY_PRINTED_TYPE: ReadonlyMap = new Map([ * An enum column's default is a member name, which is text either way, so it reads through the text * codec. */ -export function dataTypeForPrintedType( +export function dataTypeForInferredType( pslTypeName: string, isEnum: boolean, ): DataTypeId | undefined { - const codecId = isEnum ? PG_TEXT_CODEC_ID : CODEC_ID_BY_PRINTED_TYPE.get(pslTypeName); + const codecId = isEnum ? PG_TEXT_CODEC_ID : CODEC_ID_BY_INFERRED_TYPE.get(pslTypeName); if (codecId === undefined) return undefined; return postgresCodecDescriptorRegistry.descriptorFor(codecId)?.dataType; } @@ -59,8 +61,8 @@ export function dataTypeForPrintedType( const codecs = new Map(); /** One instance per codec id: every codec in the table above decodes JSON the same way for any params. */ -function printedTypeCodec(pslTypeName: string, isEnum: boolean): Codec | undefined { - const codecId = isEnum ? PG_TEXT_CODEC_ID : CODEC_ID_BY_PRINTED_TYPE.get(pslTypeName); +function inferredTypeCodec(pslTypeName: string, isEnum: boolean): Codec | undefined { + const codecId = isEnum ? PG_TEXT_CODEC_ID : CODEC_ID_BY_INFERRED_TYPE.get(pslTypeName); if (codecId === undefined) return undefined; const cached = codecs.get(codecId); if (cached !== undefined) return cached; @@ -79,13 +81,13 @@ function printedTypeCodec(pslTypeName: string, isEnum: boolean): Codec | undefin * stores and reports verbatim. A default the codec refuses has no PSL literal, so the raw * expression prints instead of a schema `contract emit` would reject. */ -export function printedDefaultReadsBack( +export function inferredDefaultReadsBack( value: ColumnDefaultLiteralInputValue, pslTypeName: string, isEnum: boolean, isList: boolean, ): boolean { - const codec = printedTypeCodec(pslTypeName, isEnum); + const codec = inferredTypeCodec(pslTypeName, isEnum); if (codec === undefined) return false; const values = isList && Array.isArray(value) ? value : [value]; return values.every((element) => { diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts index 42905f819341..0f4fdce7eb91 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts @@ -1,15 +1,8 @@ -import { toEnumMemberName, toEnumName } from '@internal/family-sql/psl-infer'; -import type { - PslExtensionBlock, - PslExtensionBlockParamValue, -} from '@internal/framework-components/psl-ast'; -import { escapePslString } from '@internal/sql-relational-core/ast'; -import { - buildTopLevelNameMap, - createUniqueFieldName, - type TopLevelNameResult, -} from './infer-names'; -import { SYNTHETIC_SPAN } from './psl-literals'; +import { toEnumName } from '@internal/family-sql/psl-infer'; +import type { PslExtensionBlock } from '@internal/framework-components/psl-ast'; +import { buildNativeEnumBlock } from '../psl-build/native-enum-block'; +import { createUniqueFieldName } from '../psl-build/unique-name'; +import { buildTopLevelNameMap, type TopLevelNameResult } from './infer-names'; export const PSL_SCALAR_TYPE_NAMES = new Set([ 'String', @@ -66,43 +59,3 @@ export function buildNativeEnumBlocks( return { enumNameMap, enumBlocks }; } - -function buildNativeEnumBlock( - name: string, - typeName: string, - values: readonly string[], -): PslExtensionBlock { - const usedMemberNames = new Set(); - const parameters: Record = {}; - for (const value of values) { - const memberName = createUniqueFieldName(toEnumMemberName(value), usedMemberNames); - usedMemberNames.add(memberName); - parameters[memberName] = { kind: 'value', raw: JSON.stringify(value), span: SYNTHETIC_SPAN }; - } - - return { - kind: 'native_enum', - keyword: 'native_enum', - name, - parameters, - blockAttributes: - name === typeName - ? [] - : [ - { - name: 'map', - args: [ - { - kind: 'positional', - value: `"${escapePslString(typeName)}"`, - span: SYNTHETIC_SPAN, - }, - ], - span: SYNTHETIC_SPAN, - }, - ], - attributes: - name === typeName ? {} : { map: { args: { name: typeName }, span: SYNTHETIC_SPAN } }, - span: SYNTHETIC_SPAN, - }; -} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts deleted file mode 100644 index 334269b37ff3..000000000000 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { - PslAttributeArgument, - PslModelAttribute, -} from '@internal/framework-components/psl-ast'; -import { escapePslString } from '@internal/sql-relational-core/ast'; -import { computeIndexContentHash, parseWireName } from '@internal/sql-schema-ir/naming'; -import type { SqlCheckConstraintIR, SqlIndexIR } from '@internal/sql-schema-ir/types'; -import { assertDefined } from '@internal/utils/assertions'; -import { buildAttribute, namedArg, positionalArg } from './psl-literals'; - -export function buildModelConstraintAttribute( - name: 'id' | 'unique', - fields: readonly string[], - constraintName?: string, -): PslModelAttribute { - const args: PslAttributeArgument[] = [positionalArg(`[${fields.join(', ')}]`)]; - if (constraintName !== undefined) { - args.push(namedArg('map', `"${escapePslString(constraintName)}"`)); - } - return buildAttribute('model', name, args); -} - -/** - * Emits one `@@index` attribute at full fidelity. The index's identity is - * re-detected rather than trusted: `name:` is emitted only when the live name - * parses as a wire name AND that hash recomputes from the introspected - * content; otherwise the live name is adopted verbatim with `map:`. - */ -export function buildIndexAttribute( - index: SqlIndexIR, - fieldNames: readonly string[] | undefined, -): PslModelAttribute { - const args: PslAttributeArgument[] = []; - if (fieldNames !== undefined) { - args.push(positionalArg(`[${fieldNames.join(', ')}]`)); - } else { - assertDefined( - index.expression, - `buildIndexAttribute: index "${index.name}" carries neither columns nor expression; SqlIndexIR enforces exactly one`, - ); - args.push(namedArg('expression', `"${escapePslString(index.expression)}"`)); - } - - const parsed = parseWireName(index.name); - const recomputed = computeIndexContentHash({ - ...(index.columns !== undefined ? { columns: index.columns } : {}), - ...(index.expression !== undefined ? { expression: index.expression } : {}), - ...(index.where !== undefined ? { where: index.where } : {}), - unique: index.unique, - ...(index.type !== undefined ? { type: index.type } : {}), - ...(index.options !== undefined ? { options: index.options } : {}), - }); - if (parsed !== undefined && parsed.hash === recomputed) { - args.push(namedArg('name', `"${escapePslString(parsed.prefix)}"`)); - } else { - args.push(namedArg('map', `"${escapePslString(index.name)}"`)); - } - - if (index.where !== undefined) { - args.push(namedArg('where', `"${escapePslString(index.where)}"`)); - } - if (index.unique) { - args.push(namedArg('unique', 'true')); - } - const hasOptions = index.options !== undefined && Object.keys(index.options).length > 0; - if (index.type !== undefined || hasOptions) { - args.push(namedArg('type', `"${escapePslString(index.type ?? 'btree')}"`)); - } - if (hasOptions) { - const entries = Object.entries(index.options ?? {}) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([key, value]) => `${key}: "${escapePslString(String(value))}"`); - args.push(namedArg('options', `{ ${entries.join(', ')} }`)); - } - return buildAttribute('model', 'index', args); -} - -/** - * Emits one `@@check` attribute for a live, non-derived check, always in the - * `map:` form. Re-detecting `name:` is not attempted: the live expression is - * Postgres's own reprint, but a wire-named check's hash was taken over the - * author's original text, so recomputing the hash from the reprint would - * essentially never match. `map:` plus the verbatim reprint is correct - * regardless — a reprint compared against a later reprint of the same - * expression is stable, so the emitted contract signs the live database with - * zero pending operations. `buildPolicyBlocks` makes the same call for - * `@@map` on adopted RLS policies, for the same reason. - */ -export function buildCheckAttribute(check: SqlCheckConstraintIR): PslModelAttribute { - return buildAttribute('model', 'check', [ - namedArg('expression', `"${escapePslString(check.expression)}"`), - namedArg('map', `"${escapePslString(check.name)}"`), - ]); -} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts index 1c5c9e4dfde8..1d363204fb5c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts @@ -1,11 +1,15 @@ -import type { ColumnDefault, ColumnDefaultLiteralInputValue } from '@internal/contract/types'; -import type { - DefaultMappingOptions, - PslPrinterOptions, - PslTypeMap, - RelationField, -} from '@internal/family-sql/psl-infer'; -import { mapDefault, toFieldName, toModelName } from '@internal/family-sql/psl-infer'; +import { + type ColumnDefault, + type ColumnDefaultLiteralInputValue, + isColumnDefault, +} from '@internal/contract/types'; +import { + type DefaultMappingOptions, + mapDefault, + type PslTypeMap, +} from '@internal/family-sql/psl-build'; +import type { PslPrinterOptions, RelationField } from '@internal/family-sql/psl-infer'; +import { toFieldName, toModelName } from '@internal/family-sql/psl-infer'; import type { PslAttributeArgument, PslField, @@ -23,28 +27,24 @@ import { import type { SqlColumnIR, SqlTableIR } from '@internal/sql-schema-ir/types'; import { ifDefined } from '@internal/utils/defined'; import { postgresRenderCheckExpressions } from '../check-expressions'; -import { dataTypeForPrintedType, printedDefaultReadsBack } from './infer-default-codec'; -import { buildDanglingForeignKeyWarning, type DanglingForeignKeyInfo } from './infer-foreign-keys'; import { buildCheckAttribute, buildIndexAttribute, buildModelConstraintAttribute, -} from './infer-index-attributes'; -import { - createUniqueFieldName, - resolveColumnFieldName, - type TableColumnFieldNameMap, -} from './infer-names'; +} from '../psl-build/index-attributes'; import { buildAttribute, buildMapAttribute, buildSimpleConstraintFieldAttribute, namedArg, - parseColumnDefault, parseDefaultAttributeString, positionalArg, SYNTHETIC_SPAN, -} from './psl-literals'; +} from '../psl-build/psl-literals'; +import { createUniqueFieldName } from '../psl-build/unique-name'; +import { dataTypeForInferredType, inferredDefaultReadsBack } from './infer-default-codec'; +import { buildDanglingForeignKeyWarning, type DanglingForeignKeyInfo } from './infer-foreign-keys'; +import { resolveColumnFieldName, type TableColumnFieldNameMap } from './infer-names'; export function buildModel( table: SqlTableIR, @@ -284,11 +284,14 @@ function buildScalarField( rawDefaultParser, { ...defaultMapping, - ...ifDefined('columnDataType', dataTypeForPrintedType(resolution.pslType.name, isEnumColumn)), + ...ifDefined( + 'columnDataType', + dataTypeForInferredType(resolution.pslType.name, isEnumColumn), + ), list: column.many === true, }, (value) => - printedDefaultReadsBack(value, resolution.pslType.name, isEnumColumn, column.many === true), + inferredDefaultReadsBack(value, resolution.pslType.name, isEnumColumn, column.many === true), ); if (defaultAttribute !== undefined) { attributes.push(parseDefaultAttributeString(defaultAttribute)); @@ -471,3 +474,22 @@ export function buildRelationField( span: SYNTHETIC_SPAN, }; } + +/** + * Resolves a `SqlColumnIR.default` value into a normalized {@link ColumnDefault}. + * + * `SqlSchemaIR` types the column default as `string` (a raw database default + * expression). Some legacy fixtures and tests still pass already-normalized + * `ColumnDefault` objects in the same slot, so we accept either shape + * defensively at runtime. + */ +function parseColumnDefault( + value: unknown, + nativeType: string | undefined, + rawDefaultParser: PslPrinterOptions['parseRawDefault'], +): ColumnDefault | undefined { + if (typeof value === 'string') { + return rawDefaultParser ? rawDefaultParser(value, nativeType) : undefined; + } + return isColumnDefault(value) ? value : undefined; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-names.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-names.ts index d7572c009722..918e04324010 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-names.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-names.ts @@ -3,6 +3,7 @@ import type { PslModel } from '@internal/framework-components/psl-ast'; import type { SqlTableIR } from '@internal/sql-schema-ir/types'; import { assertDefined } from '@internal/utils/assertions'; import { postgresError } from '../errors'; +import { createUniqueFieldName } from '../psl-build/unique-name'; export type ResolvedColumnFieldName = { readonly fieldName: string; @@ -69,21 +70,6 @@ export function resolveColumnFieldName( ); } -export function createUniqueFieldName( - desiredName: string, - usedFieldNames: ReadonlySet, -): string { - if (!usedFieldNames.has(desiredName)) { - return desiredName; - } - - let counter = 2; - while (usedFieldNames.has(`${desiredName}${counter}`)) { - counter++; - } - return `${desiredName}${counter}`; -} - export function buildTopLevelNameMap( sources: Iterable, normalize: (source: string) => TopLevelNameResult, diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts index e99d3d7b808b..22a05465ed64 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts @@ -1,20 +1,11 @@ import type { PslExtensionBlock } from '@internal/framework-components/psl-ast'; +import { isPslIdentifier } from '@internal/psl-parser'; import { escapePslString } from '@internal/sql-relational-core/ast'; import { parseWireName } from '@internal/sql-schema-ir/naming'; import { assertDefined } from '@internal/utils/assertions'; +import { POLICY_BLOCK_KEYWORDS } from '../authoring'; +import { SYNTHETIC_SPAN } from '../psl-build/psl-literals'; import type { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; -import { SYNTHETIC_SPAN } from './psl-literals'; - -const POLICY_OPERATION_KEYWORD = { - select: 'policy_select', - insert: 'policy_insert', - update: 'policy_update', - delete: 'policy_delete', - all: 'policy_all', -} as const; - -/** The PSL tokenizer's identifier grammar: leading letter/underscore, then letters/digits/`_`/`-`. */ -const PSL_IDENTIFIER = /^[\p{L}_][\p{L}\p{N}_-]*$/u; /** Replaces invalid character runs with `_`; prepends `_` when the first character is invalid. */ function sanitizePolicyHead(raw: string): string { @@ -38,7 +29,7 @@ interface PolicyBlockEmission { * legal identifier cannot be authored at all — role references have no * `@@map` escape — so it is skipped with a note. */ -export function buildPolicyBlocks( +export function buildIntrospectedPolicyBlocks( policiesByTable: ReadonlyMap, modelNameMap: ReadonlyMap, reservedHeads: ReadonlySet = new Set(), @@ -74,10 +65,10 @@ export function buildPolicyBlocks( // never live data, and must not silently under-describe the database. assertDefined( modelName, - `buildPolicyBlocks: policy "${policy.name}" targets table "${tableName}" with no emitted model; tables and policies come from the same introspection walk`, + `buildIntrospectedPolicyBlocks: policy "${policy.name}" targets table "${tableName}" with no emitted model; tables and policies come from the same introspection walk`, ); - const badRole = policy.roles.find((role) => !PSL_IDENTIFIER.test(role)); + const badRole = policy.roles.find((role) => !isPslIdentifier(role)); if (badRole !== undefined) { const notes = skipNotesByTable.get(tableName) ?? []; notes.push( @@ -97,7 +88,7 @@ export function buildPolicyBlocks( blocks.push({ kind: 'policy', - keyword: POLICY_OPERATION_KEYWORD[policy.operation], + keyword: POLICY_BLOCK_KEYWORDS[policy.operation], name: head, parameters: { target: { kind: 'ref', identifier: modelName, span: SYNTHETIC_SPAN }, diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts index 176db5b96574..50f6d723dcba 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts @@ -16,6 +16,8 @@ import { import { SqlSchemaIR, SqlTableIR } from '@internal/sql-schema-ir/types'; import { parsePostgresDefault } from '../default-normalizer'; import { postgresError } from '../errors'; +import { createPostgresTypeMap } from '../psl-build/postgres-type-map'; +import { SYNTHETIC_SPAN } from '../psl-build/psl-literals'; import type { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; import type { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; import { buildNativeEnumBlocks, PSL_SCALAR_TYPE_NAMES } from './infer-enum-blocks'; @@ -26,10 +28,8 @@ import { } from './infer-foreign-keys'; import { buildModel } from './infer-model-blocks'; import { buildFieldNamesByTable, buildTopLevelNameMap, topologicalSort } from './infer-names'; -import { buildPolicyBlocks } from './infer-policy-blocks'; +import { buildIntrospectedPolicyBlocks } from './infer-policy-blocks'; import { createPostgresDefaultMapping } from './postgres-default-mapping'; -import { createPostgresTypeMap } from './postgres-type-map'; -import { SYNTHETIC_SPAN } from './psl-literals'; /** * Infers a PSL AST (for `printPsl`) from an introspected Postgres schema tree. @@ -310,7 +310,7 @@ export function buildPslDocumentAst( ]); const { relationsByTable } = inferRelations(schemaIR.tables, modelNameMap); - const policyEmission = buildPolicyBlocks( + const policyEmission = buildIntrospectedPolicyBlocks( rlsExtras?.policiesByTable ?? new Map(), modelNameMap, new Set([ diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/junction-relation-field-names.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/junction-relation-field-names.ts index 336e1c9ab116..6a77f4501530 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/junction-relation-field-names.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/junction-relation-field-names.ts @@ -2,9 +2,9 @@ import { toModelName } from '@internal/family-sql/psl-infer'; import { flatPslModels } from '@internal/framework-components/psl-ast'; import { SqlSchemaIR, SqlTableIR } from '@internal/sql-schema-ir/types'; import { parsePostgresDefault } from '../default-normalizer'; +import { createPostgresTypeMap } from '../psl-build/postgres-type-map'; import { buildPslDocumentAst } from './infer-psl-contract'; import { createPostgresDefaultMapping } from './postgres-default-mapping'; -import { createPostgresTypeMap } from './postgres-type-map'; const EMPTY_FOREIGN_KEY_EXTRAS = { extraRelationsByTable: new Map(), diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts index ecb7271066aa..8d3635fd0f4f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts @@ -1,4 +1,4 @@ -import type { DefaultMappingOptions } from '@internal/family-sql/psl-infer'; +import type { DefaultMappingOptions } from '@internal/family-sql/psl-build'; import { createDataTypeLookup } from '@internal/framework-components/codec'; import { postgresDataTypeEntries } from '../data-type-entries'; import { postgresDataTypes } from '../data-types'; diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/column-defaults.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/column-defaults.ts new file mode 100644 index 000000000000..ec5e0bebc51b --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/column-defaults.ts @@ -0,0 +1,78 @@ +import type { ContractEnum } from '@internal/contract/types'; +import type { SqlPslBuildContext } from '@internal/family-sql/control'; +import { mapDefault } from '@internal/family-sql/psl-build'; +import type { PslFieldAttribute } from '@internal/framework-components/psl-ast'; +import type { StorageColumn } from '@internal/sql-contract/types'; +import { PG_TEXT_CODEC_ID } from '../codec-ids'; +import { + buildAttribute, + parseDefaultAttributeString, + positionalArg, +} from '../psl-build/psl-literals'; +import { refuseDefaultOutsideEnum, refuseUnwritableLiteralDefault } from './refusals'; + +/** + * The `@default(…)` attribute for a storage column, or `undefined` when the column carries no + * default. + * + * A literal prints as the PSL literal the data type of the column's codec reads back, through the + * stack's data types, with the mapping `contract infer` uses. An enum column's default is a member + * name, which reads as text; a domain enum's literal prints as the member name that carries it. + * `now()` and `autoincrement()` print by name; every other function default prints as a `sql` + * tagged literal. + * + * A literal is refused when the column's codec has no data type in the stack, or when no PSL + * literal of that data type reads back as the stored value. + */ +export function buildColumnDefault(input: { + readonly column: StorageColumn; + readonly pslTypeName: string; + readonly isEnum: boolean; + /** The domain enum the column is typed by, whose member names are the accepted default form. */ + readonly domainEnum: ContractEnum | undefined; + readonly namespaceId: string; + readonly tableName: string; + readonly columnName: string; + readonly context: SqlPslBuildContext; +}): PslFieldAttribute | undefined { + const columnDefault = input.column.default; + if (columnDefault === undefined) { + return undefined; + } + const coordinate = `"${input.namespaceId}"."${input.tableName}"."${input.columnName}"`; + + if (input.domainEnum !== undefined && columnDefault.kind === 'literal') { + const memberName = input.domainEnum.members.find( + (member) => member.value === columnDefault.value, + )?.name; + if (memberName === undefined) { + refuseDefaultOutsideEnum({ + coordinate, + value: columnDefault.value, + pslTypeName: input.pslTypeName, + }); + } + return buildAttribute('field', 'default', [positionalArg(memberName)]); + } + + const { context } = input; + const result = mapDefault(columnDefault, { + dataTypeEntries: context.authoringContributions.dataTypes, + dataTypes: context.dataTypeLookup, + columnDataType: context.codecLookup.descriptorFor?.( + input.isEnum ? PG_TEXT_CODEC_ID : input.column.codecId, + )?.dataType, + list: input.column.many === true, + }); + if (result === undefined) { + refuseUnwritableLiteralDefault({ + coordinate, + written: + columnDefault.kind === 'literal' + ? JSON.stringify(columnDefault.value) + : columnDefault.expression, + pslTypeName: input.pslTypeName, + }); + } + return parseDefaultAttributeString(result.attribute); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/column-types.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/column-types.ts new file mode 100644 index 000000000000..f0b69c343041 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/column-types.ts @@ -0,0 +1,125 @@ +import type { PslTypeMap } from '@internal/family-sql/psl-build'; +import { + type AuthoringTypeConstructorCall, + type AuthoringTypeNamespace, + findAuthoringTypeConstructorCall, +} from '@internal/framework-components/authoring'; +import type { PslTypeConstructorCall } from '@internal/framework-components/psl-ast'; +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 { refuseColumnWithoutPslType, refuseUnwritableTypeArgument } from './refusals'; + +/** The PSL type position of one column: a bare name, or a type-constructor call. */ +export interface PslColumnType { + readonly typeName: string; + readonly typeConstructor?: PslTypeConstructorCall; +} + +/** + * The type arguments a native type takes, in the order the PSL type + * constructor declares them. + */ +const TYPE_PARAM_ORDER = ['length', 'precision', 'scale'] as const; + +/** + * Re-composes the parenthesised native type the type map resolves + * (`numeric(10,2)`) from a column's bare native type and its type parameters. + */ +function nativeTypeText(column: StorageColumn): string { + const args = TYPE_PARAM_ORDER.map((key) => column.typeParams?.[key]).filter( + (value) => typeof value === 'number', + ); + return args.length === 0 ? column.nativeType : `${column.nativeType}(${args.join(', ')})`; +} + +/** A type argument as PSL writes it: a number as written, a string between quotes. */ +function typeArgumentText(value: unknown, coordinate: string): string { + if (typeof value !== 'string') return String(value); + refuseUnwritableTypeArgument(value, coordinate); + return `"${value}"`; +} + +function typeCall(path: readonly string[], args: readonly string[]): PslColumnType { + const name = path.join('.'); + if (args.length === 0) { + return { typeName: name }; + } + return { + typeName: name, + typeConstructor: { + kind: 'typeConstructor', + path, + args: args.map(positionalArg), + span: SYNTHETIC_SPAN, + }, + }; +} + +/** + * The call to the type constructor the type map `contract infer` uses names for the column's native + * type, when the stack's constructor of that name produces exactly the column's codec, native type + * and type parameters. + */ +function typeMapCall( + column: StorageColumn, + typeMap: PslTypeMap, + authoringTypes: AuthoringTypeNamespace, +): AuthoringTypeConstructorCall | undefined { + const resolution = typeMap.resolve(nativeTypeText(column)); + if ('unsupported' in resolution) return undefined; + const { name } = resolution.pslType; + const descriptor = authoringTypes[name]; + return descriptor === undefined + ? undefined + : findAuthoringTypeConstructorCall({ [name]: descriptor }, column); +} + +/** + * 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 the type map `contract infer` uses names for the native type comes first; otherwise + * the first one in the stack that produces them, such as `TimestamptzString(3)` for a `timestamptz` + * column carried as text, or `pgvector.Vector(3)`. 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. + */ +export function buildColumnType(input: { + readonly column: StorageColumn; + readonly typeMap: PslTypeMap; + readonly authoringTypes: AuthoringTypeNamespace; + readonly enumBlockNames: ReadonlyMap; + readonly coordinate: string; +}): PslColumnType { + const { column, typeMap, authoringTypes, enumBlockNames, coordinate } = input; + + const enumBlockName = column.valueSet?.entityName ?? enumBlockNames.get(column.nativeType); + if (column.codecId === PG_ENUM_CODEC_ID && enumBlockName !== undefined) { + return { + typeName: enumBlockName, + typeConstructor: { + kind: 'typeConstructor', + path: ['pg', 'enum'], + args: [positionalArg(enumBlockName)], + span: SYNTHETIC_SPAN, + }, + }; + } + + const domainEnumName = column.valueSet?.entityName; + if (domainEnumName !== undefined) { + return { typeName: domainEnumName }; + } + + const call = + typeMapCall(column, typeMap, authoringTypes) ?? + findAuthoringTypeConstructorCall(authoringTypes, column); + if (call !== undefined) { + return typeCall( + call.path, + call.args.map((value) => typeArgumentText(value, coordinate)), + ); + } + + refuseColumnWithoutPslType(column, coordinate); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/contract-model-index.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/contract-model-index.ts new file mode 100644 index 000000000000..dbc20563ccfb --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/contract-model-index.ts @@ -0,0 +1,141 @@ +import type { Contract, ContractModelBase, CrossReference } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { UNBOUND_PSL_NAMESPACE_NAME } from '@internal/framework-components/psl-ast'; +import { canonicalizeJson } from '@internal/framework-components/utils'; +import type { + ForeignKey, + SqlModelStorage, + SqlStorage, + StorageTable, +} from '@internal/sql-contract/types'; +import { assertDefined } from '@internal/utils/assertions'; +import { blindCast } from '@internal/utils/casts'; + +/** One domain model paired with the storage table it is bridged to. */ +export interface ModelWithTable { + readonly namespaceId: string; + readonly name: string; + readonly tableName: string; + readonly storage: SqlModelStorage; + readonly model: ContractModelBase; + readonly table: StorageTable; + /** Column name → field name, the inverse of `storage.fields`. */ + readonly fieldNamesByColumn: ReadonlyMap; + /** The columns this model's own fields occupy. */ + readonly ownColumns: ReadonlySet; +} + +/** How a model relates to a polymorphic base, when it is a variant of one. */ +export interface VariantInfo { + readonly base: ModelWithTable; + readonly value: string; + /** The variant shares the base's table (single-table inheritance). */ + readonly singleTable: boolean; +} + +/** The name a contract namespace is written under in PSL. */ +export function pslNamespaceName(namespaceId: string): string { + return namespaceId === UNBOUND_NAMESPACE_ID ? UNBOUND_PSL_NAMESPACE_NAME : namespaceId; +} + +export function modelCoordinate(namespaceId: string, modelName: string): string { + return JSON.stringify([namespaceId, modelName]); +} + +/** + * Every domain model of the contract, in declaration order, with its storage table. Contract + * validation has checked that each model's table exists, in the namespace of the model. + */ +export function indexContractModels(contract: Contract): readonly ModelWithTable[] { + const entries: ModelWithTable[] = []; + for (const [namespaceId, domainNamespace] of Object.entries(contract.domain.namespaces)) { + for (const [name, model] of Object.entries(domainNamespace.models)) { + const storage = blindCast(model.storage); + const table = + contract.storage.namespaces[storage.namespaceId]?.entries.table?.[storage.table]; + assertDefined( + table, + `model "${namespaceId}.${name}" is stored in table "${storage.namespaceId}"."${storage.table}", which the storage does not declare`, + ); + const fields = Object.entries(storage.fields); + entries.push({ + namespaceId: storage.namespaceId, + name, + tableName: storage.table, + storage, + model, + table, + fieldNamesByColumn: new Map(fields.map(([fieldName, field]) => [field.column, fieldName])), + ownColumns: new Set(fields.map(([, field]) => field.column)), + }); + } + } + return entries; +} + +/** Models keyed by their `(namespace, model)` coordinate. */ +export function modelsByCoordinate( + entries: readonly ModelWithTable[], +): ReadonlyMap { + return new Map(entries.map((entry) => [modelCoordinate(entry.namespaceId, entry.name), entry])); +} + +/** A cross reference's coordinate; one into another contract space never matches a model of this contract. */ +export function crossReferenceCoordinate(reference: CrossReference): string { + return reference.space === undefined + ? modelCoordinate(reference.namespace, reference.model) + : JSON.stringify([reference.space, reference.namespace, reference.model]); +} + +/** The base a variant model extends, with the discriminator value it is stored under. */ +export function variantInfo( + entry: ModelWithTable, + byCoordinate: ReadonlyMap, +): VariantInfo | undefined { + if (entry.model.base === undefined) return undefined; + const base = byCoordinate.get(crossReferenceCoordinate(entry.model.base)); + const value = base?.model.variants?.[entry.name]?.value; + assertDefined( + base, + `model "${entry.namespaceId}.${entry.name}" extends a base the contract does not declare`, + ); + assertDefined( + value, + `model "${entry.namespaceId}.${entry.name}" extends base "${base.name}", which does not list it as a variant`, + ); + return { + base, + value, + singleTable: base.namespaceId === entry.namespaceId && base.tableName === entry.tableName, + }; +} + +/** The columns the PSL source adds to a multi-table variant's table: its base's primary key. */ +export function variantLinkColumns( + entry: ModelWithTable, + variant: VariantInfo | undefined, +): readonly string[] { + if (variant === undefined || variant.singleTable) return []; + return (variant.base.table.primaryKey?.columns ?? []).filter( + (column) => !entry.ownColumns.has(column), + ); +} + +/** + * The foreign key the PSL source synthesizes from a multi-table variant to its base: unnamed, over + * the base's primary key columns, cascading on delete. + */ +export function isVariantLinkForeignKey(fk: ForeignKey, variant: VariantInfo | undefined): boolean { + if (variant === undefined || variant.singleTable) return false; + const baseKey = canonicalizeJson(variant.base.table.primaryKey?.columns ?? []); + return ( + fk.target.namespaceId === variant.base.namespaceId && + fk.target.tableName === variant.base.tableName && + fk.target.spaceId === undefined && + canonicalizeJson(fk.source.columns) === baseKey && + canonicalizeJson(fk.target.columns) === baseKey && + fk.name === undefined && + fk.onDelete === 'cascade' && + fk.onUpdate === undefined + ); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/domain-types.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/domain-types.ts new file mode 100644 index 000000000000..8a6db34023f2 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/domain-types.ts @@ -0,0 +1,148 @@ +import type { Contract, ContractField, ScalarFieldType } from '@internal/contract/types'; +import type { SqlPslBuildContext } from '@internal/family-sql/control'; +import type { PslTypeMap } from '@internal/family-sql/psl-build'; +import type { + PslCompositeType, + PslField, + PslNamedTypeDeclaration, + PslTypesBlock, +} from '@internal/framework-components/psl-ast'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { StorageColumn } from '@internal/sql-contract/types'; +import { ifDefined } from '@internal/utils/defined'; +import { isPostgresCodecDescriptor } from '../codec-descriptor'; +import { SYNTHETIC_SPAN } from '../psl-build/psl-literals'; +import { buildColumnType, type PslColumnType } from './column-types'; +import { + refuseUnwritableFieldShape, + refuseUnwritableName, + refuseValueObjectFieldCodecNeedingTypeParameters, + refuseValueObjectFieldCodecWithoutNativeType, + refuseValueObjectFieldPartsTheSourceDrops, + refuseValueObjectsOutsideDefaultNamespace, +} from './refusals'; + +/** + * The native type the stack's codec names for a value-object field. The field has no column of its + * own, and no type parameters, which the PSL source would not keep. + */ +function nativeTypeOfValueObjectField( + field: ContractField & { readonly type: ScalarFieldType }, + coordinate: string, + context: SqlPslBuildContext, +): string { + const { codecId } = field.type; + const descriptor = context.codecLookup.descriptorFor?.(codecId); + if (!isPostgresCodecDescriptor(descriptor)) { + refuseValueObjectFieldCodecWithoutNativeType(codecId, coordinate); + } + try { + return descriptor.nativeTypeFor({ codecId }); + } catch { + refuseValueObjectFieldCodecNeedingTypeParameters(codecId, coordinate); + } +} + +/** The PSL type position of a domain field: a value object by name, or a scalar as a column would print. */ +export function buildDomainFieldType(input: { + readonly field: ContractField; + readonly coordinate: string; + readonly typeMap: PslTypeMap; + readonly context: SqlPslBuildContext; + readonly enumBlockNames: ReadonlyMap; +}): PslColumnType { + const { field, coordinate } = input; + refuseUnwritableFieldShape(field, coordinate); + const { type } = field; + if (type.kind === 'valueObject') { + return { typeName: type.name }; + } + refuseValueObjectFieldPartsTheSourceDrops({ ...field, type }, coordinate); + return buildColumnType({ + column: new StorageColumn({ + nativeType: nativeTypeOfValueObjectField({ ...field, type }, coordinate, input.context), + codecId: type.codecId, + nullable: field.nullable, + ...ifDefined('many', field.many), + }), + typeMap: input.typeMap, + authoringTypes: input.context.authoringContributions.type, + enumBlockNames: input.enumBlockNames, + coordinate, + }); +} + +/** The `type` blocks of one namespace, one per value object the domain declares there. */ +export function buildCompositeTypes(input: { + readonly contract: Contract; + readonly namespaceId: string; + readonly typeMap: PslTypeMap; + readonly context: SqlPslBuildContext; + readonly enumBlockNames: ReadonlyMap; +}): readonly PslCompositeType[] { + refuseValueObjectsOutsideDefaultNamespace(input.contract, input.namespaceId); + const valueObjects = input.contract.domain.namespaces[input.namespaceId]?.valueObjects ?? {}; + return Object.entries(valueObjects).map(([name, valueObject]) => { + refuseUnwritableName('value object', name); + return { + kind: 'compositeType', + name, + fields: Object.entries(valueObject.fields).map(([fieldName, field]): PslField => { + refuseUnwritableName('field', fieldName); + const { typeName, typeConstructor } = buildDomainFieldType({ + field, + coordinate: `"${input.namespaceId}".${name}.${fieldName}`, + typeMap: input.typeMap, + context: input.context, + enumBlockNames: input.enumBlockNames, + }); + return { + kind: 'field', + name: fieldName, + typeName, + ...ifDefined('typeConstructor', typeConstructor), + optional: field.nullable, + list: field.many === true, + attributes: [], + span: SYNTHETIC_SPAN, + }; + }), + attributes: [], + span: SYNTHETIC_SPAN, + }; + }); +} + +/** The document's `types { … }` block, one declaration per named storage type. */ +export function buildTypesBlock( + contract: Contract, + typeMap: PslTypeMap, + context: SqlPslBuildContext, +): PslTypesBlock | undefined { + const declarations: PslNamedTypeDeclaration[] = []; + for (const [name, instance] of Object.entries(contract.storage.types ?? {})) { + refuseUnwritableName('named type', name); + const { typeName, typeConstructor } = buildColumnType({ + column: new StorageColumn({ + nativeType: instance.nativeType, + codecId: instance.codecId, + nullable: false, + ...ifDefined('typeParams', instance.typeParams), + }), + typeMap, + authoringTypes: context.authoringContributions.type, + enumBlockNames: new Map(), + coordinate: `types.${name}`, + }); + declarations.push({ + kind: 'namedType', + name, + ...(typeConstructor === undefined ? { baseType: typeName } : { typeConstructor }), + attributes: [], + span: SYNTHETIC_SPAN, + }); + } + return declarations.length === 0 + ? undefined + : { kind: 'types', declarations, span: SYNTHETIC_SPAN }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/enum-blocks.ts new file mode 100644 index 000000000000..d59aea4f006c --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/enum-blocks.ts @@ -0,0 +1,136 @@ +import type { ContractEnum } from '@internal/contract/types'; +import type { + PslExtensionBlock, + PslExtensionBlockParamValue, +} from '@internal/framework-components/psl-ast'; +import type { StorageColumn } from '@internal/sql-contract/types'; +import { escapePslString } from '@internal/sql-relational-core/ast'; +import type { PostgresNativeEnum } from '../postgres-native-enum'; +import { buildNativeEnumBlock } from '../psl-build/native-enum-block'; +import { SYNTHETIC_SPAN } from '../psl-build/psl-literals'; +import { + refuseNativeEnumControl, + refuseNativeEnumWithoutValueSet, + refuseUnwritableName, +} from './refusals'; + +/** + * One `enum { … }` block per domain enum, each member written as + * `Name = ` under `@@type`. The PSL source reads a member value as JSON, + * so the value is written as JSON text. + */ +export function buildDomainEnumBlocks( + enums: Readonly>, +): readonly PslExtensionBlock[] { + return Object.entries(enums).map(([name, domainEnum]): PslExtensionBlock => { + refuseUnwritableName('enum', name); + const parameters = Object.fromEntries( + domainEnum.members.map((member): [string, PslExtensionBlockParamValue] => { + refuseUnwritableName('enum member', member.name); + return [ + member.name, + { kind: 'value', raw: JSON.stringify(member.value), span: SYNTHETIC_SPAN }, + ]; + }), + ); + return { + kind: 'enum', + keyword: 'enum', + name, + parameters, + blockAttributes: [ + { + name: 'type', + args: [ + { + kind: 'positional', + value: `"${escapePslString(domainEnum.codecId)}"`, + span: SYNTHETIC_SPAN, + }, + ], + span: SYNTHETIC_SPAN, + }, + ], + attributes: { type: { args: { codec: domainEnum.codecId }, span: SYNTHETIC_SPAN } }, + span: SYNTHETIC_SPAN, + }; + }); +} + +export interface NativeEnumEmission { + readonly blocks: readonly PslExtensionBlock[]; + /** Native type name, bare and schema-qualified, → the `native_enum` block that declares it. */ + readonly blockNamesByTypeName: ReadonlyMap; + /** Block name → the members of the value set the PSL source derives from that block. */ + readonly derivedValueSets: ReadonlyMap; +} + +function sameValues(left: readonly unknown[], right: readonly unknown[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +/** + * Builds one `native_enum` block per enum type a namespace declares. + * + * A contract keys a native enum by its type name and keys the value set it + * derives by the name the schema gave the enum, and the PSL source derives + * that value set from the block, named after the block. So the block is named + * after the enum's value set: the value set a column typed by the enum names; + * else, for an enum no column refers to, the unclaimed value set with the + * enum's own name that holds exactly its members, or else the first unclaimed + * one that does. An enum with no such value set is refused. `@@map` carries + * the type name whenever it differs from the block name. + */ +export function buildNativeEnumBlocksForNamespace(input: { + readonly namespaceId: string; + readonly nativeEnums: ReadonlyMap; + readonly valueSets: ReadonlyMap; + readonly columns: readonly StorageColumn[]; +}): NativeEnumEmission { + const valueSetNamesByTypeName = new Map(); + for (const column of input.columns) { + const valueSetName = column.valueSet?.entityName; + if (valueSetName === undefined) continue; + valueSetNamesByTypeName.set(column.nativeType, valueSetName); + } + const claimed = new Set(valueSetNamesByTypeName.values()); + + const unreferenced: { entryName: string; nativeEnum: PostgresNativeEnum }[] = []; + const nameByEntry = new Map(); + for (const [entryName, nativeEnum] of input.nativeEnums) { + refuseNativeEnumControl(input.namespaceId, nativeEnum); + const { typeName } = nativeEnum; + const fromColumn = + valueSetNamesByTypeName.get(typeName) ?? + valueSetNamesByTypeName.get(`${input.namespaceId}.${typeName}`); + if (fromColumn === undefined) { + unreferenced.push({ entryName, nativeEnum }); + continue; + } + nameByEntry.set(entryName, fromColumn); + } + + for (const { entryName, nativeEnum } of unreferenced) { + const candidates = [...input.valueSets] + .filter(([name, values]) => !claimed.has(name) && sameValues(values, nativeEnum.members)) + .map(([name]) => name); + const chosen = candidates.find((name) => name === entryName) ?? candidates[0]; + if (chosen === undefined) refuseNativeEnumWithoutValueSet(input.namespaceId, nativeEnum); + claimed.add(chosen); + nameByEntry.set(entryName, chosen); + } + + const blocks: PslExtensionBlock[] = []; + const blockNamesByTypeName = new Map(); + const derivedValueSets = new Map(); + for (const [entryName, nativeEnum] of input.nativeEnums) { + const { typeName } = nativeEnum; + const blockName = nameByEntry.get(entryName) ?? typeName; + refuseUnwritableName('native enum', blockName); + blocks.push(buildNativeEnumBlock(blockName, typeName, nativeEnum.members)); + blockNamesByTypeName.set(typeName, blockName); + blockNamesByTypeName.set(`${input.namespaceId}.${typeName}`, blockName); + derivedValueSets.set(blockName, nativeEnum.members); + } + return { blocks, blockNamesByTypeName, derivedValueSets }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/generated-values.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/generated-values.ts new file mode 100644 index 000000000000..22d81fabbe55 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/generated-values.ts @@ -0,0 +1,123 @@ +import type { ExecutionMutationDefault } from '@internal/contract/types'; +import type { + PslAttributeArgument, + PslFieldAttribute, +} from '@internal/framework-components/psl-ast'; +import { + PG_TIMESTAMP_STRING_CODEC_ID, + PG_TIMESTAMP_TEMPORAL_CODEC_ID, + PG_TIMESTAMPTZ_DATE_CODEC_ID, + PG_TIMESTAMPTZ_STRING_CODEC_ID, + PG_TIMESTAMPTZ_TEMPORAL_CODEC_ID, +} from '../codec-ids'; +import { postgresNowGeneratorIdFor } from '../now-generators'; +import { buildAttribute, namedArg, positionalArg } from '../psl-build/psl-literals'; +import { + refuseGeneratorOnUpdate, + refuseGeneratorWithoutPslFunction, + refuseNowGeneratorPairedWithAnother, +} from './refusals'; + +/** + * The `temporal.*` field preset each codec with a "now" generator is authored through. The target + * contributes the presets; `test/integration/test/psl-print/generated-values-roundtrip.integration.test.ts` writes and + * reads back every one the stack contributes, so this table cannot drift from them. + */ +const TEMPORAL_PRESET_NAMES: ReadonlyMap = new Map([ + [PG_TIMESTAMP_TEMPORAL_CODEC_ID, 'timestamp'], + [PG_TIMESTAMPTZ_TEMPORAL_CODEC_ID, 'timestamptz'], + [PG_TIMESTAMP_STRING_CODEC_ID, 'timestampString'], + [PG_TIMESTAMPTZ_STRING_CODEC_ID, 'timestamptzString'], + [PG_TIMESTAMPTZ_DATE_CODEC_ID, 'timestamptzJsDate'], +]); + +/** + * The `@default((…))` call each id generator is authored through. The Postgres adapter registers + * the functions; `test/integration/test/psl-print/generated-values-roundtrip.integration.test.ts` writes and reads back + * every call the stack registers, so this table cannot drift from them. + */ +type GeneratorCall = (params: Record | undefined) => string; + +const GENERATOR_CALLS: ReadonlyMap = new Map([ + ['uuidv4', () => 'uuid()'], + ['uuidv7', () => 'uuid(7)'], + ['cuid2', () => 'cuid(2)'], + ['ulid', () => 'ulid()'], + [ + 'nanoid', + (params) => (typeof params?.['size'] === 'number' ? `nanoid(${params['size']})` : 'nanoid()'), + ], +]); + +/** The phases a column's `temporal.*` preset must switch on. */ +export interface TemporalPresetPhases { + readonly presetName: string; + readonly onCreate: boolean; + readonly onUpdate: boolean; +} + +export type BuiltExecutionDefault = + | { readonly kind: 'attribute'; readonly attribute: PslFieldAttribute } + | { readonly kind: 'temporal'; readonly phases: TemporalPresetPhases }; + +/** + * How one column's execution generators are written in PSL: an id generator as a `@default(())` + * attribute, and a wall-clock-now generator as the `temporal.*` preset the column's codec is + * authored through. + */ +export function buildExecutionDefault(input: { + readonly executionDefault: ExecutionMutationDefault; + readonly codecId: string; + readonly coordinate: string; +}): BuiltExecutionDefault { + const { executionDefault, codecId, coordinate } = input; + const onCreateId = executionDefault.onCreate?.id; + const onUpdateId = executionDefault.onUpdate?.id; + + const nowGeneratorId = postgresNowGeneratorIdFor(codecId); + const presetName = TEMPORAL_PRESET_NAMES.get(codecId); + if (nowGeneratorId !== undefined && presetName !== undefined) { + const onCreate = onCreateId === nowGeneratorId; + const onUpdate = onUpdateId === nowGeneratorId; + if (onCreate || onUpdate) { + if ((onCreateId !== undefined && !onCreate) || (onUpdateId !== undefined && !onUpdate)) { + refuseNowGeneratorPairedWithAnother({ + coordinate, + onCreate: onCreateId, + onUpdate: onUpdateId, + other: onCreate ? onUpdateId : onCreateId, + }); + } + return { kind: 'temporal', phases: { presetName, onCreate, onUpdate } }; + } + } + + const phases = { coordinate, onCreate: onCreateId, onUpdate: onUpdateId }; + if (onUpdateId !== undefined) refuseGeneratorOnUpdate(phases); + const call = onCreateId === undefined ? undefined : GENERATOR_CALLS.get(onCreateId); + if (call === undefined) refuseGeneratorWithoutPslFunction(phases); + return { + kind: 'attribute', + attribute: buildAttribute('field', 'default', [ + positionalArg(call(executionDefault.onCreate?.params)), + ]), + }; +} + +/** The `temporal.(precision?, onCreate: now, onUpdate: now)` argument list. */ +export function temporalPresetArguments(input: { + readonly phases: TemporalPresetPhases; + readonly precision: unknown; +}): readonly PslAttributeArgument[] { + const args: PslAttributeArgument[] = []; + if (typeof input.precision === 'number') { + args.push(positionalArg(String(input.precision))); + } + if (input.phases.onCreate) { + args.push(namedArg('onCreate', 'now')); + } + if (input.phases.onUpdate) { + args.push(namedArg('onUpdate', 'now')); + } + return args; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/model-attributes.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/model-attributes.ts new file mode 100644 index 000000000000..006d7ed8f07f --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/model-attributes.ts @@ -0,0 +1,198 @@ +import type { PslModelAttribute } from '@internal/framework-components/psl-ast'; +import type { StorageTable } from '@internal/sql-contract/types'; +import { pslModelMapName } from '@internal/sql-contract-psl/map-names'; +import { escapePslString } from '@internal/sql-relational-core/ast'; +import { + composeCheckWirePrefix, + computeCheckContentHash, + computeIndexContentHash, + formatWireName, +} from '@internal/sql-schema-ir/naming'; +import { postgresRenderCheckExpressions } from '../check-expressions'; +import { PG_ENUM_CODEC_ID } from '../codec-ids'; +import { + type AttributeNaming, + buildCheckAttribute, + buildIndexAttribute, + buildModelConstraintAttribute, +} from '../psl-build/index-attributes'; +import { buildAttribute, buildMapAttribute, positionalArg } from '../psl-build/psl-literals'; +import type { ModelWithTable, VariantInfo } from './contract-model-index'; +import { refuseUnwritableIndexOptions, refuseUnwritableObjectName } from './refusals'; + +/** A check the PSL source derives for a table, which the printer does not write. */ +export interface DerivedCheck { + readonly prefix: string; + readonly expression: string; +} + +/** + * The checks the PSL source derives itself for a table, keyed by name, so + * they are not also written as `@@check`. A table that is not managed derives + * none. A managed one derives a membership check for each column typed by a + * domain enum and an element-not-null check for each list column, less the + * kinds a column waives with `@noCheck`. + */ +export function derivedChecks(input: { + readonly table: StorageTable; + readonly tableName: string; + readonly managed: boolean; + readonly domainEnumValues: ReadonlyMap; +}): ReadonlyMap { + const checks = new Map(); + if (!input.managed) return checks; + for (const [columnName, column] of Object.entries(input.table.columns)) { + const enumValues = + column.codecId === PG_ENUM_CODEC_ID || column.valueSet === undefined + ? undefined + : input.domainEnumValues.get(column.valueSet.entityName); + const memberValues = enumValues?.filter( + (value): value is string | number => typeof value === 'string' || typeof value === 'number', + ); + const waived = new Set(column.noCheck ?? []); + for (const candidate of postgresRenderCheckExpressions({ + tableName: input.tableName, + columnName, + many: column.many === true, + memberValues: memberValues?.length === enumValues?.length ? memberValues : undefined, + })) { + if (waived.has(candidate.kind)) continue; + const prefix = composeCheckWirePrefix(input.tableName, columnName, candidate.kind); + checks.set(formatWireName(prefix, computeCheckContentHash(candidate.expression)), { + prefix, + expression: candidate.expression, + }); + } + } + return checks; +} + +/** + * Whether a table object belongs to this model when several models share the + * table: a single-table variant owns what lies within its own columns, and the + * base owns the rest. + */ +function ownsTableObject( + entry: ModelWithTable, + variant: VariantInfo | undefined, + singleTableVariants: readonly ModelWithTable[], + columns: readonly string[] | undefined, +): boolean { + if (variant?.singleTable === true) { + return columns?.every((column) => entry.ownColumns.has(column)) === true; + } + return !singleTableVariants.some( + (other) => columns?.every((column) => other.ownColumns.has(column)) === true, + ); +} + +/** + * How a check or index is named in PSL: `name:` with its prefix when the name + * is that prefix and the hash of its content, which the PSL source derives + * again; `map:` when it has no prefix. A prefix whose name is anything else is + * refused, because neither form reads back as it. + */ +function attributeNaming(input: { + readonly kind: 'check' | 'index'; + readonly entry: ModelWithTable; + readonly name: string; + readonly prefix: string | undefined; + readonly contentHash: () => string; +}): AttributeNaming { + const { kind, entry, name, prefix } = input; + if (prefix === undefined) return { kind: 'exact' }; + if (formatWireName(prefix, input.contentHash()) === name) return { kind: 'wire', prefix }; + refuseUnwritableObjectName({ kind, entry, name, prefix }); +} + +/** + * The `@@` attributes of one model: its polymorphism, its keys, checks and indexes, its control + * policy, `@@rls` and `@@map`. A table object several models share goes on the model that owns it. + */ +export function buildModelAttributes(input: { + readonly entry: ModelWithTable; + readonly variant: VariantInfo | undefined; + readonly singleTableVariants: readonly ModelWithTable[]; + readonly derivedChecksByName: ReadonlyMap; + readonly rlsEnabled: boolean; +}): readonly PslModelAttribute[] { + const { entry, variant, singleTableVariants, derivedChecksByName } = input; + const attributes: PslModelAttribute[] = []; + const fieldNameOf = (column: string): string => entry.fieldNamesByColumn.get(column) ?? column; + const owns = (columns: readonly string[] | undefined): boolean => + ownsTableObject(entry, variant, singleTableVariants, columns); + + if (entry.model.discriminator !== undefined) { + attributes.push( + buildAttribute('model', 'discriminator', [positionalArg(entry.model.discriminator.field)]), + ); + } + if (variant !== undefined) { + attributes.push( + buildAttribute('model', 'base', [ + positionalArg(variant.base.name), + positionalArg(`"${escapePslString(variant.value)}"`), + ]), + ); + } + + const primaryKeyColumns = variant === undefined ? (entry.table.primaryKey?.columns ?? []) : []; + if (primaryKeyColumns.length > 1) { + attributes.push( + buildModelConstraintAttribute( + 'id', + primaryKeyColumns.map(fieldNameOf), + entry.table.primaryKey?.name, + ), + ); + } + for (const unique of entry.table.uniques) { + if (!owns(unique.columns)) continue; + attributes.push( + buildModelConstraintAttribute('unique', unique.columns.map(fieldNameOf), unique.name), + ); + } + for (const check of entry.table.checks ?? []) { + if (derivedChecksByName.has(check.name) || !owns(undefined)) continue; + attributes.push( + buildCheckAttribute( + check, + attributeNaming({ + kind: 'check', + entry, + name: check.name, + prefix: check.prefix, + contentHash: () => computeCheckContentHash(check.expression), + }), + ), + ); + } + for (const index of entry.table.indexes) { + if (!owns(index.columns)) continue; + refuseUnwritableIndexOptions(entry, index); + attributes.push( + buildIndexAttribute( + index, + index.columns?.map(fieldNameOf), + attributeNaming({ + kind: 'index', + entry, + name: index.name, + prefix: index.prefix, + contentHash: () => computeIndexContentHash(index), + }), + ), + ); + } + if (entry.table.control !== undefined && variant?.singleTable !== true) { + attributes.push(buildAttribute('model', 'control', [positionalArg(entry.table.control)])); + } + if (input.rlsEnabled && variant?.singleTable !== true) { + attributes.push(buildAttribute('model', 'rls', [])); + } + const mapName = pslModelMapName(entry.name, entry.tableName); + if (mapName !== undefined && variant?.singleTable !== true) { + attributes.push(buildMapAttribute('model', mapName)); + } + return attributes; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/psl-contract.ts new file mode 100644 index 000000000000..63c5471a8a0b --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/psl-contract.ts @@ -0,0 +1,361 @@ +import { + type Contract, + type ContractEnum, + type ExecutionMutationDefault, + effectiveControlPolicy, +} from '@internal/contract/types'; +import type { SqlPslBuildContext } from '@internal/family-sql/control'; +import type { PslTypeMap } from '@internal/family-sql/psl-build'; +import type { + PslDocumentAst, + PslExtensionBlock, + PslModel, + PslNamespace, +} from '@internal/framework-components/psl-ast'; +import { + makePslNamespace, + makePslNamespaceEntries, + UNSPECIFIED_PSL_NAMESPACE_ID, +} from '@internal/framework-components/psl-ast'; +import type { ForeignKey, SqlModelStorage, SqlStorage } from '@internal/sql-contract/types'; +import { invariant } from '@internal/utils/assertions'; +import { blindCast } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; +import { DEFAULT_NAMESPACE_ID } from '../namespace-ids'; +import { PostgresNativeEnum } from '../postgres-native-enum'; +import { createPostgresTypeMap } from '../psl-build/postgres-type-map'; +import { SYNTHETIC_SPAN } from '../psl-build/psl-literals'; +import { + indexContractModels, + type ModelWithTable, + modelCoordinate, + modelsByCoordinate, + pslNamespaceName, + type VariantInfo, + variantInfo, +} from './contract-model-index'; +import { buildCompositeTypes, buildTypesBlock } from './domain-types'; +import { + buildDomainEnumBlocks, + buildNativeEnumBlocksForNamespace, + type NativeEnumEmission, +} from './enum-blocks'; +import { buildModelAttributes, derivedChecks } from './model-attributes'; +import { + refuseContractMeta, + refuseDuplicateModelNames, + refuseEnumAndNativeEnumSharingName, + refuseEnumsOutsideDefaultNamespace, + refuseModelOwner, + refuseNamespaceNamedUnbound, + refuseRlsWithoutModel, + refuseUnderivedChecks, + refuseUnderivedRoots, + refuseUnderivedValueSets, + refuseUnderivedVariantLink, + refuseUnmodelledTablesAndColumns, + refuseUnprintedEntryKinds, + refuseUntravelledForeignKeys, + refuseUnwritableMappedName, + refuseUnwritableName, + refuseUnwrittenExecutionDefaults, + refuseUnwrittenNamespaces, +} from './refusals'; +import { + buildRelationFields, + collectRelations, + foreignKeyFor, + type ModelRelation, + relationsByModel, + resolvePinnedRelations, +} from './relation-fields'; +import { buildPolicyBlocks, buildRoleBlocks, rlsEnabledTables } from './row-level-security'; +import { buildScalarFields, executionDefaultsByColumn } from './scalar-fields'; + +/** What building every model needs from the whole contract, computed once. */ +interface ContractModels { + readonly contract: Contract; + readonly context: SqlPslBuildContext; + readonly typeMap: PslTypeMap; + readonly models: readonly ModelWithTable[]; + readonly byCoordinate: ReadonlyMap; + readonly variants: ReadonlyMap; + readonly relationsByModel: ReadonlyMap; + readonly pinned: ReadonlySet; + readonly executionDefaults: ReadonlyMap; + readonly writtenExecutionDefaults: Set; + readonly defaultDomainEnums: Readonly>; + readonly domainEnumValues: ReadonlyMap; +} + +/** The native enums of one namespace, keyed as the contract keys them. */ +function nativeEnumsOf( + namespaceId: string, + entries: SqlStorage['namespaces'][string]['entries'], +): ReadonlyMap { + const nativeEnums = new Map(); + for (const [name, entity] of Object.entries(entries['native_enum'] ?? {})) { + invariant( + PostgresNativeEnum.is(entity), + `"native_enum" entry "${name}" in namespace "${namespaceId}" is not a native enum`, + ); + nativeEnums.set(name, entity); + } + return nativeEnums; +} + +/** The value sets the PSL source derives for a namespace from the enum blocks written for it. */ +function derivedValueSetsOf( + namespaceId: string, + enums: NativeEnumEmission, + defaultDomainEnums: Readonly>, +): ReadonlyMap { + const derived = new Map(enums.derivedValueSets); + if (namespaceId !== DEFAULT_NAMESPACE_ID) return derived; + for (const [name, domainEnum] of Object.entries(defaultDomainEnums)) { + if (derived.has(name)) refuseEnumAndNativeEnumSharingName(namespaceId, name); + derived.set( + name, + domainEnum.members.map((member) => member.value), + ); + } + return derived; +} + +/** One model of a namespace, with its fields and attributes. */ +function buildModel( + all: ContractModels, + entry: ModelWithTable, + enums: NativeEnumEmission, + rlsTables: ReadonlySet, +): PslModel { + const { models, variants, relationsByModel, byCoordinate } = all; + refuseUnwritableName('model', entry.name); + refuseModelOwner(entry); + const variant = variants.get(entry); + if (variant !== undefined && !variant.singleTable) refuseUnderivedVariantLink(entry, variant); + const singleTableVariants = models.filter( + (other) => + other !== entry && + variants.get(other)?.singleTable === true && + variants.get(other)?.base === entry, + ); + const travelledForeignKeys = new Set(); + const fields = [ + ...buildScalarFields({ + entry, + variant, + enums, + domainEnums: all.contract.domain.namespaces[entry.namespaceId]?.enum ?? {}, + typeMap: all.typeMap, + context: all.context, + executionDefaults: all.executionDefaults, + writtenExecutionDefaults: all.writtenExecutionDefaults, + defaultDomainEnumNames: new Set(Object.keys(all.defaultDomainEnums)), + namedTypes: all.contract.storage.types ?? {}, + }), + ...buildRelationFields({ + entry, + variant, + relations: relationsByModel.get(modelCoordinate(entry.namespaceId, entry.name)) ?? [], + relationsByModel, + modelsByCoordinate: byCoordinate, + pinned: all.pinned, + travelledForeignKeys, + }), + ]; + if (variant?.singleTable !== true) { + for (const other of singleTableVariants) { + for (const relation of relationsByModel.get(modelCoordinate(entry.namespaceId, other.name)) ?? + []) { + const target = byCoordinate.get(relation.targetCoordinate); + const fk = target === undefined ? undefined : foreignKeyFor(relation, target); + if (fk !== undefined) travelledForeignKeys.add(fk); + } + } + } + refuseUntravelledForeignKeys(entry, variant, travelledForeignKeys); + const checks = derivedChecks({ + table: entry.table, + tableName: entry.tableName, + managed: + effectiveControlPolicy(entry.table.control, all.contract.defaultControlPolicy) === 'managed', + domainEnumValues: all.domainEnumValues, + }); + if (variant?.singleTable !== true) refuseUnderivedChecks(entry, checks); + return { + kind: 'model', + name: entry.name, + fields, + attributes: buildModelAttributes({ + entry, + variant, + singleTableVariants, + derivedChecksByName: checks, + rlsEnabled: rlsTables.has(entry.tableName), + }), + span: SYNTHETIC_SPAN, + }; +} + +/** + * The namespace block of one storage namespace: its models, value objects, enum blocks, roles and + * policies. `undefined` when the namespace holds none of them. + */ +function buildNamespace( + all: ContractModels, + namespaceId: string, + namespace: SqlStorage['namespaces'][string], +): PslNamespace | undefined { + const { contract, models, variants } = all; + refuseUnprintedEntryKinds(namespaceId, namespace.entries); + const valueSets = new Map( + Object.entries(namespace.entries.valueSet ?? {}).map(([name, valueSet]) => [ + name, + valueSet.values, + ]), + ); + const enums = buildNativeEnumBlocksForNamespace({ + namespaceId, + nativeEnums: nativeEnumsOf(namespaceId, namespace.entries), + valueSets, + columns: models + .filter((entry) => entry.namespaceId === namespaceId) + .flatMap((entry) => Object.values(entry.table.columns)), + }); + refuseUnderivedValueSets({ + namespaceId, + actual: valueSets, + derived: derivedValueSetsOf(namespaceId, enums, all.defaultDomainEnums), + }); + + const rlsTables = rlsEnabledTables(namespaceId, namespace.entries['rls']); + const namespaceModels = models + .filter((entry) => entry.namespaceId === namespaceId) + .map((entry) => buildModel(all, entry, enums, rlsTables)); + const compositeTypes = buildCompositeTypes({ + contract, + namespaceId, + typeMap: all.typeMap, + context: all.context, + enumBlockNames: enums.blockNamesByTypeName, + }); + const modelNameForTable = (tableName: string): string | undefined => + models.find( + (entry) => + entry.namespaceId === namespaceId && + entry.tableName === tableName && + variants.get(entry)?.singleTable !== true, + )?.name; + for (const tableName of rlsTables) { + if (modelNameForTable(tableName) === undefined) refuseRlsWithoutModel(namespaceId, tableName); + } + const blocks: readonly PslExtensionBlock[] = [ + ...enums.blocks, + ...buildRoleBlocks(namespaceId, namespace.entries['role']), + ...buildPolicyBlocks({ + namespaceId, + entries: namespace.entries['policy'], + modelNameForTable, + rlsTables, + }), + ]; + if (namespaceModels.length === 0 && blocks.length === 0 && compositeTypes.length === 0) { + return undefined; + } + refuseNamespaceNamedUnbound(namespaceId); + const name = pslNamespaceName(namespaceId); + refuseUnwritableName('namespace', name); + return makePslNamespace({ + kind: 'namespace', + name, + entries: makePslNamespaceEntries(namespaceModels, compositeTypes, blocks), + span: SYNTHETIC_SPAN, + }); +} + +function refuseUnwritableTableNames(contract: Contract): void { + for (const domainNamespace of Object.values(contract.domain.namespaces)) { + for (const model of Object.values(domainNamespace.models)) { + const storage = blindCast(model.storage); + refuseUnwritableMappedName('table', storage.table); + } + } +} + +/** + * Builds the Prisma 8 PSL document a Postgres contract was, or would have been, authored as: every + * model with its columns, keys, indexes and relations, every value object, every named type and + * every native enum, in the namespace block that carries it. + * + * Read back by the PSL contract source with the same stack, the document yields the contract it was + * built from. Anything the language cannot carry is refused by name. `contract` must be one the + * Postgres contract serializer accepted: its structure is asserted, not checked again. + */ +export function buildPostgresPslContract( + contract: Contract, + context: SqlPslBuildContext, +): PslDocumentAst { + refuseUnwritableTableNames(contract); + const models = indexContractModels(contract); + refuseDuplicateModelNames(models); + const byCoordinate = modelsByCoordinate(models); + const relations = collectRelations(models); + const byModel = relationsByModel(relations); + const defaultDomainEnums = contract.domain.namespaces[DEFAULT_NAMESPACE_ID]?.enum ?? {}; + const all: ContractModels = { + contract, + context, + typeMap: createPostgresTypeMap(), + models, + byCoordinate, + variants: new Map(models.map((entry) => [entry, variantInfo(entry, byCoordinate)])), + relationsByModel: byModel, + pinned: resolvePinnedRelations(relations, byModel), + executionDefaults: executionDefaultsByColumn(contract), + writtenExecutionDefaults: new Set(), + defaultDomainEnums, + domainEnumValues: new Map( + Object.entries(defaultDomainEnums).map(([name, domainEnum]) => [ + name, + domainEnum.members.map((member) => member.value), + ]), + ), + }; + + const namespaces: PslNamespace[] = []; + refuseEnumsOutsideDefaultNamespace(contract); + const topLevelEnums = buildDomainEnumBlocks(defaultDomainEnums); + if (topLevelEnums.length > 0) { + namespaces.push( + makePslNamespace({ + kind: 'namespace', + name: UNSPECIFIED_PSL_NAMESPACE_ID, + entries: makePslNamespaceEntries([], [], topLevelEnums), + span: SYNTHETIC_SPAN, + }), + ); + } + const writtenStorageNamespaces = new Set(); + for (const [namespaceId, namespace] of Object.entries(contract.storage.namespaces)) { + const built = buildNamespace(all, namespaceId, namespace); + if (built === undefined) continue; + writtenStorageNamespaces.add(namespaceId); + namespaces.push(built); + } + + refuseUnmodelledTablesAndColumns(contract, models, all.variants); + refuseUnwrittenNamespaces({ contract, models, writtenStorageNamespaces }); + refuseUnwrittenExecutionDefaults(contract, all.writtenExecutionDefaults); + refuseContractMeta(contract); + refuseUnderivedRoots(contract, models, all.variants); + + const types = buildTypesBlock(contract, all.typeMap, context); + return { + kind: 'document', + sourceId: '', + namespaces, + ...ifDefined('types', types), + span: SYNTHETIC_SPAN, + }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/refusals.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/refusals.ts new file mode 100644 index 000000000000..a194080b8d6c --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/refusals.ts @@ -0,0 +1,1034 @@ +/** + * Every part of a contract `contract print` cannot write as Prisma 8 PSL that reads back the same, + * one function per case in the `CONTRACT.PRINT_UNSUPPORTED` list of + * `docs/reference/error-reference.md`, in the same order. A `refuse…` function that returns `void` + * checks its input and throws when the case applies; one that returns `never` is called where the + * printer found the case. + */ + +import type { + Contract, + ContractField, + ExecutionMutationDefault, + ScalarFieldType, + ValueObjectFieldType, +} from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { UNBOUND_PSL_NAMESPACE_NAME } from '@internal/framework-components/psl-ast'; +import { canonicalizeJson } from '@internal/framework-components/utils'; +import { isPslIdentifier, NAME_THE_PSL_SOURCE_LOSES } from '@internal/psl-parser'; +import { + type ForeignKey, + type Index, + type SqlStorage, + StorageColumn, +} from '@internal/sql-contract/types'; +import { escapePslString } from '@internal/sql-relational-core/ast'; +import { ifDefined } from '@internal/utils/defined'; +import { PG_ENUM_CODEC_ID } from '../codec-ids'; +import { postgresError } from '../errors'; +import { DEFAULT_NAMESPACE_ID } from '../namespace-ids'; +import type { PostgresNativeEnum } from '../postgres-native-enum'; +import type { PostgresRlsPolicy } from '../postgres-rls-policy'; +import { + isVariantLinkForeignKey, + type ModelWithTable, + type VariantInfo, + variantLinkColumns, +} from './contract-model-index'; +import type { DerivedCheck } from './model-attributes'; + +const KEEP_SOURCE = 'Keep authoring this contract in its current source.'; + +function unsupported(message: string, why: string, fix: string, meta: Record) { + return postgresError('CONTRACT.PRINT_UNSUPPORTED', `contract print: ${message}`, { + why, + fix, + meta, + }); +} + +function sameJson(left: unknown, right: unknown): boolean { + return canonicalizeJson(left) === canonicalizeJson(right); +} + +function sameValues(left: readonly unknown[], right: readonly unknown[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +// Column types and defaults + +export function refuseColumnWithoutPslType(column: StorageColumn, coordinate: string): never { + throw unsupported( + `column ${coordinate} has native type "${column.nativeType}" with codec "${column.codecId}", and no PSL type in the configured stack produces that pair.`, + 'A column is written as a PSL type that reads back with its codec and native type, and none of the types the target, adapter and extensions contribute does.', + 'Add the extension that contributes this type to the config, or keep authoring this contract in its current source.', + { coordinate, nativeType: column.nativeType, codecId: column.codecId }, + ); +} + +/** + * Refuses a string type argument the PSL source would read back differently: it reads a string + * argument between its quotes as written, with no escapes. + */ +export function refuseUnwritableTypeArgument(value: string, coordinate: string): void { + if (escapePslString(value) === value) return; + throw unsupported( + `column ${coordinate} takes the type argument ${JSON.stringify(value)}, which a PSL type argument cannot carry.`, + 'The PSL source reads a string type argument as written between its quotes, with no escapes, so a quote, backslash or line break in it would read back differently.', + KEEP_SOURCE, + { coordinate, argument: value }, + ); +} + +export function refuseDefaultOutsideEnum(input: { + readonly coordinate: string; + readonly value: unknown; + readonly pslTypeName: string; +}): never { + throw unsupported( + `column ${input.coordinate} defaults to ${JSON.stringify(input.value)}, which is not a member of enum ${input.pslTypeName}.`, + 'A default on a domain enum column is written as the member name, and no member carries this value.', + 'Give the column a default that is one of the enum members, or drop it.', + { coordinate: input.coordinate, pslTypeName: input.pslTypeName }, + ); +} + +export function refuseUnwritableLiteralDefault(input: { + readonly coordinate: string; + readonly written: string; + readonly pslTypeName: string; +}): never { + throw unsupported( + `column ${input.coordinate} has a default that cannot be written in Prisma 8 PSL: no data type the ${input.pslTypeName} type takes writes ${input.written}.`, + 'Writing the value in any other form would parse, but the PSL source would read it back as a different value.', + 'Replace the default with a database expression default, or drop the default before printing.', + { coordinate: input.coordinate, pslTypeName: input.pslTypeName }, + ); +} + +// Generated values + +interface GeneratorPhases { + readonly coordinate: string; + readonly onCreate: string | undefined; + readonly onUpdate: string | undefined; +} + +export function refuseNowGeneratorPairedWithAnother( + phases: GeneratorPhases & { readonly other: string | undefined }, +): never { + throw unsupported( + `column ${phases.coordinate} takes the wall-clock-now generator in one phase and "${phases.other}" in the other, which cannot be written in Prisma 8 PSL.`, + 'The temporal preset a now generator is authored through writes each phase as `now`, so the other generator would be dropped from the written file.', + KEEP_SOURCE, + { coordinate: phases.coordinate, onCreate: phases.onCreate, onUpdate: phases.onUpdate }, + ); +} + +export function refuseGeneratorOnUpdate(phases: GeneratorPhases): never { + throw unsupported( + `column ${phases.coordinate} generates a value on update ("${phases.onUpdate}"), which cannot be written in Prisma 8 PSL.`, + '`@default(…)` writes a generator for create only; a generator on update is written only through a temporal preset, which takes the wall-clock-now generator.', + KEEP_SOURCE, + { coordinate: phases.coordinate, onCreate: phases.onCreate, onUpdate: phases.onUpdate }, + ); +} + +export function refuseGeneratorWithoutPslFunction(phases: GeneratorPhases): never { + throw unsupported( + `column ${phases.coordinate} is generated by "${phases.onCreate}", and the printer knows no PSL default function that produces it.`, + 'The printer writes each generator as the PSL default function of the Postgres adapter that produces it, and no such function produces this generator.', + KEEP_SOURCE, + { coordinate: phases.coordinate, onCreate: phases.onCreate, onUpdate: phases.onUpdate }, + ); +} + +export function refuseGeneratorWithDatabaseDefault(input: { + readonly coordinate: string; + readonly onCreate: string | undefined; +}): never { + throw unsupported( + `column ${input.coordinate} has both a generated value ("${input.onCreate}") and a database default, which cannot be written in Prisma 8 PSL.`, + '`@default(…)` writes either the generator or the database default, so the other would be lost.', + 'Drop one of the two, or keep authoring this contract in its current source.', + { coordinate: input.coordinate, onCreate: input.onCreate }, + ); +} + +/** Refuses a generated value the printer did not write with the field of its column. */ +export function refuseUnwrittenExecutionDefaults( + contract: Contract, + written: ReadonlySet, +): void { + for (const entry of contract.execution?.mutations.defaults ?? []) { + if (written.has(entry)) continue; + const coordinate = `"${entry.ref.namespace}"."${entry.ref.entry}"."${entry.ref.field}"`; + throw unsupported( + `a generated value names column ${coordinate}, which no field is stored in, so it cannot be written in Prisma 8 PSL.`, + 'PSL writes a generated value on the field stored in its column.', + KEEP_SOURCE, + { coordinate }, + ); + } +} + +// Fields and columns + +/** + * The value-set references the PSL source gives a field typed by a domain enum and its column: the + * column names the enum's value set, and a field that is not a list names the enum, both in the + * default namespace. A field with no domain enum has neither. + */ +function derivedValueSetRefs(enumName: string | undefined, list: boolean) { + if (enumName === undefined) return { field: undefined, column: undefined }; + const common = { namespaceId: DEFAULT_NAMESPACE_ID, entityName: enumName }; + return { + field: list ? undefined : { plane: 'domain', entityKind: 'enum', ...common }, + column: { plane: 'storage', entityKind: 'valueSet', ...common }, + }; +} + +/** + * Refuses a field whose column the PSL source would not derive from it: PSL writes the field once, + * and the PSL source derives the column's nullability, list marker, codec and type parameters from + * what is written. + */ +export function refuseFieldColumnMismatch(input: { + readonly field: ContractField; + readonly column: StorageColumn; + readonly coordinate: string; + readonly modelName: string; + readonly singleTableVariant: boolean; + readonly domainEnumNames: ReadonlySet; +}): void { + const { field, column, coordinate } = input; + const fix = + 'Make the field and its column agree, or keep authoring this contract in its current source.'; + if (input.singleTableVariant && !column.nullable) { + throw unsupported( + `column ${coordinate} of single-table variant "${input.modelName}" is not nullable, which cannot be written in Prisma 8 PSL.`, + "The PSL source makes every column of a single-table variant nullable, because the base's other rows leave it empty.", + fix, + { coordinate }, + ); + } + if (!input.singleTableVariant && field.nullable !== column.nullable) { + throw unsupported( + `field ${coordinate} is ${field.nullable ? 'optional' : 'required'} but its column is ${column.nullable ? 'nullable' : 'not nullable'}, which cannot be written in Prisma 8 PSL.`, + 'PSL writes `?` once, and the PSL source derives the column from the field.', + fix, + { coordinate }, + ); + } + const fieldListInColumn = field.type.kind === 'scalar' && field.many === true; + if (fieldListInColumn !== (column.many === true)) { + throw unsupported( + `field ${coordinate} is ${field.many === true ? 'a list' : 'not a list'} but its column is ${column.many === true ? 'a list' : 'not a list'}, which cannot be written in Prisma 8 PSL.`, + 'PSL writes `[]` once: the PSL source stores a list of scalars in a list column, and a list of value objects in one JSON column.', + fix, + { coordinate }, + ); + } + if ( + field.type.kind === 'scalar' && + (field.type.codecId !== column.codecId || !sameJson(field.type.typeParams, column.typeParams)) + ) { + throw unsupported( + `field ${coordinate} has a different codec or type parameters from its column, which cannot be written in Prisma 8 PSL.`, + "The PSL source derives a scalar field's codec and type parameters from its column.", + fix, + { coordinate }, + ); + } + if (field.type.kind !== 'scalar' || column.codecId === PG_ENUM_CODEC_ID) return; + const enumName = column.valueSet?.entityName; + const derived = derivedValueSetRefs(enumName, field.many === true); + if ( + !sameJson(field.valueSet, derived.field) || + !sameJson(column.valueSet, derived.column) || + (enumName !== undefined && !input.domainEnumNames.has(enumName)) + ) { + throw unsupported( + `field ${coordinate} and its column do not name the enum of the default namespace and its value set that the PSL source derives for a field typed by an enum, which cannot be written in Prisma 8 PSL.`, + 'PSL types the field by the enum name, and the PSL source then points the column at the value set of that enum in the default namespace, and a field that is not a list at the enum.', + fix, + { coordinate }, + ); + } +} + +/** + * Refuses a field PSL has no form for: one whose type is a union of types, or one that is a + * dictionary. Runs for model fields and value-object fields alike. + */ +export function refuseUnwritableFieldShape( + field: ContractField, + coordinate: string, +): asserts field is ContractField & { readonly type: ScalarFieldType | ValueObjectFieldType } { + if (field.type.kind === 'union') { + throw unsupported( + `field ${coordinate} has a ${field.type.kind} type, which cannot be written in Prisma 8 PSL.`, + 'A PSL field names one scalar, enum, or value-object type; a union of types has no PSL form.', + 'Give the field a single type, or keep authoring this contract in its current source.', + { coordinate, kind: field.type.kind }, + ); + } + if (field.dict !== true) return; + throw unsupported( + `field ${coordinate} is a dictionary, which cannot be written in Prisma 8 PSL.`, + 'PSL writes a field as one value or a list; it has no form for a keyed dictionary.', + KEEP_SOURCE, + { coordinate }, + ); +} + +/** + * Refuses a value-object field with type parameters or a value set: the PSL source keeps only the + * codec of a value-object field's type, so either would be lost. + */ +export function refuseValueObjectFieldPartsTheSourceDrops( + field: ContractField & { readonly type: ScalarFieldType }, + coordinate: string, +): void { + if (Object.keys(field.type.typeParams ?? {}).length === 0 && field.valueSet === undefined) return; + throw unsupported( + `value-object field ${coordinate} carries type parameters or a value set, which cannot be written in Prisma 8 PSL.`, + "The PSL source keeps only the codec of a value-object field's type, so its type parameters and value set would be lost.", + KEEP_SOURCE, + { coordinate }, + ); +} + +export function refuseValueObjectFieldCodecWithoutNativeType( + codecId: string, + coordinate: string, +): never { + throw unsupported( + `field ${coordinate} uses codec "${codecId}", which no Postgres codec in the configured stack names a native type for.`, + 'A value-object field has no storage column, so its PSL type is derived from the native type its codec names.', + KEEP_SOURCE, + { coordinate, codecId }, + ); +} + +export function refuseValueObjectFieldCodecNeedingTypeParameters( + codecId: string, + coordinate: string, +): never { + throw unsupported( + `field ${coordinate} uses codec "${codecId}", which names a native type only from type parameters, and the PSL source keeps none on a value-object field.`, + "The PSL source keeps only the codec of a value-object field's type, so a codec that needs type parameters cannot be written there.", + KEEP_SOURCE, + { coordinate, codecId }, + ); +} + +/** Refuses a model field its storage does not store in a column. */ +export function refuseFieldsWithoutColumn(entry: ModelWithTable): void { + const storedFieldNames = new Set(Object.keys(entry.storage.fields)); + for (const fieldName of Object.keys(entry.model.fields)) { + if (storedFieldNames.has(fieldName)) continue; + throw unsupported( + `field "${entry.namespaceId}.${entry.name}.${fieldName}" is stored in no column, so it cannot be written in Prisma 8 PSL.`, + 'PSL declares a scalar or value-object field together with the column it is stored in.', + 'The contract source produced a field without storage. Fix the field if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', + { namespaceId: entry.namespaceId, modelName: entry.name, field: fieldName }, + ); + } +} + +/** Refuses a column a model stores under a field name the model does not declare. */ +export function refuseStorageOfUndeclaredField(input: { + readonly entry: ModelWithTable; + readonly fieldName: string; + readonly coordinate: string; +}): never { + const { entry, fieldName } = input; + throw unsupported( + `model "${entry.namespaceId}.${entry.name}" stores "${fieldName}" in column ${input.coordinate}, but the model has no field of that name, so it cannot be written in Prisma 8 PSL.`, + 'PSL declares a field together with the column it is stored in.', + 'Declare the field on the model, or keep authoring this contract in its current source.', + { namespaceId: entry.namespaceId, modelName: entry.name, field: fieldName }, + ); +} + +/** + * Refuses a column typed by a named type when the contract declares no such type, or when the + * column's native type or codec is not the named type's. PSL writes the column as the name of the + * type, and the PSL source copies both from the named type. + */ +export function refuseColumnDifferingFromNamedType(input: { + readonly column: StorageColumn; + readonly typeRef: string; + readonly namedType: { readonly nativeType: string; readonly codecId: string } | undefined; + readonly coordinate: string; +}): void { + const { column, typeRef, namedType, coordinate } = input; + if (namedType?.nativeType === column.nativeType && namedType.codecId === column.codecId) return; + throw unsupported( + namedType === undefined + ? `column ${coordinate} is typed by the named type "${typeRef}", which the contract does not declare, so it cannot be written in Prisma 8 PSL.` + : `column ${coordinate} is typed by the named type "${typeRef}" but has a different native type or codec from it, which cannot be written in Prisma 8 PSL.`, + 'PSL writes such a column as the name of its named type, and the PSL source gives the column the native type and codec of that named type.', + 'Make the column and its named type agree, or keep authoring this contract in its current source.', + { coordinate, typeRef }, + ); +} + +export function refuseColumnControl(column: StorageColumn, coordinate: string): void { + if (column.control === undefined) return; + throw unsupported( + `column ${coordinate} carries its own control policy "${column.control}", which cannot be written in Prisma 8 PSL.`, + 'PSL declares a control policy per model with `@@control`; a field has no control attribute.', + 'Move the policy to the model, or keep authoring this contract in its current source.', + { coordinate, control: column.control }, + ); +} + +/** Refuses a storage table no model is stored in, and a column no field or variant link stores. */ +export function refuseUnmodelledTablesAndColumns( + contract: Contract, + models: readonly ModelWithTable[], + variants: ReadonlyMap, +): void { + const tableKey = (namespaceId: string, tableName: string) => + JSON.stringify([namespaceId, tableName]); + const coveredColumns = new Map>(); + for (const entry of models) { + const key = tableKey(entry.namespaceId, entry.tableName); + const columns = coveredColumns.get(key) ?? new Set(); + for (const column of entry.ownColumns) columns.add(column); + for (const column of variantLinkColumns(entry, variants.get(entry))) columns.add(column); + coveredColumns.set(key, columns); + } + for (const [namespaceId, namespace] of Object.entries(contract.storage.namespaces)) { + for (const [tableName, table] of Object.entries(namespace.entries.table ?? {})) { + const covered = coveredColumns.get(tableKey(namespaceId, tableName)); + if (covered === undefined) { + throw unsupported( + `table "${namespaceId}"."${tableName}" has no model stored in it, so it cannot be written in Prisma 8 PSL.`, + 'PSL declares a table through the model stored in it.', + 'Declare a model for the table, or keep authoring this contract in its current source.', + { namespaceId, table: tableName }, + ); + } + for (const column of Object.keys(table.columns)) { + if (covered.has(column)) continue; + throw unsupported( + `column "${namespaceId}"."${tableName}"."${column}" is not stored by any field, so it cannot be written in Prisma 8 PSL.`, + 'PSL declares a column through the field stored in it; the only columns the PSL source adds are the primary key columns that link a multi-table variant to its base.', + 'Add a field for the column, or keep authoring this contract in its current source.', + { namespaceId, table: tableName, column }, + ); + } + } + } +} + +// Models + +export function refuseModelOwner(entry: ModelWithTable): void { + if (entry.model.owner === undefined) return; + throw unsupported( + `model "${entry.namespaceId}.${entry.name}" is owned by "${entry.model.owner}", which cannot be written in Prisma 8 PSL.`, + '`owner` names the model this model is embedded in, and SQL PSL has no syntax for an owned model.', + KEEP_SOURCE, + { namespaceId: entry.namespaceId, modelName: entry.name, owner: entry.model.owner }, + ); +} + +/** + * Refuses a multi-table variant whose link to its base differs from the one the PSL source derives: + * the base's primary key columns copied into the variant's table as its unnamed primary key, and + * the link foreign key. + */ +export function refuseUnderivedVariantLink(entry: ModelWithTable, variant: VariantInfo): void { + const baseKey = variant.base.table.primaryKey?.columns ?? []; + const derivedKey = baseKey.length === 0 ? undefined : { columns: baseKey }; + const linkColumnsMatch = variantLinkColumns(entry, variant).every((column) => { + const base = variant.base.table.columns[column]; + const expected = + base === undefined + ? undefined + : new StorageColumn({ + nativeType: base.nativeType, + codecId: base.codecId, + nullable: false, + ...ifDefined('typeParams', base.typeParams), + ...ifDefined('typeRef', base.typeRef), + }); + return sameJson(entry.table.columns[column], expected); + }); + const linkForeignKeys = entry.table.foreignKeys.filter((fk) => + isVariantLinkForeignKey(fk, variant), + ); + if ( + linkColumnsMatch && + sameJson(entry.table.primaryKey, derivedKey) && + linkForeignKeys.length === (baseKey.length === 0 ? 0 : 1) + ) { + return; + } + throw unsupported( + `multi-table variant "${entry.namespaceId}.${entry.name}" is linked to its base "${variant.base.name}" differently from the link the PSL source derives, so it cannot be written in Prisma 8 PSL.`, + "The PSL source links a multi-table variant to its base itself: it copies the base's primary key columns into the variant's table as its unnamed primary key, and adds an unnamed foreign key over them to the base that cascades on delete.", + KEEP_SOURCE, + { namespaceId: entry.namespaceId, modelName: entry.name }, + ); +} + +/** + * The PSL source groups relations by bare model name, so two models with the same name in + * different namespaces would get each other's relations. + */ +export function refuseDuplicateModelNames(models: readonly ModelWithTable[]): void { + const namespacesByName = new Map(); + for (const entry of models) { + const namespaces = namespacesByName.get(entry.name) ?? []; + namespaces.push(entry.namespaceId); + namespacesByName.set(entry.name, namespaces); + } + for (const [name, namespaces] of namespacesByName) { + if (namespaces.length <= 1) continue; + throw unsupported( + `model "${name}" is declared in more than one namespace (${namespaces.join(', ')}), which Prisma 8 PSL cannot carry: the PSL source groups relations by bare model name.`, + "The PSL source groups relations by bare model name, so when the file is read back the two models would get each other's relations.", + 'Rename one of the models, then print again.', + { modelName: name, namespaces }, + ); + } +} + +/** + * A PSL `enum` block is declared at the top level and lands in the default namespace, so only the + * default namespace's domain enums have a PSL form. + */ +export function refuseEnumsOutsideDefaultNamespace(contract: Contract): void { + for (const [namespaceId, domainNamespace] of Object.entries(contract.domain.namespaces)) { + const names = Object.keys(domainNamespace.enum ?? {}); + if (namespaceId === DEFAULT_NAMESPACE_ID || names.length === 0) continue; + throw unsupported( + `namespace "${namespaceId}" declares the enum${names.length === 1 ? '' : 's'} ${names.join(', ')}, which cannot be written in Prisma 8 PSL: an enum block is declared at the top level and belongs to the default namespace.`, + 'The PSL source refuses an enum block inside a namespace block, so an enum outside the default namespace has no PSL form.', + 'Move the enum to the default namespace, or keep authoring this contract in its current source.', + { namespaceId, names }, + ); + } +} + +/** The PSL source reads every `type` block into the default namespace. */ +export function refuseValueObjectsOutsideDefaultNamespace( + contract: Contract, + namespaceId: string, +): void { + const names = Object.keys(contract.domain.namespaces[namespaceId]?.valueObjects ?? {}); + if (namespaceId === DEFAULT_NAMESPACE_ID || names.length === 0) return; + throw unsupported( + `namespace "${namespaceId}" declares the value object${names.length === 1 ? '' : 's'} ${names.join(', ')}, which cannot be written in Prisma 8 PSL: the PSL source reads every value object into the default namespace.`, + 'A `type` block written in any namespace reads back into the default namespace, so the value object would move.', + 'Move the value object to the default namespace, or keep authoring this contract in its current source.', + { namespaceId, names }, + ); +} + +// Keys, checks and indexes + +/** + * Refuses a check or index whose name is neither its prefix followed by the hash of its content, + * which `name:` derives, nor free of a prefix, which `map:` needs. + */ +export function refuseUnwritableObjectName(input: { + readonly kind: 'check' | 'index'; + readonly entry: ModelWithTable; + readonly name: string; + readonly prefix: string; +}): never { + const { kind, entry, name, prefix } = input; + throw unsupported( + `${kind} "${name}" on "${entry.namespaceId}"."${entry.tableName}" has the prefix "${prefix}", but its name is not that prefix followed by the hash of its ${kind === 'check' ? 'expression' : 'content'}, so it cannot be written in Prisma 8 PSL.`, + `\`name:\` makes the PSL source derive the name from the prefix and that hash, and \`map:\` writes an exact name with no prefix, so neither reads back as this ${kind}.`, + KEEP_SOURCE, + { namespaceId: entry.namespaceId, table: entry.tableName, name, prefix }, + ); +} + +/** + * Refuses a table whose checks differ from the derived ones where they overlap: a derived check the + * table lacks, or a check with a derived check's name but not its prefix and expression. The PSL + * source adds the derived checks itself, so they are never written. + */ +export function refuseUnderivedChecks( + entry: ModelWithTable, + derived: ReadonlyMap, +): void { + const checks = new Map((entry.table.checks ?? []).map((check) => [check.name, check])); + for (const [name, expected] of derived) { + const check = checks.get(name); + if (check?.prefix === expected.prefix && check.expression === expected.expression) continue; + throw unsupported( + `table "${entry.namespaceId}"."${entry.tableName}" ${check === undefined ? 'lacks' : 'changes'} the check "${name}" the PSL source derives, so it cannot be written in Prisma 8 PSL.`, + 'The PSL source derives a membership check for each column of a managed table typed by an enum, and an element-not-null check for each list column, unless the column waives it with `@noCheck`.', + KEEP_SOURCE, + { namespaceId: entry.namespaceId, table: entry.tableName, name }, + ); + } +} + +/** + * Refuses index options PSL cannot write: `options:` takes string values under identifier keys, + * and requires a `type`. + */ +export function refuseUnwritableIndexOptions(entry: ModelWithTable, index: Index): void { + const meta = { namespaceId: entry.namespaceId, table: entry.tableName, index: index.name }; + if (index.options !== undefined && index.type === undefined) { + throw unsupported( + `index "${index.name}" on "${entry.namespaceId}"."${entry.tableName}" has options but no type, which cannot be written in Prisma 8 PSL.`, + 'The PSL source reads `options:` on an index only together with `type:`.', + 'Give the index a type, or keep authoring this contract in its current source.', + meta, + ); + } + for (const [key, value] of Object.entries(index.options ?? {})) { + refuseUnwritableName('index option', key); + if (typeof value === 'string') continue; + throw unsupported( + `index "${index.name}" on "${entry.namespaceId}"."${entry.tableName}" has option "${key}" whose value is not a string, which cannot be written in Prisma 8 PSL.`, + 'The PSL `options:` argument takes string values only, and reads every value back as a string.', + 'Write the option value as a string, or keep authoring this contract in its current source.', + { ...meta, key }, + ); + } +} + +// Relations + +export function refuseToOneRelationWithoutForeignKey(modelName: string, fieldName: string): never { + throw unsupported( + `relation "${modelName}.${fieldName}" has no foreign key in storage, which Prisma 8 PSL cannot express.`, + 'A to-one relation is authored as `@relation(fields:…, references:…)`, which always lowers to a foreign key.', + 'Declare a foreign key for the relation, or keep authoring this contract in its current source.', + { model: modelName, field: fieldName }, + ); +} + +/** + * A foreign key no relation travels has no PSL form: the PSL source derives every foreign key from + * a `@relation`, so this one would be lost. + */ +export function refuseUntravelledForeignKeys( + entry: ModelWithTable, + variant: VariantInfo | undefined, + travelled: ReadonlySet, +): void { + for (const fk of entry.table.foreignKeys) { + if (travelled.has(fk) || isVariantLinkForeignKey(fk, variant)) continue; + if (variant?.singleTable === true && !fk.source.columns.every((c) => entry.ownColumns.has(c))) { + continue; + } + throw unsupported( + `table "${entry.namespaceId}"."${entry.tableName}" has a foreign key on (${fk.source.columns.join(', ')}) that no relation of model "${entry.name}" travels, which cannot be written in Prisma 8 PSL.`, + 'The PSL source derives every foreign key from a `@relation` field; a foreign key without one has no PSL form.', + 'Declare a relation over the foreign key, or keep authoring this contract in its current source.', + { namespaceId: entry.namespaceId, table: entry.tableName, columns: fk.source.columns }, + ); + } +} + +export function refuseRelationToOtherContractSpace(input: { + readonly modelName: string; + readonly fieldName: string; + readonly targetModel: string; + readonly space: string | undefined; +}): void { + if (input.space === undefined) return; + throw unsupported( + `relation "${input.modelName}.${input.fieldName}" targets model "${input.targetModel}" in contract space "${input.space}", which contract print cannot write yet.`, + 'The printer writes relations between models of this contract only.', + KEEP_SOURCE, + { model: input.modelName, field: input.fieldName, space: input.space }, + ); +} + +export function refuseManyToManyWithoutJunctionRelation( + modelName: string, + fieldName: string, +): never { + throw unsupported( + `many-to-many relation "${modelName}.${fieldName}" goes through a table whose model has no relation back to "${modelName}", so it cannot be written in Prisma 8 PSL.`, + 'PSL writes a many-to-many list field through the relations of the junction model, one to each side.', + 'Declare a model for the junction table with a relation to each side, or keep authoring this contract in its current source.', + { model: modelName, field: fieldName }, + ); +} + +export function refuseBackRelationWithoutOwningRelation(input: { + readonly modelName: string; + readonly fieldName: string; + readonly targetModel: string; +}): never { + throw unsupported( + `relation "${input.modelName}.${input.fieldName}" has no foreign key of its own, and model "${input.targetModel}" has no relation back to "${input.modelName}" over the same columns, so it cannot be written in Prisma 8 PSL.`, + 'The PSL source reads a relation field without `fields:` and `references:` only as the other side of a relation field that has them.', + `Declare the relation on "${input.targetModel}" that holds the foreign key, or keep authoring this contract in its current source.`, + { model: input.modelName, field: input.fieldName }, + ); +} + +export function refuseRelationWithoutJoin(modelName: string, fieldName: string): never { + throw unsupported( + `relation "${modelName}.${fieldName}" names no fields to join on, which cannot be written in Prisma 8 PSL.`, + 'The PSL source reads every relation as a join between fields of the two models; a relation with no `on` part has no PSL form.', + KEEP_SOURCE, + { model: modelName, field: fieldName }, + ); +} + +// Enums and value sets + +/** + * Refuses a namespace whose value sets differ from the ones the PSL source derives from the enum + * and `native_enum` blocks the printer writes for it. + */ +export function refuseUnderivedValueSets(input: { + readonly namespaceId: string; + readonly actual: ReadonlyMap; + readonly derived: ReadonlyMap; +}): void { + const { namespaceId } = input; + for (const [name, values] of input.actual) { + const derived = input.derived.get(name); + if (derived !== undefined && sameValues(derived, values)) continue; + throw unsupported( + `value set "${name}" in namespace "${namespaceId}" is not the value set of an enum or native enum of that name holding exactly its values, so it cannot be written in Prisma 8 PSL.`, + 'The PSL source derives each value set from an `enum` or `native_enum` block, named after the block and holding its members.', + KEEP_SOURCE, + { namespaceId, name }, + ); + } + for (const name of input.derived.keys()) { + if (input.actual.has(name)) continue; + throw unsupported( + `enum "${name}" in namespace "${namespaceId}" has no value set holding its members, which cannot be written in Prisma 8 PSL.`, + 'The PSL source derives a value set from every enum, so the file would read back with a value set this contract does not have.', + KEEP_SOURCE, + { namespaceId, name }, + ); + } +} + +export function refuseEnumAndNativeEnumSharingName(namespaceId: string, name: string): never { + throw unsupported( + `enum "${name}" and a native enum written under the same name both derive the value set "${name}" in namespace "${namespaceId}", which cannot be written in Prisma 8 PSL.`, + 'The PSL source refuses one name declared both as an enum and as a native enum.', + KEEP_SOURCE, + { namespaceId, name }, + ); +} + +export function refuseNativeEnumWithoutValueSet( + namespaceId: string, + nativeEnum: PostgresNativeEnum, +): never { + throw unsupported( + `native enum "${nativeEnum.typeName}" in namespace "${namespaceId}" has no value set holding its members, which cannot be written in Prisma 8 PSL.`, + 'The PSL source derives a value set from every `native_enum` block, named after the block and holding its members, so the file would read back with a value set this contract does not have.', + KEEP_SOURCE, + { namespaceId, typeName: nativeEnum.typeName }, + ); +} + +export function refuseNativeEnumControl(namespaceId: string, nativeEnum: PostgresNativeEnum): void { + if (nativeEnum.control === undefined) return; + throw unsupported( + `native enum "${nativeEnum.typeName}" in namespace "${namespaceId}" carries its own control policy "${nativeEnum.control}", which cannot be written in Prisma 8 PSL.`, + 'A `native_enum` block has no control attribute.', + 'Drop the policy from the enum, or keep authoring this contract in its current source.', + { namespaceId, typeName: nativeEnum.typeName, control: nativeEnum.control }, + ); +} + +// Namespaces, meta and roots + +/** + * Refuses a storage or domain namespace the PSL source would not create again: one holding nothing + * the printer writes. The PSL source creates the default namespace in both planes, a storage + * namespace for every namespace block it writes into, and a domain namespace for each namespace + * with models. + */ +export function refuseUnwrittenNamespaces(input: { + readonly contract: Contract; + readonly models: readonly ModelWithTable[]; + readonly writtenStorageNamespaces: ReadonlySet; +}): void { + const { contract } = input; + const storageNamespaces = new Set([DEFAULT_NAMESPACE_ID, ...input.writtenStorageNamespaces]); + const defaultDomain = contract.domain.namespaces[DEFAULT_NAMESPACE_ID]; + const domainNamespaces = new Set(input.models.map((entry) => entry.namespaceId)); + if ( + domainNamespaces.size === 0 || + Object.keys(defaultDomain?.valueObjects ?? {}).length > 0 || + Object.keys(defaultDomain?.enum ?? {}).length > 0 + ) { + domainNamespaces.add(DEFAULT_NAMESPACE_ID); + } + const planes = [ + { + plane: 'storage', + expected: storageNamespaces, + actual: Object.keys(contract.storage.namespaces), + }, + { + plane: 'domain', + expected: domainNamespaces, + actual: Object.keys(contract.domain.namespaces), + }, + ]; + for (const { plane, expected, actual } of planes) { + for (const namespaceId of actual) { + if (expected.has(namespaceId)) continue; + throw unsupported( + `${plane} namespace "${namespaceId}" holds nothing Prisma 8 PSL writes, so it would not read back.`, + 'The PSL source creates a namespace only for what is written in it.', + 'Remove the empty namespace, or keep authoring this contract in its current source.', + { plane, namespaceId }, + ); + } + for (const namespaceId of expected) { + if (actual.includes(namespaceId)) continue; + throw unsupported( + `the contract has no ${plane} namespace "${namespaceId}", which the PSL source would create.`, + 'The PSL source always creates the default namespace, and a namespace for everything it writes.', + KEEP_SOURCE, + { plane, namespaceId }, + ); + } + } +} + +/** + * Refuses a namespace named `unbound` that is not the late-binding namespace: PSL writes the + * late-binding namespace as `namespace unbound`, so a block of that name reads back as it. + */ +export function refuseNamespaceNamedUnbound(namespaceId: string): void { + if (namespaceId !== UNBOUND_PSL_NAMESPACE_NAME) return; + throw unsupported( + `namespace "${namespaceId}" cannot be written in Prisma 8 PSL, because \`namespace ${UNBOUND_PSL_NAMESPACE_NAME}\` is how PSL writes the late-binding namespace.`, + `The PSL source reads \`namespace ${UNBOUND_PSL_NAMESPACE_NAME}\` as the late-binding namespace "${UNBOUND_NAMESPACE_ID}", so everything in this namespace would move there.`, + 'Rename the namespace, or keep authoring this contract in its current source.', + { namespaceId }, + ); +} + +/** Refuses top-level `meta` entries: the PSL source reads back an empty `meta`. */ +export function refuseContractMeta(contract: Contract): void { + const keys = Object.keys(contract.meta); + if (keys.length === 0) return; + throw unsupported( + `the contract carries meta entries (${keys.join(', ')}), which cannot be written in Prisma 8 PSL.`, + 'The PSL source writes no top-level meta, so the file would read back with an empty meta.', + KEEP_SOURCE, + { keys }, + ); +} + +/** + * Refuses roots that differ from the ones the PSL source derives: one per model that is not a + * variant, keyed by its table name, or by `.` when two such models share a table + * name. + */ +export function refuseUnderivedRoots( + contract: Contract, + models: readonly ModelWithTable[], + variants: ReadonlyMap, +): void { + const tableEntries = models.filter((entry) => variants.get(entry)?.singleTable !== true); + const tableNameCounts = new Map(); + for (const entry of tableEntries) { + tableNameCounts.set(entry.tableName, (tableNameCounts.get(entry.tableName) ?? 0) + 1); + } + const derived = new Map(); + for (const entry of tableEntries) { + if (variants.get(entry) !== undefined) continue; + const key = + (tableNameCounts.get(entry.tableName) ?? 0) > 1 + ? `${entry.namespaceId}.${entry.tableName}` + : entry.tableName; + derived.set(key, entry); + } + const refuse = (root: string) => + unsupported( + `root "${root}" is not a root the PSL source derives, so the contract's roots cannot be written in Prisma 8 PSL.`, + 'The PSL source derives one root per model that is not a variant, keyed by its table name.', + KEEP_SOURCE, + { root }, + ); + for (const [root, reference] of Object.entries(contract.roots)) { + const entry = derived.get(root); + if ( + entry === undefined || + reference.space !== undefined || + reference.namespace !== entry.namespaceId || + reference.model !== entry.name + ) { + throw refuse(root); + } + } + for (const root of derived.keys()) { + if (!Object.hasOwn(contract.roots, root)) throw refuse(root); + } +} + +// Row-level security + +export function refuseRlsWithoutModel(namespaceId: string, tableName: string): never { + throw unsupported( + `table "${namespaceId}"."${tableName}" has row-level security enabled but no model, so it cannot be written in Prisma 8 PSL.`, + 'Row-level security is written as `@@rls` on the model stored in the table.', + 'Declare a model for the table, or keep authoring this contract in its current source.', + { namespaceId, table: tableName }, + ); +} + +function unwritablePolicy(policy: PostgresRlsPolicy, why: string, fix: string) { + return unsupported( + `policy "${policy.name}" on "${policy.namespaceId}"."${policy.tableName}" cannot be written in Prisma 8 PSL.`, + why, + fix, + { namespaceId: policy.namespaceId, table: policy.tableName, name: policy.name }, + ); +} + +export function refusePolicyWithoutModel(policy: PostgresRlsPolicy): never { + throw unwritablePolicy( + policy, + 'A policy block names its table through the model stored there, and no model is stored in this table.', + 'Declare a model for the table, or keep authoring this contract in its current source.', + ); +} + +export function refusePolicyWithoutRls(policy: PostgresRlsPolicy): never { + throw unwritablePolicy( + policy, + 'The PSL source reads a policy only on a model that declares `@@rls`, and this table does not have row-level security enabled.', + 'Enable row-level security on the table, or keep authoring this contract in its current source.', + ); +} + +export function refusePolicyNameNotDerived(policy: PostgresRlsPolicy): never { + throw unwritablePolicy( + policy, + 'The PSL source names a wire-named policy by its block name and the hash of its content, and this name is not that.', + KEEP_SOURCE, + ); +} + +/** Refuses a role outside the unbound namespace: the PSL source reads a `role` block only there. */ +export function refuseRoleOutsideUnbound(namespaceId: string, name: string): void { + if (namespaceId === UNBOUND_NAMESPACE_ID) return; + throw unsupported( + `role "${name}" is declared in namespace "${namespaceId}", and Prisma 8 PSL declares a role only in the unbound namespace.`, + 'The PSL source reads a `role` block only inside `namespace unbound`, because roles belong to the whole database.', + 'Declare the role in the unbound namespace, or keep authoring this contract in its current source.', + { namespaceId, name }, + ); +} + +const FILED_UNDER: Readonly> = { + rls: 'The PSL source files the row-level security setting written as `@@rls` under the name of the table.', + role: 'The PSL source files a role under the name its block is written with.', +}; + +/** Refuses a row-level security setting or role filed under a key the PSL source would not use. */ +export function refuseEntryFiledUnderAnotherName(input: { + readonly namespaceId: string; + readonly kind: 'rls' | 'role'; + readonly name: string; + readonly readsBackAs: string; +}): void { + if (input.name === input.readsBackAs) return; + throw unsupported( + `namespace "${input.namespaceId}" has a "${input.kind}" entry "${input.name}" that would read back as "${input.readsBackAs}", so it cannot be written in Prisma 8 PSL.`, + FILED_UNDER[input.kind], + KEEP_SOURCE, + { namespaceId: input.namespaceId, kind: input.kind, name: input.name }, + ); +} + +/** Refuses an entry that records a namespace other than the one it is stored in. */ +export function refuseEntryInOtherNamespace(input: { + readonly namespaceId: string; + readonly kind: string; + readonly name: string; + readonly recordedNamespaceId: string; +}): void { + if (input.recordedNamespaceId === input.namespaceId) return; + throw unsupported( + `the "${input.kind}" entry "${input.name}" is stored in namespace "${input.namespaceId}" but records namespace "${input.recordedNamespaceId}", so it cannot be written in Prisma 8 PSL.`, + 'The PSL source records the namespace a block is written in, so the entry would read back with the namespace it is stored in.', + 'The contract source produced an entry that disagrees with its namespace. Fix the entry if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', + { namespaceId: input.namespaceId, kind: input.kind, name: input.name }, + ); +} + +// Names and storage entries + +function refuseNameThePslSourceLoses(kind: string, name: string): void { + if (name !== NAME_THE_PSL_SOURCE_LOSES) return; + throw unsupported( + `${kind} "${name}" cannot be written in Prisma 8 PSL, because the PSL source loses this name when it reads it.`, + 'The PSL source keeps names as keys of plain objects, where this name sets the prototype instead of adding a key.', + 'Rename it, or keep authoring this contract in its current source.', + { kind, name }, + ); +} + +/** + * Refuses a table or column named `__proto__`, which the printer would write inside `@@map` or + * `@map` and the PSL source loses when it reads it. + */ +export function refuseUnwritableMappedName(kind: 'table' | 'column', name: string): void { + refuseNameThePslSourceLoses(kind, name); +} + +/** + * Refuses a name the printer writes where PSL reads an identifier: a namespace, model, field, value + * object, enum, enum member, native enum, named type, policy, role or index option key. + */ +export function refuseUnwritableName(kind: string, name: string): void { + refuseNameThePslSourceLoses(kind, name); + if (isPslIdentifier(name)) return; + throw unsupported( + `${kind} "${name}" is not a PSL identifier, so it cannot be written in Prisma 8 PSL.`, + 'PSL writes this name as an identifier: a letter or underscore, then letters, ASCII digits, underscores or hyphens, other than the number words `NaN` and `Infinity`.', + 'Rename it to an identifier, or keep authoring this contract in its current source.', + { kind, name }, + ); +} + +/** The storage entry kinds the printer writes; any other kind is refused, whichever component defines it. */ +const PRINTED_ENTRY_KINDS: ReadonlySet = new Set([ + 'table', + 'valueSet', + 'native_enum', + 'rls', + 'policy', + 'role', +]); + +export function refuseUnprintedEntryKinds( + namespaceId: string, + entries: SqlStorage['namespaces'][string]['entries'], +): void { + for (const [kind, entities] of Object.entries(entries)) { + if (PRINTED_ENTRY_KINDS.has(kind)) continue; + const names = Object.keys(entities ?? {}); + if (names.length === 0) continue; + throw unsupported( + `namespace "${namespaceId}" declares ${names.length} "${kind}" ${names.length === 1 ? 'entity' : 'entities'} (${names.join(', ')}), which the Postgres printer cannot write.`, + 'The printer writes tables, value sets, native enums, row-level security settings, policies and roles, and no other entity kind.', + KEEP_SOURCE, + { namespaceId, kind, names }, + ); + } +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/relation-fields.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/relation-fields.ts new file mode 100644 index 000000000000..97cc3177407e --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/relation-fields.ts @@ -0,0 +1,376 @@ +import type { ContractReferenceRelation, ContractRelation } from '@internal/contract/types'; +import type { PslAttributeArgument, PslField } from '@internal/framework-components/psl-ast'; +import type { ForeignKey, ReferentialAction } from '@internal/sql-contract/types'; +import { escapePslString } from '@internal/sql-relational-core/ast'; +import { assertDefined } from '@internal/utils/assertions'; +import { ifDefined } from '@internal/utils/defined'; +import { buildAttribute, namedArg, SYNTHETIC_SPAN } from '../psl-build/psl-literals'; +import { + crossReferenceCoordinate, + type ModelWithTable, + modelCoordinate, + pslNamespaceName, + type VariantInfo, +} from './contract-model-index'; +import { + refuseBackRelationWithoutOwningRelation, + refuseManyToManyWithoutJunctionRelation, + refuseRelationToOtherContractSpace, + refuseRelationWithoutJoin, + refuseToOneRelationWithoutForeignKey, + refuseUnwritableName, +} from './refusals'; + +const PSL_REFERENTIAL_ACTIONS: Readonly> = { + noAction: 'NoAction', + restrict: 'Restrict', + cascade: 'Cascade', + setNull: 'SetNull', + setDefault: 'SetDefault', +}; + +/** One relation of one model, resolved against the models it connects. */ +export interface ModelRelation { + readonly owner: ModelWithTable; + readonly fieldName: string; + readonly relation: ContractReferenceRelation; + readonly targetCoordinate: string; +} + +function isReferenceRelation(relation: ContractRelation): relation is ContractReferenceRelation { + return 'on' in relation && relation.on !== undefined; +} + +export function collectRelations(models: readonly ModelWithTable[]): readonly ModelRelation[] { + const entries: ModelRelation[] = []; + for (const owner of models) { + for (const [fieldName, relation] of Object.entries(owner.model.relations)) { + if (!isReferenceRelation(relation)) refuseRelationWithoutJoin(owner.name, fieldName); + entries.push({ + owner, + fieldName, + relation, + targetCoordinate: crossReferenceCoordinate(relation.to), + }); + } + } + return entries; +} + +/** A relation whose local fields carry the foreign key — the side `@relation(fields:…)` goes on. */ +export function isOwningRelation(entry: ModelRelation): boolean { + return entry.relation.cardinality === 'N:1'; +} + +export function relationKey(entry: ModelRelation): string { + return JSON.stringify([entry.owner.namespaceId, entry.owner.name, entry.fieldName]); +} + +/** The relation name a pair is pinned with when the model pair carries more than one relation. */ +export function relationName(entry: ModelRelation): string { + return `${entry.owner.name}_${entry.fieldName}`; +} + +function columnsIn(model: ModelWithTable, fieldNames: readonly string[]): readonly string[] { + return fieldNames.map((fieldName) => model.storage.fields[fieldName]?.column ?? fieldName); +} + +function columnsOf(entry: ModelRelation, fieldNames: readonly string[]): readonly string[] { + return columnsIn(entry.owner, fieldNames); +} + +function sameColumns(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +/** The foreign key that backs an owning relation. */ +export function foreignKeyFor( + entry: ModelRelation, + target: ModelWithTable, +): ForeignKey | undefined { + const localColumns = columnsOf(entry, entry.relation.on.localFields); + const targetColumns = columnsIn(target, entry.relation.on.targetFields); + return entry.owner.table.foreignKeys.find( + (fk) => + fk.target.spaceId === undefined && + fk.target.tableName === target.tableName && + fk.target.namespaceId === target.namespaceId && + sameColumns(fk.source.columns, localColumns) && + sameColumns(fk.target.columns, targetColumns), + ); +} + +/** + * The owning relation on `target` that pairs with a back-relation `entry`: the + * one pointing back at `entry`'s model over the same columns. The + * back-relation's target fields name fields of the target model, so they are + * resolved to columns through the candidate's own model, not through the + * back-relation's. + */ +export function owningPartner( + entry: ModelRelation, + relationsByModel: ReadonlyMap, +): ModelRelation | undefined { + const ownerCoordinate = modelCoordinate(entry.owner.namespaceId, entry.owner.name); + return relationsByModel + .get(entry.targetCoordinate) + ?.find( + (candidate) => + isOwningRelation(candidate) && + candidate.targetCoordinate === ownerCoordinate && + sameColumns( + columnsOf(candidate, candidate.relation.on.localFields), + columnsIn(candidate.owner, entry.relation.on.targetFields), + ), + ); +} + +/** + * The junction-side relation that points back at the parent of a many-to-many + * relation — the one whose columns are the junction's parent columns. + */ +export function junctionParentRelation( + entry: ModelRelation, + relationsByModel: ReadonlyMap, +): ModelRelation | undefined { + const through = entry.relation.through; + if (through === undefined) return undefined; + const ownerCoordinate = modelCoordinate(entry.owner.namespaceId, entry.owner.name); + for (const [, relations] of relationsByModel) { + for (const candidate of relations) { + if ( + candidate.owner.tableName !== through.table || + candidate.owner.namespaceId !== through.namespaceId || + candidate.targetCoordinate !== ownerCoordinate || + !isOwningRelation(candidate) + ) { + continue; + } + if ( + sameColumns(columnsOf(candidate, candidate.relation.on.localFields), through.parentColumns) + ) { + return candidate; + } + } + } + return undefined; +} + +/** The PSL field one relation is written as. */ +function buildRelationField(input: { + readonly entry: ModelRelation; + readonly target: ModelWithTable; + readonly name: string | undefined; + /** The owner shares its base's table, so the PSL source lowers no foreign key for it. */ + readonly ownerIsSingleTableVariant: boolean; +}): PslField { + const { entry, target, name } = input; + const { relation } = entry; + const typeNamespaceId = + target.namespaceId === entry.owner.namespaceId + ? undefined + : pslNamespaceName(target.namespaceId); + + const args: PslAttributeArgument[] = []; + if (name !== undefined) { + args.push(namedArg('name', `"${escapePslString(name)}"`)); + } + + if (relation.cardinality === 'N:1' && input.ownerIsSingleTableVariant) { + args.push(namedArg('fields', `[${relation.on.localFields.join(', ')}]`)); + args.push(namedArg('references', `[${relation.on.targetFields.join(', ')}]`)); + } else if (relation.cardinality === 'N:1') { + const foreignKey = foreignKeyFor(entry, target); + if (foreignKey === undefined) { + refuseToOneRelationWithoutForeignKey(entry.owner.name, entry.fieldName); + } + args.push(namedArg('fields', `[${relation.on.localFields.join(', ')}]`)); + args.push(namedArg('references', `[${relation.on.targetFields.join(', ')}]`)); + if (foreignKey.onDelete !== undefined) { + args.push(namedArg('onDelete', PSL_REFERENTIAL_ACTIONS[foreignKey.onDelete])); + } + if (foreignKey.onUpdate !== undefined) { + args.push(namedArg('onUpdate', PSL_REFERENTIAL_ACTIONS[foreignKey.onUpdate])); + } + if (foreignKey.name !== undefined) { + args.push(namedArg('map', `"${escapePslString(foreignKey.name)}"`)); + } + // Every index the contract carries is written as its own `@@index`, so the + // relation must not also ask for a backing one. + args.push(namedArg('index', 'false')); + } + + const list = relation.cardinality === '1:N' || relation.cardinality === 'N:M'; + const optional = + !list && + (relation.cardinality === '1:1' || relation.cardinality === 'N:1') && + relation.nullable === true; + + return { + kind: 'field', + name: entry.fieldName, + typeName: target.name, + ...ifDefined('typeNamespaceId', typeNamespaceId), + optional, + list, + attributes: args.length > 0 ? [buildAttribute('field', 'relation', args)] : [], + span: SYNTHETIC_SPAN, + }; +} + +/** The relations of each model, keyed by the model's coordinate. */ +export function relationsByModel( + relations: readonly ModelRelation[], +): ReadonlyMap { + const byModel = new Map(); + for (const entry of relations) { + const key = modelCoordinate(entry.owner.namespaceId, entry.owner.name); + const bucket = byModel.get(key) ?? []; + bucket.push(entry); + byModel.set(key, bucket); + } + return byModel; +} + +/** + * The relation names to pin. A pair needs one when either of its models + * carries more than one relation to the other; a many-to-many list field + * additionally pins the junction-side relation it travels through, so that + * relation is named too. + */ +export function resolvePinnedRelations( + relations: readonly ModelRelation[], + relationsByModel: ReadonlyMap, +): ReadonlySet { + const pairCounts = new Map(); + for (const entry of relations) { + const key = JSON.stringify([ + modelCoordinate(entry.owner.namespaceId, entry.owner.name), + entry.targetCoordinate, + ]); + pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1); + } + const ambiguous = (entry: ModelRelation): boolean => { + const ownerCoordinate = modelCoordinate(entry.owner.namespaceId, entry.owner.name); + const forward = pairCounts.get(JSON.stringify([ownerCoordinate, entry.targetCoordinate])) ?? 0; + const backward = pairCounts.get(JSON.stringify([entry.targetCoordinate, ownerCoordinate])) ?? 0; + return forward > 1 || backward > 1; + }; + + const pinned = new Set(); + for (const entry of relations) { + if (!ambiguous(entry)) continue; + const owning = isOwningRelation(entry) + ? entry + : entry.relation.cardinality === 'N:M' + ? junctionParentRelation(entry, relationsByModel) + : owningPartner(entry, relationsByModel); + if (owning !== undefined) { + pinned.add(relationKey(owning)); + } + } + return pinned; +} + +/** + * Relation fields in the order their foreign keys appear in storage, so the + * `foreignKeys` the PSL source derives come back in the same order. A relation + * that owns no foreign key keeps its place at the end. + */ +function orderedByForeignKey( + entry: ModelWithTable, + relations: readonly ModelRelation[], +): readonly ModelRelation[] { + const positions = new Map(); + for (const relation of relations) { + const target = relation.relation.on.localFields.map( + (fieldName) => relation.owner.storage.fields[fieldName]?.column ?? fieldName, + ); + const position = entry.table.foreignKeys.findIndex( + (fk) => + fk.source.columns.length === target.length && + fk.source.columns.every((column, index) => column === target[index]), + ); + positions.set( + relation, + isOwningRelation(relation) && position >= 0 ? position : relations.length, + ); + } + return [...relations].sort((a, b) => (positions.get(a) ?? 0) - (positions.get(b) ?? 0)); +} + +/** + * The relation fields of one model, in the order of its foreign keys. Each foreign key an owning + * relation travels is added to `travelledForeignKeys`. + */ +export function buildRelationFields(input: { + readonly entry: ModelWithTable; + readonly variant: VariantInfo | undefined; + readonly relations: readonly ModelRelation[]; + readonly relationsByModel: ReadonlyMap; + readonly modelsByCoordinate: ReadonlyMap; + readonly pinned: ReadonlySet; + readonly travelledForeignKeys: Set; +}): readonly PslField[] { + const fields: PslField[] = []; + for (const modelRelation of orderedByForeignKey(input.entry, input.relations)) { + refuseUnwritableName('field', modelRelation.fieldName); + const target = relationTarget(modelRelation, input.modelsByCoordinate); + const junctionRelation = + modelRelation.relation.cardinality === 'N:M' + ? junctionParentRelation(modelRelation, input.relationsByModel) + : undefined; + if (modelRelation.relation.cardinality === 'N:M' && junctionRelation === undefined) { + refuseManyToManyWithoutJunctionRelation(modelRelation.owner.name, modelRelation.fieldName); + } + const owning = isOwningRelation(modelRelation) + ? modelRelation + : modelRelation.relation.cardinality === 'N:M' + ? junctionRelation + : owningPartner(modelRelation, input.relationsByModel); + if (owning === undefined) { + refuseBackRelationWithoutOwningRelation({ + modelName: modelRelation.owner.name, + fieldName: modelRelation.fieldName, + targetModel: target.name, + }); + } + const name = input.pinned.has(relationKey(owning)) ? relationName(owning) : undefined; + if (isOwningRelation(modelRelation)) { + const foreignKey = foreignKeyFor(modelRelation, target); + if (foreignKey !== undefined) input.travelledForeignKeys.add(foreignKey); + } + fields.push( + buildRelationField({ + entry: modelRelation, + target, + name, + ownerIsSingleTableVariant: input.variant?.singleTable === true, + }), + ); + } + return fields; +} + +/** + * The model a relation points at. A relation into another contract space is + * refused: the printer does not write one yet. + */ +function relationTarget( + entry: ModelRelation, + modelsByCoordinate: ReadonlyMap, +): ModelWithTable { + const { to } = entry.relation; + refuseRelationToOtherContractSpace({ + modelName: entry.owner.name, + fieldName: entry.fieldName, + targetModel: to.model, + space: to.space, + }); + const target = modelsByCoordinate.get(entry.targetCoordinate); + assertDefined( + target, + `relation "${entry.owner.name}.${entry.fieldName}" targets model "${to.namespace}.${to.model}", which the contract does not declare`, + ); + return target; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/row-level-security.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/row-level-security.ts new file mode 100644 index 000000000000..9e64279aa598 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/row-level-security.ts @@ -0,0 +1,193 @@ +import type { PslExtensionBlock } from '@internal/framework-components/psl-ast'; +import { escapePslString } from '@internal/sql-relational-core/ast'; +import { formatWireName } from '@internal/sql-schema-ir/naming'; +import { invariant } from '@internal/utils/assertions'; +import { POLICY_BLOCK_KEYWORDS } from '../authoring'; +import { PostgresRlsEnablement } from '../postgres-rls-enablement'; +import { PostgresRlsPolicy } from '../postgres-rls-policy'; +import { PostgresRole } from '../postgres-role'; +import { SYNTHETIC_SPAN } from '../psl-build/psl-literals'; +import { computeContentHash } from '../rls/canonicalize'; +import { + refuseEntryFiledUnderAnotherName, + refuseEntryInOtherNamespace, + refusePolicyNameNotDerived, + refusePolicyWithoutModel, + refusePolicyWithoutRls, + refuseRoleOutsideUnbound, + refuseUnwritableName, +} from './refusals'; + +/** + * The storage tables of one namespace that have row-level security enabled, + * each written as `@@rls` on its model. The PSL source files each one under + * its table name, in the namespace of its model. + */ +export function rlsEnabledTables( + namespaceId: string, + entries: Readonly> | undefined, +): ReadonlySet { + const tables = new Set(); + for (const [name, entity] of Object.entries(entries ?? {})) { + invariant( + entity instanceof PostgresRlsEnablement, + `"rls" entry "${name}" in namespace "${namespaceId}" is not a row-level security setting`, + ); + refuseEntryInOtherNamespace({ + namespaceId, + kind: 'rls', + name, + recordedNamespaceId: entity.namespaceId, + }); + refuseEntryFiledUnderAnotherName({ + namespaceId, + kind: 'rls', + name, + readsBackAs: entity.tableName, + }); + tables.add(entity.tableName); + } + return tables; +} + +/** + * One `role {}` block per role. The PSL source reads a role block only + * inside `namespace unbound`, and files it under its name. + */ +export function buildRoleBlocks( + namespaceId: string, + entries: Readonly> | undefined, +): readonly PslExtensionBlock[] { + return Object.entries(entries ?? {}).map(([name, entity]): PslExtensionBlock => { + invariant( + entity instanceof PostgresRole, + `"role" entry "${name}" in namespace "${namespaceId}" is not a role`, + ); + refuseRoleOutsideUnbound(namespaceId, name); + refuseEntryInOtherNamespace({ + namespaceId, + kind: 'role', + name, + recordedNamespaceId: entity.namespaceId, + }); + refuseEntryFiledUnderAnotherName({ namespaceId, kind: 'role', name, readsBackAs: entity.name }); + refuseUnwritableName('role', entity.name); + return { + kind: 'role', + keyword: 'role', + name: entity.name, + parameters: {}, + blockAttributes: [], + attributes: {}, + span: SYNTHETIC_SPAN, + }; + }); +} + +/** + * Whether the PSL source, reading a policy block named `head`, derives this + * policy's name: the block name with the hash of the policy's content for a + * wire-named policy, or the `@@map` name for an exact one. + */ +function policyNameReadsBack(head: string, policy: PostgresRlsPolicy): boolean { + if (policy.prefix === undefined) return true; + const hash = computeContentHash({ + ...(policy.using === undefined ? {} : { using: policy.using }), + ...(policy.withCheck === undefined ? {} : { withCheck: policy.withCheck }), + roles: policy.roles, + operation: policy.operation, + permissive: policy.permissive, + }); + return policy.prefix === head && policy.name === formatWireName(head, hash); +} + +/** + * One `policy_` block per policy, named by its entry key, which is + * the block head it was authored under. A policy with an exact name also + * prints `@@map` with that name. + */ +export function buildPolicyBlocks(input: { + readonly namespaceId: string; + readonly entries: Readonly> | undefined; + readonly modelNameForTable: (tableName: string) => string | undefined; + readonly rlsTables: ReadonlySet; +}): readonly PslExtensionBlock[] { + return Object.entries(input.entries ?? {}).map(([head, policy]): PslExtensionBlock => { + invariant( + policy instanceof PostgresRlsPolicy, + `"policy" entry "${head}" in namespace "${input.namespaceId}" is not a policy`, + ); + refuseUnwritableName('policy', head); + refuseEntryInOtherNamespace({ + namespaceId: input.namespaceId, + kind: 'policy', + name: head, + recordedNamespaceId: policy.namespaceId, + }); + const modelName = input.modelNameForTable(policy.tableName); + if (modelName === undefined) refusePolicyWithoutModel(policy); + if (!input.rlsTables.has(policy.tableName)) refusePolicyWithoutRls(policy); + if (!policyNameReadsBack(head, policy)) refusePolicyNameNotDerived(policy); + for (const role of policy.roles) refuseUnwritableName('role', role); + + return { + kind: 'policy', + keyword: POLICY_BLOCK_KEYWORDS[policy.operation], + name: head, + parameters: { + target: { kind: 'ref', identifier: modelName, span: SYNTHETIC_SPAN }, + roles: { + kind: 'list', + items: policy.roles.map((role) => ({ + kind: 'ref', + identifier: role, + span: SYNTHETIC_SPAN, + })), + span: SYNTHETIC_SPAN, + }, + ...(policy.using !== undefined + ? { + using: { + kind: 'value', + raw: JSON.stringify(policy.using), + span: SYNTHETIC_SPAN, + }, + } + : {}), + ...(policy.withCheck !== undefined + ? { + withCheck: { + kind: 'value', + raw: JSON.stringify(policy.withCheck), + span: SYNTHETIC_SPAN, + }, + } + : {}), + ...(policy.permissive + ? {} + : { permissive: { kind: 'value', raw: 'false', span: SYNTHETIC_SPAN } }), + }, + blockAttributes: + policy.prefix === undefined + ? [ + { + name: 'map', + args: [ + { + kind: 'positional', + value: `"${escapePslString(policy.name)}"`, + span: SYNTHETIC_SPAN, + }, + ], + span: SYNTHETIC_SPAN, + }, + ] + : [], + attributes: + policy.prefix === undefined + ? { map: { args: { name: policy.name }, span: SYNTHETIC_SPAN } } + : {}, + span: SYNTHETIC_SPAN, + }; + }); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/scalar-fields.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/scalar-fields.ts new file mode 100644 index 000000000000..46b79d059cb6 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/scalar-fields.ts @@ -0,0 +1,225 @@ +import type { Contract, ContractEnum, ExecutionMutationDefault } from '@internal/contract/types'; +import type { SqlPslBuildContext } from '@internal/family-sql/control'; +import type { PslTypeMap } from '@internal/family-sql/psl-build'; +import type { PslField, PslFieldAttribute } from '@internal/framework-components/psl-ast'; +import type { SqlStorage, StorageColumn } from '@internal/sql-contract/types'; +import { pslFieldMapName } from '@internal/sql-contract-psl/map-names'; +import { escapePslString } from '@internal/sql-relational-core/ast'; +import { assertDefined } from '@internal/utils/assertions'; +import { ifDefined } from '@internal/utils/defined'; +import { PG_ENUM_CODEC_ID } from '../codec-ids'; +import { + buildAttribute, + buildMapAttribute, + namedArg, + positionalArg, + SYNTHETIC_SPAN, +} from '../psl-build/psl-literals'; +import { buildColumnDefault } from './column-defaults'; +import { buildColumnType } from './column-types'; +import type { ModelWithTable, VariantInfo } from './contract-model-index'; +import { buildDomainFieldType } from './domain-types'; +import type { NativeEnumEmission } from './enum-blocks'; +import { buildExecutionDefault, temporalPresetArguments } from './generated-values'; +import { + refuseColumnControl, + refuseColumnDifferingFromNamedType, + refuseFieldColumnMismatch, + refuseFieldsWithoutColumn, + refuseGeneratorWithDatabaseDefault, + refuseStorageOfUndeclaredField, + refuseUnwritableFieldShape, + refuseUnwritableMappedName, + refuseUnwritableName, +} from './refusals'; + +/** The generated values of the contract, keyed by the column they fill. */ +export function executionDefaultsByColumn( + contract: Contract, +): ReadonlyMap { + const byColumn = new Map(); + for (const entry of contract.execution?.mutations.defaults ?? []) { + byColumn.set(JSON.stringify([entry.ref.namespace, entry.ref.entry, entry.ref.field]), entry); + } + return byColumn; +} + +function scalarFieldAttributes(input: { + readonly column: StorageColumn; + readonly fieldName: string; + readonly namespaceId: string; + readonly tableName: string; + readonly columnName: string; + readonly isSingleColumnId: boolean; + readonly primaryKeyName: string | undefined; + readonly pslTypeName: string; + readonly isEnum: boolean; + readonly domainEnum: ContractEnum | undefined; + readonly generatedDefault: PslFieldAttribute | undefined; + readonly context: SqlPslBuildContext; +}): readonly PslFieldAttribute[] { + const attributes: PslFieldAttribute[] = []; + if (input.isSingleColumnId) { + attributes.push( + buildAttribute( + 'field', + 'id', + input.primaryKeyName === undefined + ? [] + : [namedArg('map', `"${escapePslString(input.primaryKeyName)}"`)], + ), + ); + } + const columnDefault = + input.generatedDefault ?? + buildColumnDefault({ + column: input.column, + pslTypeName: input.pslTypeName, + isEnum: input.isEnum, + domainEnum: input.domainEnum, + namespaceId: input.namespaceId, + tableName: input.tableName, + columnName: input.columnName, + context: input.context, + }); + if (columnDefault !== undefined) { + attributes.push(columnDefault); + } + for (const kind of input.column.noCheck ?? []) { + attributes.push(buildAttribute('field', 'noCheck', [positionalArg(kind)])); + } + const mapName = pslFieldMapName(input.fieldName, input.columnName); + if (mapName !== undefined) { + attributes.push(buildMapAttribute('field', mapName)); + } + return attributes; +} + +/** + * The scalar and value-object fields of one model, one per column its storage names. Each generated + * value written with a field is added to `writtenExecutionDefaults`. + */ +export function buildScalarFields(input: { + readonly entry: ModelWithTable; + readonly variant: VariantInfo | undefined; + readonly enums: NativeEnumEmission; + readonly domainEnums: Readonly>; + readonly typeMap: PslTypeMap; + readonly context: SqlPslBuildContext; + readonly executionDefaults: ReadonlyMap; + readonly writtenExecutionDefaults: Set; + readonly defaultDomainEnumNames: ReadonlySet; + readonly namedTypes: NonNullable; +}): readonly PslField[] { + const { entry, variant, enums, domainEnums, typeMap, context, executionDefaults } = input; + const primaryKeyColumns = variant === undefined ? (entry.table.primaryKey?.columns ?? []) : []; + const fields: PslField[] = []; + + refuseFieldsWithoutColumn(entry); + + for (const [fieldName, fieldStorage] of Object.entries(entry.storage.fields)) { + const columnName = fieldStorage.column; + const coordinate = `"${entry.namespaceId}"."${entry.tableName}"."${columnName}"`; + const column = entry.table.columns[columnName]; + const field = entry.model.fields[fieldName]; + assertDefined(column, `column ${coordinate} is not declared by its table`); + if (field === undefined) refuseStorageOfUndeclaredField({ entry, fieldName, coordinate }); + refuseUnwritableName('field', fieldName); + refuseUnwritableMappedName('column', columnName); + refuseUnwritableFieldShape(field, coordinate); + refuseColumnControl(column, coordinate); + refuseFieldColumnMismatch({ + field, + column, + coordinate, + modelName: entry.name, + singleTableVariant: variant?.singleTable === true, + domainEnumNames: input.defaultDomainEnumNames, + }); + if (column.typeRef !== undefined) { + refuseColumnDifferingFromNamedType({ + column, + typeRef: column.typeRef, + namedType: input.namedTypes[column.typeRef], + coordinate, + }); + } + const columnType = + column.typeRef !== undefined + ? { typeName: column.typeRef } + : field.type.kind === 'valueObject' + ? buildDomainFieldType({ + field, + coordinate, + typeMap, + context, + enumBlockNames: enums.blockNamesByTypeName, + }) + : buildColumnType({ + column, + typeMap, + authoringTypes: context.authoringContributions.type, + enumBlockNames: enums.blockNamesByTypeName, + coordinate, + }); + let { typeName, typeConstructor } = columnType; + + const execution = executionDefaults.get( + JSON.stringify([entry.namespaceId, entry.tableName, columnName]), + ); + let generatedDefault: PslFieldAttribute | undefined; + if (execution !== undefined) { + input.writtenExecutionDefaults.add(execution); + const built = buildExecutionDefault({ + executionDefault: execution, + codecId: column.codecId, + coordinate, + }); + if (built.kind === 'temporal') { + typeName = `temporal.${built.phases.presetName}`; + typeConstructor = { + kind: 'typeConstructor', + path: ['temporal', built.phases.presetName], + args: temporalPresetArguments({ + phases: built.phases, + precision: column.typeParams?.['precision'], + }), + span: SYNTHETIC_SPAN, + }; + } else if (column.default !== undefined) { + refuseGeneratorWithDatabaseDefault({ coordinate, onCreate: execution.onCreate?.id }); + } else { + generatedDefault = built.attribute; + } + } + + fields.push({ + kind: 'field', + name: fieldName, + typeName, + ...ifDefined('typeConstructor', typeConstructor), + optional: field.nullable, + list: field.many === true, + attributes: scalarFieldAttributes({ + column, + fieldName, + namespaceId: entry.namespaceId, + tableName: entry.tableName, + columnName, + isSingleColumnId: primaryKeyColumns.length === 1 && primaryKeyColumns[0] === columnName, + primaryKeyName: entry.table.primaryKey?.name, + pslTypeName: columnType.typeName, + isEnum: column.codecId === PG_ENUM_CODEC_ID, + domainEnum: + column.codecId === PG_ENUM_CODEC_ID || column.valueSet === undefined + ? undefined + : domainEnums[column.valueSet.entityName], + generatedDefault, + context, + }), + span: SYNTHETIC_SPAN, + }); + } + + return fields; +} diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index 668b006dee0b..36e19bf017aa 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -21,6 +21,7 @@ import { PostgresContractSerializer } from '../core/postgres-contract-serializer import type { PostgresContract } from '../core/postgres-schema'; import { PostgresSchemaVerifier } from '../core/postgres-schema-verifier'; import { inferPostgresPslContract } from '../core/psl-infer/infer-psl-contract'; +import { buildPostgresPslContract } from '../core/psl-print/psl-contract'; import { PostgresDatabaseSchemaNode } from '../core/schema-ir/postgres-database-schema-node'; import { postgresDiffSubjectEntityKind, @@ -43,6 +44,9 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP PostgresDatabaseSchemaNode.assert(schema); return inferPostgresPslContract(schema, describedContracts); }, + buildPslContract(contract, context) { + return buildPostgresPslContract(contract, context); + }, diffSchema(input) { return diffPostgresSchema(input); }, diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/postgres-type-map.test.ts b/packages/3-targets/3-targets/postgres/test/psl-build/postgres-type-map.test.ts similarity index 98% rename from packages/3-targets/3-targets/postgres/test/psl-infer/postgres-type-map.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-build/postgres-type-map.test.ts index a77d62b9cd5f..6bebf52c6b7c 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/postgres-type-map.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-build/postgres-type-map.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createPostgresTypeMap } from '../../src/core/psl-infer/postgres-type-map'; +import { createPostgresTypeMap } from '../../src/core/psl-build/postgres-type-map'; describe('createPostgresTypeMap', () => { const typeMap = createPostgresTypeMap(); diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/fixtures.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/fixtures.ts index 01ee37cefc3c..7094e8902d32 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/fixtures.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/fixtures.ts @@ -53,10 +53,12 @@ export function treeFromFlat(schemaIR: SqlSchemaIR): PostgresDatabaseSchemaNode }); } -/** Infers and prints PSL from a flat introspection fixture. */ +/** Infers and prints PSL from a flat introspection fixture, with the header `contract infer` writes. */ export function printPslFromFlat(schemaIR: SqlSchemaIR): string { return printPsl(inferPostgresPslContract(treeFromFlat(schemaIR)), { pslBlockDescriptors: postgresAuthoringPslBlockDescriptors, + description: + 'Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.', }); } diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts index 9f40584208da..5f66b9c5aaa8 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts @@ -281,7 +281,6 @@ describe('inferPostgresPslContract', () => { const out = printPsl(sqlSchemaIrToPslAst(schemaIR)); expect(out).toMatchInlineSnapshot(` "// use prisma-8 - // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. model User { id Int @id diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl.round-trip.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl.round-trip.test.ts similarity index 97% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl.round-trip.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl.round-trip.test.ts index eac570e757aa..990630ca4319 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl.round-trip.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl.round-trip.test.ts @@ -27,7 +27,7 @@ import { postgresDataTypeEntries } from '../../src/core/data-type-entries'; import { postgresDataTypes } from '../../src/core/data-types'; import { parsePostgresDefault } from '../../src/core/default-normalizer'; import { type PostgresSchema, postgresCreateNamespace } from '../../src/core/postgres-schema'; -import { CODEC_ID_BY_PRINTED_TYPE } from '../../src/core/psl-infer/infer-default-codec'; +import { CODEC_ID_BY_INFERRED_TYPE } from '../../src/core/psl-infer/infer-default-codec'; import { postgresCodecRegistry } from '../../src/core/registry'; import { printPslFromFlat } from './fixtures'; @@ -204,9 +204,9 @@ two lines é'::text`, }); }); - it('prints a type constructor for every printed type name the round trip covers', () => { + it('prints a type constructor for every inferred type name the round trip covers', () => { expect( - Object.keys(authoringTypes).filter((name) => !CODEC_ID_BY_PRINTED_TYPE.has(name)), + Object.keys(authoringTypes).filter((name) => !CODEC_ID_BY_INFERRED_TYPE.has(name)), ).toEqual([]); }); }); diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.check.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.check.test.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.check.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.check.test.ts diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.core.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.core.test.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.core.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.core.test.ts diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.data-type-defaults.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.data-type-defaults.test.ts similarity index 92% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.data-type-defaults.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.data-type-defaults.test.ts index 14767a6f6bc0..0035dbfb4384 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.data-type-defaults.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.data-type-defaults.test.ts @@ -2,11 +2,11 @@ import { type SqlColumnIRInput, SqlSchemaIR } from '@internal/sql-schema-ir/type import { ifDefined } from '@internal/utils/defined'; import { describe, expect, it } from 'vitest'; import { parsePostgresDefault } from '../../../src/core/default-normalizer'; +import { INFERRED_PSL_TYPE_NAMES } from '../../../src/core/psl-build/postgres-type-map'; import { - CODEC_ID_BY_PRINTED_TYPE, - dataTypeForPrintedType, + CODEC_ID_BY_INFERRED_TYPE, + dataTypeForInferredType, } from '../../../src/core/psl-infer/infer-default-codec'; -import { PRINTED_PSL_TYPE_NAMES } from '../../../src/core/psl-infer/postgres-type-map'; import { printPslFromFlat } from '../fixtures'; /** The backtick fencing a tagged literal, as an escape so no quoted string in this file holds one. */ @@ -196,27 +196,27 @@ describe('printPsl writes each default as the literal the column data type takes }); }); -describe('the codec bound to each printed type name', () => { +describe('the codec bound to each inferred type name', () => { it('covers every PSL type name the type map prints', () => { - expect(PRINTED_PSL_TYPE_NAMES.size).toBeGreaterThan(0); + expect(INFERRED_PSL_TYPE_NAMES.size).toBeGreaterThan(0); expect( - [...PRINTED_PSL_TYPE_NAMES].filter((name) => !CODEC_ID_BY_PRINTED_TYPE.has(name)), + [...INFERRED_PSL_TYPE_NAMES].filter((name) => !CODEC_ID_BY_INFERRED_TYPE.has(name)), ).toEqual([]); }); - it('names a registered codec that represents a data type for every printed type', () => { + it('names a registered codec that represents a data type for every inferred type', () => { expect( - [...CODEC_ID_BY_PRINTED_TYPE.keys()].filter( - (typeName) => dataTypeForPrintedType(typeName, false) === undefined, + [...CODEC_ID_BY_INFERRED_TYPE.keys()].filter( + (typeName) => dataTypeForInferredType(typeName, false) === undefined, ), ).toEqual([]); }); it('reads an enum column through the text codec, whose members are text', () => { - expect(dataTypeForPrintedType('SomeEnum', true)).toBe('pg/text'); + expect(dataTypeForInferredType('SomeEnum', true)).toBe('pg/text'); }); it('names nothing for a type no codec is bound to', () => { - expect(dataTypeForPrintedType('Unsupported', false)).toBeUndefined(); + expect(dataTypeForInferredType('Unsupported', false)).toBeUndefined(); }); }); diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.defaults-and-types.test.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.defaults-and-types.test.ts diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.enums.test.ts similarity index 99% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.enums.test.ts index f33c2060d695..c97619103483 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.enums.test.ts @@ -17,9 +17,9 @@ import { SqlSchemaIR } from '@internal/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; import { postgresAuthoringPslBlockDescriptors } from '../../../src/core/authoring'; import { parsePostgresDefault } from '../../../src/core/default-normalizer'; +import { createPostgresTypeMap } from '../../../src/core/psl-build/postgres-type-map'; import { buildPslDocumentAst } from '../../../src/core/psl-infer/infer-psl-contract'; import { createPostgresDefaultMapping } from '../../../src/core/psl-infer/postgres-default-mapping'; -import { createPostgresTypeMap } from '../../../src/core/psl-infer/postgres-type-map'; function enumInfoOf(definitions: Record): EnumInfo { return { diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.literal-defaults.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.literal-defaults.test.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.literal-defaults.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.literal-defaults.test.ts diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.naming-and-constraints.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.naming-and-constraints.test.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.naming-and-constraints.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.naming-and-constraints.test.ts diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.no-check.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.no-check.test.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.no-check.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.no-check.test.ts diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.nullable-list.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.nullable-list.test.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.nullable-list.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.nullable-list.test.ts diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.relations.test.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.relations.test.ts diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.top-level-blocks.test.ts similarity index 99% rename from packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.top-level-blocks.test.ts index f6ec67898454..dd7bb590d65e 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/inferred-psl/inferred-psl.top-level-blocks.test.ts @@ -46,9 +46,9 @@ import { } from '../../../src/core/authoring'; import { parsePostgresDefault } from '../../../src/core/default-normalizer'; import { isPostgresSchema, postgresCreateNamespace } from '../../../src/core/postgres-schema'; +import { createPostgresTypeMap } from '../../../src/core/psl-build/postgres-type-map'; import { buildPslDocumentAst } from '../../../src/core/psl-infer/infer-psl-contract'; import { createPostgresDefaultMapping } from '../../../src/core/psl-infer/postgres-default-mapping'; -import { createPostgresTypeMap } from '../../../src/core/psl-infer/postgres-type-map'; import { inferPslAstFromFlat } from '../fixtures'; const postgresDataTypeLookup = createDataTypeLookup(postgresDataTypes); diff --git a/packages/3-targets/3-targets/postgres/test/psl-policy-authoring.test.ts b/packages/3-targets/3-targets/postgres/test/psl-policy-authoring.test.ts index 1aa60106bddb..c1645490f9d6 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-policy-authoring.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-policy-authoring.test.ts @@ -224,6 +224,41 @@ namespace public { ['Bytes', { codecId: 'pg/bytea@1', nativeType: 'bytea' }], ]); + function interpret(text: string) { + const { document, sources } = parse(text, 'psl-policy-authoring.test.psl'); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: assembled.pslBlockDescriptors, + }); + return interpretPslDocumentToSqlContract({ + dataTypeLookup: postgresDataTypeLookup, + documents: [document], + symbolTable, + sources, + target: postgresTarget, + scalarColumnDescriptors, + authoringContributions: assembled, + composedExtensionContracts: new Map(), + createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, + }); + } + + it('reads a policy expression as a JSON string, and keeps any other backslash sequence as written', () => { + const result = interpret( + source.replace( + `using = "owner_id = current_setting('app.uid')::int"`, + String.raw`using = "a\tb\u0041 \"q\" \\ \/ \d"`, + ), + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const ns = result.value.storage.namespaces['public'] as PostgresSchema; + expect(Object.values(ns.policy).map((policy) => policy.using)).toEqual(['a\tbA "q" \\ / \\d']); + }); + it('lowers a policy_select block to entries.policy without test-side hand-lowering', () => { const { document, sources } = parse(source, 'psl-policy-authoring.test.psl'); const { symbolTable, diagnostics } = buildSymbolTable({ diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/build-context.ts b/packages/3-targets/3-targets/postgres/test/psl-print/build-context.ts new file mode 100644 index 000000000000..98f617f50043 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/build-context.ts @@ -0,0 +1,89 @@ +import type { SqlPslBuildContext } from '@internal/family-sql/control'; +import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import { + type CodecDescriptorTemplate, + createDataTypeLookup, +} from '@internal/framework-components/codec'; +import { postgresAuthoringTypes } from '../../src/core/authoring'; +import { type AnyPostgresCodecDescriptor, postgresCodec } from '../../src/core/codec-descriptor'; +import { postgresDataTypeEntries } from '../../src/core/data-type-entries'; +import { pgText, postgresDataTypes } from '../../src/core/data-types'; +import { postgresCodecDescriptorRegistry } from '../../src/core/registry'; + +/** The adapter's type constructors these tests print. The adapter sits above this package. */ +const adapterTypes = { + String: { kind: 'typeConstructor', output: { codecId: 'pg/text@1', nativeType: 'text' } }, + Int: { kind: 'typeConstructor', output: { codecId: 'pg/int4@1', nativeType: 'int4' } }, + Jsonb: { kind: 'typeConstructor', output: { codecId: 'pg/jsonb@1', nativeType: 'jsonb' } }, + Uuid: { kind: 'typeConstructor', output: { codecId: 'pg/uuid@1', nativeType: 'uuid' } }, + Inet: { kind: 'typeConstructor', output: { codecId: 'pg/inet@1', nativeType: 'inet' } }, + Numeric: { + kind: 'typeConstructor', + args: [ + { kind: 'number', name: 'precision', integer: true, minimum: 1, optional: true }, + { kind: 'number', name: 'scale', integer: true, minimum: 0, optional: true }, + ], + output: { + codecId: 'pg/numeric@1', + nativeType: 'numeric', + typeParams: { + precision: { kind: 'arg', index: 0 }, + scale: { kind: 'arg', index: 1 }, + }, + }, + }, + Timestamp: { + kind: 'typeConstructor', + args: [{ kind: 'number', name: 'precision', integer: true, minimum: 0, optional: true }], + output: { + codecId: 'pg/timestamp-temporal@1', + nativeType: 'timestamp', + typeParams: { precision: { kind: 'arg', index: 0 } }, + }, + }, +} as const satisfies AuthoringTypeNamespace; + +const citextTemplate: CodecDescriptorTemplate = { + codecId: 'ext/citext@1', + traits: [], + targetTypes: ['citext'], + paramsSchema: undefined, + isParameterized: false, + factory: () => () => { + throw new Error('the printer never builds a codec'); + }, +}; + +/** A codec the target does not own, as an extension would contribute it: text stored as `citext`. */ +export const extensionCodec: AnyPostgresCodecDescriptor = postgresCodec(citextTemplate, { + dataType: pgText.id, + nativeType: () => 'citext', + jsonProjection: (expression) => expression, +}); + +/** + * A stand-in for the stack the SQL family hands the printer: the target's own type constructors, + * codecs and data types, the adapter's type constructors these tests print, and what `extra` adds. + */ +export function testBuildContext( + extra: { + readonly types?: AuthoringTypeNamespace; + readonly codecs?: readonly AnyPostgresCodecDescriptor[]; + } = {}, +): SqlPslBuildContext { + const extraCodecs = new Map((extra.codecs ?? []).map((codec) => [codec.codecId, codec])); + return { + authoringContributions: { + type: { ...postgresAuthoringTypes, ...adapterTypes, ...extra.types }, + dataTypes: postgresDataTypeEntries(), + }, + codecLookup: { + get: () => undefined, + targetTypesFor: () => undefined, + renderOutputTypeFor: () => undefined, + descriptorFor: (codecId) => + extraCodecs.get(codecId) ?? postgresCodecDescriptorRegistry.descriptorFor(codecId), + }, + dataTypeLookup: createDataTypeLookup(postgresDataTypes), + }; +} diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/column-defaults.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/column-defaults.test.ts new file mode 100644 index 000000000000..2768a4b71355 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/column-defaults.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + attributeText, + type ColumnShape, + fieldText, + INT_COLUMN, + oneModel, + TEXT_COLUMN, +} from './print-support'; + +describe('column defaults', () => { + function defaultOf(column: ColumnShape): string | undefined { + const model = oneModel( + { id: INT_COLUMN, value: column }, + { id: { column: 'id' }, value: { column: 'value' } }, + ); + return model?.fields[1]?.attributes.map(attributeText)[0]; + } + + it('prints a string literal as a PSL string', () => { + expect(defaultOf({ ...TEXT_COLUMN, default: { kind: 'literal', value: 'hello' } })).toBe( + '@default("hello")', + ); + }); + + it('prints a decimal default as the text that keeps every digit', () => { + expect( + defaultOf({ + nativeType: 'numeric', + codecId: 'pg/numeric@1', + nullable: false, + default: { kind: 'literal', value: '1.50' }, + }), + ).toBe('@default(1.50)'); + }); + + it('prints an integer default unquoted', () => { + expect(defaultOf({ ...INT_COLUMN, default: { kind: 'literal', value: 42 } })).toBe( + '@default(42)', + ); + }); + + it('prints now() and autoincrement() by name', () => { + expect( + defaultOf({ ...INT_COLUMN, default: { kind: 'function', expression: 'autoincrement()' } }), + ).toBe('@default(autoincrement())'); + expect( + defaultOf({ + nativeType: 'timestamp', + codecId: 'pg/timestamp-temporal@1', + nullable: false, + default: { kind: 'function', expression: 'now()' }, + }), + ).toBe('@default(now())'); + }); + + it('prints every other function default as a sql tagged literal', () => { + expect( + defaultOf({ + nativeType: 'uuid', + codecId: 'pg/uuid@1', + nullable: false, + default: { kind: 'function', expression: 'gen_random_uuid()' }, + }), + ).toBe('@default(sql`gen_random_uuid()`)'); + }); + + it('refuses a literal default that cannot be written in PSL for the column type', () => { + let thrown: unknown; + try { + defaultOf({ + nativeType: 'inet', + codecId: 'pg/inet@1', + nullable: false, + default: { kind: 'literal', value: { a: 1 } }, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + meta: { coordinate: '"public"."Widget"."value"', pslTypeName: 'Inet' }, + }); + }); +}); + +describe('list columns', () => { + it('prints a waived element-not-null check as @noCheck(elementNotNull)', () => { + const model = oneModel( + { id: INT_COLUMN, tags: { ...TEXT_COLUMN, many: true, noCheck: ['elementNotNull'] } }, + { id: { column: 'id' }, tags: { column: 'tags' } }, + ); + expect(model?.fields.map(fieldText)).toEqual([ + 'id Int @id', + 'tags String[] @noCheck(elementNotNull)', + ]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/enum-blocks.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/enum-blocks.test.ts new file mode 100644 index 000000000000..0a0703f9980f --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/enum-blocks.test.ts @@ -0,0 +1,165 @@ +import type { ApplicationDomainNamespace, Contract } from '@internal/contract/types'; +import { asNamespaceId } from '@internal/contract/types'; +import type { PslExtensionBlock } from '@internal/framework-components/psl-ast'; +import { namespacePslExtensionBlocks } from '@internal/framework-components/psl-ast'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { blindCast } from '@internal/utils/casts'; +import { createSqlContract } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; +import { buildPostgresPslContract } from '../../src/core/psl-print/psl-contract'; +import { testBuildContext } from './build-context'; +import { type ColumnShape, domainFieldOf, INT_COLUMN, table } from './print-support'; + +describe('native enum blocks', () => { + function blockText(block: PslExtensionBlock): string { + const members = Object.entries(block.parameters).map(([name, value]) => + value.kind === 'value' ? `${name} = ${value.raw}` : name, + ); + const attributes = block.blockAttributes.map( + (attribute) => `@@${attribute.name}(${attribute.args.map((arg) => arg.value).join(', ')})`, + ); + return [`${block.keyword} ${block.name}`, ...members, ...attributes].join(' '); + } + + function enumBlocks(input: { + readonly nativeEnums: Record; + readonly valueSets: Record; + readonly columns?: Record; + readonly fields?: Record; + }): readonly string[] { + const fields = input.fields ?? { id: { column: 'id' } }; + const columns: Record = input.columns ?? { id: INT_COLUMN }; + const domainNamespace: ApplicationDomainNamespace = { + models: { + Widget: { + storage: { table: 'Widget', namespaceId: 'public', fields }, + fields: Object.fromEntries( + Object.entries(fields).map(([name, { column }]) => [ + name, + domainFieldOf(columns[column]), + ]), + ), + relations: {}, + }, + }, + }; + const json = createSqlContract({ + roots: { Widget: { namespace: asNamespaceId('public'), model: 'Widget' } }, + namespaces: { public: domainNamespace }, + storage: { + namespaces: { + public: { + id: 'public', + entries: { + table: { + Widget: table({ columns, primaryKey: { columns: ['id'] } }), + }, + native_enum: Object.fromEntries( + Object.entries(input.nativeEnums).map(([name, value]) => [ + name, + { kind: 'postgres-enum', ...value }, + ]), + ), + valueSet: Object.fromEntries( + Object.entries(input.valueSets).map(([name, value]) => [ + name, + { kind: 'valueSet', ...value }, + ]), + ), + }, + }, + }, + }, + }); + const contract = new PostgresContractSerializer().deserializeContract(json); + const ast = buildPostgresPslContract( + blindCast, 'the Postgres serializer yields a SQL contract'>(contract), + testBuildContext(), + ); + return ast.namespaces + .flatMap((namespace) => namespacePslExtensionBlocks(namespace)) + .map(blockText); + } + + it('names a mapped enum no column refers to after its value set, not its type', () => { + expect( + enumBlocks({ + nativeEnums: { user_role: { typeName: 'user_role', members: ['user', 'ADMIN'] } }, + valueSets: { Role: { values: ['user', 'ADMIN'] } }, + }), + ).toEqual(['native_enum Role user = "user" ADMIN = "ADMIN" @@map("user_role")']); + }); + + it('leaves an unmapped enum no column refers to named after its type', () => { + expect( + enumBlocks({ + nativeEnums: { Unused: { typeName: 'Unused', members: ['A', 'B'] } }, + valueSets: { Unused: { values: ['A', 'B'] } }, + }), + ).toEqual(['native_enum Unused A = "A" B = "B"']); + }); + + it('names a mapped enum a column refers to after the value set the column names', () => { + expect( + enumBlocks({ + nativeEnums: { user_role: { typeName: 'user_role', members: ['user', 'ADMIN'] } }, + valueSets: { Role: { values: ['user', 'ADMIN'] } }, + columns: { + id: INT_COLUMN, + role: { + nativeType: 'user_role', + codecId: 'pg/enum@1', + nullable: false, + valueSet: { + plane: 'storage', + namespaceId: 'public', + entityKind: 'valueSet', + entityName: 'Role', + }, + }, + }, + fields: { id: { column: 'id' }, role: { column: 'role' } }, + }), + ).toEqual(['native_enum Role user = "user" ADMIN = "ADMIN" @@map("user_role")']); + }); + + it('gives two unreferenced enums with the same members one value set each', () => { + expect( + enumBlocks({ + nativeEnums: { + user_role: { typeName: 'user_role', members: ['A', 'B'] }, + other_role: { typeName: 'other_role', members: ['A', 'B'] }, + }, + valueSets: { Role: { values: ['A', 'B'] }, OtherRole: { values: ['A', 'B'] } }, + }), + ).toEqual([ + 'native_enum Role A = "A" B = "B" @@map("user_role")', + 'native_enum OtherRole A = "A" B = "B" @@map("other_role")', + ]); + }); + + it('writes a type name PSL cannot read as an identifier under @@map, named after its value set', () => { + expect( + enumBlocks({ + nativeEnums: { 'order status': { typeName: 'order status', members: ['A'] } }, + valueSets: { OrderStatus: { values: ['A'] } }, + }), + ).toEqual(['native_enum OrderStatus A = "A" @@map("order status")']); + }); + + it('names each unreferenced enum after the value set that holds its members', () => { + expect( + enumBlocks({ + nativeEnums: { + 'order status': { typeName: 'order status', members: ['A'] }, + order_status: { typeName: 'order_status', members: ['B'] }, + }, + valueSets: { OrderStatus: { values: ['B'] }, LegacyOrderStatus: { values: ['A'] } }, + }), + ).toEqual([ + 'native_enum LegacyOrderStatus A = "A" @@map("order status")', + 'native_enum OrderStatus B = "B" @@map("order_status")', + ]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/generated-values.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/generated-values.test.ts new file mode 100644 index 000000000000..c8208d0af2ce --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/generated-values.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { + buildModels, + type ColumnShape, + fieldText, + INT_COLUMN, + TEXT_COLUMN, + table, +} from './print-support'; + +describe('generated values', () => { + function withGenerator(column: ColumnShape, phases: Record): string | undefined { + const [model] = buildModels({ + models: { + Widget: { table: 'Widget', fields: { id: { column: 'id' }, value: { column: 'value' } } }, + }, + tables: { + Widget: table({ + columns: { id: INT_COLUMN, value: column }, + primaryKey: { columns: ['id'] }, + }), + }, + execution: { + mutations: { + defaults: [{ ref: { namespace: 'public', entry: 'Widget', field: 'value' }, ...phases }], + }, + }, + }); + const field = model?.fields[1]; + return field === undefined ? undefined : fieldText(field); + } + + it('prints an id generator as the default function that produces it', () => { + expect(withGenerator(TEXT_COLUMN, { onCreate: { kind: 'generator', id: 'uuidv4' } })).toBe( + 'value String @default(uuid())', + ); + expect(withGenerator(TEXT_COLUMN, { onCreate: { kind: 'generator', id: 'uuidv7' } })).toBe( + 'value String @default(uuid(7))', + ); + expect(withGenerator(TEXT_COLUMN, { onCreate: { kind: 'generator', id: 'cuid2' } })).toBe( + 'value String @default(cuid(2))', + ); + expect(withGenerator(TEXT_COLUMN, { onCreate: { kind: 'generator', id: 'ulid' } })).toBe( + 'value String @default(ulid())', + ); + expect( + withGenerator(TEXT_COLUMN, { + onCreate: { kind: 'generator', id: 'nanoid', params: { size: 10 } }, + }), + ).toBe('value String @default(nanoid(10))'); + }); + + it('prints a wall-clock-now pair as the temporal preset of the column codec', () => { + expect( + withGenerator( + { + nativeType: 'timestamp', + codecId: 'pg/timestamp-temporal@1', + nullable: false, + typeParams: { precision: 3 }, + }, + { + onCreate: { kind: 'generator', id: 'plainDateTimeNow' }, + onUpdate: { kind: 'generator', id: 'plainDateTimeNow' }, + }, + ), + ).toBe('value temporal.timestamp(3, onCreate: now, onUpdate: now)'); + }); + + it('refuses a column that pairs the wall-clock-now generator with a different one', () => { + const timestamp = { + nativeType: 'timestamp', + codecId: 'pg/timestamp-temporal@1', + nullable: false, + }; + const refusal = (phases: Record): unknown => { + try { + withGenerator(timestamp, phases); + } catch (error) { + return error; + } + return undefined; + }; + + expect( + refusal({ + onCreate: { kind: 'generator', id: 'plainDateTimeNow' }, + onUpdate: { kind: 'generator', id: 'uuidv4' }, + }), + ).toMatchObject({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + meta: { + coordinate: '"public"."Widget"."value"', + onCreate: 'plainDateTimeNow', + onUpdate: 'uuidv4', + }, + }); + expect( + refusal({ + onCreate: { kind: 'generator', id: 'uuidv4' }, + onUpdate: { kind: 'generator', id: 'plainDateTimeNow' }, + }), + ).toMatchObject({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + meta: { + coordinate: '"public"."Widget"."value"', + onCreate: 'uuidv4', + onUpdate: 'plainDateTimeNow', + }, + }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/model-attributes.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/model-attributes.test.ts new file mode 100644 index 000000000000..5a3d641ab826 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/model-attributes.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { + attributeText, + buildModels, + fieldText, + INT_COLUMN, + oneModel, + TEXT_COLUMN, + table, +} from './print-support'; + +describe('names the PSL source cannot derive', () => { + it('maps a model whose table is not the model name', () => { + const [model] = buildModels({ + models: { Widget: { table: 'widgets', fields: { id: { column: 'id' } } } }, + tables: { widgets: table({ columns: { id: INT_COLUMN }, primaryKey: { columns: ['id'] } }) }, + }); + expect(model?.attributes.map(attributeText)).toEqual(['@@map("widgets")']); + }); + + it('leaves a model whose table is the model name unmapped', () => { + const [model] = buildModels({ + models: { Widget: { table: 'Widget', fields: { id: { column: 'id' } } } }, + tables: { Widget: table({ columns: { id: INT_COLUMN }, primaryKey: { columns: ['id'] } }) }, + }); + expect(model?.attributes).toEqual([]); + }); + + it('maps a field whose column is not the field name', () => { + const model = oneModel( + { id: INT_COLUMN, first_name: TEXT_COLUMN }, + { id: { column: 'id' }, firstName: { column: 'first_name' } }, + ); + expect(model?.fields.map(fieldText)).toEqual([ + 'id Int @id', + 'firstName String @map("first_name")', + ]); + }); +}); + +describe('keys and indexes', () => { + it('prints a unique index as an index, never as @unique', () => { + const model = oneModel( + { id: INT_COLUMN, email: TEXT_COLUMN }, + { id: { column: 'id' }, email: { column: 'email' } }, + ); + expect(model?.fields.map(fieldText)).toEqual(['id Int @id', 'email String']); + + const [withIndex] = buildModels({ + models: { + Widget: { table: 'Widget', fields: { id: { column: 'id' }, email: { column: 'email' } } }, + }, + tables: { + Widget: table({ + columns: { id: INT_COLUMN, email: TEXT_COLUMN }, + primaryKey: { columns: ['id'] }, + indexes: [{ name: 'Widget_email_key', unique: true, columns: ['email'] }], + }), + }, + }); + expect(withIndex?.attributes.map(attributeText)).toEqual([ + '@@index([email], map: "Widget_email_key", unique: true)', + ]); + expect(withIndex?.fields.map(fieldText)).toEqual(['id Int @id', 'email String']); + }); + + it('prints a unique constraint as @@unique, under the field names its columns carry', () => { + const [model] = buildModels({ + models: { + Widget: { table: 'Widget', fields: { id: { column: 'id' }, email: { column: 'e_mail' } } }, + }, + tables: { + Widget: table({ + columns: { id: INT_COLUMN, e_mail: TEXT_COLUMN }, + primaryKey: { columns: ['id'] }, + uniques: [{ columns: ['e_mail'], name: 'widget_email_key' }], + }), + }, + }); + expect(model?.attributes.map(attributeText)).toEqual([ + '@@unique([email], map: "widget_email_key")', + ]); + }); + + it('prints each check constraint the PSL source does not derive', () => { + const [model] = buildModels({ + models: { Widget: { table: 'Widget', fields: { id: { column: 'id' } } } }, + tables: { + Widget: table({ + columns: { id: INT_COLUMN }, + primaryKey: { columns: ['id'] }, + checks: [ + { name: 'widget_id_positive', expression: 'id > 0' }, + { name: 'widget_id_small', expression: 'id < 100' }, + ], + }), + }, + }); + expect(model?.attributes.map(attributeText)).toEqual([ + '@@check(expression: "id > 0", map: "widget_id_positive")', + '@@check(expression: "id < 100", map: "widget_id_small")', + ]); + }); + + it('prints a multi-column primary key as a model attribute', () => { + const [model] = buildModels({ + models: { + Widget: { table: 'Widget', fields: { a: { column: 'a' }, b: { column: 'b_col' } } }, + }, + tables: { + Widget: table({ + columns: { a: INT_COLUMN, b_col: TEXT_COLUMN }, + primaryKey: { columns: ['a', 'b_col'] }, + }), + }, + }); + expect(model?.attributes.map(attributeText)).toEqual(['@@id([a, b])']); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-support.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-support.ts new file mode 100644 index 000000000000..5c93f30bbcfa --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-support.ts @@ -0,0 +1,156 @@ +import type { + ApplicationDomainNamespace, + Contract, + ContractField, + ContractRelation, + ExecutionMutationDefault, +} from '@internal/contract/types'; +import { asNamespaceId } from '@internal/contract/types'; +import type { PslAttribute, PslField, PslModel } from '@internal/framework-components/psl-ast'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { blindCast } from '@internal/utils/casts'; +import { createSqlContract } from '@repo/test-utils'; +import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; +import { buildPostgresPslContract } from '../../src/core/psl-print/psl-contract'; +import { testBuildContext } from './build-context'; + +export const INT_COLUMN = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } as const; +export const TEXT_COLUMN = { nativeType: 'text', codecId: 'pg/text@1', nullable: false } as const; + +export function attributeText(attribute: PslAttribute): string { + const prefix = attribute.target === 'model' ? '@@' : '@'; + if (attribute.args.length === 0) return `${prefix}${attribute.name}`; + const args = attribute.args + .map((arg) => (arg.kind === 'positional' ? arg.value : `${arg.name}: ${arg.value}`)) + .join(', '); + return `${prefix}${attribute.name}(${args})`; +} + +export function fieldText(field: PslField): string { + const type = + field.typeConstructor === undefined + ? field.typeName + : `${field.typeConstructor.path.join('.')}(${field.typeConstructor.args + .map((arg) => (arg.kind === 'positional' ? arg.value : `${arg.name}: ${arg.value}`)) + .join(', ')})`; + const suffix = field.list ? '[]' : field.optional ? '?' : ''; + return [`${field.name} ${type}${suffix}`, ...field.attributes.map(attributeText)].join(' '); +} + +interface ModelInput { + readonly table: string; + readonly fields: Record; + readonly relations?: Record; +} + +const INT_FIELD: ContractField = { + nullable: false, + type: { kind: 'scalar', codecId: 'pg/int4@1' }, +}; + +export interface ColumnShape { + readonly [key: string]: unknown; + readonly nativeType: string; + readonly codecId: string; + readonly nullable: boolean; + readonly many?: boolean; + readonly typeParams?: Record; +} + +interface TableShape { + readonly [key: string]: unknown; + readonly columns: Record; +} + +/** The domain field the PSL source derives for a scalar column. */ +export function domainFieldOf(column: ColumnShape | undefined): ContractField { + if (column === undefined) return INT_FIELD; + return { + nullable: column.nullable, + type: { + kind: 'scalar', + codecId: column.codecId, + ...(column.typeParams === undefined ? {} : { typeParams: column.typeParams }), + }, + ...(column.many === true ? { many: true } : {}), + }; +} + +/** The roots the PSL source derives: one per model, keyed by its table. */ +function rootsOf(models: Record) { + return Object.fromEntries( + Object.entries(models).map(([name, model]) => [ + model.table, + { namespace: asNamespaceId('public'), model: name }, + ]), + ); +} + +export function buildModels(input: { + readonly models: Record; + readonly tables: Record; + readonly execution?: { + readonly mutations: { readonly defaults: readonly ExecutionMutationDefault[] }; + }; +}): readonly PslModel[] { + const domainNamespace: ApplicationDomainNamespace = { + models: Object.fromEntries( + Object.entries(input.models).map(([name, model]) => [ + name, + { + storage: { table: model.table, namespaceId: 'public', fields: model.fields }, + fields: Object.fromEntries( + Object.entries(model.fields).map(([fieldName, { column }]) => [ + fieldName, + domainFieldOf(input.tables[model.table]?.columns[column]), + ]), + ), + relations: model.relations ?? {}, + }, + ]), + ), + }; + const overrides = { + roots: rootsOf(input.models), + namespaces: { public: domainNamespace }, + storage: { namespaces: { public: { id: 'public', entries: { table: input.tables } } } }, + }; + const json = + input.execution === undefined + ? createSqlContract(overrides) + : createSqlContract({ ...overrides, execution: input.execution }); + const contract = new PostgresContractSerializer().deserializeContract(json); + const ast = buildPostgresPslContract( + blindCast, 'the Postgres serializer yields a SQL contract'>(contract), + testBuildContext(), + ); + return ast.namespaces.flatMap((namespace) => namespace.models); +} + +export function table(input: { + readonly columns: Record; + readonly primaryKey?: { readonly columns: readonly string[] }; + readonly uniques?: readonly unknown[]; + readonly indexes?: readonly unknown[]; + readonly foreignKeys?: readonly unknown[]; + readonly checks?: readonly unknown[]; +}): TableShape { + return { + columns: input.columns, + uniques: input.uniques ?? [], + indexes: input.indexes ?? [], + foreignKeys: input.foreignKeys ?? [], + ...(input.checks === undefined ? {} : { checks: input.checks }), + ...(input.primaryKey === undefined ? {} : { primaryKey: input.primaryKey }), + }; +} + +export function oneModel( + columns: Record, + fields: Record, +) { + return buildModels({ + models: { Widget: { table: 'Widget', fields } }, + tables: { Widget: table({ columns, primaryKey: { columns: ['id'] } }) }, + })[0]; +} diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/refusal-support.ts b/packages/3-targets/3-targets/postgres/test/psl-print/refusal-support.ts new file mode 100644 index 000000000000..67cef1e9dddf --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusal-support.ts @@ -0,0 +1,157 @@ +import type { Contract, ContractField } from '@internal/contract/types'; +import { asNamespaceId } from '@internal/contract/types'; +import type { SqlPslBuildContext } from '@internal/family-sql/control'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { blindCast } from '@internal/utils/casts'; +import { createSqlContract } from '@repo/test-utils'; +import { expect } from 'vitest'; +import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; +import { buildPostgresPslContract } from '../../src/core/psl-print/psl-contract'; +import { testBuildContext } from './build-context'; + +export type Overrides = NonNullable[0]>; + +export const PUBLIC = asNamespaceId('public'); +export const INT_COLUMN = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } as const; +export const TEXT_COLUMN = { nativeType: 'text', codecId: 'pg/text@1', nullable: false } as const; +export const INT_FIELD: ContractField = { + nullable: false, + type: { kind: 'scalar', codecId: 'pg/int4@1' }, +}; +export const TEXT_FIELD: ContractField = { + nullable: false, + type: { kind: 'scalar', codecId: 'pg/text@1' }, +}; + +export interface WidgetParts { + /** Columns beside `id`. */ + readonly columns?: Record; + /** Domain fields beside `id`; each is stored in the column of the same name unless `storageFields` says otherwise. */ + readonly fields?: Record; + readonly storageFields?: Record; + readonly table?: Record; + readonly model?: Record; + readonly tables?: Record; + readonly models?: Record; + readonly entries?: Record>; + readonly domain?: Record; + readonly storageNamespaces?: Record; + readonly domainNamespaces?: Record; + /** Named storage types, the `types { … }` block. */ + readonly storageTypes?: Record; + readonly contract?: Overrides; +} + +/** One `Widget` model in `public`, stored in table `Widget`, with the roots the PSL source derives for it. */ +export function widgetContract(parts: WidgetParts = {}): Overrides { + const fields = { id: INT_FIELD, ...parts.fields }; + const storageFields = + parts.storageFields ?? + Object.fromEntries(Object.keys(fields).map((name) => [name, { column: name }])); + return { + roots: { Widget: { namespace: PUBLIC, model: 'Widget' } }, + namespaces: { + public: { + models: blindCast, 'test models are checked by the serializer'>({ + Widget: { + storage: { table: 'Widget', namespaceId: 'public', fields: storageFields }, + fields, + relations: {}, + ...parts.model, + }, + ...parts.models, + }), + ...parts.domain, + }, + ...blindCast, 'test namespaces are checked by the serializer'>( + parts.domainNamespaces ?? {}, + ), + }, + storage: { + ...(parts.storageTypes === undefined ? {} : { types: parts.storageTypes }), + namespaces: { + public: { + id: 'public', + entries: { + table: { + Widget: { + columns: { id: INT_COLUMN, ...parts.columns }, + uniques: [], + indexes: [], + foreignKeys: [], + primaryKey: { columns: ['id'] }, + ...parts.table, + }, + ...parts.tables, + }, + ...parts.entries, + }, + }, + ...blindCast, 'test namespaces are checked by the serializer'>( + parts.storageNamespaces ?? {}, + ), + }, + }, + ...parts.contract, + }; +} + +export function deserialize(overrides: Overrides): Contract { + return blindCast, 'the Postgres serializer yields a SQL contract'>( + new PostgresContractSerializer().deserializeContract(createSqlContract(overrides)), + ); +} + +/** The contract the Postgres serializer reads from `overrides` after `edit` changed its JSON. */ +export function deserializeEdited( + overrides: Overrides, + edit: (json: string) => string, +): Contract { + return blindCast, 'the Postgres serializer yields a SQL contract'>( + new PostgresContractSerializer().deserializeContract( + JSON.parse(edit(JSON.stringify(createSqlContract(overrides)))), + ), + ); +} + +export function printing( + contract: Contract, + context: SqlPslBuildContext = testBuildContext(), +): () => unknown { + return () => buildPostgresPslContract(contract, context); +} + +export function printingWidget( + parts: WidgetParts = {}, + context: SqlPslBuildContext = testBuildContext(), +): () => unknown { + return printing(deserialize(widgetContract(parts)), context); +} + +/** + * The contract with entries added to one storage namespace as they are. It stands in for a contract + * with an entity kind a pack contributes, which the Postgres serializer reads only with that pack. + */ +export function withRawEntries( + contract: Contract, + namespaceId: string, + entries: Record>, +): Contract { + const namespace = contract.storage.namespaces[namespaceId]; + return { + ...contract, + storage: blindCast( + { + ...contract.storage, + namespaces: { + ...contract.storage.namespaces, + [namespaceId]: { ...namespace, entries: { ...namespace?.entries, ...entries } }, + }, + }, + ), + }; +} + +export function refusal(meta: Record) { + return expect.objectContaining({ code: 'CONTRACT.PRINT_UNSUPPORTED', meta }); +} diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/refusals-entries.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-entries.test.ts new file mode 100644 index 000000000000..61c19698c75a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-entries.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest'; +import { INT_FIELD, printingWidget, refusal, TEXT_COLUMN, TEXT_FIELD } from './refusal-support'; + +describe('names PSL writes as identifiers', () => { + it('refuses a value object name that is not an identifier', () => { + expect( + printingWidget({ + domain: { valueObjects: { 'Postal Address': { fields: { street: TEXT_FIELD } } } }, + }), + ).toThrow(refusal({ kind: 'value object', name: 'Postal Address' })); + }); + + it('refuses a field named after a number word, which PSL reads as a number', () => { + expect(printingWidget({ columns: { NaN: TEXT_COLUMN }, fields: { NaN: TEXT_FIELD } })).toThrow( + refusal({ kind: 'field', name: 'NaN' }), + ); + }); + + it('refuses a value-object field name that is not an identifier', () => { + expect( + printingWidget({ + domain: { valueObjects: { Address: { fields: { 'street line': TEXT_FIELD } } } }, + }), + ).toThrow(refusal({ kind: 'field', name: 'street line' })); + }); + + it('refuses an enum name and an enum member name that are not identifiers', () => { + const enumOf = (name: string, member: string) => + printingWidget({ + domain: { + enum: { [name]: { codecId: 'pg/text@1', members: [{ name: member, value: 'a' }] } }, + }, + entries: { valueSet: { [name]: { kind: 'valueSet', values: ['a'] } } }, + }); + expect(enumOf('Order Status', 'Open')).toThrow(refusal({ kind: 'enum', name: 'Order Status' })); + expect(enumOf('OrderStatus', 'is open')).toThrow( + refusal({ kind: 'enum member', name: 'is open' }), + ); + }); + + it('refuses an enum member named __proto__, a name the PSL source loses when it reads it', () => { + expect( + printingWidget({ + domain: { + enum: { + Slot: { codecId: 'pg/text@1', members: [{ name: '__proto__', value: 'a' }] }, + }, + }, + entries: { valueSet: { Slot: { kind: 'valueSet', values: ['a'] } } }, + }), + ).toThrow(refusal({ kind: 'enum member', name: '__proto__' })); + }); + + it('refuses a named type name that is not an identifier', () => { + expect( + printingWidget({ + storageTypes: { + 'short text': { + kind: 'codec-instance', + codecId: 'pg/text@1', + nativeType: 'text', + typeParams: {}, + }, + }, + }), + ).toThrow(refusal({ kind: 'named type', name: 'short text' })); + }); + + it('refuses an index option key that is not an identifier', () => { + expect( + printingWidget({ + table: { + indexes: [ + { + name: 'widget_id_idx', + unique: false, + columns: ['id'], + type: 'btree', + options: { 'fill factor': '70' }, + }, + ], + }, + }), + ).toThrow(refusal({ kind: 'index option', name: 'fill factor' })); + }); +}); + +describe('index options', () => { + it('refuses options on an index with no type', () => { + expect( + printingWidget({ + table: { + indexes: [ + { + name: 'widget_id_idx', + unique: false, + columns: ['id'], + options: { fillfactor: '70' }, + }, + ], + }, + }), + ).toThrow(refusal({ namespaceId: 'public', table: 'Widget', index: 'widget_id_idx' })); + }); +}); + +describe('enums', () => { + it('refuses an enum and a native enum that derive the same value set', () => { + expect( + printingWidget({ + domain: { + enum: { Status: { codecId: 'pg/text@1', members: [{ name: 'A', value: 'a' }] } }, + }, + entries: { + native_enum: { status: { kind: 'postgres-enum', typeName: 'status', members: ['a'] } }, + valueSet: { Status: { kind: 'valueSet', values: ['a'] } }, + }, + }), + ).toThrow(refusal({ namespaceId: 'public', name: 'Status' })); + }); +}); + +describe('row-level security entries the PSL source would file differently', () => { + const unboundRoles = (roles: Record) => ({ + __unbound__: { id: '__unbound__', entries: { table: {}, role: roles } }, + }); + + it('refuses a row-level security entry not keyed by its table name', () => { + expect( + printingWidget({ + entries: { + rls: { widget_rls: { kind: 'rls', namespaceId: 'public', tableName: 'Widget' } }, + }, + }), + ).toThrow(refusal({ namespaceId: 'public', kind: 'rls', name: 'widget_rls' })); + }); + + it('refuses a row-level security entry that records another namespace', () => { + expect( + printingWidget({ + entries: { rls: { Widget: { kind: 'rls', namespaceId: 'auth', tableName: 'Widget' } } }, + }), + ).toThrow(refusal({ namespaceId: 'public', kind: 'rls', name: 'Widget' })); + }); + + it('refuses a role not keyed by its name', () => { + expect( + printingWidget({ + storageNamespaces: unboundRoles({ + app: { kind: 'role', name: 'app_user', namespaceId: '__unbound__', control: 'external' }, + }), + }), + ).toThrow(refusal({ namespaceId: '__unbound__', kind: 'role', name: 'app' })); + }); + + it('refuses a policy that records another namespace', () => { + expect( + printingWidget({ + entries: { + rls: { Widget: { kind: 'rls', namespaceId: 'public', tableName: 'Widget' } }, + policy: { + widget_read: { + kind: 'policy', + name: 'widget_read', + tableName: 'Widget', + namespaceId: 'auth', + operation: 'select', + roles: ['app_user'], + using: 'true', + permissive: true, + }, + }, + }, + storageNamespaces: unboundRoles({ + app_user: { + kind: 'role', + name: 'app_user', + namespaceId: '__unbound__', + control: 'external', + }, + }), + }), + ).toThrow(refusal({ namespaceId: 'public', kind: 'policy', name: 'widget_read' })); + }); +}); + +describe('fields and the columns they are stored in', () => { + it('refuses a field stored in no column', () => { + expect( + printingWidget({ fields: { extra: INT_FIELD }, storageFields: { id: { column: 'id' } } }), + ).toThrow(refusal({ namespaceId: 'public', modelName: 'Widget', field: 'extra' })); + }); + + it('refuses a column stored under a field name the model does not declare', () => { + expect( + printingWidget({ + columns: { name: TEXT_COLUMN }, + storageFields: { id: { column: 'id' }, name: { column: 'name' } }, + }), + ).toThrow(refusal({ namespaceId: 'public', modelName: 'Widget', field: 'name' })); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/refusals-fields.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-fields.test.ts new file mode 100644 index 000000000000..490a3c88164a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-fields.test.ts @@ -0,0 +1,373 @@ +import { blindCast } from '@internal/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { testBuildContext } from './build-context'; +import { INT_FIELD, printingWidget, refusal, TEXT_COLUMN, TEXT_FIELD } from './refusal-support'; + +it('prints the widget the refusal tests start from', () => { + expect(printingWidget()).not.toThrow(); +}); + +describe('columns and fields', () => { + it('refuses a column no field is stored in', () => { + expect(printingWidget({ columns: { legacy: TEXT_COLUMN } })).toThrow( + refusal({ namespaceId: 'public', table: 'Widget', column: 'legacy' }), + ); + }); + + it('refuses a required field stored in a nullable column', () => { + expect( + printingWidget({ + columns: { name: { ...TEXT_COLUMN, nullable: true } }, + fields: { name: TEXT_FIELD }, + }), + ).toThrow(refusal({ coordinate: '"public"."Widget"."name"' })); + }); + + it('refuses a list field stored in a column that is not a list', () => { + expect( + printingWidget({ + columns: { tags: TEXT_COLUMN }, + fields: { tags: { ...TEXT_FIELD, many: true } }, + }), + ).toThrow(refusal({ coordinate: '"public"."Widget"."tags"' })); + }); + + it('refuses a scalar field whose codec is not its column codec', () => { + expect(printingWidget({ columns: { name: TEXT_COLUMN }, fields: { name: INT_FIELD } })).toThrow( + refusal({ coordinate: '"public"."Widget"."name"' }), + ); + }); + + it('refuses a field whose column names an enum value set the field does not name', () => { + expect( + printingWidget({ + domain: { + enum: { Priority: { codecId: 'pg/text@1', members: [{ name: 'Low', value: 'low' }] } }, + }, + entries: { valueSet: { Priority: { kind: 'valueSet', values: ['low'] } } }, + columns: { + priority: { + ...TEXT_COLUMN, + valueSet: { + plane: 'storage', + namespaceId: 'public', + entityKind: 'valueSet', + entityName: 'Priority', + }, + }, + }, + fields: { priority: TEXT_FIELD }, + }), + ).toThrow(refusal({ coordinate: '"public"."Widget"."priority"' })); + }); + + it('refuses a model field whose type is a union of types', () => { + expect( + printingWidget({ + columns: { payload: { nativeType: 'jsonb', codecId: 'pg/jsonb@1', nullable: false } }, + fields: { + payload: { + nullable: false, + type: { + kind: 'union', + members: [ + { kind: 'scalar', codecId: 'pg/int4@1' }, + { kind: 'scalar', codecId: 'pg/text@1' }, + ], + }, + }, + }, + }), + ).toThrow(refusal({ coordinate: '"public"."Widget"."payload"', kind: 'union' })); + }); + + it('refuses a model field that is a dictionary', () => { + expect( + printingWidget({ + columns: { counts: { nativeType: 'jsonb', codecId: 'pg/jsonb@1', nullable: false } }, + fields: { + counts: { nullable: false, dict: true, type: { kind: 'scalar', codecId: 'pg/jsonb@1' } }, + }, + }), + ).toThrow(refusal({ coordinate: '"public"."Widget"."counts"' })); + }); + + it('refuses a column with its own control policy', () => { + expect( + printingWidget({ + columns: { name: { ...TEXT_COLUMN, control: 'external' } }, + fields: { name: TEXT_FIELD }, + }), + ).toThrow(refusal({ coordinate: '"public"."Widget"."name"', control: 'external' })); + }); + + describe('a column written through a type constructor the stack contributes', () => { + const context = testBuildContext({ + types: { + geo: { + Shape: { + kind: 'typeConstructor', + args: [{ kind: 'string', name: 'shape' }], + output: { + codecId: 'pg/geometry@1', + nativeType: 'geometry', + typeParams: { shape: { kind: 'arg', index: 0 } }, + }, + }, + }, + }, + }); + + function withShape(typeParams: Record) { + return printingWidget( + { + columns: { + area: { nativeType: 'geometry', codecId: 'pg/geometry@1', nullable: false, typeParams }, + }, + fields: { + area: { + nullable: false, + type: { kind: 'scalar', codecId: 'pg/geometry@1', typeParams }, + }, + }, + }, + context, + ); + } + + it('refuses a column whose codec no PSL type in the stack produces, naming the column', () => { + const vector = { nativeType: 'vector', codecId: 'pg/vector@1', typeParams: { length: 3 } }; + expect( + printingWidget({ + columns: { v: { ...vector, nullable: false } }, + fields: { + v: { + nullable: false, + type: { kind: 'scalar', codecId: vector.codecId, typeParams: vector.typeParams }, + }, + }, + }), + ).toThrow( + refusal({ + coordinate: '"public"."Widget"."v"', + nativeType: 'vector', + codecId: 'pg/vector@1', + }), + ); + }); + + it('writes a string type argument between quotes', () => { + expect(withShape({ shape: 'Point' })).not.toThrow(); + }); + + it('refuses a string type argument the PSL source would read back differently', () => { + expect(withShape({ shape: 'Point "A"' })).toThrow( + refusal({ coordinate: '"public"."Widget"."area"', argument: 'Point "A"' }), + ); + }); + + it('refuses a column with no value for an argument the constructor requires', () => { + expect(withShape({})).toThrow( + refusal({ + coordinate: '"public"."Widget"."area"', + nativeType: 'geometry', + codecId: 'pg/geometry@1', + }), + ); + }); + }); +}); + +describe('defaults and generated values', () => { + it('refuses a domain enum default that is not a member', () => { + expect( + printingWidget({ + domain: { + enum: { + Priority: { codecId: 'pg/text@1', members: [{ name: 'Low', value: 'low' }] }, + }, + }, + entries: { valueSet: { Priority: { kind: 'valueSet', values: ['low'] } } }, + columns: { + priority: { + ...TEXT_COLUMN, + default: { kind: 'literal', value: 'high' }, + valueSet: { + plane: 'storage', + namespaceId: 'public', + entityKind: 'valueSet', + entityName: 'Priority', + }, + }, + }, + fields: { + priority: { + ...TEXT_FIELD, + valueSet: { + plane: 'domain', + namespaceId: 'public', + entityKind: 'enum', + entityName: 'Priority', + }, + }, + }, + }), + ).toThrow(refusal({ coordinate: '"public"."Widget"."priority"', pslTypeName: 'Priority' })); + }); + + function withGenerator( + phases: Record, + column: Record = TEXT_COLUMN, + ) { + return printingWidget({ + columns: { value: column }, + fields: { value: TEXT_FIELD }, + contract: { + execution: { + mutations: { + defaults: [ + blindCast({ + ref: { namespace: 'public', entry: 'Widget', field: 'value' }, + ...phases, + }), + ], + }, + }, + }, + }); + } + + it('refuses a generator on update that is not the wall-clock-now generator', () => { + expect(withGenerator({ onUpdate: { kind: 'generator', id: 'uuidv4' } })).toThrow( + refusal({ coordinate: '"public"."Widget"."value"', onCreate: undefined, onUpdate: 'uuidv4' }), + ); + }); + + it('refuses a generator with no PSL default function', () => { + expect(withGenerator({ onCreate: { kind: 'generator', id: 'slugid' } })).toThrow( + refusal({ coordinate: '"public"."Widget"."value"', onCreate: 'slugid', onUpdate: undefined }), + ); + }); + + it('refuses a column with both an id generator and a database default', () => { + expect( + withGenerator( + { onCreate: { kind: 'generator', id: 'uuidv4' } }, + { ...TEXT_COLUMN, default: { kind: 'function', expression: 'gen_random_uuid()' } }, + ), + ).toThrow(refusal({ coordinate: '"public"."Widget"."value"', onCreate: 'uuidv4' })); + }); + + it('refuses a generated value for a column no field is stored in', () => { + expect( + printingWidget({ + contract: { + execution: { + mutations: { + defaults: [ + { + ref: { namespace: 'public', entry: 'Widget', field: 'missing' }, + onCreate: { kind: 'generator', id: 'uuidv4' }, + }, + ], + }, + }, + }, + }), + ).toThrow(refusal({ coordinate: '"public"."Widget"."missing"' })); + }); +}); + +describe('checks and indexes', () => { + it('refuses a wire-named check whose name is not its prefix and the hash of its expression', () => { + expect( + printingWidget({ + table: { + checks: [ + { + name: 'widget_id_positive_00000000', + prefix: 'widget_id_positive', + expression: 'id > 0', + }, + ], + }, + }), + ).toThrow( + refusal({ + namespaceId: 'public', + table: 'Widget', + name: 'widget_id_positive_00000000', + prefix: 'widget_id_positive', + }), + ); + }); + + it('refuses a managed list column without the element check the PSL source derives', () => { + expect( + printingWidget({ + columns: { tags: { ...TEXT_COLUMN, many: true } }, + fields: { tags: { ...TEXT_FIELD, many: true } }, + }), + ).toThrow( + refusal({ + namespaceId: 'public', + table: 'Widget', + name: 'Widget_tags_elem_not_null_aecbe9e2', + }), + ); + }); + + it('prints a list column of a table that is not managed without derived checks', () => { + expect( + printingWidget({ + columns: { tags: { ...TEXT_COLUMN, many: true } }, + fields: { tags: { ...TEXT_FIELD, many: true } }, + table: { control: 'external' }, + }), + ).not.toThrow(); + }); + + it('refuses a wire-named index whose name is not its prefix and the hash of its content', () => { + expect( + printingWidget({ + table: { + indexes: [ + { name: 'widget_id_00000000', prefix: 'widget_id', unique: false, columns: ['id'] }, + ], + }, + }), + ).toThrow( + refusal({ + namespaceId: 'public', + table: 'Widget', + name: 'widget_id_00000000', + prefix: 'widget_id', + }), + ); + }); + + it('refuses an index option whose value is not a string', () => { + expect( + printingWidget({ + table: { + indexes: [ + { + name: 'widget_id_idx', + unique: false, + columns: ['id'], + type: 'btree', + options: { fillfactor: 70 }, + }, + ], + }, + }), + ).toThrow( + refusal({ + namespaceId: 'public', + table: 'Widget', + index: 'widget_id_idx', + key: 'fillfactor', + }), + ); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/refusals-list.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-list.test.ts new file mode 100644 index 000000000000..1954b4f479e2 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-list.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'pathe'; +import { describe, expect, it } from 'vitest'; +import * as refusals from '../../src/core/psl-print/refusals'; + +const errorReference = join( + dirname(fileURLToPath(import.meta.url)), + '../../../../../../docs/reference/error-reference.md', +); + +/** The cases the error reference lists for `CONTRACT.PRINT_UNSUPPORTED`, one line each. */ +function documentedCases(): readonly string[] { + const text = readFileSync(errorReference, 'utf-8'); + const start = text.indexOf('### CONTRACT.PRINT_UNSUPPORTED\n'); + const end = text.indexOf('\n### ', start + 1); + return text + .slice(start, end) + .split('\n') + .filter((line) => line.startsWith(' - ')); +} + +describe('the refusals of contract print', () => { + it('has as many refusal functions as the error reference lists cases', () => { + const functions = Object.entries(refusals).filter( + ([name, value]) => name.startsWith('refuse') && typeof value === 'function', + ); + + expect(documentedCases().length).toBeGreaterThan(0); + expect(functions).toHaveLength(documentedCases().length); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/refusals-models.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-models.test.ts new file mode 100644 index 000000000000..7e0603b41959 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-models.test.ts @@ -0,0 +1,423 @@ +import { asNamespaceId } from '@internal/contract/types'; +import { describe, expect, it } from 'vitest'; +import { + deserialize, + INT_COLUMN, + INT_FIELD, + PUBLIC, + printing, + printingWidget, + refusal, + TEXT_COLUMN, + TEXT_FIELD, + widgetContract, +} from './refusal-support'; + +describe('models and relations', () => { + it('refuses a model with an owner', () => { + expect( + printingWidget({ + model: { owner: 'Other' }, + models: { + Other: { + storage: { table: 'Other', namespaceId: 'public', fields: { id: { column: 'id' } } }, + fields: { id: INT_FIELD }, + relations: {}, + }, + }, + tables: { + Other: { + columns: { id: INT_COLUMN }, + uniques: [], + indexes: [], + foreignKeys: [], + primaryKey: { columns: ['id'] }, + }, + }, + contract: { roots: { Other: { namespace: PUBLIC, model: 'Other' } } }, + }), + ).toThrow(refusal({ namespaceId: 'public', modelName: 'Widget', owner: 'Other' })); + }); + + it('refuses a model name that is not a PSL identifier', () => { + expect( + printing( + deserialize( + widgetContract({ + models: { + 'Line Item': { + storage: { + table: 'line_item', + namespaceId: 'public', + fields: { id: { column: 'id' } }, + }, + fields: { id: INT_FIELD }, + relations: {}, + }, + }, + tables: { + line_item: { + columns: { id: INT_COLUMN }, + uniques: [], + indexes: [], + foreignKeys: [], + primaryKey: { columns: ['id'] }, + }, + }, + contract: { + roots: { + Widget: { namespace: PUBLIC, model: 'Widget' }, + line_item: { namespace: PUBLIC, model: 'Line Item' }, + }, + }, + }), + ), + ), + ).toThrow(refusal({ kind: 'model', name: 'Line Item' })); + }); + + it('refuses a field name that is not a PSL identifier', () => { + expect( + printingWidget({ + columns: { display_name: TEXT_COLUMN }, + fields: { 'display name': TEXT_FIELD }, + storageFields: { id: { column: 'id' }, 'display name': { column: 'display_name' } }, + }), + ).toThrow(refusal({ kind: 'field', name: 'display name' })); + }); + + function postAndWidget(parts: { + readonly foreignKeys?: readonly unknown[]; + readonly relations?: Record; + }) { + return printingWidget({ + columns: { postId: INT_COLUMN }, + fields: { postId: INT_FIELD }, + table: { foreignKeys: parts.foreignKeys ?? [] }, + model: { relations: parts.relations ?? {} }, + models: { + Post: { + storage: { table: 'Post', namespaceId: 'public', fields: { id: { column: 'id' } } }, + fields: { id: INT_FIELD }, + relations: {}, + }, + }, + tables: { + Post: { + columns: { id: INT_COLUMN }, + uniques: [], + indexes: [], + foreignKeys: [], + primaryKey: { columns: ['id'] }, + }, + }, + contract: { + roots: { + Widget: { namespace: PUBLIC, model: 'Widget' }, + Post: { namespace: PUBLIC, model: 'Post' }, + }, + }, + }); + } + + it('refuses a foreign key no relation travels', () => { + expect( + postAndWidget({ + foreignKeys: [ + { + source: { namespaceId: 'public', tableName: 'Widget', columns: ['postId'] }, + target: { namespaceId: 'public', tableName: 'Post', columns: ['id'] }, + }, + ], + }), + ).toThrow(refusal({ namespaceId: 'public', table: 'Widget', columns: ['postId'] })); + }); + + it('refuses a to-one relation with no foreign key behind it', () => { + expect( + postAndWidget({ + relations: { + post: { + to: { namespace: PUBLIC, model: 'Post' }, + cardinality: 'N:1', + nullable: false, + on: { localFields: ['postId'], targetFields: ['id'] }, + }, + }, + }), + ).toThrow(refusal({ model: 'Widget', field: 'post' })); + }); + + it('refuses a relation into another contract space, with or without a foreign key', () => { + const relations = { + user: { + to: { namespace: asNamespaceId('auth'), model: 'AuthUser', space: 'supabase' }, + cardinality: 'N:1', + nullable: false, + on: { localFields: ['postId'], targetFields: ['id'] }, + }, + }; + const expected = refusal({ model: 'Widget', field: 'user', space: 'supabase' }); + expect(postAndWidget({ relations })).toThrow(expected); + expect( + postAndWidget({ + relations, + foreignKeys: [ + { + source: { namespaceId: 'public', tableName: 'Widget', columns: ['postId'] }, + target: { + namespaceId: 'auth', + tableName: 'users', + columns: ['id'], + spaceId: 'supabase', + }, + }, + ], + }), + ).toThrow(expected); + }); + + it('refuses a many-to-many relation whose junction model has no relation back to it', () => { + expect( + printingWidget({ + model: { + relations: { + tags: { + to: { namespace: PUBLIC, model: 'Tag' }, + cardinality: 'N:M', + on: { localFields: ['id'], targetFields: ['id'] }, + through: { + table: 'WidgetTag', + namespaceId: 'public', + parentColumns: ['widgetId'], + childColumns: ['tagId'], + targetColumns: ['id'], + }, + }, + }, + }, + models: { + Tag: { + storage: { table: 'Tag', namespaceId: 'public', fields: { id: { column: 'id' } } }, + fields: { id: INT_FIELD }, + relations: {}, + }, + WidgetTag: { + storage: { + table: 'WidgetTag', + namespaceId: 'public', + fields: { widgetId: { column: 'widgetId' }, tagId: { column: 'tagId' } }, + }, + fields: { widgetId: INT_FIELD, tagId: INT_FIELD }, + relations: {}, + }, + }, + tables: { + Tag: { + columns: { id: INT_COLUMN }, + uniques: [], + indexes: [], + foreignKeys: [], + primaryKey: { columns: ['id'] }, + }, + WidgetTag: { + columns: { widgetId: INT_COLUMN, tagId: INT_COLUMN }, + uniques: [], + indexes: [], + foreignKeys: [], + primaryKey: { columns: ['widgetId', 'tagId'] }, + }, + }, + contract: { + roots: { + Widget: { namespace: PUBLIC, model: 'Widget' }, + Tag: { namespace: PUBLIC, model: 'Tag' }, + WidgetTag: { namespace: PUBLIC, model: 'WidgetTag' }, + }, + }, + }), + ).toThrow(refusal({ model: 'Widget', field: 'tags' })); + }); +}); + +describe('one model name in two namespaces', () => { + it('refuses it, naming the model and both namespaces', () => { + const table = { + columns: { id: INT_COLUMN }, + uniques: [], + indexes: [], + foreignKeys: [], + primaryKey: { columns: ['id'] }, + }; + expect( + printing( + deserialize( + widgetContract({ + domainNamespaces: { + auth: { + models: { + Widget: { + storage: { + table: 'Widget', + namespaceId: 'auth', + fields: { id: { column: 'id' } }, + }, + fields: { id: INT_FIELD }, + relations: {}, + }, + }, + }, + }, + storageNamespaces: { auth: { id: 'auth', entries: { table: { Widget: table } } } }, + contract: { + roots: { + 'public.Widget': { namespace: PUBLIC, model: 'Widget' }, + 'auth.Widget': { namespace: asNamespaceId('auth'), model: 'Widget' }, + }, + }, + }), + ), + ), + ).toThrow(refusal({ modelName: 'Widget', namespaces: ['public', 'auth'] })); + }); +}); + +describe('variants', () => { + const TYPE_COLUMN = TEXT_COLUMN; + + function taskWithVariant(parts: { + readonly variantTable: string; + readonly variantColumns: Record; + readonly variantTableExtras?: Record; + readonly taskColumns?: Record; + }) { + const singleTable = parts.variantTable === 'Task'; + const variantTable = { + columns: parts.variantColumns, + uniques: [], + indexes: [], + foreignKeys: [], + ...parts.variantTableExtras, + }; + return printing( + deserialize({ + roots: { Task: { namespace: PUBLIC, model: 'Task' } }, + namespaces: { + public: { + models: { + Task: { + storage: { + table: 'Task', + namespaceId: 'public', + fields: { id: { column: 'id' }, type: { column: 'type' } }, + }, + fields: { id: INT_FIELD, type: TEXT_FIELD }, + relations: {}, + discriminator: { field: 'type' }, + variants: { Epic: { value: 'epic' } }, + }, + Epic: { + storage: { + table: parts.variantTable, + namespaceId: 'public', + fields: { scope: { column: 'scope' } }, + }, + fields: { scope: TEXT_FIELD }, + relations: {}, + base: { namespace: PUBLIC, model: 'Task' }, + }, + }, + }, + }, + storage: { + namespaces: { + public: { + id: 'public', + entries: { + table: { + Task: { + columns: { + id: INT_COLUMN, + type: TYPE_COLUMN, + ...parts.taskColumns, + ...(singleTable ? parts.variantColumns : {}), + }, + uniques: [], + indexes: [], + foreignKeys: [], + primaryKey: { columns: ['id'] }, + }, + ...(singleTable ? {} : { [parts.variantTable]: variantTable }), + }, + }, + }, + }, + }, + }), + ); + } + + const LINK = { + source: { namespaceId: 'public', tableName: 'epics', columns: ['id'] }, + target: { namespaceId: 'public', tableName: 'Task', columns: ['id'] }, + onDelete: 'cascade', + }; + + it('prints a multi-table variant whose link to its base is the one the PSL source derives', () => { + expect( + taskWithVariant({ + variantTable: 'epics', + variantColumns: { id: INT_COLUMN, scope: TEXT_COLUMN }, + variantTableExtras: { primaryKey: { columns: ['id'] }, foreignKeys: [LINK] }, + }), + ).not.toThrow(); + }); + + it.each([ + [ + 'a named primary key', + { primaryKey: { columns: ['id'], name: 'epics_pk' }, foreignKeys: [LINK] }, + ], + [ + 'a link that does not cascade', + { primaryKey: { columns: ['id'] }, foreignKeys: [{ ...LINK, onDelete: 'restrict' }] }, + ], + ['no link', { primaryKey: { columns: ['id'] } }], + ])('refuses a multi-table variant with %s', (_, variantTableExtras) => { + expect( + taskWithVariant({ + variantTable: 'epics', + variantColumns: { id: INT_COLUMN, scope: TEXT_COLUMN }, + variantTableExtras, + }), + ).toThrow(refusal({ namespaceId: 'public', modelName: 'Epic' })); + }); + + it('refuses a multi-table variant whose link column is not the base primary key column', () => { + expect( + taskWithVariant({ + variantTable: 'epics', + variantColumns: { + id: { ...INT_COLUMN, default: { kind: 'literal', value: 1 } }, + scope: TEXT_COLUMN, + }, + variantTableExtras: { primaryKey: { columns: ['id'] }, foreignKeys: [LINK] }, + }), + ).toThrow(refusal({ namespaceId: 'public', modelName: 'Epic' })); + }); + + it('refuses a single-table variant column that is not nullable', () => { + expect( + taskWithVariant({ variantTable: 'Task', variantColumns: { scope: TEXT_COLUMN } }), + ).toThrow(refusal({ coordinate: '"public"."Task"."scope"' })); + }); + + it('prints a single-table variant whose columns are nullable', () => { + expect( + taskWithVariant({ + variantTable: 'Task', + variantColumns: { scope: { ...TEXT_COLUMN, nullable: true } }, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/refusals-named-types.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-named-types.test.ts new file mode 100644 index 000000000000..428a5e4792e0 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-named-types.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { INT_FIELD, printingWidget, refusal, TEXT_FIELD } from './refusal-support'; + +const SHORT_TEXT = { + kind: 'codec-instance', + codecId: 'pg/text@1', + nativeType: 'text', + typeParams: {}, +}; +const COORDINATE = '"public"."Widget"."label"'; + +describe('a column typed by a named type', () => { + it('prints a column with the native type and codec of its named type', () => { + expect( + printingWidget({ + storageTypes: { ShortText: SHORT_TEXT }, + columns: { + label: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: false, + typeRef: 'ShortText', + }, + }, + fields: { label: TEXT_FIELD }, + }), + ).not.toThrow(); + }); + + it('refuses a column whose native type and codec are not those of its named type', () => { + expect( + printingWidget({ + storageTypes: { ShortText: SHORT_TEXT }, + columns: { + label: { + nativeType: 'int4', + codecId: 'pg/int4@1', + nullable: false, + typeRef: 'ShortText', + }, + }, + fields: { label: INT_FIELD }, + }), + ).toThrow(refusal({ coordinate: COORDINATE, typeRef: 'ShortText' })); + }); + + it('refuses a column typed by a named type the contract does not declare', () => { + expect( + printingWidget({ + columns: { + label: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: false, + typeRef: 'ShortText', + }, + }, + fields: { label: TEXT_FIELD }, + }), + ).toThrow(refusal({ coordinate: COORDINATE, typeRef: 'ShortText' })); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/refusals-names.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-names.test.ts new file mode 100644 index 000000000000..0de995d7590b --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-names.test.ts @@ -0,0 +1,124 @@ +import { asNamespaceId } from '@internal/contract/types'; +import { describe, expect, it } from 'vitest'; +import { + deserializeEdited, + INT_COLUMN, + INT_FIELD, + PUBLIC, + printing, + printingWidget, + refusal, + TEXT_COLUMN, + TEXT_FIELD, + widgetContract, +} from './refusal-support'; + +/** A second namespace holding one model `Archived`, stored in table `Archived`. */ +function printingWithNamespace(namespaceId: string) { + return printingWidget({ + domainNamespaces: { + [namespaceId]: { + models: { + Archived: { + storage: { table: 'Archived', namespaceId, fields: { id: { column: 'id' } } }, + fields: { id: INT_FIELD }, + relations: {}, + }, + }, + }, + }, + storageNamespaces: { + [namespaceId]: { + id: namespaceId, + entries: { + table: { + Archived: { + columns: { id: INT_COLUMN }, + uniques: [], + indexes: [], + foreignKeys: [], + primaryKey: { columns: ['id'] }, + }, + }, + }, + }, + }, + contract: { + roots: { + Widget: { namespace: PUBLIC, model: 'Widget' }, + Archived: { namespace: asNamespaceId(namespaceId), model: 'Archived' }, + }, + }, + }); +} + +describe('namespace names', () => { + it('prints a namespace whose name is a PSL identifier', () => { + expect(printingWithNamespace('archive')).not.toThrow(); + }); + + it.each(['2024archive', 'sales data'])( + 'refuses the namespace "%s", which is not a PSL identifier', + (namespaceId) => { + expect(printingWithNamespace(namespaceId)).toThrow( + refusal({ kind: 'namespace', name: namespaceId }), + ); + }, + ); + + it('refuses a namespace named unbound, which PSL reads as the late-binding namespace', () => { + expect(printingWithNamespace('unbound')).toThrow(refusal({ namespaceId: 'unbound' })); + }); +}); + +describe('the name __proto__ inside @map and @@map', () => { + const renamed = (placeholder: string) => (json: string) => + json.replaceAll(placeholder, '__proto__'); + + it('refuses a column named __proto__', () => { + const contract = deserializeEdited( + widgetContract({ + columns: { placeholder_column: TEXT_COLUMN }, + fields: { label: TEXT_FIELD }, + storageFields: { id: { column: 'id' }, label: { column: 'placeholder_column' } }, + }), + renamed('placeholder_column'), + ); + expect(printing(contract)).toThrow(refusal({ kind: 'column', name: '__proto__' })); + }); + + it('refuses a table named __proto__', () => { + const contract = deserializeEdited( + widgetContract({ + models: { + Gadget: { + storage: { + table: 'placeholder_table', + namespaceId: 'public', + fields: { id: { column: 'id' } }, + }, + fields: { id: INT_FIELD }, + relations: {}, + }, + }, + tables: { + placeholder_table: { + columns: { id: INT_COLUMN }, + uniques: [], + indexes: [], + foreignKeys: [], + primaryKey: { columns: ['id'] }, + }, + }, + contract: { + roots: { + Widget: { namespace: PUBLIC, model: 'Widget' }, + placeholder_table: { namespace: PUBLIC, model: 'Gadget' }, + }, + }, + }), + renamed('placeholder_table'), + ); + expect(printing(contract)).toThrow(refusal({ kind: 'table', name: '__proto__' })); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/refusals-relations.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-relations.test.ts new file mode 100644 index 000000000000..5f69b020a789 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-relations.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + INT_COLUMN, + INT_FIELD, + PUBLIC, + printingWidget, + refusal, + type WidgetParts, +} from './refusal-support'; + +/** A `Widget` with the given relations, and a `Post` that holds `widgetId` and has no relation. */ +function widgetWithRelations(relations: Record, post: WidgetParts['table'] = {}) { + return printingWidget({ + model: { relations }, + models: { + Post: { + storage: { + table: 'Post', + namespaceId: 'public', + fields: { id: { column: 'id' }, widgetId: { column: 'widgetId' } }, + }, + fields: { id: INT_FIELD, widgetId: INT_FIELD }, + relations: {}, + }, + }, + tables: { + Post: { + columns: { id: INT_COLUMN, widgetId: INT_COLUMN }, + uniques: [], + indexes: [], + foreignKeys: [], + primaryKey: { columns: ['id'] }, + ...post, + }, + }, + contract: { + roots: { + Widget: { namespace: PUBLIC, model: 'Widget' }, + Post: { namespace: PUBLIC, model: 'Post' }, + }, + }, + }); +} + +describe('relations with no owning side', () => { + it.each(['1:N', '1:1'])( + 'refuses a %s relation when the other model has no relation that holds the foreign key', + (cardinality) => { + expect( + widgetWithRelations({ + posts: { + to: { namespace: PUBLIC, model: 'Post' }, + cardinality, + on: { localFields: ['id'], targetFields: ['widgetId'] }, + }, + }), + ).toThrow(refusal({ model: 'Widget', field: 'posts' })); + }, + ); + + it('refuses a relation that names no fields to join on', () => { + expect( + widgetWithRelations({ + posts: { to: { namespace: PUBLIC, model: 'Post' }, cardinality: '1:N' }, + }), + ).toThrow(refusal({ model: 'Widget', field: 'posts' })); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/refusals-storage.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-storage.test.ts new file mode 100644 index 000000000000..ceac1c96f4a4 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-storage.test.ts @@ -0,0 +1,368 @@ +import type { ContractField } from '@internal/contract/types'; +import { describe, expect, it } from 'vitest'; +import { buildPostgresPslContract } from '../../src/core/psl-print/psl-contract'; +import { extensionCodec, testBuildContext } from './build-context'; +import { + deserialize, + deserializeEdited, + INT_COLUMN, + PUBLIC, + printing, + printingWidget, + refusal, + TEXT_FIELD, + widgetContract, + withRawEntries, +} from './refusal-support'; + +describe('parts of the contract no model carries', () => { + it('refuses a table no model is stored in', () => { + expect( + printingWidget({ + tables: { + audit_log: { columns: { id: INT_COLUMN }, uniques: [], indexes: [], foreignKeys: [] }, + }, + }), + ).toThrow(refusal({ namespaceId: 'public', table: 'audit_log' })); + }); + + it('refuses a value set no enum derives', () => { + expect( + printingWidget({ entries: { valueSet: { Stray: { kind: 'valueSet', values: ['a'] } } } }), + ).toThrow(refusal({ namespaceId: 'public', name: 'Stray' })); + }); + + it('refuses a value set whose values are not the members of the enum it is named after', () => { + expect( + printingWidget({ + domain: { + enum: { Priority: { codecId: 'pg/text@1', members: [{ name: 'Low', value: 'low' }] } }, + }, + entries: { valueSet: { Priority: { kind: 'valueSet', values: ['low', 'high'] } } }, + }), + ).toThrow(refusal({ namespaceId: 'public', name: 'Priority' })); + }); + + it('refuses a native enum with no value set holding its members', () => { + expect( + printingWidget({ + entries: { + native_enum: { status: { kind: 'postgres-enum', typeName: 'status', members: ['a'] } }, + }, + }), + ).toThrow(refusal({ namespaceId: 'public', typeName: 'status' })); + }); + + it('refuses an enum with no value set holding its members', () => { + expect( + printingWidget({ + domain: { + enum: { Priority: { codecId: 'pg/text@1', members: [{ name: 'Low', value: 'low' }] } }, + }, + }), + ).toThrow(refusal({ namespaceId: 'public', name: 'Priority' })); + }); + + it('refuses a native enum whose value set name is not a PSL identifier', () => { + expect( + printingWidget({ + entries: { + native_enum: { status: { kind: 'postgres-enum', typeName: 'status', members: ['a'] } }, + valueSet: { 'order status': { kind: 'valueSet', values: ['a'] } }, + }, + }), + ).toThrow(refusal({ kind: 'native enum', name: 'order status' })); + }); + + it('refuses a native enum with its own control policy', () => { + expect( + printingWidget({ + entries: { + native_enum: { + status: { + kind: 'postgres-enum', + typeName: 'status', + members: ['a'], + control: 'external', + }, + }, + valueSet: { status: { kind: 'valueSet', values: ['a'] } }, + }, + }), + ).toThrow(refusal({ namespaceId: 'public', typeName: 'status', control: 'external' })); + }); + + it('refuses a domain enum outside the default namespace', () => { + expect( + printingWidget({ + domainNamespaces: { + auth: { + models: {}, + enum: { Role: { codecId: 'pg/text@1', members: [{ name: 'Admin', value: 'admin' }] } }, + }, + }, + }), + ).toThrow(refusal({ namespaceId: 'auth', names: ['Role'] })); + }); + + it('refuses a namespace that declares nothing', () => { + expect( + printingWidget({ storageNamespaces: { archive: { id: 'archive', entries: { table: {} } } } }), + ).toThrow(refusal({ plane: 'storage', namespaceId: 'archive' })); + }); + + it('refuses a domain namespace with no model, value object or enum', () => { + expect(printingWidget({ domainNamespaces: { archive: { models: {} } } })).toThrow( + refusal({ plane: 'domain', namespaceId: 'archive' }), + ); + }); + + it('refuses a contract without the default namespace the PSL source always creates', () => { + const empty = deserializeEdited(widgetContract(), (json) => { + const contract = JSON.parse(json); + contract.roots = {}; + contract.domain.namespaces = {}; + contract.storage.namespaces = {}; + return JSON.stringify(contract); + }); + expect(printing(empty)).toThrow(refusal({ plane: 'storage', namespaceId: 'public' })); + }); + + it('refuses top-level meta entries', () => { + expect(printingWidget({ contract: { meta: { owner: 'billing' } } })).toThrow( + refusal({ keys: ['owner'] }), + ); + }); + + it('refuses roots other than the ones the PSL source derives', () => { + expect(printingWidget({ contract: { roots: {} } })).toThrow(refusal({ root: 'Widget' })); + expect( + printingWidget({ + contract: { + roots: { + widgets: { namespace: PUBLIC, model: 'Widget' }, + }, + }, + }), + ).toThrow(refusal({ root: 'widgets' })); + }); + + it('refuses an entity kind the printer does not write', () => { + expect( + printing( + withRawEntries(deserialize(widgetContract()), 'public', { sequence: { counter: {} } }), + ), + ).toThrow(refusal({ namespaceId: 'public', kind: 'sequence', names: ['counter'] })); + }); +}); + +describe('row-level security', () => { + const RLS = { Widget: { kind: 'rls', namespaceId: 'public', tableName: 'Widget' } }; + const ROLES = { + app_user: { kind: 'role', name: 'app_user', namespaceId: '__unbound__', control: 'external' }, + }; + + function policy(overrides: Record = {}) { + return { + kind: 'policy', + name: 'widget_read', + tableName: 'Widget', + namespaceId: 'public', + operation: 'select', + roles: ['app_user'], + using: 'true', + permissive: true, + ...overrides, + }; + } + + function withRls( + entries: Record>, + unboundRoles: Record = ROLES, + ) { + return printingWidget({ + entries, + storageNamespaces: { + __unbound__: { id: '__unbound__', entries: { table: {}, role: unboundRoles } }, + }, + }); + } + + it('prints the row-level security the refusal tests start from', () => { + expect(withRls({ rls: RLS, policy: { widget_read: policy() } })).not.toThrow(); + }); + + it('refuses row-level security on a table with no model', () => { + expect( + withRls({ + rls: { ...RLS, archive: { kind: 'rls', namespaceId: 'public', tableName: 'archive' } }, + }), + ).toThrow(refusal({ namespaceId: 'public', table: 'archive' })); + }); + + it('refuses a policy on a table with no model', () => { + expect( + withRls({ rls: RLS, policy: { widget_read: policy({ tableName: 'archive' }) } }), + ).toThrow(refusal({ namespaceId: 'public', table: 'archive', name: 'widget_read' })); + }); + + it('refuses a policy on a table without row-level security', () => { + expect(withRls({ policy: { widget_read: policy() } })).toThrow( + refusal({ namespaceId: 'public', table: 'Widget', name: 'widget_read' }), + ); + }); + + it('refuses a policy named by something other than a PSL identifier', () => { + expect( + withRls({ + rls: RLS, + policy: { + 'Enable read access for all users': policy({ name: 'Enable read access for all users' }), + }, + }), + ).toThrow(refusal({ kind: 'policy', name: 'Enable read access for all users' })); + }); + + it('refuses a policy role that is not a PSL identifier', () => { + expect( + withRls( + { rls: RLS, policy: { widget_read: policy({ roles: ['app user'] }) } }, + { + 'app user': { + kind: 'role', + name: 'app user', + namespaceId: '__unbound__', + control: 'external', + }, + }, + ), + ).toThrow(refusal({ kind: 'role', name: 'app user' })); + }); + + it('refuses a wire-named policy whose name is not its prefix and the hash of its content', () => { + expect( + withRls({ + rls: RLS, + policy: { widget_read: policy({ name: 'widget_read_00000000', prefix: 'widget_read' }) }, + }), + ).toThrow(refusal({ namespaceId: 'public', table: 'Widget', name: 'widget_read_00000000' })); + }); + + it('writes a policy expression holding a tab and another control character', () => { + expect( + withRls({ rls: RLS, policy: { widget_read: policy({ using: 'owner\t= 1\u0001' }) } }), + ).not.toThrow(); + }); + + it('refuses a role outside the unbound namespace', () => { + expect( + withRls({ role: { app_user: { ...ROLES.app_user, namespaceId: 'public' } } }, {}), + ).toThrow(refusal({ namespaceId: 'public', name: 'app_user' })); + }); +}); + +describe('value objects', () => { + function addressContract(field: ContractField) { + return deserialize( + widgetContract({ + columns: { address: { nativeType: 'jsonb', codecId: 'pg/jsonb@1', nullable: false } }, + fields: { address: { nullable: false, type: { kind: 'valueObject', name: 'Address' } } }, + domain: { valueObjects: { Address: { fields: { street: field } } } }, + }), + ); + } + + function withAddress(field: ContractField) { + return printing(addressContract(field)); + } + + it('refuses a value object outside the default namespace, which the PSL source would move', () => { + expect( + printingWidget({ + domainNamespaces: { + auth: { models: {}, valueObjects: { Address: { fields: { street: TEXT_FIELD } } } }, + }, + storageNamespaces: { auth: { id: 'auth', entries: { table: {} } } }, + }), + ).toThrow(refusal({ namespaceId: 'auth', names: ['Address'] })); + }); + + it('refuses a value-object field whose type is a union', () => { + expect( + withAddress({ + nullable: false, + type: { kind: 'union', members: [{ kind: 'scalar', codecId: 'pg/text@1' }] }, + }), + ).toThrow(refusal({ coordinate: '"public".Address.street', kind: 'union' })); + }); + + it('refuses a value-object field that is a dictionary', () => { + expect(withAddress({ ...TEXT_FIELD, dict: true })).toThrow( + refusal({ coordinate: '"public".Address.street' }), + ); + }); + + it('refuses a value-object field whose codec no Postgres codec in the stack names a native type for', () => { + expect( + withAddress({ nullable: false, type: { kind: 'scalar', codecId: 'pgvector/vector@1' } }), + ).toThrow(refusal({ coordinate: '"public".Address.street', codecId: 'pgvector/vector@1' })); + }); + + it('refuses a value-object field whose codec names a native type only from type parameters', () => { + expect( + withAddress({ nullable: false, type: { kind: 'scalar', codecId: 'pg/enum@1' } }), + ).toThrow(refusal({ coordinate: '"public".Address.street', codecId: 'pg/enum@1' })); + }); + + it('writes a value-object field typed by a codec only the stack knows, as the type constructor that produces it', () => { + const context = testBuildContext({ + codecs: [extensionCodec], + types: { + ext: { + Citext: { + kind: 'typeConstructor', + output: { codecId: extensionCodec.codecId, nativeType: 'citext' }, + }, + }, + }, + }); + const document = buildPostgresPslContract( + addressContract({ + nullable: false, + type: { kind: 'scalar', codecId: extensionCodec.codecId }, + }), + context, + ); + + expect( + document.namespaces.flatMap((namespace) => + namespace.compositeTypes.flatMap((compositeType) => + compositeType.fields.map((field) => field.typeName), + ), + ), + ).toEqual(['ext.Citext']); + }); + + it('refuses a value-object field whose type carries type parameters, which the PSL source drops', () => { + expect( + withAddress({ + nullable: false, + type: { kind: 'scalar', codecId: 'sql/varchar@1', typeParams: { length: 20 } }, + }), + ).toThrow(refusal({ coordinate: '"public".Address.street' })); + }); + + it('refuses a value-object field that names a value set, which the PSL source drops', () => { + expect( + withAddress({ + ...TEXT_FIELD, + valueSet: { + plane: 'storage', + entityKind: 'valueSet', + namespaceId: 'public', + entityName: 'Label', + }, + }), + ).toThrow(refusal({ coordinate: '"public".Address.street' })); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/relation-fields.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/relation-fields.test.ts new file mode 100644 index 000000000000..24a800fb0a5e --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/relation-fields.test.ts @@ -0,0 +1,130 @@ +import { asNamespaceId } from '@internal/contract/types'; +import { describe, expect, it } from 'vitest'; +import { buildModels, fieldText, INT_COLUMN, table } from './print-support'; + +describe('relations', () => { + function postAndUser(foreignKey: Record) { + return buildModels({ + models: { + User: { + table: 'user', + fields: { id: { column: 'id' } }, + relations: { + posts: { + to: { namespace: asNamespaceId('public'), model: 'Post' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['authorId'] }, + }, + }, + }, + Post: { + table: 'post', + fields: { id: { column: 'id' }, authorId: { column: 'authorId' } }, + relations: { + author: { + to: { namespace: asNamespaceId('public'), model: 'User' }, + cardinality: 'N:1', + nullable: false, + on: { localFields: ['authorId'], targetFields: ['id'] }, + }, + }, + }, + }, + tables: { + user: table({ columns: { id: INT_COLUMN }, primaryKey: { columns: ['id'] } }), + post: table({ + columns: { id: INT_COLUMN, authorId: INT_COLUMN }, + primaryKey: { columns: ['id'] }, + foreignKeys: [ + { + source: { namespaceId: 'public', tableName: 'post', columns: ['authorId'] }, + target: { namespaceId: 'public', tableName: 'user', columns: ['id'] }, + ...foreignKey, + }, + ], + }), + }, + }); + } + + it('writes both referential actions and declines a backing index', () => { + const models = postAndUser({ onDelete: 'cascade', onUpdate: 'restrict' }); + expect(models[1]?.fields.map(fieldText)[2]).toBe( + 'author User @relation(fields: [authorId], references: [id], onDelete: Cascade, onUpdate: Restrict, index: false)', + ); + }); + + it('writes the foreign key name when the key carries one, and no action the key does not', () => { + const models = postAndUser({ name: 'post_author_fkey' }); + expect(models[1]?.fields.map(fieldText)[2]).toBe( + 'author User @relation(fields: [authorId], references: [id], map: "post_author_fkey", index: false)', + ); + }); + + it('prints the other side as a list with no arguments', () => { + const models = postAndUser({ onDelete: 'cascade', onUpdate: 'cascade' }); + expect(models[0]?.fields.map(fieldText)[1]).toBe('posts Post[]'); + }); + + it('takes its actions from the foreign key that references the columns the relation names', () => { + const models = buildModels({ + models: { + User: { + table: 'user', + fields: { id: { column: 'id' }, altId: { column: 'altId' } }, + relations: {}, + }, + Post: { + table: 'post', + fields: { id: { column: 'id' }, authorId: { column: 'authorId' } }, + relations: { + author: { + to: { namespace: asNamespaceId('public'), model: 'User' }, + cardinality: 'N:1', + nullable: false, + on: { localFields: ['authorId'], targetFields: ['altId'] }, + }, + authorById: { + to: { namespace: asNamespaceId('public'), model: 'User' }, + cardinality: 'N:1', + nullable: false, + on: { localFields: ['authorId'], targetFields: ['id'] }, + }, + }, + }, + }, + tables: { + user: table({ + columns: { id: INT_COLUMN, altId: INT_COLUMN }, + primaryKey: { columns: ['id'] }, + uniques: [{ columns: ['altId'], name: 'user_altId_key' }], + }), + post: table({ + columns: { id: INT_COLUMN, authorId: INT_COLUMN }, + primaryKey: { columns: ['id'] }, + foreignKeys: [ + { + source: { namespaceId: 'public', tableName: 'post', columns: ['authorId'] }, + target: { namespaceId: 'public', tableName: 'user', columns: ['id'] }, + name: 'post_author_id_fkey', + onDelete: 'cascade', + onUpdate: 'cascade', + }, + { + source: { namespaceId: 'public', tableName: 'post', columns: ['authorId'] }, + target: { namespaceId: 'public', tableName: 'user', columns: ['altId'] }, + name: 'post_author_altId_fkey', + onDelete: 'restrict', + onUpdate: 'restrict', + }, + ], + }), + }, + }); + + expect(models[1]?.fields.map(fieldText).slice(2)).toEqual([ + 'author User @relation(name: "Post_author", fields: [authorId], references: [altId], onDelete: Restrict, onUpdate: Restrict, map: "post_author_altId_fkey", index: false)', + 'authorById User @relation(name: "Post_authorById", fields: [authorId], references: [id], onDelete: Cascade, onUpdate: Cascade, map: "post_author_id_fkey", index: false)', + ]); + }); +}); diff --git a/packages/3-targets/6-adapters/postgres/test/printed-type-codecs.test.ts b/packages/3-targets/6-adapters/postgres/test/inferred-type-codecs.test.ts similarity index 56% rename from packages/3-targets/6-adapters/postgres/test/printed-type-codecs.test.ts rename to packages/3-targets/6-adapters/postgres/test/inferred-type-codecs.test.ts index b241a3731f8d..2d2de506911c 100644 --- a/packages/3-targets/6-adapters/postgres/test/printed-type-codecs.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/inferred-type-codecs.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { CODEC_ID_BY_PRINTED_TYPE } from '../../../3-targets/postgres/src/core/psl-infer/infer-default-codec'; +import { CODEC_ID_BY_INFERRED_TYPE } from '../../../3-targets/postgres/src/core/psl-infer/infer-default-codec'; import { postgresNativeAuthoringTypes, postgresScalarAuthoringTypes, } from '../src/core/control-mutation-defaults'; /** - * `contract infer` writes a default in the form the codec `contract emit` binds to the printed type - * name reads back. The printer restates that binding for the type names it prints, because the - * authoring namespaces that own it sit above the target package; this fails if the two disagree. + * `contract infer` writes a default in the form the codec `contract emit` binds to the type name it + * writes reads back. It restates that binding for the type names it writes, because the authoring + * namespaces that own it sit above the target package; this fails if the two disagree. */ const emitCodecIdByTypeName: ReadonlyMap = new Map( [ @@ -17,17 +17,17 @@ const emitCodecIdByTypeName: ReadonlyMap = new Map( ].map(([typeName, typeConstructor]) => [typeName, typeConstructor.output.codecId]), ); -describe('the codec bound to each printed PSL type name', () => { +describe('the codec bound to each inferred PSL type name', () => { it('has a binding to compare against', () => { expect(emitCodecIdByTypeName.size).toBeGreaterThan(0); - expect(CODEC_ID_BY_PRINTED_TYPE.size).toBeGreaterThan(0); + expect(CODEC_ID_BY_INFERRED_TYPE.size).toBeGreaterThan(0); }); it('agrees with the type constructor contract emit resolves', () => { expect( - [...CODEC_ID_BY_PRINTED_TYPE].map(([typeName, codecId]) => ({ typeName, codecId })), + [...CODEC_ID_BY_INFERRED_TYPE].map(([typeName, codecId]) => ({ typeName, codecId })), ).toEqual( - [...CODEC_ID_BY_PRINTED_TYPE.keys()].map((typeName) => ({ + [...CODEC_ID_BY_INFERRED_TYPE.keys()].map((typeName) => ({ typeName, codecId: emitCodecIdByTypeName.get(typeName), })), diff --git a/packages/9-public/@prisma/orm-family-sql/package.json b/packages/9-public/@prisma/orm-family-sql/package.json index a70f71c534ba..e8d49abfca77 100644 --- a/packages/9-public/@prisma/orm-family-sql/package.json +++ b/packages/9-public/@prisma/orm-family-sql/package.json @@ -76,6 +76,7 @@ "./contract-psl": "./dist/contract-psl.mjs", "./contract-psl/attribute-specs": "./dist/contract-psl__attribute-specs.mjs", "./contract-psl/default-table-name": "./dist/contract-psl__default-table-name.mjs", + "./contract-psl/map-names": "./dist/contract-psl__map-names.mjs", "./contract-psl/provider": "./dist/contract-psl__provider.mjs", "./contract-ts": "./dist/contract-ts.mjs", "./contract-ts/config-types": "./dist/contract-ts__config-types.mjs", @@ -88,6 +89,7 @@ "./family/ir": "./dist/family__ir.mjs", "./family/migration": "./dist/family__migration.mjs", "./family/pack": "./dist/family__pack.mjs", + "./family/psl-build": "./dist/family__psl-build.mjs", "./family/psl-infer": "./dist/family__psl-infer.mjs", "./family/runtime": "./dist/family__runtime.mjs", "./family/verify": "./dist/family__verify.mjs", diff --git a/packages/9-public/@prisma/orm-framework/package.json b/packages/9-public/@prisma/orm-framework/package.json index d80d50b6b07c..8221fc83fa51 100644 --- a/packages/9-public/@prisma/orm-framework/package.json +++ b/packages/9-public/@prisma/orm-framework/package.json @@ -18,6 +18,7 @@ "@standard-schema/spec": "^1.1.0", "arktype": "catalog:", "pathe": "^2.0.3", + "tinyglobby": "catalog:", "uniku": "^0.5.0" }, "devDependencies": { diff --git a/packages/9-public/@prisma/orm-postgres/package.json b/packages/9-public/@prisma/orm-postgres/package.json index 15cb66f5a6a0..c402b8a9baf8 100644 --- a/packages/9-public/@prisma/orm-postgres/package.json +++ b/packages/9-public/@prisma/orm-postgres/package.json @@ -87,6 +87,7 @@ "./family/ir": "./dist/family__ir.mjs", "./family/migration": "./dist/family__migration.mjs", "./family/pack": "./dist/family__pack.mjs", + "./family/psl-build": "./dist/family__psl-build.mjs", "./family/psl-infer": "./dist/family__psl-infer.mjs", "./family/runtime": "./dist/family__runtime.mjs", "./family/verify": "./dist/family__verify.mjs", diff --git a/packages/9-public/@prisma/orm-sqlite/package.json b/packages/9-public/@prisma/orm-sqlite/package.json index 0041ed795f56..b348c94c0079 100644 --- a/packages/9-public/@prisma/orm-sqlite/package.json +++ b/packages/9-public/@prisma/orm-sqlite/package.json @@ -84,6 +84,7 @@ "./family/ir": "./dist/family__ir.mjs", "./family/migration": "./dist/family__migration.mjs", "./family/pack": "./dist/family__pack.mjs", + "./family/psl-build": "./dist/family__psl-build.mjs", "./family/psl-infer": "./dist/family__psl-infer.mjs", "./family/runtime": "./dist/family__runtime.mjs", "./family/verify": "./dist/family__verify.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd83d1a72886..70dc28814034 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1084,6 +1084,9 @@ importers: pathe: specifier: ^2.0.3 version: 2.0.3 + tinyglobby: + specifier: 'catalog:' + version: 0.2.17 devDependencies: '@repo/tsconfig': specifier: workspace:8.0.0-rc.12 @@ -4058,6 +4061,9 @@ importers: '@internal/sql-contract': specifier: workspace:8.0.0-rc.12 version: link:../../../2-sql/1-core/contract + '@internal/sql-contract-psl': + specifier: workspace:8.0.0-rc.12 + version: link:../../../2-sql/2-authoring/contract-psl '@internal/sql-errors': specifier: workspace:8.0.0-rc.12 version: link:../../../2-sql/1-core/errors @@ -4095,9 +4101,6 @@ importers: '@internal/publish-surface': specifier: workspace:8.0.0-rc.12 version: link:../../../0-shared/publish-surface - '@internal/sql-contract-psl': - specifier: workspace:8.0.0-rc.12 - version: link:../../../2-sql/2-authoring/contract-psl '@repo/test-utils': specifier: workspace:8.0.0-rc.12 version: link:../../../../test/utils @@ -4968,6 +4971,9 @@ importers: pathe: specifier: ^2.0.3 version: 2.0.3 + tinyglobby: + specifier: 'catalog:' + version: 0.2.17 uniku: specifier: ^0.5.0 version: 0.5.0 @@ -5511,6 +5517,9 @@ importers: '@internal/extension-pgvector': specifier: workspace:8.0.0-rc.12 version: link:../../packages/3-extensions/pgvector + '@internal/extension-postgis': + specifier: workspace:8.0.0-rc.12 + version: link:../../packages/3-extensions/postgis '@internal/extension-supabase': specifier: workspace:8.0.0-rc.12 version: link:../../packages/3-extensions/supabase diff --git a/projects/prisma7-contract-source/slices/03-contract-to-psl-and-convert/spec.md b/projects/prisma7-contract-source/slices/03-contract-to-psl-and-convert/spec.md deleted file mode 100644 index d3a8157fcdce..000000000000 --- a/projects/prisma7-contract-source/slices/03-contract-to-psl-and-convert/spec.md +++ /dev/null @@ -1,39 +0,0 @@ -# Slice 3: contract-to-PSL printer and `prisma contract convert` - -_Parent project: `projects/prisma7-contract-source/`. Linear: to be created. Outcome: a user on a Prisma 7 source runs one command and gets a Prisma 8 `contract.prisma` that produces the identical contract._ - -## At a glance - -```bash -prisma contract convert --output src/prisma/contract.prisma -``` - -Output begins: - -```prisma -// use prisma-8 -// Converted from prisma/schema.prisma by `prisma contract convert`. -``` - -## Chosen design - -- **Contract-to-PSL printer.** A new target-descriptor hook beside `inferPslContract`, implemented for Postgres and Mongo, that takes the family contract and returns a `PslDocumentAst`. It emits native enum blocks, namespaces, `temporal.timestamp(p, onCreate: now, onUpdate: now)` for the update-generator pair, explicit `map:` only where Prisma 8's derived name would differ from the contract's, explicit `onDelete`/`onUpdate`, and explicit junction models. Text comes from the existing `printPslFromAst`, which gains no options; the header is prepended by the command. -- **Command** `contract convert` in `packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts`, registered in `family.ts` and `cli.ts`. It requires the configured contract source to be a Prisma 7 source, loads the contract through it, prints, and writes with `publishTextArtifact`. Output path resolution reuses `inferredContractPathFor`. Refusals exit 4 and write nothing. -- **Round trip test.** For every fixture from slices 1 and 2: interpret the Prisma 7 file, convert, interpret the output with the PSL source, compare contract hashes. - -## Edge cases - -| Case | Disposition | -|---|---| -| Config uses a PSL or TypeScript source | Exit 2 with an error saying convert only applies to a Prisma 7 source. | -| Output file exists | Warn and overwrite, as `contract infer` does. | -| A construct the Prisma 8 PSL cannot spell (none expected after slices 1 and 2) | The printer throws an internal error naming the construct; the round trip test catches it. | - -## Slice Definition of Done - -Inherits `drive/calibration/dod.md`. Slice-specific: - -- [ ] Round trip hash equality holds for every fixture from slices 1 and 2. -- [ ] The printed output for the end-to-end fixtures emits with the PSL source and `db verify` reports zero findings. -- [ ] `packages/1-framework/3-tooling/cli/README.md` documents `contract convert`. -- [ ] `--json` output carries the written path. diff --git a/projects/prisma7-contract-source/slices/03-contract-to-psl-and-print/spec.md b/projects/prisma7-contract-source/slices/03-contract-to-psl-and-print/spec.md new file mode 100644 index 000000000000..0ac11067ec60 --- /dev/null +++ b/projects/prisma7-contract-source/slices/03-contract-to-psl-and-print/spec.md @@ -0,0 +1,43 @@ +# Slice 3: contract-to-PSL printer and `prisma contract print` + +_Parent project: `projects/prisma7-contract-source/`. Linear: to be created. Outcome: a user on a Prisma 7 source runs one command and gets a Prisma 8 `contract.prisma` that produces the identical contract. The command is not tied to Prisma 7: it prints the contract of any configured source._ + +## At a glance + +```bash +prisma contract print --output src/prisma/contract.prisma +``` + +Output begins: + +```prisma +// use prisma-8 +// Printed from prisma/schema.prisma by `prisma contract print`. +``` + +## Chosen design + +- **Contract-to-PSL printer.** A target-descriptor hook beside `inferPslContract`, implemented for Postgres, that takes the family contract and the configured stack's PSL types and returns a `PslDocumentAst` that reads back as the same contract. Text comes from the existing `printPslFromAst`, which always writes the `// use prisma-8` marker and takes a `description` line from each caller. +- **Command** `contract print` in `packages/1-framework/3-tooling/cli/src/orm/contract/print.ts`, registered in `family.ts` and `cli.ts`. It loads the contract through whatever source the config names and prints the PSL: on screen in a terminal, to standard output with `--format human`, or as `psl.text` in the JSON result. With `--output ` it writes the file with `publishTextArtifact` instead. Refusals exit 2 and print and write nothing. +- **Round trip test.** For every Prisma 7 fixture from slice 1: interpret the Prisma 7 file, print, interpret the output with the PSL source, compare the serialized contracts and the storage hash. + +## Edge cases + +| Case | Disposition | +|---|---| +| No `--output` | Print the PSL; write no file. | +| `--output` file exists | Warn and overwrite, as `contract infer` does. | +| `--output` path is a source file the config reads | Exit 2 with `CONTRACT.PRINT_OUTPUT_IS_SOURCE`; nothing is written. | +| `--output` path names a new file that a glob input of the source would match | Exit 2 with `CONTRACT.PRINT_OUTPUT_IS_SOURCE`; nothing is written. | +| `--output` path is the config file or an emitted contract file | Exit 2 with `CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE`; nothing is written. | +| Part of the contract PSL cannot carry | Exit 2 with `CONTRACT.PRINT_UNSUPPORTED` naming it; nothing is written. Two Prisma 7 fixtures meet this: one model name in two namespaces. | +| The contract has a default control policy | The PSL file cannot carry it; the result names it, in `sourceSettings` in the JSON result, so the config sets it on the PSL source. | + +## Slice Definition of Done + +Inherits `drive/calibration/dod.md`. Slice-specific: + +- [ ] Round trip hash equality holds for every Prisma 7 fixture from slice 1 that `contract print` can write. +- [ ] The printed output for the end-to-end fixtures emits with the PSL source and `db verify` reports zero findings. +- [ ] `packages/1-framework/3-tooling/cli/README.md` documents `contract print`. +- [ ] `--json` output carries the written path with `--output`, and the PSL text without it. diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index dd18f524c0ba..988aefce3379 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -30,7 +30,7 @@ export default definePrismaConfig({ At cutover: ```bash -prisma contract convert --output src/prisma/contract.prisma +prisma contract print --output src/prisma/contract.prisma ``` writes the same contract as Prisma 8 PSL. The user switches `contract:` to that file and removes Prisma 7. @@ -42,12 +42,12 @@ writes the same contract as Prisma 8 PSL. The user switches `contract:` to that - Migration history and `_prisma_migrations`. - Prisma 6 SQL schemas that are not valid Prisma 7 schemas. The Mongo slice is the exception it has to be: Prisma 7 has no MongoDB connector, so that slice reads the Prisma 6 MongoDB dialect through `prisma6Schema`. - Extending the Prisma 7 dialect. It is frozen. -- Teaching `contract format` or the language server to read Prisma 7 files. +- Teaching the language server to read Prisma 7 files. `contract format` formats a Prisma 7 schema with the Prisma 8 formatter when it parses, because the Prisma 7 source is a `psl` source. ## Place in the larger world - The transition story this serves is the public upgrade guides listed under § References: Prisma 7 owns migrations, Prisma 8 adopts the database read-only with `db sign`, and cutover happens once. The older note `projects/prisma-8-rc1/parallel-install.md` assumes `prisma-next` and is out of date; `design-notes.md` reads the guides instead. -- Contract sources are `ContractConfig` objects whose `source.load` returns a contract or diagnostics; the emit path calls it without caring about format (`packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts:227`). The PSL source (`packages/2-sql/2-authoring/contract-psl/src/provider.ts:65`) and the TypeScript source (`packages/2-sql/2-authoring/contract-ts/src/config-types.ts:90`) are the two existing kinds. This project adds a third, one package per family, mirroring `contract-psl`. +- Contract sources are `ContractConfig` objects whose `source.load` returns a contract or diagnostics; `contract emit` and `contract print` call it without caring about format (`packages/1-framework/3-tooling/cli/src/control-api/operations/load-contract-source.ts`). The framework knows two source formats, `psl` and `typescript`: the PSL source (`packages/2-sql/2-authoring/contract-psl/src/provider.ts`) and the TypeScript source (`packages/2-sql/2-authoring/contract-ts/src/config-types.ts`). The Prisma 7 source is a `psl` source in its own package per family, mirroring `contract-psl`. - The Prisma 8 syntax parser (`@internal/psl-parser`) already reads the Prisma 7 grammar almost completely. See `design-notes.md`. - Every existing PSL printer starts from the database schema description, not from a contract. The contract-to-PSL printer is new and exposed as a target-descriptor hook beside `inferPslContract`. @@ -57,7 +57,7 @@ writes the same contract as Prisma 8 PSL. The user switches `contract:` to that 2. **Fidelity is defined by `db verify`.** The interpreter must produce a contract that `db sign` verifies with zero findings, in lenient mode, against the database Prisma 7 built. `db verify` (`packages/2-sql/9-family/src/core/diff/schema-verify.ts`) compares: column native type string and nullability (never the codec); column defaults structurally; primary key columns but not the name; foreign key `onDelete` and `onUpdate` with `noAction` equal to absent, but not the name; unique constraints by columns, not the name; indexes by name plus uniqueness, type, and columns; check constraints by name; native enums by type name and ordered member list. Consequences: reproduce Prisma 7's default index names, always set both referential actions explicitly, keep enum member order, and leave key, foreign key, and unique names to Prisma 8. 3. **No Prisma 7 packages in the product.** No framework, family, target, or extension package depends on `prisma`, `@prisma/prisma7`, `@prisma/get-dmmf`, or `@prisma/prisma-schema-wasm`. Parsing uses `@internal/psl-parser`. 4. **Layering.** Family-specific rules live in the family authoring packages (`packages/2-sql/2-authoring/contract-prisma7`, and `packages/2-mongo-family/2-authoring/contract-prisma6` for the Mongo slice). Everything a target must answer arrives through a binding the target pack supplies (`Prisma7TargetBinding`); the authoring package holds no target facts. The Prisma 7 source is a `ContractConfig`, and `defineConfig` in both `@prisma/orm-postgres/config` and `@prisma/orm-mongo/config` accepts `contract: string | ContractConfig`. Nothing family-specific enters `packages/1-framework`. -5. **Round trip is a hash equality.** For every fixture, interpreting the Prisma 7 file and interpreting the converted Prisma 8 file produce the same contract hashes, so the signed marker survives cutover. +5. **Round trip is a hash equality.** For every fixture `contract print` can write, interpreting the Prisma 7 file and interpreting the printed Prisma 8 file produce the same contract hashes, so the signed marker survives cutover. 6. **Multi-file schemas.** A directory path reads every `.prisma` file in it, matching Prisma 7's multi-file layout. ## Transitional-shape constraints @@ -74,7 +74,7 @@ Postgres and Mongo. SQLite is not a Prisma 7 side-by-side target in this project ## ADR pointer -[ADR 252 — An earlier Prisma version's schema is a contract source](<../../docs/architecture docs/adrs/ADR 252 - An earlier Prisma version's schema is a contract source.md>) records the decisions: the earlier dialect as a first-class contract source, hard errors instead of relaxed Prisma 8 checks, fidelity defined by `db verify`, the opt-in parser grammar, where dialect rules and target facts live, the public names, and the diagnostic code space. The extension point itself is [ADR 163](<../../docs/architecture docs/adrs/ADR 163 - Provider-invoked source interpretation packages.md>), which this project follows rather than changes. +[ADR 252 — An earlier Prisma version's schema is a contract source](<../../docs/architecture docs/adrs/ADR 252 - An earlier Prisma version's schema is a contract source.md>) records the decisions: the earlier dialect as a first-class contract source, hard errors instead of relaxed Prisma 8 checks, fidelity defined by `db verify`, one parser grammar for every PSL document, where dialect rules and target facts live, the public names, and the diagnostic code space. The extension point itself is [ADR 163](<../../docs/architecture docs/adrs/ADR 163 - Provider-invoked source interpretation packages.md>), which this project follows rather than changes. ## Project Definition of Done @@ -82,10 +82,10 @@ Inherits `drive/calibration/dod.md`. Project-specific: - Every rule row and every error code in the slice specs has a fixture that passes through the real parser and interpreter. - The Postgres and Mongo end-to-end proofs emit, sign, and verify with zero findings in lenient mode against databases shaped by Prisma 7 migrations. -- For every fixture, `hash(interpret(prisma7)) === hash(interpret(convert(prisma7)))`. +- For every fixture `contract print` can write, `hash(interpret(prisma7)) === hash(interpret(print(prisma7)))`. The two fixtures that declare one model name in two namespaces are refused. - A schema using any unsupported construct fails emit with one diagnostic per construct and no partial output. - No framework, family, target, or extension package depends on `prisma`, `@prisma/prisma7`, `@prisma/get-dmmf`, or `@prisma/prisma-schema-wasm`. The adoption example app (slice 4) intentionally installs Prisma 7, because showing both side by side is its purpose. -- CLI README documents `contract convert`, and each facade's config reference documents its reader (`prisma7Schema` for Postgres, `prisma6Schema` for Mongo). +- CLI README documents `contract print`, and each facade's config reference documents its reader (`prisma7Schema` for Postgres, `prisma6Schema` for Mongo). ## Plan-time verification items @@ -109,39 +109,70 @@ Recorded so they are not lost; each becomes its own project when scheduled. - A `pg/opaque` codec carrying the native type name, which also repairs `contract infer` emitting `Unsupported(...)` that nothing reads back. - A cuid v1 generator, if mapping `cuid()` to cuid2 turns out to matter. - Referential-action emulation on Mongo. -- `Bytes` and `DateTime` literal defaults are carried as the SQL literal of the default Postgres stores (`'\x68656c6c6f'`, `'2024-01-01 00:00:00'`), not the text Prisma 7 writes, in the raw-expression form the schema IR already models, because their codec JSON forms are not what introspection reads back. Verification is exact; the cost is that the converter (slice 3) prints them as `dbgenerated("...")` rather than `@default("...")`. -- **Cross-namespace and cross-contract-space enum references: a feature to build.** Any user contract whose column is typed by a Supabase enum (for example a `public` table using `auth.factor_type`) needs it, and Prisma 7 `multiSchema` schemas do the same across schemas. The parser and AST already carry the qualifier (`space:ns.Name` on `PslField.typeContractSpaceId`/`typeNamespaceId`, printer round-trips it), but the SQL interpreter consumes it only for `@relation` (`psl-field-resolution.ts:455`, `interpreter.ts:1228-1240`) and resolves types by bare name (`psl-column-resolution.ts:803-809`, `interpreter.ts:585`). ADR 226 defines cross-space ownership and the `@relation` spelling only. Needs an ADR extending ADR 226 to enum and entity type references, then the interpreter change; until then the Prisma 7 source reports `PSL.PRISMA7_ENUM_NAMESPACE_MISMATCH`. +- `Bytes` and `DateTime` literal defaults are carried as the SQL literal of the default Postgres stores (`'\x68656c6c6f'`, `'2024-01-01 00:00:00'`), not the text Prisma 7 writes, in the raw-expression form the schema IR already models, because their codec JSON forms are not what introspection reads back. Verification is exact; the cost is that `contract print` writes them as `sql` tagged literals rather than typed literals. +- **Cross-namespace and cross-contract-space enum references: a feature to build.** Any user contract whose column is typed by a Supabase enum (for example a `public` table using `auth.factor_type`) needs it, and Prisma 7 `multiSchema` schemas do the same across schemas. The parser and AST already carry the qualifier (`space:ns.Name` on `PslField.typeContractSpaceId`/`typeNamespaceId`, printer round-trips it), but the SQL interpreter consumes it only for `@relation` (`psl-field-resolution.ts:455`, `interpreter.ts:1228-1240`) and resolves types by bare name (`psl-column-resolution.ts:803-809`, `interpreter.ts:585`). ADR 226 defines cross-space ownership and the `@relation` form only. Needs an ADR extending ADR 226 to enum and entity type references, then the interpreter change; until then the Prisma 7 source reports `PSL.PRISMA7_ENUM_NAMESPACE_MISMATCH`. - Partial indexes (`@@index(where: raw(...))` with the `partialIndexes` preview feature). The Prisma 7 source reports a hard error; mapping them is new capability with its own Prisma 7 evidence. -- Not deferred, assigned to `projects/mongo-defaults-codecs-prisma6-source/slices/05-prisma6-mongo-source/spec.md`: the Mongo PSL interpreter silently ignores unknown top-level blocks (`view` included); that slice adds the diagnostic. +- **Most of the Postgres PSL printer is SQL family logic.** Pairing models with tables, polymorphism, relations, `@@map`, value objects, named types and domain enums need no Postgres knowledge, but they live in `packages/3-targets/3-targets/postgres/src/core/psl-print/`. A second SQL target would have to copy them. The target state: the SQL family builds the PSL document and calls a narrower target hook for column types, defaults, native enums, derived checks and row-level security. +- **`contract print` cannot write an entity kind a pack contributes.** A pack can add an entity kind and its PSL block, but the entity type descriptor has no way back from entity to block, so the printer refuses every kind it does not know. Lifted by an optional inverse on the descriptor, probably with an ADR. +- **The default-function registry cannot map a generator back to its PSL call.** The printer keeps its own table from generators to `uuid()`, `cuid(2)`, `nanoid(n)` and the rest. Lifted by letting each registry entry state which calls produce which generator. +- **The contract does not record which `native_enum` block produced a value set.** The printer rebuilds that link from column references and member lists. Lifted by recording the link on the native enum entity at the next contract shape change. +- **`contract print` refuses a relation into another contract space**, such as a Supabase app's relation to `supabase:auth.AuthUser`. PSL can write it, but the printer would need the composed extension contracts to find the target's columns and foreign key. +- **Setting a default control policy on a PSL source needs `prismaContract(...)` with its full Postgres options.** The facade `defineConfig` has no option for it. +- **The storage hash covers neither the domain nor the default control policy**, so after cutover `db verify` and the signed marker cannot see a difference there. The round-trip tests compare the whole serialized contract; changing what the hash covers is a contract design decision. +- **The PSL reader drops the enum value set from a list field, and the TypeScript builder keeps it.** A TypeScript contract with an enum list field is therefore refused by `contract print`. Decide which is right, then align the other. +- **`contract print` cannot protect a config file named with `--config`**, because the CLI engine does not tell a command which file it loaded. It protects the `prisma.config.ts` in the directory of the config that defines the `orm` section. +- **The PSL parser picks a block's member grammar by keyword.** A `view` body parses as model members and `enum` members take attributes, because the parser checks for those two keywords (`genericBlockMemberParser` in `psl-parser/src/parse.ts`). A pack that claims `view` gets model members whether it wants them or not, and a pack cannot ask for model members under another keyword. Lifted, when Prisma 8 views are designed, by letting the block descriptor state its member grammar and removing the keyword checks from the parser. + +### What `contract print` cannot write + +`contract print` loads the contract the config names, from any source, and prints it, or writes it with `--output`, as Prisma 8 PSL that reads back as the same contract. The command does not read its own output back; the printer is held to that promise by tests. The round-trip tests are all in `test/integration/test/psl-print/`. The broadest is `every-postgres-contract-roundtrip.integration.test.ts`: it finds every Postgres contract tracked in the repo when it runs, composes the extension packs each one names (the test lists the few packs that exist only inside one example or test, which it cannot load), prints it, reads the text back through the PSL source with the default control policy the printer names for the config, and requires either the same contract as `contract emit` writes it (leaving out `capabilities` and `extensions`, which come from the composed stack) or a refusal the test lists with its reason. Migration snapshots are left out: they are frozen copies of contracts, many in retired formats. Beside it: every Prisma 7 fixture (`prisma7-fixture-roundtrip.integration.test.ts`); emitted and PSL-authored contracts that carry what a Prisma 7 schema cannot, including value objects and lists of them, polymorphism, named types, domain enums, control policies, every index argument, checks named by prefix, primary key names, non-default codecs, row-level security with roles and policies, and the Supabase contract (`authored-contract-roundtrip.integration.test.ts`); every default function and temporal preset the stack registers (`generated-values-roundtrip.integration.test.ts`); TypeScript-authored contracts (`typescript-contract-roundtrip.integration.test.ts`); a pgvector column, with and without a literal default, with the extension in the stack (`extension-types-roundtrip.integration.test.ts`); and a unit test for each refusal the Postgres printer raises (`target-postgres/test/psl-print/`). Each round trip prints through the SQL family instance, the path the command uses, with one helper (`print-and-read-back.ts`). The printer takes a contract that passed validation and does not check its structure again: the command validates the loaded contract as `contract emit` does, and a broken structure in the printer is an internal error. Where PSL has no form for part of a valid contract, the printer refuses it by name with `CONTRACT.PRINT_UNSUPPORTED` and writes no file. Besides walking the models, the printer checks every part of the contract the models do not reach (tables, columns, value sets, namespaces, generated values, `meta` and `roots`) and refuses any part the PSL source would not derive again as it is. The full list of refusals is under that code in `docs/reference/error-reference.md`. A PSL file cannot carry the contract's default control policy; the command names it so the config can set it on the PSL source. + +The refusals that a reader or language change would lift: + +- One model name declared in two namespaces. The PSL reader groups relations by bare model name (`contract-psl/src/psl-relation-resolution.ts`, `fkRelationsByDeclaringModel`, `modelIdColumns`), so the two models would get each other's relations. Lifted by keying those on (namespace, model). +- A domain enum or value object outside the default namespace. The PSL reader refuses an `enum` block inside a `namespace` block, and reads every `type` block into the default namespace. Lifted by reader changes. +- A foreign key no relation travels, and a to-one relation with no foreign key behind it. The PSL reader derives every foreign key from a `@relation`, and every `@relation(fields:, references:)` lowers to one. Lifted by a relation argument that declines the constraint. +- A relation to a model in another contract space, such as a Supabase app's `supabase:auth.AuthUser`. PSL has the syntax and the reader reads it; the printer does not write it yet, because the printer hook is not given the composed extension contracts. +- A many-to-many relation whose junction table's model has no relation back to it. The PSL reader resolves a many-to-many list field through the junction model's relations. +- A one-to-many or one-to-one relation with no foreign key of its own, when the model it targets has no relation back that holds the foreign key. The TypeScript builder produces it from `rel.hasMany` or `rel.hasOne` with no `rel.belongsTo` on the other model. The PSL reader rejects a relation field that is not the other side of one with `fields:` and `references:`. +- A relation that names no fields to join on. The contract accepts it; the PSL reader reads every relation as a join between fields. +- A namespace whose name is not a PSL identifier, such as a Postgres schema named `2024archive`, and a namespace named `unbound` that is not the late-binding namespace, which PSL writes as `namespace unbound`. Lifted by a quoted form for namespace names. +- A column typed by a named type when the contract does not declare that type, or when the column's native type or codec is not the named type's. PSL writes the column as the name of the type, and the PSL reader copies both from the named type. +- A value-object field with type parameters or a value set, such as `pgvector.Vector(3)` in a `type` block. The PSL reader keeps only the codec of a value-object field's type (`contract-psl/src/interpreter.ts`, where it builds value objects). Lifted by a reader change. +- A column whose codec and native type no PSL type in the configured stack produces, such as a `bit` column, or a pgvector column with no length. Lifted by a type constructor, in the target or an extension, that produces it. +- A union or dictionary field, a column or native enum with its own control policy, and a model with an owner. None has PSL syntax. +- A generator no PSL default function of the Postgres adapter produces, and a generator on update other than the wall-clock-now generator. None of the Prisma 7 generators meets either. + +The other refusals guard against contracts the sources in this repo do not produce, such as a table with no model, a column no field is stored in, or top-level `meta` entries. The printer refuses them so that a hand-edited or future contract is never changed without a word. + +A `Json` object or array literal default was refused in the first version and now prints as a `json` tagged literal through the data types of ADR 254; the `defaults` fixture round-trips. With `dbgenerated` removed (#30380), every other function default prints as a `sql` tagged literal. Three list-column cases were refused in the first version and now print: a nullable list type (`Tag[]?`, printed since #30313), a database-side default on a list column (read since #30325), and type parameters on a list field (`Decimal @db.Numeric(65,30)[]`; the PSL reader now keeps them on the domain field, in `contract-psl/src/interpreter.ts`, `patchModelDomainFields`). Every list fixture round-trips. A policy expression holding a tab or another control character was refused and now prints: the printer writes the expression as a JSON string, as the PSL printer writes every block value, and the policy reader now decodes every JSON escape, `\t` and `\uXXXX` included. A literal default on a column whose codec an extension contributes, such as a pgvector column's `@default([1, 2, 3])`, was refused and now prints: the printer finds the column's data type through the stack's codecs and data types, as the PSL reader does. ### Found outside this project's scope Each exists on `main` unless the line says otherwise, so none is a regression this project caused. Each needs its own piece of work. -- `contract format` deletes a `//` comment written between a block's name and its `{`. In `@internal/psl-parser`'s formatter. +- The PSL source loses any name `__proto__`: a model, field, block or enum member so named. The parser and readers keep names as keys of plain objects, where assigning `__proto__` sets the prototype instead of adding a key (for example block members in `block-reconstruction.ts` in `@internal/psl-parser`). `contract print` refuses the name rather than write a file that reads back without it, both where PSL reads an identifier and where the printer would write a table or column name inside `@@map` or `@map`. The name is exported as `NAME_THE_PSL_SOURCE_LOSES` from `@internal/psl-parser`, and a parser test fails when the parser starts keeping it as a block member. Fixing it means changing every place the parser, the PSL readers and the TypeScript builder use a name as a key of a plain object. The contract layers below lose the name too: the Postgres contract serializer accepts a contract whose table is named `__proto__` and returns it without that table. In the PSL parser, the readers, the TypeScript builder and the contract serializer. +- The string form of the facade `defineConfig` passes no composed extension packs to the PSL source, so a PSL contract with a ParadeDB `bm25` index fails to emit with an unregistered index type. In `packages/3-extensions/postgres/src/config/define-config.ts`. - `contract infer` prints a PascalCase table as a model of the same name with no `@@map`, and Prisma 8 then maps that model to the lower-first table name, so `db verify` reports the table missing. Every Prisma 7 table is PascalCase, so this blocks adopting a Prisma 7 database through infer. In the Postgres target's infer code. -- `contract infer` prints a nullable list column as required; it never prints `Int[]?`, the form that emits and verifies. Every Prisma 7 list column is nullable. In the Postgres target's infer code. -- `db init` fails on a `dbgenerated` date or time default, because the CLI process has no global `Temporal`. In the CLI. +- `db init` failed on a `dbgenerated` date or time default, because the CLI process has no global `Temporal`. In the CLI. Recorded before #30380 replaced `dbgenerated` with `sql` tagged literals; not rechecked since. - `db init` fails on an enum list default. In the Postgres target's planner. -- A list default Postgres reports as `'{a,b}'::text[]` or `'{t,f}'::boolean[]` infers as `dbgenerated(...)`, and `contract emit` then stops at that field. In the Postgres default reader. +- A list default Postgres reports as `'{a,b}'::text[]` or `'{t,f}'::boolean[]` inferred as `dbgenerated(...)`, and `contract emit` then stopped at that field. In the Postgres default reader. Recorded before #30380; not rechecked since. - `interval`, `timetz`, `bytea`, and `jsonb` list defaults fail `db verify` or Postgres itself; for a `bytea` list, base64 text is stored as the bytes. True of single values on `main` too. - A timestamp default is compared through a JavaScript `Date`, which drops microseconds, so a one-microsecond difference is not reported; `BC` values and offsets carrying seconds are compared as text rather than as instants. In the SQL family's default comparison. - The string timestamp presets (`pg/timestamp-string@1`, `pg/timestamptz-string@1`) pair a text codec with `timestampNow`, which hands a JavaScript `Date` to `encode` and `encodeJson` instead of text. In the SQL family's authoring presets. -- A `Timestamp(3)[]` default cannot be created through the CLI at all: the CLI process has no global `Temporal`, so no spelling of a temporal list default reaches the database. +- A `Timestamp(3)[]` default cannot be created through the CLI at all: the CLI process has no global `Temporal`, so no form of a temporal list default reaches the database. - TypeScript authoring has no typed way to write a `BigInt` default beyond 2^53, and a quoted PSL `BigInt @default("9007199254740993")` is refused. In `contract-ts` and the PSL number default rule. - SQLite `Decimal` defaults still lose digits; SQLite `BigInt @default(0)` and `@default(-5)` fail `db init`; and a `BigInt` past the `int8` range fails at `db init` rather than at emit. In the SQLite target. - `docs/reference/error-reference.md`'s `CONFIG.VERSION_MARKER_MISSING` entry tells users to export the result of `defineConfig` from `@prisma/orm-postgres/config`, which is the shape that raises that very error. Belongs with the config loader's documentation. - When `db sign` fails verification, its next action tells the user to bring the database up to the contract with `db update`. During a side-by-side period that tells the user to let Prisma 8 change a database Prisma 7 owns. Belongs with the `db sign` command or the upgrade guide. - The CLI engine's terminal renderer prints a finding's code and summary but nothing of its `where`. It lives in the `prisma-cli` repository, which is why every Prisma 7 finding puts its location at the start of the summary. - `test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts` matches CLI failure output with a regular expression that can never match. It predates this project. -- Removing `dbgenerated(...)` from Prisma 8 is its own project. When it happens it must cover this source, which maps several Prisma 7 defaults onto it. ## Product findings for hand-off Found by the adoption example (slice 4). Each is outside this project's scope and needs an owner. -- **Raw SQL in the contract, state of play (researched 2026-09-14).** Prisma 8 carries opaque target SQL in three content-addressed places under ADR 234/244 (`@@index` expression and predicate, `@@check`, Postgres RLS predicates); column defaults are the only raw-SQL site compared by normalised text; TS authoring has `.defaultSql(expression)` producing the same arm as `dbgenerated`; ADR 129 (template-tagged literals, `pg.sql\`...\``) is the accepted design for opaque textual payloads in PSL and was never implemented (no backtick token in the tokenizer, no tagged-literal node anywhere); the three existing raw-SQL attribute arguments were built as plain strings instead of ADR 129 literals; generated columns do not exist at all. Whether to remove raw-expression defaults everywhere or design one under ADR 244 is an open decision. -- **`dbgenerated("...")` must be removed from Prisma 8.** It was ADR 167's temporary escape hatch and was never meant to ship; the Postgres and SQLite registries accept it, infer emits it, the Supabase contract carries 21 uses, and the Prisma 7 source maps onto it. Proposed replacement: named storage functions and typed literal defaults; arbitrary expressions become a reported gap. +- **Raw SQL in the contract, state of play (researched 2026-09-14; since then #30325 implemented ADR 129 tagged literals for column defaults and #30380 removed `dbgenerated`).** Prisma 8 carries opaque target SQL in three content-addressed places under ADR 234/244 (`@@index` expression and predicate, `@@check`, Postgres RLS predicates); column defaults are the only raw-SQL site compared by normalised text; TS authoring has `.defaultSql(expression)` producing the same arm as `dbgenerated`; ADR 129 (template-tagged literals, `pg.sql\`...\``) is the accepted design for opaque textual payloads in PSL and was never implemented (no backtick token in the tokenizer, no tagged-literal node anywhere); the three existing raw-SQL attribute arguments were built as plain strings instead of ADR 129 literals; generated columns do not exist at all. Whether to remove raw-expression defaults everywhere or design one under ADR 244 is an open decision. - **Infer and verify should ignore `_prisma_migrations`.** The public guide has users delete the inferred `PrismaMigrations` model by hand, and strict verify flags the ledger as foreign. Proposed fix: an ignore list supplied by the Postgres facade and passed into both evaluators. - **Wrong CLI through peer resolution.** `@prisma/client@7.10.0` declares a peer dependency on `prisma`; with pnpm auto-installing peers and no explicit Prisma 8 `prisma` dev dependency, `prisma` resolves to Prisma 7 and `prisma contract emit` runs the wrong CLI. The guide should tell users to keep an explicit Prisma 8 `prisma` dev dependency; the example README does. @@ -155,4 +186,4 @@ Found by the adoption example (slice 4). Each is outside this project's scope an - The public upgrade guides: [PostgreSQL, 7 to 8](https://www.prisma.io/docs/guides/upgrade-prisma-orm/postgresql) and [MongoDB, 6 to 8](https://www.prisma.io/docs/guides/upgrade-prisma-orm/mongodb). The Postgres guide's phase 2 (`contract infer` plus hand edits) is what the Prisma 7 source replaces; its phase 4 is the cutover routine slice 3 must fit. - `design-notes.md` for alternatives considered. -- `slices/01-postgres-source/spec.md`, `slices/03-contract-to-psl-and-convert/spec.md`, and for the Mongo source `projects/mongo-defaults-codecs-prisma6-source/slices/05-prisma6-mongo-source/spec.md`. +- `slices/01-postgres-source/spec.md`, `slices/03-contract-to-psl-and-print/spec.md`, and for the Mongo source `projects/mongo-defaults-codecs-prisma6-source/slices/05-prisma6-mongo-source/spec.md`. diff --git a/scripts/check-upgrade-coverage.mjs b/scripts/check-upgrade-coverage.mjs index 7a89d31b7b29..6ac4b145dcaa 100644 --- a/scripts/check-upgrade-coverage.mjs +++ b/scripts/check-upgrade-coverage.mjs @@ -49,18 +49,26 @@ export function transitionLabel(prev, head) { const PENDING = 'upgrade-instructions/pending/'; const PENDING_INSTRUCTIONS = /^upgrade-instructions\/pending\/[^/]+\/(app|extension)\/instructions\.md$/; -const PUBLISHED_DIRECTORY = /^(skills\/prisma-8\/upgrading\/(?:app|extension)\/upgrades\/[^/]+)\//; +const PUBLISHED_ROOT = 'skills/prisma-8/upgrading/'; +const PUBLISHED_DIRECTORY = new RegExp(`^(${PUBLISHED_ROOT}(?:app|extension)/upgrades/[^/]+)/`); +function publishedGuide(audience, transition) { + return `${PUBLISHED_ROOT}${audience}/upgrades/${transition}/instructions.md`; +} const COVERED_DIRECTORIES = [ { audience: 'app', directory: 'examples/' }, { audience: 'extension', directory: 'packages/3-extensions/' }, ]; -function git(repoRoot, ...args) { +function gitWith(options, repoRoot, ...args) { return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + ...options, }); } +function git(repoRoot, ...args) { + return gitWith({}, repoRoot, ...args); +} function tryGit(repoRoot, ...args) { try { return git(repoRoot, ...args).trim(); @@ -75,9 +83,10 @@ function readFile(repoRoot, ref, path) { return null; } } +/** The files under the pending and published instruction directories, the only ones the check reads. */ function tree(repoRoot, ref) { return new Map( - git(repoRoot, 'ls-tree', '-r', '-z', ref) + git(repoRoot, 'ls-tree', '-r', '-z', ref, '--', PENDING, PUBLISHED_ROOT) .split('\0') .filter(Boolean) .map((entry) => { @@ -86,8 +95,24 @@ function tree(repoRoot, ref) { }), ); } +/** + * The changed files under the covered and published directories, the only ones the check reads. + * The listing grows with the change, so it is read without the default 1 MiB limit on output. + */ function changedPaths(repoRoot, prev, head) { - return git(repoRoot, 'diff', '--no-renames', '--name-only', '-z', prev, head, '--') + const directories = [...COVERED_DIRECTORIES.map(({ directory }) => directory), PUBLISHED_ROOT]; + return gitWith( + { maxBuffer: Number.POSITIVE_INFINITY }, + repoRoot, + 'diff', + '--no-renames', + '--name-only', + '-z', + prev, + head, + '--', + ...directories, + ) .split('\0') .filter(Boolean); } @@ -282,7 +307,7 @@ export function runCheck({ repoRoot, head = 'HEAD', prev, mode = 'pr' }) { reason: 'assemble and archive all pending files before release', }); for (const { audience } of COVERED_DIRECTORIES) - validate.add(`skills/prisma-8/upgrading/${audience}/upgrades/${transition}/instructions.md`); + validate.add(publishedGuide(audience, transition)); } else if (mode === 'pr') { for (const { audience, directory } of COVERED_DIRECTORIES) { const relevant = changed.filter( diff --git a/scripts/check-upgrade-coverage.test.mjs b/scripts/check-upgrade-coverage.test.mjs index 37b3bbad90d3..bbfc050cf86c 100644 --- a/scripts/check-upgrade-coverage.test.mjs +++ b/scripts/check-upgrade-coverage.test.mjs @@ -99,6 +99,54 @@ describe('version and format helpers', () => { }); }); +function commitEmptyFiles(count, pathOf) { + const emptyBlob = execFileSync('git', ['hash-object', '-w', '--stdin'], { + cwd: repo, + input: '', + encoding: 'utf8', + }).trim(); + const entries = Array.from( + { length: count }, + (_, index) => `100644 ${emptyBlob}\t${pathOf(index)}`, + ); + execFileSync('git', ['update-index', '--index-info'], { + cwd: repo, + input: `${entries.join('\n')}\n`, + }); + git('commit', '-qm', 'bulk'); +} +function listingBytes(...args) { + return execFileSync('git', args, { cwd: repo, maxBuffer: 64 * 1024 * 1024 }).length; +} + +describe('repository size', () => { + it('checks a change whose files outside the directories it reads list to more than 1 MiB', () => { + const base = commit(); + commitEmptyFiles(16_000, (index) => `bulk/${'x'.repeat(60)}-${index}.txt`); + assert.ok(listingBytes('ls-tree', '-r', '-z', 'HEAD') > 1024 * 1024); + assert.ok(listingBytes('diff', '--name-only', '-z', base, 'HEAD') > 1024 * 1024); + + passes(base); + }); + it('checks a change whose files inside a directory it reads list to more than 1 MiB', () => { + const base = commit(); + commitEmptyFiles(16_000, (index) => `examples/demo/test/${'x'.repeat(60)}-${index}.ts`); + assert.ok( + listingBytes('diff', '--name-only', '-z', base, 'HEAD', '--', 'examples/') > 1024 * 1024, + ); + + passes(base); + }); + it('still requires a declaration when the change outside the directories it reads is large', () => { + const base = commit(); + write('examples/demo.ts', 'changed'); + git('add', '-A'); + commitEmptyFiles(16_000, (index) => `bulk/${'x'.repeat(60)}-${index}.txt`); + + fails(base, /per-pr-declaration/); + }); +}); + describe('independent PR declarations', () => { for (const [audience, changedFile] of [ ['app', 'examples/demo.ts'], diff --git a/scripts/lint-framework-vocabulary.test.mjs b/scripts/lint-framework-vocabulary.test.mjs index e3cc91d980c2..19c16eec6798 100644 --- a/scripts/lint-framework-vocabulary.test.mjs +++ b/scripts/lint-framework-vocabulary.test.mjs @@ -52,6 +52,10 @@ const FILE_LANGUAGE_SERVER_SYMBOL_TABLE_WITH_STORAGE_TABLE = `${FILE_LANGUAGE_SE const FILE_FORMATTER_ALIGNMENT_COLUMNS = 'export function alignmentColumns(rows) { const typeColumn = 1; return { typeColumn, attributeColumn: 2 }; }\n'; const FILE_FORMATTER_ALIGNMENT_WITH_STORAGE_COLUMN = `${FILE_FORMATTER_ALIGNMENT_COLUMNS}export const storageColumn = 1;\n`; +const FILE_EARLIER_PRODUCT_VERSIONS = + "export const prisma7Schema = 1;\nexport const label = 'Prisma 6';\nexport const pinned = 'prisma@5.22.0';\nexport const PRISMA_1 = 1;\n"; +const FILE_CURRENT_PRODUCT_VERSION = + "export const label = 'Prisma 8';\nexport const prisma8Schema = 1;\nexport const pkg = '@prisma/client';\n"; const FILE_FORBIDDEN_STORAGE_VOCABULARY = 'export interface StorageShape { readonly tableName: string; readonly columnName: string; readonly nativeType: string; }\n'; @@ -448,3 +452,33 @@ describe('lint-framework-vocabulary — exclusions', () => { assert.match(result.stdout, /count=0 threshold=0/); }); }); + +describe('lint-framework-vocabulary — product versions', () => { + it('counts names of Prisma versions before 8 in framework packages, including the PSL parser', () => { + writeConfig(8); + writeRepoFile(`${SCOPE}/1-core/config/src/versions.ts`, FILE_EARLIER_PRODUCT_VERSIONS); + writeRepoFile(`${SCOPE}/2-authoring/psl-parser/src/versions.ts`, FILE_EARLIER_PRODUCT_VERSIONS); + + const result = runScript(); + assert.equal(result.status, 0, `expected exit 0; stderr=${result.stderr}`); + assert.match(result.stdout, /count=8 threshold=8/); + }); + + it('does not count names of Prisma versions in the CLI, which talks to users about their projects', () => { + writeConfig(0); + writeRepoFile(`${SCOPE}/3-tooling/cli/src/versions.ts`, FILE_EARLIER_PRODUCT_VERSIONS); + + const result = runScript(); + assert.equal(result.status, 0, `expected exit 0; stderr=${result.stderr}`); + assert.match(result.stdout, /count=0 threshold=0/); + }); + + it('does not count Prisma 8 or the prisma package scope', () => { + writeConfig(0); + writeRepoFile(`${SCOPE}/3-tooling/cli/src/versions.ts`, FILE_CURRENT_PRODUCT_VERSION); + + const result = runScript(); + assert.equal(result.status, 0, `expected exit 0; stderr=${result.stderr}`); + assert.match(result.stdout, /count=0 threshold=0/); + }); +}); diff --git a/test/integration/package.json b/test/integration/package.json index 3f0d6ea83707..a83c1f66b5fe 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -34,6 +34,7 @@ "@internal/extension-arktype-json": "workspace:8.0.0-rc.12", "@internal/extension-paradedb": "workspace:8.0.0-rc.12", "@internal/extension-pgvector": "workspace:8.0.0-rc.12", + "@internal/extension-postgis": "workspace:8.0.0-rc.12", "@internal/extension-supabase": "workspace:8.0.0-rc.12", "@internal/family-mongo": "workspace:8.0.0-rc.12", "@internal/family-sql": "workspace:8.0.0-rc.12", diff --git a/test/integration/test/cli-journeys/contract-print.e2e.test.ts b/test/integration/test/cli-journeys/contract-print.e2e.test.ts new file mode 100644 index 000000000000..8998781b0a91 --- /dev/null +++ b/test/integration/test/cli-journeys/contract-print.e2e.test.ts @@ -0,0 +1,313 @@ +/** + * The user-facing journey for `prisma contract print`: a project whose + * `prisma.config.ts` points at `prisma7Schema('./schema.prisma')` runs + * `contract print`, switches its config to the Prisma 8 PSL the command wrote, + * and emits the same contract, which `db sign` and `db verify` then accept + * against the database the Prisma 7 SQL built. It runs over the `relations` and + * `supported-verify` fixtures, whose database is the SQL Prisma 7.10.0 + * generated for the full `supported` schema. The command is not tied to + * Prisma 7: a PSL source prints too. Two things are refused with exit 2 and no + * file written: a Prisma 7 schema Prisma 8 cannot read, and an output path + * that is the schema being read. A contract with a default control policy + * prints with a warning, and the config the READMEs show for the printed file + * emits it with the same policy. + */ +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { withClient } from '@repo/test-utils'; +import { join } from 'pathe'; +import stripAnsi from 'strip-ansi'; +import { describe, expect, it } from 'vitest'; +import { withTempDir, writeProjectManifest } from '../utils/cli-test-helpers'; +import { + type EngineCommandResult, + type JourneyContext, + runContractEmit, + runContractPrint, + runDbSign, + runDbVerify, + timeouts, + useDevDatabase, +} from '../utils/journey-test-helpers'; + +const PRISMA7_FIXTURES = join(__dirname, '../fixtures/prisma7-source'); +const JOURNEY_FIXTURES = join(__dirname, '../fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys'); +const REPO_ROOT = join(__dirname, '../../../..'); +const READMES_WITH_POLICY_CONFIG = [ + join(REPO_ROOT, 'packages/1-framework/3-tooling/cli/README.md'), + join(REPO_ROOT, 'packages/3-extensions/postgres/README.md'), +]; + +/** The published import paths the READMEs use, and the workspace packages that back them. */ +const PUBLISHED_TO_WORKSPACE: ReadonlyArray = [ + ["'prisma/config'", "'@prisma/cli-engine'"], + ["'@prisma/orm-family-sql/contract-psl/provider'", "'@internal/sql-contract-psl/provider'"], + ["'@prisma/orm-postgres/config'", "'@internal/postgres/config'"], + ["'@prisma/orm-postgres/target/", "'@internal/target-postgres/"], +]; + +/** The \`prisma.config.ts\` block in a README that sets \`defaultControlPolicy\`. */ +function policyConfigIn(readmePath: string): string { + const blocks = [...readFileSync(readmePath, 'utf-8').matchAll(/```typescript\n([\s\S]*?)```/g)]; + const block = blocks + .map((match) => match[1] ?? '') + .find((text) => text.includes('defaultControlPolicy')); + if (block === undefined) { + throw new Error(`${readmePath} shows no config that sets defaultControlPolicy`); + } + return block; +} + +function withWorkspaceImports(config: string): string { + return PUBLISHED_TO_WORKSPACE.reduce( + (text, [published, workspace]) => text.replaceAll(published, workspace), + config, + ); +} + +function emittedContractJson(testDir: string): Record { + return JSON.parse(readFileSync(join(testDir, 'prisma', 'contract.json'), 'utf-8')); +} + +const PRISMA7_DDL = readFileSync(join(PRISMA7_FIXTURES, 'supported/migration.sql'), 'utf-8'); + +const VIEW_SCHEMA = `datasource db { + provider = "postgresql" +} + +model User { + id Int @id +} + +view ActiveUsers { + id Int +} +`; + +const NO_DATABASE = 'postgres://user:password@localhost:5432/unused'; + +function writeConfig(testDir: string, fixture: string, connectionString: string): string { + const config = readFileSync(join(JOURNEY_FIXTURES, fixture), 'utf-8').replace( + /\{\{DB_URL\}\}/g, + () => connectionString, + ); + const configPath = join(testDir, fixture); + writeFileSync(configPath, config, 'utf-8'); + return configPath; +} + +function setupPrisma7Project( + createTempDir: () => string, + connectionString: string, + schema: { readonly copyFrom: string } | { readonly text: string }, +): JourneyContext { + const testDir = createTempDir(); + writeProjectManifest(testDir); + mkdirSync(join(testDir, 'migrations'), { recursive: true }); + if ('copyFrom' in schema) { + copyFileSync(schema.copyFrom, join(testDir, 'schema.prisma')); + } else { + writeFileSync(join(testDir, 'schema.prisma'), schema.text, 'utf-8'); + } + return { + testDir, + configPath: writeConfig(testDir, 'prisma.config.prisma7.ts', connectionString), + outputDir: testDir, + }; +} + +/** The same project, read through the Prisma 8 PSL `contract print` wrote. */ +function onPrintedContract(ctx: JourneyContext, connectionString: string): JourneyContext { + return { + ...ctx, + configPath: writeConfig(ctx.testDir, 'prisma.config.printed-contract.ts', connectionString), + }; +} + +function output(run: { readonly stdout: string; readonly stderr: string }): string { + return `${stripAnsi(run.stderr)}\n${stripAnsi(run.stdout)}`; +} + +function storageHashOf(run: EngineCommandResult): string { + const data = run.presented?.data; + if (typeof data !== 'object' || data === null || !('storageHash' in data)) { + throw new Error('contract emit reported no storage hash'); + } + const { storageHash } = data; + if (typeof storageHash !== 'string') { + throw new Error('contract emit reported a storage hash that is not a string'); + } + return storageHash; +} + +function errorOf(run: EngineCommandResult): { readonly code: string; readonly summary: string } { + const terminal = run.json.at(-1); + if (terminal === undefined || terminal.kind !== 'result' || terminal.envelope.ok) { + throw new Error('the run did not settle as an error'); + } + const { code, summary } = terminal.envelope.error; + return { code, summary }; +} + +/** + * Prints, switches the config to the written file, emits, signs, and verifies. + * The two journeys differ only in the fixture they run over. + */ +async function printAndVerify(ctx: JourneyContext, connectionString: string): Promise { + const prisma7Emit = await runContractEmit(ctx, ['--json']); + expect(prisma7Emit.exitCode, `contract emit on the Prisma 7 source\n${output(prisma7Emit)}`).toBe( + 0, + ); + + const print = await runContractPrint(ctx, ['--output', 'contract.prisma', '--json']); + expect(print.exitCode, `contract print\n${output(print)}`).toBe(0); + expect(print.presented?.data).toMatchObject({ + ok: true, + psl: { path: 'contract.prisma' }, + source: ['schema.prisma'], + }); + + const written = readFileSync(join(ctx.testDir, 'contract.prisma'), 'utf-8'); + expect(written.split('\n\n')[0]).toBe( + '// use prisma-8\n// Printed from schema.prisma by `prisma contract print`.', + ); + + const printed = onPrintedContract(ctx, connectionString); + const pslEmit = await runContractEmit(printed, ['--json']); + expect(pslEmit.exitCode, `contract emit on the printed contract\n${output(pslEmit)}`).toBe(0); + expect(storageHashOf(pslEmit)).toBe(storageHashOf(prisma7Emit)); + + const sign = await runDbSign(printed, ['--json']); + expect(sign.exitCode, `db sign\n${output(sign)}`).toBe(0); + + const verify = await runDbVerify(printed, ['--json']); + expect(verify.exitCode, `db verify\n${output(verify)}`).toBe(0); + expect(verify.presented?.data).toMatchObject({ + ok: true, + mode: 'full', + schema: { strict: false, warnings: [] }, + }); +} + +withTempDir(({ createTempDir }) => { + describe('Journey: printing a Prisma 7 schema and emitting it as Prisma 8', () => { + const db = useDevDatabase({ + onReady: (cs) => withClient(cs, (client) => client.query(PRISMA7_DDL)), + }); + + it.each(['relations', 'supported-verify'])( + 'prints the %s fixture and verifies against the database Prisma 7 built', + async (fixture) => { + await printAndVerify( + setupPrisma7Project(createTempDir, db.connectionString, { + copyFrom: join(PRISMA7_FIXTURES, `${fixture}/schema.prisma`), + }), + db.connectionString, + ); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey: printing a contract with a default control policy', () => { + it('warns about the policy, and the README config emits the printed file with it', async () => { + const [cliReadme, ...otherReadmes] = READMES_WITH_POLICY_CONFIG.map(policyConfigIn); + for (const readme of otherReadmes) { + expect(readme).toBe(cliReadme); + } + const testDir = createTempDir(); + writeProjectManifest(testDir); + mkdirSync(join(testDir, 'prisma'), { recursive: true }); + copyFileSync( + join(JOURNEY_FIXTURES, 'contract-default-policy.ts'), + join(testDir, 'prisma', 'contract.ts'), + ); + const typeScriptConfig = writeConfig(testDir, 'prisma.config.default-policy.ts', NO_DATABASE); + writeFileSync( + join(testDir, 'prisma.config.policy-psl.ts'), + withWorkspaceImports(cliReadme ?? ''), + 'utf-8', + ); + const onTypeScript: JourneyContext = { + testDir, + configPath: typeScriptConfig, + outputDir: testDir, + }; + const onPrinted: JourneyContext = { + ...onTypeScript, + configPath: join(testDir, 'prisma.config.policy-psl.ts'), + }; + + const typeScriptEmit = await runContractEmit(onTypeScript, ['--json']); + expect(typeScriptEmit.exitCode, output(typeScriptEmit)).toBe(0); + expect(emittedContractJson(testDir)).toMatchObject({ defaultControlPolicy: 'external' }); + + const print = await runContractPrint(onTypeScript, [ + '--output', + 'prisma/contract.prisma', + '--json', + ]); + expect(print.exitCode, output(print)).toBe(0); + expect(print.presented?.data).toMatchObject({ + psl: { path: 'prisma/contract.prisma' }, + sourceSettings: { defaultControlPolicy: 'external' }, + }); + expect(print.events).toContainEqual( + expect.objectContaining({ + kind: 'message', + severity: 'warn', + text: expect.stringContaining("Set defaultControlPolicy: 'external'"), + }), + ); + + const printedEmit = await runContractEmit(onPrinted, ['--json']); + expect(printedEmit.exitCode, output(printedEmit)).toBe(0); + expect(storageHashOf(printedEmit)).toBe(storageHashOf(typeScriptEmit)); + expect(emittedContractJson(testDir)).toMatchObject({ defaultControlPolicy: 'external' }); + }); + }); + + describe('Journey: contract print on other sources, and what it refuses', () => { + it('prints a PSL source, so the command is not tied to Prisma 7', async () => { + const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { + copyFrom: join(PRISMA7_FIXTURES, 'implicit-many-to-many-names/schema.prisma'), + }); + writeFileSync( + join(ctx.testDir, 'contract.prisma'), + '// use prisma-8\nmodel User {\n id Int @id\n}\n', + ); + const onPsl = onPrintedContract(ctx, NO_DATABASE); + + const print = await runContractPrint(onPsl, ['--output', 'printed.prisma', '--json']); + + expect(print.exitCode, output(print)).toBe(0); + expect(readFileSync(join(ctx.testDir, 'printed.prisma'), 'utf-8')).toBe( + '// use prisma-8\n// Printed from contract.prisma by `prisma contract print`.\n\nnamespace public {\n model User {\n id Int @id\n }\n}\n', + ); + }); + + it('reports what the Prisma 7 source reports about a view and writes nothing', async () => { + const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { text: VIEW_SCHEMA }); + + const print = await runContractPrint(ctx, ['--output', 'contract.prisma', '--json']); + + expect(print.exitCode, output(print)).toBe(2); + expect(errorOf(print).code).toBe('CONTRACT.SOURCE_LOAD_FAILED'); + expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); + }); + + it('refuses to write over the schema it reads and leaves that file unchanged', async () => { + const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { + copyFrom: join(PRISMA7_FIXTURES, 'relations/schema.prisma'), + }); + const schemaPath = join(ctx.testDir, 'schema.prisma'); + const before = readFileSync(schemaPath, 'utf-8'); + + const print = await runContractPrint(ctx, ['--output', 'schema.prisma', '--json']); + + expect(print.exitCode, output(print)).toBe(2); + expect(errorOf(print).code).toBe('CONTRACT.PRINT_OUTPUT_IS_SOURCE'); + expect(readFileSync(schemaPath, 'utf-8')).toBe(before); + expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); + }); + }); +}); diff --git a/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts b/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts index 3bda3b040f32..c9cc162b77e3 100644 --- a/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts +++ b/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts @@ -159,6 +159,7 @@ export default definePrismaConfig({ extensions: [pgvector], contract: { source: { + format: 'typescript', load: async () => ({ ok: true as const, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-default-policy.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-default-policy.ts new file mode 100644 index 000000000000..fcfaf9fad674 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-default-policy.ts @@ -0,0 +1,13 @@ +import { int4Column } from '@internal/adapter-postgres/column-types'; +import { defineContract, field, model } from '@internal/postgres/contract-builder'; + +export const contract = defineContract({ + defaultControlPolicy: 'external', + models: { + User: model('User', { + fields: { + id: field.column(int4Column).id(), + }, + }).sql({ table: 'user' }), + }, +}); diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.default-policy.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.default-policy.ts new file mode 100644 index 000000000000..41101143ab0a --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.default-policy.ts @@ -0,0 +1,8 @@ +// A TypeScript contract with a default control policy, read from +// `prisma/contract.ts`, where the journey copies `contract-default-policy.ts`. +import { defineConfig as postgres } from '@internal/postgres/config'; +import { definePrismaConfig } from '@prisma/cli-engine'; + +export default definePrismaConfig({ + orm: postgres({ contract: './prisma/contract.ts' }), +}); diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.printed-contract.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.printed-contract.ts new file mode 100644 index 000000000000..11e3bc2fef65 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.printed-contract.ts @@ -0,0 +1,16 @@ +// The config a project switches to after `prisma contract print`: the same +// database, now read from the Prisma 8 PSL the command wrote. +import { defineConfig as postgres } from '@internal/postgres/config'; +import { definePrismaConfig } from '@prisma/cli-engine'; + +export default definePrismaConfig({ + orm: postgres({ + contract: './contract.prisma', + db: { + connection: '{{DB_URL}}', + }, + migrations: { + dir: 'migrations', + }, + }), +}); diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.ts index 4b472da150d0..e526adeb6297 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.ts @@ -13,6 +13,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true as const, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.with-db.ts index a9c75c7b25ba..25f4a06a29f2 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.with-db.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true as const, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/mongo/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/mongo/prisma.config.with-db.ts index 806500fc61e2..e1d40e2f4e7d 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/mongo/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/mongo/prisma.config.with-db.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true as const, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/postgres-external-floor/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/postgres-external-floor/prisma.config.with-db.ts index e633e3225ae8..7964329103b2 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/postgres-external-floor/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/postgres-external-floor/prisma.config.with-db.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'src/prisma/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/postgres/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/postgres/prisma.config.with-db.ts index e633e3225ae8..7964329103b2 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/postgres/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/control-policy/postgres/prisma.config.with-db.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'src/prisma/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-init-with-contract-space/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-init-with-contract-space/prisma.config.with-db.ts index 79232ac307be..299cddff4087 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-init-with-contract-space/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-init-with-contract-space/prisma.config.with-db.ts @@ -20,6 +20,7 @@ export default definePrismaConfig({ extensions: [testContractSpaceExtension], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'src/prisma/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-init/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-init/prisma.config.with-db.ts index ca8c137243df..4c42785c7258 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-init/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-init/prisma.config.with-db.ts @@ -17,6 +17,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'src/prisma/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.no-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.no-db.ts index 89bbe6480353..1ed5e48459c2 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.no-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.no-db.ts @@ -16,6 +16,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.no-driver.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.no-driver.ts index b5728052e058..9459c8f922bd 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.no-driver.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.no-driver.ts @@ -13,6 +13,7 @@ export default { extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.with-db.ts index 8784867d81b7..cf3e55aaf033 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-introspect/prisma.config.with-db.ts @@ -17,6 +17,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-sign/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-sign/prisma.config.with-db.ts index ca8c137243df..4c42785c7258 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-sign/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-sign/prisma.config.with-db.ts @@ -17,6 +17,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'src/prisma/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-preflight-gaps/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-preflight-gaps/prisma.config.with-db.ts index e633e3225ae8..7964329103b2 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-preflight-gaps/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-preflight-gaps/prisma.config.with-db.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'src/prisma/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/prisma.config.with-db.ts index ca8c137243df..4c42785c7258 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-update-scenarios/prisma.config.with-db.ts @@ -17,6 +17,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'src/prisma/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.no-driver.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.no-driver.ts index 84800ede5933..770e3220eadf 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.no-driver.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.no-driver.ts @@ -16,6 +16,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.no-verify.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.no-verify.ts index 56ef566b9f47..209e1d1661d0 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.no-verify.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.no-verify.ts @@ -27,6 +27,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.ts index 53e86edfc0e0..059dba29495b 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.with-db.ts index 8784867d81b7..cf3e55aaf033 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/db-verify/prisma.config.with-db.ts @@ -17,6 +17,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/emit/prisma.config.emit.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/emit/prisma.config.emit.ts index 53e86edfc0e0..059dba29495b 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/emit/prisma.config.emit.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/emit/prisma.config.emit.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migrate-external-space/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migrate-external-space/prisma.config.with-db.ts index dc66bf9eedf9..feb354b8d15a 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migrate-external-space/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migrate-external-space/prisma.config.with-db.ts @@ -19,6 +19,7 @@ export default definePrismaConfig({ extensions: [testExternalSpaceExtension], contract: { source: { + format: 'typescript', load: async () => ({ ok: true as const, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migration-apply/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migration-apply/prisma.config.with-db.ts index a9c75c7b25ba..25f4a06a29f2 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migration-apply/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migration-apply/prisma.config.with-db.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true as const, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migration-plan/prisma.config.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migration-plan/prisma.config.ts index 4b472da150d0..e526adeb6297 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migration-plan/prisma.config.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/migration-plan/prisma.config.ts @@ -13,6 +13,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true as const, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/prisma.config.with-db.ts index 6d7fb3f6f28a..e78bf435739d 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/prisma.config.with-db.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true as const, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-db-commands/prisma.config.with-db.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-db-commands/prisma.config.with-db.ts index d2cb9cf4db92..9a15eef87666 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-db-commands/prisma.config.with-db.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-db-commands/prisma.config.with-db.ts @@ -14,6 +14,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: {} }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/vite-plugin/prisma.config.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/vite-plugin/prisma.config.ts index 53e86edfc0e0..059dba29495b 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/vite-plugin/prisma.config.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/vite-plugin/prisma.config.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/prisma.config.async-source.ts b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/prisma.config.async-source.ts index 0c9385481778..cd0e812e9b32 100644 --- a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/prisma.config.async-source.ts +++ b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/prisma.config.async-source.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [], contract: { source: { + format: 'typescript', inputs: ['./contract.ts'], load: async () => { const { contract } = await import('./contract'); diff --git a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/prisma.config.mongo-contract-ts.ts b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/prisma.config.mongo-contract-ts.ts index b9d6c1328934..9133486a9f60 100644 --- a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/prisma.config.mongo-contract-ts.ts +++ b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/prisma.config.mongo-contract-ts.ts @@ -14,6 +14,7 @@ export default definePrismaConfig({ adapter: mongoAdapter, contract: { source: { + format: 'typescript', load: async () => ok(contract as Contract), }, output: 'output/contract.json', diff --git a/test/integration/test/fixtures/prisma.config.ts b/test/integration/test/fixtures/prisma.config.ts index 9c56b37fe9b8..0e5c15255e42 100644 --- a/test/integration/test/fixtures/prisma.config.ts +++ b/test/integration/test/fixtures/prisma.config.ts @@ -12,6 +12,7 @@ export default definePrismaConfig({ adapter: postgresAdapter, contract: { source: { + format: 'typescript', load: async () => ({ ok: true, value: contract }), }, output: 'contract.json', diff --git a/test/integration/test/mongo/fixtures/prisma.config.ts b/test/integration/test/mongo/fixtures/prisma.config.ts index b9af1fb37db8..aae02b3bb399 100644 --- a/test/integration/test/mongo/fixtures/prisma.config.ts +++ b/test/integration/test/mongo/fixtures/prisma.config.ts @@ -14,6 +14,7 @@ export default definePrismaConfig({ adapter: mongoAdapter, contract: { source: { + format: 'typescript', load: async () => ok(contract as Contract), }, output: 'generated/contract.json', diff --git a/test/integration/test/namespaced-accessors/fixtures/prisma.config.ts b/test/integration/test/namespaced-accessors/fixtures/prisma.config.ts index f0ab3eb5b20d..8a6d2f0fb1ac 100644 --- a/test/integration/test/namespaced-accessors/fixtures/prisma.config.ts +++ b/test/integration/test/namespaced-accessors/fixtures/prisma.config.ts @@ -13,6 +13,7 @@ export default definePrismaConfig({ adapter: postgresAdapter, contract: { source: { + format: 'typescript', load: async () => ok(contract), }, output: 'generated/contract.json', diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/enum/generated/contract.d.ts b/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/enum/generated/contract.d.ts index 54615423e0c9..c4a1f6d89c80 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/enum/generated/contract.d.ts +++ b/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/enum/generated/contract.d.ts @@ -377,7 +377,11 @@ type ContractBase = Omit< }; readonly enum2: { readonly nullable: false; - readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/enum@1' }; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'pg/enum@1'; + readonly typeParams: { readonly typeName: 'TestEnum' }; + }; readonly many: true; }; readonly id: { diff --git a/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/enum/generated/contract.json b/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/enum/generated/contract.json index b8ebd2e0b9dc..edef5127be77 100644 --- a/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/enum/generated/contract.json +++ b/test/integration/test/ports/engines/queries/filters/field_reference/_fixture/enum/generated/contract.json @@ -30,7 +30,10 @@ "nullable": false, "type": { "codecId": "pg/enum@1", - "kind": "scalar" + "kind": "scalar", + "typeParams": { + "typeName": "TestEnum" + } } }, "id": { diff --git a/test/integration/test/psl-print/authored-contract-roundtrip.integration.test.ts b/test/integration/test/psl-print/authored-contract-roundtrip.integration.test.ts new file mode 100644 index 000000000000..42291805f54c --- /dev/null +++ b/test/integration/test/psl-print/authored-contract-roundtrip.integration.test.ts @@ -0,0 +1,352 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import type { Contract } from '@internal/contract/types'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { PostgresContractSerializer } from '@internal/target-postgres/runtime'; +import { timeouts } from '@repo/test-utils'; +import { dirname, join } from 'pathe'; +import { describe, expect, it } from 'vitest'; +import { + printAndReadBack, + printContract, + readPsl, + serializedWithoutCapabilities, +} from './print-and-read-back'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../..'); + +/** + * Emitted contract fixtures, one per feature a Prisma 7 schema cannot carry. + * Each is printed as PSL, read back, and must come back as the same contract. + */ +const cases: ReadonlyArray<{ readonly name: string; readonly contractJson: string }> = [ + { + name: 'value objects', + contractJson: 'test/integration/test/value-objects/fixtures/generated/sql-contract.json', + }, + { + name: 'polymorphism: discriminator, base, variants and relations', + contractJson: + 'test/integration/test/sql-orm-client/fixtures/polymorphism/generated/contract.json', + }, + { + name: 'the Supabase contract: row-level security, policies and roles across schemas, and a default control policy the config sets', + contractJson: 'packages/3-extensions/supabase/src/contract/contract.json', + }, + { + name: 'named types and typeRef columns', + contractJson: + 'test/integration/test/ports/prisma/functional/decimal-list/_fixture/generated/contract.json', + }, +]; + +function loadContract(relativePath: string): Contract { + const json: unknown = JSON.parse(readFileSync(join(repoRoot, relativePath), 'utf-8')); + return new PostgresContractSerializer().deserializeContract(json); +} + +/** + * Contracts authored in PSL, one per feature the emitted fixtures above do not + * carry. Each is read, printed, read back, and compared. + */ +const pslCases: ReadonlyArray<{ readonly name: string; readonly schema: string }> = [ + { + name: 'a model control policy', + schema: `model Ledger { + id Int @id + + @@control(external) +} +`, + }, + { + name: 'an index with a predicate, a type, options and an expression', + schema: `model Doc { + id Int @id + body String + tags String[] + + @@index([body], type: "gin", options: { fastupdate: "off" }, name: "doc_body_gin") + @@index([id], where: "id > 10", name: "doc_recent") + @@index(expression: "lower(body)", name: "doc_body_lower") + @@index([tags], unique: true, name: "doc_tags_key") +} +`, + }, + { + name: 'a domain enum with membership checks on a scalar and a list column', + schema: `enum Priority { + @@type("pg/text@1") + Low = "low" + High = "high" +} + +model Task { + id Int @id + priority Priority + priorities Priority[] + waived Priority @noCheck(membership) + preset Priority @default(Low) +} +`, + }, + { + name: 'primary key names', + schema: `model Widget { + id Int @id(map: "widget_pk") +} + +model Pair { + a Int + b Int + + @@id([a, b], map: "pair_pk") +} +`, + }, + { + name: 'columns whose codec is not the default for their native type', + schema: `model Clock { + id Int @id + day DateString + local TimestampString(3) + zoned TimestamptzString(3) + js TimestamptzJsDate(3) + time TimeString(3) + big BigIntNumber + huge UnboundedInt +} +`, + }, + { + name: 'row-level security: roles, @@rls, and policies with and without an exact name', + schema: `namespace unbound { + role app_user { + } + + role admin { + } +} + +model Profile { + id Int @id + owner_id Int + + @@rls +} + +policy_select p_read { + target = Profile + roles = [app_user, admin] + using = "owner_id = current_setting('app.uid')::int" +} + +policy_insert p_write { + target = Profile + roles = [app_user] + withCheck = "owner_id > 0" +} + +policy_update p_update { + target = Profile + roles = [admin] + using = "true" + withCheck = "owner_id > 0" + permissive = false +} + +policy_all p_admin { + target = Profile + roles = [admin] + using = "true" + + @@map("profile_admin_policy") +} +`, + }, + { + name: 'a check written with a name prefix', + schema: `model Widget { + id Int @id + email String + + @@check(expression: "length(email) > 0", name: "widget_email_not_blank") + @@check(expression: "id > 0", map: "widget_id_positive") +} +`, + }, + { + name: 'a list of value objects', + schema: `type Address { + street String + city String? +} + +model Person { + id Int @id + home Address + addrs Address[] +} +`, + }, + { + name: 'a policy expression holding a quote, a backslash and a line break', + schema: `namespace unbound { + role app_user { + } +} + +model Note { + id Int @id + owner String + + @@rls +} + +policy_select p_read { + target = Note + roles = [app_user] + using = "owner = 'a\\"b' OR owner ~ '\\\\d'\\nOR owner = 'c'" +} +`, + }, + { + name: 'a policy expression holding a tab and another control character', + schema: `namespace unbound { + role app_user { + } +} + +model Note { + id Int @id + owner String + + @@rls +} + +policy_select p_read { + target = Note + roles = [app_user] + using = "owner = 'a\tb\u0001'" +} +`, + }, + { + name: 'enum member values holding a tab and a quote', + schema: `enum Label { + @@type("pg/text@1") + Tabbed = "a\\tb" + Quoted = "say \\"hi\\"" +} + +model Tagged { + id Int @id + label Label +} +`, + }, + { + name: 'a model in the unbound namespace', + schema: `namespace unbound { + model Setting { + id Int @id + } +} +`, + }, +]; + +describe('a printed PSL contract reads back as the same contract', () => { + it.each(pslCases)( + '$name', + async ({ schema }) => { + const authored = await readPsl(`// use prisma-8\n${schema}`); + const printed = await printAndReadBack(authored); + + expect(serializedWithoutCapabilities(printed)).toEqual( + serializedWithoutCapabilities(authored), + ); + expect(printed.storage.storageHash).toBe(authored.storage.storageHash); + }, + timeouts.pslRoundTrip, + ); +}); + +describe('a native enum value "__proto__"', () => { + it( + 'is written under a member name the PSL source keeps, and reads back', + async () => { + const authored = await readPsl(`// use prisma-8 +namespace public { + native_enum Mood { + proto = "__proto__" + calm = "calm" + } + + model Person { + id Int @id + mood pg.enum(Mood) + } +} +`); + const printed = await printAndReadBack(authored); + + expect(printContract(authored).text).toContain('= "__proto__"'); + expect(serializedWithoutCapabilities(printed)).toEqual( + serializedWithoutCapabilities(authored), + ); + }, + timeouts.pslRoundTrip, + ); +}); + +describe('a contract the language cannot carry is refused by name', () => { + it('refuses a to-one relation that travels no foreign key', () => { + const authored = loadContract( + 'test/integration/test/sql-orm-client/fixtures/generated/contract.json', + ); + expect(() => printContract(authored)).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + meta: { model: 'Article', field: 'reviewer' }, + }), + ); + }); + + it('refuses a relation to a Supabase model, which lives in another contract space', () => { + expect(() => printContract(loadContract('examples/supabase/src/contract.json'))).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + meta: { model: 'Profile', field: 'user', space: 'supabase' }, + }), + ); + }); + + it('refuses a many-to-many relation whose junction model has no relation back to it', () => { + const authored = loadContract( + 'test/integration/test/sql-orm-client/fixtures/junction-namespaces/generated/contract.json', + ); + expect(() => printContract(authored)).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + meta: { model: 'User', field: 'roles' }, + }), + ); + }); +}); + +describe('a printed emitted contract reads back as the same contract', () => { + it.each(cases)( + '$name', + async ({ contractJson }) => { + const authored = loadContract(contractJson); + const printed = await printAndReadBack(authored); + + expect(serializedWithoutCapabilities(printed)).toEqual( + serializedWithoutCapabilities(authored), + ); + expect(printed.storage.storageHash).toBe(authored.storage.storageHash); + }, + timeouts.pslRoundTrip, + ); +}); diff --git a/test/integration/test/psl-print/constraints-roundtrip.integration.test.ts b/test/integration/test/psl-print/constraints-roundtrip.integration.test.ts new file mode 100644 index 000000000000..80896c85b66f --- /dev/null +++ b/test/integration/test/psl-print/constraints-roundtrip.integration.test.ts @@ -0,0 +1,299 @@ +import type { + ApplicationDomainNamespace, + Contract, + ContractField, + ContractRelation, +} from '@internal/contract/types'; +import { asNamespaceId } from '@internal/contract/types'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { PostgresContractSerializer } from '@internal/target-postgres/runtime'; +import { blindCast } from '@internal/utils/casts'; +import { createSqlContract, timeouts } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { printContract, readPsl } from './print-and-read-back'; + +const INT_COLUMN = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } as const; +const TEXT_COLUMN = { nativeType: 'text', codecId: 'pg/text@1', nullable: false } as const; + +const INT_FIELD: ContractField = { + nullable: false, + type: { kind: 'scalar', codecId: 'pg/int4@1' }, +}; + +interface ModelInput { + readonly table: string; + readonly fields: Record; + readonly relations?: Record; +} + +interface ColumnShape { + readonly [key: string]: unknown; + readonly codecId: string; + readonly nullable: boolean; +} + +interface TableShape { + readonly [key: string]: unknown; + readonly columns: Record; +} + +/** The domain field the PSL source derives for a scalar column. */ +function domainFieldOf(column: ColumnShape | undefined): ContractField { + if (column === undefined) return INT_FIELD; + return { nullable: column.nullable, type: { kind: 'scalar', codecId: column.codecId } }; +} + +function contractOf(input: { + readonly models: Record; + readonly tables: Record; +}): Contract { + const domainNamespace: ApplicationDomainNamespace = { + models: Object.fromEntries( + Object.entries(input.models).map(([name, model]) => [ + name, + { + storage: { table: model.table, namespaceId: 'public', fields: model.fields }, + fields: Object.fromEntries( + Object.entries(model.fields).map(([fieldName, { column }]) => [ + fieldName, + domainFieldOf(input.tables[model.table]?.columns[column]), + ]), + ), + relations: model.relations ?? {}, + }, + ]), + ), + }; + const json = createSqlContract({ + roots: Object.fromEntries( + Object.entries(input.models).map(([name, model]) => [ + model.table, + { namespace: asNamespaceId('public'), model: name }, + ]), + ), + namespaces: { public: domainNamespace }, + storage: { namespaces: { public: { id: 'public', entries: { table: input.tables } } } }, + }); + return blindCast, 'the Postgres serializer yields a SQL contract'>( + new PostgresContractSerializer().deserializeContract(json), + ); +} + +/** The storage table of a loaded contract, or a thrown error naming what is missing. */ +function storageTable(contract: Contract, tableName: string) { + const table = contract.storage.namespaces['public']?.entries.table?.[tableName]; + if (table === undefined) { + throw new Error(`the contract has no table "public"."${tableName}"`); + } + return table; +} + +function table(input: { + readonly columns: Record; + readonly primaryKey?: { readonly columns: readonly string[] }; + readonly uniques?: readonly unknown[]; + readonly indexes?: readonly unknown[]; + readonly foreignKeys?: readonly unknown[]; + readonly checks?: readonly unknown[]; +}): TableShape { + return { + columns: input.columns, + uniques: input.uniques ?? [], + indexes: input.indexes ?? [], + foreignKeys: input.foreignKeys ?? [], + ...(input.checks === undefined ? {} : { checks: input.checks }), + ...(input.primaryKey === undefined ? {} : { primaryKey: input.primaryKey }), + }; +} + +function widget(overrides: Parameters[0]): Contract { + return contractOf({ + models: { + Widget: { + table: 'widget', + fields: { id: { column: 'id' }, email: { column: 'email' } }, + }, + }, + tables: { widget: table({ primaryKey: { columns: ['id'] }, ...overrides }) }, + }); +} + +const WIDGET_COLUMNS = { id: INT_COLUMN, email: TEXT_COLUMN }; + +describe('table constraints survive the print and the read back', { + timeout: timeouts.pslRoundTrip, +}, () => { + it('prints a check constraint the PSL source reads back with its name and expression', async () => { + const contract = widget({ + columns: WIDGET_COLUMNS, + checks: [{ name: 'widget_email_not_blank', expression: 'length(email) > 0' }], + }); + + const text = printContract(contract).text; + expect(text).toContain( + '@@check(expression: "length(email) > 0", map: "widget_email_not_blank")', + ); + + const readBack = storageTable(await readPsl(text), 'widget'); + expect(readBack.checks?.map((check) => ({ ...check }))).toEqual([ + { name: 'widget_email_not_blank', expression: 'length(email) > 0' }, + ]); + }); + + it('prints a unique constraint as @@unique, which reads back as a unique constraint', async () => { + const contract = widget({ + columns: WIDGET_COLUMNS, + uniques: [{ columns: ['email'], name: 'widget_email_key' }], + }); + + const text = printContract(contract).text; + expect(text).toContain('@@unique([email], map: "widget_email_key")'); + + const readBack = storageTable(await readPsl(text), 'widget'); + expect(readBack.uniques.map((unique) => ({ ...unique }))).toEqual([ + { columns: ['email'], name: 'widget_email_key' }, + ]); + expect(readBack.indexes).toEqual([]); + }); + + it('prints a unique index as a unique index, which reads back as an index', async () => { + const contract = widget({ + columns: WIDGET_COLUMNS, + indexes: [{ name: 'widget_email_idx', unique: true, columns: ['email'] }], + }); + + const text = printContract(contract).text; + expect(text).toContain('unique: true'); + + const readBack = storageTable(await readPsl(text), 'widget'); + expect(readBack.uniques).toEqual([]); + expect(readBack.indexes.map((index) => ({ name: index.name, unique: index.unique }))).toEqual([ + { name: 'widget_email_idx', unique: true }, + ]); + }); +}); + +function postAndUser(input: { + readonly userIdColumn: string; + readonly authorIdColumn: string; + readonly foreignKeys: readonly unknown[]; + readonly relations: Record; + readonly postFields: Record; +}): Contract { + return contractOf({ + models: { + User: { + table: 'user', + fields: { id: { column: input.userIdColumn } }, + relations: input.relations, + }, + Post: { + table: 'post', + fields: input.postFields, + relations: Object.fromEntries( + Object.entries(input.postFields) + .filter(([fieldName]) => fieldName.endsWith('Id') && fieldName !== 'id') + .map(([fieldName]) => [ + fieldName.replace(/Id$/, ''), + { + to: { namespace: asNamespaceId('public'), model: 'User' }, + cardinality: 'N:1', + nullable: false, + on: { localFields: [fieldName], targetFields: ['id'] }, + } satisfies ContractRelation, + ]), + ), + }, + }, + tables: { + user: table({ + columns: { [input.userIdColumn]: INT_COLUMN }, + primaryKey: { columns: [input.userIdColumn] }, + }), + post: table({ + columns: Object.fromEntries( + Object.values(input.postFields).map((field) => [field.column, INT_COLUMN]), + ), + primaryKey: { columns: ['id'] }, + foreignKeys: input.foreignKeys, + }), + }, + }); +} + +describe('relations survive the print and the read back', { + timeout: timeouts.pslRoundTrip, +}, () => { + it('keeps a foreign key name the PSL source would not derive', async () => { + const contract = postAndUser({ + userIdColumn: 'id', + authorIdColumn: 'authorId', + postFields: { id: { column: 'id' }, authorId: { column: 'authorId' } }, + relations: { + posts: { + to: { namespace: asNamespaceId('public'), model: 'Post' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['authorId'] }, + }, + }, + foreignKeys: [ + { + name: 'post_written_by_user', + source: { namespaceId: 'public', tableName: 'post', columns: ['authorId'] }, + target: { namespaceId: 'public', tableName: 'user', columns: ['id'] }, + }, + ], + }); + + const text = printContract(contract).text; + expect(text).toContain('map: "post_written_by_user"'); + + const readBack = storageTable(await readPsl(text), 'post'); + expect(readBack.foreignKeys.map((key) => key.name)).toEqual(['post_written_by_user']); + }); + + it('names an ambiguous pair when the referenced columns are mapped', async () => { + const contract = postAndUser({ + userIdColumn: 'user_id', + authorIdColumn: 'author_id', + postFields: { + id: { column: 'id' }, + authorId: { column: 'author_id' }, + editorId: { column: 'editor_id' }, + }, + relations: { + written: { + to: { namespace: asNamespaceId('public'), model: 'Post' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['authorId'] }, + }, + edited: { + to: { namespace: asNamespaceId('public'), model: 'Post' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['editorId'] }, + }, + }, + foreignKeys: [ + { + source: { namespaceId: 'public', tableName: 'post', columns: ['author_id'] }, + target: { namespaceId: 'public', tableName: 'user', columns: ['user_id'] }, + }, + { + source: { namespaceId: 'public', tableName: 'post', columns: ['editor_id'] }, + target: { namespaceId: 'public', tableName: 'user', columns: ['user_id'] }, + }, + ], + }); + + const readBack = storageTable(await readPsl(printContract(contract).text), 'post'); + expect( + readBack.foreignKeys.map((key) => ({ + source: [...key.source.columns], + target: [...key.target.columns], + })), + ).toEqual([ + { source: ['author_id'], target: ['user_id'] }, + { source: ['editor_id'], target: ['user_id'] }, + ]); + }); +}); diff --git a/test/integration/test/psl-print/every-postgres-contract-roundtrip.integration.test.ts b/test/integration/test/psl-print/every-postgres-contract-roundtrip.integration.test.ts new file mode 100644 index 000000000000..4c8078ba6cb1 --- /dev/null +++ b/test/integration/test/psl-print/every-postgres-contract-roundtrip.integration.test.ts @@ -0,0 +1,310 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { canonicalizeContractToObject } from '@internal/contract/hashing'; +import type { Contract } from '@internal/contract/types'; +import arktypeJsonControl from '@internal/extension-arktype-json/control'; +import arktypeJsonPack from '@internal/extension-arktype-json/pack'; +import paradedbControl from '@internal/extension-paradedb/control'; +import paradedbPack from '@internal/extension-paradedb/pack'; +import pgvectorControl from '@internal/extension-pgvector/control'; +import pgvectorPack from '@internal/extension-pgvector/pack'; +import postgisControl from '@internal/extension-postgis/control'; +import postgisPack from '@internal/extension-postgis/pack'; +import supabasePack from '@internal/extension-supabase/pack'; +import type { ExtensionPackRef } from '@internal/framework-components/components'; +import type { ControlExtensionDescriptor } from '@internal/framework-components/control'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { PostgresContractSerializer } from '@internal/target-postgres/runtime'; +import { blindCast } from '@internal/utils/casts'; +import { dirname, join } from 'pathe'; +import { describe, expect, it } from 'vitest'; +import { + composePostgresStack, + type PostgresStack, + printContract, + readPsl, +} from './print-and-read-back'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../..'); + +interface ExtensionPack { + readonly control: ControlExtensionDescriptor<'sql', 'postgres'>; + readonly packRef: ExtensionPackRef<'sql', string>; +} + +const extensionPacks: ReadonlyMap = new Map([ + ['arktype-json', { control: arktypeJsonControl, packRef: arktypeJsonPack }], + ['paradedb', { control: paradedbControl, packRef: paradedbPack }], + ['pgvector', { control: pgvectorControl, packRef: pgvectorPack }], + ['postgis', { control: postgisControl, packRef: postgisPack }], + ['supabase', { control: supabasePack, packRef: supabasePack }], +]); + +/** + * Packs that exist only inside one example or test, so this package cannot + * load them. A contract naming one is printed and read back without it, which + * is enough while none of them contributes a type, block or generator the + * contract uses. + */ +const packsOutsideThisPackage = new Set([ + 'audit', + 'demo/engagement-stats', + 'feature-flags', + 'slugid-defaults', +]); + +/** + * Every tracked Postgres contract, except migration snapshots: a snapshot is a + * frozen copy of the contract a migration was planned against, many in formats + * the serializer no longer reads, and the current contract it was taken from is + * covered here. + */ +function trackedPostgresContracts(): readonly string[] { + const files = execFileSync('git', ['ls-files', '-z', '--', '*.json'], { + cwd: repoRoot, + encoding: 'utf-8', + }) + .split('\0') + .filter((file) => file.length > 0 && !file.includes('/migrations/snapshots/')); + return files.filter((file) => { + let json: unknown; + try { + json = JSON.parse(readFileSync(join(repoRoot, file), 'utf-8')); + } catch { + return false; + } + return ( + typeof json === 'object' && + json !== null && + Reflect.get(json, 'targetFamily') === 'sql' && + Reflect.get(json, 'target') === 'postgres' + ); + }); +} + +interface Composition { + readonly stack: PostgresStack; + readonly packRefs: readonly ExtensionPackRef<'sql', string>[]; +} + +/** + * The stack a contract needs: every pack it names, and the pack whose own + * contract space it is, which names no pack. + */ +function compositionFor(contract: Contract): Composition { + const ids = new Set(Object.keys(contract.extensions)); + for (const [id, pack] of extensionPacks) { + if ( + pack.control.contractSpace?.contractJson.storage.storageHash === contract.storage.storageHash + ) { + ids.add(id); + } + } + const packs = [...ids].flatMap((id) => { + const pack = extensionPacks.get(id); + if (pack !== undefined) return [pack]; + if (packsOutsideThisPackage.has(id)) return []; + throw new Error(`the contract names pack "${id}", which this test does not compose; add it`); + }); + return { + stack: composePostgresStack(packs.map((pack) => pack.control)), + packRefs: packs.map((pack) => pack.packRef), + }; +} + +const serializer = new PostgresContractSerializer(); + +/** + * The contract as `contract emit` writes it, without `capabilities` and + * `extensions`: both are written from the composed stack, not from the source, + * so the printed file has no say over them. + */ +function comparable(contract: Contract): unknown { + const { + capabilities: _capabilities, + extensions: _extensions, + ...rest + } = canonicalizeContractToObject(contract, { + serializeContract: (value) => + serializer.serializeContract( + blindCast, 'this test canonicalizes Postgres contracts only'>(value), + ), + shouldPreserveEmpty: serializer.shouldPreserveEmpty, + sortStorage: serializer.sortStorage, + }); + return rest; +} + +interface ExpectedRefusal { + /** Words from the refusal message that name the reason. */ + readonly reason: string; + readonly meta: Record; +} + +/** + * The contracts the printer refuses, each with the words of its refusal + * message that name the reason, and its meta. A contract missing from this + * list must read back as the same contract. + */ +const expectedRefusals: ReadonlyMap = new Map([ + [ + 'examples/supabase/src/contract.json', + { + reason: 'in contract space "supabase"', + meta: { model: 'Profile', field: 'user', space: 'supabase' }, + }, + ], + [ + 'packages/2-sql/2-authoring/contract-prisma7/test/fixtures/junction-name-in-other-schema/expected-contract.json', + { + reason: 'is declared in more than one namespace', + meta: { modelName: 'PostToTag', namespaces: ['one', 'two'] }, + }, + ], + [ + 'packages/2-sql/2-authoring/contract-prisma7/test/fixtures/relation-name-in-two-schemas/expected-contract.json', + { + reason: 'is declared in more than one namespace', + meta: { modelName: 'X', namespaces: ['one', 'two'] }, + }, + ], + [ + 'packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.json', + { reason: 'has no foreign key in storage', meta: { model: 'Post', field: 'author' } }, + ], + [ + 'packages/3-extensions/pgvector/src/contract.json', + { + reason: 'no PSL type in the configured stack', + meta: { coordinate: 'types.vector', nativeType: 'vector', codecId: 'pg/vector@1' }, + }, + ], + [ + 'packages/3-extensions/postgis/src/contract.json', + { + reason: 'no PSL type in the configured stack', + meta: { coordinate: 'types.geometry', nativeType: 'geometry', codecId: 'pg/geometry@1' }, + }, + ], + [ + 'packages/3-extensions/postgres/test/fixtures/generated/contract.json', + { reason: 'has no foreign key in storage', meta: { model: 'Post', field: 'author' } }, + ], + [ + 'packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.json', + { reason: 'has no foreign key in storage', meta: { model: 'Article', field: 'reviewer' } }, + ], + [ + 'packages/3-extensions/sql-orm-client/test/fixtures/junction-namespaces/generated/contract.json', + { reason: 'many-to-many relation', meta: { model: 'User', field: 'roles' } }, + ], + [ + 'packages/3-extensions/supabase/test/fixtures/example-app/contract.json', + { + reason: 'in contract space "supabase"', + meta: { model: 'Profile', field: 'user', space: 'supabase' }, + }, + ], + [ + 'packages/3-extensions/supabase/test/fixtures/no-policy/contract.json', + { + reason: 'in contract space "supabase"', + meta: { model: 'Profile', field: 'user', space: 'supabase' }, + }, + ], + [ + 'packages/3-extensions/supabase/test/fixtures/renamed-policy/contract.json', + { + reason: 'in contract space "supabase"', + meta: { model: 'Profile', field: 'user', space: 'supabase' }, + }, + ], + [ + 'packages/3-targets/3-targets/postgres/test/fixtures/namespaced-contract.json', + { + reason: 'is declared in more than one namespace', + meta: { modelName: 'User', namespaces: ['auth', 'public'] }, + }, + ], + [ + 'packages/3-targets/3-targets/postgres/test/fixtures/snapshot-read-shapes/codec-instance.json', + { + reason: 'has no model stored in it', + meta: { namespaceId: '__unbound__', table: 'embeddings' }, + }, + ], + [ + 'test/e2e/framework/test/fixtures/generated/contract.json', + { reason: 'has no foreign key in storage', meta: { model: 'Comment', field: 'post' } }, + ], + [ + 'test/integration/test/authoring/parity/default-pack-slugid/expected.contract.json', + { + reason: 'the printer knows no PSL default function', + meta: { coordinate: '"public"."user"."id"', onCreate: 'slugid', onUpdate: undefined }, + }, + ], + [ + 'test/integration/test/namespaced-accessors/fixtures/generated/contract.json', + { + reason: 'is declared in more than one namespace', + meta: { modelName: 'User', namespaces: ['auth', 'public'] }, + }, + ], + [ + 'test/integration/test/ports/engines/queries/data_types/native/postgres/_fixture/string/generated/contract.json', + { + reason: 'no PSL type in the configured stack', + meta: { coordinate: '"public"."Child"."bit"', nativeType: 'bit', codecId: 'pg/bit@1' }, + }, + ], + [ + 'test/integration/test/sql-builder/fixtures/generated-no-pgvector/contract.json', + { reason: 'has no foreign key in storage', meta: { model: 'Post', field: 'author' } }, + ], + [ + 'test/integration/test/sql-builder/fixtures/generated/contract.json', + { reason: 'has no foreign key in storage', meta: { model: 'Post', field: 'author' } }, + ], + [ + 'test/integration/test/sql-orm-client/fixtures/execution-defaulted-tags/generated/contract.json', + { reason: 'many-to-many relation', meta: { model: 'User', field: 'tags' } }, + ], + [ + 'test/integration/test/sql-orm-client/fixtures/generated/contract.json', + { reason: 'has no foreign key in storage', meta: { model: 'Article', field: 'reviewer' } }, + ], + [ + 'test/integration/test/sql-orm-client/fixtures/junction-namespaces/generated/contract.json', + { reason: 'many-to-many relation', meta: { model: 'User', field: 'roles' } }, + ], +]); + +const contracts = trackedPostgresContracts(); + +describe('every Postgres contract in the repo prints as PSL that reads back as the same contract', () => { + it('finds the contracts', () => { + expect(contracts.length).toBeGreaterThan(200); + }); + + it.each(contracts)('%s', async (file) => { + const json: unknown = JSON.parse(readFileSync(join(repoRoot, file), 'utf-8')); + const contract = serializer.deserializeContract(json); + const composition = compositionFor(contract); + const refusal = expectedRefusals.get(file); + if (refusal !== undefined) { + expect(() => printContract(contract, composition.stack)).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + message: expect.stringContaining(refusal.reason), + meta: refusal.meta, + }), + ); + return; + } + const { text, sourceSettings } = printContract(contract, composition.stack); + const printed = await readPsl(text, { ...composition, sourceSettings }); + expect(comparable(printed)).toEqual(comparable(contract)); + }); +}); diff --git a/test/integration/test/psl-print/extension-types-roundtrip.integration.test.ts b/test/integration/test/psl-print/extension-types-roundtrip.integration.test.ts new file mode 100644 index 000000000000..e88640d691cd --- /dev/null +++ b/test/integration/test/psl-print/extension-types-roundtrip.integration.test.ts @@ -0,0 +1,76 @@ +import pgvector from '@internal/extension-pgvector/control'; +import { describe, expect, it } from 'vitest'; +import { + composePostgresStack, + printContract, + readPsl, + serializedWithoutCapabilities, +} from './print-and-read-back'; + +const stack = composePostgresStack([pgvector]); + +describe('a printed contract with an extension-contributed column type', () => { + it('writes the extension type and reads back as the same contract', async () => { + const authored = await readPsl( + `// use prisma-8 +types { + Embedding = pgvector.Vector(3) +} + +model Document { + id Int @id + embedding pgvector.Vector(3) + named Embedding +} +`, + { stack }, + ); + const { text, sourceSettings } = printContract(authored, stack); + + expect(text).toContain('embedding pgvector.Vector(3)'); + const printed = await readPsl(text, { stack, sourceSettings }); + expect(serializedWithoutCapabilities(printed)).toEqual(serializedWithoutCapabilities(authored)); + expect(printed.storage.storageHash).toBe(authored.storage.storageHash); + }); + + it('refuses an extension type on a value-object field, whose type parameters the PSL source drops', async () => { + const authored = await readPsl( + `// use prisma-8 +type Point { + at pgvector.Vector(3) +} + +model Place { + id Int @id + point Point +} +`, + { stack }, + ); + + expect(() => printContract(authored, stack)).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + meta: { coordinate: '"public".Point.at', codecId: 'pg/vector@1' }, + }), + ); + }); + + it('writes a literal default on an extension-typed column and reads back as the same contract', async () => { + const authored = await readPsl( + `// use prisma-8 +model Document { + id Int @id + embedding pgvector.Vector(3) @default([1, 2, 3]) +} +`, + { stack }, + ); + const { text, sourceSettings } = printContract(authored, stack); + + expect(text).toContain('@default([1, 2, 3])'); + const printed = await readPsl(text, { stack, sourceSettings }); + expect(serializedWithoutCapabilities(printed)).toEqual(serializedWithoutCapabilities(authored)); + expect(printed.storage.storageHash).toBe(authored.storage.storageHash); + }); +}); 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 new file mode 100644 index 000000000000..6f9fedd1d762 --- /dev/null +++ b/test/integration/test/psl-print/generated-values-roundtrip.integration.test.ts @@ -0,0 +1,78 @@ +import { + type AuthoringFieldNamespace, + isAuthoringFieldPresetDescriptor, +} from '@internal/framework-components/authoring'; +import { timeouts } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { + composePostgresStack, + printAndReadBack, + readPsl, + serializedWithoutCapabilities, +} from './print-and-read-back'; + +const stack = composePostgresStack(); + +/** + * Every call the stack registers a PSL default function for, with a written range such as + * `<2-255>` replaced by its lower bound. + */ +const defaultFunctionCalls = [...stack.controlMutationDefaults.defaultFunctionRegistry.values()] + .flatMap((entry) => entry.usageSignatures ?? []) + .map((usage) => usage.replace(/<(\d+)-\d+>/, '$1')); + +/** Every `temporal.*` field preset the stack contributes, called with the now generator in each phase it takes. */ +function temporalPresetCalls(namespace: AuthoringFieldNamespace): readonly string[] { + const temporal = namespace['temporal']; + if (temporal === undefined || isAuthoringFieldPresetDescriptor(temporal)) return []; + return Object.entries(temporal).flatMap(([name, preset]) => { + if (!isAuthoringFieldPresetDescriptor(preset)) return []; + const phases = (preset.args ?? []) + .map((arg) => arg.name) + .filter((argName) => argName === 'onCreate' || argName === 'onUpdate') + .map((argName) => `${argName}: now`); + return [`temporal.${name}(${phases.join(', ')})`]; + }); +} + +async function roundTrip(field: string): Promise { + const authored = await readPsl(`// use prisma-8 +model Widget { + id Int @id + ${field} +} +`); + const printed = await printAndReadBack(authored); + + expect(serializedWithoutCapabilities(printed)).toEqual(serializedWithoutCapabilities(authored)); +} + +describe('every generated value the stack reads prints as PSL that reads back', () => { + it('finds the default functions and temporal presets', () => { + expect(defaultFunctionCalls).toContain('uuid(7)'); + expect(temporalPresetCalls(stack.authoringContributions.field)).toContain( + 'temporal.timestamptz(onCreate: now, onUpdate: now)', + ); + }); + + it.each(defaultFunctionCalls)( + '@default(%s)', + async (call) => { + const type = call.startsWith('autoincrement') + ? 'Int' + : call.startsWith('now') + ? 'DateTime' + : 'String'; + await roundTrip(`value ${type} @default(${call})`); + }, + timeouts.pslRoundTrip, + ); + + it.each(temporalPresetCalls(stack.authoringContributions.field))( + '%s', + async (call) => { + await roundTrip(`value ${call}`); + }, + timeouts.pslRoundTrip, + ); +}); diff --git a/test/integration/test/psl-print/print-and-read-back.ts b/test/integration/test/psl-print/print-and-read-back.ts new file mode 100644 index 000000000000..9336de652c2d --- /dev/null +++ b/test/integration/test/psl-print/print-and-read-back.ts @@ -0,0 +1,138 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import postgresAdapter from '@internal/adapter-postgres/control'; +import type { ContractSourceContext } from '@internal/config/config-types'; +import type { Contract } from '@internal/contract/types'; +import postgresDriver from '@internal/driver-postgres/control'; +import sql from '@internal/family-sql/control'; +import type { ExtensionPackRef } from '@internal/framework-components/components'; +import { + type ControlExtensionDescriptor, + type ControlStack, + createControlStack, + type PslSourceSettings, +} from '@internal/framework-components/control'; +import { printPsl } from '@internal/psl-printer'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { prismaContract } from '@internal/sql-contract-psl/provider'; +import { PG_INT_CODEC_ID, PG_TEXT_CODEC_ID } from '@internal/target-postgres/codec-ids'; +import postgres from '@internal/target-postgres/control'; +import postgresPackRef from '@internal/target-postgres/pack'; +import { PostgresContractSerializer } from '@internal/target-postgres/runtime'; +import { postgresCreateNamespace } from '@internal/target-postgres/types'; +import { blindCast } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; +import { join } from 'pathe'; + +export type PostgresStack = ControlStack<'sql', 'postgres'>; + +/** The stack `contract emit` composes for a Postgres config with these extensions. */ +export function composePostgresStack( + extensions: readonly ControlExtensionDescriptor<'sql', 'postgres'>[] = [], +): PostgresStack { + return createControlStack({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresDriver, + extensions: [...extensions], + }); +} + +/** A contract printed as PSL text, and the settings the config must set on the PSL source. */ +export interface PrintedContract { + readonly text: string; + readonly sourceSettings: PslSourceSettings; +} + +/** + * Prints a contract as PSL text the way `contract print` does: through the SQL family instance + * created from the stack, then the PSL printer with the stack's block descriptors and codecs. + */ +export function printContract( + contract: Contract, + stack: PostgresStack = composePostgresStack(), +): PrintedContract { + const { document, sourceSettings } = sql.create(stack).buildPslContract(contract); + return { + text: printPsl(document, { + pslBlockDescriptors: stack.authoringContributions.pslBlockDescriptors, + codecLookup: stack.codecLookup, + }), + sourceSettings, + }; +} + +/** The context `contract emit` gives a contract source for this stack and these input files. */ +export function sourceContext( + stack: PostgresStack, + resolvedInputs: readonly string[], +): ContractSourceContext { + return { + composedExtensions: stack.extensions.map((extension) => extension.id), + composedExtensionContracts: stack.extensionContracts, + authoringContributions: stack.authoringContributions, + codecLookup: stack.codecLookup, + dataTypeLookup: stack.dataTypeLookup, + controlMutationDefaults: stack.controlMutationDefaults, + resolvedInputs, + capabilities: stack.capabilities, + }; +} + +export interface ReadPslOptions { + readonly stack?: PostgresStack; + /** The extension packs the config gives the PSL source. */ + readonly packRefs?: readonly ExtensionPackRef<'sql', string>[]; + readonly sourceSettings?: PslSourceSettings; +} + +/** + * Reads PSL text through the Prisma 8 PSL contract source with the stack's context, the way + * `contract emit` does, and throws with the source's diagnostics when it does not load. + */ +export async function readPsl( + text: string, + options: ReadPslOptions = {}, +): Promise> { + const stack = options.stack ?? composePostgresStack(); + const directory = mkdtempSync(join(tmpdir(), 'psl-print-')); + try { + const path = join(directory, 'contract.prisma'); + writeFileSync(path, text); + const result = await prismaContract(path, { + target: postgresPackRef, + createNamespace: postgresCreateNamespace, + enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, + ...ifDefined('composedExtensionPackRefs', options.packRefs), + ...ifDefined('defaultControlPolicy', options.sourceSettings?.defaultControlPolicy), + }).source.load(sourceContext(stack, [path])); + if (!result.ok) { + throw new Error( + `the PSL did not load: ${JSON.stringify(result.failure.diagnostics)}\n${text}`, + ); + } + return blindCast, 'the Postgres PSL source yields a SQL contract'>( + result.value, + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +/** Prints a contract as `contract print` does and reads the text back with the settings the printer names. */ +export function printAndReadBack( + contract: Contract, + options: Omit = {}, +): Promise> { + const { text, sourceSettings } = printContract(contract, options.stack); + return readPsl(text, { ...options, sourceSettings }); +} + +/** The serialized contract without `capabilities`, which the composed stack reports rather than the source. */ +export function serializedWithoutCapabilities(contract: Contract): unknown { + const { capabilities: _, ...authored } = JSON.parse( + JSON.stringify(new PostgresContractSerializer().serializeContract(contract)), + ); + return authored; +} diff --git a/test/integration/test/psl-print/prisma7-fixture-roundtrip.integration.test.ts b/test/integration/test/psl-print/prisma7-fixture-roundtrip.integration.test.ts new file mode 100644 index 000000000000..d340c6338729 --- /dev/null +++ b/test/integration/test/psl-print/prisma7-fixture-roundtrip.integration.test.ts @@ -0,0 +1,86 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +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 { dirname, join } from 'pathe'; +import { describe, expect, it } from 'vitest'; +import { + composePostgresStack, + printAndReadBack, + printContract, + serializedWithoutCapabilities, + sourceContext, +} from './print-and-read-back'; + +const fixturesDir = join( + dirname(fileURLToPath(import.meta.url)), + '../../../../packages/2-sql/2-authoring/contract-prisma7/test/fixtures', +); + +const cases = readdirSync(fixturesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => existsSync(join(fixturesDir, name, 'expected-contract.json'))) + .sort(); + +/** The fixtures `contract print` refuses, each with the meta of its refusal. */ +const expectedRefusals: ReadonlyMap> = new Map([ + ['junction-name-in-other-schema', { modelName: 'PostToTag', namespaces: ['one', 'two'] }], + ['relation-name-in-two-schemas', { modelName: 'X', namespaces: ['one', 'two'] }], +]); + +function prisma7SchemaPath(caseName: string): string { + const directory = join(fixturesDir, caseName, 'schema'); + return existsSync(directory) ? directory : join(fixturesDir, caseName, 'schema.prisma'); +} + +async function loadPrisma7Fixture( + schemaPath: string, + caseName: string, +): Promise> { + const result = await prisma7Contract(schemaPath, { + binding: prisma7PostgresBinding, + }).source.load(sourceContext(composePostgresStack(), [schemaPath])); + if (!result.ok) { + throw new Error( + `Prisma 7 fixture "${caseName}" did not load: ${JSON.stringify(result.failure.diagnostics)}`, + ); + } + return blindCast, 'the Prisma 7 source yields a SQL contract'>(result.value); +} + +async function roundTrip(caseName: string): Promise { + const schemaPath = prisma7SchemaPath(caseName); + const prisma7 = await loadPrisma7Fixture(schemaPath, caseName); + const printedContract = await printAndReadBack(prisma7); + + expect(serializedWithoutCapabilities(printedContract)).toEqual( + serializedWithoutCapabilities(prisma7), + ); + expect(printedContract.storage.storageHash).toBe(prisma7.storage.storageHash); +} + +describe('a printed Prisma 7 contract reads back as the same contract', () => { + for (const caseName of cases) { + const refusal = expectedRefusals.get(caseName); + if (refusal !== undefined) { + it(`${caseName} is refused: one model name in two namespaces`, async () => { + const prisma7 = await loadPrisma7Fixture(prisma7SchemaPath(caseName), caseName); + expect(() => printContract(prisma7)).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + message: expect.stringContaining('is declared in more than one namespace'), + meta: refusal, + }), + ); + }); + continue; + } + it(caseName, async () => { + await roundTrip(caseName); + }); + } +}); diff --git a/test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts b/test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts new file mode 100644 index 000000000000..4bbcf3c098a3 --- /dev/null +++ b/test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts @@ -0,0 +1,94 @@ +import { int4Column, textColumn } from '@internal/adapter-postgres/column-types'; +import type { Contract } from '@internal/contract/types'; +import { + autoincrement, + check, + defineContract, + field, + model, + rel, +} from '@internal/postgres/contract-builder'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import { contract as coreSurface } from '../authoring/parity/core-surface/contract'; +import { contract as mapAttributes } from '../authoring/parity/map-attributes/contract'; +import { contract as nativeEnum } from '../authoring/parity/native-enum/contract'; +import { contract as relationBackrelationList } from '../authoring/parity/relation-backrelation-list/contract'; +import { + printAndReadBack, + printContract, + serializedWithoutCapabilities, +} from './print-and-read-back'; + +const Account = model('Account', { + fields: { + id: field.column(int4Column).default(autoincrement()).id(), + email: field.column(textColumn), + }, +}).sql({ + table: 'account', + checks: [ + check({ expression: 'length(email) > 0', name: 'account_email_not_blank' }), + check({ expression: 'id > 0', map: 'account_id_positive' }), + ], +}); + +const Session = model('Session', { + fields: { + id: field.column(int4Column).default(autoincrement()).id(), + accountId: field.column(int4Column), + }, + relations: { + account: rel.belongsTo(Account, { from: 'accountId', to: 'id' }).sql({ + fk: { onDelete: 'cascade' }, + }), + }, +}).sql(({ cols, constraints }) => ({ + table: 'session', + indexes: [constraints.index([cols.accountId])], +})); + +const checksAndRelations = defineContract({ models: { Account, Session } }); + +const Author = model('Author', { + fields: { id: field.column(int4Column).default(autoincrement()).id() }, + relations: { articles: rel.hasMany(() => Article, { by: 'authorId' }) }, +}).sql({ table: 'author' }); + +const Article = model('Article', { + fields: { + id: field.column(int4Column).default(autoincrement()).id(), + authorId: field.column(int4Column), + }, +}).sql({ table: 'article' }); + +const oneSidedRelation = defineContract({ models: { Author, Article } }); + +const cases: ReadonlyArray<{ readonly name: string; readonly contract: Contract }> = [ + { + name: 'checks named by prefix and by exact name, a relation and an index', + contract: checksAndRelations, + }, + { name: 'the core surface: named types, enums, defaults, relations', contract: coreSurface }, + { name: 'mapped tables and columns', contract: mapAttributes }, + { name: 'a native enum', contract: nativeEnum }, + { name: 'a relation with a list back-relation', contract: relationBackrelationList }, +]; + +describe('a TypeScript-authored contract printed as PSL reads back as the same contract', () => { + it.each(cases)('$name', async ({ contract }) => { + const printed = await printAndReadBack(contract); + + expect(serializedWithoutCapabilities(printed)).toEqual(serializedWithoutCapabilities(contract)); + expect(printed.storage.storageHash).toBe(contract.storage.storageHash); + }); + + it('refuses a hasMany relation when the other model declares no belongsTo', () => { + expect(() => printContract(oneSidedRelation)).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + meta: { model: 'Author', field: 'articles' }, + }), + ); + }); +}); diff --git a/test/integration/test/sql-builder/fixtures/prisma.config.no-pgvector.ts b/test/integration/test/sql-builder/fixtures/prisma.config.no-pgvector.ts index cdbd9838d8ad..194f77a36828 100644 --- a/test/integration/test/sql-builder/fixtures/prisma.config.no-pgvector.ts +++ b/test/integration/test/sql-builder/fixtures/prisma.config.no-pgvector.ts @@ -13,6 +13,7 @@ export default definePrismaConfig({ adapter: postgresAdapter, contract: { source: { + format: 'typescript', load: async () => ok(contract), }, output: 'generated-no-pgvector/contract.json', diff --git a/test/integration/test/sql-builder/fixtures/prisma.config.ts b/test/integration/test/sql-builder/fixtures/prisma.config.ts index 796439a98f1f..9c6b1a5e05a4 100644 --- a/test/integration/test/sql-builder/fixtures/prisma.config.ts +++ b/test/integration/test/sql-builder/fixtures/prisma.config.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [pgvector], contract: { source: { + format: 'typescript', load: async () => ok(contract), }, output: 'generated/contract.json', diff --git a/test/integration/test/sql-orm-client/fixtures/execution-defaulted-tags/prisma.config.ts b/test/integration/test/sql-orm-client/fixtures/execution-defaulted-tags/prisma.config.ts index f0ab3eb5b20d..8a6d2f0fb1ac 100644 --- a/test/integration/test/sql-orm-client/fixtures/execution-defaulted-tags/prisma.config.ts +++ b/test/integration/test/sql-orm-client/fixtures/execution-defaulted-tags/prisma.config.ts @@ -13,6 +13,7 @@ export default definePrismaConfig({ adapter: postgresAdapter, contract: { source: { + format: 'typescript', load: async () => ok(contract), }, output: 'generated/contract.json', diff --git a/test/integration/test/sql-orm-client/fixtures/junction-namespaces/prisma.config.ts b/test/integration/test/sql-orm-client/fixtures/junction-namespaces/prisma.config.ts index f0ab3eb5b20d..8a6d2f0fb1ac 100644 --- a/test/integration/test/sql-orm-client/fixtures/junction-namespaces/prisma.config.ts +++ b/test/integration/test/sql-orm-client/fixtures/junction-namespaces/prisma.config.ts @@ -13,6 +13,7 @@ export default definePrismaConfig({ adapter: postgresAdapter, contract: { source: { + format: 'typescript', load: async () => ok(contract), }, output: 'generated/contract.json', diff --git a/test/integration/test/sql-orm-client/fixtures/prisma.config.ts b/test/integration/test/sql-orm-client/fixtures/prisma.config.ts index 796439a98f1f..9c6b1a5e05a4 100644 --- a/test/integration/test/sql-orm-client/fixtures/prisma.config.ts +++ b/test/integration/test/sql-orm-client/fixtures/prisma.config.ts @@ -15,6 +15,7 @@ export default definePrismaConfig({ extensions: [pgvector], contract: { source: { + format: 'typescript', load: async () => ok(contract), }, output: 'generated/contract.json', diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index 988aa6eee056..8a711010337f 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -292,6 +292,14 @@ export async function runContractInfer( return runOnEngine(ctx, ['contract', 'infer', ...extraArgs], options); } +export async function runContractPrint( + ctx: JourneyContext, + extraArgs: readonly string[] = [], + options?: RunCommandOptions, +): Promise { + return runOnEngine(ctx, ['contract', 'print', ...extraArgs], options); +} + export async function runDbInit( ctx: JourneyContext, extraArgs: readonly string[] = [], diff --git a/test/utils/src/timeouts.ts b/test/utils/src/timeouts.ts index bb624c4dff87..d1b3e7e24d17 100644 --- a/test/utils/src/timeouts.ts +++ b/test/utils/src/timeouts.ts @@ -5,6 +5,7 @@ const BASE_TIMEOUTS = { typeScriptCompilation: 8000, coldTransformImport: 30000, databaseOperation: 5000, + pslRoundTrip: 5000, default: 100, /** * Vitest `testTimeout` / `hookTimeout` when a package uses mostly local I/O @@ -110,6 +111,15 @@ export const timeouts = { return Math.round(BASE_TIMEOUTS.databaseOperation * getMultiplier()); }, + /** + * Timeout for a test that prints a contract as PSL, writes the file, and reads it back through the + * PSL contract source: several parses and a file round trip, which can pass the package default + * on a busy worker. + */ + get pslRoundTrip(): number { + return Math.round(BASE_TIMEOUTS.pslRoundTrip * getMultiplier()); + }, + /** * A short wait inside a test: polling a condition, a connection attempt that * must give up quickly. Never a vitest `testTimeout` or `hookTimeout` — CI's diff --git a/upgrade-instructions/pending/contract-print/app/instructions.md b/upgrade-instructions/pending/contract-print/app/instructions.md new file mode 100644 index 000000000000..5dab789502aa --- /dev/null +++ b/upgrade-instructions/pending/contract-print/app/instructions.md @@ -0,0 +1,25 @@ +--- +changes: + - id: contract-format-formats-prisma7-schema + summary: prisma contract format now formats a Prisma 7 schema configured through prisma7Schema(). + - id: config-contract-source-requires-format + summary: A contract source object written in prisma.config.ts must declare format 'psl' or 'typescript'. + - id: policy-expression-json-escapes + summary: A policy expression in a PSL contract decodes every JSON escape, so \t, \b, \f, \/ and \uXXXX no longer read as written. +--- + +# `prisma contract format` formats a Prisma 7 schema + +A project whose `contract` is `prisma7Schema('./prisma/schema.prisma')` used to be skipped by `prisma contract format`. The Prisma 7 source is now a PSL source, so the command formats that file with the Prisma 8 formatter when it parses, and refuses with `PSL.PARSE_FAILED`, without writing, when it does not (for example, a schema with a model whose closing brace is missing). A schema with a `view` block parses, so the command formats it. + +If the Prisma 7 schema must keep Prisma 7's own formatting, do not run `prisma contract format` on it; format it with Prisma 7's `prisma format` instead. + +# A contract source in `prisma.config.ts` declares its format + +A config that builds `contract.source` itself, as an object with a `load` function, must now give it a `format`: `'psl'` when its inputs are PSL text, and `'typescript'` when it builds the contract in TypeScript, for example `source: { format: 'typescript', load: async () => ok(contract) }`. Without it, or with any other value, every command that reads the config fails with `CONFIG.VALIDATION_FAILED` on the field `contract.source.format`. Sources made by `defineConfig`, `prisma7Schema()`, `prismaContract()` and the TypeScript contract helpers already declare one. + +# A policy expression decodes every JSON escape + +A `using` or `withCheck` expression in a `policy_*` block of a PSL contract is a JSON string, the form `prisma contract print` and `prisma contract infer` write. The reader used to decode only `\n`, `\r`, `\"` and `\\`, and kept every other backslash sequence as written. It now also decodes `\t`, `\b`, `\f`, `\/` and `\uXXXX`, so `"a\tb"` reads as `a`, a tab and `b`. A backslash sequence that is not a JSON escape, such as `\d`, is still kept as written. + +If a PSL contract writes one of those five sequences in a policy expression and means the backslash and the letter, write the backslash twice (`\\t`), then run `prisma contract emit`. Otherwise the emitted policy, and the contract's storage hash, change. diff --git a/upgrade-instructions/pending/contract-print/extension/instructions.md b/upgrade-instructions/pending/contract-print/extension/instructions.md new file mode 100644 index 000000000000..368283a511a2 --- /dev/null +++ b/upgrade-instructions/pending/contract-print/extension/instructions.md @@ -0,0 +1,35 @@ +--- +changes: + - id: contract-source-format-is-psl-or-typescript + summary: Every contract source declares format 'psl' or 'typescript'; format is required, and OpaqueContractSourceProvider is removed. + - id: prisma7-schema-source-declares-psl + summary: The prisma7Schema() contract source declares format 'psl' instead of 'prisma7'. + - id: print-psl-description-option + summary: printPsl() from @internal/psl-printer opens every file with only the // use prisma-8 marker unless the caller passes description. + - id: family-sql-psl-build-export + summary: mapDefault, its option types, the PslTypeMap types and toEnumMemberName moved from @internal/family-sql/psl-infer to @internal/family-sql/psl-build. +--- + +# Contract source formats are PSL and TypeScript + +`ContractSourceProvider` from `@internal/config/config-types` is now the union of `PslContractSourceProvider` (`format: 'psl'`) and `TypeScriptContractSourceProvider` (`format: 'typescript'`). `format` is required on both. `OpaqueContractSourceProvider` and every other `format` value are gone, and config validation reports a source with no `format`, or any other value, as an issue on `contract.source.format`. + +Give every contract source an extension defines a `format`: `'psl'` when its inputs are PSL text, and `'typescript'` when it builds the contract in TypeScript. Replace imports of `OpaqueContractSourceProvider` with `ContractSourceProvider`. + +The source returned by `prisma7Schema()` now declares `format: 'psl'`, because a Prisma 7 schema is PSL text. It still does not implement `interpret`. Code that compared `source.format` with `'prisma7'` must stop doing so; nothing in the framework tells a Prisma 7 schema from a Prisma 8 one by its format. + +# `printPsl()` takes a description line + +`printPsl()` from `@internal/psl-printer` used to open every file with the `// use prisma-8` marker and a line saying the contract was inferred from the live database. It now writes only the marker, and a second comment line only when the caller passes `description`. Code that prints an inferred contract and wants the old second line passes it: + +```ts +printPsl(ast, { + pslBlockDescriptors, + description: + 'Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.', +}); +``` + +# PSL building blocks moved to `@internal/family-sql/psl-build` + +`contract print` uses some of what `@internal/family-sql/psl-infer` exported, so those exports moved to the new subpath `@internal/family-sql/psl-build`: `mapDefault`, `DefaultMappingOptions`, `DefaultMappingResult`, `PslTypeMap`, `PslTypeReference`, `PslTypeResolution` and `toEnumMemberName`. Import them from `@internal/family-sql/psl-build`. Everything else stays in `@internal/family-sql/psl-infer`. diff --git a/upgrade-instructions/releases/8.0.0-rc.11-to-8.0.0-rc.12/sources/data-types-column-defaults/extension/instructions.md b/upgrade-instructions/releases/8.0.0-rc.11-to-8.0.0-rc.12/sources/data-types-column-defaults/extension/instructions.md index 95584981ebd1..37a23bb819ab 100644 --- a/upgrade-instructions/releases/8.0.0-rc.11-to-8.0.0-rc.12/sources/data-types-column-defaults/extension/instructions.md +++ b/upgrade-instructions/releases/8.0.0-rc.11-to-8.0.0-rc.12/sources/data-types-column-defaults/extension/instructions.md @@ -265,7 +265,7 @@ Assembly refuses two entries claiming one tag or one plain form with `CONTRACT.D ## `map-default-takes-data-types` -`mapDefault` (`@internal/family-sql/psl-infer`) classifies the stored value with the same rules a written value uses, confirms the column's type takes it, prints it with the classified type's authoring entry, and reads the text straight back. `DefaultMappingOptions` lost `literalTypes` and gained: +`mapDefault` (`@internal/family-sql/psl-ast`) classifies the stored value with the same rules a written value uses, confirms the column's type takes it, prints it with the classified type's authoring entry, and reads the text straight back. `DefaultMappingOptions` lost `literalTypes` and gained: - `dataTypeEntries` — the stack's authoring entries, keyed by data type id; - `dataTypes` — a `DataTypeLookup` over the stack's types, whose casts say what each one takes;