From de5684af0df663645bba4ecc05492eab05034a70 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 10:39:58 +0200 Subject: [PATCH 01/55] test(sql-contract-prisma7): a printed Prisma 7 contract reads back as the same contract Table-driven round trip over every Prisma 7 fixture that produces a contract. The Postgres printer hook does not exist yet, so every case fails. The eight fixtures with a list column are marked expected-fail: a Prisma 7 list column is nullable, a PSL one is not, and the printer has no spelling for a nullable list. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../2-authoring/contract-prisma7/package.json | 1 + .../test/convert-roundtrip.test.ts | 117 ++++++++++++++++++ pnpm-lock.yaml | 3 + 3 files changed, 121 insertions(+) create mode 100644 packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts diff --git a/packages/2-sql/2-authoring/contract-prisma7/package.json b/packages/2-sql/2-authoring/contract-prisma7/package.json index effe88337efa..9471cec242e4 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/package.json +++ b/packages/2-sql/2-authoring/contract-prisma7/package.json @@ -34,6 +34,7 @@ "@internal/adapter-postgres": "workspace:8.0.0-rc.11", "@internal/driver-postgres": "workspace:8.0.0-rc.11", "@internal/family-sql": "workspace:8.0.0-rc.11", + "@internal/psl-printer": "workspace:8.0.0-rc.11", "@internal/target-postgres": "workspace:8.0.0-rc.11", "@repo/test-utils": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts new file mode 100644 index 000000000000..a97e57d57a49 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts @@ -0,0 +1,117 @@ +import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import type { ContractSourceContext } from '@internal/config/config-types'; +import type { Contract } from '@internal/contract/types'; +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 postgresTarget from '@internal/target-postgres/control'; +import postgresPackRef from '@internal/target-postgres/pack'; +import { prisma7PostgresBinding } from '@internal/target-postgres/prisma7-binding'; +import { PostgresContractSerializer } from '@internal/target-postgres/runtime'; +import { postgresCreateNamespace } from '@internal/target-postgres/types'; +import { dirname, join } from 'pathe'; +import { describe, expect, it } from 'vitest'; +import { prisma7Contract } from '../src/provider'; +import { postgresSourceContext } from './support'; + +const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), '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(); + +/** + * A list column is nullable in a Prisma 7 contract and not nullable in a PSL + * one, and the printer has no spelling for a nullable list (`Tag[]?`). + */ +const listColumnCases = new Set([ + 'dbgenerated-without-expression-optional', + 'defaults', + 'enum-native', + 'list-defaults', + 'native-types-accepted', + 'number-default-spellings', + 'number-defaults', + 'scalars', +]); + +function prisma7SchemaPath(caseName: string): string { + const directory = join(fixturesDir, caseName, 'schema'); + return existsSync(directory) ? directory : join(fixturesDir, caseName, 'schema.prisma'); +} + +async function loadThroughSource( + load: (context: ContractSourceContext) => Promise | unknown, + schemaPath: string, + what: string, +): Promise> { + const result = await load(postgresSourceContext([schemaPath])); + const outcome = result as + | { ok: true; value: unknown } + | { ok: false; failure: { diagnostics: unknown } }; + if (!outcome.ok) { + throw new Error(`${what} did not load: ${JSON.stringify(outcome.failure.diagnostics)}`); + } + return outcome.value as Contract; +} + +function serialize(contract: Contract): unknown { + return JSON.parse(JSON.stringify(new PostgresContractSerializer().serializeContract(contract))); +} + +async function roundTrip(caseName: string): Promise { + const schemaPath = prisma7SchemaPath(caseName); + const sourceContext = postgresSourceContext([schemaPath]); + const prisma7 = await loadThroughSource( + (context) => + prisma7Contract(schemaPath, { binding: prisma7PostgresBinding }).source.load(context), + schemaPath, + `Prisma 7 fixture "${caseName}"`, + ); + + const ast = postgresTarget.printPslContract?.(prisma7); + if (ast === undefined) { + throw new Error('the Postgres target descriptor has no printPslContract hook'); + } + const text = printPsl(ast, { + pslBlockDescriptors: sourceContext.authoringContributions.pslBlockDescriptors, + codecLookup: sourceContext.codecLookup, + }); + + const directory = mkdtempSync(join(tmpdir(), 'prisma7-convert-')); + const printedPath = join(directory, 'contract.prisma'); + writeFileSync(printedPath, text); + + const printedContract = await loadThroughSource( + (context) => + prismaContract(printedPath, { + target: postgresPackRef, + createNamespace: postgresCreateNamespace, + enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, + }).source.load(context), + printedPath, + `the PSL printed from "${caseName}"`, + ); + + expect(serialize(printedContract)).toEqual(serialize(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) { + if (listColumnCases.has(caseName)) { + it.fails(`${caseName} (a nullable list has no PSL spelling yet)`, async () => { + await roundTrip(caseName); + }); + continue; + } + it(caseName, async () => { + await roundTrip(caseName); + }); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccf1372bee1e..4ed56d24f361 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2501,6 +2501,9 @@ importers: '@internal/family-sql': specifier: workspace:8.0.0-rc.11 version: link:../../9-family + '@internal/psl-printer': + specifier: workspace:8.0.0-rc.11 + version: link:../../../1-framework/2-authoring/psl-printer '@internal/target-postgres': specifier: workspace:8.0.0-rc.11 version: link:../../../3-targets/3-targets/postgres From ce027e9b2073703a0389c9aa14d714790fde5bfc Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 11:06:37 +0200 Subject: [PATCH 02/55] feat(target-postgres): print a Postgres contract as the Prisma 8 PSL it reads back as A new optional target-descriptor hook, printPslContract, takes an assembled family contract and returns the PSL document the PSL source reads back as that same contract. It is plumbed like its sibling inferPslContract: a family-instance method that throws CONTRACT.CONVERT_UNSUPPORTED when the target lacks it, a framework capability probe, and a CLI control-client passthrough. Postgres implements it. The printer reuses the inference side's literal, index and enum-block builders. Uniques print as unique indexes, because that is what Prisma 7 lowers them to; every index is printed explicitly, so every relation asks for no backing index of its own. Execution generators join back onto their columns: an id generator as a @default call, a wall-clock-now pair as the temporal preset the column's codec is authored through. Two small changes on the read side make the round trip hold: a unique index now counts as uniqueness for a singular back-relation, as a unique constraint already did, and an index with no options but a type keeps its empty options map. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../src/control/control-capabilities.ts | 17 + .../src/exports/control.ts | 2 + .../3-tooling/cli/src/control-api/client.ts | 9 + .../3-tooling/cli/src/control-api/types.ts | 9 + .../test/convert-roundtrip.test.ts | 15 + .../contract-psl/src/exports/resolution.ts | 1 + .../contract-psl/src/interpreter.ts | 7 + .../contract-psl/src/psl-name-mapping.ts | 18 + .../9-family/src/core/control-instance.ts | 21 + .../src/core/control-target-descriptor.ts | 9 + packages/2-sql/9-family/src/core/errors.ts | 1 + .../3-targets/postgres/src/core/errors.ts | 1 + .../src/core/psl-infer/infer-enum-blocks.ts | 3 +- .../core/psl-infer/infer-index-attributes.ts | 23 +- .../core/psl-print/contract-model-index.ts | 73 ++++ .../core/psl-print/print-column-default.ts | 58 +++ .../src/core/psl-print/print-column-type.ts | 82 ++++ .../src/core/psl-print/print-enum-blocks.ts | 47 +++ .../psl-print/print-execution-defaults.ts | 111 +++++ .../src/core/psl-print/print-psl-contract.ts | 385 ++++++++++++++++++ .../core/psl-print/print-relation-fields.ts | 195 +++++++++ .../3-targets/postgres/src/exports/control.ts | 4 + 22 files changed, 1085 insertions(+), 6 deletions(-) create mode 100644 packages/2-sql/2-authoring/contract-psl/src/psl-name-mapping.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/contract-model-index.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts 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..b310a16e192b 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 @@ -53,6 +53,23 @@ export function hasPslContractInfer( ); } +/** + * Capability declaring that a family can print a contract as a PSL document + * AST. Consumed by `prisma contract convert`. + */ +export interface PslContractPrintCapable { + printPslContract(contract: TContract): PslDocumentAst; +} + +export function hasPslContractPrint( + instance: ControlFamilyInstance, +): instance is ControlFamilyInstance & PslContractPrintCapable { + return ( + 'printPslContract' in instance && + typeof (instance as Record)['printPslContract'] === '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/exports/control.ts b/packages/1-framework/1-core/framework-components/src/exports/control.ts index 231f7f70a8a2..aca3a99f87f2 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 @@ -11,6 +11,7 @@ export type { MigratableTargetDescriptor, OperationPreviewCapable, PslContractInferCapable, + PslContractPrintCapable, SchemaSubjectClassifierCapable, SchemaViewCapable, } from '../control/control-capabilities'; @@ -18,6 +19,7 @@ export { hasMigrations, hasOperationPreview, hasPslContractInfer, + hasPslContractPrint, hasSchemaSubjectClassifier, hasSchemaView, } from '../control/control-capabilities'; 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 b6402f4852ea..483bfc6f6c07 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 @@ -21,6 +21,7 @@ import { hasMigrations, hasOperationPreview, hasPslContractInfer, + hasPslContractPrint, hasSchemaView, } from '@internal/framework-components/control'; import { @@ -593,6 +594,14 @@ class ControlClientImpl implements ControlClient { return undefined; } + printPslContract(contract: unknown): PslDocumentAst | undefined { + this.init(); + if (this.familyInstance && hasPslContractPrint(this.familyInstance)) { + return this.familyInstance.printPslContract(contract); + } + return undefined; + } + getPslBlockDescriptors(): AuthoringPslBlockDescriptorNamespace { this.init(); return this.stack!.authoringContributions.pslBlockDescriptors; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/types.ts b/packages/1-framework/3-tooling/cli/src/control-api/types.ts index 01e92f0fcbb8..6790bdefdcf8 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/types.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/types.ts @@ -1008,6 +1008,15 @@ export interface ControlClient { */ inferPslContract(schemaIR: unknown): PslDocumentAst | undefined; + /** + * Prints a family contract as a PSL contract AST. + * Delegates to the family instance's printPslContract method. + * + * @param contract - The family contract to print + * @returns PslDocumentAst if the family supports the capability, undefined otherwise + */ + printPslContract(contract: unknown): PslDocumentAst | undefined; + /** * Returns the assembled PSL block descriptors from the control stack — the full * set of extension-contributed top-level block descriptors. The CLI's diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts index a97e57d57a49..7e8683fc5268 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts @@ -40,6 +40,15 @@ const listColumnCases = new Set([ 'scalars', ]); +/** + * The Prisma 8 PSL source resolves a relation's target by model name alone, so + * two models sharing a name in different namespaces cannot be printed. + */ +const duplicateModelNameCases = new Set([ + 'junction-name-in-other-schema', + 'relation-name-in-two-schemas', +]); + function prisma7SchemaPath(caseName: string): string { const directory = join(fixturesDir, caseName, 'schema'); return existsSync(directory) ? directory : join(fixturesDir, caseName, 'schema.prisma'); @@ -110,6 +119,12 @@ describe('a printed Prisma 7 contract reads back as the same contract', () => { }); continue; } + if (duplicateModelNameCases.has(caseName)) { + it.fails(`${caseName} (one model name in two namespaces has no PSL spelling)`, async () => { + await roundTrip(caseName); + }); + continue; + } it(caseName, async () => { await roundTrip(caseName); }); diff --git a/packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts index 1aaaf53eec8e..bfabbcf42572 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts @@ -5,6 +5,7 @@ export { type ResolveFieldTypeResult, resolveFieldTypeDescriptor, } from '../psl-column-resolution'; +export { pslFieldMapName, pslModelMapName } from '../psl-name-mapping'; export { applyBackrelationCandidates, type FkRelationMetadata, 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 1eae38a1d659..c5f803c17559 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -2527,6 +2527,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..af5070cf3d2f --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-name-mapping.ts @@ -0,0 +1,18 @@ +import { lowerFirst } from './psl-attribute-parsing'; + +/** + * 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 spells it. + */ +export function pslModelMapName(modelName: string, tableName: string): string | undefined { + return tableName === lowerFirst(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 spells it. + */ +export function pslFieldMapName(fieldName: string, columnName: string): string | undefined { + return columnName === fieldName ? undefined : columnName; +} 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 4f7ab70a3a3d..95a2adebef0d 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -582,6 +582,12 @@ export function createSqlFamilyInstance( SqlControlTargetDescriptor, 'reading the optional target-descriptor inferPslContract hook' >(target).inferPslContract; + // Contract→PSL printing is target logic for the same reason, and is read the + // same way. Absent for targets without `contract convert`. + const targetPrintPslContract = blindCast< + SqlControlTargetDescriptor, + 'reading the optional target-descriptor printPslContract hook' + >(target).printPslContract; // 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. @@ -1013,6 +1019,21 @@ export function createSqlFamilyInstance( return targetInferPslContract(schemaIR, describedContracts); }, + printPslContract(contract: Contract): PslDocumentAst { + if (!targetPrintPslContract) { + throw sqlFamilyError( + 'CONTRACT.CONVERT_UNSUPPORTED', + `Target "${target.targetId}" does not support contract convert (no printPslContract on its descriptor).`, + { + why: 'The target descriptor does not provide the printPslContract hook, so the contract cannot be printed as a Prisma 8 PSL file.', + fix: 'Use a target package that supports contract convert, or author the contract instead of converting it.', + meta: { targetId: target.targetId }, + }, + ); + } + return targetPrintPslContract(contract); + }, + 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..fb6eb3afb431 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 @@ -62,6 +62,15 @@ export interface SqlControlTargetDescriptor< schema: SqlSchemaIRNode, describedContracts?: readonly SqlDescribedContractSpace[], ) => PslDocumentAst; + /** + * Contract→PSL printing for `contract convert`. The reverse of + * {@link inferPslContract}: it takes an already-assembled family contract and + * returns the PSL document that reads back as the same contract. Target logic + * for the same reason — it owns the dialect maps — so it lives beside its + * sibling on the descriptor. Optional: targets without `contract convert` + * omit it, and the family instance throws when it is absent. + */ + readonly printPslContract?: (contract: TContract) => 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..7cd4372a6e16 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.CONVERT_UNSUPPORTED' | 'CONTRACT.FOREIGN_KEY_INVALID' | 'CONTRACT.INFER_UNSUPPORTED' | 'CONTRACT.MARKER_ROW_CORRUPT' 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 f3853d77bc99..e0ae423eb34b 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.CONVERT_UNSUPPORTED' | 'CONTRACT.DEFAULT_INVALID' | 'CONTRACT.ENTITY_KIND_INVALID' | 'CONTRACT.IDENTIFIER_INVALID' diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts index b439ebffba22..9772adbc8eec 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 @@ -66,7 +66,8 @@ export function buildNativeEnumBlocks( return { enumNameMap, enumBlocks }; } -function buildNativeEnumBlock( +/** 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[], diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts index 108f809ee6d5..599c21d42762 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts @@ -3,7 +3,7 @@ import type { PslModelAttribute, } from '@internal/framework-components/psl-ast'; import { computeIndexContentHash, parseWireName } from '@internal/sql-schema-ir/naming'; -import type { SqlCheckConstraintIR, SqlIndexIR } from '@internal/sql-schema-ir/types'; +import type { SqlCheckConstraintIR } from '@internal/sql-schema-ir/types'; import { assertDefined } from '@internal/utils/assertions'; import { buildAttribute, escapePslString, namedArg, positionalArg } from './psl-literals'; @@ -19,6 +19,20 @@ export function buildModelConstraintAttribute( 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; +} + /** * 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 @@ -26,7 +40,7 @@ export function buildModelConstraintAttribute( * content; otherwise the live name is adopted verbatim with `map:`. */ export function buildIndexAttribute( - index: SqlIndexIR, + index: IndexAttributeSource, fieldNames: readonly string[] | undefined, ): PslModelAttribute { const args: PslAttributeArgument[] = []; @@ -61,11 +75,10 @@ export function buildIndexAttribute( if (index.unique) { args.push(namedArg('unique', 'true')); } - const hasOptions = index.options !== undefined && Object.keys(index.options).length > 0; - if (index.type !== undefined || hasOptions) { + if (index.type !== undefined || index.options !== undefined) { args.push(namedArg('type', `"${escapePslString(index.type ?? 'btree')}"`)); } - if (hasOptions) { + 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))}"`); 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..8952ec1cee9d --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/contract-model-index.ts @@ -0,0 +1,73 @@ +import type { Contract, ContractModelBase, CrossReference } from '@internal/contract/types'; +import type { SqlModelStorage, SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { blindCast } from '@internal/utils/casts'; +import { postgresError } from '../errors'; + +/** One domain model paired with the storage table it is bridged to. */ +export interface ModelEntry { + 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; +} + +export function modelCoordinate(namespaceId: string, modelName: string): string { + return `${namespaceId}\u0000${modelName}`; +} + +/** Every domain model of the contract, in declaration order, with its storage table. */ +export function indexContractModels(contract: Contract): readonly ModelEntry[] { + const entries: ModelEntry[] = []; + 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]; + if (table === undefined) { + throw postgresError( + 'CONTRACT.MODEL_UNKNOWN', + `contract convert: model "${namespaceId}.${name}" is bridged to table "${storage.namespaceId}"."${storage.table}", which the contract's storage does not declare.`, + { + why: "The printer reads each model's columns, keys and indexes off its storage table.", + fix: 'Re-emit the contract from its source.', + meta: { namespaceId, modelName: name, table: storage.table }, + }, + ); + } + entries.push({ + namespaceId: storage.namespaceId, + name, + tableName: storage.table, + storage, + model, + table, + fieldNamesByColumn: new Map( + Object.entries(storage.fields).map(([fieldName, field]) => [field.column, fieldName]), + ), + }); + } + } + return entries; +} + +/** Models keyed by their `(namespace, model)` coordinate. */ +export function modelsByCoordinate( + entries: readonly ModelEntry[], +): ReadonlyMap { + return new Map(entries.map((entry) => [modelCoordinate(entry.namespaceId, entry.name), entry])); +} + +/** Models keyed by their `(namespace, table)` coordinate. */ +export function modelsByTable(entries: readonly ModelEntry[]): ReadonlyMap { + return new Map( + entries.map((entry) => [modelCoordinate(entry.namespaceId, entry.tableName), entry]), + ); +} + +export function crossReferenceCoordinate(reference: CrossReference): string { + return modelCoordinate(reference.namespace, reference.model); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts new file mode 100644 index 000000000000..e2e4da433752 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts @@ -0,0 +1,58 @@ +import type { PslFieldAttribute } from '@internal/framework-components/psl-ast'; +import type { StorageColumn } from '@internal/sql-contract/types'; +import { + buildAttribute, + escapePslString, + formatPslListLiteralValue, + formatPslValue, + type PslDefaultValueFormat, + positionalArg, + pslDefaultValueFormat, +} from '../psl-infer/psl-literals'; + +/** The two function defaults Prisma 8 PSL spells by name; every other one is `dbgenerated`. */ +const NAMED_FUNCTION_DEFAULTS = new Set(['now()', 'autoincrement()']); + +/** + * A value the field's own literal format declines — a JSON object, for + * instance — still has to reach the file as something PSL parses, so it goes + * as the JSON text of the value. + */ +const jsonText: PslDefaultValueFormat = (value) => formatPslValue(JSON.stringify(value)); + +/** + * The `@default(…)` attribute for a storage column, or `undefined` when the + * column carries no default. + * + * A literal prints as the PSL literal its codec reads back, with a number + * printing unquoted from its decimal text; `now()` and `autoincrement()` print + * by name; every other function default prints as `dbgenerated("…")`. This is + * the one place the `dbgenerated` spelling is decided. + */ +export function printColumnDefault(input: { + readonly column: StorageColumn; + readonly pslTypeName: string; + readonly isEnum: boolean; +}): PslFieldAttribute | undefined { + const columnDefault = input.column.default; + if (columnDefault === undefined) { + return undefined; + } + + if (columnDefault.kind === 'function') { + const { expression } = columnDefault; + const argument = NAMED_FUNCTION_DEFAULTS.has(expression) + ? expression + : `dbgenerated("${escapePslString(expression)}")`; + return buildAttribute('field', 'default', [positionalArg(argument)]); + } + + const format = input.isEnum ? formatPslValue : pslDefaultValueFormat(input.pslTypeName); + const { value } = columnDefault; + const literal = Array.isArray(value) + ? (formatPslListLiteralValue(value, format) ?? formatPslListLiteralValue(value, jsonText)) + : (format(value) ?? jsonText(value)); + return buildAttribute('field', 'default', [ + positionalArg(literal ?? `"${escapePslString(String(value))}"`), + ]); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts new file mode 100644 index 000000000000..cbdfaff6d019 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts @@ -0,0 +1,82 @@ +import type { PslTypeMap } from '@internal/family-sql/psl-infer'; +import type { PslTypeConstructorCall } from '@internal/framework-components/psl-ast'; +import type { StorageColumn } from '@internal/sql-contract/types'; +import { postgresError } from '../errors'; +import { positionalArg, SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; + +/** 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(', ')})`; +} + +/** + * The PSL type position for a storage column, from the reverse of the type map + * `contract infer` uses. An enum-typed column takes the `pg.enum()` + * constructor, named after the value set the column points at. + */ +export function printColumnType(input: { + readonly column: StorageColumn; + readonly typeMap: PslTypeMap; + readonly enumBlockNames: ReadonlyMap; + readonly coordinate: string; +}): PslColumnType { + const { column, typeMap, enumBlockNames, coordinate } = input; + + const enumBlockName = column.valueSet?.entityName ?? enumBlockNames.get(column.nativeType); + if (column.codecId === 'pg/enum@1' && enumBlockName !== undefined) { + return { + typeName: enumBlockName, + typeConstructor: { + kind: 'typeConstructor', + path: ['pg', 'enum'], + args: [positionalArg(enumBlockName)], + span: SYNTHETIC_SPAN, + }, + }; + } + + const resolution = typeMap.resolve(nativeTypeText(column)); + if ('unsupported' in resolution) { + throw postgresError( + 'CONTRACT.CONVERT_UNSUPPORTED', + `contract convert: column ${coordinate} has native type "${column.nativeType}", which has no Prisma 8 PSL spelling.`, + { + why: 'The Postgres contract-to-PSL printer maps each native type back to the PSL type that produces it; this one is not in that map.', + fix: 'Retype the column, or author the Prisma 8 contract by hand.', + meta: { coordinate, nativeType: column.nativeType }, + }, + ); + } + + const { name, args } = resolution.pslType; + if (args === undefined) { + return { typeName: name }; + } + return { + typeName: name, + typeConstructor: { + kind: 'typeConstructor', + path: [name], + args: args.map(positionalArg), + span: SYNTHETIC_SPAN, + }, + }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts new file mode 100644 index 000000000000..0e473646c926 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts @@ -0,0 +1,47 @@ +import type { PslExtensionBlock } from '@internal/framework-components/psl-ast'; +import type { StorageColumn } from '@internal/sql-contract/types'; +import type { PostgresNativeEnum } from '../postgres-native-enum'; +import { buildNativeEnumBlock } from '../psl-infer/infer-enum-blocks'; + +export interface NativeEnumEmission { + readonly blocks: readonly PslExtensionBlock[]; + /** Native type name, bare and schema-qualified, → the `native_enum` block that declares it. */ + readonly blockNamesByTypeName: ReadonlyMap; +} + +/** + * Builds one `native_enum` block per enum type a namespace declares. + * + * The block's name is the name of the value set the enum derives, because that + * is what a column referring to the enum points at: a column typed by the enum + * names it directly, and an enum no column uses keeps the name its value set + * carries. `@@map` carries the physical type name whenever the two differ. + */ +export function buildNativeEnumBlocksForNamespace(input: { + readonly namespaceId: string; + readonly nativeEnums: ReadonlyMap; + readonly valueSetNames: ReadonlySet; + 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 blocks: PslExtensionBlock[] = []; + const blockNamesByTypeName = new Map(); + for (const [entryName, nativeEnum] of input.nativeEnums) { + const { typeName } = nativeEnum; + const qualifiedTypeName = `${input.namespaceId}.${typeName}`; + const blockName = + valueSetNamesByTypeName.get(typeName) ?? + valueSetNamesByTypeName.get(qualifiedTypeName) ?? + (input.valueSetNames.has(entryName) ? entryName : typeName); + blocks.push(buildNativeEnumBlock(blockName, typeName, nativeEnum.members)); + blockNamesByTypeName.set(typeName, blockName); + blockNamesByTypeName.set(qualifiedTypeName, blockName); + } + return { blocks, blockNamesByTypeName }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts new file mode 100644 index 000000000000..4174bda62b27 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts @@ -0,0 +1,111 @@ +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 { postgresError } from '../errors'; +import { postgresNowGeneratorIdFor } from '../now-generators'; +import { buildAttribute, namedArg, positionalArg } from '../psl-infer/psl-literals'; + +/** The `temporal.*` preset each codec with a "now" generator is authored through. */ +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. */ +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 PrintedExecutionDefault = + | { readonly kind: 'attribute'; readonly attribute: PslFieldAttribute } + | { readonly kind: 'temporal'; readonly phases: TemporalPresetPhases }; + +/** + * The PSL spelling of one column's execution generators: an id generator + * prints as a `@default(())` attribute, and a wall-clock-now generator + * prints as the `temporal.*` preset the column's codec is authored through. + */ +export function printExecutionDefault(input: { + readonly executionDefault: ExecutionMutationDefault; + readonly codecId: string; + readonly coordinate: string; +}): PrintedExecutionDefault { + 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) { + return { kind: 'temporal', phases: { presetName, onCreate, onUpdate } }; + } + } + + const call = onCreateId === undefined ? undefined : GENERATOR_CALLS.get(onCreateId); + if (call === undefined || onUpdateId !== undefined) { + throw postgresError( + 'CONTRACT.CONVERT_UNSUPPORTED', + `contract convert: column ${coordinate} carries generated value "${onCreateId ?? onUpdateId}", which has no Prisma 8 PSL spelling.`, + { + why: 'The Postgres contract-to-PSL printer knows one authored spelling per generator; this generator is not among them.', + fix: 'Author the Prisma 8 contract by hand for this column.', + meta: { coordinate, onCreate: onCreateId, onUpdate: onUpdateId }, + }, + ); + } + 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/print-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts new file mode 100644 index 000000000000..5786dc2c6d3a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts @@ -0,0 +1,385 @@ +import type { Contract, ExecutionMutationDefault } from '@internal/contract/types'; +import type { + PslDocumentAst, + PslExtensionBlock, + PslField, + PslFieldAttribute, + PslModel, + PslModelAttribute, + PslNamespace, +} from '@internal/framework-components/psl-ast'; +import { makePslNamespace, makePslNamespaceEntries } from '@internal/framework-components/psl-ast'; +import type { SqlStorage, StorageColumn } from '@internal/sql-contract/types'; +import { pslFieldMapName, pslModelMapName } from '@internal/sql-contract-psl/resolution'; +import { ifDefined } from '@internal/utils/defined'; +import { postgresError } from '../errors'; +import { PostgresNativeEnum } from '../postgres-native-enum'; +import { + buildIndexAttribute, + buildModelConstraintAttribute, +} from '../psl-infer/infer-index-attributes'; +import { createPostgresTypeMap } from '../psl-infer/postgres-type-map'; +import { + buildAttribute, + buildMapAttribute, + positionalArg, + SYNTHETIC_SPAN, +} from '../psl-infer/psl-literals'; +import { + indexContractModels, + type ModelEntry, + modelCoordinate, + modelsByCoordinate, +} from './contract-model-index'; +import { printColumnDefault } from './print-column-default'; +import { printColumnType } from './print-column-type'; +import { buildNativeEnumBlocksForNamespace, type NativeEnumEmission } from './print-enum-blocks'; +import { printExecutionDefault, temporalPresetArguments } from './print-execution-defaults'; +import { + collectRelations, + isOwningRelation, + junctionParentRelation, + owningPartner, + printRelationField, + type RelationEntry, + relationKey, + relationName, +} from './print-relation-fields'; + +function executionDefaultsByColumn( + contract: Contract, +): ReadonlyMap { + const byColumn = new Map(); + for (const entry of contract.execution?.mutations.defaults ?? []) { + byColumn.set(`${entry.ref.namespace}\u0000${entry.ref.table}\u0000${entry.ref.column}`, entry); + } + return byColumn; +} + +function scalarFieldAttributes(input: { + readonly column: StorageColumn; + readonly fieldName: string; + readonly columnName: string; + readonly isSingleColumnId: boolean; + readonly pslTypeName: string; + readonly isEnum: boolean; + readonly generatedDefault: PslFieldAttribute | undefined; +}): readonly PslFieldAttribute[] { + const attributes: PslFieldAttribute[] = []; + if (input.isSingleColumnId) { + attributes.push(buildAttribute('field', 'id', [])); + } + const columnDefault = + input.generatedDefault ?? + printColumnDefault({ + column: input.column, + pslTypeName: input.pslTypeName, + isEnum: input.isEnum, + }); + 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; +} + +function buildScalarFields(input: { + readonly entry: ModelEntry; + readonly enums: NativeEnumEmission; + readonly executionDefaults: ReadonlyMap; +}): readonly PslField[] { + const { entry, enums, executionDefaults } = input; + const typeMap = createPostgresTypeMap(); + const primaryKeyColumns = entry.table.primaryKey?.columns ?? []; + const fields: PslField[] = []; + + for (const [fieldName, fieldStorage] of Object.entries(entry.storage.fields)) { + const columnName = fieldStorage.column; + const column = entry.table.columns[columnName]; + if (column === undefined) continue; + const coordinate = `"${entry.namespaceId}"."${entry.tableName}"."${columnName}"`; + + const columnType = printColumnType({ + column, + typeMap, + enumBlockNames: enums.blockNamesByTypeName, + coordinate, + }); + let { typeName, typeConstructor } = columnType; + + const execution = executionDefaults.get( + `${entry.namespaceId}\u0000${entry.tableName}\u0000${columnName}`, + ); + let generatedDefault: PslFieldAttribute | undefined; + if (execution !== undefined) { + const printed = printExecutionDefault({ + executionDefault: execution, + codecId: column.codecId, + coordinate, + }); + if (printed.kind === 'temporal') { + typeName = `temporal.${printed.phases.presetName}`; + typeConstructor = { + kind: 'typeConstructor', + path: ['temporal', printed.phases.presetName], + args: temporalPresetArguments({ + phases: printed.phases, + precision: column.typeParams?.['precision'], + }), + span: SYNTHETIC_SPAN, + }; + } else { + generatedDefault = printed.attribute; + } + } + + fields.push({ + kind: 'field', + name: fieldName, + typeName, + ...ifDefined('typeConstructor', typeConstructor), + optional: column.nullable, + list: column.many === true, + attributes: scalarFieldAttributes({ + column, + fieldName, + columnName, + isSingleColumnId: primaryKeyColumns.length === 1 && primaryKeyColumns[0] === columnName, + pslTypeName: columnType.typeName, + isEnum: column.codecId === 'pg/enum@1', + generatedDefault, + }), + span: SYNTHETIC_SPAN, + }); + } + + return fields; +} + +function buildModelAttributes(entry: ModelEntry): readonly PslModelAttribute[] { + const attributes: PslModelAttribute[] = []; + const primaryKeyColumns = entry.table.primaryKey?.columns ?? []; + if (primaryKeyColumns.length > 1) { + attributes.push( + buildModelConstraintAttribute( + 'id', + primaryKeyColumns.map((column) => entry.fieldNamesByColumn.get(column) ?? column), + ), + ); + } + for (const index of entry.table.indexes) { + attributes.push( + buildIndexAttribute( + index, + index.columns?.map((column) => entry.fieldNamesByColumn.get(column) ?? column), + ), + ); + } + const mapName = pslModelMapName(entry.name, entry.tableName); + if (mapName !== undefined) { + attributes.push(buildMapAttribute('model', mapName)); + } + return attributes; +} + +/** + * 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. + */ +function resolvePinnedRelations( + relations: readonly RelationEntry[], + relationsByModel: ReadonlyMap, +): ReadonlySet { + const pairCounts = new Map(); + for (const entry of relations) { + const key = `${modelCoordinate(entry.owner.namespaceId, entry.owner.name)}\u0000${entry.targetCoordinate}`; + pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1); + } + const ambiguous = (entry: RelationEntry): boolean => { + const ownerCoordinate = modelCoordinate(entry.owner.namespaceId, entry.owner.name); + const forward = pairCounts.get(`${ownerCoordinate}\u0000${entry.targetCoordinate}`) ?? 0; + const backward = pairCounts.get(`${entry.targetCoordinate}\u0000${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: ModelEntry, + relations: readonly RelationEntry[], +): readonly RelationEntry[] { + 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)); +} + +function buildRelationFields(input: { + readonly entry: ModelEntry; + readonly relations: readonly RelationEntry[]; + readonly relationsByModel: ReadonlyMap; + readonly modelsByCoordinate: ReadonlyMap; + readonly pinned: ReadonlySet; +}): readonly PslField[] { + const fields: PslField[] = []; + for (const relationEntry of orderedByForeignKey(input.entry, input.relations)) { + const target = input.modelsByCoordinate.get(relationEntry.targetCoordinate); + if (target === undefined) continue; + const owning = isOwningRelation(relationEntry) + ? relationEntry + : relationEntry.relation.cardinality === 'N:M' + ? junctionParentRelation(relationEntry, input.relationsByModel) + : owningPartner(relationEntry, input.relationsByModel); + const name = + owning !== undefined && input.pinned.has(relationKey(owning)) + ? relationName(owning) + : undefined; + fields.push(printRelationField({ entry: relationEntry, target, name })); + } + return fields; +} + +/** + * The Prisma 8 PSL source resolves a relation's target by model name alone, so + * two models sharing a name in different namespaces would be read as one. + */ +function refuseDuplicateModelNames(models: readonly ModelEntry[]): 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) { + throw postgresError( + 'CONTRACT.CONVERT_UNSUPPORTED', + `contract convert: model "${name}" is declared in more than one namespace (${namespaces.join(', ')}), which Prisma 8 PSL cannot express: it resolves a relation's target by model name alone.`, + { + why: 'Two models sharing a name would be read back as one model, so the relations between them would resolve to the wrong tables.', + fix: 'Rename one of the models before converting.', + meta: { modelName: name, namespaces }, + }, + ); + } + } +} + +/** + * Prints the Prisma 8 PSL document a Postgres contract was, or would have + * been, authored as: every model with its columns, keys, indexes and + * relations, and every native enum, in the namespace block that carries it. + * + * Read back by the PSL contract source, the document yields the contract it + * was printed from. + */ +export function printPostgresPslContract(contract: Contract): PslDocumentAst { + const models = indexContractModels(contract); + refuseDuplicateModelNames(models); + const byCoordinate = modelsByCoordinate(models); + const relations = collectRelations(models); + const relationsByModel = new Map(); + for (const entry of relations) { + const key = modelCoordinate(entry.owner.namespaceId, entry.owner.name); + const bucket = relationsByModel.get(key) ?? []; + bucket.push(entry); + relationsByModel.set(key, bucket); + } + const pinned = resolvePinnedRelations(relations, relationsByModel); + const executionDefaults = executionDefaultsByColumn(contract); + + const namespaces: PslNamespace[] = []; + for (const [namespaceId, namespace] of Object.entries(contract.storage.namespaces)) { + const nativeEnums = new Map(); + for (const [name, entity] of Object.entries(namespace.entries['native_enum'] ?? {})) { + if (PostgresNativeEnum.is(entity)) { + nativeEnums.set(name, entity); + } + } + const enums = buildNativeEnumBlocksForNamespace({ + namespaceId, + nativeEnums, + valueSetNames: new Set(Object.keys(namespace.entries.valueSet ?? {})), + columns: models + .filter((entry) => entry.namespaceId === namespaceId) + .flatMap((entry) => Object.values(entry.table.columns)), + }); + + const namespaceModels: PslModel[] = []; + for (const entry of models) { + if (entry.namespaceId !== namespaceId) continue; + namespaceModels.push({ + kind: 'model', + name: entry.name, + fields: [ + ...buildScalarFields({ entry, enums, executionDefaults }), + ...buildRelationFields({ + entry, + relations: relationsByModel.get(modelCoordinate(namespaceId, entry.name)) ?? [], + relationsByModel, + modelsByCoordinate: byCoordinate, + pinned, + }), + ], + attributes: buildModelAttributes(entry), + span: SYNTHETIC_SPAN, + }); + } + + const blocks: readonly PslExtensionBlock[] = enums.blocks; + if (namespaceModels.length === 0 && blocks.length === 0) continue; + namespaces.push( + makePslNamespace({ + kind: 'namespace', + name: namespaceId, + entries: makePslNamespaceEntries(namespaceModels, [], blocks), + span: SYNTHETIC_SPAN, + }), + ); + } + + return { + kind: 'document', + sourceId: '', + namespaces, + span: SYNTHETIC_SPAN, + }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts new file mode 100644 index 000000000000..fbaa2290e868 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts @@ -0,0 +1,195 @@ +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 { ifDefined } from '@internal/utils/defined'; +import { postgresError } from '../errors'; +import { + buildAttribute, + escapePslString, + namedArg, + SYNTHETIC_SPAN, +} from '../psl-infer/psl-literals'; +import { crossReferenceCoordinate, type ModelEntry, modelCoordinate } from './contract-model-index'; + +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 RelationEntry { + readonly owner: ModelEntry; + readonly fieldName: string; + readonly relation: ContractReferenceRelation; + readonly targetCoordinate: string; +} + +function isReferenceRelation(relation: ContractRelation): relation is ContractReferenceRelation { + return 'on' in relation; +} + +export function collectRelations(models: readonly ModelEntry[]): readonly RelationEntry[] { + const entries: RelationEntry[] = []; + for (const owner of models) { + for (const [fieldName, relation] of Object.entries(owner.model.relations)) { + if (!isReferenceRelation(relation)) continue; + 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: RelationEntry): boolean { + return entry.relation.cardinality === 'N:1'; +} + +export function relationKey(entry: RelationEntry): string { + return `${modelCoordinate(entry.owner.namespaceId, entry.owner.name)}\u0000${entry.fieldName}`; +} + +/** The relation name a pair is pinned with when the model pair carries more than one relation. */ +export function relationName(entry: RelationEntry): string { + return `${entry.owner.name}_${entry.fieldName}`; +} + +function columnsOf(entry: RelationEntry, fieldNames: readonly string[]): readonly string[] { + return fieldNames.map((fieldName) => entry.owner.storage.fields[fieldName]?.column ?? fieldName); +} + +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: RelationEntry, target: ModelEntry): ForeignKey | undefined { + const localColumns = columnsOf(entry, entry.relation.on.localFields); + return entry.owner.table.foreignKeys.find( + (fk) => + fk.target.tableName === target.tableName && + fk.target.namespaceId === target.namespaceId && + sameColumns(fk.source.columns, localColumns), + ); +} + +/** + * The owning relation on `target` that pairs with a back-relation `entry`: the + * one pointing back at `entry`'s model over the same columns. + */ +export function owningPartner( + entry: RelationEntry, + relationsByModel: ReadonlyMap, +): RelationEntry | 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), + columnsOf(entry, 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: RelationEntry, + relationsByModel: ReadonlyMap, +): RelationEntry | 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 prints as. */ +export function printRelationField(input: { + readonly entry: RelationEntry; + readonly target: ModelEntry; + readonly name: string | undefined; +}): PslField { + const { entry, target, name } = input; + const { relation } = entry; + const typeNamespaceId = + target.namespaceId === entry.owner.namespaceId ? undefined : target.namespaceId; + + const args: PslAttributeArgument[] = []; + if (name !== undefined) { + args.push(namedArg('name', `"${escapePslString(name)}"`)); + } + + if (relation.cardinality === 'N:1') { + const foreignKey = foreignKeyFor(entry, target); + if (foreignKey === undefined) { + throw postgresError( + 'CONTRACT.CONVERT_UNSUPPORTED', + `contract convert: relation "${entry.owner.name}.${entry.fieldName}" has no foreign key in storage, which Prisma 8 PSL cannot express.`, + { + why: 'A to-one relation is authored as `@relation(fields:…, references:…)`, which always lowers to a foreign key.', + fix: 'Author the Prisma 8 contract by hand for this relation.', + meta: { model: entry.owner.name, field: entry.fieldName }, + }, + ); + } + args.push(namedArg('fields', `[${relation.on.localFields.join(', ')}]`)); + args.push(namedArg('references', `[${relation.on.targetFields.join(', ')}]`)); + args.push( + namedArg('onDelete', PSL_REFERENTIAL_ACTIONS[foreignKey.onDelete ?? 'noAction']), + namedArg('onUpdate', PSL_REFERENTIAL_ACTIONS[foreignKey.onUpdate ?? 'noAction']), + ); + if (foreignKey.name !== undefined) { + args.push(namedArg('map', `"${escapePslString(foreignKey.name)}"`)); + } + // Every index the contract carries is printed 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, + }; +} 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 65fd945e7d1f..303ff5f5a315 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 { printPostgresPslContract } from '../core/psl-print/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); }, + printPslContract(contract) { + return printPostgresPslContract(contract); + }, diffSchema(input) { return diffPostgresSchema(input); }, From d8ce511b016b11324cf4b66ebacaddebdd42bc09 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 11:20:04 +0200 Subject: [PATCH 03/55] docs(target-postgres): describe the contract-to-PSL printer and its error code Unit tests for the rules the printer owns on its own: the map rule, a unique printed as an index, each kind of column default, the generator join, and the referential actions on a relation. README lines in the Postgres and SQL family packages, and the CONTRACT.CONVERT_UNSUPPORTED entry in the error reference. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- docs/reference/error-reference.md | 4 + .../src/control/control-capabilities.ts | 2 +- .../src/control-api/testing/fixture-client.ts | 6 + packages/2-sql/9-family/README.md | 3 + .../9-family/src/core/control-instance.ts | 2 + .../3-targets/3-targets/postgres/README.md | 1 + .../test/psl-print/print-psl-contract.test.ts | 362 ++++++++++++++++++ 7 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 6aff6cfc4c7b..45d34ce6d989 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -245,6 +245,10 @@ 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.CONVERT_UNSUPPORTED + +`contract convert` cannot print the loaded contract as a Prisma 8 PSL file: either the configured target has no `printPslContract` hook on its descriptor (meta: `targetId`), or the contract holds something Prisma 8 PSL has no spelling for — a column whose native type is outside the target's type map, a generated value the target cannot author, a to-one relation with no foreign key behind it, or one model name declared in more than one namespace (a relation's target is resolved by model name alone, so the two would read back as one). Meta at the shape sites: `coordinate`, `nativeType`, `onCreate`, `onUpdate`, `model`, `field`, `modelName`, `namespaces`. + ### CONTRACT.DEFAULT_INVALID A field's default declaration is invalid: `defaultSql` is used on an enum field, a field declares both `default` and `executionDefaults`, or a field is nullable while carrying `executionDefaults`. Raised while authoring/building a SQL contract. Payload: `modelName`, `fieldName`, `reason`. Also raised by the Postgres adapter's DDL renderer when a hand-authored `col(...)` pairs an `autoincrement()` default with a type that isn't `SERIAL`/`BIGSERIAL`/`SMALLSERIAL` (or their `SERIAL4`/`SERIAL8`/`SERIAL2` aliases). Meta in that case: `nativeType`. 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 b310a16e192b..aa1810fc345f 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 @@ -66,7 +66,7 @@ export function hasPslContractPrint( ): instance is ControlFamilyInstance & PslContractPrintCapable { return ( 'printPslContract' in instance && - typeof (instance as Record)['printPslContract'] === 'function' + typeof Reflect.get(instance, 'printPslContract') === 'function' ); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/testing/fixture-client.ts b/packages/1-framework/3-tooling/cli/src/control-api/testing/fixture-client.ts index cd657725a799..a1d59b1f31ac 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/testing/fixture-client.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/testing/fixture-client.ts @@ -64,6 +64,7 @@ export interface ControlClientFixtures { readonly introspect: unknown; readonly toSchemaView: CoreSchemaView | undefined; readonly inferPslContract: PslDocumentAst | undefined; + readonly printPslContract: PslDocumentAst | undefined; readonly getPslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; readonly toOperationPreview: OperationPreview | undefined; readonly emit: EmitResult; @@ -205,6 +206,7 @@ export function defaultControlClientFixtures(): ControlClientFixtures { introspect: {}, toSchemaView: undefined, inferPslContract: undefined, + printPslContract: undefined, getPslBlockDescriptors: {}, toOperationPreview: undefined, emit: ok({ @@ -332,6 +334,10 @@ class FixtureControlClientImpl implements FixtureControlClient { return this.record('inferPslContract', schemaIR, this.fixtures.inferPslContract); } + printPslContract(contract: unknown): PslDocumentAst | undefined { + return this.record('printPslContract', contract, this.fixtures.printPslContract); + } + getPslBlockDescriptors(): AuthoringPslBlockDescriptorNamespace { return this.record('getPslBlockDescriptors', undefined, this.fixtures.getPslBlockDescriptors); } diff --git a/packages/2-sql/9-family/README.md b/packages/2-sql/9-family/README.md index 9d455b5fde13..381f7cb4c132 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. +- **`printPslContract(contract)`**: Prints an assembled contract as the PSL document that reads back as the same contract, for `contract convert`. Delegates to the target descriptor's optional `printPslContract` hook; throws `CONTRACT.CONVERT_UNSUPPORTED` when the target has none. + The descriptor is "pure data + factory" - it only provides the hook and factory method. All family-specific logic lives on the instance. ## Package Structure 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 95a2adebef0d..92f0ff447001 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -281,6 +281,8 @@ export interface SqlControlFamilyInstance inferPslContract(schemaIR: SqlSchemaIRNode): PslDocumentAst; + printPslContract(contract: Contract): PslDocumentAst; + lowerAst( ast: AnyQueryAst | DdlNode, context: LowererContext, diff --git a/packages/3-targets/3-targets/postgres/README.md b/packages/3-targets/3-targets/postgres/README.md index 78ee7375ba72..b759b535e216 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 `printPslContract` descriptor hook, which `prisma contract convert` uses: it takes an assembled Postgres contract and returns the Prisma 8 PSL document that reads back as that same contract. It lives in `src/core/psl-print/` and is the reverse of the `inferPslContract` hook in `src/core/psl-infer/`, whose literal, index and enum-block builders it reuses. Model and field names come from the contract, never re-derived; `@@map` and `@map` appear only where the name the PSL source would derive differs. Uniques print as unique indexes, because that is what a Prisma 7 schema lowers them to, and every index prints explicitly, so each relation declines a backing index of its own. 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/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts new file mode 100644 index 000000000000..3a7b6a3d2538 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -0,0 +1,362 @@ +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 { createSqlContract } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; +import { printPostgresPslContract } from '../../src/core/psl-print/print-psl-contract'; + +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; + +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})`; +} + +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' }, +}; + +function print(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.keys(model.fields).map((fieldName) => [fieldName, INT_FIELD]), + ), + relations: model.relations ?? {}, + }, + ]), + ), + }; + const overrides = { + 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 = printPostgresPslContract(contract as Contract); + return ast.namespaces.flatMap((namespace) => namespace.models); +} + +function table(input: { + readonly columns: Record; + readonly primaryKey?: { readonly columns: readonly string[] }; + readonly indexes?: readonly unknown[]; + readonly foreignKeys?: readonly unknown[]; +}): unknown { + return { + columns: input.columns, + uniques: [], + indexes: input.indexes ?? [], + foreignKeys: input.foreignKeys ?? [], + ...(input.primaryKey === undefined ? {} : { primaryKey: input.primaryKey }), + }; +} + +function oneModel(columns: Record, fields: Record) { + return print({ + models: { Widget: { table: 'widget', fields } }, + tables: { widget: table({ columns, primaryKey: { columns: ['id'] } }) }, + })[0]; +} + +describe('names the PSL source cannot derive', () => { + it('maps a model whose table is not the lower-cased model name', () => { + const [model] = print({ + 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 lower-cased model name unmapped', () => { + const [model] = print({ + 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] = print({ + 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 multi-column primary key as a model attribute', () => { + const [model] = print({ + 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])']); + }); +}); + +describe('column defaults', () => { + function defaultOf(column: Record): 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 dbgenerated', () => { + expect( + defaultOf({ + nativeType: 'uuid', + codecId: 'pg/uuid@1', + nullable: false, + default: { kind: 'function', expression: 'gen_random_uuid()' }, + }), + ).toBe('@default(dbgenerated("gen_random_uuid()"))'); + }); +}); + +describe('generated values', () => { + function withGenerator( + column: Record, + phases: Record, + ): string | undefined { + const [model] = print({ + 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', table: 'widget', column: '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)'); + }); +}); + +describe('relations', () => { + function postAndUser(foreignKey: Record, indexes: readonly unknown[] = []) { + return print({ + 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'] }, + indexes, + 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', () => { + const models = postAndUser({ name: 'post_author_fkey' }); + expect(models[1]?.fields.map(fieldText)[2]).toBe( + 'author User @relation(fields: [authorId], references: [id], onDelete: NoAction, onUpdate: NoAction, 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[]'); + }); +}); From db82351daa0629403fe02124100d4bcb24662067 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 11:26:31 +0200 Subject: [PATCH 04/55] feat(psl-printer): the caller names the header the printed file opens with A command that prints a contract as PSL says where the file came from. The printer keeps the `contract infer` header as its default, so inferred output is unchanged. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../psl-printer/src/ast-to-print-document.ts | 7 ++- .../2-authoring/psl-printer/src/print-psl.ts | 7 ++- .../test/print-psl-from-ast.test.ts | 53 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) 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 6155d1fb4061..74a4be6b96a3 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 @@ -25,7 +25,10 @@ import type { PrinterField, PrinterModel, PrinterNamedType } from './types'; const DEFAULT_AST_PRINT_HEADER = '// use prisma-8\n// Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.'; -export function astDocumentToPrintDocument(ast: PslDocumentAst): PrintDocument { +export function astDocumentToPrintDocument( + ast: PslDocumentAst, + headerComment: string = DEFAULT_AST_PRINT_HEADER, +): 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 @@ -98,7 +101,7 @@ export function astDocumentToPrintDocument(ast: PslDocumentAst): PrintDocument { }); return { - headerComment: DEFAULT_AST_PRINT_HEADER, + headerComment, namedTypes, namespaces: namespaceSections, }; 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..c8de5a04634d 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; + /** + * The comment block the printed file opens with, written as PSL comment + * lines. When absent the printer writes the `contract infer` header. + */ + readonly headerComment?: string; } export function printPslFromAst(ast: PslDocumentAst, options: PrintPslOptions = {}): string { - const doc = astDocumentToPrintDocument(ast); + const doc = astDocumentToPrintDocument(ast, options.headerComment); return serializePrintDocument(doc, { ...ifDefined('pslBlockDescriptors', options.pslBlockDescriptors), ...ifDefined('codecLookup', options.codecLookup), 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 66d88e3f5a89..724886f043b5 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 @@ -916,4 +916,57 @@ describe('printPslFromAst', () => { expect(printed).toMatch(/user\s+supabase:auth\.User\?\s+@relation/); }); }); + + 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('describes an inferred contract when the caller names none', () => { + expect(headerOf(printPslFromAst(headerAst))).toBe( + '// use prisma-8\n// Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.', + ); + }); + + it('opens with the header the caller names', () => { + const printed = printPslFromAst(headerAst, { + headerComment: '// use prisma-8\n// Converted from prisma/schema.prisma.', + }); + + expect(headerOf(printed)).toBe('// use prisma-8\n// Converted from prisma/schema.prisma.'); + expect(printed).toContain('model X {'); + }); + }); }); From f74ef0041f6be08a143be2e156a31c6517532909 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 11:28:25 +0200 Subject: [PATCH 05/55] feat(config): a contract source can declare the Prisma 7 format The Prisma 7 source already reports `format: 'prisma7'`, but the config types only knew 'psl' and 'typescript', so tooling had to read it as an opaque format. Naming it lets a command require it. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../1-core/config/src/contract-source-types.ts | 8 +++++++- .../1-core/config/src/exports/config-types.ts | 1 + .../1-core/config/test/config-types.test-d.ts | 10 +++++++++- 3 files changed, 17 insertions(+), 2 deletions(-) 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 3e275ce6ec09..00d69032f73b 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 @@ -49,7 +49,7 @@ export interface ContractSourceContext { } /** Lets format-aware tooling avoid file-extension sniffing and opaque loader introspection. */ -export type ContractSourceFormat = 'psl' | 'typescript'; +export type ContractSourceFormat = 'psl' | 'typescript' | 'prisma7'; export interface ContractSourceProviderBase { readonly inputs?: readonly string[]; @@ -66,6 +66,11 @@ export interface TypeScriptContractSourceProvider extends ContractSourceProvider readonly format: 'typescript'; } +/** A Prisma 7 `schema.prisma` read as the contract source; `prisma contract convert` writes its Prisma 8 equivalent. */ +export interface Prisma7ContractSourceProvider extends ContractSourceProviderBase { + readonly format: 'prisma7'; +} + /** * Third-party or unspecified source formats. Absent (or unrecognized) * `format` means format-aware tooling must leave the source untouched. @@ -79,4 +84,5 @@ export interface OpaqueContractSourceProvider extends ContractSourceProviderBase export type ContractSourceProvider = | PslContractSourceProvider | TypeScriptContractSourceProvider + | Prisma7ContractSourceProvider | OpaqueContractSourceProvider; 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..35896cb8bbcc 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 @@ -14,6 +14,7 @@ export type { ContractSourceProvider, ContractSourceProviderBase, OpaqueContractSourceProvider, + Prisma7ContractSourceProvider, PslContractSourceProvider, TypeScriptContractSourceProvider, } from '../contract-source-types'; 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 6c4bcf205fe3..a14b4253c0f3 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,6 +14,7 @@ import type { ContractSourceFormat, ContractSourceProvider, OpaqueContractSourceProvider, + Prisma7ContractSourceProvider, PslContractSourceProvider, TypeScriptContractSourceProvider, } from '../src/contract-source-types'; @@ -131,13 +132,18 @@ test('accepts contract source providers with declared inputs', () => { test('contract source providers form a format-keyed union', () => { expectTypeOf().toEqualTypeOf< - PslContractSourceProvider | TypeScriptContractSourceProvider | OpaqueContractSourceProvider + | PslContractSourceProvider + | TypeScriptContractSourceProvider + | Prisma7ContractSourceProvider + | OpaqueContractSourceProvider >(); expectTypeOf().toEqualTypeOf<'psl'>(); expectTypeOf().toEqualTypeOf<'typescript'>(); + expectTypeOf().toEqualTypeOf<'prisma7'>(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toExtend(); expectTypeOf().toExtend(); + expectTypeOf().toExtend(); }); test('provider literals remain assignable to the union without casts', () => { @@ -149,11 +155,13 @@ test('provider literals remain assignable to the union without casts', () => { load, }; const typescript: ContractSourceProvider = { format: 'typescript', load }; + const prisma7: ContractSourceProvider = { format: 'prisma7', inputs: ['./schema.prisma'], load }; const absent: ContractSourceProvider = { load }; const thirdParty: ContractSourceProvider = { format: 'made-up-format', load }; expectTypeOf(psl).toExtend(); expectTypeOf(typescript).toExtend(); + expectTypeOf(prisma7).toExtend(); expectTypeOf(absent).toExtend(); expectTypeOf(thirdParty).toExtend(); }); From 50f2ae8d533a440ded9ce8b04911a767ff2e8475 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 11:30:43 +0200 Subject: [PATCH 06/55] refactor(cli): one place builds the stack and loads the contract source `contract emit` owned the sequence that builds the control stack, asks the configured source for the contract, and turns a bad answer into CONTRACT.SOURCE_LOAD_FAILED. `contract convert` needs the same sequence, so it moves to its own module and emit calls it. Emit's spans, errors and behaviour are unchanged. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../control-api/operations/contract-emit.ts | 208 +--------------- .../operations/load-contract-source.ts | 231 ++++++++++++++++++ 2 files changed, 241 insertions(+), 198 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/src/control-api/operations/load-contract-source.ts 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 5e88a5ef2625..ed69dc327ec0 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,14 +1,11 @@ import { mkdir } from 'node:fs/promises'; import type { Contract } from '@internal/contract/types'; import { emit, getEmittedArtifactPaths } from '@internal/emitter'; -import { createControlStack } from '@internal/framework-components/control'; import { abortable } from '@internal/utils/abortable'; import { ifDefined } from '@internal/utils/defined'; import type { JsonObject } from '@internal/utils/json'; -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'; @@ -21,6 +18,8 @@ import type { ControlActionName, OnControlProgress, } from '../types'; +import type { LoadedContractSource } from './load-contract-source'; +import { loadContractSource } from './load-contract-source'; const EMIT_ACTION: ControlActionName = 'emit'; @@ -30,10 +29,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 }); } @@ -46,164 +41,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; -} - -/** - * 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; -} - -type ValidatedProviderResult = - | { readonly ok: true; readonly value: unknown } - | { readonly ok: false; 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 { - ok: false, - 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 { - ok: false, - error: failedToResolveContractSource( - 'Contract source provider returned malformed success result: missing value.', - 'Ensure contract.source.load success payload is ok(Contract).', - ), - }; - } - return { ok: true, value: providerResult['value'] }; - } - - const failure = providerResult['failure']; - if ( - !isRecord(failure) || - typeof failure['summary'] !== 'string' || - !Array.isArray(failure['diagnostics']) - ) { - return { - ok: false, - error: failedToResolveContractSource( - 'Contract source provider returned malformed failure result: expected summary and diagnostics.', - 'Ensure contract.source.load failure payload is notOk({ summary, diagnostics, meta? }).', - ), - }; - } - return { - ok: false, - error: failedToResolveContractSource( - String(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']), - ), - }; -} - /** * Canonical contract emit operation. * @@ -271,41 +108,16 @@ export async function executeContractEmit( const { jsonPath: outputJsonPath, dtsPath: outputDtsPath } = outputPaths; return queueEmitByOutput(outputJsonPath, async () => { - const stack = createControlStack(config); - - const sourceContext = { - composedExtensions: stack.extensions.map((p) => p.id), - composedExtensionContracts: stack.extensionContracts, - authoringContributions: stack.authoringContributions, - codecLookup: stack.codecLookup, - controlMutationDefaults: stack.controlMutationDefaults, - resolvedInputs: contractConfig.source.inputs ?? [], - capabilities: stack.capabilities, - }; - startSpan(onProgress, 'resolveSource', 'Resolving contract source...'); - let providerResult: Awaited>; + let loadedSource: LoadedContractSource; try { - providerResult = await unlessAborted(contractConfig.source.load(sourceContext)); + loadedSource = await loadContractSource({ config, contractConfig, signal }); } catch (error) { endSpan(onProgress, 'resolveSource', '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 validatedContract = validateProviderResult(providerResult); - if (!validatedContract.ok) { - endSpan(onProgress, 'resolveSource', 'error'); - throw validatedContract.error; + throw error; } endSpan(onProgress, 'resolveSource', 'ok'); + const { stack } = loadedSource; startSpan(onProgress, 'emit', 'Emitting contract...'); let emitResult: Awaited>; @@ -317,15 +129,15 @@ export async function executeContractEmit( config.target.targetId, rawComponents, ); - // Blind cast: `validateProviderResult` upstream has already - // pinned `validatedContract.value` to the provider's loose + // Blind cast: `loadContractSource` upstream has already + // pinned `loadedSource.contract` 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( - validatedContract.value as unknown as Contract, + loadedSource.contract as unknown as Contract, frameworkComponents, ); const rawContractJson = config.target.contractSerializer.serializeContract(enrichedIR); 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..7ef1b2a5887a --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/load-contract-source.ts @@ -0,0 +1,231 @@ +import type { PrismaNextConfig } from '@internal/config/config-types'; +import { createControlStack } from '@internal/framework-components/control'; +import { abortable } from '@internal/utils/abortable'; +import { ifDefined } from '@internal/utils/defined'; +import type { Diagnostic } from '@internal/utils/structured-error'; +import { isStructuredErrorCode } from '@internal/utils/structured-error'; +import { errorRuntime } from '../../utils/cli-errors'; + +type ContractConfig = NonNullable; + +export type ControlStack = ReturnType; + +/** The contract the configured source produced, and the stack it was loaded against. */ +export interface LoadedContractSource { + readonly stack: ControlStack; + readonly contract: unknown; +} + +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; +} + +/** + * 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; +} + +type ValidatedProviderResult = + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; 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 { + ok: false, + 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 { + ok: false, + error: failedToResolveContractSource( + 'Contract source provider returned malformed success result: missing value.', + 'Ensure contract.source.load success payload is ok(Contract).', + ), + }; + } + return { ok: true, value: providerResult['value'] }; + } + + const failure = providerResult['failure']; + if ( + !isRecord(failure) || + typeof failure['summary'] !== 'string' || + !Array.isArray(failure['diagnostics']) + ) { + return { + ok: false, + error: failedToResolveContractSource( + 'Contract source provider returned malformed failure result: expected summary and diagnostics.', + 'Ensure contract.source.load failure payload is notOk({ summary, diagnostics, meta? }).', + ), + }; + } + return { + ok: false, + error: failedToResolveContractSource( + String(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']), + ), + }; +} + +/** + * Builds the control stack, asks the configured contract source for the + * contract, and turns every failure into `CONTRACT.SOURCE_LOAD_FAILED`. Shared + * by `contract emit` and `contract convert` so both report a bad source the + * same way. + * + * @throws {CliStructuredError} `CONTRACT.SOURCE_LOAD_FAILED` when the source + * cannot produce a contract + * @throws {DOMException} `AbortError` if cancelled via `signal` + */ +export async function loadContractSource(inputs: { + readonly config: PrismaNextConfig; + readonly contractConfig: ContractConfig; + readonly signal?: AbortSignal; +}): Promise { + const { config, contractConfig } = inputs; + const signal = inputs.signal ?? new AbortController().signal; + const unlessAborted = abortable(signal); + const stack = createControlStack(config); + + const sourceContext = { + composedExtensions: stack.extensions.map((p) => p.id), + composedExtensionContracts: stack.extensionContracts, + authoringContributions: stack.authoringContributions, + codecLookup: stack.codecLookup, + controlMutationDefaults: stack.controlMutationDefaults, + resolvedInputs: contractConfig.source.inputs ?? [], + capabilities: stack.capabilities, + }; + + let providerResult: Awaited>; + try { + providerResult = await unlessAborted(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); + if (!validated.ok) { + throw validated.error; + } + return { stack, contract: validated.value }; +} From bbc67b4dd657d1943fed8b3d46d123a4a0719413 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 11:34:53 +0200 Subject: [PATCH 07/55] feat(cli): prisma contract convert writes a Prisma 7 schema as Prisma 8 PSL The command loads the contract through the configured Prisma 7 source, asks the target to print it, and writes the result with a header naming the schema it came from. A source in any other format is refused before anything is read. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- docs/reference/error-reference.md | 4 + .../1-framework/3-tooling/cli/src/orm/cli.ts | 2 + .../3-tooling/cli/src/orm/contract/convert.ts | 232 ++++++++++++++++ .../3-tooling/cli/src/orm/family.ts | 2 + .../cli/test/orm/contract-convert.test.ts | 259 ++++++++++++++++++ 5 files changed, 499 insertions(+) create mode 100644 packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts create mode 100644 packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 45d34ce6d989..009146de3cd4 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -245,6 +245,10 @@ 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.CONVERT_SOURCE_NOT_PRISMA7 + +`prisma contract convert` applies only to a Prisma 7 source, and the configured contract source is a PSL or TypeScript one, or names no format at all. A contract already authored the Prisma 8 way has nothing to convert. Point `contract` at `prisma7Schema("./schema.prisma")` to convert a Prisma 7 schema. Raised by `contract convert` before it reads anything, so nothing is written. Payload: `format` (the format the configured source declares, `null` when it declares none). + ### CONTRACT.CONVERT_UNSUPPORTED `contract convert` cannot print the loaded contract as a Prisma 8 PSL file: either the configured target has no `printPslContract` hook on its descriptor (meta: `targetId`), or the contract holds something Prisma 8 PSL has no spelling for — a column whose native type is outside the target's type map, a generated value the target cannot author, a to-one relation with no foreign key behind it, or one model name declared in more than one namespace (a relation's target is resolved by model name alone, so the two would read back as one). Meta at the shape sites: `coordinate`, `nativeType`, `onCreate`, `onUpdate`, `model`, `field`, `modelName`, `namespaces`. 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..7aaaedd20459 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/cli.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/cli.ts @@ -5,6 +5,7 @@ import { createCli, telemetryCommandGroup } from '@prisma/cli-engine'; import { version as CLI_VERSION } from '../../package.json' with { type: 'json' }; import { createControlClient } from '../control-api/client'; import type { CreateControlClient } from '../control-api/types'; +import { contractConvertCommand } from './contract/convert'; import { contractEmitCommand } from './contract/emit'; import { contractInferCommand } from './contract/infer'; import { createDbInitCommand } from './db/init'; @@ -95,6 +96,7 @@ export const BIN_GROUPS = { */ export function createBinCommands(createClient: CreateControlClient): MountedTree { return { + 'contract convert': contractConvertCommand, 'contract emit': contractEmitCommand, 'contract format': formatCommand, 'contract infer': contractInferCommand, diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts new file mode 100644 index 000000000000..74a6a253e687 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts @@ -0,0 +1,232 @@ +import { existsSync } from 'node:fs'; +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 } from 'pathe'; +import { createControlClient as createDefaultControlClient } from '../../control-api/client'; +import { loadContractSource } from '../../control-api/operations/load-contract-source'; +import type { ControlClient, ControlClientOptions } from '../../control-api/types'; +import { + CliStructuredError, + errorContractConfigMissing, + errorRuntime, + errorUnexpected, +} from '../../utils/cli-errors'; +import { closeQuietly } from '../../utils/command-helpers'; +import { runCommandAction } from '../../utils/next-actions'; +import { publishTextArtifact } from '../../utils/publish-text-artifact'; +import { ormConfigSection } from '../config-section'; +import { defineOrmCommand } from '../define-command'; +import { normalizeError } from '../normalize-error'; +import { inferredContractPathFor } from './paths'; + +interface ConvertDocument { + readonly ok: true; + readonly summary: string; + readonly target: { readonly familyId: string; readonly id: string }; + readonly psl: { readonly path: string }; + readonly source: string; + readonly timings: { readonly total: number }; +} + +/** + * The routine that carries a converted contract onto the database Prisma 7 + * built: emit, plan a baseline, sign, then point the `db` ref at the baseline. + */ +const CUTOVER_ACTIONS: readonly NextAction[] = [ + runCommandAction('Emit the converted contract', '{bin} contract emit'), + runCommandAction('Plan the baseline migration', '{bin} migration plan --name baseline'), + runCommandAction('Sign the database', '{bin} db sign'), + runCommandAction( + 'Point the db ref at the baseline migration', + '{bin} migration ref set db _baseline', + ), +]; + +function convertPresentations(document: ConvertDocument): Presentations { + return { + stdout: () => [], + next: () => CUTOVER_ACTIONS, + human: (): readonly Block[] => [ + { + kind: 'summary', + status: 'ok', + text: [{ text: 'Contract written to ' }, { text: document.psl.path, tone: 'identifier' }], + }, + ], + json: () => document, + }; +} + +/** What `contract convert` uses of the control client; doubles implement just this. */ +export type ConvertControlClient = Pick< + ControlClient, + 'printPslContract' | 'getPslBlockDescriptors' | 'close' +>; + +export interface ContractConvertCommandDeps { + readonly createControlClient: (options: ControlClientOptions) => ConvertControlClient; + readonly printPsl: typeof printPslFromAst; +} + +function convertHeaderComment(schemaPath: string): string { + return `// use prisma-8\n// Converted from ${schemaPath} by \`prisma contract convert\`.`; +} + +export function createContractConvertCommand({ + createControlClient, + printPsl, +}: ContractConvertCommandDeps) { + return defineOrmCommand({ + help: { + summary: 'Convert a Prisma 7 schema into a Prisma 8 PSL contract', + description: + 'Reads the Prisma 7 schema the config names as the contract source and\n' + + 'writes the Prisma 8 PSL that produces the same contract. The command\n' + + 'stops at contract.prisma; switch the config to the written file, then\n' + + 'run `contract emit` and the rest of the cutover. An existing file at the\n' + + 'output path is overwritten, with a warning.', + examples: [ + 'contract convert', + 'contract convert --output ./src/prisma/contract.prisma', + 'contract convert --json', + ], + }, + args: { + flags: { + output: flag.string({ + brief: 'Write the converted PSL contract to the specified path', + 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 convert. Define it in your config: contract: prisma7Schema("./schema.prisma")', + }), + ), + ); + } + if (contractConfig.source.format !== 'prisma7') { + return notOk( + normalizeError( + errorRuntime( + 'CONTRACT.CONVERT_SOURCE_NOT_PRISMA7', + 'contract convert applies only to a Prisma 7 source', + { + why: `The configured contract source has format "${contractConfig.source.format ?? 'unspecified'}", and there is nothing to convert: the contract is already authored the Prisma 8 way.`, + fix: 'Point contract at prisma7Schema("./schema.prisma") to convert a Prisma 7 schema.', + meta: { format: contractConfig.source.format ?? null }, + }, + ), + ), + ); + } + + const schemaInput = contractConfig.source.inputs?.[0]; + if (schemaInput === undefined) { + return notOk( + normalizeError( + errorContractConfigMissing({ + why: 'The Prisma 7 contract source names no schema file, so there is nothing to convert.', + }), + ), + ); + } + const schemaPath = relative(ctx.cwd, schemaInput); + + const client = createControlClient({ + family: ctx.config.family, + target: ctx.config.target, + adapter: ctx.config.adapter, + ...(ctx.config.driver === undefined ? {} : { driver: ctx.config.driver }), + extensions: ctx.config.extensions ?? [], + }); + + let pslContent: string; + try { + const { stack, contract } = await loadContractSource({ + config: ctx.config, + contractConfig, + }); + const pslContractAst = client.printPslContract(contract); + if (pslContractAst === undefined) { + return notOk( + normalizeError( + errorRuntime( + 'CONTRACT.CONVERT_UNSUPPORTED', + 'contract convert is not supported for this target', + { + why: 'The configured target does not implement the PslContractPrintCapable capability, so the loaded contract cannot be written as Prisma 8 PSL.', + fix: 'Use a target that supports contract conversion (Postgres today).', + }, + ), + ), + ); + } + pslContent = printPsl(pslContractAst, { + pslBlockDescriptors: client.getPslBlockDescriptors(), + codecLookup: stack.codecLookup, + headerComment: convertHeaderComment(schemaPath), + }); + } catch (error) { + if (CliStructuredError.is(error)) { + return notOk(normalizeError(error)); + } + const message = error instanceof Error ? error.message : String(error); + return notOk( + normalizeError( + errorUnexpected(message, { + why: `Unexpected error during contract convert: ${message}`, + }), + ), + ); + } finally { + await closeQuietly(client); + } + + const outputPath = inferredContractPathFor({ + config: ctx.config, + cwd: ctx.cwd, + output: args.flags.output, + }); + const displayPath = relative(ctx.cwd, outputPath); + if (existsSync(outputPath)) { + ctx.report({ + kind: 'message', + severity: 'warn', + text: `Overwriting existing file: ${displayPath}`, + }); + } + await publishTextArtifact({ + path: outputPath, + content: pslContent, + publicationToken: String(process.hrtime.bigint()), + }); + + const document: ConvertDocument = { + ok: true, + summary: 'Contract converted successfully', + target: { familyId: ctx.config.family.familyId, id: ctx.config.target.targetId }, + psl: { path: displayPath }, + source: schemaPath, + timings: { total: Date.now() - startedAt }, + }; + + return ok(ctx.present({ data: document }, convertPresentations(document))); + }, + }); +} + +export const contractConvertCommand = createContractConvertCommand({ + createControlClient: createDefaultControlClient, + 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 544e986d10a9..9001543d9dc7 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/family.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/family.ts @@ -2,6 +2,7 @@ import { DOCS_BASE } from '@internal/utils/structured-error'; import type { AnyCommand, RedirectSpec } from '@prisma/cli-engine'; import { defineCommandFamily } from '@prisma/cli-engine'; import { ormConfigSection } from './config-section'; +import { contractConvertCommand } from './contract/convert'; import { contractEmitCommand } from './contract/emit'; import { contractInferCommand } from './contract/infer'; import { dbInitCommand } from './db/init'; @@ -32,6 +33,7 @@ import { refSetCommand } from './ref/set'; const DOCS_BASE_URL = `${DOCS_BASE}/`; const commands: Readonly> = { + 'contract convert': contractConvertCommand, 'contract emit': contractEmitCommand, 'contract format': formatCommand, 'contract infer': contractInferCommand, diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts new file mode 100644 index 000000000000..48120dff5cc0 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts @@ -0,0 +1,259 @@ +import { readdir, readFile, rm, writeFile } 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 stripAnsi from 'strip-ansi'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BIN_GROUPS } from '../../src/orm/cli'; +import { createContractConvertCommand } from '../../src/orm/contract/convert'; +import { createTestProjectDir } from '../utils/test-project-dir'; + +const PSL = 'model User {\n id Int @id\n}\n'; + +/** + * The command is mounted from the factory with a control-client double and a + * printer double injected, so no module mocking is involved and the doubles + * are scoped to this file. + */ +const mocks = { + printPslContract: vi.fn(), + getPslBlockDescriptors: vi.fn(), + close: vi.fn(), + printPsl: vi.fn(), + load: vi.fn(), +}; + +const commands: MountedTree = { + 'contract convert': createContractConvertCommand({ + createControlClient: () => ({ + printPslContract: mocks.printPslContract, + getPslBlockDescriptors: mocks.getPslBlockDescriptors, + close: mocks.close, + }), + printPsl: mocks.printPsl, + }), +}; +const groups = BIN_GROUPS; + +const dirs: string[] = []; + +async function projectDir(): Promise { + const dir = createTestProjectDir('orm-convert'); + dirs.push(dir); + return dir; +} + +afterEach(async () => { + for (const dir of dirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +beforeEach(() => { + mocks.printPslContract.mockReset().mockReturnValue({ kind: 'psl-document' }); + mocks.getPslBlockDescriptors.mockReset().mockReturnValue({}); + mocks.close.mockReset().mockResolvedValue(undefined); + mocks.printPsl.mockReset().mockReturnValue(PSL); + mocks.load.mockReset().mockResolvedValue(ok({ roots: {}, domain: {} })); +}); + +const DESCRIPTOR = { + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + create: () => ({}), +}; + +function ormConfig(dir: string, overrides: Record = {}): Record { + return { + family: { + kind: 'family', + id: 'sql', + familyId: 'sql', + version: '1.0.0', + emission: {}, + create: () => ({}), + }, + target: { ...DESCRIPTOR, kind: 'target', id: 'postgres' }, + adapter: { ...DESCRIPTOR, kind: 'adapter', id: 'pg' }, + driver: { ...DESCRIPTOR, kind: 'driver', id: 'pg-driver' }, + contract: { + source: { format: 'prisma7', inputs: ['./prisma/schema.prisma'], load: mocks.load }, + output: join(dir, 'generated', 'contract.json'), + }, + ...overrides, + }; +} + +function harness(config: Record) { + return createTestCli({ commands, groups, config: { orm: config } }); +} + +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; +} + +describe('contract convert', () => { + 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', 'convert', '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ + ok: true, + summary: 'Contract converted successfully', + target: { familyId: 'sql', id: 'postgres' }, + psl: { path: 'generated/contract.prisma' }, + source: 'prisma/schema.prisma', + timings: { total: expect.any(Number) }, + }); + }); + + it('opens the written file with a header naming the schema it converted', async () => { + const dir = await projectDir(); + + await harness(ormConfig(dir)).run(['contract', 'convert', '--json'], { cwd: dir }); + + expect(mocks.printPsl).toHaveBeenCalledWith( + { kind: 'psl-document' }, + expect.objectContaining({ + headerComment: + '// use prisma-8\n// Converted from prisma/schema.prisma by `prisma contract convert`.', + }), + ); + expect(await readFile(join(dir, 'generated', 'contract.prisma'), 'utf-8')).toBe(PSL); + }); + + it('publishes through a staged rename, leaving no temporary file behind', async () => { + const dir = await projectDir(); + + await harness(ormConfig(dir)).run(['contract', 'convert', '--json'], { cwd: dir }); + + expect(await readdir(join(dir, 'generated'))).toEqual(['contract.prisma']); + }); + + it('resolves a relative --output against the invocation directory', async () => { + const dir = await projectDir(); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'convert', '--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', 'convert', '--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', 'convert', '--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', 'convert'], { + 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: 'run-command', label: 'Emit the converted contract', command: '{bin} contract emit' }, + { + kind: 'run-command', + label: 'Plan the baseline migration', + command: '{bin} migration plan --name baseline', + }, + { kind: 'run-command', label: 'Sign the database', command: '{bin} db sign' }, + { + kind: 'run-command', + label: 'Point the db ref at the baseline migration', + command: '{bin} migration ref set db _baseline', + }, + ]); + expect(run.presented?.presentation.stdout).toEqual([]); + expect(stripAnsi(run.stderr)).toContain('Contract written to generated/contract.prisma'); + expect(run.stdout).toBe(''); + }); + + it('refuses a PSL source and writes nothing', 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', 'convert', '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.CONVERT_SOURCE_NOT_PRISMA7', + }); + expect(mocks.load).not.toHaveBeenCalled(); + expect(await readdir(dir)).not.toContain('generated'); + }); + + 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.PRISMA7_VIEW_UNSUPPORTED', message: 'a view is not a model' }], + }, + }); + + const run = await harness(ormConfig(dir)).run(['contract', 'convert', '--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 target cannot print the contract as PSL', async () => { + const dir = await projectDir(); + mocks.printPslContract.mockReturnValue(undefined); + + const run = await harness(ormConfig(dir)).run(['contract', 'convert', '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.CONVERT_UNSUPPORTED' }); + expect(mocks.close).toHaveBeenCalled(); + }); +}); From 730521bdce918459098e5d614633a5bc8bf71b1e Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 11:40:08 +0200 Subject: [PATCH 08/55] test(integration): a converted Prisma 7 project emits the same contract and verifies clean The journey converts, switches the config to the written file, emits, signs and verifies against the database the Prisma 7 SQL built. The run over the reference schema is expected to fail: the Prisma 8 PSL source refuses a function default on a list column, and every Prisma 7 list default is one. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../cli-journeys/prisma7-convert.e2e.test.ts | 228 ++++++++++++++++++ .../prisma.config.prisma7-converted.ts | 16 ++ .../test/utils/journey-test-helpers.ts | 8 + 3 files changed, 252 insertions(+) create mode 100644 test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-converted.ts diff --git a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts new file mode 100644 index 000000000000..4620cfd95297 --- /dev/null +++ b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts @@ -0,0 +1,228 @@ +/** + * The user-facing journey for `prisma contract convert`: a project whose + * `prisma.config.ts` points at `prisma7Schema('./schema.prisma')` runs + * `contract convert`, switches its config to the Prisma 8 PSL the command + * wrote, and emits the same contract — which `db verify` then reports zero + * findings against the database the Prisma 7 SQL built. A config on any other + * contract source, and a Prisma 7 schema Prisma 8 refuses, both exit 2 and + * write nothing. + */ +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, + runContractConvert, + runContractEmit, + 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 LIST_COLUMN_SQL = readFileSync(join(PRISMA7_FIXTURES, 'supported/migration.sql'), 'utf-8'); +const NO_LIST_COLUMN_SQL = readFileSync( + join(PRISMA7_FIXTURES, 'implicit-many-to-many-names/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 convert` wrote. */ +function onConvertedContract(ctx: JourneyContext, connectionString: string): JourneyContext { + return { + ...ctx, + configPath: writeConfig(ctx.testDir, 'prisma.config.prisma7-converted.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 errorCodeOf(run: EngineCommandResult): string | undefined { + const terminal = run.json.at(-1); + if (terminal === undefined || terminal.kind !== 'result' || terminal.envelope.ok) { + return undefined; + } + return terminal.envelope.error.code; +} + +/** + * Converts, switches the config to the written file, emits, and verifies. The + * two passing and failing journeys differ only in the fixture they run over. + */ +async function convertAndVerify(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 convert = await runContractConvert(ctx, ['--json']); + expect(convert.exitCode, `contract convert\n${output(convert)}`).toBe(0); + expect(convert.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// Converted from schema.prisma by `prisma contract convert`.', + ); + + const converted = onConvertedContract(ctx, connectionString); + const pslEmit = await runContractEmit(converted, ['--json']); + expect(pslEmit.exitCode, `contract emit on the converted contract\n${output(pslEmit)}`).toBe(0); + expect(storageHashOf(pslEmit)).toBe(storageHashOf(prisma7Emit)); + + const sign = await runDbSign(converted, ['--json']); + expect(sign.exitCode, `db sign\n${output(sign)}`).toBe(0); + + const verify = await runDbVerify(converted, ['--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: converting a Prisma 7 schema over a schema with list columns', () => { + const db = useDevDatabase({ + onReady: (cs) => withClient(cs, (client) => client.query(LIST_COLUMN_SQL)), + }); + + it.fails( + 'converts the reference schema and verifies against the database Prisma 7 built', + async () => { + // The Prisma 8 PSL source refuses a function default on a list column, + // and every Prisma 7 list default is one, so the written file does not + // read back yet. + await convertAndVerify( + setupPrisma7Project(createTempDir, db.connectionString, { + copyFrom: join(PRISMA7_FIXTURES, 'supported-verify/schema.prisma'), + }), + db.connectionString, + ); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey: converting a Prisma 7 schema with no list columns', () => { + const db = useDevDatabase({ + onReady: (cs) => withClient(cs, (client) => client.query(NO_LIST_COLUMN_SQL)), + }); + + it( + 'converts relations across two schemas and verifies against the database Prisma 7 built', + async () => { + await convertAndVerify( + setupPrisma7Project(createTempDir, db.connectionString, { + copyFrom: join(PRISMA7_FIXTURES, 'implicit-many-to-many-names/schema.prisma'), + }), + db.connectionString, + ); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey: contract convert refuses what it cannot convert', () => { + it( + 'refuses a contract source that is not a Prisma 7 schema and writes nothing', + async () => { + const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { + copyFrom: join(PRISMA7_FIXTURES, 'implicit-many-to-many-names/schema.prisma'), + }); + writeFileSync(join(ctx.testDir, 'contract.prisma'), 'model User {\n id Int @id\n}\n'); + const onPsl = onConvertedContract(ctx, NO_DATABASE); + + const convert = await runContractConvert(onPsl, ['--output', 'converted.prisma', '--json']); + + expect(convert.exitCode, output(convert)).toBe(2); + expect(errorCodeOf(convert)).toBe('CONTRACT.CONVERT_SOURCE_NOT_PRISMA7'); + expect(existsSync(join(ctx.testDir, 'converted.prisma'))).toBe(false); + }, + timeouts.spinUpPpgDev, + ); + + 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 convert = await runContractConvert(ctx, ['--json']); + + expect(convert.exitCode, output(convert)).toBe(2); + expect(errorCodeOf(convert)).toBe('CONTRACT.SOURCE_LOAD_FAILED'); + expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); + }, + timeouts.spinUpPpgDev, + ); + }); +}); diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-converted.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-converted.ts new file mode 100644 index 000000000000..5aa313ee8555 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-converted.ts @@ -0,0 +1,16 @@ +// The config a project switches to after `prisma contract convert`: the same +// database, now read from the Prisma 8 PSL the command wrote. +import { defineConfig as postgres } from '@internal/postgres/config'; +import { defineConfig } from '@prisma/cli-engine'; + +export default defineConfig({ + orm: postgres({ + contract: './contract.prisma', + db: { + connection: '{{DB_URL}}', + }, + migrations: { + dir: 'migrations', + }, + }), +}); diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index d7128dbcf0e5..76dd56bb796b 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 runContractConvert( + ctx: JourneyContext, + extraArgs: readonly string[] = [], + options?: RunCommandOptions, +): Promise { + return runOnEngine(ctx, ['contract', 'convert', ...extraArgs], options); +} + export async function runDbInit( ctx: JourneyContext, extraArgs: readonly string[] = [], From de1930141ad7810d6f680f1335e78c2c44d7a864 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 11:44:37 +0200 Subject: [PATCH 09/55] docs: contract convert and the cutover that follows it The CLI README documents the command, its refusals and the four commands that follow it. The Postgres README and the adoption example name it as the step that ends the transition. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- examples/prisma7-adoption/README.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 40 +++++++++++++++++++ .../3-tooling/cli/src/orm/contract/convert.ts | 1 + packages/3-extensions/postgres/README.md | 2 + .../03-contract-to-psl-and-convert/spec.md | 2 +- 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/examples/prisma7-adoption/README.md b/examples/prisma7-adoption/README.md index 3b20f6a83314..666ba00f0218 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 convert` 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/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index a9690a04ae2c..64f5313024a0 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -345,6 +345,46 @@ prisma db schema --json prisma db schema -v ``` +### `prisma contract convert` + +Read the Prisma 7 schema the config names as the contract source and write the Prisma 8 PSL that produces the same contract. Use it when a project on `prisma7Schema(...)` is ready to stop reading the Prisma 7 file and author in Prisma 8 PSL instead. + +**Command:** +```bash +prisma contract convert [--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 converted PSL contract to the specified path +- `--json`: Output a JSON result envelope (includes `psl.path` and the `source` schema it read) +- `-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 schema file, not the server. The output path is chosen the same way `contract infer` chooses one, and an existing file there is overwritten with a warning. + +The written file opens with the two lines that say where it came from: + +```prisma +// use prisma-8 +// Converted from prisma/schema.prisma by `prisma contract convert`. +``` + +Two things are refused, and neither writes a file: +- A contract source that is not a Prisma 7 schema exits `2` with `CONTRACT.CONVERT_SOURCE_NOT_PRISMA7`. A contract already authored the Prisma 8 way has nothing to convert. +- A Prisma 7 schema Prisma 8 cannot read exits `2` with `CONTRACT.SOURCE_LOAD_FAILED`, reporting exactly what `contract emit` reports for the same schema. + +Converting is the first step of the cutover, not the whole of it. After it succeeds, point `contract` in `prisma.config.ts` at the written file and run the rest: + +```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`. diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts index 74a6a253e687..05982a9d6c4b 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts @@ -166,6 +166,7 @@ export function createContractConvertCommand({ 'contract convert is not supported for this target', { why: 'The configured target does not implement the PslContractPrintCapable capability, so the loaded contract cannot be written as Prisma 8 PSL.', + // biome-ignore lint/plugin/no-family-vocabulary: names a target on purpose — this is user-facing guidance about which target can convert, not a framework type fix: 'Use a target that supports contract conversion (Postgres today).', }, ), diff --git a/packages/3-extensions/postgres/README.md b/packages/3-extensions/postgres/README.md index 1458ac1a6a01..41cf91b730d1 100644 --- a/packages/3-extensions/postgres/README.md +++ b/packages/3-extensions/postgres/README.md @@ -99,6 +99,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 convert` writes the Prisma 8 PSL that produces the same contract, with a header naming the schema it came from. 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`. The command needs no database connection, and refuses rather than approximating, so the contract before and after the switch is the same one. + 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/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 index d3a8157fcdce..f82a012a16a5 100644 --- 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 @@ -18,7 +18,7 @@ Output begins: ## 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. +- **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 2 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 From 9fb8b3534cf41d011d7ef35acec5ea0c5a1728f5 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 11:57:32 +0200 Subject: [PATCH 10/55] fix(target-postgres): the contract-to-PSL printer keeps the checks, uniques and defaults a table carries A table's check constraints now print as `@@check`, and its unique constraints as `@@unique`, under the field names their columns carry. A unique index still prints as `@@index(..., unique: true)`, which is what the PSL source reads back as an index. A literal default whose column type has no PSL spelling, such as a Json object, is refused with `CONTRACT.CONVERT_UNSUPPORTED` naming the table and column. It used to be written as the JSON text inside quotes, which the PSL source reads back as a plain string, so the converted file held a different value than the contract it came from. A back-relation's target fields are field names of the target model, so they are now resolved to columns through that model. Resolving them through the back-relation's own model gave the wrong columns whenever a referenced field carried `@map`, and the relation then lost the name it should have been pinned with. Also removes the `modelsByTable` index and the `indexes` test parameter, neither of which anything called. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../core/psl-infer/infer-index-attributes.ts | 12 ++- .../core/psl-print/contract-model-index.ts | 7 -- .../core/psl-print/print-column-default.ts | 39 +++++---- .../src/core/psl-print/print-psl-contract.ts | 20 ++++- .../core/psl-print/print-relation-fields.ts | 13 ++- .../test/psl-print/print-psl-contract.test.ts | 82 ++++++++++++++++++- 6 files changed, 139 insertions(+), 34 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts index 599c21d42762..1a0302df5331 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts @@ -3,7 +3,6 @@ import type { PslModelAttribute, } from '@internal/framework-components/psl-ast'; import { computeIndexContentHash, parseWireName } from '@internal/sql-schema-ir/naming'; -import type { SqlCheckConstraintIR } from '@internal/sql-schema-ir/types'; import { assertDefined } from '@internal/utils/assertions'; import { buildAttribute, escapePslString, namedArg, positionalArg } from './psl-literals'; @@ -87,6 +86,15 @@ export function buildIndexAttribute( 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 for a live, non-derived check, always in the * `map:` form. Re-detecting `name:` is not attempted: the live expression is @@ -98,7 +106,7 @@ export function buildIndexAttribute( * zero pending operations. `buildPolicyBlocks` makes the same call for * `@@map` on adopted RLS policies, for the same reason. */ -export function buildCheckAttribute(check: SqlCheckConstraintIR): PslModelAttribute { +export function buildCheckAttribute(check: CheckAttributeSource): 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-print/contract-model-index.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/contract-model-index.ts index 8952ec1cee9d..60d65fd4e82a 100644 --- 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 @@ -61,13 +61,6 @@ export function modelsByCoordinate( return new Map(entries.map((entry) => [modelCoordinate(entry.namespaceId, entry.name), entry])); } -/** Models keyed by their `(namespace, table)` coordinate. */ -export function modelsByTable(entries: readonly ModelEntry[]): ReadonlyMap { - return new Map( - entries.map((entry) => [modelCoordinate(entry.namespaceId, entry.tableName), entry]), - ); -} - export function crossReferenceCoordinate(reference: CrossReference): string { return modelCoordinate(reference.namespace, reference.model); } diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts index e2e4da433752..17ec78b11e6d 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts @@ -1,11 +1,11 @@ import type { PslFieldAttribute } from '@internal/framework-components/psl-ast'; import type { StorageColumn } from '@internal/sql-contract/types'; +import { postgresError } from '../errors'; import { buildAttribute, escapePslString, formatPslListLiteralValue, formatPslValue, - type PslDefaultValueFormat, positionalArg, pslDefaultValueFormat, } from '../psl-infer/psl-literals'; @@ -13,13 +13,6 @@ import { /** The two function defaults Prisma 8 PSL spells by name; every other one is `dbgenerated`. */ const NAMED_FUNCTION_DEFAULTS = new Set(['now()', 'autoincrement()']); -/** - * A value the field's own literal format declines — a JSON object, for - * instance — still has to reach the file as something PSL parses, so it goes - * as the JSON text of the value. - */ -const jsonText: PslDefaultValueFormat = (value) => formatPslValue(JSON.stringify(value)); - /** * The `@default(…)` attribute for a storage column, or `undefined` when the * column carries no default. @@ -28,11 +21,17 @@ const jsonText: PslDefaultValueFormat = (value) => formatPslValue(JSON.stringify * printing unquoted from its decimal text; `now()` and `autoincrement()` print * by name; every other function default prints as `dbgenerated("…")`. This is * the one place the `dbgenerated` spelling is decided. + * + * A literal the column's codec has no PSL spelling for is refused, because + * every spelling that would parse reads back as a different value. */ export function printColumnDefault(input: { readonly column: StorageColumn; readonly pslTypeName: string; readonly isEnum: boolean; + readonly namespaceId: string; + readonly tableName: string; + readonly columnName: string; }): PslFieldAttribute | undefined { const columnDefault = input.column.default; if (columnDefault === undefined) { @@ -49,10 +48,22 @@ export function printColumnDefault(input: { const format = input.isEnum ? formatPslValue : pslDefaultValueFormat(input.pslTypeName); const { value } = columnDefault; - const literal = Array.isArray(value) - ? (formatPslListLiteralValue(value, format) ?? formatPslListLiteralValue(value, jsonText)) - : (format(value) ?? jsonText(value)); - return buildAttribute('field', 'default', [ - positionalArg(literal ?? `"${escapePslString(String(value))}"`), - ]); + const literal = Array.isArray(value) ? formatPslListLiteralValue(value, format) : format(value); + if (literal === undefined) { + throw postgresError( + 'CONTRACT.CONVERT_UNSUPPORTED', + `contract convert: column "${input.namespaceId}"."${input.tableName}"."${input.columnName}" has a literal default that Prisma 8 PSL has no spelling for: the ${input.pslTypeName} type reads no PSL literal back as ${JSON.stringify(value)}.`, + { + why: 'Writing the value as a quoted string would parse, but the PSL source would read it back as a string rather than as the value the column defaults to.', + fix: 'Replace the literal default with a database expression default, or drop the default before converting.', + meta: { + namespaceId: input.namespaceId, + table: input.tableName, + column: input.columnName, + pslTypeName: input.pslTypeName, + }, + }, + ); + } + return buildAttribute('field', 'default', [positionalArg(literal)]); } diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts index 5786dc2c6d3a..5a691763eead 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts @@ -15,6 +15,7 @@ import { ifDefined } from '@internal/utils/defined'; import { postgresError } from '../errors'; import { PostgresNativeEnum } from '../postgres-native-enum'; import { + buildCheckAttribute, buildIndexAttribute, buildModelConstraintAttribute, } from '../psl-infer/infer-index-attributes'; @@ -59,6 +60,8 @@ function executionDefaultsByColumn( function scalarFieldAttributes(input: { readonly column: StorageColumn; readonly fieldName: string; + readonly namespaceId: string; + readonly tableName: string; readonly columnName: string; readonly isSingleColumnId: boolean; readonly pslTypeName: string; @@ -75,6 +78,9 @@ function scalarFieldAttributes(input: { column: input.column, pslTypeName: input.pslTypeName, isEnum: input.isEnum, + namespaceId: input.namespaceId, + tableName: input.tableName, + columnName: input.columnName, }); if (columnDefault !== undefined) { attributes.push(columnDefault); @@ -149,6 +155,8 @@ function buildScalarFields(input: { attributes: scalarFieldAttributes({ column, fieldName, + namespaceId: entry.namespaceId, + tableName: entry.tableName, columnName, isSingleColumnId: primaryKeyColumns.length === 1 && primaryKeyColumns[0] === columnName, pslTypeName: columnType.typeName, @@ -164,15 +172,19 @@ function buildScalarFields(input: { function buildModelAttributes(entry: ModelEntry): readonly PslModelAttribute[] { const attributes: PslModelAttribute[] = []; + const fieldNameOf = (column: string): string => entry.fieldNamesByColumn.get(column) ?? column; const primaryKeyColumns = entry.table.primaryKey?.columns ?? []; if (primaryKeyColumns.length > 1) { + attributes.push(buildModelConstraintAttribute('id', primaryKeyColumns.map(fieldNameOf))); + } + for (const unique of entry.table.uniques) { attributes.push( - buildModelConstraintAttribute( - 'id', - primaryKeyColumns.map((column) => entry.fieldNamesByColumn.get(column) ?? column), - ), + buildModelConstraintAttribute('unique', unique.columns.map(fieldNameOf), unique.name), ); } + for (const check of entry.table.checks ?? []) { + attributes.push(buildCheckAttribute(check)); + } for (const index of entry.table.indexes) { attributes.push( buildIndexAttribute( diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts index fbaa2290e868..86fb26c78c39 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts @@ -61,8 +61,12 @@ export function relationName(entry: RelationEntry): string { return `${entry.owner.name}_${entry.fieldName}`; } +function columnsIn(model: ModelEntry, fieldNames: readonly string[]): readonly string[] { + return fieldNames.map((fieldName) => model.storage.fields[fieldName]?.column ?? fieldName); +} + function columnsOf(entry: RelationEntry, fieldNames: readonly string[]): readonly string[] { - return fieldNames.map((fieldName) => entry.owner.storage.fields[fieldName]?.column ?? fieldName); + return columnsIn(entry.owner, fieldNames); } function sameColumns(a: readonly string[], b: readonly string[]): boolean { @@ -82,7 +86,10 @@ export function foreignKeyFor(entry: RelationEntry, target: ModelEntry): Foreign /** * The owning relation on `target` that pairs with a back-relation `entry`: the - * one pointing back at `entry`'s model over the same columns. + * 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: RelationEntry, @@ -97,7 +104,7 @@ export function owningPartner( candidate.targetCoordinate === ownerCoordinate && sameColumns( columnsOf(candidate, candidate.relation.on.localFields), - columnsOf(entry, entry.relation.on.targetFields), + columnsIn(candidate.owner, entry.relation.on.targetFields), ), ); } diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts index 3a7b6a3d2538..0c35bb32a2ab 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -8,6 +8,7 @@ import type { 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 { describe, expect, it } from 'vitest'; import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; @@ -77,21 +78,26 @@ function print(input: { ? createSqlContract(overrides) : createSqlContract({ ...overrides, execution: input.execution }); const contract = new PostgresContractSerializer().deserializeContract(json); - const ast = printPostgresPslContract(contract as Contract); + const ast = printPostgresPslContract( + blindCast, 'the Postgres serializer yields a SQL contract'>(contract), + ); return ast.namespaces.flatMap((namespace) => namespace.models); } 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[]; }): unknown { return { columns: input.columns, - uniques: [], + uniques: input.uniques ?? [], indexes: input.indexes ?? [], foreignKeys: input.foreignKeys ?? [], + ...(input.checks === undefined ? {} : { checks: input.checks }), ...(input.primaryKey === undefined ? {} : { primaryKey: input.primaryKey }), }; } @@ -158,6 +164,44 @@ describe('keys and indexes', () => { 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] = print({ + 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 every check constraint the table carries', () => { + const [model] = print({ + 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] = print({ models: { @@ -230,6 +274,37 @@ describe('column defaults', () => { }), ).toBe('@default(dbgenerated("gen_random_uuid()"))'); }); + + it('refuses a literal default the column type has no PSL spelling for', () => { + let thrown: unknown; + try { + defaultOf({ + nativeType: 'jsonb', + codecId: 'pg/jsonb@1', + nullable: false, + default: { kind: 'literal', value: { a: 1 } }, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ + code: 'CONTRACT.CONVERT_UNSUPPORTED', + message: expect.stringContaining('"public"."widget"."value"'), + }); + }); +}); + +describe('list columns', () => { + it('prints the element-not-null check a list column carries', () => { + 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)', + ]); + }); }); describe('generated values', () => { @@ -296,7 +371,7 @@ describe('generated values', () => { }); describe('relations', () => { - function postAndUser(foreignKey: Record, indexes: readonly unknown[] = []) { + function postAndUser(foreignKey: Record) { return print({ models: { User: { @@ -328,7 +403,6 @@ describe('relations', () => { post: table({ columns: { id: INT_COLUMN, authorId: INT_COLUMN }, primaryKey: { columns: ['id'] }, - indexes, foreignKeys: [ { source: { namespaceId: 'public', tableName: 'post', columns: ['authorId'] }, From 937aa58c7d77535d1e815ea724fe8678c7211ee7 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 11:57:46 +0200 Subject: [PATCH 11/55] test(sql-contract-prisma7): the printed PSL reads back with its constraints, and each expected failure names its true cause A new suite prints a hand-built contract and loads the text back through the Prisma 8 PSL source, so a check constraint, a unique constraint, a unique index, a foreign key whose name the PSL source would not derive, and an ambiguous relation pair whose referenced column is mapped are each proven to survive the round trip rather than only inspected as an AST. Each `it.fails` case in the fixture round trip now names every cause it actually has. Four fixtures lose `type.typeParams` from the domain field of a scalar list column, which is a separate defect in the PSL source and is not fixed here. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../test/convert-print-constraints.test.ts | 260 ++++++++++++++++++ .../test/convert-roundtrip.test.ts | 119 +++----- .../contract-prisma7/test/support.ts | 52 ++++ 3 files changed, 351 insertions(+), 80 deletions(-) create mode 100644 packages/2-sql/2-authoring/contract-prisma7/test/convert-print-constraints.test.ts diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/convert-print-constraints.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/convert-print-constraints.test.ts new file mode 100644 index 000000000000..82cbf5a37a4b --- /dev/null +++ b/packages/2-sql/2-authoring/contract-prisma7/test/convert-print-constraints.test.ts @@ -0,0 +1,260 @@ +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 } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { loadPrintedPsl, printContractAsPsl, storageTable } from './support'; + +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; +} + +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.keys(model.fields).map((fieldName) => [fieldName, INT_FIELD]), + ), + relations: model.relations ?? {}, + }, + ]), + ), + }; + const json = createSqlContract({ + 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), + ); +} + +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[]; +}): unknown { + 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', () => { + 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 = printContractAsPsl(contract); + expect(text).toContain( + '@@check(expression: "length(email) > 0", map: "widget_email_not_blank")', + ); + + const readBack = storageTable(await loadPrintedPsl(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 = printContractAsPsl(contract); + expect(text).toContain('@@unique([email], map: "widget_email_key")'); + + const readBack = storageTable(await loadPrintedPsl(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 = printContractAsPsl(contract); + expect(text).toContain('unique: true'); + + const readBack = storageTable(await loadPrintedPsl(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', () => { + 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 = printContractAsPsl(contract); + expect(text).toContain('map: "post_written_by_user"'); + + const readBack = storageTable(await loadPrintedPsl(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 loadPrintedPsl(printContractAsPsl(contract)), '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/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts index 7e8683fc5268..70d61f5fe381 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts @@ -1,21 +1,14 @@ -import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { existsSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import type { ContractSourceContext } from '@internal/config/config-types'; import type { Contract } from '@internal/contract/types'; -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 postgresTarget from '@internal/target-postgres/control'; -import postgresPackRef from '@internal/target-postgres/pack'; import { prisma7PostgresBinding } from '@internal/target-postgres/prisma7-binding'; import { PostgresContractSerializer } from '@internal/target-postgres/runtime'; -import { postgresCreateNamespace } from '@internal/target-postgres/types'; +import { blindCast } from '@internal/utils/casts'; import { dirname, join } from 'pathe'; import { describe, expect, it } from 'vitest'; import { prisma7Contract } from '../src/provider'; -import { postgresSourceContext } from './support'; +import { loadPrintedPsl, postgresSourceContext, printContractAsPsl } from './support'; const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); @@ -25,28 +18,28 @@ const cases = readdirSync(fixturesDir, { withFileTypes: true }) .filter((name) => existsSync(join(fixturesDir, name, 'expected-contract.json'))) .sort(); -/** - * A list column is nullable in a Prisma 7 contract and not nullable in a PSL - * one, and the printer has no spelling for a nullable list (`Tag[]?`). - */ -const listColumnCases = new Set([ - 'dbgenerated-without-expression-optional', - 'defaults', - 'enum-native', - 'list-defaults', - 'native-types-accepted', - 'number-default-spellings', - 'number-defaults', - 'scalars', -]); +const NULLABLE_LIST = + 'the printer has no spelling for a nullable list, so a list column comes back not nullable'; +const LOST_LIST_TYPE_PARAMS = + 'the PSL source drops type.typeParams from the domain field of a scalar list column'; -/** - * The Prisma 8 PSL source resolves a relation's target by model name alone, so - * two models sharing a name in different namespaces cannot be printed. - */ -const duplicateModelNameCases = new Set([ - 'junction-name-in-other-schema', - 'relation-name-in-two-schemas', +/** Why each fixture does not round-trip yet, naming every cause it has. */ +const expectedFailures: ReadonlyMap = new Map([ + ['dbgenerated-without-expression-optional', [NULLABLE_LIST]], + [ + 'defaults', + [ + 'a Json object literal default is refused, because PSL reads a quoted default back as a string', + ], + ], + ['enum-native', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], + ['junction-name-in-other-schema', ['one model name in two namespaces has no PSL spelling']], + ['list-defaults', ['the PSL source refuses a function default on a list column']], + ['native-types-accepted', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], + ['number-default-spellings', [NULLABLE_LIST]], + ['number-defaults', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], + ['relation-name-in-two-schemas', ['one model name in two namespaces has no PSL spelling']], + ['scalars', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], ]); function prisma7SchemaPath(caseName: string): string { @@ -54,19 +47,19 @@ function prisma7SchemaPath(caseName: string): string { return existsSync(directory) ? directory : join(fixturesDir, caseName, 'schema.prisma'); } -async function loadThroughSource( - load: (context: ContractSourceContext) => Promise | unknown, +async function loadPrisma7Fixture( schemaPath: string, - what: string, + caseName: string, ): Promise> { - const result = await load(postgresSourceContext([schemaPath])); - const outcome = result as - | { ok: true; value: unknown } - | { ok: false; failure: { diagnostics: unknown } }; - if (!outcome.ok) { - throw new Error(`${what} did not load: ${JSON.stringify(outcome.failure.diagnostics)}`); + const result = await prisma7Contract(schemaPath, { + binding: prisma7PostgresBinding, + }).source.load(postgresSourceContext([schemaPath])); + if (!result.ok) { + throw new Error( + `Prisma 7 fixture "${caseName}" did not load: ${JSON.stringify(result.failure.diagnostics)}`, + ); } - return outcome.value as Contract; + return blindCast, 'the Prisma 7 source yields a SQL contract'>(result.value); } function serialize(contract: Contract): unknown { @@ -75,37 +68,8 @@ function serialize(contract: Contract): unknown { async function roundTrip(caseName: string): Promise { const schemaPath = prisma7SchemaPath(caseName); - const sourceContext = postgresSourceContext([schemaPath]); - const prisma7 = await loadThroughSource( - (context) => - prisma7Contract(schemaPath, { binding: prisma7PostgresBinding }).source.load(context), - schemaPath, - `Prisma 7 fixture "${caseName}"`, - ); - - const ast = postgresTarget.printPslContract?.(prisma7); - if (ast === undefined) { - throw new Error('the Postgres target descriptor has no printPslContract hook'); - } - const text = printPsl(ast, { - pslBlockDescriptors: sourceContext.authoringContributions.pslBlockDescriptors, - codecLookup: sourceContext.codecLookup, - }); - - const directory = mkdtempSync(join(tmpdir(), 'prisma7-convert-')); - const printedPath = join(directory, 'contract.prisma'); - writeFileSync(printedPath, text); - - const printedContract = await loadThroughSource( - (context) => - prismaContract(printedPath, { - target: postgresPackRef, - createNamespace: postgresCreateNamespace, - enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, - }).source.load(context), - printedPath, - `the PSL printed from "${caseName}"`, - ); + const prisma7 = await loadPrisma7Fixture(schemaPath, caseName); + const printedContract = await loadPrintedPsl(printContractAsPsl(prisma7)); expect(serialize(printedContract)).toEqual(serialize(prisma7)); expect(printedContract.storage.storageHash).toBe(prisma7.storage.storageHash); @@ -113,14 +77,9 @@ async function roundTrip(caseName: string): Promise { describe('a printed Prisma 7 contract reads back as the same contract', () => { for (const caseName of cases) { - if (listColumnCases.has(caseName)) { - it.fails(`${caseName} (a nullable list has no PSL spelling yet)`, async () => { - await roundTrip(caseName); - }); - continue; - } - if (duplicateModelNameCases.has(caseName)) { - it.fails(`${caseName} (one model name in two namespaces has no PSL spelling)`, async () => { + const reasons = expectedFailures.get(caseName); + if (reasons !== undefined) { + it.fails(`${caseName} (${reasons.join('; ')})`, async () => { await roundTrip(caseName); }); continue; diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/support.ts b/packages/2-sql/2-authoring/contract-prisma7/test/support.ts index 6504f98aea01..a5b81a09a7ac 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/support.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/support.ts @@ -1,3 +1,5 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import postgresAdapter from '@internal/adapter-postgres/control'; import type { ContractSourceContext } from '@internal/config/config-types'; @@ -5,10 +7,16 @@ import type { Contract } from '@internal/contract/types'; import postgresDriver from '@internal/driver-postgres/control'; import sql from '@internal/family-sql/control'; import { createControlStack } 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 { prisma7PostgresBinding } from '@internal/target-postgres/prisma7-binding'; import { PostgresContractSerializer } from '@internal/target-postgres/runtime'; +import { postgresCreateNamespace } from '@internal/target-postgres/types'; +import { blindCast } from '@internal/utils/casts'; import { dirname, join } from 'pathe'; import { prisma7Contract } from '../src/provider'; @@ -42,6 +50,50 @@ export function loadFixtureSchema(caseName: string) { ); } +/** Prints a contract as Prisma 8 PSL text, the way `contract convert` does. */ +export function printContractAsPsl(contract: Contract): string { + const ast = postgres.printPslContract?.(contract); + if (ast === undefined) { + throw new Error('the Postgres target descriptor has no printPslContract hook'); + } + const context = postgresSourceContext([]); + return printPsl(ast, { + pslBlockDescriptors: context.authoringContributions.pslBlockDescriptors, + codecLookup: context.codecLookup, + }); +} + +/** Loads printed PSL text back through the Prisma 8 PSL source. */ +export async function loadPrintedPsl(text: string): Promise> { + const directory = mkdtempSync(join(tmpdir(), 'prisma7-convert-')); + const printedPath = join(directory, 'contract.prisma'); + writeFileSync(printedPath, text); + const result = await prismaContract(printedPath, { + target: postgresPackRef, + createNamespace: postgresCreateNamespace, + enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, + }).source.load(postgresSourceContext([printedPath])); + if (!result.ok) { + throw new Error(`the printed PSL did not load: ${JSON.stringify(result.failure.diagnostics)}`); + } + return blindCast, 'the Postgres PSL source yields a SQL contract'>( + result.value, + ); +} + +/** The storage table of a loaded contract, or a thrown error naming what is missing. */ +export function storageTable( + contract: Contract, + tableName: string, + namespaceId = 'public', +) { + const table = contract.storage.namespaces[namespaceId]?.entries.table?.[tableName]; + if (table === undefined) { + throw new Error(`the contract has no table "${namespaceId}"."${tableName}"`); + } + return table; +} + interface SerializedTable { readonly columns: Record>; readonly foreignKeys: readonly Record[]; From 1e6d74c355ed8ea3f399949270220d1404ede5e1 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 11:57:54 +0200 Subject: [PATCH 12/55] docs(sql-contract-psl): the uniqueness rule names the unique index that now counts A unique index satisfies the uniqueness a singular back-relation needs, as a unique constraint already did. The comment above the check still listed only the primary key and the unique constraints. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../2-authoring/contract-psl/src/psl-relation-resolution.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 0a4a99acfca7..fcb3ceac662c 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 @@ -360,8 +360,9 @@ 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 + * column sets — its primary key, any single- or multi-column `@unique` / + * `@@unique` constraint, or any unique index. 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. */ From 22b145e67a50845a99e6dcaf0ac52b3c067c4ecd Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 12:03:02 +0200 Subject: [PATCH 13/55] refactor: the converter's messages and tests say what cannot be written, in plain words Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- docs/reference/error-reference.md | 2 +- .../contract-prisma7/test/convert-roundtrip.test.ts | 12 +++++++++--- .../2-authoring/contract-psl/src/psl-name-mapping.ts | 4 ++-- .../src/core/psl-print/print-column-default.ts | 10 +++++----- .../postgres/src/core/psl-print/print-column-type.ts | 2 +- .../src/core/psl-print/print-execution-defaults.ts | 6 +++--- .../test/psl-print/print-psl-contract.test.ts | 2 +- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 009146de3cd4..aae36e70d4b9 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -251,7 +251,7 @@ A model declares an empty unique constraint (a unique with no fields), raised du ### CONTRACT.CONVERT_UNSUPPORTED -`contract convert` cannot print the loaded contract as a Prisma 8 PSL file: either the configured target has no `printPslContract` hook on its descriptor (meta: `targetId`), or the contract holds something Prisma 8 PSL has no spelling for — a column whose native type is outside the target's type map, a generated value the target cannot author, a to-one relation with no foreign key behind it, or one model name declared in more than one namespace (a relation's target is resolved by model name alone, so the two would read back as one). Meta at the shape sites: `coordinate`, `nativeType`, `onCreate`, `onUpdate`, `model`, `field`, `modelName`, `namespaces`. +`contract convert` cannot print the loaded contract as a Prisma 8 PSL file: either the configured target has no `printPslContract` hook on its descriptor (meta: `targetId`), or the contract holds something that cannot be written in a Prisma 8 PSL file — a column whose native type is outside the target's type map, a generated value the target cannot author, a to-one relation with no foreign key behind it, or one model name declared in more than one namespace (a relation's target is resolved by model name alone, so the two would read back as one). Meta at the shape sites: `coordinate`, `nativeType`, `onCreate`, `onUpdate`, `model`, `field`, `modelName`, `namespaces`. ### CONTRACT.DEFAULT_INVALID diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts index 70d61f5fe381..88cbdc0fa649 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts @@ -19,7 +19,7 @@ const cases = readdirSync(fixturesDir, { withFileTypes: true }) .sort(); const NULLABLE_LIST = - 'the printer has no spelling for a nullable list, so a list column comes back not nullable'; + 'the printer cannot print a nullable list type, so a list column comes back not nullable'; const LOST_LIST_TYPE_PARAMS = 'the PSL source drops type.typeParams from the domain field of a scalar list column'; @@ -33,12 +33,18 @@ const expectedFailures: ReadonlyMap = new Map([ ], ], ['enum-native', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], - ['junction-name-in-other-schema', ['one model name in two namespaces has no PSL spelling']], + [ + 'junction-name-in-other-schema', + ['one model name in two namespaces cannot be written in a Prisma 8 schema'], + ], ['list-defaults', ['the PSL source refuses a function default on a list column']], ['native-types-accepted', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], ['number-default-spellings', [NULLABLE_LIST]], ['number-defaults', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], - ['relation-name-in-two-schemas', ['one model name in two namespaces has no PSL spelling']], + [ + 'relation-name-in-two-schemas', + ['one model name in two namespaces cannot be written in a Prisma 8 schema'], + ], ['scalars', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], ]); 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 index af5070cf3d2f..7983909c9977 100644 --- 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 @@ -3,7 +3,7 @@ import { lowerFirst } from './psl-attribute-parsing'; /** * 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 spells it. + * name already matches it. */ export function pslModelMapName(modelName: string, tableName: string): string | undefined { return tableName === lowerFirst(modelName) ? undefined : tableName; @@ -11,7 +11,7 @@ export function pslModelMapName(modelName: string, tableName: string): string | /** * The `@map` name a field must carry for its column to read back as - * `columnName`, or `undefined` when the field name already spells it. + * `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/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts index 17ec78b11e6d..b77f8c6ef601 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts @@ -10,7 +10,7 @@ import { pslDefaultValueFormat, } from '../psl-infer/psl-literals'; -/** The two function defaults Prisma 8 PSL spells by name; every other one is `dbgenerated`. */ +/** The two function defaults Prisma 8 PSL writes by name; every other one is `dbgenerated`. */ const NAMED_FUNCTION_DEFAULTS = new Set(['now()', 'autoincrement()']); /** @@ -20,10 +20,10 @@ const NAMED_FUNCTION_DEFAULTS = new Set(['now()', 'autoincrement()']); * A literal prints as the PSL literal its codec reads back, with a number * printing unquoted from its decimal text; `now()` and `autoincrement()` print * by name; every other function default prints as `dbgenerated("…")`. This is - * the one place the `dbgenerated` spelling is decided. + * the one place that decides when to write `dbgenerated`. * - * A literal the column's codec has no PSL spelling for is refused, because - * every spelling that would parse reads back as a different value. + * A literal the column's codec cannot write as a PSL literal is refused, + * because every literal that would parse reads back as a different value. */ export function printColumnDefault(input: { readonly column: StorageColumn; @@ -52,7 +52,7 @@ export function printColumnDefault(input: { if (literal === undefined) { throw postgresError( 'CONTRACT.CONVERT_UNSUPPORTED', - `contract convert: column "${input.namespaceId}"."${input.tableName}"."${input.columnName}" has a literal default that Prisma 8 PSL has no spelling for: the ${input.pslTypeName} type reads no PSL literal back as ${JSON.stringify(value)}.`, + `contract convert: column "${input.namespaceId}"."${input.tableName}"."${input.columnName}" has a literal default that cannot be written in Prisma 8 PSL: the ${input.pslTypeName} type reads no PSL literal back as ${JSON.stringify(value)}.`, { why: 'Writing the value as a quoted string would parse, but the PSL source would read it back as a string rather than as the value the column defaults to.', fix: 'Replace the literal default with a database expression default, or drop the default before converting.', diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts index cbdfaff6d019..e0f7e16da5d2 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts @@ -57,7 +57,7 @@ export function printColumnType(input: { if ('unsupported' in resolution) { throw postgresError( 'CONTRACT.CONVERT_UNSUPPORTED', - `contract convert: column ${coordinate} has native type "${column.nativeType}", which has no Prisma 8 PSL spelling.`, + `contract convert: column ${coordinate} has native type "${column.nativeType}", which cannot be written in Prisma 8 PSL.`, { why: 'The Postgres contract-to-PSL printer maps each native type back to the PSL type that produces it; this one is not in that map.', fix: 'Retype the column, or author the Prisma 8 contract by hand.', diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts index 4174bda62b27..49ca92dded99 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts @@ -49,7 +49,7 @@ export type PrintedExecutionDefault = | { readonly kind: 'temporal'; readonly phases: TemporalPresetPhases }; /** - * The PSL spelling of one column's execution generators: an id generator + * How one column's execution generators are written in PSL: an id generator * prints as a `@default(())` attribute, and a wall-clock-now generator * prints as the `temporal.*` preset the column's codec is authored through. */ @@ -76,9 +76,9 @@ export function printExecutionDefault(input: { if (call === undefined || onUpdateId !== undefined) { throw postgresError( 'CONTRACT.CONVERT_UNSUPPORTED', - `contract convert: column ${coordinate} carries generated value "${onCreateId ?? onUpdateId}", which has no Prisma 8 PSL spelling.`, + `contract convert: column ${coordinate} carries generated value "${onCreateId ?? onUpdateId}", which cannot be written in Prisma 8 PSL.`, { - why: 'The Postgres contract-to-PSL printer knows one authored spelling per generator; this generator is not among them.', + why: 'The Postgres contract-to-PSL printer knows one way to write each generator; this generator is not among them.', fix: 'Author the Prisma 8 contract by hand for this column.', meta: { coordinate, onCreate: onCreateId, onUpdate: onUpdateId }, }, diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts index 0c35bb32a2ab..831b38bd7d03 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -275,7 +275,7 @@ describe('column defaults', () => { ).toBe('@default(dbgenerated("gen_random_uuid()"))'); }); - it('refuses a literal default the column type has no PSL spelling for', () => { + it('refuses a literal default that cannot be written in PSL for the column type', () => { let thrown: unknown; try { defaultOf({ From c32576101b402eb94772755b101fba30840517e8 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 12:07:53 +0200 Subject: [PATCH 14/55] docs(project): the spec records what contract convert cannot write yet and why Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- projects/prisma7-contract-source/spec.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index 0a391d156070..919f64d47601 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -110,10 +110,21 @@ Recorded so they are not lost; each becomes its own project when scheduled. - 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`. +- **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 slice 2: the Mongo PSL interpreter silently ignores unknown top-level blocks (`view` included); slice 2 adds the diagnostic. +### What `contract convert` cannot write yet + +The converter prints the loaded contract as a Prisma 8 schema and the round-trip test reads it back through the Prisma 8 PSL source. These are the cases where the Prisma 8 schema language or its reader cannot carry what the Prisma 7 contract holds. Each is a Prisma 8 feature to build, not a converter defect; the converter refuses the first three with `CONTRACT.CONVERT_UNSUPPORTED` rather than writing a file that reads back differently. + +- A `Json` or `Jsonb` column whose default is an object or array literal. The PSL reader treats `@default("...")` on a Json field as a string, so there is no way to write the parsed value. +- One model name declared in two namespaces. The PSL reader keys relation targets, junction detection and id columns by model name alone (`contract-psl/src/interpreter.ts`, `fkRelationsByDeclaringModel`, `modelIdColumns`), so the two models read back as one. +- A generator the Postgres printer has no PSL form for (none of the Prisma 7 generators, which all print). +- A database-side default on a list column (`@default(dbgenerated("ARRAY[...]::text[]"))`). The PSL reader refuses it with `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`, although it is a storage default. Every Prisma 7 list default is one. In `contract-psl/src/psl-field-resolution.ts`. Exists on `main`. +- A nullable list type (`Tag[]?`). The PSL printer writes `[]` or `?`, never both, while every Prisma 7 list column is nullable. In `@internal/psl-printer`. Exists on `main`; the same defect makes `contract infer` print nullable lists as required. +- Type parameters on a list field (`Decimal @db.Numeric(65,30)[]`). The PSL reader keeps precision and scale on the storage column but drops them from the domain field's type. In `contract-psl/src/interpreter.ts`, `patchModelDomainFields`. Exists on `main`. + ### 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. @@ -127,7 +138,7 @@ Each exists on `main` unless the line says otherwise, so none is a regression th - `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. From c1e26edd6186af501f18c08a18d008cd7f88d9b4 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 12:08:30 +0200 Subject: [PATCH 15/55] fix(cli): a refusal the target raises reaches the user with its own code contract convert kept the dotted code only for errors built by the CLI's own factory, so the Postgres printer's CONTRACT.CONVERT_UNSUPPORTED arrived as CLI.UNEXPECTED with its summary, reason and advice discarded. normalizeError already reads any structured error and falls back to CLI.UNEXPECTED on its own. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../3-tooling/cli/src/orm/contract/convert.ts | 19 ++--------- .../cli/test/orm/contract-convert.test.ts | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts index 05982a9d6c4b..20c308e329db 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts @@ -8,12 +8,7 @@ import { relative } from 'pathe'; import { createControlClient as createDefaultControlClient } from '../../control-api/client'; import { loadContractSource } from '../../control-api/operations/load-contract-source'; import type { ControlClient, ControlClientOptions } from '../../control-api/types'; -import { - CliStructuredError, - errorContractConfigMissing, - errorRuntime, - errorUnexpected, -} from '../../utils/cli-errors'; +import { errorContractConfigMissing, errorRuntime } from '../../utils/cli-errors'; import { closeQuietly } from '../../utils/command-helpers'; import { runCommandAction } from '../../utils/next-actions'; import { publishTextArtifact } from '../../utils/publish-text-artifact'; @@ -179,17 +174,7 @@ export function createContractConvertCommand({ headerComment: convertHeaderComment(schemaPath), }); } catch (error) { - if (CliStructuredError.is(error)) { - return notOk(normalizeError(error)); - } - const message = error instanceof Error ? error.message : String(error); - return notOk( - normalizeError( - errorUnexpected(message, { - why: `Unexpected error during contract convert: ${message}`, - }), - ), - ); + return notOk(normalizeError(error)); } finally { await closeQuietly(client); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts index 48120dff5cc0..74724c7560f0 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts @@ -1,5 +1,6 @@ import { readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { ok } from '@internal/utils/result'; +import { structuredError } from '@internal/utils/structured-error'; import type { ErroredEnvelope, MountedTree, StreamEvent } from '@prisma/cli-engine'; import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; @@ -256,4 +257,37 @@ describe('contract convert', () => { expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.CONVERT_UNSUPPORTED' }); expect(mocks.close).toHaveBeenCalled(); }); + + it('reports the code, summary and next actions of a refusal the target raised', async () => { + const dir = await projectDir(); + mocks.printPslContract.mockImplementation(() => { + throw structuredError( + 'CONTRACT.CONVERT_UNSUPPORTED', + 'contract convert: column "public"."Defaults"."jsonLiteral" has a literal default that cannot be written in Prisma 8 PSL.', + { + why: 'The PSL source would read the written value back as a string rather than as the value the column defaults to.', + fix: 'Replace the literal default with a database expression default, or drop the default before converting.', + meta: { namespaceId: 'public', table: 'Defaults', column: 'jsonLiteral' }, + }, + ); + }); + + const run = await harness(ormConfig(dir)).run(['contract', 'convert', '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.CONVERT_UNSUPPORTED', + summary: expect.stringContaining('"public"."Defaults"."jsonLiteral"'), + why: 'The PSL source would read the written value back as a string rather than as the value the column defaults to.', + nextActions: [ + { + kind: 'user-choice', + label: + 'Replace the literal default with a database expression default, or drop the default before converting.', + }, + ], + meta: { namespaceId: 'public', table: 'Defaults', column: 'jsonLiteral' }, + }); + expect(await readdir(dir)).not.toContain('generated'); + }); }); From cba58a2805eac91978e8860ef127bfc41f00c6a5 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 12:09:30 +0200 Subject: [PATCH 16/55] fix(cli): the cutover steps start with pointing the config at the written file Running contract emit before the config names the converted file emits through the Prisma 7 source again, so the first next step now names the file the command just wrote. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../3-tooling/cli/src/orm/contract/convert.ts | 28 +++++++++++-------- .../cli/test/orm/contract-convert.test.ts | 4 +++ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts index 20c308e329db..45e9d645bc74 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts @@ -10,7 +10,7 @@ import { loadContractSource } from '../../control-api/operations/load-contract-s import type { ControlClient, ControlClientOptions } from '../../control-api/types'; import { errorContractConfigMissing, errorRuntime } from '../../utils/cli-errors'; import { closeQuietly } from '../../utils/command-helpers'; -import { runCommandAction } from '../../utils/next-actions'; +import { chooseAction, runCommandAction } from '../../utils/next-actions'; import { publishTextArtifact } from '../../utils/publish-text-artifact'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; @@ -28,22 +28,26 @@ interface ConvertDocument { /** * The routine that carries a converted contract onto the database Prisma 7 - * built: emit, plan a baseline, sign, then point the `db` ref at the baseline. + * built: point the config at the written file, emit, plan a baseline, sign, + * then point the `db` ref at the baseline. */ -const CUTOVER_ACTIONS: readonly NextAction[] = [ - runCommandAction('Emit the converted contract', '{bin} contract emit'), - runCommandAction('Plan the baseline migration', '{bin} migration plan --name baseline'), - runCommandAction('Sign the database', '{bin} db sign'), - runCommandAction( - 'Point the db ref at the baseline migration', - '{bin} migration ref set db _baseline', - ), -]; +function cutoverActions(writtenPath: string): readonly NextAction[] { + return [ + chooseAction(`Point contract in prisma.config.ts at ${writtenPath}`), + runCommandAction('Emit the converted contract', '{bin} contract emit'), + runCommandAction('Plan the baseline migration', '{bin} migration plan --name baseline'), + runCommandAction('Sign the database', '{bin} db sign'), + runCommandAction( + 'Point the db ref at the baseline migration', + '{bin} migration ref set db _baseline', + ), + ]; +} function convertPresentations(document: ConvertDocument): Presentations { return { stdout: () => [], - next: () => CUTOVER_ACTIONS, + next: () => cutoverActions(document.psl.path), human: (): readonly Block[] => [ { kind: 'summary', diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts index 74724c7560f0..fe5c68e47e2e 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts @@ -193,6 +193,10 @@ describe('contract convert', () => { }, ]); 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 converted contract', command: '{bin} contract emit' }, { kind: 'run-command', From 71bc9ff1a357d91e9b9863c6835eb1ed4ebeb2cd Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 12:13:23 +0200 Subject: [PATCH 17/55] fix(cli): contract convert honours a cancellation while it reads the schema contract emit already passes the command's abort signal to the source loader; convert now does the same. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts index 45e9d645bc74..fd3d798125a5 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts @@ -155,6 +155,7 @@ export function createContractConvertCommand({ const { stack, contract } = await loadContractSource({ config: ctx.config, contractConfig, + signal: ctx.signal, }); const pslContractAst = client.printPslContract(contract); if (pslContractAst === undefined) { From 94a81593b37d436a75e1193f380c928f23789de7 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 12:13:27 +0200 Subject: [PATCH 18/55] test(journeys): the convert journey asserts the refusal a JSON object default earns The reference schema was covered by an it.fails whose stated reason was a list-column default it never reached. It is now a positive assertion: exit 2, CONTRACT.CONVERT_UNSUPPORTED, and a message naming the Defaults.jsonLiteral column, with nothing written. The two refusal cases that never open a database no longer carry a database timeout. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../cli-journeys/prisma7-convert.e2e.test.ts | 101 ++++++++---------- 1 file changed, 43 insertions(+), 58 deletions(-) diff --git a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts index 4620cfd95297..574f81ea5319 100644 --- a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts @@ -3,9 +3,10 @@ * `prisma.config.ts` points at `prisma7Schema('./schema.prisma')` runs * `contract convert`, switches its config to the Prisma 8 PSL the command * wrote, and emits the same contract — which `db verify` then reports zero - * findings against the database the Prisma 7 SQL built. A config on any other - * contract source, and a Prisma 7 schema Prisma 8 refuses, both exit 2 and - * write nothing. + * findings against the database the Prisma 7 SQL built. Three things are + * refused with exit 2 and no file written: a config on any other contract + * source, a Prisma 7 schema Prisma 8 cannot read, and a schema holding a + * default that cannot be written in Prisma 8 PSL. */ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { withClient } from '@repo/test-utils'; @@ -27,8 +28,7 @@ import { const PRISMA7_FIXTURES = join(__dirname, '../fixtures/prisma7-source'); const JOURNEY_FIXTURES = join(__dirname, '../fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys'); -const LIST_COLUMN_SQL = readFileSync(join(PRISMA7_FIXTURES, 'supported/migration.sql'), 'utf-8'); -const NO_LIST_COLUMN_SQL = readFileSync( +const PRISMA7_DDL = readFileSync( join(PRISMA7_FIXTURES, 'implicit-many-to-many-names/migration.sql'), 'utf-8', ); @@ -102,12 +102,13 @@ function storageHashOf(run: EngineCommandResult): string { return storageHash; } -function errorCodeOf(run: EngineCommandResult): string | undefined { +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) { - return undefined; + throw new Error('the run did not settle as an error'); } - return terminal.envelope.error.code; + const { code, summary } = terminal.envelope.error; + return { code, summary }; } /** @@ -151,31 +152,9 @@ async function convertAndVerify(ctx: JourneyContext, connectionString: string): } withTempDir(({ createTempDir }) => { - describe('Journey: converting a Prisma 7 schema over a schema with list columns', () => { + describe('Journey: converting a Prisma 7 schema and emitting it as Prisma 8', () => { const db = useDevDatabase({ - onReady: (cs) => withClient(cs, (client) => client.query(LIST_COLUMN_SQL)), - }); - - it.fails( - 'converts the reference schema and verifies against the database Prisma 7 built', - async () => { - // The Prisma 8 PSL source refuses a function default on a list column, - // and every Prisma 7 list default is one, so the written file does not - // read back yet. - await convertAndVerify( - setupPrisma7Project(createTempDir, db.connectionString, { - copyFrom: join(PRISMA7_FIXTURES, 'supported-verify/schema.prisma'), - }), - db.connectionString, - ); - }, - timeouts.spinUpPpgDev, - ); - }); - - describe('Journey: converting a Prisma 7 schema with no list columns', () => { - const db = useDevDatabase({ - onReady: (cs) => withClient(cs, (client) => client.query(NO_LIST_COLUMN_SQL)), + onReady: (cs) => withClient(cs, (client) => client.query(PRISMA7_DDL)), }); it( @@ -193,36 +172,42 @@ withTempDir(({ createTempDir }) => { }); describe('Journey: contract convert refuses what it cannot convert', () => { - it( - 'refuses a contract source that is not a Prisma 7 schema and writes nothing', - async () => { - const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { - copyFrom: join(PRISMA7_FIXTURES, 'implicit-many-to-many-names/schema.prisma'), - }); - writeFileSync(join(ctx.testDir, 'contract.prisma'), 'model User {\n id Int @id\n}\n'); - const onPsl = onConvertedContract(ctx, NO_DATABASE); + it('refuses a contract source that is not a Prisma 7 schema and writes nothing', async () => { + const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { + copyFrom: join(PRISMA7_FIXTURES, 'implicit-many-to-many-names/schema.prisma'), + }); + writeFileSync(join(ctx.testDir, 'contract.prisma'), 'model User {\n id Int @id\n}\n'); + const onPsl = onConvertedContract(ctx, NO_DATABASE); + + const convert = await runContractConvert(onPsl, ['--output', 'converted.prisma', '--json']); + + expect(convert.exitCode, output(convert)).toBe(2); + expect(errorOf(convert).code).toBe('CONTRACT.CONVERT_SOURCE_NOT_PRISMA7'); + expect(existsSync(join(ctx.testDir, 'converted.prisma'))).toBe(false); + }); - const convert = await runContractConvert(onPsl, ['--output', 'converted.prisma', '--json']); + it('reports what the Prisma 7 source reports about a view and writes nothing', async () => { + const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { text: VIEW_SCHEMA }); - expect(convert.exitCode, output(convert)).toBe(2); - expect(errorCodeOf(convert)).toBe('CONTRACT.CONVERT_SOURCE_NOT_PRISMA7'); - expect(existsSync(join(ctx.testDir, 'converted.prisma'))).toBe(false); - }, - timeouts.spinUpPpgDev, - ); + const convert = await runContractConvert(ctx, ['--json']); - it( - 'reports what the Prisma 7 source reports about a view and writes nothing', - async () => { - const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { text: VIEW_SCHEMA }); + expect(convert.exitCode, output(convert)).toBe(2); + expect(errorOf(convert).code).toBe('CONTRACT.SOURCE_LOAD_FAILED'); + expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); + }); - const convert = await runContractConvert(ctx, ['--json']); + it('refuses a JSON object literal default it cannot write and writes nothing', async () => { + const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { + copyFrom: join(PRISMA7_FIXTURES, 'supported-verify/schema.prisma'), + }); - expect(convert.exitCode, output(convert)).toBe(2); - expect(errorCodeOf(convert)).toBe('CONTRACT.SOURCE_LOAD_FAILED'); - expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); - }, - timeouts.spinUpPpgDev, - ); + const convert = await runContractConvert(ctx, ['--json']); + + expect(convert.exitCode, output(convert)).toBe(2); + const { code, summary } = errorOf(convert); + expect(code).toBe('CONTRACT.CONVERT_UNSUPPORTED'); + expect(summary).toContain('"Defaults"."jsonLiteral"'); + expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); + }); }); }); From 4edc3a3d9b73d9af40cdbdfac329c0e64d1cc872 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 12:14:32 +0200 Subject: [PATCH 19/55] docs: contract convert documents the refusal most conversions meet first The READMEs listed two refusals and left the impression that conversion always succeeds. They now list CONTRACT.CONVERT_UNSUPPORTED as well, with the JSON object literal default as its common case and what to do about it. The slice spec now says the printer gained a header option, which is what was built. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- packages/1-framework/3-tooling/cli/README.md | 3 ++- packages/3-extensions/postgres/README.md | 2 +- .../slices/03-contract-to-psl-and-convert/spec.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 64f5313024a0..9e18be417dd8 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -372,7 +372,8 @@ The written file opens with the two lines that say where it came from: // Converted from prisma/schema.prisma by `prisma contract convert`. ``` -Two things are refused, and neither writes a file: +Some Prisma 7 schemas cannot be converted yet. Three things are refused, and none of them writes a file: +- A schema holding something a Prisma 8 schema cannot express exits `2` with `CONTRACT.CONVERT_UNSUPPORTED`, naming the column it stopped on. This is the one most conversions meet first. A JSON object literal default is the common case: the written value would read back as a string rather than as the value the column defaults to. Replace such a default with a database expression default, or drop it, then convert again. - A contract source that is not a Prisma 7 schema exits `2` with `CONTRACT.CONVERT_SOURCE_NOT_PRISMA7`. A contract already authored the Prisma 8 way has nothing to convert. - A Prisma 7 schema Prisma 8 cannot read exits `2` with `CONTRACT.SOURCE_LOAD_FAILED`, reporting exactly what `contract emit` reports for the same schema. diff --git a/packages/3-extensions/postgres/README.md b/packages/3-extensions/postgres/README.md index 41cf91b730d1..f50d5af819ce 100644 --- a/packages/3-extensions/postgres/README.md +++ b/packages/3-extensions/postgres/README.md @@ -99,7 +99,7 @@ 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 convert` writes the Prisma 8 PSL that produces the same contract, with a header naming the schema it came from. 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`. The command needs no database connection, and refuses rather than approximating, so the contract before and after the switch is the same one. +When the project is ready to stop reading the Prisma 7 file, `prisma contract convert` writes the Prisma 8 PSL that produces the same contract, with a header naming the schema it came from. 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`. 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 converts yet: a construct a Prisma 8 schema cannot express, a JSON object literal default above all, exits `2` with `CONTRACT.CONVERT_UNSUPPORTED` 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: 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 index f82a012a16a5..7d2d91eba513 100644 --- 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 @@ -17,7 +17,7 @@ Output begins: ## 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. +- **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 a `headerComment` option the command passes; its default leaves `contract infer` output unchanged. - **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 2 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. From 2e7e73f05d9adbc827861d6fa9e5cedd1e489e65 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 12:17:16 +0200 Subject: [PATCH 20/55] docs(project): a mapped enum no column uses converts under its database name Found while trying the relations integration fixture as the convert journey: the printer learns an enum's Prisma-side name from a column that carries it, so an unused mapped enum reads back keyed by the database type name and the storage hash changes. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- projects/prisma7-contract-source/spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index 919f64d47601..f0f0920ac816 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -123,6 +123,7 @@ The converter prints the loaded contract as a Prisma 8 schema and the round-trip - A generator the Postgres printer has no PSL form for (none of the Prisma 7 generators, which all print). - A database-side default on a list column (`@default(dbgenerated("ARRAY[...]::text[]"))`). The PSL reader refuses it with `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`, although it is a storage default. Every Prisma 7 list default is one. In `contract-psl/src/psl-field-resolution.ts`. Exists on `main`. - A nullable list type (`Tag[]?`). The PSL printer writes `[]` or `?`, never both, while every Prisma 7 list column is nullable. In `@internal/psl-printer`. Exists on `main`; the same defect makes `contract infer` print nullable lists as required. +- A mapped enum no column uses (`enum Role { ... @@map("user_role") }` with no field of type `Role`). The printer learns the Prisma-side name from a column that carries the enum, so with no such column it names the block after the database type and the contract reads back with the value set keyed `user_role` rather than `Role`. The converter writes the file rather than refusing, so the storage hash changes. Found while trying the `relations` integration fixture as the convert journey. - Type parameters on a list field (`Decimal @db.Numeric(65,30)[]`). The PSL reader keeps precision and scale on the storage column but drops them from the domain field's type. In `contract-psl/src/interpreter.ts`, `patchModelDomainFields`. Exists on `main`. ### Found outside this project's scope From 166a48fb344b68bdce8358c559e73371a3ce8d11 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 12:27:27 +0200 Subject: [PATCH 21/55] fix(target-postgres): a native enum takes its block name from the contract, not from a column A contract keys a native enum by its physical type name and keys the value set it derives by the name the schema gave the enum, so the value set is the only place the authored name survives. The printer read the name off a column that carried the enum, so a mapped enum no column refers to was named after its database type and read back with its value set keyed by that type name, changing the storage hash. The printer now matches an unreferenced enum to the one unclaimed value set holding exactly its members. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../contract-prisma7/test/fixtures.test.ts | 1 + .../enum-unreferenced/expected-contract.json | 84 ++++++++++++ .../fixtures/enum-unreferenced/migration.sql | 12 ++ .../fixtures/enum-unreferenced/schema.prisma | 19 +++ .../src/core/psl-print/print-enum-blocks.ts | 51 +++++-- .../src/core/psl-print/print-psl-contract.ts | 7 +- .../test/psl-print/print-psl-contract.test.ts | 129 +++++++++++++++++- projects/prisma7-contract-source/spec.md | 1 - 8 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/expected-contract.json create mode 100644 packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/migration.sql create mode 100644 packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-unreferenced/schema.prisma 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 355fba4c1426..0ae33ac0c3d1 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 @@ -51,6 +51,7 @@ describe('Prisma 7 fixtures', () => { 'enum-default-member', '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-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/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts index 0e473646c926..8c09c5f23056 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts @@ -9,18 +9,26 @@ export interface NativeEnumEmission { readonly blockNamesByTypeName: 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. * - * The block's name is the name of the value set the enum derives, because that - * is what a column referring to the enum points at: a column typed by the enum - * names it directly, and an enum no column uses keeps the name its value set - * carries. `@@map` carries the physical type name whenever the two differ. + * The block's name is the name of the value set the enum derives, never the + * physical type name: 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, so the value + * set is the only place the authored name survives. A column typed by the enum + * names its value set directly. An enum no column refers to is matched to the + * one unclaimed value set that holds exactly its members, in order; if no value + * set matches, or more than one does, the block keeps the type name. `@@map` + * carries the physical type name whenever the two differ. */ export function buildNativeEnumBlocksForNamespace(input: { readonly namespaceId: string; readonly nativeEnums: ReadonlyMap; - readonly valueSetNames: ReadonlySet; + readonly valueSets: ReadonlyMap; readonly columns: readonly StorageColumn[]; }): NativeEnumEmission { const valueSetNamesByTypeName = new Map(); @@ -29,19 +37,40 @@ export function buildNativeEnumBlocksForNamespace(input: { if (valueSetName === undefined) continue; valueSetNamesByTypeName.set(column.nativeType, valueSetName); } + const claimed = new Set(valueSetNamesByTypeName.values()); - const blocks: PslExtensionBlock[] = []; + const unreferenced: { entryName: string; nativeEnum: PostgresNativeEnum }[] = []; const blockNamesByTypeName = new Map(); + const nameByEntry = new Map(); for (const [entryName, nativeEnum] of input.nativeEnums) { const { typeName } = nativeEnum; - const qualifiedTypeName = `${input.namespaceId}.${typeName}`; - const blockName = + const fromColumn = valueSetNamesByTypeName.get(typeName) ?? - valueSetNamesByTypeName.get(qualifiedTypeName) ?? - (input.valueSetNames.has(entryName) ? entryName : 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 exact = candidates.find((name) => name === entryName); + const chosen = exact ?? (candidates.length === 1 ? candidates[0] : undefined); + if (chosen !== undefined) claimed.add(chosen); + nameByEntry.set(entryName, chosen ?? nativeEnum.typeName); + } + + const blocks: PslExtensionBlock[] = []; + for (const [entryName, nativeEnum] of input.nativeEnums) { + const { typeName } = nativeEnum; + const blockName = nameByEntry.get(entryName) ?? typeName; blocks.push(buildNativeEnumBlock(blockName, typeName, nativeEnum.members)); blockNamesByTypeName.set(typeName, blockName); - blockNamesByTypeName.set(qualifiedTypeName, blockName); + blockNamesByTypeName.set(`${input.namespaceId}.${typeName}`, blockName); } return { blocks, blockNamesByTypeName }; } diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts index 5a691763eead..1485bab8303a 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts @@ -349,7 +349,12 @@ export function printPostgresPslContract(contract: Contract): PslDoc const enums = buildNativeEnumBlocksForNamespace({ namespaceId, nativeEnums, - valueSetNames: new Set(Object.keys(namespace.entries.valueSet ?? {})), + valueSets: new Map( + Object.entries(namespace.entries.valueSet ?? {}).map(([name, valueSet]) => [ + name, + valueSet.values, + ]), + ), columns: models .filter((entry) => entry.namespaceId === namespaceId) .flatMap((entry) => Object.values(entry.table.columns)), diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts index 831b38bd7d03..2182b3444140 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -6,7 +6,13 @@ import type { ExecutionMutationDefault, } from '@internal/contract/types'; import { asNamespaceId } from '@internal/contract/types'; -import type { PslAttribute, PslField, PslModel } from '@internal/framework-components/psl-ast'; +import type { + PslAttribute, + PslExtensionBlock, + PslField, + PslModel, +} 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'; @@ -434,3 +440,124 @@ describe('relations', () => { expect(models[0]?.fields.map(fieldText)[1]).toBe('posts Post[]'); }); }); + +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) => + attributeText({ ...attribute, target: 'model' }), + ); + 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 domainNamespace: ApplicationDomainNamespace = { + models: { + Widget: { + storage: { table: 'widget', namespaceId: 'public', fields }, + fields: Object.fromEntries(Object.keys(fields).map((name) => [name, INT_FIELD])), + relations: {}, + }, + }, + }; + const json = createSqlContract({ + namespaces: { public: domainNamespace }, + storage: { + namespaces: { + public: { + id: 'public', + entries: { + table: { + widget: table({ + columns: input.columns ?? { id: INT_COLUMN }, + 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 = printPostgresPslContract( + blindCast, 'the Postgres serializer yields a SQL contract'>(contract), + ); + 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('keeps a type name when two unreferenced enums share their members', () => { + 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 user_role A = "A" B = "B"', 'native_enum other_role A = "A" B = "B"']); + }); +}); diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index f0f0920ac816..919f64d47601 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -123,7 +123,6 @@ The converter prints the loaded contract as a Prisma 8 schema and the round-trip - A generator the Postgres printer has no PSL form for (none of the Prisma 7 generators, which all print). - A database-side default on a list column (`@default(dbgenerated("ARRAY[...]::text[]"))`). The PSL reader refuses it with `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`, although it is a storage default. Every Prisma 7 list default is one. In `contract-psl/src/psl-field-resolution.ts`. Exists on `main`. - A nullable list type (`Tag[]?`). The PSL printer writes `[]` or `?`, never both, while every Prisma 7 list column is nullable. In `@internal/psl-printer`. Exists on `main`; the same defect makes `contract infer` print nullable lists as required. -- A mapped enum no column uses (`enum Role { ... @@map("user_role") }` with no field of type `Role`). The printer learns the Prisma-side name from a column that carries the enum, so with no such column it names the block after the database type and the contract reads back with the value set keyed `user_role` rather than `Role`. The converter writes the file rather than refusing, so the storage hash changes. Found while trying the `relations` integration fixture as the convert journey. - Type parameters on a list field (`Decimal @db.Numeric(65,30)[]`). The PSL reader keeps precision and scale on the storage column but drops them from the domain field's type. In `contract-psl/src/interpreter.ts`, `patchModelDomainFields`. Exists on `main`. ### Found outside this project's scope From 80afd4b4637f47061d70c1e63a1fa9dab0df1038 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 12:29:51 +0200 Subject: [PATCH 22/55] test(journeys): the convert journey runs over the relations fixture The relations fixture carries both enums, relations across two schemas, junction tables and a mapped enum no column refers to, so the journey now proves more of the printer. It applies the SQL Prisma 7.10.0 generated for the full supported schema, which is the database the relations fixture is meant to run against. The implicit-many-to-many-names case is dropped: its tables collide with that SQL in the public schema, so keeping it would need a second database. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../test/cli-journeys/prisma7-convert.e2e.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts index 574f81ea5319..75e1183c02d7 100644 --- a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts @@ -3,7 +3,9 @@ * `prisma.config.ts` points at `prisma7Schema('./schema.prisma')` runs * `contract convert`, switches its config to the Prisma 8 PSL the command * wrote, and emits the same contract — which `db verify` then reports zero - * findings against the database the Prisma 7 SQL built. Three things are + * findings against the database the Prisma 7 SQL built. It runs over the + * `relations` fixture, whose database is the SQL Prisma 7.10.0 generated for + * the full `supported` schema. Three things are * refused with exit 2 and no file written: a config on any other contract * source, a Prisma 7 schema Prisma 8 cannot read, and a schema holding a * default that cannot be written in Prisma 8 PSL. @@ -28,10 +30,7 @@ import { const PRISMA7_FIXTURES = join(__dirname, '../fixtures/prisma7-source'); const JOURNEY_FIXTURES = join(__dirname, '../fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys'); -const PRISMA7_DDL = readFileSync( - join(PRISMA7_FIXTURES, 'implicit-many-to-many-names/migration.sql'), - 'utf-8', -); +const PRISMA7_DDL = readFileSync(join(PRISMA7_FIXTURES, 'supported/migration.sql'), 'utf-8'); const VIEW_SCHEMA = `datasource db { provider = "postgresql" @@ -162,7 +161,7 @@ withTempDir(({ createTempDir }) => { async () => { await convertAndVerify( setupPrisma7Project(createTempDir, db.connectionString, { - copyFrom: join(PRISMA7_FIXTURES, 'implicit-many-to-many-names/schema.prisma'), + copyFrom: join(PRISMA7_FIXTURES, 'relations/schema.prisma'), }), db.connectionString, ); From 96642469b950309da1269e536eb6909777770612 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 12:34:55 +0200 Subject: [PATCH 23/55] test(target-postgres): the enum block test reads a block attribute's own argument shape Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../postgres/test/psl-print/print-psl-contract.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts index 2182b3444140..53a88181f6d4 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -446,8 +446,8 @@ describe('native enum blocks', () => { const members = Object.entries(block.parameters).map(([name, value]) => value.kind === 'value' ? `${name} = ${value.raw}` : name, ); - const attributes = block.blockAttributes.map((attribute) => - attributeText({ ...attribute, target: 'model' }), + const attributes = block.blockAttributes.map( + (attribute) => `@@${attribute.name}(${attribute.args.map((arg) => arg.value).join(', ')})`, ); return [`${block.keyword} ${block.name}`, ...members, ...attributes].join(' '); } From 961f28205fc72b3c234021bf35e2304fd5b7e8b9 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 13:07:44 +0200 Subject: [PATCH 24/55] docs(upgrading): contract convert declares no consumer action for apps or extensions Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- .../pending/contract-convert/app/instructions.md | 3 +++ .../pending/contract-convert/extension/instructions.md | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 upgrade-instructions/pending/contract-convert/app/instructions.md create mode 100644 upgrade-instructions/pending/contract-convert/extension/instructions.md diff --git a/upgrade-instructions/pending/contract-convert/app/instructions.md b/upgrade-instructions/pending/contract-convert/app/instructions.md new file mode 100644 index 000000000000..8e1cc0ebe668 --- /dev/null +++ b/upgrade-instructions/pending/contract-convert/app/instructions.md @@ -0,0 +1,3 @@ +--- +changes: [] +--- diff --git a/upgrade-instructions/pending/contract-convert/extension/instructions.md b/upgrade-instructions/pending/contract-convert/extension/instructions.md new file mode 100644 index 000000000000..8e1cc0ebe668 --- /dev/null +++ b/upgrade-instructions/pending/contract-convert/extension/instructions.md @@ -0,0 +1,3 @@ +--- +changes: [] +--- From 4e8f69d9333f83b30a03781f60ed469ff58c88b6 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 13:27:11 +0200 Subject: [PATCH 25/55] docs(cli): the convert refusal names a target or a shape, and a column only when the failure is about one CONTRACT.CONVERT_UNSUPPORTED also covers a target with no converter, a model name in two namespaces, and a to-one relation with no foreign key. None of those names a column. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- packages/1-framework/3-tooling/cli/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 9e18be417dd8..32dd1ae3e37e 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -373,7 +373,7 @@ The written file opens with the two lines that say where it came from: ``` Some Prisma 7 schemas cannot be converted yet. Three things are refused, and none of them writes a file: -- A schema holding something a Prisma 8 schema cannot express exits `2` with `CONTRACT.CONVERT_UNSUPPORTED`, naming the column it stopped on. This is the one most conversions meet first. A JSON object literal default is the common case: the written value would read back as a string rather than as the value the column defaults to. Replace such a default with a database expression default, or drop it, then convert again. +- A target or a schema shape Prisma 8 cannot express exits `2` with `CONTRACT.CONVERT_UNSUPPORTED`, naming the target or the shape it stopped on. A target with no converter, one model name declared in two namespaces, and a to-one relation with no foreign key behind it name no column; a refusal that is about one column names that column. This is the one most conversions meet first. A JSON object literal default is the common case: the written value would read back as a string rather than as the value the column defaults to. Replace such a default with a database expression default, or drop it, then convert again. - A contract source that is not a Prisma 7 schema exits `2` with `CONTRACT.CONVERT_SOURCE_NOT_PRISMA7`. A contract already authored the Prisma 8 way has nothing to convert. - A Prisma 7 schema Prisma 8 cannot read exits `2` with `CONTRACT.SOURCE_LOAD_FAILED`, reporting exactly what `contract emit` reports for the same schema. From 390046bbab000cbe975a3676ff45a55b24376190 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 13:28:16 +0200 Subject: [PATCH 26/55] fix(cli): a contract source that succeeds with no contract fails at the validation boundary A success result carrying undefined or null now reports CONTRACT.SOURCE_LOAD_FAILED, instead of reaching contract enrichment or the target printer as a missing contract. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../control-api/operations/load-contract-source.ts | 5 +++-- .../cli/test/control-api/contract-emit.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) 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 index 7ef1b2a5887a..1f4ffbcbea01 100644 --- 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 @@ -136,7 +136,8 @@ function validateProviderResult(providerResult: unknown): ValidatedProviderResul } if (providerResult['ok']) { - if (!('value' in providerResult)) { + const value = providerResult['value']; + if (value === undefined || value === null) { return { ok: false, error: failedToResolveContractSource( @@ -145,7 +146,7 @@ function validateProviderResult(providerResult: unknown): ValidatedProviderResul ), }; } - return { ok: true, value: providerResult['value'] }; + return { ok: true, value }; } const failure = providerResult['failure']; 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 f84457626d89..7d00e9471597 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 @@ -176,6 +176,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( From 220b7e523bd168099f4a9609bdbc82ad218d5753 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 13:30:27 +0200 Subject: [PATCH 27/55] fix(target-postgres): a printed relation reads the foreign key that references the columns it names A table can carry two foreign keys over the same source columns to the same table, referencing different columns there. The printer matched only the source columns and the target table, so it could take its name and its actions from the other key. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../core/psl-print/print-relation-fields.ts | 4 +- .../test/psl-print/print-psl-contract.test.ts | 55 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts index 86fb26c78c39..271434e40db4 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts @@ -76,11 +76,13 @@ function sameColumns(a: readonly string[], b: readonly string[]): boolean { /** The foreign key that backs an owning relation. */ export function foreignKeyFor(entry: RelationEntry, target: ModelEntry): 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.tableName === target.tableName && fk.target.namespaceId === target.namespaceId && - sameColumns(fk.source.columns, localColumns), + sameColumns(fk.source.columns, localColumns) && + sameColumns(fk.target.columns, targetColumns), ); } diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts index 53a88181f6d4..cf1f4ff12de2 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -439,6 +439,61 @@ describe('relations', () => { 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 = print({ + 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'] }, + }, + }, + }, + }, + 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)[2]).toBe( + 'author User @relation(fields: [authorId], references: [altId], onDelete: Restrict, onUpdate: Restrict, map: "post_author_altId_fkey", index: false)', + ); + }); }); describe('native enum blocks', () => { From 9b558fffa49319de56d56e8ba4ec597b2d2b5a1e Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 13:31:51 +0200 Subject: [PATCH 28/55] fix(target-postgres): a column mixing the now generator with another one is refused, not written The temporal preset writes each phase as now, so a second, different generator in the other phase was dropped from the written file. It now stops the conversion with CONTRACT.CONVERT_UNSUPPORTED naming the column. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../psl-print/print-execution-defaults.ts | 11 ++++++ .../test/psl-print/print-psl-contract.test.ts | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts index 49ca92dded99..83671659be8a 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts @@ -68,6 +68,17 @@ export function printExecutionDefault(input: { const onCreate = onCreateId === nowGeneratorId; const onUpdate = onUpdateId === nowGeneratorId; if (onCreate || onUpdate) { + if ((onCreateId !== undefined && !onCreate) || (onUpdateId !== undefined && !onUpdate)) { + throw postgresError( + 'CONTRACT.CONVERT_UNSUPPORTED', + `contract convert: column ${coordinate} takes the wall-clock-now generator in one phase and "${onCreate ? onUpdateId : onCreateId}" in the other, which cannot be written in Prisma 8 PSL.`, + { + why: '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.', + fix: 'Author the Prisma 8 contract by hand for this column.', + meta: { coordinate, onCreate: onCreateId, onUpdate: onUpdateId }, + }, + ); + } return { kind: 'temporal', phases: { presetName, onCreate, onUpdate } }; } } diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts index cf1f4ff12de2..8fbd609b4f86 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -374,6 +374,41 @@ describe('generated values', () => { ), ).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.CONVERT_UNSUPPORTED', + message: expect.stringContaining('"public"."widget"."value"'), + }); + expect( + refusal({ + onCreate: { kind: 'generator', id: 'uuidv4' }, + onUpdate: { kind: 'generator', id: 'plainDateTimeNow' }, + }), + ).toMatchObject({ + code: 'CONTRACT.CONVERT_UNSUPPORTED', + message: expect.stringContaining('"public"."widget"."value"'), + }); + }); }); describe('relations', () => { From 2418264894cf5d5248fb5d875d9574091a97173c Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 13:33:41 +0200 Subject: [PATCH 29/55] fix(target-postgres): an enum block name no value set supplies is derived from the type name A quoted Postgres type such as "order status" was written straight into the block name, which the PSL parser cannot read as a declaration. The name is now derived the way contract infer derives one, kept apart from the other block names in the namespace, and the physical type name goes into @@map. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../src/core/psl-print/print-enum-blocks.ts | 24 +++++++++++--- .../test/psl-print/print-psl-contract.test.ts | 31 +++++++++++++++++-- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts index 8c09c5f23056..05741253c620 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts @@ -1,7 +1,9 @@ +import { toEnumName } from '@internal/family-sql/psl-infer'; import type { PslExtensionBlock } from '@internal/framework-components/psl-ast'; import type { StorageColumn } from '@internal/sql-contract/types'; import type { PostgresNativeEnum } from '../postgres-native-enum'; import { buildNativeEnumBlock } from '../psl-infer/infer-enum-blocks'; +import { createUniqueFieldName } from '../psl-infer/infer-names'; export interface NativeEnumEmission { readonly blocks: readonly PslExtensionBlock[]; @@ -22,8 +24,10 @@ function sameValues(left: readonly unknown[], right: readonly unknown[]): boolea * set is the only place the authored name survives. A column typed by the enum * names its value set directly. An enum no column refers to is matched to the * one unclaimed value set that holds exactly its members, in order; if no value - * set matches, or more than one does, the block keeps the type name. `@@map` - * carries the physical type name whenever the two differ. + * set matches, or more than one does, the block name is derived from the + * physical type name the way `contract infer` derives one, and kept apart from + * every other block name in the namespace. `@@map` carries the physical type + * name whenever the two differ. */ export function buildNativeEnumBlocksForNamespace(input: { readonly namespaceId: string; @@ -54,14 +58,26 @@ export function buildNativeEnumBlocksForNamespace(input: { nameByEntry.set(entryName, fromColumn); } + const unmatched: { entryName: string; nativeEnum: PostgresNativeEnum }[] = []; for (const { entryName, nativeEnum } of unreferenced) { const candidates = [...input.valueSets] .filter(([name, values]) => !claimed.has(name) && sameValues(values, nativeEnum.members)) .map(([name]) => name); const exact = candidates.find((name) => name === entryName); const chosen = exact ?? (candidates.length === 1 ? candidates[0] : undefined); - if (chosen !== undefined) claimed.add(chosen); - nameByEntry.set(entryName, chosen ?? nativeEnum.typeName); + if (chosen === undefined) { + unmatched.push({ entryName, nativeEnum }); + continue; + } + claimed.add(chosen); + nameByEntry.set(entryName, chosen); + } + + const blockNames = new Set(nameByEntry.values()); + for (const { entryName, nativeEnum } of unmatched) { + const derived = createUniqueFieldName(toEnumName(nativeEnum.typeName).name, blockNames); + blockNames.add(derived); + nameByEntry.set(entryName, derived); } const blocks: PslExtensionBlock[] = []; diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts index 8fbd609b4f86..052e9f4b4213 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -639,7 +639,7 @@ describe('native enum blocks', () => { ).toEqual(['native_enum Role user = "user" ADMIN = "ADMIN" @@map("user_role")']); }); - it('keeps a type name when two unreferenced enums share their members', () => { + it('derives a block name from the type name when two unreferenced enums share their members', () => { expect( enumBlocks({ nativeEnums: { @@ -648,6 +648,33 @@ describe('native enum blocks', () => { }, valueSets: { Role: { values: ['A', 'B'] }, OtherRole: { values: ['A', 'B'] } }, }), - ).toEqual(['native_enum user_role A = "A" B = "B"', 'native_enum other_role A = "A" B = "B"']); + ).toEqual([ + 'native_enum UserRole 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 as a mapped block name', () => { + expect( + enumBlocks({ + nativeEnums: { 'order status': { typeName: 'order status', members: ['A'] } }, + valueSets: {}, + }), + ).toEqual(['native_enum OrderStatus A = "A" @@map("order status")']); + }); + + it('keeps a derived block name apart from a block name a value set already claimed', () => { + expect( + enumBlocks({ + nativeEnums: { + 'order status': { typeName: 'order status', members: ['A'] }, + order_status: { typeName: 'order_status', members: ['B'] }, + }, + valueSets: { OrderStatus: { values: ['B'] } }, + }), + ).toEqual([ + 'native_enum OrderStatus2 A = "A" @@map("order status")', + 'native_enum OrderStatus B = "B" @@map("order_status")', + ]); }); }); From 598f2368e3b0d7598ce6283107d4f247b45e4f95 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 13:35:43 +0200 Subject: [PATCH 30/55] fix(cli): a cancelled contract convert writes no file Closing the control client is awaited after the contract is printed, and a cancellation arriving during that wait went unobserved, so the command went on to write the output file. It now stops at the cancellation instead. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../3-tooling/cli/src/orm/contract/convert.ts | 1 + .../cli/test/orm/contract-convert.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts index fd3d798125a5..ce858528f75c 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts @@ -183,6 +183,7 @@ export function createContractConvertCommand({ } finally { await closeQuietly(client); } + ctx.signal.throwIfAborted(); const outputPath = inferredContractPathFor({ config: ctx.config, diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts index fe5c68e47e2e..b15299aa57fe 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts @@ -139,6 +139,22 @@ describe('contract convert', () => { expect(await readdir(join(dir, 'generated'))).toEqual(['contract.prisma']); }); + it('writes nothing when the run is cancelled while the client is closing', async () => { + const dir = await projectDir(); + const controller = new AbortController(); + mocks.close.mockImplementation(async () => { + controller.abort(); + }); + + const run = await harness(ormConfig(dir)).run(['contract', 'convert', '--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(); From 342fa14ee0403a5723b0b27c56a6f74f51d3e3ae Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 13:40:26 +0200 Subject: [PATCH 31/55] feat(cli): contract convert refuses to write over the schema it reads An --output path that resolves to one of the contract source's inputs, or sits inside a directory of schema files the source reads, now exits 2 with CONTRACT.CONVERT_OUTPUT_IS_SOURCE before anything is read. The overwrite-with-warning behaviour for every other path is unchanged. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- docs/reference/error-reference.md | 4 ++ packages/1-framework/3-tooling/cli/README.md | 3 +- .../3-tooling/cli/src/orm/contract/convert.ts | 54 ++++++++++++++++--- .../cli/test/orm/contract-convert.test.ts | 41 +++++++++++++- .../cli-journeys/prisma7-convert.e2e.test.ts | 23 ++++++-- 5 files changed, 112 insertions(+), 13 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index aae36e70d4b9..00f75c97bb2c 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -245,6 +245,10 @@ 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.CONVERT_OUTPUT_IS_SOURCE + +`prisma contract convert` was asked to write its output over the Prisma 7 schema it reads: the resolved output path is one of the contract source's inputs, or sits inside a directory of schema files the source reads. Writing there would destroy the schema the conversion is made from. Pick another `--output` path, outside the Prisma 7 schema the config names. Raised before the schema is read, so nothing is written and the source file is untouched. Payload: `output` and `source`, both relative to the invocation directory. + ### CONTRACT.CONVERT_SOURCE_NOT_PRISMA7 `prisma contract convert` applies only to a Prisma 7 source, and the configured contract source is a PSL or TypeScript one, or names no format at all. A contract already authored the Prisma 8 way has nothing to convert. Point `contract` at `prisma7Schema("./schema.prisma")` to convert a Prisma 7 schema. Raised by `contract convert` before it reads anything, so nothing is written. Payload: `format` (the format the configured source declares, `null` when it declares none). diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 32dd1ae3e37e..1c625d7d58b7 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -372,10 +372,11 @@ The written file opens with the two lines that say where it came from: // Converted from prisma/schema.prisma by `prisma contract convert`. ``` -Some Prisma 7 schemas cannot be converted yet. Three things are refused, and none of them writes a file: +Some Prisma 7 schemas cannot be converted yet. Four things are refused, and none of them writes a file: - A target or a schema shape Prisma 8 cannot express exits `2` with `CONTRACT.CONVERT_UNSUPPORTED`, naming the target or the shape it stopped on. A target with no converter, one model name declared in two namespaces, and a to-one relation with no foreign key behind it name no column; a refusal that is about one column names that column. This is the one most conversions meet first. A JSON object literal default is the common case: the written value would read back as a string rather than as the value the column defaults to. Replace such a default with a database expression default, or drop it, then convert again. - A contract source that is not a Prisma 7 schema exits `2` with `CONTRACT.CONVERT_SOURCE_NOT_PRISMA7`. A contract already authored the Prisma 8 way has nothing to convert. - A Prisma 7 schema Prisma 8 cannot read exits `2` with `CONTRACT.SOURCE_LOAD_FAILED`, reporting exactly what `contract emit` reports for the same schema. +- An `--output` path that is the Prisma 7 schema the config reads, or sits inside a directory of schema files it reads, exits `2` with `CONTRACT.CONVERT_OUTPUT_IS_SOURCE`. Converting there would destroy the schema the conversion is made from. Pick another path. Converting is the first step of the cutover, not the whole of it. After it succeeds, point `contract` in `prisma.config.ts` at the written file and run the rest: diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts index ce858528f75c..4b02f56c0c4a 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts @@ -4,7 +4,7 @@ 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 } from 'pathe'; +import { relative, resolve } from 'pathe'; import { createControlClient as createDefaultControlClient } from '../../control-api/client'; import { loadContractSource } from '../../control-api/operations/load-contract-source'; import type { ControlClient, ControlClientOptions } from '../../control-api/types'; @@ -74,6 +74,25 @@ function convertHeaderComment(schemaPath: string): string { return `// use prisma-8\n// Converted from ${schemaPath} by \`prisma contract convert\`.`; } +/** + * The contract source input the output path would be written over: the one it + * names, or the directory of schema files it sits inside. `undefined` when the + * output path touches no input. + */ +function sourceInputCovering(inputs: { + readonly inputs: readonly string[]; + readonly cwd: string; + readonly outputPath: string; +}): string | undefined { + return inputs.inputs.find((input) => { + const resolved = resolve(inputs.cwd, input); + return ( + resolved === inputs.outputPath || + inputs.outputPath.startsWith(`${resolved.replace(/\/$/, '')}/`) + ); + }); +} + export function createContractConvertCommand({ createControlClient, printPsl, @@ -142,6 +161,33 @@ export function createContractConvertCommand({ } const schemaPath = relative(ctx.cwd, schemaInput); + const outputPath = inferredContractPathFor({ + config: ctx.config, + cwd: ctx.cwd, + output: args.flags.output, + }); + const displayPath = relative(ctx.cwd, outputPath); + const sourceInput = sourceInputCovering({ + inputs: contractConfig.source.inputs ?? [], + cwd: ctx.cwd, + outputPath, + }); + if (sourceInput !== undefined) { + return notOk( + normalizeError( + errorRuntime( + 'CONTRACT.CONVERT_OUTPUT_IS_SOURCE', + 'contract convert would write over the schema it reads', + { + why: `The output path ${displayPath} is the contract source ${relative(ctx.cwd, sourceInput)}, or sits inside it, so converting would destroy the Prisma 7 schema.`, + fix: 'Pick another --output path, outside the Prisma 7 schema the config names.', + meta: { output: displayPath, source: relative(ctx.cwd, sourceInput) }, + }, + ), + ), + ); + } + const client = createControlClient({ family: ctx.config.family, target: ctx.config.target, @@ -185,12 +231,6 @@ export function createContractConvertCommand({ } ctx.signal.throwIfAborted(); - const outputPath = inferredContractPathFor({ - config: ctx.config, - cwd: ctx.cwd, - output: args.flags.output, - }); - const displayPath = relative(ctx.cwd, outputPath); if (existsSync(outputPath)) { ctx.report({ kind: 'message', diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts index b15299aa57fe..857f676fe42c 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts @@ -1,4 +1,4 @@ -import { readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { ok } from '@internal/utils/result'; import { structuredError } from '@internal/utils/structured-error'; import type { ErroredEnvelope, MountedTree, StreamEvent } from '@prisma/cli-engine'; @@ -231,6 +231,45 @@ describe('contract convert', () => { expect(run.stdout).toBe(''); }); + 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', 'convert', '--output', 'prisma/schema.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.CONVERT_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('refuses an output path inside a directory the contract source reads', async () => { + const dir = await projectDir(); + const config = ormConfig(dir, { + contract: { + source: { format: 'prisma7', inputs: ['./prisma'], load: mocks.load }, + output: join(dir, 'generated', 'contract.json'), + }, + }); + + const run = await harness(config).run( + ['contract', 'convert', '--output', 'prisma/nested/contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.CONVERT_OUTPUT_IS_SOURCE' }); + expect(await readdir(dir)).not.toContain('prisma'); + }); + it('refuses a PSL source and writes nothing', async () => { const dir = await projectDir(); const config = ormConfig(dir, { diff --git a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts index 75e1183c02d7..b926b567b0b1 100644 --- a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts @@ -5,10 +5,10 @@ * wrote, and emits the same contract — which `db verify` then reports zero * findings against the database the Prisma 7 SQL built. It runs over the * `relations` fixture, whose database is the SQL Prisma 7.10.0 generated for - * the full `supported` schema. Three things are - * refused with exit 2 and no file written: a config on any other contract - * source, a Prisma 7 schema Prisma 8 cannot read, and a schema holding a - * default that cannot be written in Prisma 8 PSL. + * the full `supported` schema. Four things are refused with exit 2 and no file + * written: a config on any other contract source, a Prisma 7 schema Prisma 8 + * cannot read, an output path that is the schema being read, and a schema + * holding a default that cannot be written in Prisma 8 PSL. */ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { withClient } from '@repo/test-utils'; @@ -195,6 +195,21 @@ withTempDir(({ createTempDir }) => { 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 convert = await runContractConvert(ctx, ['--output', 'schema.prisma', '--json']); + + expect(convert.exitCode, output(convert)).toBe(2); + expect(errorOf(convert).code).toBe('CONTRACT.CONVERT_OUTPUT_IS_SOURCE'); + expect(readFileSync(schemaPath, 'utf-8')).toBe(before); + expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); + }); + it('refuses a JSON object literal default it cannot write and writes nothing', async () => { const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { copyFrom: join(PRISMA7_FIXTURES, 'supported-verify/schema.prisma'), From 10176998cc228f0337ff11860c63b6e4654ba77d Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 13:51:31 +0200 Subject: [PATCH 32/55] test(prisma7): an enum default on an unmapped enum type round-trips The existing enum-default-member fixture maps both the enum type and one of its members. This one maps only the member, so the printed native_enum block takes the enum's own name and only the member default has to survive. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../contract-prisma7/test/fixtures.test.ts | 1 + .../expected-contract.json | 115 ++++++++++++++++++ .../migration.sql | 13 ++ .../schema.prisma | 13 ++ 4 files changed, 142 insertions(+) create mode 100644 packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/expected-contract.json create mode 100644 packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/migration.sql create mode 100644 packages/2-sql/2-authoring/contract-prisma7/test/fixtures/enum-default-member-unmapped-type/schema.prisma 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 0ae33ac0c3d1..9ce2c41a64e9 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,6 +49,7 @@ describe('Prisma 7 fixtures', () => { 'dbgenerated-without-expression-optional', 'defaults', 'enum-default-member', + 'enum-default-member-unmapped-type', 'enum-namespace-mismatch', 'enum-native', 'enum-unreferenced', 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) +} From 79d72d509fa2f70fdde863a731bb57219a6819c9 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 13:56:36 +0200 Subject: [PATCH 33/55] fix(target-postgres): a list column the printed file cannot carry is refused, not written lossy A nullable list column and a list column whose domain type parameters the PSL source drops both read back as a different contract. The printer now stops at the first such column and names it, and the round-trip test asserts the refusal instead of expecting a failure. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- docs/reference/error-reference.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 2 +- .../cli/test/orm/contract-convert.test.ts | 27 ++++++++ .../test/convert-roundtrip.test.ts | 63 ++++++++++++++++--- .../src/core/psl-print/print-psl-contract.ts | 44 ++++++++++++- projects/prisma7-contract-source/spec.md | 10 +-- 6 files changed, 133 insertions(+), 15 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 00f75c97bb2c..a81932fd9c05 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -255,7 +255,7 @@ A model declares an empty unique constraint (a unique with no fields), raised du ### CONTRACT.CONVERT_UNSUPPORTED -`contract convert` cannot print the loaded contract as a Prisma 8 PSL file: either the configured target has no `printPslContract` hook on its descriptor (meta: `targetId`), or the contract holds something that cannot be written in a Prisma 8 PSL file — a column whose native type is outside the target's type map, a generated value the target cannot author, a to-one relation with no foreign key behind it, or one model name declared in more than one namespace (a relation's target is resolved by model name alone, so the two would read back as one). Meta at the shape sites: `coordinate`, `nativeType`, `onCreate`, `onUpdate`, `model`, `field`, `modelName`, `namespaces`. +`contract convert` cannot print the loaded contract as a Prisma 8 PSL file: either the configured target has no `printPslContract` hook on its descriptor (meta: `targetId`), or the contract holds something that cannot be written in a Prisma 8 PSL file — a column whose native type is outside the target's type map, a generated value the target cannot author, a column that takes a wall-clock-now generator in one phase and a different generator in the other, a nullable list column or a list column carrying type parameters (both would read back with a different type), a to-one relation with no foreign key behind it, or one model name declared in more than one namespace (a relation's target is resolved by model name alone, so the two would read back as one). Meta at the shape sites: `coordinate`, `nativeType`, `typeParams`, `onCreate`, `onUpdate`, `model`, `field`, `modelName`, `namespaces`. ### CONTRACT.DEFAULT_INVALID diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 1c625d7d58b7..55b32cee2030 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -373,7 +373,7 @@ The written file opens with the two lines that say where it came from: ``` Some Prisma 7 schemas cannot be converted yet. Four things are refused, and none of them writes a file: -- A target or a schema shape Prisma 8 cannot express exits `2` with `CONTRACT.CONVERT_UNSUPPORTED`, naming the target or the shape it stopped on. A target with no converter, one model name declared in two namespaces, and a to-one relation with no foreign key behind it name no column; a refusal that is about one column names that column. This is the one most conversions meet first. A JSON object literal default is the common case: the written value would read back as a string rather than as the value the column defaults to. Replace such a default with a database expression default, or drop it, then convert again. +- A target or a schema shape Prisma 8 cannot express exits `2` with `CONTRACT.CONVERT_UNSUPPORTED`, naming the target or the shape it stopped on. A target with no converter, one model name declared in two namespaces, and a to-one relation with no foreign key behind it name no column; a refusal that is about one column names that column. This is the one most conversions meet first. A list column is the common case: a Prisma 7 list column is always nullable, and a Prisma 8 field type is written as a list or as optional, never as both, so the written column would read back as a list that cannot be null. A JSON object literal default is the next: the written value would read back as a string rather than as the value the column defaults to. Replace such a default with a database expression default, or drop it, then convert again. - A contract source that is not a Prisma 7 schema exits `2` with `CONTRACT.CONVERT_SOURCE_NOT_PRISMA7`. A contract already authored the Prisma 8 way has nothing to convert. - A Prisma 7 schema Prisma 8 cannot read exits `2` with `CONTRACT.SOURCE_LOAD_FAILED`, reporting exactly what `contract emit` reports for the same schema. - An `--output` path that is the Prisma 7 schema the config reads, or sits inside a directory of schema files it reads, exits `2` with `CONTRACT.CONVERT_OUTPUT_IS_SOURCE`. Converting there would destroy the schema the conversion is made from. Pick another path. diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts index 857f676fe42c..62d018b0bfae 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts @@ -317,6 +317,33 @@ describe('contract convert', () => { expect(mocks.close).toHaveBeenCalled(); }); + it('writes no file at the output path when the target refuses a lossy list column', async () => { + const dir = await projectDir(); + mocks.printPslContract.mockImplementation(() => { + throw structuredError( + 'CONTRACT.CONVERT_UNSUPPORTED', + 'contract convert: column "public"."Scalars"."stringList" is a nullable list, which cannot be written in Prisma 8 PSL.', + { + why: 'A field type is written as a list or as optional, never as both.', + fix: 'Make the column not null before converting.', + meta: { coordinate: '"public"."Scalars"."stringList"' }, + }, + ); + }); + + const run = await harness(ormConfig(dir)).run( + ['contract', 'convert', '--output', 'contract.prisma', '--json'], + { cwd: dir }, + ); + + expect(run.exitCode).toBe(2); + expect(erroredEnvelope(run).error).toMatchObject({ + code: 'CONTRACT.CONVERT_UNSUPPORTED', + summary: expect.stringContaining('"public"."Scalars"."stringList"'), + }); + 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.printPslContract.mockImplementation(() => { diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts index 88cbdc0fa649..61c3af30a92a 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts @@ -19,33 +19,70 @@ const cases = readdirSync(fixturesDir, { withFileTypes: true }) .sort(); const NULLABLE_LIST = - 'the printer cannot print a nullable list type, so a list column comes back not nullable'; + 'the printer cannot print a nullable list type, so a list column would come back not nullable'; const LOST_LIST_TYPE_PARAMS = 'the PSL source drops type.typeParams from the domain field of a scalar list column'; /** Why each fixture does not round-trip yet, naming every cause it has. */ const expectedFailures: ReadonlyMap = new Map([ - ['dbgenerated-without-expression-optional', [NULLABLE_LIST]], [ 'defaults', [ 'a Json object literal default is refused, because PSL reads a quoted default back as a string', ], ], - ['enum-native', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], [ 'junction-name-in-other-schema', ['one model name in two namespaces cannot be written in a Prisma 8 schema'], ], - ['list-defaults', ['the PSL source refuses a function default on a list column']], - ['native-types-accepted', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], - ['number-default-spellings', [NULLABLE_LIST]], - ['number-defaults', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], [ 'relation-name-in-two-schemas', ['one model name in two namespaces cannot be written in a Prisma 8 schema'], ], - ['scalars', [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS]], +]); + +/** + * Fixtures the printer refuses rather than write a file that would read back as + * a different contract, with the column it stops on and every reason it has. + */ +const refusedListColumns: ReadonlyMap< + string, + { readonly column: string; readonly reasons: readonly string[] } +> = new Map([ + [ + 'dbgenerated-without-expression-optional', + { column: '"public"."T"."list"', reasons: [NULLABLE_LIST] }, + ], + [ + 'enum-native', + { column: '"public"."User"."roleList"', reasons: [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS] }, + ], + [ + 'list-defaults', + { + column: '"public"."Lists"."bl"', + reasons: [NULLABLE_LIST, 'the PSL source refuses a function default on a list column'], + }, + ], + [ + 'native-types-accepted', + { + column: '"public"."NativeTypes"."varCharList"', + reasons: [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS], + }, + ], + [ + 'number-default-spellings', + { column: '"public"."Spellings"."mixed"', reasons: [NULLABLE_LIST] }, + ], + [ + 'number-defaults', + { column: '"public"."Decimals"."list"', reasons: [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS] }, + ], + [ + 'scalars', + { column: '"public"."Scalars"."stringList"', reasons: [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS] }, + ], ]); function prisma7SchemaPath(caseName: string): string { @@ -83,6 +120,16 @@ async function roundTrip(caseName: string): Promise { describe('a printed Prisma 7 contract reads back as the same contract', () => { for (const caseName of cases) { + const refused = refusedListColumns.get(caseName); + if (refused !== undefined) { + it(`${caseName} is refused at ${refused.column} (${refused.reasons.join('; ')})`, async () => { + await expect(roundTrip(caseName)).rejects.toMatchObject({ + code: 'CONTRACT.CONVERT_UNSUPPORTED', + message: expect.stringContaining(refused.column), + }); + }); + continue; + } const reasons = expectedFailures.get(caseName); if (reasons !== undefined) { it.fails(`${caseName} (${reasons.join('; ')})`, async () => { diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts index 1485bab8303a..8bd73684e224 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts @@ -1,4 +1,4 @@ -import type { Contract, ExecutionMutationDefault } from '@internal/contract/types'; +import type { Contract, ContractField, ExecutionMutationDefault } from '@internal/contract/types'; import type { PslDocumentAst, PslExtensionBlock, @@ -95,6 +95,47 @@ function scalarFieldAttributes(input: { return attributes; } +/** + * Stops the conversion at a list column the printed file cannot carry without + * changing the contract: the printer package writes `Type[]` or `Type?` but + * never both, and the PSL source drops a list field's type parameters from the + * domain field it reads back. A converted file must never read back as a + * different contract, so both are refused rather than written. + */ +function refuseLossyListColumn(input: { + readonly column: StorageColumn; + readonly field: ContractField | undefined; + readonly coordinate: string; +}): void { + const { column, field, coordinate } = input; + if (column.many !== true) return; + + if (column.nullable) { + throw postgresError( + 'CONTRACT.CONVERT_UNSUPPORTED', + `contract convert: column ${coordinate} is a nullable list, which cannot be written in Prisma 8 PSL.`, + { + why: 'A field type is written as a list or as optional, never as both, so the written column would read back as a list that cannot be null.', + fix: 'Make the column not null before converting, or author the Prisma 8 contract by hand for this model.', + meta: { coordinate }, + }, + ); + } + + const typeParams = field?.type.kind === 'scalar' ? field.type.typeParams : undefined; + if (typeParams !== undefined && Object.keys(typeParams).length > 0) { + throw postgresError( + 'CONTRACT.CONVERT_UNSUPPORTED', + `contract convert: column ${coordinate} is a list whose type parameters cannot be written in Prisma 8 PSL.`, + { + why: "The PSL source keeps a list column's type parameters on the storage column but drops them from the field it reads back, so the written column would read back with a different type.", + fix: 'Author the Prisma 8 contract by hand for this model.', + meta: { coordinate, typeParams }, + }, + ); + } +} + function buildScalarFields(input: { readonly entry: ModelEntry; readonly enums: NativeEnumEmission; @@ -110,6 +151,7 @@ function buildScalarFields(input: { const column = entry.table.columns[columnName]; if (column === undefined) continue; const coordinate = `"${entry.namespaceId}"."${entry.tableName}"."${columnName}"`; + refuseLossyListColumn({ column, field: entry.model.fields[fieldName], coordinate }); const columnType = printColumnType({ column, diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index 919f64d47601..e814cce97d36 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -116,14 +116,16 @@ Recorded so they are not lost; each becomes its own project when scheduled. ### What `contract convert` cannot write yet -The converter prints the loaded contract as a Prisma 8 schema and the round-trip test reads it back through the Prisma 8 PSL source. These are the cases where the Prisma 8 schema language or its reader cannot carry what the Prisma 7 contract holds. Each is a Prisma 8 feature to build, not a converter defect; the converter refuses the first three with `CONTRACT.CONVERT_UNSUPPORTED` rather than writing a file that reads back differently. +The converter prints the loaded contract as a Prisma 8 schema and the round-trip test reads it back through the Prisma 8 PSL source. These are the cases where the Prisma 8 schema language or its reader cannot carry what the Prisma 7 contract holds. Each is a Prisma 8 feature to build, not a converter defect. A converted file must never read back as a different contract, so the converter refuses every one of them with `CONTRACT.CONVERT_UNSUPPORTED` and writes no file. - A `Json` or `Jsonb` column whose default is an object or array literal. The PSL reader treats `@default("...")` on a Json field as a string, so there is no way to write the parsed value. - One model name declared in two namespaces. The PSL reader keys relation targets, junction detection and id columns by model name alone (`contract-psl/src/interpreter.ts`, `fkRelationsByDeclaringModel`, `modelIdColumns`), so the two models read back as one. - A generator the Postgres printer has no PSL form for (none of the Prisma 7 generators, which all print). -- A database-side default on a list column (`@default(dbgenerated("ARRAY[...]::text[]"))`). The PSL reader refuses it with `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`, although it is a storage default. Every Prisma 7 list default is one. In `contract-psl/src/psl-field-resolution.ts`. Exists on `main`. -- A nullable list type (`Tag[]?`). The PSL printer writes `[]` or `?`, never both, while every Prisma 7 list column is nullable. In `@internal/psl-printer`. Exists on `main`; the same defect makes `contract infer` print nullable lists as required. -- Type parameters on a list field (`Decimal @db.Numeric(65,30)[]`). The PSL reader keeps precision and scale on the storage column but drops them from the domain field's type. In `contract-psl/src/interpreter.ts`, `patchModelDomainFields`. Exists on `main`. +- A database-side default on a list column (`@default(dbgenerated("ARRAY[...]::text[]"))`). The PSL reader refuses it with `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`, although it is a storage default. Every Prisma 7 list default is one. In `contract-psl/src/psl-field-resolution.ts`. Exists on `main`. Reached only through a list column, which the converter already refuses for the two reader defects below. +- A nullable list type (`Tag[]?`). The PSL printer writes `[]` or `?`, never both, while every Prisma 7 list column is nullable. In `@internal/psl-printer`. Exists on `main`; the same defect makes `contract infer` print nullable lists as required. The converter refuses the column, naming it, until this is fixed. +- Type parameters on a list field (`Decimal @db.Numeric(65,30)[]`). The PSL reader keeps precision and scale on the storage column but drops them from the domain field's type. In `contract-psl/src/interpreter.ts`, `patchModelDomainFields`. Exists on `main`. The converter refuses the column, naming it, until this is fixed. + +Because every Prisma 7 list column is nullable, a Prisma 7 schema with any list column is refused today. The refusal names the first such column, so the user learns which schema shape blocks the conversion instead of receiving a file that reads back differently. ### Found outside this project's scope From 35a70fda75d7960cb8038957ce0e2f91efc997de Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 13:57:31 +0200 Subject: [PATCH 34/55] test(journeys): the convert refusal journey names the first column the printer stops on The supported-verify schema declares a list column before its JSON object default, so the refusal now names that column. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Opus 5 --- .../test/cli-journeys/prisma7-convert.e2e.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts index b926b567b0b1..571c8228df4c 100644 --- a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts @@ -8,7 +8,7 @@ * the full `supported` schema. Four things are refused with exit 2 and no file * written: a config on any other contract source, a Prisma 7 schema Prisma 8 * cannot read, an output path that is the schema being read, and a schema - * holding a default that cannot be written in Prisma 8 PSL. + * holding a column that cannot be written in Prisma 8 PSL. */ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { withClient } from '@repo/test-utils'; @@ -210,7 +210,7 @@ withTempDir(({ createTempDir }) => { expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); }); - it('refuses a JSON object literal default it cannot write and writes nothing', async () => { + it('refuses the first column it cannot write and writes nothing', async () => { const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { copyFrom: join(PRISMA7_FIXTURES, 'supported-verify/schema.prisma'), }); @@ -220,7 +220,7 @@ withTempDir(({ createTempDir }) => { expect(convert.exitCode, output(convert)).toBe(2); const { code, summary } = errorOf(convert); expect(code).toBe('CONTRACT.CONVERT_UNSUPPORTED'); - expect(summary).toContain('"Defaults"."jsonLiteral"'); + expect(summary).toContain('"Scalars"."stringList"'); expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); }); }); From 53a3317afa63276c6572780dd4c205e99cce6137 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 21 Sep 2026 16:24:02 +0200 Subject: [PATCH 35/55] feat(sql): contract convert writes every list column After the merge with main, a nullable list prints as Type[]? and a list column reads a database-side default, so those two refusals are deleted. The PSL reader now keeps a list field's type parameters on the domain field, the same as a non-list field, so Decimal[] and DateTime[] columns convert too. Every list fixture round-trips; the refusals left are a Json object or array literal default and one model name in two namespaces. The printer follows the table naming rule main now has: a model with no @@map names its table exactly as the model is named. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 2 +- .../cli/test/orm/contract-convert.test.ts | 12 ++-- .../test/convert-roundtrip.test.ts | 59 ------------------- .../contract-psl/src/interpreter.ts | 8 ++- .../contract-psl/src/psl-name-mapping.ts | 4 +- .../test/interpreter.list-type-params.test.ts | 52 ++++++++++++++++ .../src/core/psl-print/print-psl-contract.ts | 45 +------------- .../test/psl-print/print-psl-contract.test.ts | 44 +++++++------- projects/prisma7-contract-source/spec.md | 7 +-- .../slices/c-remove-dbgenerated/spec.md | 1 + .../cli-journeys/prisma7-convert.e2e.test.ts | 2 +- .../_fixture/enum/generated/contract.d.ts | 6 +- .../_fixture/enum/generated/contract.json | 5 +- 13 files changed, 105 insertions(+), 142 deletions(-) create mode 100644 packages/2-sql/2-authoring/contract-psl/test/interpreter.list-type-params.test.ts diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 0c4096ed14b8..eb0af3b51af6 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -255,7 +255,7 @@ A model declares an empty unique constraint (a unique with no fields), raised du ### CONTRACT.CONVERT_UNSUPPORTED -`contract convert` cannot print the loaded contract as a Prisma 8 PSL file: either the configured target has no `printPslContract` hook on its descriptor (meta: `targetId`), or the contract holds something that cannot be written in a Prisma 8 PSL file — a column whose native type is outside the target's type map, a generated value the target cannot author, a column that takes a wall-clock-now generator in one phase and a different generator in the other, a nullable list column or a list column carrying type parameters (both would read back with a different type), a to-one relation with no foreign key behind it, or one model name declared in more than one namespace (a relation's target is resolved by model name alone, so the two would read back as one). Meta at the shape sites: `coordinate`, `nativeType`, `typeParams`, `onCreate`, `onUpdate`, `model`, `field`, `modelName`, `namespaces`. +`contract convert` cannot print the loaded contract as a Prisma 8 PSL file: either the configured target has no `printPslContract` hook on its descriptor (meta: `targetId`), or the contract holds something that cannot be written in a Prisma 8 PSL file — a column whose native type is outside the target's type map, a generated value the target cannot author, a column that takes a wall-clock-now generator in one phase and a different generator in the other, a to-one relation with no foreign key behind it, or one model name declared in more than one namespace (a relation's target is resolved by model name alone, so the two would read back as one). Meta at the shape sites: `coordinate`, `nativeType`, `onCreate`, `onUpdate`, `model`, `field`, `modelName`, `namespaces`. ### CONTRACT.DEFAULT_INVALID diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts index 62d018b0bfae..e2d2522b2433 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts @@ -317,16 +317,16 @@ describe('contract convert', () => { expect(mocks.close).toHaveBeenCalled(); }); - it('writes no file at the output path when the target refuses a lossy list column', async () => { + it('writes no file at the output path when the target refuses a column', async () => { const dir = await projectDir(); mocks.printPslContract.mockImplementation(() => { throw structuredError( 'CONTRACT.CONVERT_UNSUPPORTED', - 'contract convert: column "public"."Scalars"."stringList" is a nullable list, which cannot be written in Prisma 8 PSL.', + 'contract convert: column "public"."Defaults"."jsonLiteral" has a literal default that cannot be written in Prisma 8 PSL.', { - why: 'A field type is written as a list or as optional, never as both.', - fix: 'Make the column not null before converting.', - meta: { coordinate: '"public"."Scalars"."stringList"' }, + why: 'The PSL source would read a quoted default back as a string.', + fix: 'Replace the literal default with a database expression default.', + meta: { namespaceId: 'public', table: 'Defaults', column: 'jsonLiteral' }, }, ); }); @@ -339,7 +339,7 @@ describe('contract convert', () => { expect(run.exitCode).toBe(2); expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.CONVERT_UNSUPPORTED', - summary: expect.stringContaining('"public"."Scalars"."stringList"'), + summary: expect.stringContaining('"public"."Defaults"."jsonLiteral"'), }); expect(await readdir(dir)).not.toContain('contract.prisma'); }); diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts index 61c3af30a92a..905847bfdd65 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts @@ -18,11 +18,6 @@ const cases = readdirSync(fixturesDir, { withFileTypes: true }) .filter((name) => existsSync(join(fixturesDir, name, 'expected-contract.json'))) .sort(); -const NULLABLE_LIST = - 'the printer cannot print a nullable list type, so a list column would come back not nullable'; -const LOST_LIST_TYPE_PARAMS = - 'the PSL source drops type.typeParams from the domain field of a scalar list column'; - /** Why each fixture does not round-trip yet, naming every cause it has. */ const expectedFailures: ReadonlyMap = new Map([ [ @@ -41,50 +36,6 @@ const expectedFailures: ReadonlyMap = new Map([ ], ]); -/** - * Fixtures the printer refuses rather than write a file that would read back as - * a different contract, with the column it stops on and every reason it has. - */ -const refusedListColumns: ReadonlyMap< - string, - { readonly column: string; readonly reasons: readonly string[] } -> = new Map([ - [ - 'dbgenerated-without-expression-optional', - { column: '"public"."T"."list"', reasons: [NULLABLE_LIST] }, - ], - [ - 'enum-native', - { column: '"public"."User"."roleList"', reasons: [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS] }, - ], - [ - 'list-defaults', - { - column: '"public"."Lists"."bl"', - reasons: [NULLABLE_LIST, 'the PSL source refuses a function default on a list column'], - }, - ], - [ - 'native-types-accepted', - { - column: '"public"."NativeTypes"."varCharList"', - reasons: [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS], - }, - ], - [ - 'number-default-spellings', - { column: '"public"."Spellings"."mixed"', reasons: [NULLABLE_LIST] }, - ], - [ - 'number-defaults', - { column: '"public"."Decimals"."list"', reasons: [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS] }, - ], - [ - 'scalars', - { column: '"public"."Scalars"."stringList"', reasons: [NULLABLE_LIST, LOST_LIST_TYPE_PARAMS] }, - ], -]); - function prisma7SchemaPath(caseName: string): string { const directory = join(fixturesDir, caseName, 'schema'); return existsSync(directory) ? directory : join(fixturesDir, caseName, 'schema.prisma'); @@ -120,16 +71,6 @@ async function roundTrip(caseName: string): Promise { describe('a printed Prisma 7 contract reads back as the same contract', () => { for (const caseName of cases) { - const refused = refusedListColumns.get(caseName); - if (refused !== undefined) { - it(`${caseName} is refused at ${refused.column} (${refused.reasons.join('; ')})`, async () => { - await expect(roundTrip(caseName)).rejects.toMatchObject({ - code: 'CONTRACT.CONVERT_UNSUPPORTED', - message: expect.stringContaining(refused.column), - }); - }); - continue; - } const reasons = expectedFailures.get(caseName); if (reasons !== undefined) { it.fails(`${caseName} (${reasons.join('; ')})`, async () => { 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 a3158207c771..8963b80eddfb 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -1645,9 +1645,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, }; } 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 index 7983909c9977..a108f3c2bac1 100644 --- 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 @@ -1,4 +1,4 @@ -import { lowerFirst } from './psl-attribute-parsing'; +import { defaultTableName } from './default-table-name'; /** * The `@@map` name a model must carry for its table to read back as @@ -6,7 +6,7 @@ import { lowerFirst } from './psl-attribute-parsing'; * name already matches it. */ export function pslModelMapName(modelName: string, tableName: string): string | undefined { - return tableName === lowerFirst(modelName) ? undefined : tableName; + return tableName === defaultTableName(modelName) ? undefined : tableName; } /** 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..50aab3d6a928 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.list-type-params.test.ts @@ -0,0 +1,52 @@ +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 { + 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 }, + 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/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts index 8bd73684e224..16d5ee801af8 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts @@ -1,4 +1,4 @@ -import type { Contract, ContractField, ExecutionMutationDefault } from '@internal/contract/types'; +import type { Contract, ExecutionMutationDefault } from '@internal/contract/types'; import type { PslDocumentAst, PslExtensionBlock, @@ -95,47 +95,6 @@ function scalarFieldAttributes(input: { return attributes; } -/** - * Stops the conversion at a list column the printed file cannot carry without - * changing the contract: the printer package writes `Type[]` or `Type?` but - * never both, and the PSL source drops a list field's type parameters from the - * domain field it reads back. A converted file must never read back as a - * different contract, so both are refused rather than written. - */ -function refuseLossyListColumn(input: { - readonly column: StorageColumn; - readonly field: ContractField | undefined; - readonly coordinate: string; -}): void { - const { column, field, coordinate } = input; - if (column.many !== true) return; - - if (column.nullable) { - throw postgresError( - 'CONTRACT.CONVERT_UNSUPPORTED', - `contract convert: column ${coordinate} is a nullable list, which cannot be written in Prisma 8 PSL.`, - { - why: 'A field type is written as a list or as optional, never as both, so the written column would read back as a list that cannot be null.', - fix: 'Make the column not null before converting, or author the Prisma 8 contract by hand for this model.', - meta: { coordinate }, - }, - ); - } - - const typeParams = field?.type.kind === 'scalar' ? field.type.typeParams : undefined; - if (typeParams !== undefined && Object.keys(typeParams).length > 0) { - throw postgresError( - 'CONTRACT.CONVERT_UNSUPPORTED', - `contract convert: column ${coordinate} is a list whose type parameters cannot be written in Prisma 8 PSL.`, - { - why: "The PSL source keeps a list column's type parameters on the storage column but drops them from the field it reads back, so the written column would read back with a different type.", - fix: 'Author the Prisma 8 contract by hand for this model.', - meta: { coordinate, typeParams }, - }, - ); - } -} - function buildScalarFields(input: { readonly entry: ModelEntry; readonly enums: NativeEnumEmission; @@ -151,8 +110,6 @@ function buildScalarFields(input: { const column = entry.table.columns[columnName]; if (column === undefined) continue; const coordinate = `"${entry.namespaceId}"."${entry.tableName}"."${columnName}"`; - refuseLossyListColumn({ column, field: entry.model.fields[fieldName], coordinate }); - const columnType = printColumnType({ column, typeMap, diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts index 052e9f4b4213..ac49155d9fe9 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -110,13 +110,13 @@ function table(input: { function oneModel(columns: Record, fields: Record) { return print({ - models: { Widget: { table: 'widget', fields } }, - tables: { widget: table({ columns, primaryKey: { columns: ['id'] } }) }, + models: { Widget: { table: 'Widget', fields } }, + tables: { Widget: table({ columns, primaryKey: { columns: ['id'] } }) }, })[0]; } describe('names the PSL source cannot derive', () => { - it('maps a model whose table is not the lower-cased model name', () => { + it('maps a model whose table is not the model name', () => { const [model] = print({ models: { Widget: { table: 'widgets', fields: { id: { column: 'id' } } } }, tables: { widgets: table({ columns: { id: INT_COLUMN }, primaryKey: { columns: ['id'] } }) }, @@ -124,10 +124,10 @@ describe('names the PSL source cannot derive', () => { expect(model?.attributes.map(attributeText)).toEqual(['@@map("widgets")']); }); - it('leaves a model whose table is the lower-cased model name unmapped', () => { + it('leaves a model whose table is the model name unmapped', () => { const [model] = print({ - models: { Widget: { table: 'widget', fields: { id: { column: 'id' } } } }, - tables: { widget: table({ columns: { id: INT_COLUMN }, primaryKey: { columns: ['id'] } }) }, + models: { Widget: { table: 'Widget', fields: { id: { column: 'id' } } } }, + tables: { Widget: table({ columns: { id: INT_COLUMN }, primaryKey: { columns: ['id'] } }) }, }); expect(model?.attributes).toEqual([]); }); @@ -154,10 +154,10 @@ describe('keys and indexes', () => { const [withIndex] = print({ models: { - Widget: { table: 'widget', fields: { id: { column: 'id' }, email: { column: 'email' } } }, + Widget: { table: 'Widget', fields: { id: { column: 'id' }, email: { column: 'email' } } }, }, tables: { - widget: table({ + Widget: table({ columns: { id: INT_COLUMN, email: TEXT_COLUMN }, primaryKey: { columns: ['id'] }, indexes: [{ name: 'Widget_email_key', unique: true, columns: ['email'] }], @@ -173,10 +173,10 @@ describe('keys and indexes', () => { it('prints a unique constraint as @@unique, under the field names its columns carry', () => { const [model] = print({ models: { - Widget: { table: 'widget', fields: { id: { column: 'id' }, email: { column: 'e_mail' } } }, + Widget: { table: 'Widget', fields: { id: { column: 'id' }, email: { column: 'e_mail' } } }, }, tables: { - widget: table({ + Widget: table({ columns: { id: INT_COLUMN, e_mail: TEXT_COLUMN }, primaryKey: { columns: ['id'] }, uniques: [{ columns: ['e_mail'], name: 'widget_email_key' }], @@ -190,9 +190,9 @@ describe('keys and indexes', () => { it('prints every check constraint the table carries', () => { const [model] = print({ - models: { Widget: { table: 'widget', fields: { id: { column: 'id' } } } }, + models: { Widget: { table: 'Widget', fields: { id: { column: 'id' } } } }, tables: { - widget: table({ + Widget: table({ columns: { id: INT_COLUMN }, primaryKey: { columns: ['id'] }, checks: [ @@ -211,10 +211,10 @@ describe('keys and indexes', () => { it('prints a multi-column primary key as a model attribute', () => { const [model] = print({ models: { - Widget: { table: 'widget', fields: { a: { column: 'a' }, b: { column: 'b_col' } } }, + Widget: { table: 'Widget', fields: { a: { column: 'a' }, b: { column: 'b_col' } } }, }, tables: { - widget: table({ + Widget: table({ columns: { a: INT_COLUMN, b_col: TEXT_COLUMN }, primaryKey: { columns: ['a', 'b_col'] }, }), @@ -295,7 +295,7 @@ describe('column defaults', () => { } expect(thrown).toMatchObject({ code: 'CONTRACT.CONVERT_UNSUPPORTED', - message: expect.stringContaining('"public"."widget"."value"'), + message: expect.stringContaining('"public"."Widget"."value"'), }); }); }); @@ -320,17 +320,17 @@ describe('generated values', () => { ): string | undefined { const [model] = print({ models: { - Widget: { table: 'widget', fields: { id: { column: 'id' }, value: { column: 'value' } } }, + Widget: { table: 'Widget', fields: { id: { column: 'id' }, value: { column: 'value' } } }, }, tables: { - widget: table({ + Widget: table({ columns: { id: INT_COLUMN, value: column }, primaryKey: { columns: ['id'] }, }), }, execution: { mutations: { - defaults: [{ ref: { namespace: 'public', table: 'widget', column: 'value' }, ...phases }], + defaults: [{ ref: { namespace: 'public', table: 'Widget', column: 'value' }, ...phases }], }, }, }); @@ -397,7 +397,7 @@ describe('generated values', () => { }), ).toMatchObject({ code: 'CONTRACT.CONVERT_UNSUPPORTED', - message: expect.stringContaining('"public"."widget"."value"'), + message: expect.stringContaining('"public"."Widget"."value"'), }); expect( refusal({ @@ -406,7 +406,7 @@ describe('generated values', () => { }), ).toMatchObject({ code: 'CONTRACT.CONVERT_UNSUPPORTED', - message: expect.stringContaining('"public"."widget"."value"'), + message: expect.stringContaining('"public"."Widget"."value"'), }); }); }); @@ -552,7 +552,7 @@ describe('native enum blocks', () => { const domainNamespace: ApplicationDomainNamespace = { models: { Widget: { - storage: { table: 'widget', namespaceId: 'public', fields }, + storage: { table: 'Widget', namespaceId: 'public', fields }, fields: Object.fromEntries(Object.keys(fields).map((name) => [name, INT_FIELD])), relations: {}, }, @@ -566,7 +566,7 @@ describe('native enum blocks', () => { id: 'public', entries: { table: { - widget: table({ + Widget: table({ columns: input.columns ?? { id: INT_COLUMN }, primaryKey: { columns: ['id'] }, }), diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index e814cce97d36..0bc7ae4274c5 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -121,11 +121,10 @@ The converter prints the loaded contract as a Prisma 8 schema and the round-trip - A `Json` or `Jsonb` column whose default is an object or array literal. The PSL reader treats `@default("...")` on a Json field as a string, so there is no way to write the parsed value. - One model name declared in two namespaces. The PSL reader keys relation targets, junction detection and id columns by model name alone (`contract-psl/src/interpreter.ts`, `fkRelationsByDeclaringModel`, `modelIdColumns`), so the two models read back as one. - A generator the Postgres printer has no PSL form for (none of the Prisma 7 generators, which all print). -- A database-side default on a list column (`@default(dbgenerated("ARRAY[...]::text[]"))`). The PSL reader refuses it with `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`, although it is a storage default. Every Prisma 7 list default is one. In `contract-psl/src/psl-field-resolution.ts`. Exists on `main`. Reached only through a list column, which the converter already refuses for the two reader defects below. -- A nullable list type (`Tag[]?`). The PSL printer writes `[]` or `?`, never both, while every Prisma 7 list column is nullable. In `@internal/psl-printer`. Exists on `main`; the same defect makes `contract infer` print nullable lists as required. The converter refuses the column, naming it, until this is fixed. -- Type parameters on a list field (`Decimal @db.Numeric(65,30)[]`). The PSL reader keeps precision and scale on the storage column but drops them from the domain field's type. In `contract-psl/src/interpreter.ts`, `patchModelDomainFields`. Exists on `main`. The converter refuses the column, naming it, until this is fixed. -Because every Prisma 7 list column is nullable, a Prisma 7 schema with any list column is refused today. The refusal names the first such column, so the user learns which schema shape blocks the conversion instead of receiving a file that reads back differently. +Three list-column cases were refused in the first version and now convert: 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. + +The Json literal default is covered by slice B of [`projects/remove-dbgenerated`](../remove-dbgenerated/spec.md) (codec-owned PSL literals). Slice C of that project switches `print-column-default.ts` from `dbgenerated("...")` to the `sql` tagged literal along with every other printer. ### Found outside this project's scope diff --git a/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md b/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md index 5d9bc4e6b452..5782c64e9bb2 100644 --- a/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md +++ b/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md @@ -43,6 +43,7 @@ File: [`contract-prisma7/src/defaults.ts`](../../../../packages/2-sql/2-authorin Files: [`9-family/src/core/psl-contract-infer/default-mapping.ts`](../../../../packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts), [`postgres-default-mapping.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts), [`infer-model-blocks.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts), [`infer-psl-contract.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts). - `mapDefault`'s function arm: named functions print as today; anything else prints `@default(sql\`\`)`, or `@default(sql"")` with PSL string escaping when the expression contains a backtick. This is the family's built-in fallback; the `fallbackFunctionAttribute` option and `DefaultMappingResult`'s `comment` member are deleted. `postgres-default-mapping.ts` keeps only the named-function table (`gen_random_uuid()`) and its `formatDbGeneratedAttribute` is deleted. +- `prisma contract convert` has its own default printer, [`psl-print/print-column-default.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts), which writes `dbgenerated("...")` for every function default other than `now()` and `autoincrement()`. It prints the same `sql` form as `mapDefault`, and the round-trip test in `contract-prisma7/test/convert-roundtrip.test.ts` proves it. - Classification before printing: infer already resolves a column's raw default with the same Postgres parser verify uses (`parsePostgresDefault`, reached through `resolvedDefault`). Two classifications it does not make today are added so the Supabase contract prints literals where it can: - A default written as `''::` where `` is the column's own type (including `jsonb`, `json`, `text`, and array types) is a literal whose value is `` read through the column's codec. The four Supabase JSON defaults print as `@default("{}")` and `@default("[]")`. - A default written as `''::.` or `''::` where the enum is a native enum in the introspected schema and the column is typed by it is the enum member ``; infer prints `@default()`. The six Supabase enum casts print this way. The test at `infer-psl-contract.enum-adoption.test.ts` that pins a schema-qualified cast as preserved raw is rewritten to expect the member. diff --git a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts index 571c8228df4c..7562cc3a3322 100644 --- a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts @@ -220,7 +220,7 @@ withTempDir(({ createTempDir }) => { expect(convert.exitCode, output(convert)).toBe(2); const { code, summary } = errorOf(convert); expect(code).toBe('CONTRACT.CONVERT_UNSUPPORTED'); - expect(summary).toContain('"Scalars"."stringList"'); + expect(summary).toContain('"Defaults"."jsonLiteral"'); expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); }); }); 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 c67f0e4e663a..99ad85c622f4 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 335ece4b0a53..488d3731a80a 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": { From a37fc50ea512a2c271c745890a848a3847c127ab Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 12:23:42 +0200 Subject: [PATCH 36/55] feat(cli): contract print writes any contract as Prisma 8 PSL, and refuses by name what the language cannot carry The command was named contract convert, was restricted to a Prisma 7 source, and silently dropped what a Prisma 7 schema cannot produce: value objects, polymorphism, named types, domain enums, control policies and index arguments came out of the printer as if they were not in the contract. The restriction hid that. The rule the printer follows is the only one it ever had: the written file reads back as the same contract. It now writes value objects as type blocks, named types as a types block, domain enums as enum blocks with member-name defaults, @@discriminator and @@base for single- and multi-table variants, @@control, every index argument, and only the referential actions a foreign key carries. It skips the checks the reader derives itself. Where the language has no form for something, it refuses by name: a foreign key no relation travels, a union or dictionary field, a column with its own control policy, a model with an owner, an enum outside the default namespace, and the entities an extension contributes. The PSL printer package dropped every value-object type block from a document; it prints them now. The Prisma 7 check and its error code are gone, the command is contract print, and the error codes are CONTRACT.PRINT_*. A round-trip test over emitted TypeScript contracts and PSL-authored contracts proves each printed feature reads back equal, and proves the refusals. The Prisma 7 round trip, the printer unit tests and the journeys still pass. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 12 +- examples/prisma7-adoption/README.md | 2 +- .../config/src/contract-source-types.ts | 2 +- .../src/control/control-capabilities.ts | 2 +- .../psl-printer/src/ast-to-print-document.ts | 13 +- .../psl-printer/src/print-document.ts | 2 + .../src/serialize-print-document.ts | 9 +- .../test/print-psl-from-ast.test.ts | 56 +++++ packages/1-framework/3-tooling/cli/README.md | 25 ++- .../operations/load-contract-source.ts | 2 +- .../1-framework/3-tooling/cli/src/orm/cli.ts | 4 +- .../src/orm/contract/{convert.ts => print.ts} | 110 ++++------ .../3-tooling/cli/src/orm/family.ts | 4 +- ...convert.test.ts => contract-print.test.ts} | 82 ++++---- .../contract-prisma7/test/support.ts | 4 +- packages/2-sql/9-family/README.md | 2 +- .../9-family/src/core/control-instance.ts | 8 +- .../src/core/control-target-descriptor.ts | 4 +- packages/2-sql/9-family/src/core/errors.ts | 2 +- packages/3-extensions/postgres/README.md | 2 +- .../3-targets/3-targets/postgres/README.md | 2 +- .../3-targets/postgres/src/core/errors.ts | 2 +- .../core/psl-print/contract-model-index.ts | Bin 2738 -> 4271 bytes .../core/psl-print/print-column-default.ts | 30 ++- .../src/core/psl-print/print-column-type.ts | 12 +- .../src/core/psl-print/print-domain-types.ts | 165 +++++++++++++++ .../src/core/psl-print/print-enum-blocks.ts | 44 +++- .../psl-print/print-execution-defaults.ts | 8 +- .../src/core/psl-print/print-psl-contract.ts | Bin 14412 -> 26615 bytes .../core/psl-print/print-relation-fields.ts | 21 +- .../test/psl-print/print-psl-contract.test.ts | 23 ++- .../6-adapters/postgres/package.json | 1 + .../postgres/test/psl-print-roundtrip.test.ts | 195 ++++++++++++++++++ pnpm-lock.yaml | 3 + .../03-contract-to-psl-and-convert/spec.md | 10 +- projects/prisma7-contract-source/spec.md | 22 +- .../slices/c-remove-dbgenerated/spec.md | 2 +- ....e2e.test.ts => prisma7-print.e2e.test.ts} | 50 ++--- ...ed.ts => prisma.config.prisma7-printed.ts} | 2 +- .../test/utils/journey-test-helpers.ts | 4 +- 40 files changed, 724 insertions(+), 219 deletions(-) rename packages/1-framework/3-tooling/cli/src/orm/contract/{convert.ts => print.ts} (65%) rename packages/1-framework/3-tooling/cli/test/orm/{contract-convert.test.ts => contract-print.test.ts} (82%) create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts create mode 100644 packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts rename test/integration/test/cli-journeys/{prisma7-convert.e2e.test.ts => prisma7-print.e2e.test.ts} (80%) rename test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/{prisma.config.prisma7-converted.ts => prisma.config.prisma7-printed.ts} (82%) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index eb0af3b51af6..8d8c1dd4bb86 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -245,17 +245,13 @@ 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.CONVERT_OUTPUT_IS_SOURCE +### CONTRACT.PRINT_OUTPUT_IS_SOURCE -`prisma contract convert` was asked to write its output over the Prisma 7 schema it reads: the resolved output path is one of the contract source's inputs, or sits inside a directory of schema files the source reads. Writing there would destroy the schema the conversion is made from. Pick another `--output` path, outside the Prisma 7 schema the config names. Raised before the schema is read, so nothing is written and the source file is untouched. Payload: `output` and `source`, both relative to the invocation directory. +`prisma contract print` was asked to write its output 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. 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.CONVERT_SOURCE_NOT_PRISMA7 +### CONTRACT.PRINT_UNSUPPORTED -`prisma contract convert` applies only to a Prisma 7 source, and the configured contract source is a PSL or TypeScript one, or names no format at all. A contract already authored the Prisma 8 way has nothing to convert. Point `contract` at `prisma7Schema("./schema.prisma")` to convert a Prisma 7 schema. Raised by `contract convert` before it reads anything, so nothing is written. Payload: `format` (the format the configured source declares, `null` when it declares none). - -### CONTRACT.CONVERT_UNSUPPORTED - -`contract convert` cannot print the loaded contract as a Prisma 8 PSL file: either the configured target has no `printPslContract` hook on its descriptor (meta: `targetId`), or the contract holds something that cannot be written in a Prisma 8 PSL file — a column whose native type is outside the target's type map, a generated value the target cannot author, a column that takes a wall-clock-now generator in one phase and a different generator in the other, a to-one relation with no foreign key behind it, or one model name declared in more than one namespace (a relation's target is resolved by model name alone, so the two would read back as one). Meta at the shape sites: `coordinate`, `nativeType`, `onCreate`, `onUpdate`, `model`, `field`, `modelName`, `namespaces`. +`contract print` cannot write the loaded contract as a Prisma 8 PSL file that reads back as the same contract, so it writes nothing. Either the configured target has no `printPslContract` hook on its descriptor (meta: `targetId`), or the contract holds something the PSL language has no form for. The message names the model, column, field, or entity. The refused shapes: a column whose native type is outside the target's type map; a generated value the target cannot author, or a column that takes a wall-clock-now generator in one phase and a different generator in the other; a to-one relation with no foreign key behind it, or a foreign key no relation travels; a field whose type is a union of types, or a dictionary field; a column with its own control policy (PSL declares the policy per model); a model with an owner; one model name declared in more than one namespace; an entity kind an extension contributes (row-level security policies, roles), whose PSL syntax the target printer does not know; and a value-object field whose codec the Postgres target does not own. Meta at the shape sites: `coordinate`, `nativeType`, `onCreate`, `onUpdate`, `model`, `field`, `columns`, `kind`, `control`, `owner`, `modelName`, `namespaces`, `names`, `codecId`. ### CONTRACT.DEFAULT_INVALID diff --git a/examples/prisma7-adoption/README.md b/examples/prisma7-adoption/README.md index 666ba00f0218..2bcc0a8a0cb4 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, `prisma contract convert` 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. +Out of scope here. When the last route has moved, `prisma contract print` 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/1-framework/1-core/config/src/contract-source-types.ts b/packages/1-framework/1-core/config/src/contract-source-types.ts index b1013b906e09..c344a5aa37af 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 @@ -66,7 +66,7 @@ export interface TypeScriptContractSourceProvider extends ContractSourceProvider readonly format: 'typescript'; } -/** A Prisma 7 `schema.prisma` read as the contract source; `prisma contract convert` writes its Prisma 8 equivalent. */ +/** A Prisma 7 `schema.prisma` read as the contract source; `prisma contract print` writes its Prisma 8 equivalent. */ export interface Prisma7ContractSourceProvider extends ContractSourceProviderBase { readonly format: 'prisma7'; } 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 aa1810fc345f..d58c6344302f 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 @@ -55,7 +55,7 @@ export function hasPslContractInfer( /** * Capability declaring that a family can print a contract as a PSL document - * AST. Consumed by `prisma contract convert`. + * AST. Consumed by `prisma contract print`. */ export interface PslContractPrintCapable { printPslContract(contract: TContract): PslDocumentAst; 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 fad089912fa4..b5476efcd42e 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, @@ -57,15 +58,20 @@ export function astDocumentToPrintDocument( // 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); } @@ -82,6 +88,9 @@ export function astDocumentToPrintDocument( 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)), @@ -184,7 +193,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[] = []; @@ -207,7 +216,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..5e14cec473b9 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 that use them. */ + readonly compositeTypes: readonly PrinterModel[]; readonly models: readonly PrinterModel[]; readonly extensionBlocks: readonly PslExtensionBlock[]; }; 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 f3d00ecdc98c..346a6bcb0c13 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', diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 55b32cee2030..a60e4623e63d 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -345,40 +345,39 @@ prisma db schema --json prisma db schema -v ``` -### `prisma contract convert` +### `prisma contract print` -Read the Prisma 7 schema the config names as the contract source and write the Prisma 8 PSL that produces the same contract. Use it when a project on `prisma7Schema(...)` is ready to stop reading the Prisma 7 file and author in Prisma 8 PSL instead. +Load the contract from the source the config names and write the Prisma 8 PSL that reads back as the same contract. The source can be a Prisma 7 schema (`prisma7Schema(...)`), a TypeScript contract, or a PSL schema. 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 convert [--config ] [--output ] [--json] [-v] [-q] [--color/--no-color] +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 converted PSL contract to the specified path -- `--json`: Output a JSON result envelope (includes `psl.path` and the `source` schema it read) +- `--output `: Write the printed PSL contract to the specified path +- `--json`: Output a JSON result envelope (includes `psl.path` and the `source` files it read) - `-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 schema file, not the server. The output path is chosen the same way `contract infer` chooses one, and an existing file there is overwritten with a warning. +The command needs no database connection: it reads the source files, not the server. The output path is chosen the same way `contract infer` chooses one, and an existing file there is overwritten with a warning. The written file opens with the two lines that say where it came from: ```prisma // use prisma-8 -// Converted from prisma/schema.prisma by `prisma contract convert`. +// Printed from prisma/schema.prisma by `prisma contract print`. ``` -Some Prisma 7 schemas cannot be converted yet. Four things are refused, and none of them writes a file: -- A target or a schema shape Prisma 8 cannot express exits `2` with `CONTRACT.CONVERT_UNSUPPORTED`, naming the target or the shape it stopped on. A target with no converter, one model name declared in two namespaces, and a to-one relation with no foreign key behind it name no column; a refusal that is about one column names that column. This is the one most conversions meet first. A list column is the common case: a Prisma 7 list column is always nullable, and a Prisma 8 field type is written as a list or as optional, never as both, so the written column would read back as a list that cannot be null. A JSON object literal default is the next: the written value would read back as a string rather than as the value the column defaults to. Replace such a default with a database expression default, or drop it, then convert again. -- A contract source that is not a Prisma 7 schema exits `2` with `CONTRACT.CONVERT_SOURCE_NOT_PRISMA7`. A contract already authored the Prisma 8 way has nothing to convert. -- A Prisma 7 schema Prisma 8 cannot read exits `2` with `CONTRACT.SOURCE_LOAD_FAILED`, reporting exactly what `contract emit` reports for the same schema. -- An `--output` path that is the Prisma 7 schema the config reads, or sits inside a directory of schema files it reads, exits `2` with `CONTRACT.CONVERT_OUTPUT_IS_SOURCE`. Converting there would destroy the schema the conversion is made from. Pick another path. +The written file must read back as the identical contract. The printer writes models, value objects (`type` blocks), named types (`types` block), native enums, keys, indexes with every argument, checks, relations, polymorphism (`@@discriminator`, `@@base`), control policies and execution defaults. Where the PSL language has no form for something the contract holds, the command refuses and writes nothing. Three things are refused with exit `2`: +- A shape the language cannot carry exits with `CONTRACT.PRINT_UNSUPPORTED`, naming the model, column, field or entity it stopped on: a foreign key no relation travels, a to-one relation with no foreign key, a union or dictionary field, a column with its own control policy, a model with an owner, one model name declared in two namespaces, a `Json` object or array literal default, or an entity an extension contributes (row-level security policies, roles). +- A source Prisma 8 cannot read exits with `CONTRACT.SOURCE_LOAD_FAILED`, reporting exactly what `contract emit` reports for the same source. +- An `--output` path that is a source file the config reads, or sits inside a directory of source files, exits with `CONTRACT.PRINT_OUTPUT_IS_SOURCE`. Pick another path. -Converting is the first step of the cutover, not the whole of it. After it succeeds, point `contract` in `prisma.config.ts` at the written file and run the rest: +Printing is the first step of the cutover, not the whole of it. After it succeeds, point `contract` in `prisma.config.ts` at the written file and run the rest: ```bash prisma contract emit 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 index 87f91f4bc286..2ef52ab6498c 100644 --- 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 @@ -195,7 +195,7 @@ function validateProviderResult(providerResult: unknown): ValidatedProviderResul /** * Builds the control stack, asks the configured contract source for the * contract, and turns every failure into `CONTRACT.SOURCE_LOAD_FAILED`. Shared - * by `contract emit` and `contract convert` so both report a bad source the + * by `contract emit` and `contract print` so both report a bad source the * same way. * * @throws {CliStructuredError} `CONTRACT.SOURCE_LOAD_FAILED` when the source 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 7aaaedd20459..d118ee2790ae 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/cli.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/cli.ts @@ -5,9 +5,9 @@ import { createCli, telemetryCommandGroup } from '@prisma/cli-engine'; import { version as CLI_VERSION } from '../../package.json' with { type: 'json' }; import { createControlClient } from '../control-api/client'; import type { CreateControlClient } from '../control-api/types'; -import { contractConvertCommand } from './contract/convert'; 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'; @@ -96,7 +96,7 @@ export const BIN_GROUPS = { */ export function createBinCommands(createClient: CreateControlClient): MountedTree { return { - 'contract convert': contractConvertCommand, + 'contract print': contractPrintCommand, 'contract emit': contractEmitCommand, 'contract format': formatCommand, 'contract infer': contractInferCommand, diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts b/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts similarity index 65% rename from packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts rename to packages/1-framework/3-tooling/cli/src/orm/contract/print.ts index 4b02f56c0c4a..6d838f60a83e 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/convert.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts @@ -17,24 +17,24 @@ import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; import { inferredContractPathFor } from './paths'; -interface ConvertDocument { +interface PrintDocument { readonly ok: true; readonly summary: string; readonly target: { readonly familyId: string; readonly id: string }; readonly psl: { readonly path: string }; - readonly source: string; + readonly source: readonly string[]; readonly timings: { readonly total: number }; } /** - * The routine that carries a converted contract onto the database Prisma 7 - * built: point the config at the written file, emit, plan a baseline, sign, - * then point the `db` ref at the baseline. + * The routine that carries a printed contract onto the database its source + * describes: point the config at the written file, emit, plan a baseline, + * sign, then point the `db` ref at the baseline. */ function cutoverActions(writtenPath: string): readonly NextAction[] { return [ chooseAction(`Point contract in prisma.config.ts at ${writtenPath}`), - runCommandAction('Emit the converted contract', '{bin} contract emit'), + runCommandAction('Emit the printed contract', '{bin} contract emit'), runCommandAction('Plan the baseline migration', '{bin} migration plan --name baseline'), runCommandAction('Sign the database', '{bin} db sign'), runCommandAction( @@ -44,7 +44,7 @@ function cutoverActions(writtenPath: string): readonly NextAction[] { ]; } -function convertPresentations(document: ConvertDocument): Presentations { +function printPresentations(document: PrintDocument): Presentations { return { stdout: () => [], next: () => cutoverActions(document.psl.path), @@ -59,19 +59,20 @@ function convertPresentations(document: ConvertDocument): Presentations { }; } -/** What `contract convert` uses of the control client; doubles implement just this. */ -export type ConvertControlClient = Pick< +/** What `contract print` uses of the control client; doubles implement just this. */ +export type PrintControlClient = Pick< ControlClient, 'printPslContract' | 'getPslBlockDescriptors' | 'close' >; -export interface ContractConvertCommandDeps { - readonly createControlClient: (options: ControlClientOptions) => ConvertControlClient; +export interface ContractPrintCommandDeps { + readonly createControlClient: (options: ControlClientOptions) => PrintControlClient; readonly printPsl: typeof printPslFromAst; } -function convertHeaderComment(schemaPath: string): string { - return `// use prisma-8\n// Converted from ${schemaPath} by \`prisma contract convert\`.`; +function printHeaderComment(sourcePaths: readonly string[]): string { + const origin = sourcePaths.length === 0 ? '' : ` from ${sourcePaths.join(', ')}`; + return `// use prisma-8\n// Printed${origin} by \`prisma contract print\`.`; } /** @@ -93,29 +94,31 @@ function sourceInputCovering(inputs: { }); } -export function createContractConvertCommand({ +export function createContractPrintCommand({ createControlClient, printPsl, -}: ContractConvertCommandDeps) { +}: ContractPrintCommandDeps) { return defineOrmCommand({ help: { - summary: 'Convert a Prisma 7 schema into a Prisma 8 PSL contract', + summary: 'Write the configured contract as a Prisma 8 PSL schema file', description: - 'Reads the Prisma 7 schema the config names as the contract source and\n' + - 'writes the Prisma 8 PSL that produces the same contract. The command\n' + + 'Loads the contract from the source the config names (a Prisma 7\n' + + 'schema, a TypeScript contract, or a PSL schema) and writes the Prisma 8\n' + + 'PSL that reads back as the same contract. Anything the PSL language\n' + + 'cannot carry is refused by name and nothing is written. The command\n' + 'stops at contract.prisma; switch the config to the written file, then\n' + - 'run `contract emit` and the rest of the cutover. An existing file at the\n' + - 'output path is overwritten, with a warning.', + 'run `contract emit`. An existing file at the output path is\n' + + 'overwritten, with a warning.', examples: [ - 'contract convert', - 'contract convert --output ./src/prisma/contract.prisma', - 'contract convert --json', + 'contract print', + 'contract print --output ./src/prisma/contract.prisma', + 'contract print --json', ], }, args: { flags: { output: flag.string({ - brief: 'Write the converted PSL contract to the specified path', + brief: 'Write the printed PSL contract to the specified path', placeholder: 'path', }), }, @@ -128,38 +131,13 @@ export function createContractConvertCommand({ return notOk( normalizeError( errorContractConfigMissing({ - why: 'Config.contract.source is required for contract convert. Define it in your config: contract: prisma7Schema("./schema.prisma")', + why: 'Config.contract.source is required for contract print. Define it in your config, for example contract: prisma7Schema("./schema.prisma") or contract: prismaContract("./contract.prisma")', }), ), ); } - if (contractConfig.source.format !== 'prisma7') { - return notOk( - normalizeError( - errorRuntime( - 'CONTRACT.CONVERT_SOURCE_NOT_PRISMA7', - 'contract convert applies only to a Prisma 7 source', - { - why: `The configured contract source has format "${contractConfig.source.format ?? 'unspecified'}", and there is nothing to convert: the contract is already authored the Prisma 8 way.`, - fix: 'Point contract at prisma7Schema("./schema.prisma") to convert a Prisma 7 schema.', - meta: { format: contractConfig.source.format ?? null }, - }, - ), - ), - ); - } - - const schemaInput = contractConfig.source.inputs?.[0]; - if (schemaInput === undefined) { - return notOk( - normalizeError( - errorContractConfigMissing({ - why: 'The Prisma 7 contract source names no schema file, so there is nothing to convert.', - }), - ), - ); - } - const schemaPath = relative(ctx.cwd, schemaInput); + const sourceInputs = contractConfig.source.inputs ?? []; + const sourcePaths = sourceInputs.map((input) => relative(ctx.cwd, input)); const outputPath = inferredContractPathFor({ config: ctx.config, @@ -168,7 +146,7 @@ export function createContractConvertCommand({ }); const displayPath = relative(ctx.cwd, outputPath); const sourceInput = sourceInputCovering({ - inputs: contractConfig.source.inputs ?? [], + inputs: sourceInputs, cwd: ctx.cwd, outputPath, }); @@ -176,11 +154,11 @@ export function createContractConvertCommand({ return notOk( normalizeError( errorRuntime( - 'CONTRACT.CONVERT_OUTPUT_IS_SOURCE', - 'contract convert would write over the schema it reads', + 'CONTRACT.PRINT_OUTPUT_IS_SOURCE', + 'contract print would write over the schema it reads', { - why: `The output path ${displayPath} is the contract source ${relative(ctx.cwd, sourceInput)}, or sits inside it, so converting would destroy the Prisma 7 schema.`, - fix: 'Pick another --output path, outside the Prisma 7 schema the config names.', + why: `The output path ${displayPath} is the contract source ${relative(ctx.cwd, sourceInput)}, 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: displayPath, source: relative(ctx.cwd, sourceInput) }, }, ), @@ -208,12 +186,12 @@ export function createContractConvertCommand({ return notOk( normalizeError( errorRuntime( - 'CONTRACT.CONVERT_UNSUPPORTED', - 'contract convert is not supported for this target', + 'CONTRACT.PRINT_UNSUPPORTED', + 'contract print is not supported for this target', { why: 'The configured target does not implement the PslContractPrintCapable capability, so the loaded contract cannot be written as Prisma 8 PSL.', // biome-ignore lint/plugin/no-family-vocabulary: names a target on purpose — this is user-facing guidance about which target can convert, not a framework type - fix: 'Use a target that supports contract conversion (Postgres today).', + fix: 'Use a target that can print a contract as PSL (Postgres today).', }, ), ), @@ -222,7 +200,7 @@ export function createContractConvertCommand({ pslContent = printPsl(pslContractAst, { pslBlockDescriptors: client.getPslBlockDescriptors(), codecLookup: stack.codecLookup, - headerComment: convertHeaderComment(schemaPath), + headerComment: printHeaderComment(sourcePaths), }); } catch (error) { return notOk(normalizeError(error)); @@ -244,21 +222,21 @@ export function createContractConvertCommand({ publicationToken: String(process.hrtime.bigint()), }); - const document: ConvertDocument = { + const document: PrintDocument = { ok: true, - summary: 'Contract converted successfully', + summary: 'Contract printed successfully', target: { familyId: ctx.config.family.familyId, id: ctx.config.target.targetId }, psl: { path: displayPath }, - source: schemaPath, + source: sourcePaths, timings: { total: Date.now() - startedAt }, }; - return ok(ctx.present({ data: document }, convertPresentations(document))); + return ok(ctx.present({ data: document }, printPresentations(document))); }, }); } -export const contractConvertCommand = createContractConvertCommand({ +export const contractPrintCommand = createContractPrintCommand({ createControlClient: createDefaultControlClient, 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 9001543d9dc7..cbd0782dc41f 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/family.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/family.ts @@ -2,9 +2,9 @@ import { DOCS_BASE } from '@internal/utils/structured-error'; import type { AnyCommand, RedirectSpec } from '@prisma/cli-engine'; import { defineCommandFamily } from '@prisma/cli-engine'; import { ormConfigSection } from './config-section'; -import { contractConvertCommand } from './contract/convert'; 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'; @@ -33,7 +33,7 @@ import { refSetCommand } from './ref/set'; const DOCS_BASE_URL = `${DOCS_BASE}/`; const commands: Readonly> = { - 'contract convert': contractConvertCommand, + 'contract print': contractPrintCommand, 'contract emit': contractEmitCommand, 'contract format': formatCommand, 'contract infer': contractInferCommand, diff --git a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-print.test.ts similarity index 82% rename from packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts rename to packages/1-framework/3-tooling/cli/test/orm/contract-print.test.ts index e2d2522b2433..17ede4072105 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/contract-convert.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-print.test.ts @@ -7,7 +7,7 @@ import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BIN_GROUPS } from '../../src/orm/cli'; -import { createContractConvertCommand } from '../../src/orm/contract/convert'; +import { createContractPrintCommand } from '../../src/orm/contract/print'; import { createTestProjectDir } from '../utils/test-project-dir'; const PSL = 'model User {\n id Int @id\n}\n'; @@ -26,7 +26,7 @@ const mocks = { }; const commands: MountedTree = { - 'contract convert': createContractConvertCommand({ + 'contract print': createContractPrintCommand({ createControlClient: () => ({ printPslContract: mocks.printPslContract, getPslBlockDescriptors: mocks.getPslBlockDescriptors, @@ -99,33 +99,33 @@ function erroredEnvelope(run: { readonly json: readonly StreamEvent[] }): Errore return terminal.envelope; } -describe('contract convert', () => { +describe('contract print', () => { 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', 'convert', '--json'], { cwd: dir }); + 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 converted successfully', + summary: 'Contract printed successfully', target: { familyId: 'sql', id: 'postgres' }, psl: { path: 'generated/contract.prisma' }, - source: 'prisma/schema.prisma', + source: ['prisma/schema.prisma'], timings: { total: expect.any(Number) }, }); }); - it('opens the written file with a header naming the schema it converted', async () => { + it('opens the written file with a header naming the source it printed', async () => { const dir = await projectDir(); - await harness(ormConfig(dir)).run(['contract', 'convert', '--json'], { cwd: dir }); + await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); expect(mocks.printPsl).toHaveBeenCalledWith( { kind: 'psl-document' }, expect.objectContaining({ headerComment: - '// use prisma-8\n// Converted from prisma/schema.prisma by `prisma contract convert`.', + '// use prisma-8\n// Printed from prisma/schema.prisma by `prisma contract print`.', }), ); expect(await readFile(join(dir, 'generated', 'contract.prisma'), 'utf-8')).toBe(PSL); @@ -134,7 +134,7 @@ describe('contract convert', () => { it('publishes through a staged rename, leaving no temporary file behind', async () => { const dir = await projectDir(); - await harness(ormConfig(dir)).run(['contract', 'convert', '--json'], { cwd: dir }); + await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); expect(await readdir(join(dir, 'generated'))).toEqual(['contract.prisma']); }); @@ -146,7 +146,7 @@ describe('contract convert', () => { controller.abort(); }); - const run = await harness(ormConfig(dir)).run(['contract', 'convert', '--json'], { + const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir, abort: controller.signal, }); @@ -159,7 +159,7 @@ describe('contract convert', () => { const dir = await projectDir(); const run = await harness(ormConfig(dir)).run( - ['contract', 'convert', '--output', 'schema/live.prisma', '--json'], + ['contract', 'print', '--output', 'schema/live.prisma', '--json'], { cwd: dir }, ); @@ -170,13 +170,13 @@ describe('contract convert', () => { it('overwrites an existing contract with a warning and no prompt', async () => { const dir = await projectDir(); const run1 = await harness(ormConfig(dir)).run( - ['contract', 'convert', '--output', 'contract.prisma', '--json'], + ['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', 'convert', '--output', 'contract.prisma', '--json'], + ['contract', 'print', '--output', 'contract.prisma', '--json'], { cwd: dir }, ); @@ -193,7 +193,7 @@ describe('contract convert', () => { it('ships the written path and the next step as blocks', async () => { const dir = await projectDir(); - const run = await harness(ormConfig(dir)).run(['contract', 'convert'], { + const run = await harness(ormConfig(dir)).run(['contract', 'print'], { cwd: dir, isTty: { stdout: true, stderr: true }, }); @@ -213,7 +213,7 @@ describe('contract convert', () => { kind: 'user-choice', label: 'Point contract in prisma.config.ts at generated/contract.prisma', }, - { kind: 'run-command', label: 'Emit the converted contract', command: '{bin} contract emit' }, + { kind: 'run-command', label: 'Emit the printed contract', command: '{bin} contract emit' }, { kind: 'run-command', label: 'Plan the baseline migration', @@ -238,13 +238,13 @@ describe('contract convert', () => { await writeFile(join(dir, 'prisma', 'schema.prisma'), schema, 'utf-8'); const run = await harness(ormConfig(dir)).run( - ['contract', 'convert', '--output', 'prisma/schema.prisma', '--json'], + ['contract', 'print', '--output', 'prisma/schema.prisma', '--json'], { cwd: dir }, ); expect(run.exitCode).toBe(2); expect(erroredEnvelope(run).error).toMatchObject({ - code: 'CONTRACT.CONVERT_OUTPUT_IS_SOURCE', + 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); @@ -261,16 +261,16 @@ describe('contract convert', () => { }); const run = await harness(config).run( - ['contract', 'convert', '--output', 'prisma/nested/contract.prisma', '--json'], + ['contract', 'print', '--output', 'prisma/nested/contract.prisma', '--json'], { cwd: dir }, ); expect(run.exitCode).toBe(2); - expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.CONVERT_OUTPUT_IS_SOURCE' }); + expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.PRINT_OUTPUT_IS_SOURCE' }); expect(await readdir(dir)).not.toContain('prisma'); }); - it('refuses a PSL source and writes nothing', async () => { + it('prints a PSL source too: the command is not tied to Prisma 7', async () => { const dir = await projectDir(); const config = ormConfig(dir, { contract: { @@ -279,14 +279,18 @@ describe('contract convert', () => { }, }); - const run = await harness(config).run(['contract', 'convert', '--json'], { cwd: dir }); + const run = await harness(config).run(['contract', 'print', '--json'], { cwd: dir }); - expect(run.exitCode).toBe(2); - expect(erroredEnvelope(run).error).toMatchObject({ - code: 'CONTRACT.CONVERT_SOURCE_NOT_PRISMA7', - }); - expect(mocks.load).not.toHaveBeenCalled(); - expect(await readdir(dir)).not.toContain('generated'); + expect(run.exitCode).toBe(0); + expect(mocks.load).toHaveBeenCalled(); + expect(mocks.printPsl).toHaveBeenCalledWith( + { kind: 'psl-document' }, + expect.objectContaining({ + headerComment: + '// use prisma-8\n// 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 () => { @@ -299,7 +303,7 @@ describe('contract convert', () => { }, }); - const run = await harness(ormConfig(dir)).run(['contract', 'convert', '--json'], { cwd: dir }); + 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' }); @@ -310,10 +314,10 @@ describe('contract convert', () => { const dir = await projectDir(); mocks.printPslContract.mockReturnValue(undefined); - const run = await harness(ormConfig(dir)).run(['contract', 'convert', '--json'], { cwd: dir }); + const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); expect(run.exitCode).toBe(2); - expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.CONVERT_UNSUPPORTED' }); + expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.PRINT_UNSUPPORTED' }); expect(mocks.close).toHaveBeenCalled(); }); @@ -321,8 +325,8 @@ describe('contract convert', () => { const dir = await projectDir(); mocks.printPslContract.mockImplementation(() => { throw structuredError( - 'CONTRACT.CONVERT_UNSUPPORTED', - 'contract convert: column "public"."Defaults"."jsonLiteral" has a literal default that cannot be written in Prisma 8 PSL.', + 'CONTRACT.PRINT_UNSUPPORTED', + 'contract print: column "public"."Defaults"."jsonLiteral" has a literal default that cannot be written in Prisma 8 PSL.', { why: 'The PSL source would read a quoted default back as a string.', fix: 'Replace the literal default with a database expression default.', @@ -332,13 +336,13 @@ describe('contract convert', () => { }); const run = await harness(ormConfig(dir)).run( - ['contract', 'convert', '--output', 'contract.prisma', '--json'], + ['contract', 'print', '--output', 'contract.prisma', '--json'], { cwd: dir }, ); expect(run.exitCode).toBe(2); expect(erroredEnvelope(run).error).toMatchObject({ - code: 'CONTRACT.CONVERT_UNSUPPORTED', + code: 'CONTRACT.PRINT_UNSUPPORTED', summary: expect.stringContaining('"public"."Defaults"."jsonLiteral"'), }); expect(await readdir(dir)).not.toContain('contract.prisma'); @@ -348,8 +352,8 @@ describe('contract convert', () => { const dir = await projectDir(); mocks.printPslContract.mockImplementation(() => { throw structuredError( - 'CONTRACT.CONVERT_UNSUPPORTED', - 'contract convert: column "public"."Defaults"."jsonLiteral" has a literal default that cannot be written in Prisma 8 PSL.', + 'CONTRACT.PRINT_UNSUPPORTED', + 'contract print: column "public"."Defaults"."jsonLiteral" has a literal default that cannot be written in Prisma 8 PSL.', { why: 'The PSL source would read the written value back as a string rather than as the value the column defaults to.', fix: 'Replace the literal default with a database expression default, or drop the default before converting.', @@ -358,11 +362,11 @@ describe('contract convert', () => { ); }); - const run = await harness(ormConfig(dir)).run(['contract', 'convert', '--json'], { cwd: dir }); + const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); expect(run.exitCode).toBe(2); expect(erroredEnvelope(run).error).toMatchObject({ - code: 'CONTRACT.CONVERT_UNSUPPORTED', + code: 'CONTRACT.PRINT_UNSUPPORTED', summary: expect.stringContaining('"public"."Defaults"."jsonLiteral"'), why: 'The PSL source would read the written value back as a string rather than as the value the column defaults to.', nextActions: [ diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/support.ts b/packages/2-sql/2-authoring/contract-prisma7/test/support.ts index a5b81a09a7ac..96084108c0f6 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/support.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/support.ts @@ -50,7 +50,7 @@ export function loadFixtureSchema(caseName: string) { ); } -/** Prints a contract as Prisma 8 PSL text, the way `contract convert` does. */ +/** Prints a contract as Prisma 8 PSL text, the way `contract print` does. */ export function printContractAsPsl(contract: Contract): string { const ast = postgres.printPslContract?.(contract); if (ast === undefined) { @@ -65,7 +65,7 @@ export function printContractAsPsl(contract: Contract): string { /** Loads printed PSL text back through the Prisma 8 PSL source. */ export async function loadPrintedPsl(text: string): Promise> { - const directory = mkdtempSync(join(tmpdir(), 'prisma7-convert-')); + const directory = mkdtempSync(join(tmpdir(), 'prisma7-print-')); const printedPath = join(directory, 'contract.prisma'); writeFileSync(printedPath, text); const result = await prismaContract(printedPath, { diff --git a/packages/2-sql/9-family/README.md b/packages/2-sql/9-family/README.md index 381f7cb4c132..2a378bfb46ad 100644 --- a/packages/2-sql/9-family/README.md +++ b/packages/2-sql/9-family/README.md @@ -104,7 +104,7 @@ Family instances implement domain actions: - **`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. -- **`printPslContract(contract)`**: Prints an assembled contract as the PSL document that reads back as the same contract, for `contract convert`. Delegates to the target descriptor's optional `printPslContract` hook; throws `CONTRACT.CONVERT_UNSUPPORTED` when the target has none. +- **`printPslContract(contract)`**: Prints an assembled contract as the PSL document that reads back as the same contract, for `contract print`. Delegates to the target descriptor's optional `printPslContract` hook; throws `CONTRACT.PRINT_UNSUPPORTED` when the target has none. The descriptor is "pure data + factory" - it only provides the hook and factory method. All family-specific logic lives on the instance. 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 0d2dc57a2279..b3d101fc1d37 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -587,7 +587,7 @@ export function createSqlFamilyInstance( 'reading the optional target-descriptor inferPslContract hook' >(target).inferPslContract; // Contract→PSL printing is target logic for the same reason, and is read the - // same way. Absent for targets without `contract convert`. + // same way. Absent for targets without `contract print`. const targetPrintPslContract = blindCast< SqlControlTargetDescriptor, 'reading the optional target-descriptor printPslContract hook' @@ -1026,11 +1026,11 @@ export function createSqlFamilyInstance( printPslContract(contract: Contract): PslDocumentAst { if (!targetPrintPslContract) { throw sqlFamilyError( - 'CONTRACT.CONVERT_UNSUPPORTED', - `Target "${target.targetId}" does not support contract convert (no printPslContract on its descriptor).`, + 'CONTRACT.PRINT_UNSUPPORTED', + `Target "${target.targetId}" does not support contract print (no printPslContract on its descriptor).`, { why: 'The target descriptor does not provide the printPslContract hook, so the contract cannot be printed as a Prisma 8 PSL file.', - fix: 'Use a target package that supports contract convert, or author the contract instead of converting it.', + fix: 'Use a target package that supports contract print, or author the contract instead of converting it.', meta: { targetId: target.targetId }, }, ); 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 fb6eb3afb431..2d2f4787dd8c 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 @@ -63,11 +63,11 @@ export interface SqlControlTargetDescriptor< describedContracts?: readonly SqlDescribedContractSpace[], ) => PslDocumentAst; /** - * Contract→PSL printing for `contract convert`. The reverse of + * Contract→PSL printing for `contract print`. The reverse of * {@link inferPslContract}: it takes an already-assembled family contract and * returns the PSL document that reads back as the same contract. Target logic * for the same reason — it owns the dialect maps — so it lives beside its - * sibling on the descriptor. Optional: targets without `contract convert` + * sibling on the descriptor. Optional: targets without `contract print` * omit it, and the family instance throws when it is absent. */ readonly printPslContract?: (contract: TContract) => PslDocumentAst; diff --git a/packages/2-sql/9-family/src/core/errors.ts b/packages/2-sql/9-family/src/core/errors.ts index 7cd4372a6e16..d3b9d8a8bb91 100644 --- a/packages/2-sql/9-family/src/core/errors.ts +++ b/packages/2-sql/9-family/src/core/errors.ts @@ -2,7 +2,7 @@ import type { StructuredError, StructuredErrorOptions } from '@internal/utils/st import { structuredError } from '@internal/utils/structured-error'; type SqlFamilyErrorCode = - | 'CONTRACT.CONVERT_UNSUPPORTED' + | 'CONTRACT.PRINT_UNSUPPORTED' | 'CONTRACT.FOREIGN_KEY_INVALID' | 'CONTRACT.INFER_UNSUPPORTED' | 'CONTRACT.MARKER_ROW_CORRUPT' diff --git a/packages/3-extensions/postgres/README.md b/packages/3-extensions/postgres/README.md index 988e516b030c..96ea0ffe51cd 100644 --- a/packages/3-extensions/postgres/README.md +++ b/packages/3-extensions/postgres/README.md @@ -99,7 +99,7 @@ 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 convert` writes the Prisma 8 PSL that produces the same contract, with a header naming the schema it came from. 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`. 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 converts yet: a construct a Prisma 8 schema cannot express, a JSON object literal default above all, exits `2` with `CONTRACT.CONVERT_UNSUPPORTED` and writes nothing. +When the project is ready to stop reading the Prisma 7 file, `prisma contract print` writes the Prisma 8 PSL that produces the same contract, with a header naming the schema it came from. 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`. 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 converts yet: a construct a Prisma 8 schema cannot express, a JSON object literal default above all, exits `2` with `CONTRACT.PRINT_UNSUPPORTED` 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: diff --git a/packages/3-targets/3-targets/postgres/README.md b/packages/3-targets/3-targets/postgres/README.md index b759b535e216..ac6b5be61278 100644 --- a/packages/3-targets/3-targets/postgres/README.md +++ b/packages/3-targets/3-targets/postgres/README.md @@ -25,7 +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 `printPslContract` descriptor hook, which `prisma contract convert` uses: it takes an assembled Postgres contract and returns the Prisma 8 PSL document that reads back as that same contract. It lives in `src/core/psl-print/` and is the reverse of the `inferPslContract` hook in `src/core/psl-infer/`, whose literal, index and enum-block builders it reuses. Model and field names come from the contract, never re-derived; `@@map` and `@map` appear only where the name the PSL source would derive differs. Uniques print as unique indexes, because that is what a Prisma 7 schema lowers them to, and every index prints explicitly, so each relation declines a backing index of its own. 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. +- **Contract-to-PSL Printing**: Implements the `printPslContract` descriptor hook, which `prisma contract print` uses: it takes an assembled Postgres contract, from any source, and returns the Prisma 8 PSL document that reads back as that same contract: models, value objects, named types, domain and native enums, keys, indexes, checks, relations, polymorphism, control policies and execution defaults. Anything the PSL language has no form for is refused by name with `CONTRACT.PRINT_UNSUPPORTED`, never dropped. It lives in `src/core/psl-print/` and is the reverse of the `inferPslContract` hook in `src/core/psl-infer/`, whose literal, index and enum-block builders it reuses. Model and field names come from the contract, never re-derived; `@@map` and `@map` appear only where the name the PSL source would derive differs. Uniques print as unique indexes, because that is what a Prisma 7 schema lowers them to, and every index prints explicitly, so each relation declines a backing index of its own. 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/src/core/errors.ts b/packages/3-targets/3-targets/postgres/src/core/errors.ts index e0ae423eb34b..0c632e42275a 100644 --- a/packages/3-targets/3-targets/postgres/src/core/errors.ts +++ b/packages/3-targets/3-targets/postgres/src/core/errors.ts @@ -4,7 +4,7 @@ import { structuredError } from '@internal/utils/structured-error'; export type PostgresTargetErrorCode = | 'CONTRACT.CODEC_DESCRIPTOR_MISSING' - | 'CONTRACT.CONVERT_UNSUPPORTED' + | '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-print/contract-model-index.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/contract-model-index.ts index 60d65fd4e82a1980ab05e36e970497827a26a933..787bbe2cd288fed2862c48cb40006ca1616eb9f8 100644 GIT binary patch delta 1462 zcmZWpy>1gh5XOQ0xu5~kfRu@a5?^F45=|rrCxHlr3lIu{CIoqXx6Z9}-nI7DaV*Dj z$pc)&6Ho#o9ioGdo(hSQ*I;JXXCKKG)_Qkl=lhx2FSkCeeEu;r0@zqzhl7YipyF;@ zCSW3wKwE`8UQ0mrB(wyNLtF+yx6}7weWM7VISZAH`xLO}605L3-h9c;gT!bdkGHpq z!(x#tJXJkl?pjQanc-MT!3Kkliu-M)JCO*W$r4_HUc@B`1Bipc3Dbf}1F8i|^7L(C zY4qA{)n!Y;AP8sp{Z2NnIs9)7mG{j@0nMHE9aY6f6ZL$pg89@<~|m# z2TCR;lem_?TUah^rC;a2&pFl84-1=5yb7XqUtVs>dw^1xU8LV;opSlG;_f=~ptkLn zzBAT4|GV(BFtTQl-9#}buQeR_so^qA&;ns1t3)W0K#R7J%qWd!poU{>65QY+bY;l3 z)q$UOltwOH85p5t<{a5nMgp81xs$htXf^wLO6id9=jr>!l~JYWRUz;2nJv*05{r@B zq6KApmv1rqmPoBun}&JVVWz9)bcgJAO1tR)%KFDBVxU!$?DN)$YmUxNL0zildDGjR z9yKfG&KS?Qosu2@(7}|f&5w3`8%wD~;4)}9J0p2-qP`c%42tQu+2!=lEQ}gR!e|y4 zpbj$C@j#XC4yL7&4hwia9QygqVJY+089&LGYUmSAn>a zhy^wB*z+`9} zk#?5pl|qov(yBcfR40PJRw<^p=eI{~ZdertFriZqtFHN`4C`UVWj#mE`BNBXz{d+~ x_7F*oTgvevs4ma~bxSw=Z?fsfkbFVw>Lr(8b=4Z;+sB2_a-C)b0B_S2)c^nh diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts index b77f8c6ef601..bb5f5bd3b50f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts @@ -1,3 +1,4 @@ +import type { ContractEnum } from '@internal/contract/types'; import type { PslFieldAttribute } from '@internal/framework-components/psl-ast'; import type { StorageColumn } from '@internal/sql-contract/types'; import { postgresError } from '../errors'; @@ -29,6 +30,8 @@ export function printColumnDefault(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; @@ -38,6 +41,29 @@ export function printColumnDefault(input: { return undefined; } + if (input.domainEnum !== undefined && columnDefault.kind === 'literal') { + const memberName = input.domainEnum.members.find( + (member) => member.value === columnDefault.value, + )?.name; + if (memberName === undefined) { + throw postgresError( + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: column "${input.namespaceId}"."${input.tableName}"."${input.columnName}" defaults to ${JSON.stringify(columnDefault.value)}, which is not a member of enum ${input.pslTypeName}.`, + { + why: 'A default on a domain enum column is written as the member name, and no member carries this value.', + fix: 'Give the column a default that is one of the enum members, or drop it.', + meta: { + namespaceId: input.namespaceId, + table: input.tableName, + column: input.columnName, + pslTypeName: input.pslTypeName, + }, + }, + ); + } + return buildAttribute('field', 'default', [positionalArg(memberName)]); + } + if (columnDefault.kind === 'function') { const { expression } = columnDefault; const argument = NAMED_FUNCTION_DEFAULTS.has(expression) @@ -51,8 +77,8 @@ export function printColumnDefault(input: { const literal = Array.isArray(value) ? formatPslListLiteralValue(value, format) : format(value); if (literal === undefined) { throw postgresError( - 'CONTRACT.CONVERT_UNSUPPORTED', - `contract convert: column "${input.namespaceId}"."${input.tableName}"."${input.columnName}" has a literal default that cannot be written in Prisma 8 PSL: the ${input.pslTypeName} type reads no PSL literal back as ${JSON.stringify(value)}.`, + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: column "${input.namespaceId}"."${input.tableName}"."${input.columnName}" has a literal default that cannot be written in Prisma 8 PSL: the ${input.pslTypeName} type reads no PSL literal back as ${JSON.stringify(value)}.`, { why: 'Writing the value as a quoted string would parse, but the PSL source would read it back as a string rather than as the value the column defaults to.', fix: 'Replace the literal default with a database expression default, or drop the default before converting.', diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts index e0f7e16da5d2..e3dccf099789 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts @@ -30,7 +30,8 @@ function nativeTypeText(column: StorageColumn): string { /** * The PSL type position for a storage column, from the reverse of the type map * `contract infer` uses. An enum-typed column takes the `pg.enum()` - * constructor, named after the value set the column points at. + * constructor, named after the value set the column points at; a column typed + * by a domain enum takes that enum's name. */ export function printColumnType(input: { readonly column: StorageColumn; @@ -53,11 +54,16 @@ export function printColumnType(input: { }; } + const domainEnumName = column.valueSet?.entityName; + if (domainEnumName !== undefined) { + return { typeName: domainEnumName }; + } + const resolution = typeMap.resolve(nativeTypeText(column)); if ('unsupported' in resolution) { throw postgresError( - 'CONTRACT.CONVERT_UNSUPPORTED', - `contract convert: column ${coordinate} has native type "${column.nativeType}", which cannot be written in Prisma 8 PSL.`, + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: column ${coordinate} has native type "${column.nativeType}", which cannot be written in Prisma 8 PSL.`, { why: 'The Postgres contract-to-PSL printer maps each native type back to the PSL type that produces it; this one is not in that map.', fix: 'Retype the column, or author the Prisma 8 contract by hand.', diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts new file mode 100644 index 000000000000..c959c293b691 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts @@ -0,0 +1,165 @@ +import type { Contract, ContractField } from '@internal/contract/types'; +import type { PslTypeMap } from '@internal/family-sql/psl-infer'; +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 { blindCast } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; +import type { JsonValue } from '@internal/utils/json'; +import type { AnyPostgresCodecDescriptor } from '../codec-descriptor'; +import { codecDescriptorMap } from '../codec-type-map'; +import { postgresError } from '../errors'; +import { SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; +import { type PslColumnType, printColumnType } from './print-column-type'; + +const descriptorsByCodecId: ReadonlyMap = new Map( + Object.values(codecDescriptorMap).map((descriptor) => [descriptor.codecId, descriptor]), +); + +/** + * The storage column a domain field would occupy, so its PSL type prints + * through the same map as a real column. A value object's fields have no + * column of their own; their codec names the native type. + */ +function columnForDomainField(field: ContractField, coordinate: string): StorageColumn { + if (field.type.kind !== 'scalar') { + throw postgresError( + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: field ${coordinate} has a ${field.type.kind} 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, or author the Prisma 8 contract by hand.', + meta: { coordinate, kind: field.type.kind }, + }, + ); + } + const descriptor = descriptorsByCodecId.get(field.type.codecId); + if (descriptor === undefined) { + throw postgresError( + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: field ${coordinate} uses codec "${field.type.codecId}", which the Postgres printer cannot name a native type for.`, + { + why: 'A value-object field has no storage column, so its PSL type is derived from its codec; this codec is not one the Postgres target owns.', + fix: 'Author the Prisma 8 contract by hand for this value object.', + meta: { coordinate, codecId: field.type.codecId }, + }, + ); + } + return new StorageColumn({ + nativeType: descriptor.nativeTypeFor({ + codecId: field.type.codecId, + ...ifDefined( + 'typeParams', + blindCast(field.type.typeParams), + ), + }), + codecId: field.type.codecId, + nullable: field.nullable, + ...ifDefined('many', field.many), + ...ifDefined('typeParams', field.type.typeParams), + ...ifDefined('valueSet', field.valueSet), + }); +} + +function refuseDictField(field: ContractField, coordinate: string): void { + if (field.dict !== true) return; + throw postgresError( + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: field ${coordinate} is a dictionary, which cannot be written in Prisma 8 PSL.`, + { + why: 'PSL writes a field as one value or a list; it has no form for a keyed dictionary.', + fix: 'Author the Prisma 8 contract by hand for this field.', + meta: { coordinate }, + }, + ); +} + +/** The PSL type position of a domain field: a value object by name, or a scalar through the type map. */ +export function printDomainFieldType(input: { + readonly field: ContractField; + readonly coordinate: string; + readonly typeMap: PslTypeMap; + readonly enumBlockNames: ReadonlyMap; +}): PslColumnType { + const { field, coordinate } = input; + refuseDictField(field, coordinate); + if (field.type.kind === 'valueObject') { + return { typeName: field.type.name }; + } + return printColumnType({ + column: columnForDomainField(field, coordinate), + typeMap: input.typeMap, + 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 enumBlockNames: ReadonlyMap; +}): readonly PslCompositeType[] { + const valueObjects = input.contract.domain.namespaces[input.namespaceId]?.valueObjects ?? {}; + return Object.entries(valueObjects).map(([name, valueObject]) => ({ + kind: 'compositeType', + name, + fields: Object.entries(valueObject.fields).map(([fieldName, field]): PslField => { + const { typeName, typeConstructor } = printDomainFieldType({ + field, + coordinate: `"${input.namespaceId}".${name}.${fieldName}`, + typeMap: input.typeMap, + 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, +): PslTypesBlock | undefined { + const declarations: PslNamedTypeDeclaration[] = []; + for (const [name, instance] of Object.entries(contract.storage.types ?? {})) { + const { typeName, typeConstructor } = printColumnType({ + column: new StorageColumn({ + nativeType: instance.nativeType, + codecId: instance.codecId, + nullable: false, + ...ifDefined('typeParams', instance.typeParams), + }), + typeMap, + 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/print-enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts index 05741253c620..70c1e5c255f5 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts @@ -1,9 +1,51 @@ +import type { ContractEnum } from '@internal/contract/types'; import { toEnumName } from '@internal/family-sql/psl-infer'; -import type { PslExtensionBlock } from '@internal/framework-components/psl-ast'; +import type { + PslExtensionBlock, + PslExtensionBlockParamValue, +} from '@internal/framework-components/psl-ast'; import type { StorageColumn } from '@internal/sql-contract/types'; import type { PostgresNativeEnum } from '../postgres-native-enum'; import { buildNativeEnumBlock } from '../psl-infer/infer-enum-blocks'; import { createUniqueFieldName } from '../psl-infer/infer-names'; +import { escapePslString, SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; + +/** One `enum { … }` block per domain enum, each member written as `Name = ` under `@@type`. */ +export function buildDomainEnumBlocks( + enums: Readonly>, +): readonly PslExtensionBlock[] { + return Object.entries(enums).map(([name, domainEnum]): PslExtensionBlock => { + const parameters: Record = {}; + for (const member of domainEnum.members) { + parameters[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[]; diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts index 83671659be8a..89eacae0eebd 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts @@ -70,8 +70,8 @@ export function printExecutionDefault(input: { if (onCreate || onUpdate) { if ((onCreateId !== undefined && !onCreate) || (onUpdateId !== undefined && !onUpdate)) { throw postgresError( - 'CONTRACT.CONVERT_UNSUPPORTED', - `contract convert: column ${coordinate} takes the wall-clock-now generator in one phase and "${onCreate ? onUpdateId : onCreateId}" in the other, which cannot be written in Prisma 8 PSL.`, + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: column ${coordinate} takes the wall-clock-now generator in one phase and "${onCreate ? onUpdateId : onCreateId}" in the other, which cannot be written in Prisma 8 PSL.`, { why: '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.', fix: 'Author the Prisma 8 contract by hand for this column.', @@ -86,8 +86,8 @@ export function printExecutionDefault(input: { const call = onCreateId === undefined ? undefined : GENERATOR_CALLS.get(onCreateId); if (call === undefined || onUpdateId !== undefined) { throw postgresError( - 'CONTRACT.CONVERT_UNSUPPORTED', - `contract convert: column ${coordinate} carries generated value "${onCreateId ?? onUpdateId}", which cannot be written in Prisma 8 PSL.`, + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: column ${coordinate} carries generated value "${onCreateId ?? onUpdateId}", which cannot be written in Prisma 8 PSL.`, { why: 'The Postgres contract-to-PSL printer knows one way to write each generator; this generator is not among them.', fix: 'Author the Prisma 8 contract by hand for this column.', diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts index 16d5ee801af87408b66350e24504c46945d31513..264e087b9976f38ea11cdc64e20bbf12f8f013f4 100644 GIT binary patch literal 26615 zcmd5_TT@)ewLZ`K6>XI(Gx)&BemjLN$udF}Di)$7oK%WZnGLgn33K!886ghgzn|}0 zm%i-TBe0WH9X~|O?%P_c*S%MFMw9t$u?)-Gc^&S;-fX&DROid>_}Bh)HQ5gPpX>A0 zax|O1T`epA`?|iUR^w&34;PEsB=mk9O_%jzT8*Eb+Z4|@Wz+lq$*5T2s2Lxl;cYeV z7<*AoM&sL^=C|>)c{AP_O)u(2dxj?=V2;6sHrx8Qeb9bXH-zSw!w*OMdk1e0_Fw;c^x@}U4`03A|8VqbZ~xbW z*MA%k-DFJvX11tDm(#!0w_NPQZ{rWk*`m4>qS&8%v+-&&m1b=_uFl4tM8IwVedU|> zrr!nZqig~;oXBtUgAm<5bZ|jF;SRb#ThtApA={Ni6{0uw-c@~m{kPGgK3dclqtAqy zE!bpfAx~pt{#-RzY`T~&Ce@PN1)h#w<@(L}RXwS8MvG@t@Z|I|TR4m^!2P3XJ?wxT zP>!2tLv7Co&1cQ>5{SI7r^9+7fbD;t;{#c?i8Bob&#?0O^-i4{yQbM+EM|**RLu{W zxZQ=<`)^+T^7C=QJNXD8rkuSq8WsbO4AetN`lp%-ARp$4>`bM3r$&1QAt-#d8jXj7 zVIfTN`2g5|UjHl{fsJaQeV|FQ4lZ;c3|K)`!ZT3}_#1QWR4&m44(E%yTGoz9TjLay zMT96GJCkbOflUUG;Rp6DKGw~7HK&MwfDlhF$!VB)M5e9AuNLy*!`}~&|Ga;Eu=guu z`7p_G2@bFvkDyGeagGt@6MV_d)X+jP(G?=q@{iJws_%z^e&wMN@GVC&cjEkT{K1L>gc*f5xT zmH4e3NpSOF%4t-+pmfcV&9<_i{H>NiA$vWWfU`sub*?a!_8qR!BD*Z2!zl|gsSsWo zl8|TvR4et0nT()@HFesbj6~wI%{LS&M>}V4$luYWl>eElIj57YW4e5jZGlirf{vbP<2pQ`by#y9@0I`dc2 znIE?R?}d>OYnq2;TP&hVap}u0snG96x9o>xv|nsL+1dpqtKn=qzJ*@;0a}zQXrtHm zBG4sKG|r$AxQxO6sNsD;E`~JpbrPlW?1FQOb{a%z*q5 zdjxzo20kRlZYaGV;N`k>7pz6n3SpKgbntI_!tMoy=*WjL!vI^Z+G;)J8c zsF_sZe*)al!Kng5?%{toSGT*NhlVh$&&N;%5SR9PbBLB$jBqnrUWL;ie>4)ELimCR zZn&zNFrCGb(V`Cwaq!ycGgf*#`&6qGD=y2K{F3Rn1N@Gv_^S27PGih&>nmr4#7X%DV7J^S#p!*(dKcYIKJjY5wxIdbw>y-J$)F6m@8B<=`YAGc}r83IgNzV=`` zlenfdVC|_lGJuUH7ZkBUpKEQcF$O{rq7PQYHVNCqI`fM$WK?1|--Pu17s|>h4 zJRF0=JpF+#u;p4g*R3W+`FsW}Dd03v@PT-apW$?(|8gR+3O+`rj6=i`F+dU`HX!M# z7DfO0PjiD>J|i$#j;5=+gwg#_EP4uGPGOw_E{Qk~hWAeg@pIIv`Z3{f|MXO6-RuI> z74o%2n>flk3z^?+6&u8%B+I?8FG8Vf%q|5m&YdtNX&BurA3|SU|Jzk!4A?Rd9Za%l z5T*Oyi+wq%*uvLka38IqPb%BwhQ&W7{Ms#C8{*pjOfi!npD2KpMeGI=vdfpBZjUjL zf0iTfw=#%#72-W18T+pRtJEx~&^*Gw5hiY$Z*RGjTGbH7^)lNrRVaIsp8er}RSqSit$qfVd6+lNI5!4)iEr^q*C18zPmK`?TH813@Z#FGLY=t?P|2sR zHyj7yE2MZ~e3`r2Z=sRxTAa{yJ{ZG0C(53~#GTWrR&=0#@u&G zSJ3)BUl@+g#)Us7)I+EimyjnP*2-yRq?Sbl1rsA=WE7=*HUP4y&qqwGM|u-%q@=1z z^Kx*~i|xGvjQg#88YomI0$Mp+g24aooo@G`9yd5Ui!$EkOlKj$8oi}BSVke5Gu&r~yHBX^OO_(3qa&9y-v(G5MQFP*Z%xSn1ji9$@j3xE0(rK{|5@+~4)w013! z#d=P-V{nXE+7eX{0ZUya6Zhtq&uI64{4R21$Q;jI+dUHE%cBF_9_!%=QduA-&9a$j z=exh}W@|U*qz!yNI*Oy^Epf}lye*!-14;Nd3%;sk5m$?URAwdiLF)NQ(Co7u;c3E| z*|BD}TAbHNk}YtaCYiZR;A|~VLD{;!lev^NZ~BLk*wD( zlqSRv`8u2v&0@w5nKY)@c!YQgQ9`ylW|(z(J(wd+8s;5k?U?R7)-&FMi7lCozE0P- z_2jHxK&m`|%0ru&e(CE#7-6sr`fr_lEHl+eiCaLF=cA{D3@XzS>E2^4<(zrsbXBR5 zx?%*u&m)soaNzim7+A5zjW09yQFX1(%|x*OE0g*fC24kFCxPfiM$MTvAX{w507gA= zgZvu@7f$v~g$<|k;pYhkT$z7Ru(Ie08QkG$h>RJdCC@Q;pEyUWDik6BE(p3*{)Ot* zRmPxG*XBggdF~^z)qhcp1~A_J2&FhP=m)}Pk&da_K{Xs|W}M0dNM@(@={wa7kN?_5 z0R4oPZLA=~9Jf+P*LIv$Uv<_DWZ0w~UJQ83A z9(FpN42T`ymNPD!?+pH-4#NlJQk!K+hC&U=$SsYUJq2vwwsE;ADS&9cr zB`T1V6*>|1t$dmHPz{4jLY6?5!!XNSi&Mt9+kkmF$W|0 z&Q^H-!mK_hu&0?0j++`3vFGw#F27nPqP~sbsnU(u_H%!ZhO0 z4C8RroWt{HZ4VMLMY_$ESt6pwlLCpAE=6MYdomx-riv-o4=6USuGvnUU8p4$8%X7s zY2AMhfRtClWr(z@t6akmqF6_>-0mq^)3R7AjuwAx3)#^)x`h~4SEg8tyuf^}t!M~x zFYxy!%t^bo`91b~5x(2B$4T|#fCRFIL!`zSgobQ3ccCL^UM#$LH6)=8@bT+&)( zD=f1~Un!Sbrw7hgh3^o^(v%n~$RA`Ee!$a#jaBz9l4@81Vwaon|!5=0&mt6=a`~ zQ^lf-+3NDjG9J~EbH(+UO9c6cz;4zGQ4qDv#-Hk=(G)e>@4d2-9;J`YjktH%XG%{a zjo9s{TVaa_G99fM9+TzZM(oX2xGrEZKDIud4r{*T#ahZk1dJOM;d9Lk76-xU z^_^_uUOeA1c7|rI7r8&|yqy}hQdOP0&uNl-J26aJl|3jtD9fwsPysZ7gbDsQrUc53IgG+hwUD!(5)sg{ zwdR)yq&VjjpH=7A8)tTX$uK1$#Rb}Hp5`-q;?ZD+KS1*nSHf?hw>XYUHi#TAE5u~d z#+=$_1{2dBcTTr$EpZb!2}cK5tz9*2qs)POsb<%Hd9K_!djRdj+I1AdYhu55xcfF8 z{Jz{!eymGuCjl`5P6x2NOl|-^D^K1yKzEU7YyVQsyWGLI6xvFz%3=RYeQSeMWotGd z%LwQgkJG8uod4|zh=(amzu-g=ni+pVYlBBUl1G*0d|p?J?99cp!kp~Xx&n^oROZQ| ze)VIRYli6})A(8qZJ8`cL*ck^KIs0Xri4(VnSlX)Q_r=|bUsE59ng(Xm%hi_Rb;=h?3vHK2&Tt>@G zP)~4z8hd^OSr*HFzry*9;L-Q_8Zmm6o4g&^Lt9B(&>j2sEww>jH!ObldjsT+++gUe z8eS6<>ZX{)F^BqbMRF0DEIs59nEVuz$NqoJ5h~_V&oaM&)a_P@$0fj&rFW_|)0iSn zl@SCHF7Epcld6FxVc+!OQB`&#W*CYLl# zpBfjM z3{#5RY=sa7=_#mV-XwF$OLx7fns|lmwO&+1!pcP~;8K}h8OzA`2X2$$JqNu{7S-3| zYsO+7P9`oi7p|nJTJ1y#^>A$mJ@vZxKkW}wYI-0{)g#2uZuI$Ku%*m+f6wWHf+TYv zP>!TbA?C3{odVLL%}ip$o0+8yr7VGge`Q5W(=Z%e;QbDuqJMektF-NF<_=bSK#)-oIWZ%Qyl>xV$Wux|xKVIbEBxLDh8XjL0=E8ZB3* zd{5Y%caqwuG)C#+I8k+`HjnqRlP2)zrfaKY&4;h9vezk9C!a1*o3fiU4cCT_fswO& zq_}cDMRT5bj_^^f#UITe*JrMsj&csSTdh{R1K(f?7MBF^qq*Jhy%LMNOuY5}>+wbz zD0yA7Ez0nUx+iU-$f3-lUf$G5%EMupn5Y=AS{THuxhNCTvY#ebZ?51_?FDLzG6e6^ z@lgK7)YNZ0zJ$Jm3A-#u`(;?^eTXS`g~f6n3Q@fvrRAE&_?3q6-YQS17+#4EK4q!s zX^<-t#r9gNXgQnXo&l2mQi3!_Z5HxLn)|{z8Xt+^TyeLBV7%lmcv+9s42`8%px|}j zLKKVvt_xSm7+|if98$vXi)geniwFS{c+iz%* zy7H;3V9{3}C@Lvm-wqXpD$qW5DGE9Frmth-{HS2W?ONm>?))x6A?YZ3p*P(5P7Itd zS-_P6J0hJ==+d2}FDQk_sDw3zAS=>L(EW%%c&Et~gB~3bE$fiO_Z@OPy&g&}drj{? zc!x|uxPCB|u=%3jshkE;a%YaNiWL$8UPP_M)7jJtZT=6YxDdoj!}|S)XVFSq(@4Rh4~zPxq)v9 zCFzvwS4l+Ih2*4*n?JIaHJd*nFaW}efJb*HUQux47UhvQu@cFm zewJZ}V*FAoSf0D=19WP|+?ZDWfgxPyt7snfVN-Z#6=t#kLj6w~Q!a=eNLI$@*iP_X zjG2n2_whvgemv<}S6*JqnKL&#yf-B0yEu8Tn=V6Rlf=-LtljaZjfCZLKfSH}v8y0RAfHzt6jUwuMf1s!kE$}^OvWHtl@?uB$MiO+R=(qapc91Yx zjHgMM1so{EXuMT&J)PYQp1POk-sSI*LfkM^u)%*xm}8Wji@~-a_JB!9K`h*R7ETC7 zb5DP0Z$eVBIHld43O5xl)<8X|Pfpj6uH_k=ZPdSuZh`u$Aby&NP^~1HJeI{+@C`uW zX4zpD2nC0gvv|mi79A~hgjU(_-zX@kR>6%cadx$%z6S+5@sZ8Zl#>yyDE8)ms1}V6 z`!HT+*Y^yeC9KdJL$bvYV@){X;w~nPq`8#@t$d3eN8)9JK%VTAdT<;APj7i&0MQ0! zfOCGi!V{6svn&s~rBAO0RBR2w)>t0V7GD*ORnfv;zA12<_sqwB*x5$s?5$4;zW_KC zkL(bScIEWYCw$Aw>*OFvbfOS$&L>DGos@4;;Rr`w&BN5XMt){xDTr){-_RYpRY1hhFtYPlo#(#;d3)lbr5$^5+KY;}H(yWHsv9T^na!J>@Fb0^w6%bUn9 zWK~^0n7I-NDxU)6{L+m9s*+SsZe-EUIR~7iNdhXw<@_Waj{5IJ&-(fuOK^=RHzV8OFX*|g+hoP3~~h^n*L&>o9Gve`7~ll zAQY!N?RRanvmLv-90x{kOkJSe2Oha)vW5SVfy6IrFGUVULmxGJcxeQBF2_9=9KzVMSOoEkASc`7ET_i8x3(?V zu3Zn~tF0#6u-)lY#u9^=9{xlZDiIzBflesjfwe~q&8Ks=&_l%aweeaObpjTN4kkA< zLqX~~xSF}Cw}z{hOZopQTl+o9T**Q>_@AXej9w{?>EOrPzy`Q#Wg~;ni?;}ILFB}y zRQ^n^kN484f(AImF%Luohp$`I4Z=wT}Ds*mwt?06!PoJWC*Hg zBvG{sgZ!R$>hX+ny%AfFd(KhKMSiAwHGo{Pax#NVqmC{8i9!{lS0)}d*5-pBOzu-W zHR0zf0pC;&t>q4d-!057vLU%OjvOH-SWS)mnVFelLxi!`k4RJ}taBa9{Nox2!*)DL zL(WH5WyyCb7Zd5|d?l|H%ht+YT4W{5wYqlPtimuAn01bb7cnD~1bZ5mfGe(9#aq1M zs_X0M@?HaXnfDrS@|?}-4x{)uK8*L4L@Z?;m--ey8l>f=O(s>X`X-kh=Mt@DK;NX{ zP}wHFLGeV}zy7jVW_EoAcQ~brs)=fkJY3+)4@MeCpP!;PmWqoCCsB4Yb3`)+bLW$v zV1?vPPv_L;H|A~1B)R90YpR)-p7&LUviP!AG?QB0<$wWE;Zt$xEL~_{j3GfXV=l}? z`Ricxq!C+#oS+=%e?Ok0vZOllq-e(0flpf&$79ZZmSTW6nh~%lWZP83M$q}6NHX?h0tE+AhDMpvsi~YXF)5v;aMvYGtDdfl(OUh%dHgT`S2bB@ zF^)2QWOa|Z;TssxO6f5XZ({rAM`D&p>*=xCy~!3L5k)i;3Pyk2~@t1>lv#Wr?vyP zNX+=CSRA;<#<3;Q_Yoms9Hof!ftc3+M}WMupK;Ld2oiyn>LYG+3bOe__BjUo`eq)y zX%T2U&{FX=(e<>Pp{*G^o=x7@8|tK@6T=KRdk8P zyF7NerEYmkF_ADoL;GSOHy@_4DDS5no(EZmz{xZcIT@v7o(x&I-3~2K8Ajc$!tIm=By{;LTo#Rlk9AIY%*>qxDMWte`6Qv9g$FW6CeZPpYeG zo{F-RbV)SmlKH6+T}a3A%+RK^7dRMYwQ!kH`i&QKcwwrB+Z@iOVmutfj@gFfNF{2W zWe7NrW5V(lLNSXw#X*xlBF1FmPr(gp@Mm%5a0&EB3*n)yg+x7yNC83QWy%>)zzVqr zl3G4&_E_rwM@wO8x@6F*=F^Q+As?5jRGzTGEMp`p3lWhIHD#_=9+cf>@U46l=WXRU z)MG)V-G*O76UZVCd-mA6_=?!Lr93A z#nabn%qiEEMvAq%!hF}B6ha-%eK(c{uA_hOD_#%fDG!HUGFTnqFxXv!o1=NC8*SXUKgyuj z%bS{rNask8D$SmXx5tj*{n#nh)lf8vQ0lc75Hh~67dE|Xw(Z#M=|e08*f2=X+ATK?|!FRqdp&hf)4Dexob3v#Xci0 Z23K_6ar_9jL747^*~(@wG#eHSe*xOhIe7p8 diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts index 271434e40db4..be00fd564084 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts @@ -147,6 +147,8 @@ export function printRelationField(input: { readonly entry: RelationEntry; readonly target: ModelEntry; 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; @@ -158,12 +160,15 @@ export function printRelationField(input: { args.push(namedArg('name', `"${escapePslString(name)}"`)); } - if (relation.cardinality === 'N:1') { + 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) { throw postgresError( - 'CONTRACT.CONVERT_UNSUPPORTED', - `contract convert: relation "${entry.owner.name}.${entry.fieldName}" has no foreign key in storage, which Prisma 8 PSL cannot express.`, + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: relation "${entry.owner.name}.${entry.fieldName}" has no foreign key in storage, which Prisma 8 PSL cannot express.`, { why: 'A to-one relation is authored as `@relation(fields:…, references:…)`, which always lowers to a foreign key.', fix: 'Author the Prisma 8 contract by hand for this relation.', @@ -173,10 +178,12 @@ export function printRelationField(input: { } args.push(namedArg('fields', `[${relation.on.localFields.join(', ')}]`)); args.push(namedArg('references', `[${relation.on.targetFields.join(', ')}]`)); - args.push( - namedArg('onDelete', PSL_REFERENTIAL_ACTIONS[foreignKey.onDelete ?? 'noAction']), - namedArg('onUpdate', PSL_REFERENTIAL_ACTIONS[foreignKey.onUpdate ?? 'noAction']), - ); + 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)}"`)); } diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts index ac49155d9fe9..c97e504ead63 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -294,7 +294,7 @@ describe('column defaults', () => { thrown = error; } expect(thrown).toMatchObject({ - code: 'CONTRACT.CONVERT_UNSUPPORTED', + code: 'CONTRACT.PRINT_UNSUPPORTED', message: expect.stringContaining('"public"."Widget"."value"'), }); }); @@ -396,7 +396,7 @@ describe('generated values', () => { onUpdate: { kind: 'generator', id: 'uuidv4' }, }), ).toMatchObject({ - code: 'CONTRACT.CONVERT_UNSUPPORTED', + code: 'CONTRACT.PRINT_UNSUPPORTED', message: expect.stringContaining('"public"."Widget"."value"'), }); expect( @@ -405,7 +405,7 @@ describe('generated values', () => { onUpdate: { kind: 'generator', id: 'plainDateTimeNow' }, }), ).toMatchObject({ - code: 'CONTRACT.CONVERT_UNSUPPORTED', + code: 'CONTRACT.PRINT_UNSUPPORTED', message: expect.stringContaining('"public"."Widget"."value"'), }); }); @@ -463,10 +463,10 @@ describe('relations', () => { ); }); - it('writes the foreign key name when the key carries one', () => { + 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], onDelete: NoAction, onUpdate: NoAction, map: "post_author_fkey", index: false)', + 'author User @relation(fields: [authorId], references: [id], map: "post_author_fkey", index: false)', ); }); @@ -493,6 +493,12 @@ describe('relations', () => { nullable: false, on: { localFields: ['authorId'], targetFields: ['altId'] }, }, + authorById: { + to: { namespace: asNamespaceId('public'), model: 'User' }, + cardinality: 'N:1', + nullable: false, + on: { localFields: ['authorId'], targetFields: ['id'] }, + }, }, }, }, @@ -525,9 +531,10 @@ describe('relations', () => { }, }); - expect(models[1]?.fields.map(fieldText)[2]).toBe( - 'author User @relation(fields: [authorId], references: [altId], onDelete: Restrict, onUpdate: Restrict, map: "post_author_altId_fkey", index: false)', - ); + 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/package.json b/packages/3-targets/6-adapters/postgres/package.json index a67681e94109..58f4b04411d9 100644 --- a/packages/3-targets/6-adapters/postgres/package.json +++ b/packages/3-targets/6-adapters/postgres/package.json @@ -37,6 +37,7 @@ "@internal/cli": "workspace:8.0.0-rc.11", "@internal/driver-postgres": "workspace:8.0.0-rc.11", "@internal/migration-tools": "workspace:8.0.0-rc.11", + "@internal/psl-printer": "workspace:8.0.0-rc.11", "@repo/test-utils": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", diff --git a/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts b/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts new file mode 100644 index 000000000000..ab2ccd435d01 --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts @@ -0,0 +1,195 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +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 { createControlStack } 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 { dirname, join } from 'pathe'; +import { describe, expect, it } from 'vitest'; +import postgresAdapter from '../src/exports/control'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../../..'); + +/** + * Contracts emitted from TypeScript sources, one per feature a Prisma 7 schema + * cannot carry. Each is printed as PSL, read back through the PSL source, 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, owner', + contractJson: + 'test/integration/test/sql-orm-client/fixtures/polymorphism/generated/contract.json', + }, + { + name: 'named types and typeRef columns', + contractJson: + 'test/integration/test/ports/prisma/functional/decimal-list/_fixture/generated/contract.json', + }, +]; + +function sourceContext(resolvedInputs: readonly string[]): ContractSourceContext { + const stack = createControlStack({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresDriver, + extensions: [], + }); + return { + composedExtensions: stack.extensions.map((extension) => extension.id), + composedExtensionContracts: stack.extensionContracts, + authoringContributions: stack.authoringContributions, + codecLookup: stack.codecLookup, + controlMutationDefaults: stack.controlMutationDefaults, + resolvedInputs, + capabilities: stack.capabilities, + }; +} + +function loadContract(relativePath: string): Contract { + const json: unknown = JSON.parse(readFileSync(join(repoRoot, relativePath), 'utf-8')); + return new PostgresContractSerializer().deserializeContract(json); +} + +function printAsPsl(contract: Contract): string { + const ast = postgres.printPslContract?.(contract); + if (ast === undefined) throw new Error('the Postgres target has no printPslContract hook'); + const context = sourceContext([]); + return printPsl(ast, { + pslBlockDescriptors: context.authoringContributions.pslBlockDescriptors, + codecLookup: context.codecLookup, + }); +} + +async function readBack(text: string): Promise> { + const printedPath = join(mkdtempSync(join(tmpdir(), 'psl-print-')), 'contract.prisma'); + writeFileSync(printedPath, text); + const result = await prismaContract(printedPath, { + target: postgresPackRef, + createNamespace: postgresCreateNamespace, + enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, + }).source.load(sourceContext([printedPath])); + if (!result.ok) { + throw new Error( + `the printed PSL did not load: ${JSON.stringify(result.failure.diagnostics)}\n${text}`, + ); + } + return blindCast, 'the Postgres PSL source yields a SQL contract'>( + result.value, + ); +} + +/** The serialized contract without `capabilities`, which the composed stack reports rather than the source. */ +function serialize(contract: Contract): unknown { + const { capabilities: _, ...authored } = JSON.parse( + JSON.stringify(new PostgresContractSerializer().serializeContract(contract)), + ); + return authored; +} + +/** + * Contracts authored in PSL, one per feature the emitted TypeScript 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) +} +`, + }, +]; + +describe('a printed PSL contract reads back as the same contract', () => { + it.each(pslCases)('$name', async ({ schema }) => { + const authored = await readBack(`// use prisma-8\n${schema}`); + const printed = await readBack(printAsPsl(authored)); + + expect(serialize(printed)).toEqual(serialize(authored)); + expect(printed.storage.storageHash).toBe(authored.storage.storageHash); + }); +}); + +describe('a contract the language cannot carry is refused by name', () => { + it('refuses the entities an extension contributes, naming their kind', () => { + const authored = loadContract('packages/3-extensions/supabase/src/contract/contract.json'); + expect(() => printAsPsl(authored)).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + message: expect.stringMatching(/declares \d+ "(role|rls)" entit/), + }), + ); + }); + + 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(() => printAsPsl(authored)).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + message: expect.stringContaining('"Article.reviewer"'), + }), + ); + }); +}); + +describe('a printed TypeScript contract reads back as the same contract', () => { + it.each(cases)('$name', async ({ contractJson }) => { + const authored = loadContract(contractJson); + const printed = await readBack(printAsPsl(authored)); + + expect(serialize(printed)).toEqual(serialize(authored)); + expect(printed.storage.storageHash).toBe(authored.storage.storageHash); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97bffcd082d9..19173efeb6df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4162,6 +4162,9 @@ importers: '@internal/migration-tools': specifier: workspace:8.0.0-rc.11 version: link:../../../1-framework/3-tooling/migration + '@internal/psl-printer': + specifier: workspace:8.0.0-rc.11 + version: link:../../../1-framework/2-authoring/psl-printer '@repo/test-utils': specifier: workspace:8.0.0-rc.11 version: link:../../../../test/utils 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 index 7d2d91eba513..0a0888ed317c 100644 --- 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 @@ -1,24 +1,24 @@ -# Slice 3: contract-to-PSL printer and `prisma contract convert` +# 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._ ## At a glance ```bash -prisma contract convert --output src/prisma/contract.prisma +prisma contract print --output src/prisma/contract.prisma ``` Output begins: ```prisma // use prisma-8 -// Converted from prisma/schema.prisma by `prisma contract convert`. +// Converted from prisma/schema.prisma by `prisma contract print`. ``` ## 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 a `headerComment` option the command passes; its default leaves `contract infer` output unchanged. -- **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 2 and write nothing. +- **Command** `contract print` in `packages/1-framework/3-tooling/cli/src/orm/contract/print.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 2 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 @@ -35,5 +35,5 @@ 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`. +- [ ] `packages/1-framework/3-tooling/cli/README.md` documents `contract print`. - [ ] `--json` output carries the written path. diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index 0bc7ae4274c5..38c34106c0f1 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. @@ -85,7 +85,7 @@ Inherits `drive/calibration/dod.md`. Project-specific: - For every fixture, `hash(interpret(prisma7)) === hash(interpret(convert(prisma7)))`. - 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 @@ -114,17 +114,21 @@ Recorded so they are not lost; each becomes its own project when scheduled. - 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 slice 2: the Mongo PSL interpreter silently ignores unknown top-level blocks (`view` included); slice 2 adds the diagnostic. -### What `contract convert` cannot write yet +### What `contract print` cannot write -The converter prints the loaded contract as a Prisma 8 schema and the round-trip test reads it back through the Prisma 8 PSL source. These are the cases where the Prisma 8 schema language or its reader cannot carry what the Prisma 7 contract holds. Each is a Prisma 8 feature to build, not a converter defect. A converted file must never read back as a different contract, so the converter refuses every one of them with `CONTRACT.CONVERT_UNSUPPORTED` and writes no file. +`contract print` loads the contract the config names, from any source, and writes it as a Prisma 8 schema that reads back as the same contract. The proof is two round-trip tests: one over every Prisma 7 fixture (`contract-prisma7/test/convert-roundtrip.test.ts`) and one over contracts emitted from TypeScript and PSL sources that carry what a Prisma 7 schema cannot (`adapter-postgres/test/psl-print-roundtrip.test.ts`): value objects, polymorphism, named types, domain enums, control policies, and every index argument. The printer writes all of those. Where the PSL language has no form for something the contract holds, the printer refuses it by name with `CONTRACT.PRINT_UNSUPPORTED` and writes no file; it never drops anything silently. -- A `Json` or `Jsonb` column whose default is an object or array literal. The PSL reader treats `@default("...")` on a Json field as a string, so there is no way to write the parsed value. -- One model name declared in two namespaces. The PSL reader keys relation targets, junction detection and id columns by model name alone (`contract-psl/src/interpreter.ts`, `fkRelationsByDeclaringModel`, `modelIdColumns`), so the two models read back as one. -- A generator the Postgres printer has no PSL form for (none of the Prisma 7 generators, which all print). +The refusals, and what would lift each: -Three list-column cases were refused in the first version and now convert: 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 `Json` or `Jsonb` column whose default is an object or array literal. The PSL reader treats `@default("...")` on a Json field as a string. Lifted by slice B of [`projects/remove-dbgenerated`](../remove-dbgenerated/spec.md) (codec-owned PSL literals). Slice C of that project switches `print-column-default.ts` from `dbgenerated("...")` to the `sql` tagged literal along with every other printer. +- One model name declared in two namespaces. The PSL reader keys relation targets, junction detection and id columns by model name alone (`contract-psl/src/interpreter.ts`, `fkRelationsByDeclaringModel`, `modelIdColumns`), so the two models read back as one. Lifted by re-keying those on (namespace, model). +- A domain enum outside the default namespace. The PSL reader refuses an `enum` block inside a `namespace` block. Lifted by a reader change. +- 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 field whose type is a union of types, a dictionary field, a column with its own control policy, and a model with an owner. None has PSL syntax. +- An entity kind an extension contributes (row-level security policies, roles). Its PSL syntax belongs to the extension. Lifted by a per-kind print hook on the extension pack, the mirror of the block descriptors extensions already contribute for reading. +- A generator the Postgres printer has no PSL form for (none of the Prisma 7 generators, which all print). -The Json literal default is covered by slice B of [`projects/remove-dbgenerated`](../remove-dbgenerated/spec.md) (codec-owned PSL literals). Slice C of that project switches `print-column-default.ts` from `dbgenerated("...")` to the `sql` tagged literal along with every other printer. +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. ### Found outside this project's scope diff --git a/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md b/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md index 5782c64e9bb2..b38901ac265e 100644 --- a/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md +++ b/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md @@ -43,7 +43,7 @@ File: [`contract-prisma7/src/defaults.ts`](../../../../packages/2-sql/2-authorin Files: [`9-family/src/core/psl-contract-infer/default-mapping.ts`](../../../../packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts), [`postgres-default-mapping.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts), [`infer-model-blocks.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts), [`infer-psl-contract.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts). - `mapDefault`'s function arm: named functions print as today; anything else prints `@default(sql\`\`)`, or `@default(sql"")` with PSL string escaping when the expression contains a backtick. This is the family's built-in fallback; the `fallbackFunctionAttribute` option and `DefaultMappingResult`'s `comment` member are deleted. `postgres-default-mapping.ts` keeps only the named-function table (`gen_random_uuid()`) and its `formatDbGeneratedAttribute` is deleted. -- `prisma contract convert` has its own default printer, [`psl-print/print-column-default.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts), which writes `dbgenerated("...")` for every function default other than `now()` and `autoincrement()`. It prints the same `sql` form as `mapDefault`, and the round-trip test in `contract-prisma7/test/convert-roundtrip.test.ts` proves it. +- `prisma contract print` has its own default printer, [`psl-print/print-column-default.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts), which writes `dbgenerated("...")` for every function default other than `now()` and `autoincrement()`. It prints the same `sql` form as `mapDefault`, and the round-trip test in `contract-prisma7/test/convert-roundtrip.test.ts` proves it. - Classification before printing: infer already resolves a column's raw default with the same Postgres parser verify uses (`parsePostgresDefault`, reached through `resolvedDefault`). Two classifications it does not make today are added so the Supabase contract prints literals where it can: - A default written as `''::` where `` is the column's own type (including `jsonb`, `json`, `text`, and array types) is a literal whose value is `` read through the column's codec. The four Supabase JSON defaults print as `@default("{}")` and `@default("[]")`. - A default written as `''::.` or `''::` where the enum is a native enum in the introspected schema and the column is typed by it is the enum member ``; infer prints `@default()`. The six Supabase enum casts print this way. The test at `infer-psl-contract.enum-adoption.test.ts` that pins a schema-qualified cast as preserved raw is rewritten to expect the member. diff --git a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts similarity index 80% rename from test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts rename to test/integration/test/cli-journeys/prisma7-print.e2e.test.ts index 7562cc3a3322..3a455b78b213 100644 --- a/test/integration/test/cli-journeys/prisma7-convert.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts @@ -1,14 +1,15 @@ /** - * The user-facing journey for `prisma contract convert`: a project whose + * The user-facing journey for `prisma contract print`: a project whose * `prisma.config.ts` points at `prisma7Schema('./schema.prisma')` runs - * `contract convert`, switches its config to the Prisma 8 PSL the command + * `contract print`, switches its config to the Prisma 8 PSL the command * wrote, and emits the same contract — which `db verify` then reports zero * findings against the database the Prisma 7 SQL built. It runs over the * `relations` fixture, whose database is the SQL Prisma 7.10.0 generated for - * the full `supported` schema. Four things are refused with exit 2 and no file - * written: a config on any other contract source, a Prisma 7 schema Prisma 8 - * cannot read, an output path that is the schema being read, and a schema - * holding a column that cannot be written in Prisma 8 PSL. + * the full `supported` schema. The command is not tied to Prisma 7: a PSL source prints + * as the same schema. Three things are refused with exit 2 and no file + * written: a Prisma 7 schema Prisma 8 cannot read, an output path that is the + * schema being read, and a schema holding a column that cannot be written in + * Prisma 8 PSL. */ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { withClient } from '@repo/test-utils'; @@ -19,8 +20,8 @@ import { withTempDir, writeProjectManifest } from '../utils/cli-test-helpers'; import { type EngineCommandResult, type JourneyContext, - runContractConvert, runContractEmit, + runContractPrint, runDbSign, runDbVerify, timeouts, @@ -77,11 +78,11 @@ function setupPrisma7Project( }; } -/** The same project, read through the Prisma 8 PSL `contract convert` wrote. */ +/** The same project, read through the Prisma 8 PSL `contract print` wrote. */ function onConvertedContract(ctx: JourneyContext, connectionString: string): JourneyContext { return { ...ctx, - configPath: writeConfig(ctx.testDir, 'prisma.config.prisma7-converted.ts', connectionString), + configPath: writeConfig(ctx.testDir, 'prisma.config.prisma7-printed.ts', connectionString), }; } @@ -120,17 +121,17 @@ async function convertAndVerify(ctx: JourneyContext, connectionString: string): 0, ); - const convert = await runContractConvert(ctx, ['--json']); - expect(convert.exitCode, `contract convert\n${output(convert)}`).toBe(0); + const convert = await runContractPrint(ctx, ['--json']); + expect(convert.exitCode, `contract print\n${output(convert)}`).toBe(0); expect(convert.presented?.data).toMatchObject({ ok: true, psl: { path: 'contract.prisma' }, - source: 'schema.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// Converted from schema.prisma by `prisma contract convert`.', + '// use prisma-8\n// Printed from schema.prisma by `prisma contract print`.', ); const converted = onConvertedContract(ctx, connectionString); @@ -170,25 +171,26 @@ withTempDir(({ createTempDir }) => { ); }); - describe('Journey: contract convert refuses what it cannot convert', () => { - it('refuses a contract source that is not a Prisma 7 schema and writes nothing', async () => { + describe('Journey: contract print refuses what it cannot convert', () => { + it('prints a PSL source as the same schema, 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'), 'model User {\n id Int @id\n}\n'); const onPsl = onConvertedContract(ctx, NO_DATABASE); - const convert = await runContractConvert(onPsl, ['--output', 'converted.prisma', '--json']); + const print = await runContractPrint(onPsl, ['--output', 'printed.prisma', '--json']); - expect(convert.exitCode, output(convert)).toBe(2); - expect(errorOf(convert).code).toBe('CONTRACT.CONVERT_SOURCE_NOT_PRISMA7'); - expect(existsSync(join(ctx.testDir, 'converted.prisma'))).toBe(false); + 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 convert = await runContractConvert(ctx, ['--json']); + const convert = await runContractPrint(ctx, ['--json']); expect(convert.exitCode, output(convert)).toBe(2); expect(errorOf(convert).code).toBe('CONTRACT.SOURCE_LOAD_FAILED'); @@ -202,10 +204,10 @@ withTempDir(({ createTempDir }) => { const schemaPath = join(ctx.testDir, 'schema.prisma'); const before = readFileSync(schemaPath, 'utf-8'); - const convert = await runContractConvert(ctx, ['--output', 'schema.prisma', '--json']); + const convert = await runContractPrint(ctx, ['--output', 'schema.prisma', '--json']); expect(convert.exitCode, output(convert)).toBe(2); - expect(errorOf(convert).code).toBe('CONTRACT.CONVERT_OUTPUT_IS_SOURCE'); + expect(errorOf(convert).code).toBe('CONTRACT.PRINT_OUTPUT_IS_SOURCE'); expect(readFileSync(schemaPath, 'utf-8')).toBe(before); expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); }); @@ -215,11 +217,11 @@ withTempDir(({ createTempDir }) => { copyFrom: join(PRISMA7_FIXTURES, 'supported-verify/schema.prisma'), }); - const convert = await runContractConvert(ctx, ['--json']); + const convert = await runContractPrint(ctx, ['--json']); expect(convert.exitCode, output(convert)).toBe(2); const { code, summary } = errorOf(convert); - expect(code).toBe('CONTRACT.CONVERT_UNSUPPORTED'); + expect(code).toBe('CONTRACT.PRINT_UNSUPPORTED'); expect(summary).toContain('"Defaults"."jsonLiteral"'); expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); }); diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-converted.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-printed.ts similarity index 82% rename from test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-converted.ts rename to test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-printed.ts index 5aa313ee8555..64e5c1b85e92 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-converted.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-printed.ts @@ -1,4 +1,4 @@ -// The config a project switches to after `prisma contract convert`: the same +// 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 { defineConfig } from '@prisma/cli-engine'; diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index 76dd56bb796b..7b86180dccfe 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -292,12 +292,12 @@ export async function runContractInfer( return runOnEngine(ctx, ['contract', 'infer', ...extraArgs], options); } -export async function runContractConvert( +export async function runContractPrint( ctx: JourneyContext, extraArgs: readonly string[] = [], options?: RunCommandOptions, ): Promise { - return runOnEngine(ctx, ['contract', 'convert', ...extraArgs], options); + return runOnEngine(ctx, ['contract', 'print', ...extraArgs], options); } export async function runDbInit( From ad1e8192f1238ed3f123ca281ae780411caf8af8 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 13:05:25 +0200 Subject: [PATCH 37/55] feat(sql): contract print writes defaults through the data types of ADR 254, and a Json literal default now prints The merge with main removed the literal formatters the printer used; defaults now go through the same mapDefault the infer printer uses, keyed by the column data type. A Json object literal prints as a json tagged literal, so the last Prisma 7 refusal on a column is gone: the defaults fixture round-trips, and the supported-verify journey converts and verifies in full. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-framework/3-tooling/cli/README.md | 2 +- .../test/convert-roundtrip.test.ts | 6 -- .../test/interpreter.list-type-params.test.ts | 4 +- .../core/psl-print/print-column-default.ts | 66 +++++++----------- .../src/core/psl-print/print-enum-blocks.ts | 3 +- .../src/core/psl-print/print-psl-contract.ts | Bin 26615 -> 26665 bytes .../core/psl-print/print-relation-fields.ts | 8 +-- .../test/psl-print/print-psl-contract.test.ts | 6 +- .../postgres/test/psl-print-roundtrip.test.ts | 5 +- projects/prisma7-contract-source/spec.md | 3 +- .../cli-journeys/prisma7-print.e2e.test.ts | 29 ++------ 11 files changed, 47 insertions(+), 85 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index a60e4623e63d..14ad25306d4b 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -373,7 +373,7 @@ The written file opens with the two lines that say where it came from: ``` The written file must read back as the identical contract. The printer writes models, value objects (`type` blocks), named types (`types` block), native enums, keys, indexes with every argument, checks, relations, polymorphism (`@@discriminator`, `@@base`), control policies and execution defaults. Where the PSL language has no form for something the contract holds, the command refuses and writes nothing. Three things are refused with exit `2`: -- A shape the language cannot carry exits with `CONTRACT.PRINT_UNSUPPORTED`, naming the model, column, field or entity it stopped on: a foreign key no relation travels, a to-one relation with no foreign key, a union or dictionary field, a column with its own control policy, a model with an owner, one model name declared in two namespaces, a `Json` object or array literal default, or an entity an extension contributes (row-level security policies, roles). +- A shape the language cannot carry exits with `CONTRACT.PRINT_UNSUPPORTED`, naming the model, column, field or entity it stopped on: a foreign key no relation travels, a to-one relation with no foreign key, a union or dictionary field, a column with its own control policy, a model with an owner, one model name declared in two namespaces, or an entity an extension contributes (row-level security policies, roles). - A source Prisma 8 cannot read exits with `CONTRACT.SOURCE_LOAD_FAILED`, reporting exactly what `contract emit` reports for the same source. - An `--output` path that is a source file the config reads, or sits inside a directory of source files, exits with `CONTRACT.PRINT_OUTPUT_IS_SOURCE`. Pick another path. diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts index 905847bfdd65..8abc6a8edb44 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts @@ -20,12 +20,6 @@ const cases = readdirSync(fixturesDir, { withFileTypes: true }) /** Why each fixture does not round-trip yet, naming every cause it has. */ const expectedFailures: ReadonlyMap = new Map([ - [ - 'defaults', - [ - 'a Json object literal default is refused, because PSL reads a quoted default back as a string', - ], - ], [ 'junction-name-in-other-schema', ['one model name in two namespaces cannot be written in a Prisma 8 schema'], 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 index 50aab3d6a928..a452ee5a2e53 100644 --- 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 @@ -2,6 +2,7 @@ import type { AuthoringTypeNamespace } from '@internal/framework-components/auth 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, @@ -33,7 +34,8 @@ describe('interpretPslDocumentToSqlContract list fields with type parameters', ( ...document, target: postgresTarget, scalarColumnDescriptors: postgresNativeScalarTypeDescriptors, - authoringContributions: { type: authoringTypes }, + authoringContributions: { type: authoringTypes, dataTypes: fixtureDataTypeSupport.entries }, + dataTypeLookup: fixtureDataTypeSupport.lookup, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, capabilities: { sql: { scalarList: true } }, diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts index bb5f5bd3b50f..f7f7d3d16086 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts @@ -1,30 +1,29 @@ import type { ContractEnum } from '@internal/contract/types'; +import { mapDefault } from '@internal/family-sql/psl-infer'; import type { PslFieldAttribute } from '@internal/framework-components/psl-ast'; import type { StorageColumn } from '@internal/sql-contract/types'; import { postgresError } from '../errors'; +import { dataTypeForPrintedType } from '../psl-infer/infer-default-codec'; +import { createPostgresDefaultMapping } from '../psl-infer/postgres-default-mapping'; import { buildAttribute, - escapePslString, - formatPslListLiteralValue, - formatPslValue, + parseDefaultAttributeString, positionalArg, - pslDefaultValueFormat, } from '../psl-infer/psl-literals'; -/** The two function defaults Prisma 8 PSL writes by name; every other one is `dbgenerated`. */ -const NAMED_FUNCTION_DEFAULTS = new Set(['now()', 'autoincrement()']); +const defaultMapping = createPostgresDefaultMapping(); /** * The `@default(…)` attribute for a storage column, or `undefined` when the * column carries no default. * - * A literal prints as the PSL literal its codec reads back, with a number - * printing unquoted from its decimal text; `now()` and `autoincrement()` print - * by name; every other function default prints as `dbgenerated("…")`. This is - * the one place that decides when to write `dbgenerated`. + * A literal prints as the PSL literal the column's data type reads back, the + * same mapping `contract infer` uses; 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 `dbgenerated("…")`. * - * A literal the column's codec cannot write as a PSL literal is refused, - * because every literal that would parse reads back as a different value. + * A literal no data type of the column writes is refused, because every + * literal that would parse reads back as a different value. */ export function printColumnDefault(input: { readonly column: StorageColumn; @@ -40,6 +39,7 @@ export function printColumnDefault(input: { 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( @@ -48,48 +48,32 @@ export function printColumnDefault(input: { if (memberName === undefined) { throw postgresError( 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: column "${input.namespaceId}"."${input.tableName}"."${input.columnName}" defaults to ${JSON.stringify(columnDefault.value)}, which is not a member of enum ${input.pslTypeName}.`, + `contract print: column ${coordinate} defaults to ${JSON.stringify(columnDefault.value)}, which is not a member of enum ${input.pslTypeName}.`, { why: 'A default on a domain enum column is written as the member name, and no member carries this value.', fix: 'Give the column a default that is one of the enum members, or drop it.', - meta: { - namespaceId: input.namespaceId, - table: input.tableName, - column: input.columnName, - pslTypeName: input.pslTypeName, - }, + meta: { coordinate, pslTypeName: input.pslTypeName }, }, ); } return buildAttribute('field', 'default', [positionalArg(memberName)]); } - if (columnDefault.kind === 'function') { - const { expression } = columnDefault; - const argument = NAMED_FUNCTION_DEFAULTS.has(expression) - ? expression - : `dbgenerated("${escapePslString(expression)}")`; - return buildAttribute('field', 'default', [positionalArg(argument)]); - } - - const format = input.isEnum ? formatPslValue : pslDefaultValueFormat(input.pslTypeName); - const { value } = columnDefault; - const literal = Array.isArray(value) ? formatPslListLiteralValue(value, format) : format(value); - if (literal === undefined) { + const result = mapDefault(columnDefault, { + ...defaultMapping, + columnDataType: dataTypeForPrintedType(input.pslTypeName, input.isEnum), + list: input.column.many === true, + }); + if ('comment' in result) { throw postgresError( 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: column "${input.namespaceId}"."${input.tableName}"."${input.columnName}" has a literal default that cannot be written in Prisma 8 PSL: the ${input.pslTypeName} type reads no PSL literal back as ${JSON.stringify(value)}.`, + `contract print: column ${coordinate} has a default that cannot be written in Prisma 8 PSL: no data type the ${input.pslTypeName} type takes writes ${columnDefault.kind === 'literal' ? JSON.stringify(columnDefault.value) : columnDefault.expression}.`, { - why: 'Writing the value as a quoted string would parse, but the PSL source would read it back as a string rather than as the value the column defaults to.', - fix: 'Replace the literal default with a database expression default, or drop the default before converting.', - meta: { - namespaceId: input.namespaceId, - table: input.tableName, - column: input.columnName, - pslTypeName: input.pslTypeName, - }, + why: 'Writing the value in any other form would parse, but the PSL source would read it back as a different value.', + fix: 'Replace the default with a database expression default, or drop the default before printing.', + meta: { coordinate, pslTypeName: input.pslTypeName }, }, ); } - return buildAttribute('field', 'default', [positionalArg(literal)]); + return parseDefaultAttributeString(result.attribute); } diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts index 70c1e5c255f5..3ad3d30bebc8 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts @@ -5,10 +5,11 @@ import type { 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-infer/infer-enum-blocks'; import { createUniqueFieldName } from '../psl-infer/infer-names'; -import { escapePslString, SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; +import { SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; /** One `enum { … }` block per domain enum, each member written as `Name = ` under `@@type`. */ export function buildDomainEnumBlocks( diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts index 264e087b9976f38ea11cdc64e20bbf12f8f013f4..cd08b99c2e9560d3bc0b3925dc4150175f9508e6 100644 GIT binary patch delta 60 zcmexlM@S41B!EkONuh{(kC}Ci3=B{<|LM6=I15m=qBeErRpaZ Qmuz0gG>d6-H0zus0P0N_y8r+H delta 41 xcmZ2^f${r!#tl1}7`Y~YRFc>%&RoXCpIV%pSdbb}oD*D9l$n>lxs!E45&&9>5F`Kq diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts index be00fd564084..5c483b868ed1 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts @@ -1,14 +1,10 @@ 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 { ifDefined } from '@internal/utils/defined'; import { postgresError } from '../errors'; -import { - buildAttribute, - escapePslString, - namedArg, - SYNTHETIC_SPAN, -} from '../psl-infer/psl-literals'; +import { buildAttribute, namedArg, SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; import { crossReferenceCoordinate, type ModelEntry, modelCoordinate } from './contract-model-index'; const PSL_REFERENTIAL_ACTIONS: Readonly> = { diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts index c97e504ead63..545dbabf7a51 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -247,7 +247,7 @@ describe('column defaults', () => { nullable: false, default: { kind: 'literal', value: '1.50' }, }), - ).toBe('@default("1.50")'); + ).toBe('@default(1.50)'); }); it('prints an integer default unquoted', () => { @@ -285,8 +285,8 @@ describe('column defaults', () => { let thrown: unknown; try { defaultOf({ - nativeType: 'jsonb', - codecId: 'pg/jsonb@1', + nativeType: 'inet', + codecId: 'pg/inet@1', nullable: false, default: { kind: 'literal', value: { a: 1 } }, }); diff --git a/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts b/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts index ff8c473d0363..27d70b146093 100644 --- a/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts @@ -1,7 +1,6 @@ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; -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'; @@ -43,7 +42,9 @@ const cases: ReadonlyArray<{ readonly name: string; readonly contractJson: strin }, ]; -function sourceContext(resolvedInputs: readonly string[]): ContractSourceContext { +type SourceContext = Parameters['source']['load']>[0]; + +function sourceContext(resolvedInputs: readonly string[]): SourceContext { const stack = createControlStack({ family: sql, target: postgres, diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index 38c34106c0f1..f561d967627e 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -120,7 +120,6 @@ Recorded so they are not lost; each becomes its own project when scheduled. The refusals, and what would lift each: -- A `Json` or `Jsonb` column whose default is an object or array literal. The PSL reader treats `@default("...")` on a Json field as a string. Lifted by slice B of [`projects/remove-dbgenerated`](../remove-dbgenerated/spec.md) (codec-owned PSL literals). Slice C of that project switches `print-column-default.ts` from `dbgenerated("...")` to the `sql` tagged literal along with every other printer. - One model name declared in two namespaces. The PSL reader keys relation targets, junction detection and id columns by model name alone (`contract-psl/src/interpreter.ts`, `fkRelationsByDeclaringModel`, `modelIdColumns`), so the two models read back as one. Lifted by re-keying those on (namespace, model). - A domain enum outside the default namespace. The PSL reader refuses an `enum` block inside a `namespace` block. Lifted by a reader change. - 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. @@ -128,7 +127,7 @@ The refusals, and what would lift each: - An entity kind an extension contributes (row-level security policies, roles). Its PSL syntax belongs to the extension. Lifted by a per-kind print hook on the extension pack, the mirror of the block descriptors extensions already contribute for reading. - A generator the Postgres printer has no PSL form for (none of the Prisma 7 generators, which all print). -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 `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. Slice C of [`projects/remove-dbgenerated`](../remove-dbgenerated/spec.md) switches `print-column-default.ts` from `dbgenerated("...")` to the `sql` tagged literal along with every other printer. 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. ### Found outside this project's scope diff --git a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts index 3a455b78b213..0f7de0f243db 100644 --- a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts @@ -6,10 +6,9 @@ * findings against the database the Prisma 7 SQL built. It runs over the * `relations` fixture, 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 - * as the same schema. Three things are refused with exit 2 and no file - * written: a Prisma 7 schema Prisma 8 cannot read, an output path that is the - * schema being read, and a schema holding a column that cannot be written in - * Prisma 8 PSL. + * as the same schema. 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. */ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { withClient } from '@repo/test-utils'; @@ -157,12 +156,12 @@ withTempDir(({ createTempDir }) => { onReady: (cs) => withClient(cs, (client) => client.query(PRISMA7_DDL)), }); - it( - 'converts relations across two schemas and verifies against the database Prisma 7 built', - async () => { + it.each(['relations', 'supported-verify'])( + 'prints the %s fixture and verifies against the database Prisma 7 built', + async (fixture) => { await convertAndVerify( setupPrisma7Project(createTempDir, db.connectionString, { - copyFrom: join(PRISMA7_FIXTURES, 'relations/schema.prisma'), + copyFrom: join(PRISMA7_FIXTURES, `${fixture}/schema.prisma`), }), db.connectionString, ); @@ -211,19 +210,5 @@ withTempDir(({ createTempDir }) => { expect(readFileSync(schemaPath, 'utf-8')).toBe(before); expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); }); - - it('refuses the first column it cannot write and writes nothing', async () => { - const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { - copyFrom: join(PRISMA7_FIXTURES, 'supported-verify/schema.prisma'), - }); - - const convert = await runContractPrint(ctx, ['--json']); - - expect(convert.exitCode, output(convert)).toBe(2); - const { code, summary } = errorOf(convert); - expect(code).toBe('CONTRACT.PRINT_UNSUPPORTED'); - expect(summary).toContain('"Defaults"."jsonLiteral"'); - expect(existsSync(join(ctx.testDir, 'contract.prisma'))).toBe(false); - }); }); }); From 49d9b1c21ebcadeb2bae55b78dc321a96816714d Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 15:07:24 +0200 Subject: [PATCH 38/55] docs(cli): contract print writes a PSL contract, not a schema Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-framework/3-tooling/cli/README.md | 2 +- .../1-framework/3-tooling/cli/src/orm/contract/print.ts | 6 +++--- projects/prisma7-contract-source/spec.md | 2 +- .../integration/test/cli-journeys/prisma7-print.e2e.test.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 14ad25306d4b..25f650ad2864 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -347,7 +347,7 @@ prisma db schema -v ### `prisma contract print` -Load the contract from the source the config names and write the Prisma 8 PSL that reads back as the same contract. The source can be a Prisma 7 schema (`prisma7Schema(...)`), a TypeScript contract, or a PSL schema. 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. +Load the contract from the source the config names and write the Prisma 8 PSL that reads back as the same contract. 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 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 index 6d838f60a83e..2ca1f08994bb 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts @@ -77,7 +77,7 @@ function printHeaderComment(sourcePaths: readonly string[]): string { /** * The contract source input the output path would be written over: the one it - * names, or the directory of schema files it sits inside. `undefined` when the + * names, or the directory of source files it sits inside. `undefined` when the * output path touches no input. */ function sourceInputCovering(inputs: { @@ -100,10 +100,10 @@ export function createContractPrintCommand({ }: ContractPrintCommandDeps) { return defineOrmCommand({ help: { - summary: 'Write the configured contract as a Prisma 8 PSL schema file', + summary: 'Write the configured contract as Prisma 8 PSL', description: 'Loads the contract from the source the config names (a Prisma 7\n' + - 'schema, a TypeScript contract, or a PSL schema) and writes the Prisma 8\n' + + 'schema, a TypeScript contract, or a PSL contract) and writes the Prisma 8\n' + 'PSL that reads back as the same contract. Anything the PSL language\n' + 'cannot carry is refused by name and nothing is written. The command\n' + 'stops at contract.prisma; switch the config to the written file, then\n' + diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index f561d967627e..05219417eb43 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -116,7 +116,7 @@ Recorded so they are not lost; each becomes its own project when scheduled. ### What `contract print` cannot write -`contract print` loads the contract the config names, from any source, and writes it as a Prisma 8 schema that reads back as the same contract. The proof is two round-trip tests: one over every Prisma 7 fixture (`contract-prisma7/test/convert-roundtrip.test.ts`) and one over contracts emitted from TypeScript and PSL sources that carry what a Prisma 7 schema cannot (`adapter-postgres/test/psl-print-roundtrip.test.ts`): value objects, polymorphism, named types, domain enums, control policies, and every index argument. The printer writes all of those. Where the PSL language has no form for something the contract holds, the printer refuses it by name with `CONTRACT.PRINT_UNSUPPORTED` and writes no file; it never drops anything silently. +`contract print` loads the contract the config names, from any source, and writes it as Prisma 8 PSL that reads back as the same contract. The proof is two round-trip tests: one over every Prisma 7 fixture (`contract-prisma7/test/convert-roundtrip.test.ts`) and one over contracts emitted from TypeScript and PSL sources that carry what a Prisma 7 schema cannot (`adapter-postgres/test/psl-print-roundtrip.test.ts`): value objects, polymorphism, named types, domain enums, control policies, and every index argument. The printer writes all of those. Where the PSL language has no form for something the contract holds, the printer refuses it by name with `CONTRACT.PRINT_UNSUPPORTED` and writes no file; it never drops anything silently. The refusals, and what would lift each: diff --git a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts index 0f7de0f243db..1fafa394e872 100644 --- a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts @@ -171,7 +171,7 @@ withTempDir(({ createTempDir }) => { }); describe('Journey: contract print refuses what it cannot convert', () => { - it('prints a PSL source as the same schema, so the command is not tied to Prisma 7', async () => { + it('prints a PSL source as the same contract, 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'), }); From e1c929d4d349818d6983e8dd0057f14e1714a5d8 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 15:36:41 +0200 Subject: [PATCH 39/55] refactor: the framework no longer knows Prisma 7 The config package had learned a prisma7 source format and a Prisma7ContractSourceProvider so the old convert command could refuse other sources. The framework defines what a contract source is; the package that owns a language defines that language's source. Config now has one open ContractSourceProvider with an opaque format tag, and psl-parser exports its own PslContractSourceProvider. The parser keeps its prisma7 grammar, which now covers only what differs: a view body read as model members. An attribute on an enum member is read by every grammar, and the SQL PSL interpreter reports it as PSL_ENUM_MEMBER_ATTRIBUTE_UNSUPPORTED, because validity is the interpreter's decision, not the parser's. The contract print help and error text no longer name Prisma 7, the CLI unit tests use a neutral fixture format, and the no-family-vocabulary lint flags the product name in the framework, with the parser exempt because it owns the dialect. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- biome-plugins/no-family-vocabulary.grit | 6 +++ .../config/src/contract-source-types.ts | 41 +++------------- .../1-core/config/src/exports/config-types.ts | 6 --- .../1-core/config/test/config-types.test-d.ts | 46 +++-------------- .../psl-parser/src/exports/interpret.ts | 6 ++- .../2-authoring/psl-parser/src/interpret.ts | 6 ++- .../2-authoring/psl-parser/src/parse.ts | 18 +++---- .../psl-parser/src/syntax/ast/declarations.ts | 2 +- .../test/format/prisma7-constructs.test.ts | 9 ++-- .../psl-parser/test/interpret.test-d.ts | 2 +- .../psl-parser/test/parse-prisma7.test.ts | 49 +++++-------------- .../3-tooling/cli/src/orm/contract/print.ts | 16 +++--- .../test/control-api/contract-emit.test.ts | 16 +++--- .../cli/test/orm/contract-print.test.ts | 8 +-- .../cli/test/orm/normalize-error.test.ts | 2 +- .../contract-psl/src/interpreter.ts | 11 +++++ .../test/interpreter.enum.test.ts | 27 ++++++++++ .../provider.enum-member-attributes.test.ts | 11 +++-- 18 files changed, 123 insertions(+), 159 deletions(-) diff --git a/biome-plugins/no-family-vocabulary.grit b/biome-plugins/no-family-vocabulary.grit index 6c7146eeba24..203ec0bee686 100644 --- a/biome-plugins/no-family-vocabulary.grit +++ b/biome-plugins/no-family-vocabulary.grit @@ -101,6 +101,12 @@ or { not $filename <: r".*\.(?:test|test-d)\.ts", not $filename <: r".*/test/.*\.ts", or { + // A product name the framework must not know. The PSL parser is exempt: it + // owns the PSL language, and Prisma 7 PSL is a dialect it reads by name. + and { + not $filename <: r".*/psl-parser/.*", + $tok <: r"(?s)(?:|.*[^A-Za-z0-9]|.*[a-z0-9])[pP](?i:risma)[^A-Za-z0-9]*7(?:|[^A-Za-z0-9].*|[A-Z].*)" + }, $tok <: r"(?s)(?:|.*[^A-Za-z0-9])(?:p(?i:ostgre)s|p(?i:ostgresq)l|s(?i:qlit)e|m(?i:ysq)l|m(?i:ariad)b|m(?i:ssq)l|s(?i:qlserve)r|c(?i:ockroac)h|p(?i:glit)e|m(?i:ong)o|m(?i:ongod)b|c(?i:ollectio)n|c(?i:olum)n|t(?i:abl)e|c(?i:onstrain)t|s(?i:q)l|v(?i:archa)r|j(?i:son)b|d(?i:ialec)t|r(?i:l)s|n(?i:ativ)(?:[eE][^A-Za-z0-9]+[tT]|eT)(?i:yp)e|p(?i:rimar)(?:[yY][^A-Za-z0-9]+[kK]|yK)(?i:e)y|f(?i:oreig)(?:[nN][^A-Za-z0-9]+[kK]|nK)(?i:e)y)[sS]?(?:|[^A-Za-z0-9].*|[A-Z].*)", $tok <: r"(?s)(?:|.*[^A-Za-z0-9]|.*[a-z0-9])(?:P(?i:ostgre)s|P(?i:ostgresq)l|S(?i:qlit)e|M(?i:ysq)l|M(?i:ariad)b|M(?i:ssq)l|S(?i:qlserve)r|C(?i:ockroac)h|P(?i:glit)e|M(?i:ong)o|M(?i:ongod)b|C(?i:ollectio)n|C(?i:olum)n|T(?i:abl)e|C(?i:onstrain)t|S(?i:q)l|V(?i:archa)r|J(?i:son)b|D(?i:ialec)t|R(?i:l)s|N(?i:ativ)(?:[eE][^A-Za-z0-9]+[tT]|eT)(?i:yp)e|P(?i:rimar)(?:[yY][^A-Za-z0-9]+[kK]|yK)(?i:e)y|F(?i:oreig)(?:[nN][^A-Za-z0-9]+[kK]|nK)(?i:e)y)[sS]?(?:|[^A-Za-z0-9].*|[A-Z].*)", $tok <: r"(?s)(?:|.*[^A-Za-z0-9]|.*[a-z0-9])(?:P(?i:ostgre)S|P(?i:ostgresq)L|S(?i:qlit)E|M(?i:ysq)L|M(?i:ariad)B|M(?i:ssq)L|S(?i:qlserve)R|C(?i:ockroac)H|P(?i:glit)E|M(?i:ong)O|M(?i:ongod)B|C(?i:ollectio)N|C(?i:olum)N|T(?i:abl)E|C(?i:onstrain)T|S(?i:q)L|V(?i:archa)R|J(?i:son)B|D(?i:ialec)T|R(?i:l)S|N(?i:ativ)(?:[eE][^A-Za-z0-9]+[tT]|eT)(?i:yp)E|P(?i:rimar)(?:[yY][^A-Za-z0-9]+[kK]|yK)(?i:e)Y|F(?i:oreig)(?:[nN][^A-Za-z0-9]+[kK]|nK)(?i:e)Y)[sS]?(?:|[^A-Za-z0-9].*)" 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 b92deb801d47..cd72f643f0c9 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 @@ -50,41 +50,16 @@ export interface ContractSourceContext { readonly capabilities: CapabilityMatrix; } -/** Lets format-aware tooling avoid file-extension sniffing and opaque loader introspection. */ -export type ContractSourceFormat = 'psl' | 'typescript' | 'prisma7'; - -export interface ContractSourceProviderBase { +/** + * A contract source: the inputs it reads and the `load` that turns them into a + * contract. The framework never enumerates sources. `format` is a tag the + * owning package gives its source; tooling that acts on one kind of source + * checks for the capability that package exports, never for the tag alone. + */ +export interface ContractSourceProvider { readonly inputs?: readonly string[]; + readonly format?: string; readonly load: ( context: ContractSourceContext, ) => Promise>; } - -export interface PslContractSourceProvider extends ContractSourceProviderBase { - readonly format: 'psl'; -} - -export interface TypeScriptContractSourceProvider extends ContractSourceProviderBase { - readonly format: 'typescript'; -} - -/** A Prisma 7 `schema.prisma` read as the contract source; `prisma contract print` writes its Prisma 8 equivalent. */ -export interface Prisma7ContractSourceProvider extends ContractSourceProviderBase { - readonly format: 'prisma7'; -} - -/** - * 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 - | Prisma7ContractSourceProvider - | OpaqueContractSourceProvider; 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 35896cb8bbcc..ea8efcae0720 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 @@ -10,11 +10,5 @@ export type { ContractSourceDiagnosticPosition, ContractSourceDiagnosticSpan, ContractSourceDiagnostics, - ContractSourceFormat, ContractSourceProvider, - ContractSourceProviderBase, - OpaqueContractSourceProvider, - Prisma7ContractSourceProvider, - PslContractSourceProvider, - TypeScriptContractSourceProvider, } from '../contract-source-types'; 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 15c2472127bf..493fd8ec4508 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 @@ -12,12 +12,7 @@ import { expectTypeOf, test } from 'vitest'; import { defineConfig, type FormatterConfig, type PrismaNextConfig } from '../src/config-types'; import type { ContractSourceDiagnostic, - ContractSourceFormat, ContractSourceProvider, - OpaqueContractSourceProvider, - Prisma7ContractSourceProvider, - PslContractSourceProvider, - TypeScriptContractSourceProvider, } from '../src/contract-source-types'; const mockHook = { @@ -147,40 +142,13 @@ test('source diagnostics require a filename but not a span', () => { void missingFilename; }); -test('contract source providers form a format-keyed union', () => { - expectTypeOf().toEqualTypeOf< - | PslContractSourceProvider - | TypeScriptContractSourceProvider - | Prisma7ContractSourceProvider - | OpaqueContractSourceProvider - >(); - expectTypeOf().toEqualTypeOf<'psl'>(); - expectTypeOf().toEqualTypeOf<'typescript'>(); - expectTypeOf().toEqualTypeOf<'prisma7'>(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toExtend(); - 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 prisma7: ContractSourceProvider = { format: 'prisma7', inputs: ['./schema.prisma'], load }; - const absent: ContractSourceProvider = { load }; - const thirdParty: ContractSourceProvider = { format: 'made-up-format', load }; - - expectTypeOf(psl).toExtend(); - expectTypeOf(typescript).toExtend(); - expectTypeOf(prisma7).toExtend(); - expectTypeOf(absent).toExtend(); - expectTypeOf(thirdParty).toExtend(); +test('a contract source is one open interface: the framework enumerates no formats', () => { + expectTypeOf().toEqualTypeOf(); + const load: ContractSourceProvider['load'] = async () => ok({} as never); + const tagged: ContractSourceProvider = { format: 'anything', inputs: ['./x'], load }; + const untagged: ContractSourceProvider = { inputs: ['./x'], load }; + expectTypeOf(tagged).toExtend(); + expectTypeOf(untagged).toExtend(); }); test('carries an optional formatter section', () => { 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 f73ddf1568eb..6bd130f1a150 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,8 @@ -export type { PslInterpretCapable, PslInterpretInput } from '../interpret'; +export type { + PslContractSourceProvider, + PslInterpretCapable, + PslInterpretInput, +} from '../interpret'; export { hasPslInterpreter, withSeedDiagnostics } from '../interpret'; export type { InvalidFkPairing } from '../relation-backrelations'; export { diff --git a/packages/1-framework/2-authoring/psl-parser/src/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/interpret.ts index 7c18255a5041..9a07c9c8962f 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/interpret.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/interpret.ts @@ -3,7 +3,6 @@ import type { ContractSourceDiagnostic, ContractSourceDiagnostics, ContractSourceProvider, - PslContractSourceProvider, } from '@internal/config/config-types'; import type { Contract } from '@internal/contract/types'; import { notOk, type Result } from '@internal/utils/result'; @@ -28,6 +27,11 @@ export interface PslInterpretInput { * types. `interpret` must not read disk or `context.resolvedInputs` — those * are load-path concerns. */ +/** A contract source whose inputs are PSL files, which this package reads. */ +export interface PslContractSourceProvider extends ContractSourceProvider { + readonly format: 'psl'; +} + export interface PslInterpretCapable extends PslContractSourceProvider { interpret( input: PslInterpretInput, 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..529e29927469 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/parse.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/parse.ts @@ -21,8 +21,8 @@ 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. + * `prisma7` also reads field lines in a `view` body. Defaults to `psl`, which reads a `view` + * block as plain `key = value` entries. */ readonly grammar?: PslGrammar; } @@ -744,13 +744,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. + * An `enum` member may carry `@` attributes in every grammar (`USER @map("user")`); whether one + * is allowed is the interpreter's decision. With the `prisma7` grammar, a `view` body is read like + * a model body. Every other generic block reads plain `key = value` entries. */ function genericBlockMemberParser(keyword: string, grammar: PslGrammar): MemberParser { + if (keyword === 'enum') return parseEnumMember; if (grammar === 'prisma7' && keyword === 'view') return parseModelMember; - if (grammar === 'prisma7' && keyword === 'enum') return parseEnumMember; return parseKeyValueMember; } @@ -819,9 +819,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..82c6cf5fa8b8 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 @@ -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/test/format/prisma7-constructs.test.ts b/packages/1-framework/2-authoring/psl-parser/test/format/prisma7-constructs.test.ts index f75ebb09f6b8..de43a7a28039 100644 --- 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 @@ -13,12 +13,9 @@ describe('format given a Prisma 7 construct in a Prisma 8 document', () => { ); }); - 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', - }), + it('keeps an attribute on an enum member, leaving its validity to the interpreter', () => { + expect(format('enum Role {\n USER @map("user")\n ADMIN\n}\n')).toContain( + 'USER @map("user")', ); }); }); diff --git a/packages/1-framework/2-authoring/psl-parser/test/interpret.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/interpret.test-d.ts index 597a06cd5c1b..f9e727758e13 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/interpret.test-d.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/interpret.test-d.ts @@ -2,13 +2,13 @@ import type { ContractSourceContext, ContractSourceDiagnostics, ContractSourceProvider, - PslContractSourceProvider, } from '@internal/config/config-types'; import type { Contract } from '@internal/contract/types'; import { ok, type Result } from '@internal/utils/result'; import { expectTypeOf, test } from 'vitest'; import { hasPslInterpreter, + type PslContractSourceProvider, type PslInterpretCapable, type PslInterpretInput, } from '../src/interpret'; 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 index 5ebccb2413bb..04e1a6d6c18d 100644 --- 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 @@ -1,7 +1,8 @@ /** - * 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. + * The `prisma7` grammar reads one Prisma 7 construct so the Prisma 7 + * interpreter can walk it with spans: field lines inside `view` blocks. The + * default grammar reads a `view` block as plain entries. Attributes on an enum + * member are read by every grammar. */ import { describe, expect, it } from 'vitest'; import { parse } from '../src/parse'; @@ -121,43 +122,19 @@ describe('enum member attributes', () => { }); describe('given the default grammar', () => { - it('reports an attribute after an enum member as an invalid block entry at the attribute', () => { + it('reads an attribute after an enum member, leaving its validity to the interpreter', () => { 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 } }, - }, - ]); + expect(result.diagnostics).toEqual([]); + expect(printTree(result.document.syntax.green)).toBe( + printTree( + parse('enum Role {\n USER @map("user")\n}', 'test.psl', prisma7).document.syntax.green, + ), + ); }); - it('reports an attribute after an enum member value as an invalid block entry', () => { + it('reads an attribute after an enum member value', () => { 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 } }, - }, - ]); + expect(result.diagnostics).toEqual([]); }); }); }); 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 index 2ca1f08994bb..8659535b265c 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts @@ -102,13 +102,13 @@ export function createContractPrintCommand({ help: { summary: 'Write the configured contract as Prisma 8 PSL', description: - 'Loads the contract from the source the config names (a Prisma 7\n' + - 'schema, a TypeScript contract, or a PSL contract) and writes the Prisma 8\n' + - 'PSL that reads back as the same contract. Anything the PSL language\n' + - 'cannot carry is refused by name and nothing is written. The command\n' + - 'stops at contract.prisma; switch the config to the written file, then\n' + - 'run `contract emit`. An existing file at the output path is\n' + - 'overwritten, with a warning.', + 'Loads the contract from contract.source in your config, whatever kind\n' + + 'of source that is, and writes it as a Prisma 8 PSL file. Emitting that\n' + + 'file produces the same contract: same hashes, same domain. If the\n' + + 'contract holds something PSL cannot express, the command refuses,\n' + + 'names it, and writes nothing. The command stops at contract.prisma;\n' + + 'switch the config to the written file, then run `contract emit`. An\n' + + 'existing file at the output path is overwritten, with a warning.', examples: [ 'contract print', 'contract print --output ./src/prisma/contract.prisma', @@ -131,7 +131,7 @@ export function createContractPrintCommand({ return notOk( normalizeError( errorContractConfigMissing({ - why: 'Config.contract.source is required for contract print. Define it in your config, for example contract: prisma7Schema("./schema.prisma") or contract: prismaContract("./contract.prisma")', + why: 'Config.contract.source is required for contract print. Define contract in your config so the command has a source to print.', }), ), ); 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 e72591464536..180ded153c03 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 @@ -224,7 +224,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: { @@ -232,7 +232,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' }, ]; @@ -240,7 +240,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' }, }, @@ -256,7 +256,7 @@ 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', + why: 'Source interpretation failed', fix: 'Edit the schema where each finding points, then run contract emit again.', }); }); @@ -264,7 +264,7 @@ describe('executeContractEmit', () => { 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.', @@ -272,7 +272,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: [], @@ -294,11 +294,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/orm/contract-print.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-print.test.ts index 17ede4072105..4a0e21c99bd5 100644 --- 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 @@ -80,7 +80,7 @@ function ormConfig(dir: string, overrides: Record = {}): Record adapter: { ...DESCRIPTOR, kind: 'adapter', id: 'pg' }, driver: { ...DESCRIPTOR, kind: 'driver', id: 'pg-driver' }, contract: { - source: { format: 'prisma7', inputs: ['./prisma/schema.prisma'], load: mocks.load }, + source: { format: 'fixture', inputs: ['./prisma/schema.prisma'], load: mocks.load }, output: join(dir, 'generated', 'contract.json'), }, ...overrides, @@ -255,7 +255,7 @@ describe('contract print', () => { const dir = await projectDir(); const config = ormConfig(dir, { contract: { - source: { format: 'prisma7', inputs: ['./prisma'], load: mocks.load }, + source: { format: 'fixture', inputs: ['./prisma'], load: mocks.load }, output: join(dir, 'generated', 'contract.json'), }, }); @@ -270,7 +270,7 @@ describe('contract print', () => { expect(await readdir(dir)).not.toContain('prisma'); }); - it('prints a PSL source too: the command is not tied to Prisma 7', async () => { + it('prints a PSL source the same way as any other source', async () => { const dir = await projectDir(); const config = ormConfig(dir, { contract: { @@ -299,7 +299,7 @@ describe('contract print', () => { ok: false, failure: { summary: 'Prisma 8 does not support views', - diagnostics: [{ code: 'PSL.PRISMA7_VIEW_UNSUPPORTED', message: 'a view is not a model' }], + diagnostics: [{ code: 'PSL.FIXTURE_VIEW_UNSUPPORTED', message: 'a view is not a model' }], }, }); 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/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index 0822dbb10985..a4fe93d660d9 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -580,6 +580,17 @@ function processEnumDeclarations(input: ProcessEnumDeclarationsInput): { for (const symbol of input.enumBlocks) { const decl = symbol.block; + for (const entry of symbol.node.entries()) { + for (const attribute of entry.attributes()) { + input.diagnostics.push({ + code: 'PSL_ENUM_MEMBER_ATTRIBUTE_UNSUPPORTED', + message: `enum "${decl.name}": member "${entry.key()?.name() ?? '?'}" carries @${attribute.name()?.path().join('.') ?? '?'}, but an enum member takes no attributes`, + ...diagnosticSource(input.source.sources, symbol.node.syntax).at( + nodePslSpan(attribute.syntax, input.source.sources), + ), + }); + } + } const handle = instantiateAuthoringEntityType( 'enum', enumDescriptor, 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..19ad52293f61 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_ENUM_MEMBER_ATTRIBUTE_UNSUPPORTED', + 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/provider.enum-member-attributes.test.ts b/packages/2-sql/2-authoring/contract-psl/test/provider.enum-member-attributes.test.ts index 1f3f416a466e..667716ba1f5e 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'); @@ -79,15 +79,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_ENUM_MEMBER_ATTRIBUTE_UNSUPPORTED', + message: + 'enum "Role": member "USER" carries @map, but an enum member takes no attributes', sourceId: './schema.prisma', span: { start: { offset: 42, line: 3, column: 9 }, - end: { offset: 43, line: 3, column: 10 }, + end: { offset: 54, line: 3, column: 21 }, }, }, ], From 366c6033801036130bbc59300b50c311dc23b45a Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 15:52:27 +0200 Subject: [PATCH 40/55] fix(mongo): the Mongo PSL reader reports an attribute on an enum member The parser now reads it in every grammar, so the reader must say it is not allowed, as the SQL reader does. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../2-authoring/contract-psl/src/interpreter.ts | 11 +++++++++++ .../2-authoring/contract-psl/test/provider.test.ts | 11 ++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) 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 20b0b2d0bfd1..ff94cadd2108 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 @@ -1062,6 +1062,17 @@ function processEnumDeclarations(input: { for (const enumSymbol of input.enumSymbols) { const sourceFile = input.sources.sourceFileFor(enumSymbol.node.syntax); const decl = enumSymbol.block; + for (const entry of enumSymbol.node.entries()) { + for (const attribute of entry.attributes()) { + input.diagnostics.push({ + code: 'PSL_ENUM_MEMBER_ATTRIBUTE_UNSUPPORTED', + message: `enum "${decl.name}": member "${entry.key()?.name() ?? '?'}" carries @${attribute.name()?.path().join('.') ?? '?'}, but an enum member takes no attributes`, + ...diagnosticSource(input.sources, enumSymbol.node.syntax).at( + nodePslSpan(attribute.syntax, input.sources), + ), + }); + } + } const handle = instantiateAuthoringEntityType( 'enum', enumDescriptor, 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 65bebfa0288f..b5d7e1ae022e 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 @@ -176,7 +176,7 @@ describe('mongoContract provider helper', () => { }); }); - 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'); @@ -215,15 +215,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_ENUM_MEMBER_ATTRIBUTE_UNSUPPORTED', + message: + 'enum "Role": member "USER" carries @map, but an enum member takes no attributes', sourceId: './schema.prisma', span: { start: { offset: 20, line: 2, column: 9 }, - end: { offset: 21, line: 2, column: 10 }, + end: { offset: 32, line: 2, column: 21 }, }, }, ], From af00251f1bb8cd2051f129434d6d8277a5a7d67a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 08:30:26 +0200 Subject: [PATCH 41/55] refactor(config): a contract source is PSL or TypeScript, and a Prisma 7 schema is a PSL source The framework supports two source formats. The prisma7 format value and the opaque catch-all provider are gone: a source declares psl or typescript, and one that declares nothing is a TypeScript source. The Prisma 7 source declares psl, without the interpret capability the Prisma 8 reader adds, so contract format leaves it alone by narrowing through hasPslInterpreter rather than the tag. The language server already made that distinction. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../config/src/contract-source-types.ts | 26 ++++++++++++++----- .../1-core/config/src/exports/config-types.ts | 4 +++ .../1-core/config/test/config-types.test-d.ts | 26 +++++++++++++------ .../psl-parser/src/exports/interpret.ts | 6 +---- .../2-authoring/psl-parser/src/interpret.ts | 6 +---- .../psl-parser/test/interpret.test-d.ts | 2 +- .../psl-parser/test/interpret.test.ts | 11 ++------ .../cli/src/control-api/operations/format.ts | 3 ++- .../3-tooling/cli/test/config-types.test.ts | 1 + .../cli/test/control-api/client.test.ts | 1 + .../cli/test/control-api/format.test.ts | 24 ++++++++++++++++- .../testing/fixture-client.test.ts | 10 +++++-- .../cli/test/orm/contract-print.test.ts | 4 +-- .../3-tooling/cli/test/orm/format.test.ts | 7 ++++- .../test/finalize-config.test.ts | 1 + .../contract-prisma7/src/provider.ts | 2 +- .../contract-prisma7/test/provider.test.ts | 2 +- .../test/config/define-config.prisma7.test.ts | 2 +- 18 files changed, 94 insertions(+), 44 deletions(-) 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 cd72f643f0c9..7bc8a0e907cc 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 @@ -51,15 +51,29 @@ export interface ContractSourceContext { } /** - * A contract source: the inputs it reads and the `load` that turns them into a - * contract. The framework never enumerates sources. `format` is a tag the - * owning package gives its source; tooling that acts on one kind of source - * checks for the capability that package exports, never for the tag alone. + * A contract source is PSL or TypeScript: the inputs it reads and the `load` + * that turns them into a contract. `format` says which language the inputs are + * written in; a source that declares none is a TypeScript source. A PSL source + * may also carry the `interpret` capability `@internal/psl-parser` defines; + * tooling that rewrites PSL in place narrows through `hasPslInterpreter`, + * because PSL text exists that the Prisma 8 reader does not interpret, such as + * a Prisma 7 schema. */ -export interface ContractSourceProvider { +export type ContractSourceFormat = 'psl' | 'typescript'; + +export interface ContractSourceProviderBase { readonly inputs?: readonly string[]; - readonly format?: string; readonly load: ( context: ContractSourceContext, ) => Promise>; } + +export interface PslContractSourceProvider extends ContractSourceProviderBase { + readonly format: 'psl'; +} + +export interface TypeScriptContractSourceProvider extends ContractSourceProviderBase { + readonly format?: 'typescript'; +} + +export type ContractSourceProvider = PslContractSourceProvider | TypeScriptContractSourceProvider; 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 ea8efcae0720..d231c6a6d556 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 @@ -10,5 +10,9 @@ export type { ContractSourceDiagnosticPosition, ContractSourceDiagnosticSpan, ContractSourceDiagnostics, + ContractSourceFormat, ContractSourceProvider, + ContractSourceProviderBase, + PslContractSourceProvider, + TypeScriptContractSourceProvider, } from '../contract-source-types'; 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 493fd8ec4508..c99f10a6097a 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 @@ -12,7 +12,10 @@ import { expectTypeOf, test } from 'vitest'; import { defineConfig, type FormatterConfig, type PrismaNextConfig } from '../src/config-types'; import type { ContractSourceDiagnostic, + ContractSourceFormat, ContractSourceProvider, + PslContractSourceProvider, + TypeScriptContractSourceProvider, } from '../src/contract-source-types'; const mockHook = { @@ -121,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(); }); @@ -142,13 +145,20 @@ test('source diagnostics require a filename but not a span', () => { void missingFilename; }); -test('a contract source is one open interface: the framework enumerates no formats', () => { - expectTypeOf().toEqualTypeOf(); - const load: ContractSourceProvider['load'] = async () => ok({} as never); - const tagged: ContractSourceProvider = { format: 'anything', inputs: ['./x'], load }; - const untagged: ContractSourceProvider = { inputs: ['./x'], load }; - expectTypeOf(tagged).toExtend(); - expectTypeOf(untagged).toExtend(); +test('a contract source is PSL or TypeScript, and nothing else', () => { + expectTypeOf().toEqualTypeOf< + PslContractSourceProvider | TypeScriptContractSourceProvider + >(); + expectTypeOf().toEqualTypeOf<'psl' | 'typescript'>(); + expectTypeOf().toEqualTypeOf<'psl'>(); + expectTypeOf().toEqualTypeOf< + 'typescript' | undefined + >(); + const untagged: ContractSourceProvider = { inputs: [], load: async () => ok({} as never) }; + expectTypeOf(untagged).toExtend(); + // @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/2-authoring/psl-parser/src/exports/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/interpret.ts index 6bd130f1a150..f73ddf1568eb 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,8 +1,4 @@ -export type { - PslContractSourceProvider, - PslInterpretCapable, - PslInterpretInput, -} from '../interpret'; +export type { PslInterpretCapable, PslInterpretInput } from '../interpret'; export { hasPslInterpreter, withSeedDiagnostics } from '../interpret'; export type { InvalidFkPairing } from '../relation-backrelations'; export { diff --git a/packages/1-framework/2-authoring/psl-parser/src/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/interpret.ts index 9a07c9c8962f..7c18255a5041 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/interpret.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/interpret.ts @@ -3,6 +3,7 @@ import type { ContractSourceDiagnostic, ContractSourceDiagnostics, ContractSourceProvider, + PslContractSourceProvider, } from '@internal/config/config-types'; import type { Contract } from '@internal/contract/types'; import { notOk, type Result } from '@internal/utils/result'; @@ -27,11 +28,6 @@ export interface PslInterpretInput { * types. `interpret` must not read disk or `context.resolvedInputs` — those * are load-path concerns. */ -/** A contract source whose inputs are PSL files, which this package reads. */ -export interface PslContractSourceProvider extends ContractSourceProvider { - readonly format: 'psl'; -} - export interface PslInterpretCapable extends PslContractSourceProvider { interpret( input: PslInterpretInput, diff --git a/packages/1-framework/2-authoring/psl-parser/test/interpret.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/interpret.test-d.ts index f9e727758e13..597a06cd5c1b 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/interpret.test-d.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/interpret.test-d.ts @@ -2,13 +2,13 @@ import type { ContractSourceContext, ContractSourceDiagnostics, ContractSourceProvider, + PslContractSourceProvider, } from '@internal/config/config-types'; import type { Contract } from '@internal/contract/types'; import { ok, type Result } from '@internal/utils/result'; import { expectTypeOf, test } from 'vitest'; import { hasPslInterpreter, - type PslContractSourceProvider, type PslInterpretCapable, type PslInterpretInput, } from '../src/interpret'; 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..e4337aaceb45 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,15 +42,8 @@ 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: () => [] }; + it('rejects a typescript provider carrying interpret', () => { + const provider = { format: 'typescript' as const, load, interpret: () => [] }; const source: ContractSourceProvider = provider; expect(hasPslInterpreter(source)).toBe(false); 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 fa31532d08c5..ca8c17b5ef14 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 @@ -2,6 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { EOL } from 'node:os'; import type { PrismaNextConfig } from '@internal/config/config-types'; import { type FormatOptions, format } from '@internal/psl-parser/format'; +import { hasPslInterpreter } from '@internal/psl-parser/interpret'; import { notOk, ok, type Result } from '@internal/utils/result'; import { isStructuredError } from '@internal/utils/structured-error'; import { resolve } from 'pathe'; @@ -36,7 +37,7 @@ export async function executeFormat( const config = options.config; const source = config.contract?.source; - if (source?.format !== 'psl') { + if (source === undefined || !hasPslInterpreter(source)) { return ok({ formatted: false }); } 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 7c03b60e0d09..f9b4b36fe205 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 @@ -46,6 +46,7 @@ function createSourceProvider( inputs?: readonly string[], ): ContractSourceProvider { return { + format: 'typescript', ...ifDefined('inputs', inputs), load, }; 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 5c9946dacf47..50aafc621365 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 @@ -15,7 +15,7 @@ function pslConfig( ) { return mockConfig({ contract: { - source: { format: 'psl', inputs: [inputPath], load: () => {} }, + source: { format: 'psl', inputs: [inputPath], load: () => {}, interpret: () => {} }, output: join(inputPath, '..', 'contract.json'), }, ...(formatter ? { formatter } : {}), @@ -68,6 +68,28 @@ describe('executeFormat', () => { expect(await readFile(inputPath, 'utf-8')).toBe(FORMATTED_PSL); }); + it('leaves a psl source the Prisma 8 reader does not interpret untouched', async () => { + const inputPath = join(tmpDir, 'schema.prisma'); + await writeFile(inputPath, MESSY_PSL, 'utf-8'); + + const result = await executeFormat({ + config: mockConfig({ + contract: { + source: { format: 'psl', inputs: [inputPath], load: () => {} }, + output: join(tmpDir, 'contract.json'), + }, + }), + cwd: tmpDir, + eol: '\n', + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.formatted).toBe(false); + } + expect(await readFile(inputPath, 'utf-8')).toBe(MESSY_PSL); + }); + it('leaves a typescript source untouched', async () => { const inputPath = join(tmpDir, 'contract.ts'); const original = 'export const x = 1;\n'; 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-print.test.ts b/packages/1-framework/3-tooling/cli/test/orm/contract-print.test.ts index 4a0e21c99bd5..b269c72a56e7 100644 --- 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 @@ -80,7 +80,7 @@ function ormConfig(dir: string, overrides: Record = {}): Record adapter: { ...DESCRIPTOR, kind: 'adapter', id: 'pg' }, driver: { ...DESCRIPTOR, kind: 'driver', id: 'pg-driver' }, contract: { - source: { format: 'fixture', inputs: ['./prisma/schema.prisma'], load: mocks.load }, + source: { format: 'typescript', inputs: ['./prisma/schema.prisma'], load: mocks.load }, output: join(dir, 'generated', 'contract.json'), }, ...overrides, @@ -255,7 +255,7 @@ describe('contract print', () => { const dir = await projectDir(); const config = ormConfig(dir, { contract: { - source: { format: 'fixture', inputs: ['./prisma'], load: mocks.load }, + source: { format: 'typescript', inputs: ['./prisma'], load: mocks.load }, output: join(dir, 'generated', 'contract.json'), }, }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/format.test.ts b/packages/1-framework/3-tooling/cli/test/orm/format.test.ts index e26bc9591fa5..840813f575d7 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/format.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/format.test.ts @@ -59,7 +59,12 @@ function ormConfig(source: Record): Record { } function pslConfig(inputPath: string): Record { - return ormConfig({ format: 'psl', inputs: [inputPath], load: async () => ({}) }); + return ormConfig({ + format: 'psl', + inputs: [inputPath], + load: async () => ({}), + interpret: () => ({}), + }); } function harness(config: Record) { diff --git a/packages/1-framework/3-tooling/config-loader/test/finalize-config.test.ts b/packages/1-framework/3-tooling/config-loader/test/finalize-config.test.ts index c92df7762c0b..19a28a45747c 100644 --- a/packages/1-framework/3-tooling/config-loader/test/finalize-config.test.ts +++ b/packages/1-framework/3-tooling/config-loader/test/finalize-config.test.ts @@ -46,6 +46,7 @@ function createConfig( function createSource(inputs?: readonly string[]) { return { + format: 'typescript' as const, ...(inputs ? { inputs } : {}), load: async () => ok({ targetFamily: 'sql' } as never), }; 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 b5f63df967cc..8c10f42012b9 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; 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 1070c15451e1..4145be4c1686 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 @@ -50,7 +50,7 @@ function scratchDir(name: string): string { describe('prisma7Contract', () => { it('declares the prisma7 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/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'); }); From 6689da96a4db66bce3ad8061dd5f329f9fa43463 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 10:24:07 +0200 Subject: [PATCH 42/55] fix: contract print writes what it used to drop, and every claim about it matches the code An audit of every comment, message, test name and document in this branch found statements that were false, and three places where the printer dropped part of a contract without refusing: - a primary key name, which PSL writes as @id(map:) or @@id(map:); - a column codec that is not the default for its native type: the printer now writes the PSL type that reads back with that codec, looked up in the PSL types the configured stack contributes, which the family now passes to the target hook. Extension types such as pgvector.Vector print when the extension is in the config; - a value object outside the default namespace, which the reader would move, is now refused by name. The printer also writes row-level security, policies and roles, which PSL can express and it used to refuse, and names the unbound namespace as PSL does. A PSL file cannot carry a contract default control policy, so contract print now reports it in its result and next step instead of losing it. Defaults take their data type from the column codec. contract format checks the source format again rather than the interpret capability, which is an editor optimisation, so it formats a Prisma 7 schema when it parses. The next steps after printing are the switch to the written file only; the Prisma 7 baseline steps stay in the docs. The error reference lists every refusal, and the specs, READMEs, test names, upgrade instructions and directory names now describe contract print as it is. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- biome-plugins/no-family-vocabulary.grit | 11 +- docs/reference/error-reference.md | 58 ++++--- .../config/src/contract-source-types.ts | 11 +- .../test/format/prisma7-constructs.test.ts | 49 +++++- .../psl-parser/test/interpret.test.ts | 4 +- .../psl-printer/src/print-document.ts | 2 +- .../test/print-psl-from-ast.test.ts | 7 +- packages/1-framework/3-tooling/cli/README.md | 14 +- .../control-api/operations/contract-emit.ts | 11 +- .../cli/src/control-api/operations/format.ts | 3 +- .../operations/load-contract-source.ts | 2 +- .../3-tooling/cli/src/orm/contract/infer.ts | 4 +- .../3-tooling/cli/src/orm/contract/paths.ts | 16 +- .../3-tooling/cli/src/orm/contract/print.ts | 57 ++++--- .../test/control-api/contract-emit.test.ts | 2 +- .../cli/test/control-api/format.test.ts | 24 +-- .../cli/test/orm/contract-print.test.ts | 71 ++++---- .../3-tooling/cli/test/orm/format.test.ts | 7 +- ...ints.test.ts => print-constraints.test.ts} | 0 ...ndtrip.test.ts => print-roundtrip.test.ts} | 0 .../contract-prisma7/test/provider.test.ts | 2 +- .../contract-prisma7/test/support.ts | 6 +- .../src/psl-relation-resolution.ts | 8 +- packages/2-sql/9-family/README.md | 2 +- .../9-family/src/core/control-instance.ts | 11 +- .../src/core/control-target-descriptor.ts | 19 ++- packages/3-extensions/postgres/README.md | 2 +- .../3-targets/3-targets/postgres/README.md | 2 +- .../src/core/psl-infer/infer-default-codec.ts | 6 + .../core/psl-print/contract-model-index.ts | Bin 4271 -> 4633 bytes .../core/psl-print/print-column-default.ts | 16 +- .../src/core/psl-print/print-column-type.ts | 135 ++++++++++++--- .../src/core/psl-print/print-domain-types.ts | 26 ++- .../src/core/psl-print/print-enum-blocks.ts | 18 +- .../psl-print/print-execution-defaults.ts | 21 ++- .../src/core/psl-print/print-psl-contract.ts | Bin 26665 -> 29234 bytes .../core/psl-print/print-relation-fields.ts | 13 +- .../src/core/psl-print/print-rls-blocks.ts | 155 ++++++++++++++++++ .../3-targets/postgres/src/exports/control.ts | 4 +- .../test/psl-print/print-psl-contract.test.ts | 100 ++++++++++- .../postgres/test/psl-print-roundtrip.test.ts | 124 +++++++++++--- .../03-contract-to-psl-and-convert/spec.md | 39 ----- .../03-contract-to-psl-and-print/spec.md | 40 +++++ projects/prisma7-contract-source/spec.md | 34 ++-- .../slices/c-remove-dbgenerated/spec.md | 1 - .../cli-journeys/prisma7-print.e2e.test.ts | 62 +++---- ...ension-types-roundtrip.integration.test.ts | 88 ++++++++++ .../contract-convert/app/instructions.md | 3 - .../extension/instructions.md | 3 - .../contract-print/app/instructions.md | 11 ++ .../contract-print/extension/instructions.md | 15 ++ 51 files changed, 968 insertions(+), 351 deletions(-) rename packages/2-sql/2-authoring/contract-prisma7/test/{convert-print-constraints.test.ts => print-constraints.test.ts} (100%) rename packages/2-sql/2-authoring/contract-prisma7/test/{convert-roundtrip.test.ts => print-roundtrip.test.ts} (100%) create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-rls-blocks.ts delete mode 100644 projects/prisma7-contract-source/slices/03-contract-to-psl-and-convert/spec.md create mode 100644 projects/prisma7-contract-source/slices/03-contract-to-psl-and-print/spec.md create mode 100644 test/integration/test/psl-print/extension-types-roundtrip.integration.test.ts delete mode 100644 upgrade-instructions/pending/contract-convert/app/instructions.md delete mode 100644 upgrade-instructions/pending/contract-convert/extension/instructions.md create mode 100644 upgrade-instructions/pending/contract-print/app/instructions.md create mode 100644 upgrade-instructions/pending/contract-print/extension/instructions.md diff --git a/biome-plugins/no-family-vocabulary.grit b/biome-plugins/no-family-vocabulary.grit index 203ec0bee686..c2d975d1cf48 100644 --- a/biome-plugins/no-family-vocabulary.grit +++ b/biome-plugins/no-family-vocabulary.grit @@ -1,5 +1,8 @@ // Recognise SQL/Mongo/target vocabulary (`table`, `column`, `postgres`, // `nativeType`, …) reaching the family-blind `packages/1-framework` domain. +// It also flags the product name Prisma 7 (`prisma7`, `Prisma 7`, …) +// everywhere in that domain except the PSL parser, which owns the `prisma7` +// grammar. // // The scope is enforced by the `$filename` guard below, not by a biome // `overrides` entry. Biome 2.5.6 does accept `plugins` inside `overrides` (and @@ -44,8 +47,8 @@ // identifier splits on non-alphanumerics and on a lowercase/digit → uppercase // hump, then each token is compared case-insensitively, allowing a trailing // plural `s`. Reproducing that means the term's own casing decides which -// boundaries are legal, which is why there are three alternatives instead of -// one case-insensitive pattern: +// boundaries are legal, which is why there are three term alternatives instead +// of one case-insensitive pattern: // // 1. lowercase head, lowercase tail (`table`) — a lowercase head cannot open // a hump, so the prefix must be empty or end in a non-alphanumeric. This @@ -59,7 +62,7 @@ // stops `SQL` matching inside `SQLQueryPlan`, which the tokenizer reads as // the single token `sqlquery`. // -// Each alternative lists every forbidden term with a literal head character, a +// Each term alternative lists every forbidden term with a literal head character, a // case-insensitive middle, and a literal tail character. The three multi-word // terms (`nativeType`, `primaryKey`, `foreignKey`) additionally accept either a // separator run or a hump between their two words, so `native-type`, @@ -116,5 +119,5 @@ or { not $tok <: r"(?s).*(?i:alignment)[^A-Za-z0-9]*(?i:columns).*", not $tok <: r"(?s).*(?i:type)[^A-Za-z0-9]*(?i:column).*", not $tok <: r"(?s).*(?i:attribute)[^A-Za-z0-9]*(?i:column).*", - register_diagnostic(span=$tok, message="no-family-vocabulary: family/target vocabulary in the family-blind framework domain; move the concept into the owning family layer, or suppress with `// biome-ignore lint/plugin/no-family-vocabulary: `", severity="info") + register_diagnostic(span=$tok, message="no-family-vocabulary: family, target or product vocabulary in the family-blind framework domain; move the concept into the layer that owns it, or suppress with `// biome-ignore lint/plugin/no-family-vocabulary: `", severity="info") } diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index ca2ec245a892..0fe81a4ea338 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -255,7 +255,19 @@ A model declares an empty unique constraint (a unique with no fields), raised du ### CONTRACT.PRINT_UNSUPPORTED -`contract print` cannot write the loaded contract as a Prisma 8 PSL file that reads back as the same contract, so it writes nothing. Either the configured target has no `printPslContract` hook on its descriptor (meta: `targetId`), or the contract holds something the PSL language has no form for. The message names the model, column, field, or entity. The refused shapes: a column whose native type is outside the target's type map; a generated value the target cannot author, or a column that takes a wall-clock-now generator in one phase and a different generator in the other; a to-one relation with no foreign key behind it, or a foreign key no relation travels; a field whose type is a union of types, or a dictionary field; a column with its own control policy (PSL declares the policy per model); a model with an owner; one model name declared in more than one namespace; an entity kind an extension contributes (row-level security policies, roles), whose PSL syntax the target printer does not know; and a value-object field whose codec the Postgres target does not own. Meta at the shape sites: `coordinate`, `nativeType`, `onCreate`, `onUpdate`, `model`, `field`, `columns`, `kind`, `control`, `owner`, `modelName`, `namespaces`, `names`, `codecId`. +`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 the target's descriptor has no `printPslContract` hook (meta: `targetId`); +- no PSL type in the configured stack produces a column's codec and native type. Add the extension that contributes the type to the config (meta: `coordinate`, `nativeType`, `codecId`); +- a column's literal default has no PSL literal that reads back as the stored value, or a domain enum column defaults to a value that is not a member (meta: `coordinate`, `pslTypeName`); +- a column is generated by a generator that has no PSL default function, generates a value on update other than through a temporal preset, or pairs the wall-clock-now generator with a different generator (meta: `coordinate`, `onCreate`, `onUpdate`); +- a to-one relation has no foreign key behind it (meta: `model`, `field`), or a foreign key has no relation that travels it (meta: `namespaceId`, `table`, `columns`); +- a field's type is a union of types (meta: `coordinate`, `kind`), a field is a dictionary (meta: `coordinate`), or a value-object field uses a codec the Postgres target does not own (meta: `coordinate`, `codecId`); +- a column has its own control policy (meta: `coordinate`, `control`), or a model has an owner (meta: `namespaceId`, `modelName`, `owner`); +- one model name is declared in more than one namespace (meta: `modelName`, `namespaces`), or a domain enum or value object is declared outside the default namespace (meta: `namespaceId`, `names`); +- a table has row-level security enabled but no model, or a policy is on a table with no model or names a role that is not a PSL identifier (meta: `namespaceId`, `table`); +- 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`), or a storage entry that is not an entity of its kind (meta: `namespaceId`, `kind`, `name`). + ### 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`. @@ -458,11 +470,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 @@ -516,55 +528,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 @@ -572,31 +584,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 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 7bc8a0e907cc..6e1534d29cff 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 @@ -51,13 +51,10 @@ export interface ContractSourceContext { } /** - * A contract source is PSL or TypeScript: the inputs it reads and the `load` - * that turns them into a contract. `format` says which language the inputs are - * written in; a source that declares none is a TypeScript source. A PSL source - * may also carry the `interpret` capability `@internal/psl-parser` defines; - * tooling that rewrites PSL in place narrows through `hasPslInterpreter`, - * because PSL text exists that the Prisma 8 reader does not interpret, such as - * a Prisma 7 schema. + * The language a contract source's inputs are written in. Tooling that reads + * the inputs itself, such as `contract format` and the language server, checks + * this instead of guessing from file extensions. A source that declares no + * format is a TypeScript source. */ export type ContractSourceFormat = 'psl' | 'typescript'; 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 index de43a7a28039..48e3da53ebd3 100644 --- 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 @@ -3,7 +3,7 @@ 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', () => { +describe('format given Prisma 7 syntax', () => { 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({ @@ -13,6 +13,53 @@ describe('format given a Prisma 7 construct in a Prisma 8 document', () => { ); }); + it('formats a Prisma 7 schema with no view block', () => { + 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'), + ); + }); + it('keeps an attribute on an enum member, leaving its validity to the interpreter', () => { expect(format('enum Role {\n USER @map("user")\n ADMIN\n}\n')).toContain( 'USER @map("user")', 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 e4337aaceb45..ee054c35f75e 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,8 +42,8 @@ describe('hasPslInterpreter', () => { expect(hasPslInterpreter(source)).toBe(false); }); - it('rejects a typescript provider carrying interpret', () => { - const provider = { format: 'typescript' as const, load, interpret: () => [] }; + it('rejects a provider that declares no format, which is a TypeScript source', () => { + const provider = { load, interpret: () => [] }; const source: ContractSourceProvider = provider; expect(hasPslInterpreter(source)).toBe(false); 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 5e14cec473b9..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,7 +19,7 @@ import type { PrinterModel, PrinterNamedType } from './types'; */ export type PrintNamespaceSection = { readonly name: string; - /** Value-object `type` blocks, printed before the models that use them. */ + /** 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/test/print-psl-from-ast.test.ts b/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts index 346a6bcb0c13..2209b5fe15c9 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 @@ -408,10 +408,13 @@ describe('printPslFromAst', () => { it('opens with the header the caller names', () => { const printed = printPslFromAst(headerAst, { - headerComment: '// use prisma-8\n// Converted from prisma/schema.prisma.', + headerComment: + '// use prisma-8\n// Printed from prisma/schema.prisma by `prisma contract print`.', }); - expect(headerOf(printed)).toBe('// use prisma-8\n// Converted from prisma/schema.prisma.'); + 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 25f650ad2864..bd2ca5a875b5 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -365,19 +365,21 @@ Options: The command needs no database connection: it reads the source files, not the server. The output path is chosen the same way `contract infer` chooses one, and an existing file there is overwritten with a warning. -The written file opens with the two lines that say where it came from: +The written file 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 written file must read back as the identical contract. The printer writes models, value objects (`type` blocks), named types (`types` block), native enums, keys, indexes with every argument, checks, relations, polymorphism (`@@discriminator`, `@@base`), control policies and execution defaults. Where the PSL language has no form for something the contract holds, the command refuses and writes nothing. Three things are refused with exit `2`: -- A shape the language cannot carry exits with `CONTRACT.PRINT_UNSUPPORTED`, naming the model, column, field or entity it stopped on: a foreign key no relation travels, a to-one relation with no foreign key, a union or dictionary field, a column with its own control policy, a model with an owner, one model name declared in two namespaces, or an entity an extension contributes (row-level security policies, roles). -- A source Prisma 8 cannot read exits with `CONTRACT.SOURCE_LOAD_FAILED`, reporting exactly what `contract emit` reports for the same source. -- An `--output` path that is a source file the config reads, or sits inside a directory of source files, exits with `CONTRACT.PRINT_OUTPUT_IS_SOURCE`. Pick another path. +The written file reads back as the identical contract. Where PSL has no form for part of the contract, the command refuses, names that part, and writes nothing. It exits `2` in three 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. +- `CONTRACT.PRINT_OUTPUT_IS_SOURCE`: the `--output` path is a source file the config reads, or sits inside a directory of source files. Pick another path. -Printing is the first step of the cutover, not the whole of it. After it succeeds, point `contract` in `prisma.config.ts` at the written file and run the rest: +A PSL file cannot carry the contract's default control policy. When the contract has one, the result names it (`defaultControlPolicy` in the JSON result, and in the next step), and the config must set it on the PSL source. + +To switch to the written file, point `contract` in `prisma.config.ts` at it and run `prisma contract emit`. For a project leaving a Prisma 7 schema, that is the first step of the cutover; the rest takes migration ownership of the database Prisma 7 built: ```bash prisma contract emit 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 1263471312f0..306d7e6b1d7e 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 @@ -130,13 +130,10 @@ export async function executeContractEmit( config.target.targetId, rawComponents, ); - // Blind cast: `loadContractSource` upstream has already - // pinned `loadedSource.contract` 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. + // Blind cast: `loadContractSource` returns the provider's value as + // `unknown` and checks only that it is present. The cast defers the + // structural check by one statement so `enrichContract` can decorate + // first; the serialize→deserialize round-trip below checks the shape. const enrichedIR = enrichContract( blindCast< Contract, 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 ca8c17b5ef14..fa31532d08c5 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 @@ -2,7 +2,6 @@ import { readFile, writeFile } from 'node:fs/promises'; import { EOL } from 'node:os'; import type { PrismaNextConfig } from '@internal/config/config-types'; import { type FormatOptions, format } from '@internal/psl-parser/format'; -import { hasPslInterpreter } from '@internal/psl-parser/interpret'; import { notOk, ok, type Result } from '@internal/utils/result'; import { isStructuredError } from '@internal/utils/structured-error'; import { resolve } from 'pathe'; @@ -37,7 +36,7 @@ export async function executeFormat( const config = options.config; const source = config.contract?.source; - if (source === undefined || !hasPslInterpreter(source)) { + if (source?.format !== 'psl') { return ok({ formatted: false }); } 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 index 76977ae31fed..8cd3a0c40066 100644 --- 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 @@ -180,7 +180,7 @@ function validateProviderResult(providerResult: unknown): ValidatedProviderResul ok: false, error: failedToResolveContractSource( String(failure['summary']), - 'Edit the schema where each finding points, then run contract emit again.', + 'Edit the source where each finding points, then run the command again.', { diagnostics: failure['diagnostics'], issues: mapDiagnosticsToIssues(failure['diagnostics']), 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 b2013ebdac49..ce23128432ff 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 @@ -21,7 +21,7 @@ import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { normalizeError } from '../normalize-error'; import { controlProgressReporter } from '../progress'; -import { inferredContractPathFor } from './paths'; +import { pslOutputPathFor } from './paths'; interface InferDocument { readonly ok: true; @@ -173,7 +173,7 @@ export function createContractInferCommand({ await closeQuietly(client); } - const outputPath = inferredContractPathFor({ + const outputPath = pslOutputPathFor({ config: ctx.config, cwd: ctx.cwd, output: args.flags.output, 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..40cfce6d85f7 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,15 +1,15 @@ import type { PrismaNextConfig } from '@internal/config/config-types'; import { dirname, join, resolve } from 'pathe'; -/** The file `contract infer` writes when nothing names another. */ -const INFERRED_CONTRACT_FILENAME = 'contract.prisma'; +/** The file `contract infer` and `contract print` write when nothing names another. */ +const PSL_CONTRACT_FILENAME = 'contract.prisma'; /** - * Where `contract infer` writes the PSL it inferred: `--output`, else - * `contract.prisma` beside the emitted contract, else `contract.prisma` in the - * invocation directory. + * Where `contract infer` and `contract print` write their PSL: `--output`, + * else `contract.prisma` beside the emitted contract, else `contract.prisma` + * in the invocation directory. */ -export function inferredContractPathFor(inputs: { +export function pslOutputPathFor(inputs: { readonly config: PrismaNextConfig; readonly cwd: string; readonly output: string | undefined; @@ -19,7 +19,7 @@ export function inferredContractPathFor(inputs: { } const contractOutput = inputs.config.contract?.output; if (contractOutput !== undefined) { - return join(dirname(resolve(inputs.cwd, contractOutput)), INFERRED_CONTRACT_FILENAME); + return join(dirname(resolve(inputs.cwd, contractOutput)), PSL_CONTRACT_FILENAME); } - return join(inputs.cwd, INFERRED_CONTRACT_FILENAME); + return join(inputs.cwd, PSL_CONTRACT_FILENAME); } 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 index 8659535b265c..103628806e2a 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs'; import { printPsl as printPslFromAst } from '@internal/psl-printer'; +import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations } from '@prisma/cli-engine'; import { flag } from '@prisma/cli-engine'; import type { NextAction } from '@prisma/cli-engine/protocol'; @@ -15,7 +16,7 @@ import { publishTextArtifact } from '../../utils/publish-text-artifact'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; -import { inferredContractPathFor } from './paths'; +import { pslOutputPathFor } from './paths'; interface PrintDocument { readonly ok: true; @@ -23,31 +24,37 @@ interface PrintDocument { readonly target: { readonly familyId: string; readonly id: string }; readonly psl: { readonly path: string }; readonly source: readonly string[]; + /** + * The contract's default control policy. A PSL file cannot carry it; the + * config sets it on the PSL source, or the emitted contract loses it. + */ + readonly defaultControlPolicy?: string; readonly timings: { readonly total: number }; } -/** - * The routine that carries a printed contract onto the database its source - * describes: point the config at the written file, emit, plan a baseline, - * sign, then point the `db` ref at the baseline. - */ -function cutoverActions(writtenPath: string): readonly NextAction[] { +/** Switching the config to the written file: emitting it produces the same contract. */ +function switchToPrintedActions(document: PrintDocument): readonly NextAction[] { + const policy = document.defaultControlPolicy; return [ - chooseAction(`Point contract in prisma.config.ts at ${writtenPath}`), - runCommandAction('Emit the printed contract', '{bin} contract emit'), - runCommandAction('Plan the baseline migration', '{bin} migration plan --name baseline'), - runCommandAction('Sign the database', '{bin} db sign'), - runCommandAction( - 'Point the db ref at the baseline migration', - '{bin} migration ref set db _baseline', + chooseAction( + policy === undefined + ? `Point contract in prisma.config.ts at ${document.psl.path}` + : `Point contract in prisma.config.ts at ${document.psl.path}, with defaultControlPolicy '${policy}' on its source`, ), + runCommandAction('Emit the printed contract', '{bin} contract emit'), ]; } +function defaultControlPolicyOf(contract: unknown): string | undefined { + if (typeof contract !== 'object' || contract === null) return undefined; + const policy = Reflect.get(contract, 'defaultControlPolicy'); + return typeof policy === 'string' ? policy : undefined; +} + function printPresentations(document: PrintDocument): Presentations { return { stdout: () => [], - next: () => cutoverActions(document.psl.path), + next: () => switchToPrintedActions(document), human: (): readonly Block[] => [ { kind: 'summary', @@ -106,9 +113,9 @@ export function createContractPrintCommand({ 'of source that is, and writes it as a Prisma 8 PSL file. Emitting that\n' + 'file produces the same contract: same hashes, same domain. If the\n' + 'contract holds something PSL cannot express, the command refuses,\n' + - 'names it, and writes nothing. The command stops at contract.prisma;\n' + - 'switch the config to the written file, then run `contract emit`. An\n' + - 'existing file at the output path is overwritten, with a warning.', + 'names it, and writes nothing. The command only writes the PSL file;\n' + + 'switch the config to it, then run `contract emit`. An existing file at\n' + + 'the output path is overwritten, with a warning.', examples: [ 'contract print', 'contract print --output ./src/prisma/contract.prisma', @@ -139,7 +146,7 @@ export function createContractPrintCommand({ const sourceInputs = contractConfig.source.inputs ?? []; const sourcePaths = sourceInputs.map((input) => relative(ctx.cwd, input)); - const outputPath = inferredContractPathFor({ + const outputPath = pslOutputPathFor({ config: ctx.config, cwd: ctx.cwd, output: args.flags.output, @@ -155,7 +162,7 @@ export function createContractPrintCommand({ normalizeError( errorRuntime( 'CONTRACT.PRINT_OUTPUT_IS_SOURCE', - 'contract print would write over the schema it reads', + 'contract print would write over its own contract source', { why: `The output path ${displayPath} is the contract source ${relative(ctx.cwd, sourceInput)}, or sits inside it, so printing would destroy the source it reads.`, fix: 'Pick another --output path, outside the source files the config names.', @@ -175,23 +182,24 @@ export function createContractPrintCommand({ }); let pslContent: string; + let defaultControlPolicy: string | undefined; try { const { stack, contract } = await loadContractSource({ config: ctx.config, contractConfig, signal: ctx.signal, }); + defaultControlPolicy = defaultControlPolicyOf(contract); const pslContractAst = client.printPslContract(contract); if (pslContractAst === undefined) { return notOk( normalizeError( errorRuntime( 'CONTRACT.PRINT_UNSUPPORTED', - 'contract print is not supported for this target', + 'contract print is not supported for this family', { - why: 'The configured target does not implement the PslContractPrintCapable capability, so the loaded contract cannot be written as Prisma 8 PSL.', - // biome-ignore lint/plugin/no-family-vocabulary: names a target on purpose — this is user-facing guidance about which target can convert, not a framework type - fix: 'Use a target that can print a contract as PSL (Postgres today).', + 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.', }, ), ), @@ -228,6 +236,7 @@ export function createContractPrintCommand({ target: { familyId: ctx.config.family.familyId, id: ctx.config.target.targetId }, psl: { path: displayPath }, source: sourcePaths, + ...ifDefined('defaultControlPolicy', defaultControlPolicy), timings: { total: Date.now() - startedAt }, }; 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 180ded153c03..22b194d22be5 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 @@ -257,7 +257,7 @@ describe('executeContractEmit', () => { expect(await emitFailure()).toMatchObject({ code: 'CONTRACT.SOURCE_LOAD_FAILED', why: 'Source interpretation failed', - fix: 'Edit the schema where each finding points, then run contract emit again.', + fix: 'Edit the source where each finding points, then run the command again.', }); }); 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 50aafc621365..5c9946dacf47 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 @@ -15,7 +15,7 @@ function pslConfig( ) { return mockConfig({ contract: { - source: { format: 'psl', inputs: [inputPath], load: () => {}, interpret: () => {} }, + source: { format: 'psl', inputs: [inputPath], load: () => {} }, output: join(inputPath, '..', 'contract.json'), }, ...(formatter ? { formatter } : {}), @@ -68,28 +68,6 @@ describe('executeFormat', () => { expect(await readFile(inputPath, 'utf-8')).toBe(FORMATTED_PSL); }); - it('leaves a psl source the Prisma 8 reader does not interpret untouched', async () => { - const inputPath = join(tmpDir, 'schema.prisma'); - await writeFile(inputPath, MESSY_PSL, 'utf-8'); - - const result = await executeFormat({ - config: mockConfig({ - contract: { - source: { format: 'psl', inputs: [inputPath], load: () => {} }, - output: join(tmpDir, 'contract.json'), - }, - }), - cwd: tmpDir, - eol: '\n', - }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value.formatted).toBe(false); - } - expect(await readFile(inputPath, 'utf-8')).toBe(MESSY_PSL); - }); - it('leaves a typescript source untouched', async () => { const inputPath = join(tmpDir, 'contract.ts'); const original = 'export const x = 1;\n'; 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 index b269c72a56e7..d07af61202c8 100644 --- 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 @@ -40,7 +40,7 @@ const groups = BIN_GROUPS; const dirs: string[] = []; async function projectDir(): Promise { - const dir = createTestProjectDir('orm-convert'); + const dir = createTestProjectDir('orm-print'); dirs.push(dir); return dir; } @@ -80,7 +80,7 @@ function ormConfig(dir: string, overrides: Record = {}): Record adapter: { ...DESCRIPTOR, kind: 'adapter', id: 'pg' }, driver: { ...DESCRIPTOR, kind: 'driver', id: 'pg-driver' }, contract: { - source: { format: 'typescript', inputs: ['./prisma/schema.prisma'], load: mocks.load }, + source: { format: 'psl', inputs: ['./prisma/schema.prisma'], load: mocks.load }, output: join(dir, 'generated', 'contract.json'), }, ...overrides, @@ -214,23 +214,30 @@ describe('contract print', () => { label: 'Point contract in prisma.config.ts at generated/contract.prisma', }, { kind: 'run-command', label: 'Emit the printed contract', command: '{bin} contract emit' }, - { - kind: 'run-command', - label: 'Plan the baseline migration', - command: '{bin} migration plan --name baseline', - }, - { kind: 'run-command', label: 'Sign the database', command: '{bin} db sign' }, - { - kind: 'run-command', - label: 'Point the db ref at the baseline migration', - command: '{bin} migration ref set db _baseline', - }, ]); expect(run.presented?.presentation.stdout).toEqual([]); expect(stripAnsi(run.stderr)).toContain('Contract written to generated/contract.prisma'); expect(run.stdout).toBe(''); }); + it('names the default control policy the config must set, because a PSL file cannot carry it', async () => { + const dir = await projectDir(); + mocks.load.mockResolvedValue(ok({ roots: {}, domain: {}, defaultControlPolicy: 'external' })); + + const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ defaultControlPolicy: 'external' }); + expect(run.presented?.presentation.next).toEqual([ + { + kind: 'user-choice', + label: + "Point contract in prisma.config.ts at generated/contract.prisma, with defaultControlPolicy 'external' on its source", + }, + { kind: 'run-command', label: 'Emit the printed contract', command: '{bin} contract emit' }, + ]); + }); + 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'; @@ -255,7 +262,7 @@ describe('contract print', () => { const dir = await projectDir(); const config = ormConfig(dir, { contract: { - source: { format: 'typescript', inputs: ['./prisma'], load: mocks.load }, + source: { format: 'psl', inputs: ['./prisma'], load: mocks.load }, output: join(dir, 'generated', 'contract.json'), }, }); @@ -310,7 +317,7 @@ describe('contract print', () => { expect(await readdir(dir)).not.toContain('generated'); }); - it('errors when the target cannot print the contract as PSL', async () => { + it('errors when the family cannot print the contract as PSL', async () => { const dir = await projectDir(); mocks.printPslContract.mockReturnValue(undefined); @@ -321,16 +328,16 @@ describe('contract print', () => { expect(mocks.close).toHaveBeenCalled(); }); - it('writes no file at the output path when the target refuses a column', async () => { + it('writes no file at the output path when the target refuses part of the contract', async () => { const dir = await projectDir(); mocks.printPslContract.mockImplementation(() => { throw structuredError( 'CONTRACT.PRINT_UNSUPPORTED', - 'contract print: column "public"."Defaults"."jsonLiteral" has a literal default that cannot be written in Prisma 8 PSL.', + 'contract print: field "public".Shop.location has a union type, which cannot be written in Prisma 8 PSL.', { - why: 'The PSL source would read a quoted default back as a string.', - fix: 'Replace the literal default with a database expression default.', - meta: { namespaceId: 'public', table: 'Defaults', column: 'jsonLiteral' }, + 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' }, }, ); }); @@ -343,7 +350,7 @@ describe('contract print', () => { expect(run.exitCode).toBe(2); expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.PRINT_UNSUPPORTED', - summary: expect.stringContaining('"public"."Defaults"."jsonLiteral"'), + summary: expect.stringContaining('"public".Shop.location'), }); expect(await readdir(dir)).not.toContain('contract.prisma'); }); @@ -353,11 +360,11 @@ describe('contract print', () => { mocks.printPslContract.mockImplementation(() => { throw structuredError( 'CONTRACT.PRINT_UNSUPPORTED', - 'contract print: column "public"."Defaults"."jsonLiteral" has a literal default that cannot be written in Prisma 8 PSL.', + 'contract print: field "public".Shop.location has a union type, which cannot be written in Prisma 8 PSL.', { - why: 'The PSL source would read the written value back as a string rather than as the value the column defaults to.', - fix: 'Replace the literal default with a database expression default, or drop the default before converting.', - meta: { namespaceId: 'public', table: 'Defaults', column: 'jsonLiteral' }, + 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' }, }, ); }); @@ -367,16 +374,10 @@ describe('contract print', () => { expect(run.exitCode).toBe(2); expect(erroredEnvelope(run).error).toMatchObject({ code: 'CONTRACT.PRINT_UNSUPPORTED', - summary: expect.stringContaining('"public"."Defaults"."jsonLiteral"'), - why: 'The PSL source would read the written value back as a string rather than as the value the column defaults to.', - nextActions: [ - { - kind: 'user-choice', - label: - 'Replace the literal default with a database expression default, or drop the default before converting.', - }, - ], - meta: { namespaceId: 'public', table: 'Defaults', column: 'jsonLiteral' }, + 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/format.test.ts b/packages/1-framework/3-tooling/cli/test/orm/format.test.ts index 840813f575d7..e26bc9591fa5 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/format.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/format.test.ts @@ -59,12 +59,7 @@ function ormConfig(source: Record): Record { } function pslConfig(inputPath: string): Record { - return ormConfig({ - format: 'psl', - inputs: [inputPath], - load: async () => ({}), - interpret: () => ({}), - }); + return ormConfig({ format: 'psl', inputs: [inputPath], load: async () => ({}) }); } function harness(config: Record) { diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/convert-print-constraints.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/print-constraints.test.ts similarity index 100% rename from packages/2-sql/2-authoring/contract-prisma7/test/convert-print-constraints.test.ts rename to packages/2-sql/2-authoring/contract-prisma7/test/print-constraints.test.ts diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/print-roundtrip.test.ts similarity index 100% rename from packages/2-sql/2-authoring/contract-prisma7/test/convert-roundtrip.test.ts rename to packages/2-sql/2-authoring/contract-prisma7/test/print-roundtrip.test.ts 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 4145be4c1686..7ee0c8c776b9 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 @@ -48,7 +48,7 @@ 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: 'psl', inputs: ['prisma/schema.prisma'] }, }); diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/support.ts b/packages/2-sql/2-authoring/contract-prisma7/test/support.ts index 48bc67fe11b7..417cd226ef44 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/support.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/support.ts @@ -70,11 +70,13 @@ export function loadFixtureSchema(caseName: string, contextFor = postgresSourceC /** Prints a contract as Prisma 8 PSL text, the way `contract print` does. */ export function printContractAsPsl(contract: Contract): string { - const ast = postgres.printPslContract?.(contract); + const context = postgresSourceContext([]); + const ast = postgres.printPslContract?.(contract, { + authoringTypes: context.authoringContributions.type, + }); if (ast === undefined) { throw new Error('the Postgres target descriptor has no printPslContract hook'); } - const context = postgresSourceContext([]); return printPsl(ast, { pslBlockDescriptors: context.authoringContributions.pslBlockDescriptors, codecLookup: context.codecLookup, 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 e54e2304862d..ea1076df7ad1 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 @@ -363,10 +363,10 @@ function relationsForModel( /** * A set of columns is unique when it exactly matches one of the model's unique * column sets — its primary key, any single- or multi-column `@unique` / - * `@@unique` constraint, or any unique index. 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. + * `@@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/9-family/README.md b/packages/2-sql/9-family/README.md index 2a378bfb46ad..12bb24de5a8e 100644 --- a/packages/2-sql/9-family/README.md +++ b/packages/2-sql/9-family/README.md @@ -104,7 +104,7 @@ Family instances implement domain actions: - **`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. -- **`printPslContract(contract)`**: Prints an assembled contract as the PSL document that reads back as the same contract, for `contract print`. Delegates to the target descriptor's optional `printPslContract` hook; throws `CONTRACT.PRINT_UNSUPPORTED` when the target has none. +- **`printPslContract(contract)`**: Builds the PSL document AST that reads back as the same contract, for `contract print`. Delegates to the target descriptor's optional `printPslContract` hook. Throws `CONTRACT.PRINT_UNSUPPORTED` when the target has no hook, or when the contract holds something PSL cannot express. The descriptor is "pure data + factory" - it only provides the hook and factory method. All family-specific logic lives on the instance. 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 b3d101fc1d37..39e139605d9f 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -586,8 +586,9 @@ export function createSqlFamilyInstance( SqlControlTargetDescriptor, 'reading the optional target-descriptor inferPslContract hook' >(target).inferPslContract; - // Contract→PSL printing is target logic for the same reason, and is read the - // same way. Absent for targets without `contract print`. + // Contract→PSL printing is also target logic (it owns the dialect type and + // default maps), so it is read off the descriptor the same way. Absent for + // targets without `contract print`. const targetPrintPslContract = blindCast< SqlControlTargetDescriptor, 'reading the optional target-descriptor printPslContract hook' @@ -1030,12 +1031,14 @@ export function createSqlFamilyInstance( `Target "${target.targetId}" does not support contract print (no printPslContract on its descriptor).`, { why: 'The target descriptor does not provide the printPslContract hook, so the contract cannot be printed as a Prisma 8 PSL file.', - fix: 'Use a target package that supports contract print, or author the contract instead of converting it.', + fix: 'Use a target whose descriptor provides printPslContract, or write the Prisma 8 PSL file by hand.', meta: { targetId: target.targetId }, }, ); } - return targetPrintPslContract(contract); + return targetPrintPslContract(contract, { + authoringTypes: stack.authoringContributions.type, + }); }, lowerAst( 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 2d2f4787dd8c..2e3efbf14b66 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,4 +1,5 @@ import type { Contract } from '@internal/contract/types'; +import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; import type { ContractSerializer, DiffSubjectGranularity, @@ -63,14 +64,18 @@ export interface SqlControlTargetDescriptor< describedContracts?: readonly SqlDescribedContractSpace[], ) => PslDocumentAst; /** - * Contract→PSL printing for `contract print`. The reverse of - * {@link inferPslContract}: it takes an already-assembled family contract and - * returns the PSL document that reads back as the same contract. Target logic - * for the same reason — it owns the dialect maps — so it lives beside its - * sibling on the descriptor. Optional: targets without `contract print` - * omit it, and the family instance throws when it is absent. + * Contract→PSL printing for `contract print`. Like {@link inferPslContract} + * it produces a PSL document, but from an assembled contract instead of a + * database schema. The document reads back as the same contract; the hook + * throws `CONTRACT.PRINT_UNSUPPORTED` for a contract PSL cannot express. + * Target logic, because it owns the dialect maps, so it lives on the + * descriptor. Optional: targets without `contract print` omit it, and the + * family instance throws when it is absent. */ - readonly printPslContract?: (contract: TContract) => PslDocumentAst; + readonly printPslContract?: ( + contract: TContract, + context: { readonly authoringTypes: AuthoringTypeNamespace }, + ) => 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/3-extensions/postgres/README.md b/packages/3-extensions/postgres/README.md index f1dbe0ad1252..c43706e1d4ad 100644 --- a/packages/3-extensions/postgres/README.md +++ b/packages/3-extensions/postgres/README.md @@ -99,7 +99,7 @@ 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` writes the Prisma 8 PSL that produces the same contract, with a header naming the schema it came from. 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`. 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 converts yet: a construct a Prisma 8 schema cannot express, a JSON object literal default above all, exits `2` with `CONTRACT.PRINT_UNSUPPORTED` and writes nothing. +When the project is ready to stop reading the Prisma 7 file, `prisma contract print` writes the Prisma 8 PSL that produces the same contract, with a header naming the schema it came from. 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`. 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 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: diff --git a/packages/3-targets/3-targets/postgres/README.md b/packages/3-targets/3-targets/postgres/README.md index d4d6253d9adc..969a83f6a707 100644 --- a/packages/3-targets/3-targets/postgres/README.md +++ b/packages/3-targets/3-targets/postgres/README.md @@ -25,7 +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 `printPslContract` descriptor hook, which `prisma contract print` uses: it takes an assembled Postgres contract, from any source, and returns the Prisma 8 PSL document that reads back as that same contract: models, value objects, named types, domain and native enums, keys, indexes, checks, relations, polymorphism, control policies and execution defaults. Anything the PSL language has no form for is refused by name with `CONTRACT.PRINT_UNSUPPORTED`, never dropped. It lives in `src/core/psl-print/` and is the reverse of the `inferPslContract` hook in `src/core/psl-infer/`, whose literal, index and enum-block builders it reuses. Model and field names come from the contract, never re-derived; `@@map` and `@map` appear only where the name the PSL source would derive differs. Uniques print as unique indexes, because that is what a Prisma 7 schema lowers them to, and every index prints explicitly, so each relation declines a backing index of its own. 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. +- **Contract-to-PSL Printing**: Implements the `printPslContract` descriptor hook, which `prisma contract print` uses. It takes an assembled Postgres contract, from any source, and returns the Prisma 8 PSL document that reads back as the same contract. 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 the PSL type that reads back with its codec: the native type's usual type, or else a type constructor the configured stack contributes, such as `TimestamptzString(3)` or an extension's `pgvector.Vector(3)`; the family passes the stack's 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`); 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; it reuses that hook's literal, index, enum-block and default-mapping builders. 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/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..80127260c540 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 @@ -42,6 +42,12 @@ export const CODEC_ID_BY_PRINTED_TYPE: ReadonlyMap = new Map([ ['Char', 'sql/char@1'], ]); +/** The data type a codec represents; an enum column's default is a member name, so it reads as text. */ +export function dataTypeForCodec(codecId: string, isEnum: boolean): DataTypeId | undefined { + return postgresCodecDescriptorRegistry.descriptorFor(isEnum ? PG_TEXT_CODEC_ID : codecId) + ?.dataType; +} + /** * The data type a column of `pslTypeName` holds values of, which is the one its codec represents. * An enum column's default is a member name, which is text either way, so it reads through the text 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 index 787bbe2cd288fed2862c48cb40006ca1616eb9f8..5acf2d8eb783fa15fd25c440eca695da224fd0a2 100644 GIT binary patch delta 338 zcmZus%SyvQ6h#aQMO^7pID*g;)VOg|h_>1;3K|Qw8xh-aGQq**PMDd{f{4E$bma%Q z7Re8}^dnq2(V{NAtNS>w`?C48^%%GAFpQ4QhEZn}wNJZ)e*3sP>UD6BM5_$-mLw}Q zu{20D%fv+K+a0d5T#+Da0;yjw-WDGM-^cll067R4xFV|=w_mf#85fWS6D_S3go0wB zAqoA#NfXx53c6Nq3zI5aA;*=@H*bC#@4^piVwx*$krc$ORD|55QH5NqEUK>YL+r(~ zskd?PXGR!?|NnZ3`rM|D=5J;vvG1v+%VN(}`RrZQX79@@>xh*wAeS^2JY`yJL+SEk HwOsfF)G>9x delta 26 icmbQKvR-jQ%Eooqm?xLBW-&5se$E=lwK-Q{4I==P#0nSy diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts index a26246b08209..1ca4c221d296 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts @@ -3,7 +3,7 @@ import { mapDefault } from '@internal/family-sql/psl-infer'; import type { PslFieldAttribute } from '@internal/framework-components/psl-ast'; import type { StorageColumn } from '@internal/sql-contract/types'; import { postgresError } from '../errors'; -import { dataTypeForPrintedType } from '../psl-infer/infer-default-codec'; +import { dataTypeForCodec } from '../psl-infer/infer-default-codec'; import { createPostgresDefaultMapping } from '../psl-infer/postgres-default-mapping'; import { buildAttribute, @@ -17,13 +17,13 @@ const defaultMapping = createPostgresDefaultMapping(); * The `@default(…)` attribute for a storage column, or `undefined` when the * column carries no default. * - * A literal prints as the PSL literal the column's data type reads back, the - * same mapping `contract infer` uses; 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 prints as the PSL literal the data type of the column's codec reads + * back, the same mapping `contract infer` uses; 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 no data type of the column writes is refused, because every - * literal that would parse reads back as a different value. + * A literal is refused when the column's codec has no Postgres data type, or + * when no PSL literal of that data type reads back as the stored value. */ export function printColumnDefault(input: { readonly column: StorageColumn; @@ -61,7 +61,7 @@ export function printColumnDefault(input: { const result = mapDefault(columnDefault, { ...defaultMapping, - columnDataType: dataTypeForPrintedType(input.pslTypeName, input.isEnum), + columnDataType: dataTypeForCodec(input.column.codecId, input.isEnum), list: input.column.many === true, }); if (result === undefined) { diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts index e3dccf099789..1ba023dacd23 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts @@ -1,7 +1,15 @@ import type { PslTypeMap } from '@internal/family-sql/psl-infer'; +import { + type AuthoringTypeConstructorDescriptor, + type AuthoringTypeNamespace, + instantiateAuthoringTypeConstructor, + isAuthoringArgRef, + isAuthoringTypeConstructorDescriptor, +} from '@internal/framework-components/authoring'; import type { PslTypeConstructorCall } from '@internal/framework-components/psl-ast'; import type { StorageColumn } from '@internal/sql-contract/types'; import { postgresError } from '../errors'; +import { CODEC_ID_BY_PRINTED_TYPE } from '../psl-infer/infer-default-codec'; import { positionalArg, SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; /** The PSL type position of one column: a bare name, or a type-constructor call. */ @@ -27,15 +35,91 @@ function nativeTypeText(column: StorageColumn): string { return args.length === 0 ? column.nativeType : `${column.nativeType}(${args.join(', ')})`; } +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 sameTypeParams( + left: Readonly> | undefined, + right: Readonly> | undefined, +): boolean { + return JSON.stringify(left ?? {}) === JSON.stringify(right ?? {}); +} + /** - * The PSL type position for a storage column, from the reverse of the type map - * `contract infer` uses. 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. + * The positional arguments that make `descriptor` produce exactly the column's + * codec, native type and type parameters, when each parameter comes straight + * from one argument; `undefined` when the constructor cannot produce them. + */ +function argumentsFor( + descriptor: AuthoringTypeConstructorDescriptor, + column: StorageColumn, +): readonly unknown[] | undefined { + if (descriptor.entityRefArg !== undefined || descriptor.output.codecId !== column.codecId) { + return undefined; + } + const args: unknown[] = []; + for (const [key, template] of Object.entries(descriptor.output.typeParams ?? {})) { + const value = column.typeParams?.[key]; + if (value !== undefined && isAuthoringArgRef(template) && template.path === undefined) { + args[template.index] = value; + } + } + if (args.some((value) => value === undefined)) return undefined; + try { + const produced = instantiateAuthoringTypeConstructor(descriptor, args); + return produced.nativeType === column.nativeType && + sameTypeParams(produced.typeParams, column.typeParams) + ? args + : undefined; + } catch { + return undefined; + } +} + +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 PSL type position for a storage column. The native type's default PSL + * type, from the type map `contract infer` uses, is written when it reads back + * with the column's codec. Otherwise the first type constructor the configured + * stack contributes that produces exactly the column's codec, native type and + * type parameters is written, 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 printColumnType(input: { readonly column: StorageColumn; readonly typeMap: PslTypeMap; + readonly authoringTypes: AuthoringTypeNamespace; readonly enumBlockNames: ReadonlyMap; readonly coordinate: string; }): PslColumnType { @@ -60,29 +144,30 @@ export function printColumnType(input: { } const resolution = typeMap.resolve(nativeTypeText(column)); - if ('unsupported' in resolution) { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: column ${coordinate} has native type "${column.nativeType}", which cannot be written in Prisma 8 PSL.`, - { - why: 'The Postgres contract-to-PSL printer maps each native type back to the PSL type that produces it; this one is not in that map.', - fix: 'Retype the column, or author the Prisma 8 contract by hand.', - meta: { coordinate, nativeType: column.nativeType }, - }, - ); + if ( + !('unsupported' in resolution) && + CODEC_ID_BY_PRINTED_TYPE.get(resolution.pslType.name) === column.codecId + ) { + return typeCall([resolution.pslType.name], resolution.pslType.args ?? []); } - const { name, args } = resolution.pslType; - if (args === undefined) { - return { typeName: name }; + for (const { path, descriptor } of typeConstructors(input.authoringTypes)) { + const args = argumentsFor(descriptor, column); + if (args !== undefined) { + return typeCall( + path, + args.map((value) => (typeof value === 'string' ? JSON.stringify(value) : String(value))), + ); + } } - return { - typeName: name, - typeConstructor: { - kind: 'typeConstructor', - path: [name], - args: args.map(positionalArg), - span: SYNTHETIC_SPAN, + + throw postgresError( + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: column ${coordinate} has native type "${column.nativeType}" with codec "${column.codecId}", and no PSL type in the configured stack produces that pair.`, + { + why: '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.', + fix: 'Add the extension that contributes this type to the config, or keep authoring this contract in its current source.', + meta: { coordinate, nativeType: column.nativeType, codecId: column.codecId }, }, - }; + ); } diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts index c959c293b691..55ae703266ea 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts @@ -1,5 +1,6 @@ import type { Contract, ContractField } from '@internal/contract/types'; import type { PslTypeMap } from '@internal/family-sql/psl-infer'; +import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; import type { PslCompositeType, PslField, @@ -14,6 +15,7 @@ import type { JsonValue } from '@internal/utils/json'; import type { AnyPostgresCodecDescriptor } from '../codec-descriptor'; import { codecDescriptorMap } from '../codec-type-map'; import { postgresError } from '../errors'; +import { DEFAULT_NAMESPACE_ID } from '../namespace-ids'; import { SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; import { type PslColumnType, printColumnType } from './print-column-type'; @@ -33,7 +35,7 @@ function columnForDomainField(field: ContractField, coordinate: string): Storage `contract print: field ${coordinate} has a ${field.type.kind} 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, or author the Prisma 8 contract by hand.', + fix: 'Give the field a single type, or keep authoring this contract in its current source.', meta: { coordinate, kind: field.type.kind }, }, ); @@ -45,7 +47,7 @@ function columnForDomainField(field: ContractField, coordinate: string): Storage `contract print: field ${coordinate} uses codec "${field.type.codecId}", which the Postgres printer cannot name a native type for.`, { why: 'A value-object field has no storage column, so its PSL type is derived from its codec; this codec is not one the Postgres target owns.', - fix: 'Author the Prisma 8 contract by hand for this value object.', + fix: 'Keep authoring this contract in its current source.', meta: { coordinate, codecId: field.type.codecId }, }, ); @@ -73,7 +75,7 @@ function refuseDictField(field: ContractField, coordinate: string): void { `contract print: field ${coordinate} is a dictionary, which cannot be written in Prisma 8 PSL.`, { why: 'PSL writes a field as one value or a list; it has no form for a keyed dictionary.', - fix: 'Author the Prisma 8 contract by hand for this field.', + fix: 'Keep authoring this contract in its current source.', meta: { coordinate }, }, ); @@ -84,6 +86,7 @@ export function printDomainFieldType(input: { readonly field: ContractField; readonly coordinate: string; readonly typeMap: PslTypeMap; + readonly authoringTypes: AuthoringTypeNamespace; readonly enumBlockNames: ReadonlyMap; }): PslColumnType { const { field, coordinate } = input; @@ -94,6 +97,7 @@ export function printDomainFieldType(input: { return printColumnType({ column: columnForDomainField(field, coordinate), typeMap: input.typeMap, + authoringTypes: input.authoringTypes, enumBlockNames: input.enumBlockNames, coordinate, }); @@ -104,9 +108,22 @@ export function buildCompositeTypes(input: { readonly contract: Contract; readonly namespaceId: string; readonly typeMap: PslTypeMap; + readonly authoringTypes: AuthoringTypeNamespace; readonly enumBlockNames: ReadonlyMap; }): readonly PslCompositeType[] { const valueObjects = input.contract.domain.namespaces[input.namespaceId]?.valueObjects ?? {}; + const names = Object.keys(valueObjects); + if (input.namespaceId !== DEFAULT_NAMESPACE_ID && names.length > 0) { + throw postgresError( + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: namespace "${input.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.`, + { + why: 'A `type` block written in any namespace reads back into the default namespace, so the value object would move.', + fix: 'Move the value object to the default namespace, or keep authoring this contract in its current source.', + meta: { namespaceId: input.namespaceId, names }, + }, + ); + } return Object.entries(valueObjects).map(([name, valueObject]) => ({ kind: 'compositeType', name, @@ -115,6 +132,7 @@ export function buildCompositeTypes(input: { field, coordinate: `"${input.namespaceId}".${name}.${fieldName}`, typeMap: input.typeMap, + authoringTypes: input.authoringTypes, enumBlockNames: input.enumBlockNames, }); return { @@ -137,6 +155,7 @@ export function buildCompositeTypes(input: { export function buildTypesBlock( contract: Contract, typeMap: PslTypeMap, + authoringTypes: AuthoringTypeNamespace, ): PslTypesBlock | undefined { const declarations: PslNamedTypeDeclaration[] = []; for (const [name, instance] of Object.entries(contract.storage.types ?? {})) { @@ -148,6 +167,7 @@ export function buildTypesBlock( ...ifDefined('typeParams', instance.typeParams), }), typeMap, + authoringTypes, enumBlockNames: new Map(), coordinate: `types.${name}`, }); diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts index 3ad3d30bebc8..ab202bef3d5b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts @@ -61,16 +61,14 @@ function sameValues(left: readonly unknown[], right: readonly unknown[]): boolea /** * Builds one `native_enum` block per enum type a namespace declares. * - * The block's name is the name of the value set the enum derives, never the - * physical type name: 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, so the value - * set is the only place the authored name survives. A column typed by the enum - * names its value set directly. An enum no column refers to is matched to the - * one unclaimed value set that holds exactly its members, in order; if no value - * set matches, or more than one does, the block name is derived from the - * physical type name the way `contract infer` derives one, and kept apart from - * every other block name in the namespace. `@@map` carries the physical type - * name whenever the two differ. + * 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, so the block is named after + * that value set when one is found: 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 the only such value + * set. If none is found, the block name is derived from the type name the way + * `contract infer` derives one, kept apart from every other block name in the + * namespace. `@@map` carries the type name whenever the two differ. */ export function buildNativeEnumBlocksForNamespace(input: { readonly namespaceId: string; diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts index 89eacae0eebd..80ddf9e2d72b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts @@ -74,7 +74,7 @@ export function printExecutionDefault(input: { `contract print: column ${coordinate} takes the wall-clock-now generator in one phase and "${onCreate ? onUpdateId : onCreateId}" in the other, which cannot be written in Prisma 8 PSL.`, { why: '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.', - fix: 'Author the Prisma 8 contract by hand for this column.', + fix: 'Keep authoring this contract in its current source.', meta: { coordinate, onCreate: onCreateId, onUpdate: onUpdateId }, }, ); @@ -83,14 +83,25 @@ export function printExecutionDefault(input: { } } + if (onUpdateId !== undefined) { + throw postgresError( + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: column ${coordinate} generates a value on update ("${onUpdateId}"), which cannot be written in Prisma 8 PSL.`, + { + why: '`@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.', + fix: 'Keep authoring this contract in its current source.', + meta: { coordinate, onCreate: onCreateId, onUpdate: onUpdateId }, + }, + ); + } const call = onCreateId === undefined ? undefined : GENERATOR_CALLS.get(onCreateId); - if (call === undefined || onUpdateId !== undefined) { + if (call === undefined) { throw postgresError( 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: column ${coordinate} carries generated value "${onCreateId ?? onUpdateId}", which cannot be written in Prisma 8 PSL.`, + `contract print: column ${coordinate} is generated by "${onCreateId}", which has no PSL default function.`, { - why: 'The Postgres contract-to-PSL printer knows one way to write each generator; this generator is not among them.', - fix: 'Author the Prisma 8 contract by hand for this column.', + why: 'The printer writes each generator as the PSL default function that produces it, and this generator has none.', + fix: 'Keep authoring this contract in its current source.', meta: { coordinate, onCreate: onCreateId, onUpdate: onUpdateId }, }, ); diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts index cd08b99c2e9560d3bc0b3925dc4150175f9508e6..659ba5b664768fdf7c3c95534e2c5cc850c039f8 100644 GIT binary patch delta 3179 zcmbVOO>7fK6qfCP5*LUdgd|X`S3|KKoDGFbWe5ZakcvRk5DBPB@!C6%w^{F)on4HT zny6|oRWFrB?Uhzlg=nR=bSn<1t<+Ob6B%$s@d zd*A!H^Dy%5pOFvm4#088u24%i7v>r@a$c65N*6Zf^_ z)&hTj!|mQ0GlKKDaQAQgHF{C6)~IE}5)>WX&}OKiml`JwT3TdTPc%yn;tMQ;oif#; zL9{vXmq8QC7Og@`8Lgp7_6QyrDGF`=Z}*v$Mam8%S{CXRy;LFBi3KH!@0rB3K_g|| z#4B{07kZ`!vyejN6#lD88^p@Or`<1mMk0wT&H?=GT|z~xq2RrWEevRSa`#ERFM4p1 zkQxN;gI&=XK8Ffw$p+oThw$66Eb%y_j#VP=p3TQ1{gz%;t;Si>5a8s1*#cbtc-xSx zZrKyz_lI{Ji9_p`D(l41QrWgZ4}a-q&9N1PB6K7FAu)9I806;z9e^C<{m7(KRkeoB z7Lw>4E2%Xy!;A~khV)w3Wtz!CX5Kd-<1O6JdmqL21-=Vru-tk~VSS%$S$Wjmx_v6v zA>)q+CizDLS5}LEKHu`B;KxJwB3byF){2O~Bcmj64df+iI8{@M3N7P*A3VAty#liw zc!8GcAv}*MrrVo15l;YEMAfKiG@#AN926-sMm62(^j9$q+$J8)4($kVPlaQ}A-hNM zVTASHL)-mReCx&t?swlf z*d19Z;!B4nH~WWZ-6R&jc4*N3<`)L`bBURPQK5V^G!LCTGhPP#fo(ZzRfx4jTrkkD2bv<8z`gme`lY3|C$H;O)2D~5x!K7`TC$eyP zOSGO;i$v3ih7~Les;R++*QX@T6_tTWfi+B9y#Zxv;Vy_PH7}YhBnX!*S|2e`(*Q=WTI+TLb5ph)Y)7{6SVAM2i>+hy{Hx2et5p!VI_laOH@vej zK8|Tw0O}FL!Sz$Ec zVH7Ga8m(dupOVnU`NM(T!<{y?>{M3Dg$LXi8jLf)A%J_c(CFHa%_x|Qa$@Zns34uw zp*|+kgFn%3ujjD>{;8+QU3Ckt@~l$zCg=A5|{vEV`FU+^Q%{PZIqW}_)qGNWgn;v zHg0s{u)IfPQ5t<_c3Tl>xeZ(<=mw(Cu7aTLKU$Bk2OejTRbt?r^()rQ>XH>IRT^1bA+a%cQ}eBf4*-va&Gn$3=UwGrc6SW=BwVvI{I~LboVM2? I-Ny_3Kgh*F2mk;8 delta 1217 zcmb7@$!ior6voq&#bgvioWv!fJmbi?3=`u*ByI%bf?MK>xL|6!E15PuRby9mW(JX5 z1wAP5BnYBj6!f4ac=2Bl1L8qU4x+bs@#aZ=-JM8Wz&Mwgp00Y|_j~Vq&pug?#;u!A zO_McIhhOR+n1>DH34GJEX9W|$E1C=CN|8~(gafVupAQ9!JGe7>*xXNMt=5|K6)v=2 z&C#2jW_gcatudeHrxIxv=z{b?7dQ;~t|~Iv2DYTL%+`>vLV-$}u7$fTbGNy}!v4+m zSW2}vx=tnw=Ps->*IVA#;_WsxI~E_T#nbKEaAMhc`hH;z;=N<79Y2*L)uXIP%IPb% z^n#*@o(9di<-6+5YuJ)Bt2aEZC5Z-1*Dq;Un+ZiQ*|{2TbR}_W<9svP`8t75QYkaO zX#{*BnSM%!f6Z z155!O$6I^aOyxj-Vmq&tNK5sAtN&LNzaLyVmO1$s#n@O`2A%^m!g*$oOsmHZL(9?V zX8;wQ)n(2__-_&7>(h+@9iJ`xl8~Zts$3?U#0Q7gE~0xw(&u4xM!ByE+DsnmshisX z#FykQW)T<>)UBw;S}TX zINbIa0f3Nk)zC3SpCPx>1DW1g`QI>;g5MVah>aA;P*gRd?jKl&NA|bk*W<^{@TtDU zSVu4IqR_6c!iY?xS;M#?VZ8~=)%9G|k zI5J|=Fd}3**Fj8uF%|rY^$#kpcF5D|K3%Cf1Dwp@9J)-cVe1#uG3g4EV;oZyl13){nt^fc4 diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts index 5c483b868ed1..08eb9e176311 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts @@ -5,7 +5,12 @@ import { escapePslString } from '@internal/sql-relational-core/ast'; import { ifDefined } from '@internal/utils/defined'; import { postgresError } from '../errors'; import { buildAttribute, namedArg, SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; -import { crossReferenceCoordinate, type ModelEntry, modelCoordinate } from './contract-model-index'; +import { + crossReferenceCoordinate, + type ModelEntry, + modelCoordinate, + pslNamespaceName, +} from './contract-model-index'; const PSL_REFERENTIAL_ACTIONS: Readonly> = { noAction: 'NoAction', @@ -149,7 +154,9 @@ export function printRelationField(input: { const { entry, target, name } = input; const { relation } = entry; const typeNamespaceId = - target.namespaceId === entry.owner.namespaceId ? undefined : target.namespaceId; + target.namespaceId === entry.owner.namespaceId + ? undefined + : pslNamespaceName(target.namespaceId); const args: PslAttributeArgument[] = []; if (name !== undefined) { @@ -167,7 +174,7 @@ export function printRelationField(input: { `contract print: relation "${entry.owner.name}.${entry.fieldName}" has no foreign key in storage, which Prisma 8 PSL cannot express.`, { why: 'A to-one relation is authored as `@relation(fields:…, references:…)`, which always lowers to a foreign key.', - fix: 'Author the Prisma 8 contract by hand for this relation.', + fix: 'Declare a foreign key for the relation, or keep authoring this contract in its current source.', meta: { model: entry.owner.name, field: entry.fieldName }, }, ); diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-rls-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-rls-blocks.ts new file mode 100644 index 000000000000..5e9bcd3073e3 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-rls-blocks.ts @@ -0,0 +1,155 @@ +import type { PslExtensionBlock } from '@internal/framework-components/psl-ast'; +import { escapePslString } from '@internal/sql-relational-core/ast'; +import { postgresError } from '../errors'; +import { PostgresRlsEnablement } from '../postgres-rls-enablement'; +import { PostgresRlsPolicy } from '../postgres-rls-policy'; +import { PostgresRole } from '../postgres-role'; +import { SYNTHETIC_SPAN } from '../psl-infer/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; + +function invalidEntry(namespaceId: string, kind: string, name: string): never { + throw postgresError( + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: namespace "${namespaceId}" has a "${kind}" entry "${name}" that is not a ${kind} entity.`, + { + why: 'The printer reads each storage entry through the entity class of its kind.', + fix: 'Re-emit the contract from its source.', + meta: { namespaceId, kind, name }, + }, + ); +} + +/** The storage tables of one namespace that have row-level security enabled, each written as `@@rls` on its model. */ +export function rlsEnabledTables( + namespaceId: string, + entries: Readonly> | undefined, +): ReadonlySet { + const tables = new Set(); + for (const [name, entity] of Object.entries(entries ?? {})) { + if (!(entity instanceof PostgresRlsEnablement)) invalidEntry(namespaceId, 'rls', name); + tables.add(entity.tableName); + } + return tables; +} + +/** One `role {}` block per role; they print inside `namespace unbound`. */ +export function buildRoleBlocks( + namespaceId: string, + entries: Readonly> | undefined, +): readonly PslExtensionBlock[] { + return Object.entries(entries ?? {}).map(([name, entity]): PslExtensionBlock => { + if (!(entity instanceof PostgresRole)) invalidEntry(namespaceId, 'role', name); + return { + kind: 'role', + keyword: 'role', + name: entity.name, + parameters: {}, + blockAttributes: [], + attributes: {}, + span: SYNTHETIC_SPAN, + }; + }); +} + +function refuseUnwritablePolicy(policy: PostgresRlsPolicy, why: string, fix: string): never { + throw postgresError( + 'CONTRACT.PRINT_UNSUPPORTED', + `contract print: policy "${policy.name}" on "${policy.namespaceId}"."${policy.tableName}" cannot be written in Prisma 8 PSL.`, + { why, fix, meta: { namespaceId: policy.namespaceId, table: policy.tableName } }, + ); +} + +/** + * 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 PslExtensionBlock[] { + return Object.entries(input.entries ?? {}).map(([head, policy]): PslExtensionBlock => { + if (!(policy instanceof PostgresRlsPolicy)) invalidEntry(input.namespaceId, 'policy', head); + const modelName = input.modelNameForTable(policy.tableName); + if (modelName === undefined) { + refuseUnwritablePolicy( + 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.', + ); + } + const badRole = policy.roles.find((role) => !PSL_IDENTIFIER.test(role)); + if (badRole !== undefined) { + refuseUnwritablePolicy( + policy, + `A policy names its roles by identifier, and role "${badRole}" is not a PSL identifier.`, + 'Rename the role to a plain identifier, or keep authoring this contract in its current source.', + ); + } + + return { + kind: 'policy', + keyword: POLICY_OPERATION_KEYWORD[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/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index 2f1c3f761a58..fac1c0319c1d 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -44,8 +44,8 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP PostgresDatabaseSchemaNode.assert(schema); return inferPostgresPslContract(schema, describedContracts); }, - printPslContract(contract) { - return printPostgresPslContract(contract); + printPslContract(contract, context) { + return printPostgresPslContract(contract, context); }, diffSchema(input) { return diffPostgresSchema(input); diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts index 71ab09d43057..a1b5fdca6cab 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts @@ -17,6 +17,7 @@ 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 { postgresAuthoringTypes } from '../../src/core/authoring'; import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; import { printPostgresPslContract } from '../../src/core/psl-print/print-psl-contract'; @@ -86,6 +87,7 @@ function print(input: { const contract = new PostgresContractSerializer().deserializeContract(json); const ast = printPostgresPslContract( blindCast, 'the Postgres serializer yields a SQL contract'>(contract), + { authoringTypes: postgresAuthoringTypes }, ); return ast.namespaces.flatMap((namespace) => namespace.models); } @@ -188,7 +190,7 @@ describe('keys and indexes', () => { ]); }); - it('prints every check constraint the table carries', () => { + it('prints each check constraint the PSL source does not derive', () => { const [model] = print({ models: { Widget: { table: 'Widget', fields: { id: { column: 'id' } } } }, tables: { @@ -301,7 +303,7 @@ describe('column defaults', () => { }); describe('list columns', () => { - it('prints the element-not-null check a list column carries', () => { + 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' } }, @@ -598,6 +600,7 @@ describe('native enum blocks', () => { const contract = new PostgresContractSerializer().deserializeContract(json); const ast = printPostgresPslContract( blindCast, 'the Postgres serializer yields a SQL contract'>(contract), + { authoringTypes: postgresAuthoringTypes }, ); return ast.namespaces .flatMap((namespace) => namespacePslExtensionBlocks(namespace)) @@ -685,3 +688,96 @@ describe('native enum blocks', () => { ]); }); }); + +describe('parts of a contract PSL cannot carry', () => { + function printContract(overrides: Parameters[0]) { + const contract = new PostgresContractSerializer().deserializeContract( + createSqlContract(overrides), + ); + return () => + printPostgresPslContract( + blindCast, 'the Postgres serializer yields a SQL contract'>(contract), + { authoringTypes: postgresAuthoringTypes }, + ); + } + + it('refuses a value object outside the default namespace, which the PSL source would move', () => { + const print = printContract({ + namespaces: { + public: { models: {} }, + auth: { + models: {}, + valueObjects: { Address: { fields: { street: INT_FIELD } } }, + }, + }, + storage: { + namespaces: { + public: { id: 'public', entries: { table: {} } }, + auth: { id: 'auth', entries: { table: {} } }, + }, + }, + }); + + expect(print).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + message: expect.stringContaining('namespace "auth" declares the value object Address'), + }), + ); + }); + + it('refuses a column whose codec no PSL type in the stack produces, naming the column', () => { + const print = printContract({ + namespaces: { + public: { + models: { + Doc: { + storage: { + table: 'Doc', + namespaceId: 'public', + fields: { id: { column: 'id' }, v: { column: 'v' } }, + }, + fields: { + id: INT_FIELD, + v: { nullable: false, type: { kind: 'scalar', codecId: 'pg/vector@1' } }, + }, + relations: {}, + }, + }, + }, + }, + storage: { + namespaces: { + public: { + id: 'public', + entries: { + table: { + Doc: table({ + columns: { + id: INT_COLUMN, + v: { + nativeType: 'vector', + codecId: 'pg/vector@1', + nullable: false, + typeParams: { length: 3 }, + }, + }, + primaryKey: { columns: ['id'] }, + }), + }, + }, + }, + }, + }, + }); + + expect(print).toThrow( + expect.objectContaining({ + code: 'CONTRACT.PRINT_UNSUPPORTED', + message: expect.stringContaining( + '"public"."Doc"."v" has native type "vector" with codec "pg/vector@1"', + ), + }), + ); + }); +}); diff --git a/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts b/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts index 27d70b146093..1491b459376f 100644 --- a/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts @@ -21,9 +21,8 @@ import postgresAdapter from '../src/exports/control'; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../../..'); /** - * Contracts emitted from TypeScript sources, one per feature a Prisma 7 schema - * cannot carry. Each is printed as PSL, read back through the PSL source, and - * must come back as the same contract. + * 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 }> = [ { @@ -31,10 +30,14 @@ const cases: ReadonlyArray<{ readonly name: string; readonly contractJson: strin contractJson: 'test/integration/test/value-objects/fixtures/generated/sql-contract.json', }, { - name: 'polymorphism: discriminator, base, variants, owner', + 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: @@ -70,22 +73,28 @@ function loadContract(relativePath: string): Contract { } function printAsPsl(contract: Contract): string { - const ast = postgres.printPslContract?.(contract); - if (ast === undefined) throw new Error('the Postgres target has no printPslContract hook'); const context = sourceContext([]); + const ast = postgres.printPslContract?.(contract, { + authoringTypes: context.authoringContributions.type, + }); + if (ast === undefined) throw new Error('the Postgres target has no printPslContract hook'); return printPsl(ast, { pslBlockDescriptors: context.authoringContributions.pslBlockDescriptors, codecLookup: context.codecLookup, }); } -async function readBack(text: string): Promise> { +async function readBack( + text: string, + defaultControlPolicy?: Contract['defaultControlPolicy'], +): Promise> { const printedPath = join(mkdtempSync(join(tmpdir(), 'psl-print-')), 'contract.prisma'); writeFileSync(printedPath, text); const result = await prismaContract(printedPath, { target: postgresPackRef, createNamespace: postgresCreateNamespace, enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, + ...(defaultControlPolicy === undefined ? {} : { defaultControlPolicy }), }).source.load(sourceContext([printedPath])); if (!result.ok) { throw new Error( @@ -106,8 +115,8 @@ function serialize(contract: Contract): unknown { } /** - * Contracts authored in PSL, one per feature the emitted TypeScript fixtures - * above do not carry. Each is read, printed, read back, and compared. + * 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 }> = [ { @@ -148,6 +157,89 @@ model Task { 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 model in the unbound namespace', + schema: `namespace unbound { + model Setting { + id Int @id + } +} `, }, ]; @@ -163,16 +255,6 @@ describe('a printed PSL contract reads back as the same contract', () => { }); describe('a contract the language cannot carry is refused by name', () => { - it('refuses the entities an extension contributes, naming their kind', () => { - const authored = loadContract('packages/3-extensions/supabase/src/contract/contract.json'); - expect(() => printAsPsl(authored)).toThrow( - expect.objectContaining({ - code: 'CONTRACT.PRINT_UNSUPPORTED', - message: expect.stringMatching(/declares \d+ "(role|rls)" entit/), - }), - ); - }); - it('refuses a to-one relation that travels no foreign key', () => { const authored = loadContract( 'test/integration/test/sql-orm-client/fixtures/generated/contract.json', @@ -186,10 +268,10 @@ describe('a contract the language cannot carry is refused by name', () => { }); }); -describe('a printed TypeScript contract reads back as the same contract', () => { +describe('a printed emitted contract reads back as the same contract', () => { it.each(cases)('$name', async ({ contractJson }) => { const authored = loadContract(contractJson); - const printed = await readBack(printAsPsl(authored)); + const printed = await readBack(printAsPsl(authored), authored.defaultControlPolicy); expect(serialize(printed)).toEqual(serialize(authored)); expect(printed.storage.storageHash).toBe(authored.storage.storageHash); 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 0a0888ed317c..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 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._ - -## At a glance - -```bash -prisma contract print --output src/prisma/contract.prisma -``` - -Output begins: - -```prisma -// use prisma-8 -// Converted from prisma/schema.prisma by `prisma contract print`. -``` - -## 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 a `headerComment` option the command passes; its default leaves `contract infer` output unchanged. -- **Command** `contract print` in `packages/1-framework/3-tooling/cli/src/orm/contract/print.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 2 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 print`. -- [ ] `--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..83f787fc3dd4 --- /dev/null +++ b/projects/prisma7-contract-source/slices/03-contract-to-psl-and-print/spec.md @@ -0,0 +1,40 @@ +# 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 gains a `headerComment` option the command passes; its default leaves `contract infer` output unchanged. +- **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, prints, and writes with `publishTextArtifact`. Output path resolution uses `pslOutputPathFor`, shared with `contract infer`. Refusals exit 2 and write nothing. +- **Round trip test.** For every fixture from slices 1 and 2: 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 | +|---|---| +| 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. | +| 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 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 fixture from slices 1 and 2 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. diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index 1eb3c88083f6..90f9d60d8c28 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -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 @@ -82,7 +82,7 @@ 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 print`, and each facade's config reference documents its reader (`prisma7Schema` for Postgres, `prisma6Schema` for Mongo). @@ -116,18 +116,17 @@ Recorded so they are not lost; each becomes its own project when scheduled. ### What `contract print` cannot write -`contract print` loads the contract the config names, from any source, and writes it as Prisma 8 PSL that reads back as the same contract. The proof is two round-trip tests: one over every Prisma 7 fixture (`contract-prisma7/test/convert-roundtrip.test.ts`) and one over contracts emitted from TypeScript and PSL sources that carry what a Prisma 7 schema cannot (`adapter-postgres/test/psl-print-roundtrip.test.ts`): value objects, polymorphism, named types, domain enums, control policies, and every index argument. The printer writes all of those. Where the PSL language has no form for something the contract holds, the printer refuses it by name with `CONTRACT.PRINT_UNSUPPORTED` and writes no file; it never drops anything silently. +`contract print` loads the contract the config names, from any source, and writes it as Prisma 8 PSL that reads back as the same contract. The proof is three round-trip tests: every Prisma 7 fixture (`contract-prisma7/test/print-roundtrip.test.ts`); emitted and PSL-authored contracts that carry what a Prisma 7 schema cannot, including value objects, polymorphism, named types, domain enums, control policies, every index argument, primary key names, non-default codecs, row-level security with roles and policies, and the Supabase contract (`adapter-postgres/test/psl-print-roundtrip.test.ts`); and a pgvector column with the extension in the stack (`test/integration/test/psl-print/extension-types-roundtrip.integration.test.ts`). Where PSL has no form for part of the contract, the printer refuses it by name with `CONTRACT.PRINT_UNSUPPORTED` and writes no file. 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, and what would lift each: +The refusals that a reader or language change would lift: -- One model name declared in two namespaces. The PSL reader keys relation targets, junction detection and id columns by model name alone (`contract-psl/src/interpreter.ts`, `fkRelationsByDeclaringModel`, `modelIdColumns`), so the two models read back as one. Lifted by re-keying those on (namespace, model). -- A domain enum outside the default namespace. The PSL reader refuses an `enum` block inside a `namespace` block. Lifted by a reader change. +- 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 field whose type is a union of types, a dictionary field, a column with its own control policy, and a model with an owner. None has PSL syntax. -- An entity kind an extension contributes (row-level security policies, roles). Its PSL syntax belongs to the extension. Lifted by a per-kind print hook on the extension pack, the mirror of the block descriptors extensions already contribute for reading. -- A generator the Postgres printer has no PSL form for (none of the Prisma 7 generators, which all print). +- A union or dictionary field, a column with its own control policy, and a model with an owner. None has PSL syntax. +- A generator with no PSL default function, and a generator on update other than the wall-clock-now generator. None of the Prisma 7 generators meets either. -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), `print-column-default.ts` writes every other function default 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 `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. ### Found outside this project's scope @@ -135,10 +134,9 @@ Each exists on `main` unless the line says otherwise, so none is a regression th - `contract format` deletes a `//` comment written between a block's name and its `{`. In `@internal/psl-parser`'s formatter. - `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. @@ -149,14 +147,12 @@ Each exists on `main` unless the line says otherwise, so none is a regression th - 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. @@ -170,4 +166,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/02-mongo-source/spec.md`, `slices/03-contract-to-psl-and-convert/spec.md`. +- `slices/01-postgres-source/spec.md`, `slices/02-mongo-source/spec.md`, `slices/03-contract-to-psl-and-print/spec.md`. diff --git a/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md b/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md index b38901ac265e..5d9bc4e6b452 100644 --- a/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md +++ b/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md @@ -43,7 +43,6 @@ File: [`contract-prisma7/src/defaults.ts`](../../../../packages/2-sql/2-authorin Files: [`9-family/src/core/psl-contract-infer/default-mapping.ts`](../../../../packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts), [`postgres-default-mapping.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts), [`infer-model-blocks.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts), [`infer-psl-contract.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts). - `mapDefault`'s function arm: named functions print as today; anything else prints `@default(sql\`\`)`, or `@default(sql"")` with PSL string escaping when the expression contains a backtick. This is the family's built-in fallback; the `fallbackFunctionAttribute` option and `DefaultMappingResult`'s `comment` member are deleted. `postgres-default-mapping.ts` keeps only the named-function table (`gen_random_uuid()`) and its `formatDbGeneratedAttribute` is deleted. -- `prisma contract print` has its own default printer, [`psl-print/print-column-default.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts), which writes `dbgenerated("...")` for every function default other than `now()` and `autoincrement()`. It prints the same `sql` form as `mapDefault`, and the round-trip test in `contract-prisma7/test/convert-roundtrip.test.ts` proves it. - Classification before printing: infer already resolves a column's raw default with the same Postgres parser verify uses (`parsePostgresDefault`, reached through `resolvedDefault`). Two classifications it does not make today are added so the Supabase contract prints literals where it can: - A default written as `''::` where `` is the column's own type (including `jsonb`, `json`, `text`, and array types) is a literal whose value is `` read through the column's codec. The four Supabase JSON defaults print as `@default("{}")` and `@default("[]")`. - A default written as `''::.` or `''::` where the enum is a native enum in the introspected schema and the column is typed by it is the enum member ``; infer prints `@default()`. The six Supabase enum casts print this way. The test at `infer-psl-contract.enum-adoption.test.ts` that pins a schema-qualified cast as preserved raw is rewritten to expect the member. diff --git a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts index 1fafa394e872..105b791db4b0 100644 --- a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts @@ -1,14 +1,14 @@ /** * 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 verify` then reports zero - * findings against the database the Prisma 7 SQL built. It runs over the - * `relations` fixture, 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 - * as the same schema. 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. + * `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. */ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { withClient } from '@repo/test-utils'; @@ -78,7 +78,7 @@ function setupPrisma7Project( } /** The same project, read through the Prisma 8 PSL `contract print` wrote. */ -function onConvertedContract(ctx: JourneyContext, connectionString: string): JourneyContext { +function onPrintedContract(ctx: JourneyContext, connectionString: string): JourneyContext { return { ...ctx, configPath: writeConfig(ctx.testDir, 'prisma.config.prisma7-printed.ts', connectionString), @@ -111,18 +111,18 @@ function errorOf(run: EngineCommandResult): { readonly code: string; readonly su } /** - * Converts, switches the config to the written file, emits, and verifies. The - * two passing and failing journeys differ only in the fixture they run over. + * 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 convertAndVerify(ctx: JourneyContext, connectionString: string): Promise { +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 convert = await runContractPrint(ctx, ['--json']); - expect(convert.exitCode, `contract print\n${output(convert)}`).toBe(0); - expect(convert.presented?.data).toMatchObject({ + const print = await runContractPrint(ctx, ['--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'], @@ -133,15 +133,15 @@ async function convertAndVerify(ctx: JourneyContext, connectionString: string): '// use prisma-8\n// Printed from schema.prisma by `prisma contract print`.', ); - const converted = onConvertedContract(ctx, connectionString); - const pslEmit = await runContractEmit(converted, ['--json']); - expect(pslEmit.exitCode, `contract emit on the converted contract\n${output(pslEmit)}`).toBe(0); + 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(converted, ['--json']); + const sign = await runDbSign(printed, ['--json']); expect(sign.exitCode, `db sign\n${output(sign)}`).toBe(0); - const verify = await runDbVerify(converted, ['--json']); + const verify = await runDbVerify(printed, ['--json']); expect(verify.exitCode, `db verify\n${output(verify)}`).toBe(0); expect(verify.presented?.data).toMatchObject({ ok: true, @@ -151,7 +151,7 @@ async function convertAndVerify(ctx: JourneyContext, connectionString: string): } withTempDir(({ createTempDir }) => { - describe('Journey: converting a Prisma 7 schema and emitting it as Prisma 8', () => { + 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)), }); @@ -159,7 +159,7 @@ withTempDir(({ createTempDir }) => { it.each(['relations', 'supported-verify'])( 'prints the %s fixture and verifies against the database Prisma 7 built', async (fixture) => { - await convertAndVerify( + await printAndVerify( setupPrisma7Project(createTempDir, db.connectionString, { copyFrom: join(PRISMA7_FIXTURES, `${fixture}/schema.prisma`), }), @@ -170,13 +170,13 @@ withTempDir(({ createTempDir }) => { ); }); - describe('Journey: contract print refuses what it cannot convert', () => { - it('prints a PSL source as the same contract, so the command is not tied to Prisma 7', async () => { + 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'), 'model User {\n id Int @id\n}\n'); - const onPsl = onConvertedContract(ctx, NO_DATABASE); + const onPsl = onPrintedContract(ctx, NO_DATABASE); const print = await runContractPrint(onPsl, ['--output', 'printed.prisma', '--json']); @@ -189,10 +189,10 @@ withTempDir(({ createTempDir }) => { 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 convert = await runContractPrint(ctx, ['--json']); + const print = await runContractPrint(ctx, ['--json']); - expect(convert.exitCode, output(convert)).toBe(2); - expect(errorOf(convert).code).toBe('CONTRACT.SOURCE_LOAD_FAILED'); + 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); }); @@ -203,10 +203,10 @@ withTempDir(({ createTempDir }) => { const schemaPath = join(ctx.testDir, 'schema.prisma'); const before = readFileSync(schemaPath, 'utf-8'); - const convert = await runContractPrint(ctx, ['--output', 'schema.prisma', '--json']); + const print = await runContractPrint(ctx, ['--output', 'schema.prisma', '--json']); - expect(convert.exitCode, output(convert)).toBe(2); - expect(errorOf(convert).code).toBe('CONTRACT.PRINT_OUTPUT_IS_SOURCE'); + 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/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..1ead38aad4d5 --- /dev/null +++ b/test/integration/test/psl-print/extension-types-roundtrip.integration.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import postgresAdapter from '@internal/adapter-postgres/control'; +import type { Contract } from '@internal/contract/types'; +import postgresDriver from '@internal/driver-postgres/control'; +import pgvector from '@internal/extension-pgvector/control'; +import sql from '@internal/family-sql/control'; +import { createControlStack } from '@internal/framework-components/control'; +import { 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 { join } from 'pathe'; +import { describe, expect, it } from 'vitest'; + +const stack = createControlStack({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresDriver, + extensions: [pgvector], +}); + +async function read(text: string): Promise> { + const path = join(mkdtempSync(join(tmpdir(), 'psl-print-extension-')), '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 }, + }).source.load({ + composedExtensions: stack.extensions.map((extension) => extension.id), + composedExtensionContracts: stack.extensionContracts, + authoringContributions: stack.authoringContributions, + codecLookup: stack.codecLookup, + dataTypeLookup: stack.dataTypeLookup, + controlMutationDefaults: stack.controlMutationDefaults, + resolvedInputs: [path], + capabilities: stack.capabilities, + }); + 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, + ); +} + +function serialize(contract: Contract): unknown { + const { capabilities: _, ...authored } = JSON.parse( + JSON.stringify(new PostgresContractSerializer().serializeContract(contract)), + ); + return authored; +} + +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 read(`// use prisma-8 +types { + Embedding = pgvector.Vector(3) +} + +model Document { + id Int @id + embedding pgvector.Vector(3) + named Embedding +} +`); + const ast = postgres.printPslContract?.(authored, { + authoringTypes: stack.authoringContributions.type, + }); + if (ast === undefined) throw new Error('the Postgres target has no printPslContract hook'); + const text = printPsl(ast, { + pslBlockDescriptors: stack.authoringContributions.pslBlockDescriptors, + codecLookup: stack.codecLookup, + }); + + expect(text).toContain('embedding pgvector.Vector(3)'); + const printed = await read(text); + expect(serialize(printed)).toEqual(serialize(authored)); + expect(printed.storage.storageHash).toBe(authored.storage.storageHash); + }); +}); diff --git a/upgrade-instructions/pending/contract-convert/app/instructions.md b/upgrade-instructions/pending/contract-convert/app/instructions.md deleted file mode 100644 index 8e1cc0ebe668..000000000000 --- a/upgrade-instructions/pending/contract-convert/app/instructions.md +++ /dev/null @@ -1,3 +0,0 @@ ---- -changes: [] ---- diff --git a/upgrade-instructions/pending/contract-convert/extension/instructions.md b/upgrade-instructions/pending/contract-convert/extension/instructions.md deleted file mode 100644 index 8e1cc0ebe668..000000000000 --- a/upgrade-instructions/pending/contract-convert/extension/instructions.md +++ /dev/null @@ -1,3 +0,0 @@ ---- -changes: [] ---- 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..152a19dff24f --- /dev/null +++ b/upgrade-instructions/pending/contract-print/app/instructions.md @@ -0,0 +1,11 @@ +--- +changes: + - id: contract-format-formats-prisma7-schema + summary: prisma contract format now formats a Prisma 7 schema configured through prisma7Schema(). +--- + +# `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 without writing when it does not (for example, a schema with a `view` block). + +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. 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..cf521fba428b --- /dev/null +++ b/upgrade-instructions/pending/contract-print/extension/instructions.md @@ -0,0 +1,15 @@ +--- +changes: + - id: contract-source-format-is-psl-or-typescript + summary: A contract source declares format 'psl' or 'typescript', or none; OpaqueContractSourceProvider is removed. + - id: prisma7-schema-source-declares-psl + summary: The prisma7Schema() contract source declares format 'psl' instead of 'prisma7'. +--- + +# 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'`, or no `format`). `OpaqueContractSourceProvider` and any other `format` string are gone. + +If an extension defines a contract source with another `format` value, set `format: 'psl'` when its inputs are PSL text, and otherwise leave `format` out, which makes it a TypeScript source. 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. From dfdbbc4f52bb1608b7227bf1023b7b6a7f4b1b38 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 18:03:05 +0200 Subject: [PATCH 43/55] fix(psl): one grammar for every PSL document, and the formatter keeps what it used to damage The parser has no grammar option. A view body parses as fields everywhere, and each reader decides by keyword whether it accepts a block. The Mongo reader now reports an unknown top-level block, as the SQL reader does. The enum member attribute check and the unknown-block check each have one owner in the parser's shared reader helpers. The formatter keeps a comment written between a block's name and its brace, and writes a space before a list value after a colon, comma or equals sign. The framework vocabulary lint flags names of Prisma versions before 8 everywhere in packages/1-framework. ADR 252 describes the single grammar. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .agents/rules/README.md | 2 +- .../no-family-vocabulary-in-framework.mdc | 8 +- biome-plugins/no-family-vocabulary.grit | 14 +- ...a version's schema is a contract source.md | 4 +- .../2-authoring/psl-parser/README.md | 4 +- .../psl-parser/src/enum-member-attributes.ts | 18 ++ .../psl-parser/src/exports/index.ts | 1 + .../psl-parser/src/exports/interpret.ts | 2 + .../psl-parser/src/exports/syntax.ts | 2 +- .../2-authoring/psl-parser/src/format/emit.ts | 64 ++--- .../2-authoring/psl-parser/src/parse.ts | 51 ++-- .../psl-parser/src/syntax/ast/declarations.ts | 2 +- .../2-authoring/psl-parser/src/tokenizer.ts | 9 + .../psl-parser/src/unclaimed-blocks.ts | 31 +++ .../psl-parser/test/format/fixtures.test.ts | 2 +- .../expected.prisma | 22 ++ .../header-comment-before-brace/input.prisma | 27 +++ .../list-value-spacing/expected.prisma | 13 + .../fixtures/list-value-spacing/input.prisma | 12 + .../view-block-plain-fields/expected.prisma | 2 +- .../view-block-plain-fields/input.prisma | 4 +- .../test/format/prisma7-constructs.test.ts | 86 ------- .../test/format/view-and-enum-member.test.ts | 77 ++++++ .../psl-parser/test/interpret.test.ts | 7 - .../psl-parser/test/is-psl-identifier.test.ts | 20 ++ .../test/parse-enum-member-attributes.test.ts | 111 +++++++++ .../psl-parser/test/parse-prisma7.test.ts | 224 ------------------ .../psl-parser/test/parse-view-body.test.ts | 95 ++++++++ .../test/reader-block-checks.test.ts | 88 +++++++ .../contract-psl/src/interpreter.ts | 23 +- .../contract-psl/test/interpreter.test.ts | 12 + .../interpreter.unsupported-blocks.test.ts | 68 ++++++ .../test/provider.interpret.test.ts | 10 + .../contract-psl/test/provider.test.ts | 2 +- .../contract-prisma7/src/provider.ts | 4 +- .../contract-psl/src/interpreter.ts | 80 ++----- .../test/interpreter.enum.test.ts | 2 +- .../test/interpreter.view-block.test.ts | 53 +++++ .../provider.enum-member-attributes.test.ts | 2 +- scripts/lint-framework-vocabulary.test.mjs | 25 ++ 40 files changed, 797 insertions(+), 486 deletions(-) create mode 100644 packages/1-framework/2-authoring/psl-parser/src/enum-member-attributes.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/unclaimed-blocks.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/expected.prisma create mode 100644 packages/1-framework/2-authoring/psl-parser/test/format/fixtures/header-comment-before-brace/input.prisma create mode 100644 packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/expected.prisma create mode 100644 packages/1-framework/2-authoring/psl-parser/test/format/fixtures/list-value-spacing/input.prisma delete mode 100644 packages/1-framework/2-authoring/psl-parser/test/format/prisma7-constructs.test.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/format/view-and-enum-member.test.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/is-psl-identifier.test.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/parse-enum-member-attributes.test.ts delete mode 100644 packages/1-framework/2-authoring/psl-parser/test/parse-prisma7.test.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/parse-view-body.test.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/reader-block-checks.test.ts create mode 100644 packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.unsupported-blocks.test.ts create mode 100644 packages/2-sql/2-authoring/contract-psl/test/interpreter.view-block.test.ts diff --git a/.agents/rules/README.md b/.agents/rules/README.md index 953a14ea1255..1a18c39d4ca0 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/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index 9c05d230c892..c34bc3192fda 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -38,7 +38,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. - `readResolvedAttribute(s)` / `readResolvedConstructorCall` + the span maps (`nodePslSpan`, `keywordPslSpan`) in `src/resolve.ts` — the shared CST read helpers `buildSymbolTable` uses and that consumers (e.g. enum-block reconstruction) reuse, with `PslSpan` spans derived from `PslSources`. Pure coordinate conversion lives on `SourceFile`: resolve the file with `sources.sourceFileFor(node.syntax)` and call `sourceFile.rangeToPslSpan(range)`, `sourceFile.offsetToPslPosition(offset)`, or `sourceFile.pslSpanToRange(span)`. - `reconstructExtensionBlock` / `findBlockDescriptor` / @@ -47,6 +47,8 @@ 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. +- 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 728b1cd31b90..dfc34abe90a6 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 @@ -133,3 +133,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 f73ddf1568eb..5e72c18d379c 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,3 +1,4 @@ +export { enumMemberAttributeDiagnostics } from '../enum-member-attributes'; export type { PslInterpretCapable, PslInterpretInput } from '../interpret'; export { hasPslInterpreter, withSeedDiagnostics } from '../interpret'; export type { InvalidFkPairing } from '../relation-backrelations'; @@ -6,3 +7,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/parse.ts b/packages/1-framework/2-authoring/psl-parser/src/parse.ts index 529e29927469..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 field lines in a `view` body. Defaults to `psl`, which reads a `view` - * block as plain `key = value` entries. - */ - 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 { } /** - * An `enum` member may carry `@` attributes in every grammar (`USER @map("user")`); whether one - * is allowed is the interpreter's decision. With the `prisma7` grammar, a `view` body is read like - * a model body. Every other generic block 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 { +function genericBlockMemberParser(keyword: string): MemberParser { + if (keyword === 'view') return parseModelMember; if (keyword === 'enum') return parseEnumMember; - if (grammar === 'prisma7' && keyword === 'view') return parseModelMember; return parseKeyValueMember; } 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 82c6cf5fa8b8..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); } 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 48e3da53ebd3..000000000000 --- a/packages/1-framework/2-authoring/psl-parser/test/format/prisma7-constructs.test.ts +++ /dev/null @@ -1,86 +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 Prisma 7 syntax', () => { - 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('formats a Prisma 7 schema with no view block', () => { - 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'), - ); - }); - - it('keeps an attribute on an enum member, leaving its validity to the interpreter', () => { - expect(format('enum Role {\n USER @map("user")\n ADMIN\n}\n')).toContain( - 'USER @map("user")', - ); - }); -}); - -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 ee054c35f75e..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,13 +42,6 @@ describe('hasPslInterpreter', () => { expect(hasPslInterpreter(source)).toBe(false); }); - it('rejects a provider that declares no format, which is a TypeScript source', () => { - 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/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 04e1a6d6c18d..000000000000 --- a/packages/1-framework/2-authoring/psl-parser/test/parse-prisma7.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * The `prisma7` grammar reads one Prisma 7 construct so the Prisma 7 - * interpreter can walk it with spans: field lines inside `view` blocks. The - * default grammar reads a `view` block as plain entries. Attributes on an enum - * member are read by every grammar. - */ -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('reads an attribute after an enum member, leaving its validity to the interpreter', () => { - const result = parse('enum Role {\n USER @map("user")\n}', 'test.psl'); - expect(result.diagnostics).toEqual([]); - expect(printTree(result.document.syntax.green)).toBe( - printTree( - parse('enum Role {\n USER @map("user")\n}', 'test.psl', prisma7).document.syntax.green, - ), - ); - }); - - it('reads an attribute after an enum member value', () => { - const result = parse('enum Role {\n Admin = "admin" @map("ADMIN")\n}', 'test.psl'); - expect(result.diagnostics).toEqual([]); - }); - }); -}); - -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/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts index ddd2f4ab1174..39c9b47eaf9f 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 @@ -59,10 +59,13 @@ import { type PslDiagnosticCollector, } from '@internal/psl-parser'; import { + claimedBlockKeywords, consumeInvalidFkPairing, + enumMemberAttributeDiagnostics, fkRelationPairKey, type InvalidFkPairing, requiredOneToOneBackrelationDiagnostic, + unsupportedBlockDiagnostic, } from '@internal/psl-parser/interpret'; import type { DocumentAst, PslSources } from '@internal/psl-parser/syntax'; import { assertDefined } from '@internal/utils/assertions'; @@ -1057,17 +1060,7 @@ function processEnumDeclarations(input: { for (const enumSymbol of input.enumSymbols) { const sourceFile = input.sources.sourceFileFor(enumSymbol.node.syntax); const decl = enumSymbol.block; - for (const entry of enumSymbol.node.entries()) { - for (const attribute of entry.attributes()) { - input.diagnostics.push({ - code: 'PSL_ENUM_MEMBER_ATTRIBUTE_UNSUPPORTED', - message: `enum "${decl.name}": member "${entry.key()?.name() ?? '?'}" carries @${attribute.name()?.path().join('.') ?? '?'}, but an enum member takes no attributes`, - ...diagnosticSource(input.sources, enumSymbol.node.syntax).at( - nodePslSpan(attribute.syntax, input.sources), - ), - }); - } - } + input.diagnostics.push(...enumMemberAttributeDiagnostics(enumSymbol, input.sources)); const handle = instantiateAuthoringEntityType( 'enum', enumDescriptor, @@ -1136,7 +1129,13 @@ export function interpretPslDocumentToMongoContract( }); } - const topLevelEnumSymbols = Object.values(topLevel.blocks).filter((b) => b.keyword === 'enum'); + const blockKeywords = claimedBlockKeywords(input.authoringContributions?.pslBlockDescriptors); + const claimedBlocks: BlockSymbol[] = []; + for (const block of Object.values(topLevel.blocks)) { + if (blockKeywords.has(block.keyword)) claimedBlocks.push(block); + else diagnostics.push(unsupportedBlockDiagnostic(block, sources)); + } + const topLevelEnumSymbols = claimedBlocks.filter((b) => b.keyword === 'enum'); const builtEnums = processEnumDeclarations({ enumSymbols: topLevelEnumSymbols, 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 887fe0c6e1f8..e09aba9c96d7 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 @@ -166,6 +166,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..d460783cb162 --- /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({ + 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 e33323797b0a..07714b436c98 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 @@ -354,6 +354,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 b5d7e1ae022e..55b2d382b2b9 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 @@ -218,7 +218,7 @@ model User { summary: 'PSL to Mongo contract interpretation failed', diagnostics: [ { - code: 'PSL_ENUM_MEMBER_ATTRIBUTE_UNSUPPORTED', + code: 'PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE', message: 'enum "Role": member "USER" carries @map, but an enum member takes no attributes', sourceId: './schema.prisma', 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 8c10f42012b9..5f9eb8822d4c 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/src/provider.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/src/provider.ts @@ -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-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index 5c454a382803..6de90dc68ef4 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -28,7 +28,6 @@ import { instantiateAuthoringEntityType, isAuthoringEntityTypeDescriptor, isAuthoringModelAttributeDescriptor, - isAuthoringPslBlockDescriptor, } from '@internal/framework-components/authoring'; import type { CodecLookup, DataTypeLookup } from '@internal/framework-components/codec'; import type { @@ -42,6 +41,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 BlockSymbol, type CompositeTypeSymbol, @@ -50,7 +53,6 @@ import { diagnosticSource, type FieldSymbol, findBlockDescriptor, - keywordPslSpan, type ModelAttributeSpecFactory, type ModelSymbol, type NamedTypeSymbol, @@ -61,7 +63,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; @@ -580,17 +577,7 @@ function processEnumDeclarations(input: ProcessEnumDeclarationsInput): { for (const symbol of input.enumBlocks) { const decl = symbol.block; - for (const entry of symbol.node.entries()) { - for (const attribute of entry.attributes()) { - input.diagnostics.push({ - code: 'PSL_ENUM_MEMBER_ATTRIBUTE_UNSUPPORTED', - message: `enum "${decl.name}": member "${entry.key()?.name() ?? '?'}" carries @${attribute.name()?.path().join('.') ?? '?'}, but an enum member takes no attributes`, - ...diagnosticSource(input.source.sources, symbol.node.syntax).at( - nodePslSpan(attribute.syntax, input.source.sources), - ), - }); - } - } + input.diagnostics.push(...enumMemberAttributeDiagnostics(symbol, input.source.sources)); const handle = instantiateAuthoringEntityType( 'enum', enumDescriptor, @@ -613,21 +600,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; @@ -1333,7 +1305,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 @@ -1415,8 +1387,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 && @@ -2171,7 +2143,7 @@ export function interpretPslDocumentToSqlContract( }; collectScope( - UNSPECIFIED_PSL_NAMESPACE_NAME, + UNSPECIFIED_PSL_NAMESPACE_ID, Object.values(topLevel.models), Object.values(topLevel.compositeTypes), ); @@ -2202,15 +2174,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[] = []; @@ -2366,7 +2334,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, @@ -2394,7 +2362,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( 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 19ad52293f61..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 @@ -145,7 +145,7 @@ model Post { if (result.ok) return; expect(result.failure.diagnostics).toEqual([ expect.objectContaining({ - code: 'PSL_ENUM_MEMBER_ATTRIBUTE_UNSUPPORTED', + 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 }) }), 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 667716ba1f5e..d54f4e35f521 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 @@ -82,7 +82,7 @@ model User { summary: 'PSL to SQL contract interpretation failed', diagnostics: [ { - code: 'PSL_ENUM_MEMBER_ATTRIBUTE_UNSUPPORTED', + code: 'PSL_UNSUPPORTED_ENUM_MEMBER_ATTRIBUTE', message: 'enum "Role": member "USER" carries @map, but an enum member takes no attributes', sourceId: './schema.prisma', diff --git a/scripts/lint-framework-vocabulary.test.mjs b/scripts/lint-framework-vocabulary.test.mjs index e3cc91d980c2..eb3e559c6a68 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,24 @@ 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 any framework package, including the PSL parser', () => { + writeConfig(8); + writeRepoFile(`${SCOPE}/3-tooling/cli/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 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/); + }); +}); From ebacca4866df9512c661078739abbea0f067ed76 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 18:03:19 +0200 Subject: [PATCH 44/55] fix(cli): contract print loads, prints and renders against one stack, and guards every file the project needs contract print is an operation like contract emit. One control stack loads the source, creates the family instance and renders the text, and every path that loads a contract source goes through loadContractSource, which now returns a typed Contract. The family method returns the PSL document and the settings the new PSL source must carry, so the command no longer reads the default control policy itself. When the contract has one, the command warns, and the READMEs show a config that sets it. The next step says where contract emit writes after the switch. The output path is compared as a real file: symbolic links are resolved, and case is ignored on a volume that ignores it. Writing over the config file or an emitted contract file is refused with CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE. Every contract source states its format, psl or typescript, and config validation accepts only those two. The PSL printer always writes the `// use prisma-8` marker; callers pass one description line. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-framework/1-core/config/README.md | 9 +- .../1-core/config/src/config-validation.ts | 4 +- .../config/src/contract-source-types.ts | 9 +- .../1-core/config/test/config-types.test-d.ts | 9 +- .../config/test/config-validation.test.ts | 18 +- .../1-core/config/test/define-config.test.ts | 1 + .../src/control/control-capabilities.ts | 20 +- .../src/control/psl-ast.ts | 6 + .../test/psl-contract-print.types.test-d.ts | 24 ++ .../2-authoring/psl-printer/README.md | 1 + .../psl-printer/src/ast-to-print-document.ts | 16 +- .../2-authoring/psl-printer/src/print-psl.ts | 8 +- .../test/print-psl-from-ast.test.ts | 11 +- packages/1-framework/3-tooling/cli/README.md | 34 ++- .../3-tooling/cli/src/control-api/client.ts | 91 ++---- .../control-api/operations/contract-emit.ts | 35 +-- .../control-api/operations/contract-print.ts | 79 ++++++ .../operations/load-contract-source.ts | 134 ++++----- .../operations/validate-loaded-contract.ts | 31 ++ .../src/control-api/testing/fixture-client.ts | 6 - .../3-tooling/cli/src/control-api/types.ts | 9 - .../3-tooling/cli/src/orm/contract/infer.ts | 2 + .../3-tooling/cli/src/orm/contract/paths.ts | 69 ++++- .../3-tooling/cli/src/orm/contract/print.ts | 265 +++++++++++------- .../cli/test/control-api/client.test.ts | 28 ++ .../test/control-api/contract-emit.test.ts | 3 +- .../test/control-api/contract-print.test.ts | 76 +++++ .../cli/test/control-api/format.test.ts | 4 +- .../cli/test/orm/contract-infer.test.ts | 14 + .../cli/test/orm/contract-print-support.ts | 116 ++++++++ .../orm/contract-print.output-path.test.ts | 141 ++++++++++ .../cli/test/orm/contract-print.test.ts | 201 +++++-------- .../cli/test/orm/define-command.test.ts | 2 +- .../3-tooling/config-loader/test/load.test.ts | 2 + .../emitter/src/model-types-emission.ts | 3 +- .../test/plugin.test.ts | 1 + .../9-family/src/core/control-instance.ts | 21 +- packages/3-extensions/postgres/README.md | 30 +- .../supabase/scripts/generate-contract.ts | 6 +- .../@prisma/orm-family-sql/package.json | 1 + .../@prisma/orm-postgres/package.json | 1 + .../9-public/@prisma/orm-sqlite/package.json | 1 + .../cli-journeys/prisma7-print.e2e.test.ts | 94 ++++++- .../cli-journeys/contract-default-policy.ts | 13 + .../prisma.config.default-policy.ts | 8 + .../fixtures/cli-journeys/prisma.config.ts | 1 + .../cli-journeys/prisma.config.with-db.ts | 1 + .../mongo/prisma.config.with-db.ts | 1 + .../prisma.config.with-db.ts | 1 + .../postgres/prisma.config.with-db.ts | 1 + .../prisma.config.with-db.ts | 1 + .../fixtures/db-init/prisma.config.with-db.ts | 1 + .../db-introspect/prisma.config.no-db.ts | 1 + .../db-introspect/prisma.config.no-driver.ts | 1 + .../db-introspect/prisma.config.with-db.ts | 1 + .../fixtures/db-sign/prisma.config.with-db.ts | 1 + .../prisma.config.with-db.ts | 1 + .../prisma.config.with-db.ts | 1 + .../db-verify/prisma.config.no-driver.ts | 1 + .../db-verify/prisma.config.no-verify.ts | 1 + .../fixtures/db-verify/prisma.config.ts | 1 + .../db-verify/prisma.config.with-db.ts | 1 + .../fixtures/emit/prisma.config.emit.ts | 1 + .../prisma.config.with-db.ts | 1 + .../migration-apply/prisma.config.with-db.ts | 1 + .../fixtures/migration-plan/prisma.config.ts | 1 + .../prisma.config.with-db.ts | 1 + .../prisma.config.with-db.ts | 1 + .../fixtures/vite-plugin/prisma.config.ts | 1 + .../prisma.config.async-source.ts | 1 + .../prisma.config.mongo-contract-ts.ts | 1 + .../test/fixtures/prisma.config.ts | 1 + .../test/mongo/fixtures/prisma.config.ts | 1 + .../fixtures/prisma.config.ts | 1 + .../fixtures/prisma.config.no-pgvector.ts | 1 + .../sql-builder/fixtures/prisma.config.ts | 1 + .../execution-defaulted-tags/prisma.config.ts | 1 + .../junction-namespaces/prisma.config.ts | 1 + .../sql-orm-client/fixtures/prisma.config.ts | 1 + test/utils/src/timeouts.ts | 10 + .../contract-print/app/instructions.md | 14 + .../contract-print/extension/instructions.md | 26 +- 82 files changed, 1267 insertions(+), 474 deletions(-) create mode 100644 packages/1-framework/1-core/framework-components/test/psl-contract-print.types.test-d.ts create mode 100644 packages/1-framework/3-tooling/cli/src/control-api/operations/contract-print.ts create mode 100644 packages/1-framework/3-tooling/cli/src/control-api/operations/validate-loaded-contract.ts create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/contract-print.test.ts create mode 100644 packages/1-framework/3-tooling/cli/test/orm/contract-print-support.ts create mode 100644 packages/1-framework/3-tooling/cli/test/orm/contract-print.output-path.test.ts create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-default-policy.ts create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.default-policy.ts 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/src/config-validation.ts b/packages/1-framework/1-core/config/src/config-validation.ts index 402e545202f4..0d291e806229 100644 --- a/packages/1-framework/1-core/config/src/config-validation.ts +++ b/packages/1-framework/1-core/config/src/config-validation.ts @@ -230,11 +230,11 @@ function validateContract(config: Record, issues: IssueCollecto } const format = Object.hasOwn(source, 'format') ? source['format'] : undefined; - if (format !== undefined && typeof format !== 'string') { + if (format !== 'psl' && format !== 'typescript') { issues.add( 'contract', 'contract.source.format', - 'Config.contract.source.format must be a string when provided', + "Config.contract.source.format must be 'psl' or 'typescript'", ); } 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 6e1534d29cff..c4c1cfa01b29 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 @@ -51,10 +51,9 @@ export interface ContractSourceContext { } /** - * The language a contract source's inputs are written in. Tooling that reads - * the inputs itself, such as `contract format` and the language server, checks - * this instead of guessing from file extensions. A source that declares no - * format is a TypeScript source. + * 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'; @@ -70,7 +69,7 @@ export interface PslContractSourceProvider extends ContractSourceProviderBase { } export interface TypeScriptContractSourceProvider extends ContractSourceProviderBase { - readonly format?: 'typescript'; + readonly format: 'typescript'; } export type ContractSourceProvider = PslContractSourceProvider | TypeScriptContractSourceProvider; 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 c99f10a6097a..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 @@ -124,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(); }); @@ -151,11 +151,10 @@ test('a contract source is PSL or TypeScript, and nothing else', () => { >(); expectTypeOf().toEqualTypeOf<'psl' | 'typescript'>(); expectTypeOf().toEqualTypeOf<'psl'>(); - expectTypeOf().toEqualTypeOf< - 'typescript' | undefined - >(); + expectTypeOf().toEqualTypeOf<'typescript'>(); + // @ts-expect-error every contract source states its format const untagged: ContractSourceProvider = { inputs: [], load: async () => ok({} as never) }; - expectTypeOf(untagged).toExtend(); + void untagged; // @ts-expect-error a source format the framework does not have const other: ContractSourceProvider = { format: 'other', inputs: [], load: async () => ok({}) }; void other; diff --git a/packages/1-framework/1-core/config/test/config-validation.test.ts b/packages/1-framework/1-core/config/test/config-validation.test.ts index 7792f380dfe0..4e7d6c6f982c 100644 --- a/packages/1-framework/1-core/config/test/config-validation.test.ts +++ b/packages/1-framework/1-core/config/test/config-validation.test.ts @@ -5,6 +5,7 @@ import { collectConfigIssues } from '../src/config-validation'; function createSourceProvider(overrides: Record = {}) { return { + format: 'typescript', load: async () => ok({ targetFamily: 'sql' } as Contract), ...overrides, }; @@ -298,12 +299,12 @@ describe('collectConfigIssues', () => { ).toEqual([]); }); - it('accepts a provider with an unknown format string and extra keys', () => { + it('accepts a provider with a psl format and extra keys', () => { const issues = collectConfigIssues( createValidRawConfig({ contract: { source: createSourceProvider({ - format: 'made-up-format', + format: 'psl', inputs: ['./schema.prisma'], interpret: () => [], }), @@ -313,6 +314,19 @@ describe('collectConfigIssues', () => { expect(issues).toEqual([]); }); + it.each([ + ['a format the framework does not have', createSourceProvider({ format: 'made-up-format' })], + ['no format', { load: createSourceProvider().load }], + ])('rejects a provider with %s', (_label, source) => { + expect(collectConfigIssues(createValidRawConfig({ contract: { source } }))).toEqual([ + expect.objectContaining({ + section: 'contract', + field: 'contract.source.format', + message: "Config.contract.source.format must be 'psl' or 'typescript'", + }), + ]); + }); + it('collects migrations issues under the migrations section', () => { expectIssue(createValidRawConfig({ migrations: 'invalid' }), 'migrations', 'migrations'); expectIssue(createValidRawConfig({ migrations: { dir: 123 } }), 'migrations.dir', 'migrations'); 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/src/control/control-capabilities.ts b/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts index d58c6344302f..b19e32ce482b 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,12 +54,27 @@ export function hasPslContractInfer( ); } +/** + * Settings a PSL contract source takes from the config, because a PSL file + * cannot carry them. Emitting the printed file produces the same contract only + * when the config sets each of them on the new source. + */ +export interface PslSourceSettings { + readonly defaultControlPolicy?: ControlPolicy; +} + +/** A contract printed as a PSL document, and the settings its PSL source must carry. */ +export interface PrintedPslContract { + readonly document: PslDocumentAst; + readonly sourceSettings: PslSourceSettings; +} + /** * Capability declaring that a family can print a contract as a PSL document * AST. Consumed by `prisma contract print`. */ -export interface PslContractPrintCapable { - printPslContract(contract: TContract): PslDocumentAst; +export interface PslContractPrintCapable { + printPslContract(contract: TContract): PrintedPslContract; } export function hasPslContractPrint( 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/test/psl-contract-print.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/psl-contract-print.types.test-d.ts new file mode 100644 index 000000000000..2ad33bcff4f1 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/psl-contract-print.types.test-d.ts @@ -0,0 +1,24 @@ +import type { Contract, ControlPolicy } from '@internal/contract/types'; +import { expectTypeOf, test } from 'vitest'; +import type { + PrintedPslContract, + PslContractPrintCapable, + PslSourceSettings, +} from '../src/control/control-capabilities'; +import type { PslDocumentAst } from '../src/control/psl-ast'; + +test('printing a contract returns the PSL document and the settings its PSL source must carry', () => { + expectTypeOf().parameters.toEqualTypeOf< + [Contract] + >(); + expectTypeOf< + PslContractPrintCapable['printPslContract'] + >().returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ + readonly document: PslDocumentAst; + readonly sourceSettings: PslSourceSettings; + }>(); + expectTypeOf().toEqualTypeOf<{ + readonly defaultControlPolicy?: ControlPolicy; + }>(); +}); 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 b5476efcd42e..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 @@ -18,17 +18,15 @@ 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'; + +function headerCommentFor(description: string | undefined): string { + return description === undefined ? PRISMA_8_MARKER : `${PRISMA_8_MARKER}\n// ${description}`; +} export function astDocumentToPrintDocument( ast: PslDocumentAst, - headerComment: string = DEFAULT_AST_PRINT_HEADER, + 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 @@ -110,7 +108,7 @@ export function astDocumentToPrintDocument( }); return { - headerComment, + headerComment: headerCommentFor(description), namedTypes, namespaces: namespaceSections, }; 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 c8de5a04634d..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 @@ -31,14 +31,14 @@ export interface PrintPslOptions { */ readonly codecLookup?: CodecLookup; /** - * The comment block the printed file opens with, written as PSL comment - * lines. When absent the printer writes the `contract infer` header. + * 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 headerComment?: string; + readonly description?: string; } export function printPslFromAst(ast: PslDocumentAst, options: PrintPslOptions = {}): string { - const doc = astDocumentToPrintDocument(ast, options.headerComment); + 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/test/print-psl-from-ast.test.ts b/packages/1-framework/2-authoring/psl-printer/test/print-psl-from-ast.test.ts index 2209b5fe15c9..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 @@ -400,16 +400,13 @@ describe('printPslFromAst', () => { return printed.split('\n\n')[0] ?? ''; } - it('describes an inferred contract when the caller names none', () => { - expect(headerOf(printPslFromAst(headerAst))).toBe( - '// use prisma-8\n// Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.', - ); + 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 header the caller names', () => { + it('opens with the prisma-8 marker, then the description the caller names', () => { const printed = printPslFromAst(headerAst, { - headerComment: - '// use prisma-8\n// Printed from prisma/schema.prisma by `prisma contract print`.', + description: 'Printed from prisma/schema.prisma by `prisma contract print`.', }); expect(headerOf(printed)).toBe( diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index bd2ca5a875b5..dd43b6b5a7d2 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -372,14 +372,40 @@ The written file opens with two comment lines: the `// use prisma-8` marker, and // Printed from prisma/schema.prisma by `prisma contract print`. ``` -The written file reads back as the identical contract. Where PSL has no form for part of the contract, the command refuses, names that part, and writes nothing. It exits `2` in three cases: +The written file reads back as the identical contract. Where PSL has no form for part of the contract, the command refuses, names that part, and writes nothing. It exits `2` and writes nothing 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. -- `CONTRACT.PRINT_OUTPUT_IS_SOURCE`: the `--output` path is a source file the config reads, or sits inside a directory of source files. Pick another path. +- 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, or sits inside a directory of source files. Pick another path. +- `CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE`: the output path is `prisma.config.ts` in the invocation directory, or one of the files `contract emit` writes (`contract.json` and `contract.d.ts`, or whatever `contract.output` names). Pick another path. -A PSL file cannot carry the contract's default control policy. When the contract has one, the result names it (`defaultControlPolicy` in the JSON result, and in the next step), and the config must set it on the PSL source. +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. -To switch to the written file, point `contract` in `prisma.config.ts` at it and run `prisma contract emit`. For a project leaving a Prisma 7 schema, that is the first step of the cutover; the rest takes migration ownership of the database Prisma 7 built: +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 (`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 default output path of a Prisma 7 cutover: + +```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. 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 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 2ee196bdb46a..5e6880675a2a 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 @@ -21,7 +21,6 @@ import { hasMigrations, hasOperationPreview, hasPslContractInfer, - hasPslContractPrint, hasSchemaView, } from '@internal/framework-components/control'; import { @@ -41,6 +40,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 { loadContractSource } from './operations/load-contract-source'; import { executeMigrate } from './operations/migrate'; import type { RenderContractDtsOptions, RenderContractDtsResult } from './render-contract-dts'; @@ -607,14 +607,6 @@ class ControlClientImpl implements ControlClient { return undefined; } - printPslContract(contract: unknown): PslDocumentAst | undefined { - this.init(); - if (this.familyInstance && hasPslContractPrint(this.familyInstance)) { - return this.familyInstance.printPslContract(contract); - } - return undefined; - } - getPslBlockDescriptors(): AuthoringPslBlockDescriptorNamespace { this.init(); return this.stack!.authoringContributions.pslBlockDescriptors; @@ -639,7 +631,6 @@ class ControlClientImpl implements ControlClient { throw new InternalError('Family instance was not initialized. This is a bug.'); } - let contractRaw: unknown; onProgress?.({ action: 'emit', kind: 'spanStart', @@ -647,57 +638,24 @@ 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: contractConfig.source.inputs ?? [], - capabilities: stack.capabilities, - }; - 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 loadContractSource({ + stack: this.stack!, + source: contractConfig.source, + }); + 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), }); } @@ -710,20 +668,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 306d7e6b1d7e..f8537e51eeac 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,26 +1,24 @@ import { mkdir } from 'node:fs/promises'; 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 { dirname, join } from 'pathe'; 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 type { LoadedContractSource } from './load-contract-source'; import { loadContractSource } from './load-contract-source'; +import { validateLoadedContract } from './validate-loaded-contract'; const EMIT_ACTION: ControlActionName = 'emit'; @@ -110,39 +108,24 @@ export async function executeContractEmit( return queueEmitByOutput(outputJsonPath, async () => { startSpan(onProgress, 'resolveSource', 'Resolving contract source...'); - let loadedSource: LoadedContractSource; + let stack: ControlStack; + let contract: Contract; try { - loadedSource = await loadContractSource({ config, contractConfig, signal }); + stack = createControlStack(config); + const loaded = await loadContractSource({ stack, source: contractConfig.source, signal }); + if (!loaded.ok) throw loaded.failure.error; + contract = loaded.value; } catch (error) { endSpan(onProgress, 'resolveSource', 'error'); throw error; } endSpan(onProgress, 'resolveSource', 'ok'); - const { stack } = loadedSource; 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: `loadContractSource` returns the provider's value as - // `unknown` and checks only that it is present. The cast defers the - // structural check by one statement so `enrichContract` can decorate - // first; the serialize→deserialize round-trip below checks the shape. - const enrichedIR = enrichContract( - blindCast< - Contract, - 'Provider payload is enriched before target serialization and family validation' - >(loadedSource.contract), - 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..c3d44c8409e5 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-print.ts @@ -0,0 +1,79 @@ +import type { PrismaNextConfig } from '@internal/config/config-types'; +import type { PslSourceSettings } from '@internal/framework-components/control'; +import { createControlStack, hasPslContractPrint } from '@internal/framework-components/control'; +import { printPsl } from '@internal/psl-printer'; +import { ifDefined } from '@internal/utils/defined'; +import { errorRuntime } from '../../utils/cli-errors'; +import { loadContractSource } 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; +} + +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 loaded = await loadContractSource({ + stack, + source: contractConfig.source, + ...ifDefined('signal', signal), + }); + if (!loaded.ok) { + throw loaded.failure.error; + } + + const familyInstance = config.family.create(stack); + const contract = validateLoadedContract({ config, familyInstance, contract: loaded.value }); + if (!hasPslContractPrint(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.printPslContract(contract); + + return { + psl: dependencies.printPsl(document, { + pslBlockDescriptors: stack.authoringContributions.pslBlockDescriptors, + codecLookup: stack.codecLookup, + description, + }), + sourceSettings, + }; +} 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 index 8cd3a0c40066..781aa9e70b65 100644 --- 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 @@ -1,19 +1,25 @@ -import type { PrismaNextConfig } from '@internal/config/config-types'; -import { createControlStack } from '@internal/framework-components/control'; +import type { + ContractSourceDiagnostics, + ContractSourceProvider, +} from '@internal/config/config-types'; +import type { Contract } from '@internal/contract/types'; +import type { CliStructuredError } from '@internal/errors/control'; +import type { ControlStack } 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 { errorRuntime } from '../../utils/cli-errors'; -type ContractConfig = NonNullable; - -export type ControlStack = ReturnType; - -/** The contract the configured source produced, and the stack it was loaded against. */ -export interface LoadedContractSource { - readonly stack: ControlStack; - readonly contract: unknown; +/** + * 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 { @@ -102,10 +108,6 @@ function sourceDiagnosticsToFindings(diagnostics: readonly unknown[]): Diagnosti return findings; } -type ValidatedProviderResult = - | { readonly ok: true; readonly value: unknown } - | { readonly ok: false; readonly error: ReturnType }; - function diagnosticLocationSuffix(diagnostic: Record): string { const formatted = formatLocation(diagnosticLocation(diagnostic)); return formatted === undefined ? '' : ` (${formatted})`; @@ -124,62 +126,70 @@ function mapDiagnosticsToIssues( return issues; } -function validateProviderResult(providerResult: unknown): ValidatedProviderResult { - if (!isRecord(providerResult) || typeof providerResult['ok'] !== 'boolean') { - return { - ok: false, - error: failedToResolveContractSource( +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 = providerResult['value']; + if (providerResult.ok) { + const value: unknown = providerResult.value; if (value === undefined || value === null) { - return { - ok: false, - error: failedToResolveContractSource( + return failedWith( + failedToResolveContractSource( 'Contract source provider returned malformed success result: missing value.', 'Ensure contract.source.load success payload is ok(Contract).', ), - }; + ); } - return { ok: true, value }; + return ok(providerResult.value); } - const failure = providerResult['failure']; + const failure: unknown = providerResult.failure; if ( !isRecord(failure) || typeof failure['summary'] !== 'string' || !Array.isArray(failure['diagnostics']) ) { - return { - ok: false, - error: failedToResolveContractSource( + 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 { - ok: false, - error: failedToResolveContractSource( + 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 { - ok: false, + return notOk({ error: failedToResolveContractSource( - String(failure['summary']), + failure['summary'], 'Edit the source where each finding points, then run the command again.', { diagnostics: failure['diagnostics'], @@ -189,28 +199,26 @@ function validateProviderResult(providerResult: unknown): ValidatedProviderResul undefined, sourceDiagnosticsToFindings(failure['diagnostics']), ), - }; + sourceDiagnostics: providerResult.failure, + }); } /** - * Builds the control stack, asks the configured contract source for the - * contract, and turns every failure into `CONTRACT.SOURCE_LOAD_FAILED`. Shared - * by `contract emit` and `contract print` so both report a bad source the - * same way. + * 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 {CliStructuredError} `CONTRACT.SOURCE_LOAD_FAILED` when the source - * cannot produce a contract * @throws {DOMException} `AbortError` if cancelled via `signal` */ export async function loadContractSource(inputs: { - readonly config: PrismaNextConfig; - readonly contractConfig: ContractConfig; + readonly stack: ControlStack; + readonly source: ContractSourceProvider; readonly signal?: AbortSignal; -}): Promise { - const { config, contractConfig } = inputs; +}): Promise { + const { stack, source } = inputs; const signal = inputs.signal ?? new AbortController().signal; const unlessAborted = abortable(signal); - const stack = createControlStack(config); const sourceContext = { composedExtensions: stack.extensions.map((p) => p.id), @@ -219,28 +227,26 @@ export async function loadContractSource(inputs: { codecLookup: stack.codecLookup, controlMutationDefaults: stack.controlMutationDefaults, dataTypeLookup: stack.dataTypeLookup, - resolvedInputs: contractConfig.source.inputs ?? [], + resolvedInputs: source.inputs ?? [], capabilities: stack.capabilities, }; - let providerResult: Awaited>; + let providerResult: Result; try { - providerResult = await unlessAborted(contractConfig.source.load(sourceContext)); + providerResult = await unlessAborted(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, + return failedWith( + 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); - if (!validated.ok) { - throw validated.error; - } - return { stack, contract: validated.value }; + return validateProviderResult(providerResult); } 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/control-api/testing/fixture-client.ts b/packages/1-framework/3-tooling/cli/src/control-api/testing/fixture-client.ts index a1d59b1f31ac..cd657725a799 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/testing/fixture-client.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/testing/fixture-client.ts @@ -64,7 +64,6 @@ export interface ControlClientFixtures { readonly introspect: unknown; readonly toSchemaView: CoreSchemaView | undefined; readonly inferPslContract: PslDocumentAst | undefined; - readonly printPslContract: PslDocumentAst | undefined; readonly getPslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; readonly toOperationPreview: OperationPreview | undefined; readonly emit: EmitResult; @@ -206,7 +205,6 @@ export function defaultControlClientFixtures(): ControlClientFixtures { introspect: {}, toSchemaView: undefined, inferPslContract: undefined, - printPslContract: undefined, getPslBlockDescriptors: {}, toOperationPreview: undefined, emit: ok({ @@ -334,10 +332,6 @@ class FixtureControlClientImpl implements FixtureControlClient { return this.record('inferPslContract', schemaIR, this.fixtures.inferPslContract); } - printPslContract(contract: unknown): PslDocumentAst | undefined { - return this.record('printPslContract', contract, this.fixtures.printPslContract); - } - getPslBlockDescriptors(): AuthoringPslBlockDescriptorNamespace { return this.record('getPslBlockDescriptors', undefined, this.fixtures.getPslBlockDescriptors); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/types.ts b/packages/1-framework/3-tooling/cli/src/control-api/types.ts index 6790bdefdcf8..01e92f0fcbb8 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/types.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/types.ts @@ -1008,15 +1008,6 @@ export interface ControlClient { */ inferPslContract(schemaIR: unknown): PslDocumentAst | undefined; - /** - * Prints a family contract as a PSL contract AST. - * Delegates to the family instance's printPslContract method. - * - * @param contract - The family contract to print - * @returns PslDocumentAst if the family supports the capability, undefined otherwise - */ - printPslContract(contract: unknown): PslDocumentAst | undefined; - /** * Returns the assembled PSL block descriptors from the control stack — the full * set of extension-contributed top-level block descriptors. The CLI's 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 ce23128432ff..f83c23dfc4d0 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 40cfce6d85f7..212de09c1d98 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, extname, join, normalize, resolve } from 'pathe'; /** The file `contract infer` and `contract print` write when nothing names another. */ const PSL_CONTRACT_FILENAME = 'contract.prisma'; @@ -23,3 +24,69 @@ export function pslOutputPathFor(inputs: { } return join(inputs.cwd, PSL_CONTRACT_FILENAME); } + +/** + * Where a config that names `contractPath` and sets no `output` has + * `contract emit` write its JSON: beside the contract file, named after it. + */ +export function emittedJsonPathFor(contractPath: string): string { + const extension = extname(contractPath); + return `${extension.length === 0 ? contractPath : contractPath.slice(0, -extension.length)}.json`; +} + +/** + * 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 index 103628806e2a..91555d2670a9 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts @@ -1,4 +1,6 @@ import { existsSync } from 'node:fs'; +import { getEmittedArtifactPaths } from '@internal/emitter'; +import type { CliStructuredError } from '@internal/errors/control'; import { printPsl as printPslFromAst } from '@internal/psl-printer'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations } from '@prisma/cli-engine'; @@ -6,17 +8,18 @@ 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 { createControlClient as createDefaultControlClient } from '../../control-api/client'; -import { loadContractSource } from '../../control-api/operations/load-contract-source'; -import type { ControlClient, ControlClientOptions } from '../../control-api/types'; +import { + type ContractPrintResult, + executeContractPrint, +} from '../../control-api/operations/contract-print'; import { errorContractConfigMissing, errorRuntime } from '../../utils/cli-errors'; -import { closeQuietly } from '../../utils/command-helpers'; import { chooseAction, runCommandAction } from '../../utils/next-actions'; import { publishTextArtifact } from '../../utils/publish-text-artifact'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; +import { projectConfigPathFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; -import { pslOutputPathFor } from './paths'; +import { emittedJsonPathFor, filePathKey, pslOutputPathFor } from './paths'; interface PrintDocument { readonly ok: true; @@ -32,29 +35,46 @@ interface PrintDocument { readonly timings: { readonly total: number }; } -/** Switching the config to the written file: emitting it produces the same contract. */ -function switchToPrintedActions(document: PrintDocument): readonly NextAction[] { +/** 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 switchToPrintedActions( + document: PrintDocument, + emittedFilesMove: EmittedFilesMove | undefined, +): readonly NextAction[] { + const path = document.psl.path; const policy = document.defaultControlPolicy; return [ chooseAction( policy === undefined - ? `Point contract in prisma.config.ts at ${document.psl.path}` - : `Point contract in prisma.config.ts at ${document.psl.path}, with defaultControlPolicy '${policy}' on its source`, + ? `Point contract in prisma.config.ts at ${path}` + : `Point contract in prisma.config.ts at ${path}, through a PSL source that sets defaultControlPolicy: '${policy}'`, ), + ...(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}`, + ), + ]), runCommandAction('Emit the printed contract', '{bin} contract emit'), ]; } -function defaultControlPolicyOf(contract: unknown): string | undefined { - if (typeof contract !== 'object' || contract === null) return undefined; - const policy = Reflect.get(contract, 'defaultControlPolicy'); - return typeof policy === 'string' ? policy : undefined; +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.`; } -function printPresentations(document: PrintDocument): Presentations { +function printPresentations( + document: PrintDocument, + emittedFilesMove: EmittedFilesMove | undefined, +): Presentations { return { stdout: () => [], - next: () => switchToPrintedActions(document), + next: () => switchToPrintedActions(document, emittedFilesMove), human: (): readonly Block[] => [ { kind: 'summary', @@ -66,45 +86,113 @@ function printPresentations(document: PrintDocument): Presentations { }; } -/** What `contract print` uses of the control client; doubles implement just this. */ -export type PrintControlClient = Pick< - ControlClient, - 'printPslContract' | 'getPslBlockDescriptors' | 'close' ->; - export interface ContractPrintCommandDeps { - readonly createControlClient: (options: ControlClientOptions) => PrintControlClient; readonly printPsl: typeof printPslFromAst; } -function printHeaderComment(sourcePaths: readonly string[]): string { +function printDescription(sourcePaths: readonly string[]): string { const origin = sourcePaths.length === 0 ? '' : ` from ${sourcePaths.join(', ')}`; - return `// use prisma-8\n// Printed${origin} by \`prisma contract print\`.`; + 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 contract source input the output path would be written over: the one it - * names, or the directory of source files it sits inside. `undefined` when the - * output path touches no input. + * 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. */ -function sourceInputCovering(inputs: { - readonly inputs: readonly string[]; +async function outputPathRefusal(inputs: { readonly cwd: string; readonly outputPath: string; -}): string | undefined { - return inputs.inputs.find((input) => { - const resolved = resolve(inputs.cwd, input); - return ( - resolved === inputs.outputPath || - inputs.outputPath.startsWith(`${resolved.replace(/\/$/, '')}/`) + readonly sourceInputs: readonly 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 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 configPath = projectConfigPathFor(cwd); + 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; } -export function createContractPrintCommand({ - createControlClient, - printPsl, -}: ContractPrintCommandDeps) { +function emittedFilesMoveFor(inputs: { + readonly cwd: string; + readonly outputPath: string; + readonly emittedJsonPath: string | undefined; +}): EmittedFilesMove | undefined { + if (inputs.emittedJsonPath === undefined) { + return undefined; + } + const before = getEmittedArtifactPaths(inputs.emittedJsonPath); + const after = getEmittedArtifactPaths(emittedJsonPathFor(inputs.outputPath)); + if (after.jsonPath === before.jsonPath) { + return undefined; + } + return { + before: { + json: relative(inputs.cwd, before.jsonPath), + dts: relative(inputs.cwd, before.dtsPath), + }, + after: { json: relative(inputs.cwd, after.jsonPath), dts: relative(inputs.cwd, after.dtsPath) }, + }; +} + +export function createContractPrintCommand({ printPsl }: ContractPrintCommandDeps) { return defineOrmCommand({ help: { summary: 'Write the configured contract as Prisma 8 PSL', @@ -145,6 +233,8 @@ export function createContractPrintCommand({ } 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 outputPath = pslOutputPathFor({ config: ctx.config, @@ -152,68 +242,29 @@ export function createContractPrintCommand({ output: args.flags.output, }); const displayPath = relative(ctx.cwd, outputPath); - const sourceInput = sourceInputCovering({ - inputs: sourceInputs, + const refusal = await outputPathRefusal({ cwd: ctx.cwd, outputPath, + sourceInputs, + emittedJsonPath, }); - if (sourceInput !== undefined) { - return notOk( - normalizeError( - errorRuntime( - 'CONTRACT.PRINT_OUTPUT_IS_SOURCE', - 'contract print would write over its own contract source', - { - why: `The output path ${displayPath} is the contract source ${relative(ctx.cwd, sourceInput)}, 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: displayPath, source: relative(ctx.cwd, sourceInput) }, - }, - ), - ), - ); + if (refusal !== undefined) { + return notOk(normalizeError(refusal)); } - const client = createControlClient({ - family: ctx.config.family, - target: ctx.config.target, - adapter: ctx.config.adapter, - ...(ctx.config.driver === undefined ? {} : { driver: ctx.config.driver }), - extensions: ctx.config.extensions ?? [], - }); - - let pslContent: string; - let defaultControlPolicy: string | undefined; + let printed: ContractPrintResult; try { - const { stack, contract } = await loadContractSource({ - config: ctx.config, - contractConfig, - signal: ctx.signal, - }); - defaultControlPolicy = defaultControlPolicyOf(contract); - const pslContractAst = client.printPslContract(contract); - if (pslContractAst === undefined) { - return notOk( - normalizeError( - 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.', - }, - ), - ), - ); - } - pslContent = printPsl(pslContractAst, { - pslBlockDescriptors: client.getPslBlockDescriptors(), - codecLookup: stack.codecLookup, - headerComment: printHeaderComment(sourcePaths), - }); + printed = await executeContractPrint( + { + config: ctx.config, + contractConfig, + description: printDescription(sourcePaths), + signal: ctx.signal, + }, + { printPsl }, + ); } catch (error) { return notOk(normalizeError(error)); - } finally { - await closeQuietly(client); } ctx.signal.throwIfAborted(); @@ -226,10 +277,19 @@ export function createContractPrintCommand({ } await publishTextArtifact({ path: outputPath, - content: pslContent, + 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', @@ -240,12 +300,17 @@ export function createContractPrintCommand({ timings: { total: Date.now() - startedAt }, }; - return ok(ctx.present({ data: document }, printPresentations(document))); + return ok( + ctx.present( + { data: document }, + printPresentations( + document, + emittedFilesMoveFor({ cwd: ctx.cwd, outputPath, emittedJsonPath }), + ), + ), + ); }, }); } -export const contractPrintCommand = createContractPrintCommand({ - createControlClient: createDefaultControlClient, - printPsl: printPslFromAst, -}); +export const contractPrintCommand = createContractPrintCommand({ printPsl: printPslFromAst }); 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 f9b4b36fe205..d1def1e3cf7b 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 @@ -614,6 +614,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 22b194d22be5..b9f1551971c6 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 { 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..9124e22e9a84 --- /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; + }), + printPslContract: 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.printPslContract).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 5c9946dacf47..ef82d8f241cd 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 @@ -91,14 +91,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'), }, }), 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 ef0aec5d26e7..c8d9a4c3ce48 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..e657d3b02547 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-print-support.ts @@ -0,0 +1,116 @@ +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 `printPslContract` 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' | 'printPslContract' | 'printPsl' | 'load', + Mock + > +> = { + createFamilyInstance: vi.fn(), + deserializeContract: vi.fn(), + printPslContract: 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 } }); +} + +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.printPslContract + .mockReset() + .mockReturnValue({ document: { kind: 'psl-document' }, sourceSettings: {} }); + mocks.deserializeContract.mockReset().mockReturnValue(VALIDATED_CONTRACT); + mocks.createFamilyInstance.mockReset().mockReturnValue({ + deserializeContract: mocks.deserializeContract, + printPslContract: mocks.printPslContract, + }); + 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..06ae472f5e86 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/contract-print.output-path.test.ts @@ -0,0 +1,141 @@ +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, + 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 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 index d07af61202c8..7f693e16408d 100644 --- 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 @@ -1,103 +1,21 @@ -import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { readdir, readFile, writeFile } from 'node:fs/promises'; import { ok } from '@internal/utils/result'; import { structuredError } from '@internal/utils/structured-error'; -import type { ErroredEnvelope, MountedTree, StreamEvent } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { BIN_GROUPS } from '../../src/orm/cli'; -import { createContractPrintCommand } from '../../src/orm/contract/print'; -import { createTestProjectDir } from '../utils/test-project-dir'; - -const PSL = 'model User {\n id Int @id\n}\n'; - -/** - * The command is mounted from the factory with a control-client double and a - * printer double injected, so no module mocking is involved and the doubles - * are scoped to this file. - */ -const mocks = { - printPslContract: vi.fn(), - getPslBlockDescriptors: vi.fn(), - close: vi.fn(), - printPsl: vi.fn(), - load: vi.fn(), -}; - -const commands: MountedTree = { - 'contract print': createContractPrintCommand({ - createControlClient: () => ({ - printPslContract: mocks.printPslContract, - getPslBlockDescriptors: mocks.getPslBlockDescriptors, - close: mocks.close, - }), - printPsl: mocks.printPsl, - }), -}; -const groups = BIN_GROUPS; - -const dirs: string[] = []; - -async function projectDir(): Promise { - const dir = createTestProjectDir('orm-print'); - dirs.push(dir); - return dir; -} - -afterEach(async () => { - for (const dir of dirs.splice(0)) { - await rm(dir, { recursive: true, force: true }); - } -}); - -beforeEach(() => { - mocks.printPslContract.mockReset().mockReturnValue({ kind: 'psl-document' }); - mocks.getPslBlockDescriptors.mockReset().mockReturnValue({}); - mocks.close.mockReset().mockResolvedValue(undefined); - mocks.printPsl.mockReset().mockReturnValue(PSL); - mocks.load.mockReset().mockResolvedValue(ok({ roots: {}, domain: {} })); -}); - -const DESCRIPTOR = { - familyId: 'sql', - targetId: 'postgres', - version: '1.0.0', - create: () => ({}), -}; - -function ormConfig(dir: string, overrides: Record = {}): Record { - return { - family: { - kind: 'family', - id: 'sql', - familyId: 'sql', - version: '1.0.0', - emission: {}, - create: () => ({}), - }, - target: { ...DESCRIPTOR, kind: 'target', id: 'postgres' }, - 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, - }; -} - -function harness(config: Record) { - return createTestCli({ commands, groups, config: { orm: config } }); -} - -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; -} +import { describe, expect, it } from 'vitest'; +import { + erroredEnvelope, + harness, + mocks, + ormConfig, + PSL, + projectDir, + useContractPrintDoubles, + VALIDATED_CONTRACT, +} from './contract-print-support'; + +useContractPrintDoubles(); describe('contract print', () => { it('settles as a completed envelope carrying the written path and the schema it read', async () => { @@ -116,7 +34,7 @@ describe('contract print', () => { }); }); - it('opens the written file with a header naming the source it printed', async () => { + it('describes the written file as printed from the source it read', async () => { const dir = await projectDir(); await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); @@ -124,13 +42,27 @@ describe('contract print', () => { expect(mocks.printPsl).toHaveBeenCalledWith( { kind: 'psl-document' }, expect.objectContaining({ - headerComment: - '// use prisma-8\n// Printed from prisma/schema.prisma by `prisma contract print`.', + 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.printPslContract).toHaveBeenCalledWith(VALIDATED_CONTRACT); + }); + it('publishes through a staged rename, leaving no temporary file behind', async () => { const dir = await projectDir(); @@ -139,11 +71,12 @@ describe('contract print', () => { expect(await readdir(join(dir, 'generated'))).toEqual(['contract.prisma']); }); - it('writes nothing when the run is cancelled while the client is closing', async () => { + it('writes nothing when the run is cancelled while the source loads', async () => { const dir = await projectDir(); const controller = new AbortController(); - mocks.close.mockImplementation(async () => { + mocks.load.mockImplementation(async () => { controller.abort(); + return ok({ roots: {}, domain: {} }); }); const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { @@ -220,61 +153,64 @@ describe('contract print', () => { expect(run.stdout).toBe(''); }); - it('names the default control policy the config must set, because a PSL file cannot carry it', async () => { + it('warns that the config must set the default control policy, because a PSL file cannot carry it', async () => { const dir = await projectDir(); - mocks.load.mockResolvedValue(ok({ roots: {}, domain: {}, defaultControlPolicy: 'external' })); + mocks.printPslContract.mockReturnValue({ + document: { kind: 'psl-document' }, + sourceSettings: { defaultControlPolicy: 'external' }, + }); 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: "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({ defaultControlPolicy: 'external' }); expect(run.presented?.presentation.next).toEqual([ { kind: 'user-choice', label: - "Point contract in prisma.config.ts at generated/contract.prisma, with defaultControlPolicy 'external' on its source", + "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('refuses to write over the schema it reads and leaves that file untouched', async () => { + it('prints no warning when the contract has no default control policy', 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 }, - ); + 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_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(); + expect(run.events).not.toContainEqual(expect.objectContaining({ severity: 'warn' })); }); - it('refuses an output path inside a directory the contract source reads', async () => { + 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'], load: mocks.load }, - output: join(dir, 'generated', 'contract.json'), + 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/nested/contract.prisma', '--json'], - { cwd: dir }, - ); + const run = await harness(config).run(['contract', 'print', '--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'); + 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('prints a PSL source the same way as any other source', async () => { @@ -293,8 +229,7 @@ describe('contract print', () => { expect(mocks.printPsl).toHaveBeenCalledWith( { kind: 'psl-document' }, expect.objectContaining({ - headerComment: - '// use prisma-8\n// Printed from contract.prisma by `prisma contract print`.', + description: 'Printed from contract.prisma by `prisma contract print`.', }), ); expect(await readFile(join(dir, 'generated', 'contract.prisma'), 'utf-8')).toBe(PSL); @@ -319,13 +254,13 @@ describe('contract print', () => { it('errors when the family cannot print the contract as PSL', async () => { const dir = await projectDir(); - mocks.printPslContract.mockReturnValue(undefined); + 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(mocks.close).toHaveBeenCalled(); + expect(await readdir(dir)).not.toContain('generated'); }); it('writes no file at the output path when the target refuses part of the contract', async () => { diff --git a/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts b/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts index 7b09f6771f2b..4c782ee34cde 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/define-command.test.ts @@ -180,7 +180,7 @@ describe('config finalization at the command boundary', () => { config: { orm: { contract: { - source: { load: async () => ({ ok: true, value: {} }) }, + source: { format: 'typescript', load: async () => ({ ok: true, value: {} }) }, output: './src/prisma/contract.json', }, migrations: { dir: './migrations' }, 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 7fda67e48366..b1a68456b782 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 @@ -48,6 +48,7 @@ const config = { }, contract: { source: { + format: 'psl', inputs: ['./schema.prisma'], load: async () => ({ ok: true, value: { targetFamily: 'sql' } }), }, @@ -251,6 +252,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/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 9407f7f5bb20..6597423342af 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 @@ -89,6 +89,7 @@ function createLoadedConfig({ return { contract: { source: { + format: 'typescript', ...(inputs === undefined ? {} : { inputs: inputs.map((input) => toAbsolutePath(input)) as SourceInputs }), 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 39e139605d9f..80a8c7e436c1 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -11,7 +11,9 @@ import type { MigrationPlanOperation, OperationPreview, OperationPreviewCapable, + PrintedPslContract, PslContractInferCapable, + PslContractPrintCapable, SchemaDiffIssue, SchemaViewCapable, SignDatabaseResult, @@ -207,6 +209,7 @@ export interface SqlControlFamilyInstance extends ControlFamilyInstance<'sql', SqlSchemaIRNode>, SchemaViewCapable, PslContractInferCapable, + PslContractPrintCapable>, OperationPreviewCapable, SqlFamilyInstanceState { /** @@ -277,8 +280,6 @@ export interface SqlControlFamilyInstance inferPslContract(schemaIR: SqlSchemaIRNode): PslDocumentAst; - printPslContract(contract: Contract): PslDocumentAst; - lowerAst( ast: AnyQueryAst | DdlNode, context: LowererContext, @@ -586,8 +587,7 @@ export function createSqlFamilyInstance( SqlControlTargetDescriptor, 'reading the optional target-descriptor inferPslContract hook' >(target).inferPslContract; - // Contract→PSL printing is also target logic (it owns the dialect type and - // default maps), so it is read off the descriptor the same way. Absent for + // Contract→PSL printing is read off the descriptor the same way. Absent for // targets without `contract print`. const targetPrintPslContract = blindCast< SqlControlTargetDescriptor, @@ -1024,7 +1024,7 @@ export function createSqlFamilyInstance( return targetInferPslContract(schemaIR, describedContracts); }, - printPslContract(contract: Contract): PslDocumentAst { + printPslContract(contract: Contract): PrintedPslContract { if (!targetPrintPslContract) { throw sqlFamilyError( 'CONTRACT.PRINT_UNSUPPORTED', @@ -1036,9 +1036,14 @@ export function createSqlFamilyInstance( }, ); } - return targetPrintPslContract(contract, { - authoringTypes: stack.authoringContributions.type, - }); + return { + document: targetPrintPslContract(contract, { + authoringContributions: stack.authoringContributions, + codecLookup: stack.codecLookup, + dataTypeLookup: stack.dataTypeLookup, + }), + sourceSettings: ifDefined('defaultControlPolicy', contract.defaultControlPolicy), + }; }, lowerAst( diff --git a/packages/3-extensions/postgres/README.md b/packages/3-extensions/postgres/README.md index c43706e1d4ad..645061f880f6 100644 --- a/packages/3-extensions/postgres/README.md +++ b/packages/3-extensions/postgres/README.md @@ -71,6 +71,34 @@ 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` 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`. @@ -99,7 +127,7 @@ 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` writes the Prisma 8 PSL that produces the same contract, with a header naming the schema it came from. 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`. 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 writes nothing. +When the project is ready to stop reading the Prisma 7 file, `prisma contract print` writes the Prisma 8 PSL that produces the same contract, with a header naming the schema it came from. 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 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: 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/9-public/@prisma/orm-family-sql/package.json b/packages/9-public/@prisma/orm-family-sql/package.json index 31924bbfdb19..bac51938876c 100644 --- a/packages/9-public/@prisma/orm-family-sql/package.json +++ b/packages/9-public/@prisma/orm-family-sql/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-ast": "./dist/family__psl-ast.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-postgres/package.json b/packages/9-public/@prisma/orm-postgres/package.json index 8efffc1605df..378d3e11b834 100644 --- a/packages/9-public/@prisma/orm-postgres/package.json +++ b/packages/9-public/@prisma/orm-postgres/package.json @@ -86,6 +86,7 @@ "./family/ir": "./dist/family__ir.mjs", "./family/migration": "./dist/family__migration.mjs", "./family/pack": "./dist/family__pack.mjs", + "./family/psl-ast": "./dist/family__psl-ast.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 4920f8ee461c..591d72f8f37f 100644 --- a/packages/9-public/@prisma/orm-sqlite/package.json +++ b/packages/9-public/@prisma/orm-sqlite/package.json @@ -83,6 +83,7 @@ "./family/ir": "./dist/family__ir.mjs", "./family/migration": "./dist/family__migration.mjs", "./family/pack": "./dist/family__pack.mjs", + "./family/psl-ast": "./dist/family__psl-ast.mjs", "./family/psl-infer": "./dist/family__psl-infer.mjs", "./family/runtime": "./dist/family__runtime.mjs", "./family/verify": "./dist/family__verify.mjs", diff --git a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts index 105b791db4b0..51d2b2c8635e 100644 --- a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts @@ -8,7 +8,9 @@ * 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. + * 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'; @@ -29,6 +31,42 @@ import { 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'); @@ -170,6 +208,60 @@ withTempDir(({ createTempDir }) => { ); }); + 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, ['--json']); + expect(print.exitCode, output(print)).toBe(0); + expect(print.presented?.data).toMatchObject({ + psl: { path: 'prisma/contract.prisma' }, + 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, { 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.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/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/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 index 152a19dff24f..3d8bb7b098cd 100644 --- a/upgrade-instructions/pending/contract-print/app/instructions.md +++ b/upgrade-instructions/pending/contract-print/app/instructions.md @@ -2,6 +2,10 @@ 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 @@ -9,3 +13,13 @@ changes: 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 without writing when it does not (for example, a schema with a `view` block). 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 index cf521fba428b..0864bb0598ec 100644 --- a/upgrade-instructions/pending/contract-print/extension/instructions.md +++ b/upgrade-instructions/pending/contract-print/extension/instructions.md @@ -1,15 +1,35 @@ --- changes: - id: contract-source-format-is-psl-or-typescript - summary: A contract source declares format 'psl' or 'typescript', or none; OpaqueContractSourceProvider is removed. + 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-ast-export + summary: mapDefault, its option types, the PslTypeMap types and toEnumMemberName moved from @internal/family-sql/psl-infer to @internal/family-sql/psl-ast. --- # 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'`, or no `format`). `OpaqueContractSourceProvider` and any other `format` string are gone. +`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`. -If an extension defines a contract source with another `format` value, set `format: 'psl'` when its inputs are PSL text, and otherwise leave `format` out, which makes it a TypeScript source. Replace imports of `OpaqueContractSourceProvider` with `ContractSourceProvider`. +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-ast` + +`contract print` uses some of what `@internal/family-sql/psl-infer` exported, so those exports moved to the new subpath `@internal/family-sql/psl-ast`: `mapDefault`, `DefaultMappingOptions`, `DefaultMappingResult`, `PslTypeMap`, `PslTypeReference`, `PslTypeResolution` and `toEnumMemberName`. Import them from `@internal/family-sql/psl-ast`. Everything else stays in `@internal/family-sql/psl-infer`. From ec590b0cad30bc43a56423bf6a4821ec341e2616 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 18:03:42 +0200 Subject: [PATCH 45/55] fix(postgres): contract print writes every part of a contract or refuses it by name, and a test proves it for every Postgres contract in the repo The printer no longer drops or changes part of a contract without saying so. It writes checks by their prefix, lists of value objects as lists, and policy expressions with any character a PSL string can hold. It refuses by name what PSL cannot carry back: a relation into another contract space, a table or column no model reaches, a value set nothing produces, a generator beside a storage default, a non-string index option, a name that is not a PSL identifier, and the rest listed under CONTRACT.PRINT_UNSUPPORTED in the error reference. A new integration test prints every emitted Postgres contract tracked in the repo, reads it back through the PSL source with the stack it needs, and requires the same serialized contract or an expected refusal. Five TypeScript-authored contracts round-trip too. The printer asks the configured stack for column types, value-object field types and default data types through SqlPslPrintContext. The inverse of a type constructor call lives in framework-components beside the constructor. Builders shared with contract infer move to psl-ast, print-psl-contract.ts is split by job, and every refusal lives in one module that matches the error reference. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 82 +- .../1-core/framework-components/README.md | 2 +- .../src/exports/authoring.ts | 5 + .../src/exports/control.ts | 2 + .../shared/authoring-type-constructor-call.ts | 93 ++ .../authoring-type-constructor-call.test.ts | 133 +++ .../test/control-capabilities.test.ts | 16 + .../test/print-roundtrip.test.ts | 47 +- .../contract-prisma7/test/support.ts | 54 - .../contract-prisma7/tsconfig.json | 2 +- .../test/fixtures/contract.d.ts | 92 -- .../test/fixtures/contract.json | 96 -- packages/2-sql/9-family/README.md | 4 +- packages/2-sql/9-family/package.json | 1 + .../src/core/control-target-descriptor.ts | 34 +- .../default-mapping.ts | 0 .../9-family/src/core/psl-ast/psl-names.ts | 75 ++ .../9-family/src/core/psl-ast/type-map.ts | 21 + .../psl-contract-infer/name-transforms.ts | 84 +- .../core/psl-contract-infer/printer-config.ts | 23 +- .../2-sql/9-family/src/exports/control.ts | 1 + .../2-sql/9-family/src/exports/psl-ast.ts | 10 + .../2-sql/9-family/src/exports/psl-infer.ts | 21 +- ...ontrol-instance.print-psl-contract.test.ts | 111 ++ .../default-mapping.test.ts | 2 +- .../9-family/test/psl-ast/psl-names.test.ts | 29 + .../name-transforms.test.ts | 28 - packages/2-sql/9-family/tsdown.config.ts | 1 + .../3-targets/3-targets/postgres/README.md | 2 +- .../3-targets/postgres/src/core/authoring.ts | 92 +- .../index-attributes.ts} | 78 +- .../src/core/psl-ast/native-enum-block.ts | 49 + .../postgres-type-map.ts | 2 +- .../{psl-infer => psl-ast}/psl-literals.ts | 21 - .../postgres/src/core/psl-ast/unique-name.ts | 15 + .../src/core/psl-infer/infer-default-codec.ts | 20 +- .../src/core/psl-infer/infer-enum-blocks.ts | 58 +- .../src/core/psl-infer/infer-model-blocks.ts | 55 +- .../src/core/psl-infer/infer-names.ts | 16 +- .../src/core/psl-infer/infer-policy-blocks.ts | 23 +- .../src/core/psl-infer/infer-psl-contract.ts | 8 +- .../junction-relation-field-names.ts | 2 +- .../psl-infer/postgres-default-mapping.ts | 2 +- .../src/core/psl-print/column-defaults.ts | 78 ++ .../src/core/psl-print/column-types.ts | 125 +++ .../core/psl-print/contract-model-index.ts | 77 +- .../src/core/psl-print/domain-types.ts | 148 +++ .../{print-enum-blocks.ts => enum-blocks.ts} | 59 +- ...cution-defaults.ts => generated-values.ts} | 72 +- .../src/core/psl-print/model-attributes.ts | 198 ++++ .../core/psl-print/print-column-default.ts | 79 -- .../src/core/psl-print/print-column-type.ts | 173 --- .../src/core/psl-print/print-domain-types.ts | 185 ---- .../src/core/psl-print/print-psl-contract.ts | 781 -------------- .../core/psl-print/print-relation-fields.ts | 214 ---- .../src/core/psl-print/print-rls-blocks.ts | 155 --- .../src/core/psl-print/psl-contract.ts | 342 ++++++ .../postgres/src/core/psl-print/refusals.ts | 982 ++++++++++++++++++ .../src/core/psl-print/relation-fields.ts | 381 +++++++ .../src/core/psl-print/row-level-security.ts | 185 ++++ .../src/core/psl-print/scalar-fields.ts | 213 ++++ .../3-targets/postgres/src/exports/control.ts | 4 +- .../postgres-type-map.test.ts | 2 +- .../postgres/test/psl-infer/fixtures.ts | 4 +- .../test/psl-infer/infer-psl-contract.test.ts | 1 - .../print-psl.data-type-defaults.test.ts | 2 +- .../print-psl/print-psl.enums.test.ts | 2 +- .../print-psl.top-level-blocks.test.ts | 2 +- .../test/psl-policy-authoring.test.ts | 35 + .../test/psl-print/column-defaults.test.ts | 98 ++ .../test/psl-print/enum-blocks.test.ts | 165 +++ .../test/psl-print/generated-values.test.ts | 112 ++ .../test/psl-print/model-attributes.test.ts | 119 +++ .../postgres/test/psl-print/print-context.ts | 90 ++ .../test/psl-print/print-psl-contract.test.ts | 783 -------------- .../postgres/test/psl-print/print-support.ts | 156 +++ .../test/psl-print/refusal-support.ts | 142 +++ .../test/psl-print/refusals-entries.test.ts | 283 +++++ .../test/psl-print/refusals-fields.test.ts | 373 +++++++ .../test/psl-print/refusals-list.test.ts | 32 + .../test/psl-print/refusals-models.test.ts | 436 ++++++++ .../test/psl-print/refusals-storage.test.ts | 380 +++++++ .../test/psl-print/relation-fields.test.ts | 130 +++ .../postgres/test/helpers/psl-print.ts | 129 +++ .../postgres/test/printed-type-codecs.test.ts | 6 +- .../test/psl-print-constraints.test.ts} | 73 +- .../test/psl-print-generated-values.test.ts | 78 ++ .../postgres/test/psl-print-roundtrip.test.ts | 227 ++-- pnpm-lock.yaml | 3 + projects/prisma7-contract-source/spec.md | 28 +- test/integration/package.json | 1 + .../cli.migration-plan-ref-aware.e2e.test.ts | 1 + ...res-contract-roundtrip.integration.test.ts | 310 ++++++ ...ension-types-roundtrip.integration.test.ts | 124 +-- ...ipt-contract-roundtrip.integration.test.ts | 70 ++ .../extension/instructions.md | 2 +- 96 files changed, 7071 insertions(+), 3291 deletions(-) create mode 100644 packages/1-framework/1-core/framework-components/src/shared/authoring-type-constructor-call.ts create mode 100644 packages/1-framework/1-core/framework-components/test/authoring-type-constructor-call.test.ts delete mode 100644 packages/2-sql/4-lanes/relational-core/test/fixtures/contract.d.ts delete mode 100644 packages/2-sql/4-lanes/relational-core/test/fixtures/contract.json rename packages/2-sql/9-family/src/core/{psl-contract-infer => psl-ast}/default-mapping.ts (100%) create mode 100644 packages/2-sql/9-family/src/core/psl-ast/psl-names.ts create mode 100644 packages/2-sql/9-family/src/core/psl-ast/type-map.ts create mode 100644 packages/2-sql/9-family/src/exports/psl-ast.ts create mode 100644 packages/2-sql/9-family/test/control-instance.print-psl-contract.test.ts rename packages/2-sql/9-family/test/{psl-contract-infer => psl-ast}/default-mapping.test.ts (99%) create mode 100644 packages/2-sql/9-family/test/psl-ast/psl-names.test.ts rename packages/3-targets/3-targets/postgres/src/core/{psl-infer/infer-index-attributes.ts => psl-ast/index-attributes.ts} (62%) create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-ast/native-enum-block.ts rename packages/3-targets/3-targets/postgres/src/core/{psl-infer => psl-ast}/postgres-type-map.ts (99%) rename packages/3-targets/3-targets/postgres/src/core/{psl-infer => psl-ast}/psl-literals.ts (67%) create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-ast/unique-name.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/column-defaults.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/column-types.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/domain-types.ts rename packages/3-targets/3-targets/postgres/src/core/psl-print/{print-enum-blocks.ts => enum-blocks.ts} (69%) rename packages/3-targets/3-targets/postgres/src/core/psl-print/{print-execution-defaults.ts => generated-values.ts} (60%) create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/model-attributes.ts delete mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts delete mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts delete mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts delete mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts delete mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts delete mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/print-rls-blocks.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/psl-contract.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/refusals.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/relation-fields.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/row-level-security.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-print/scalar-fields.ts rename packages/3-targets/3-targets/postgres/test/{psl-infer => psl-ast}/postgres-type-map.test.ts (98%) create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/column-defaults.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/enum-blocks.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/generated-values.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/model-attributes.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/print-context.ts delete mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/print-support.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/refusal-support.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/refusals-entries.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/refusals-fields.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/refusals-list.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/refusals-models.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/refusals-storage.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/relation-fields.test.ts create mode 100644 packages/3-targets/6-adapters/postgres/test/helpers/psl-print.ts rename packages/{2-sql/2-authoring/contract-prisma7/test/print-constraints.test.ts => 3-targets/6-adapters/postgres/test/psl-print-constraints.test.ts} (78%) create mode 100644 packages/3-targets/6-adapters/postgres/test/psl-print-generated-values.test.ts create mode 100644 test/integration/test/psl-print/every-postgres-contract-roundtrip.integration.test.ts create mode 100644 test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 0fe81a4ea338..6543c068ca74 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -249,24 +249,76 @@ 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` was asked to write its output over a file the project needs: `prisma.config.ts` in the invocation directory, 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` was asked to write its output 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. 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 the target's descriptor has no `printPslContract` hook (meta: `targetId`); -- no PSL type in the configured stack produces a column's codec and native type. Add the extension that contributes the type to the config (meta: `coordinate`, `nativeType`, `codecId`); -- a column's literal default has no PSL literal that reads back as the stored value, or a domain enum column defaults to a value that is not a member (meta: `coordinate`, `pslTypeName`); -- a column is generated by a generator that has no PSL default function, generates a value on update other than through a temporal preset, or pairs the wall-clock-now generator with a different generator (meta: `coordinate`, `onCreate`, `onUpdate`); -- a to-one relation has no foreign key behind it (meta: `model`, `field`), or a foreign key has no relation that travels it (meta: `namespaceId`, `table`, `columns`); -- a field's type is a union of types (meta: `coordinate`, `kind`), a field is a dictionary (meta: `coordinate`), or a value-object field uses a codec the Postgres target does not own (meta: `coordinate`, `codecId`); -- a column has its own control policy (meta: `coordinate`, `control`), or a model has an owner (meta: `namespaceId`, `modelName`, `owner`); -- one model name is declared in more than one namespace (meta: `modelName`, `namespaces`), or a domain enum or value object is declared outside the default namespace (meta: `namespaceId`, `names`); -- a table has row-level security enabled but no model, or a policy is on a table with no model or names a role that is not a PSL identifier (meta: `namespaceId`, `table`); -- 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`), or a storage entry that is not an entity of its kind (meta: `namespaceId`, `kind`, `name`). +`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 `printPslContract` 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. + +- 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's storage names a field or column that does not exist (meta: `namespaceId`, `modelName`, `field`); + - 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's domain namespace is not the namespace of its table (meta: `namespaceId`, `modelName`, `tableNamespaceId`); + - 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`). +- 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`); + - 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 name PSL writes as an identifier is not one: a 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`); + - a storage entry is not an entity of its kind (meta: `namespaceId`, `kind`, `name`). ### CONTRACT.DATA_TYPE_DUPLICATE @@ -400,6 +452,8 @@ An emitted model type name, formed as `_`, is not a TypeScript A relation, foreign key, junction (`through`) reference, or context declaration names a model that is not declared in the contract. Raised while lowering/building a SQL contract. Payload: `sourceModel`, `relationName`, `targetModel`. +Also raised by `contract print` when the loaded contract names a model or table it does not declare: a model is stored in a table the storage does not declare (payload: `namespaceId`, `modelName`, `table`), a variant's base is not declared or does not list it as a variant (payload: `namespaceId`, `modelName`), or a relation targets a model the contract does not declare (payload: `model`, `field`, `target`). `contract print` loads the contract from its source on every run, so the source produced the broken contract: fix the model if the source is a TypeScript contract, and otherwise report the bug to the source that produced it. + ### CONTRACT.MODEL_VARIANT_MISSING A polymorphic base names a variant that is not a model in the same namespace, so the `Any` union in `contract.d.ts` cannot be formed. Raised while emitting `contract.d.ts`. Payload: `base`, `variantName`. @@ -650,6 +704,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"`. + ## ORM ### ORM.AGGREGATE_OPERATION_RESERVED diff --git a/packages/1-framework/1-core/framework-components/README.md b/packages/1-framework/1-core/framework-components/README.md index cd8cb6355889..b697ddc48a0a 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/exports/authoring.ts b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts index ea5b46931f7a..6ba47c7cc281 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 type { AuthoringArgRef, AuthoringArgumentDescriptor, 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 f22f92175f17..adfd60907cd5 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,8 +10,10 @@ export type { DiffSubjectGranularity, MigratableTargetDescriptor, OperationPreviewCapable, + PrintedPslContract, PslContractInferCapable, PslContractPrintCapable, + PslSourceSettings, SchemaSubjectClassifierCapable, SchemaViewCapable, } from '../control/control-capabilities'; 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..048ee92cd021 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 @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { hasOperationPreview, hasPslContractInfer, + hasPslContractPrint, hasSchemaView, } from '../src/control/control-capabilities'; import type { ControlFamilyInstance } from '../src/control/control-instances'; @@ -48,6 +49,21 @@ describe('hasPslContractInfer', () => { }); }); +describe('hasPslContractPrint', () => { + it('returns true when instance exposes printPslContract function', () => { + const instance = { + ...baseInstance, + printPslContract: () => ({ document: SYNTHETIC_AST, sourceSettings: {} }), + } as ControlFamilyInstance<'sql', unknown>; + + expect(hasPslContractPrint(instance)).toBe(true); + }); + + it('returns false when instance does not declare printPslContract', () => { + expect(hasPslContractPrint(baseInstance)).toBe(false); + }); +}); + describe('hasSchemaView', () => { it('returns true when instance exposes toSchemaView function', () => { const instance = { diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/print-roundtrip.test.ts b/packages/2-sql/2-authoring/contract-prisma7/test/print-roundtrip.test.ts index 8abc6a8edb44..f17cf3f4ae9d 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/print-roundtrip.test.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/print-roundtrip.test.ts @@ -3,12 +3,16 @@ import { fileURLToPath } from 'node:url'; import type { Contract } from '@internal/contract/types'; import type { SqlStorage } from '@internal/sql-contract/types'; import { prisma7PostgresBinding } from '@internal/target-postgres/prisma7-binding'; -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 { + printAndReadBack, + printContract, + serializedWithoutCapabilities, +} from '../../../../3-targets/6-adapters/postgres/test/helpers/psl-print'; import { prisma7Contract } from '../src/provider'; -import { loadPrintedPsl, postgresSourceContext, printContractAsPsl } from './support'; +import { postgresSourceContext } from './support'; const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); @@ -18,16 +22,10 @@ const cases = readdirSync(fixturesDir, { withFileTypes: true }) .filter((name) => existsSync(join(fixturesDir, name, 'expected-contract.json'))) .sort(); -/** Why each fixture does not round-trip yet, naming every cause it has. */ -const expectedFailures: ReadonlyMap = new Map([ - [ - 'junction-name-in-other-schema', - ['one model name in two namespaces cannot be written in a Prisma 8 schema'], - ], - [ - 'relation-name-in-two-schemas', - ['one model name in two namespaces cannot be written in a Prisma 8 schema'], - ], +/** 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 { @@ -50,25 +48,30 @@ async function loadPrisma7Fixture( return blindCast, 'the Prisma 7 source yields a SQL contract'>(result.value); } -function serialize(contract: Contract): unknown { - return JSON.parse(JSON.stringify(new PostgresContractSerializer().serializeContract(contract))); -} - async function roundTrip(caseName: string): Promise { const schemaPath = prisma7SchemaPath(caseName); const prisma7 = await loadPrisma7Fixture(schemaPath, caseName); - const printedContract = await loadPrintedPsl(printContractAsPsl(prisma7)); + const printedContract = await printAndReadBack(prisma7); - expect(serialize(printedContract)).toEqual(serialize(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 reasons = expectedFailures.get(caseName); - if (reasons !== undefined) { - it.fails(`${caseName} (${reasons.join('; ')})`, async () => { - await roundTrip(caseName); + 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; } diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/support.ts b/packages/2-sql/2-authoring/contract-prisma7/test/support.ts index 417cd226ef44..32acb68263e1 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/support.ts +++ b/packages/2-sql/2-authoring/contract-prisma7/test/support.ts @@ -1,5 +1,3 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import postgresAdapter from '@internal/adapter-postgres/control'; import type { ContractSourceContext } from '@internal/config/config-types'; @@ -7,16 +5,10 @@ import type { Contract } from '@internal/contract/types'; import postgresDriver from '@internal/driver-postgres/control'; import sql from '@internal/family-sql/control'; import { createControlStack } 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 { prisma7PostgresBinding } from '@internal/target-postgres/prisma7-binding'; import { PostgresContractSerializer } from '@internal/target-postgres/runtime'; -import { postgresCreateNamespace } from '@internal/target-postgres/types'; -import { blindCast } from '@internal/utils/casts'; import { dirname, join } from 'pathe'; import { prisma7Contract } from '../src/provider'; @@ -68,52 +60,6 @@ export function loadFixtureSchema(caseName: string, contextFor = postgresSourceC ); } -/** Prints a contract as Prisma 8 PSL text, the way `contract print` does. */ -export function printContractAsPsl(contract: Contract): string { - const context = postgresSourceContext([]); - const ast = postgres.printPslContract?.(contract, { - authoringTypes: context.authoringContributions.type, - }); - if (ast === undefined) { - throw new Error('the Postgres target descriptor has no printPslContract hook'); - } - return printPsl(ast, { - pslBlockDescriptors: context.authoringContributions.pslBlockDescriptors, - codecLookup: context.codecLookup, - }); -} - -/** Loads printed PSL text back through the Prisma 8 PSL source. */ -export async function loadPrintedPsl(text: string): Promise> { - const directory = mkdtempSync(join(tmpdir(), 'prisma7-print-')); - const printedPath = join(directory, 'contract.prisma'); - writeFileSync(printedPath, text); - const result = await prismaContract(printedPath, { - target: postgresPackRef, - createNamespace: postgresCreateNamespace, - enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, - }).source.load(postgresSourceContext([printedPath])); - if (!result.ok) { - throw new Error(`the printed PSL did not load: ${JSON.stringify(result.failure.diagnostics)}`); - } - return blindCast, 'the Postgres PSL source yields a SQL contract'>( - result.value, - ); -} - -/** The storage table of a loaded contract, or a thrown error naming what is missing. */ -export function storageTable( - contract: Contract, - tableName: string, - namespaceId = 'public', -) { - const table = contract.storage.namespaces[namespaceId]?.entries.table?.[tableName]; - if (table === undefined) { - throw new Error(`the contract has no table "${namespaceId}"."${tableName}"`); - } - return table; -} - interface SerializedTable { readonly columns: Record>; readonly foreignKeys: readonly Record[]; diff --git a/packages/2-sql/2-authoring/contract-prisma7/tsconfig.json b/packages/2-sql/2-authoring/contract-prisma7/tsconfig.json index a5d18dc62396..b39bfbe462c1 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/tsconfig.json +++ b/packages/2-sql/2-authoring/contract-prisma7/tsconfig.json @@ -1,7 +1,7 @@ { "extends": ["@repo/tsconfig/base"], "compilerOptions": { - "rootDir": "." + "rootDir": "../../.." }, "include": ["src", "test", "*.config.*", "package.json"], "exclude": ["dist", "dist-tsc", "dist-tsc-prod", "node_modules"] 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 12bb24de5a8e..843f4c8e2621 100644 --- a/packages/2-sql/9-family/README.md +++ b/packages/2-sql/9-family/README.md @@ -104,7 +104,7 @@ Family instances implement domain actions: - **`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. -- **`printPslContract(contract)`**: Builds the PSL document AST that reads back as the same contract, for `contract print`. Delegates to the target descriptor's optional `printPslContract` hook. Throws `CONTRACT.PRINT_UNSUPPORTED` when the target has no hook, or when the contract holds something PSL cannot express. +- **`printPslContract(contract)`**: Builds the PSL document AST that reads back as the same contract, for `contract print`. Delegates to the target descriptor's optional `printPslContract` hook, and passes it the stack's authoring contributions, codecs and data types (`SqlPslPrintContext`), 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. The descriptor is "pure data + factory" - it only provides the hook and factory method. All family-specific logic lives on the instance. @@ -136,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-ast`**: 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 1b463053b006..78ee578f1bb8 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-ast": "./dist/psl-ast.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-target-descriptor.ts b/packages/2-sql/9-family/src/core/control-target-descriptor.ts index 2e3efbf14b66..19dfaf7ea425 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,6 +1,7 @@ import type { Contract } from '@internal/contract/types'; -import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import type { CodecLookup, DataTypeLookup } from '@internal/framework-components/codec'; import type { + AssembledAuthoringContributions, ContractSerializer, DiffSubjectGranularity, MigratableTargetDescriptor, @@ -30,6 +31,18 @@ export interface SqlDescribedContractSpace { readonly contract: Contract; } +/** + * The parts of the composed stack a printer writes a PSL contract with. The PSL contract source + * reads the printed file with the same parts, so the printer 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 SqlPslPrintContext { + readonly authoringContributions: Pick; + readonly codecLookup: CodecLookup; + readonly dataTypeLookup: DataTypeLookup; +} + export interface SqlControlTargetDescriptor< TTargetId extends string, TTargetDetails, @@ -64,18 +77,15 @@ export interface SqlControlTargetDescriptor< describedContracts?: readonly SqlDescribedContractSpace[], ) => PslDocumentAst; /** - * Contract→PSL printing for `contract print`. Like {@link inferPslContract} - * it produces a PSL document, but from an assembled contract instead of a - * database schema. The document reads back as the same contract; the hook - * throws `CONTRACT.PRINT_UNSUPPORTED` for a contract PSL cannot express. - * Target logic, because it owns the dialect maps, so it lives on the - * descriptor. Optional: targets without `contract print` omit it, and the - * family instance throws when it is absent. + * Contract→PSL printing for `contract print`. 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 printer 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 printPslContract?: ( - contract: TContract, - context: { readonly authoringTypes: AuthoringTypeNamespace }, - ) => PslDocumentAst; + readonly printPslContract?: (contract: TContract, context: SqlPslPrintContext) => 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/psl-contract-infer/default-mapping.ts b/packages/2-sql/9-family/src/core/psl-ast/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-ast/default-mapping.ts diff --git a/packages/2-sql/9-family/src/core/psl-ast/psl-names.ts b/packages/2-sql/9-family/src/core/psl-ast/psl-names.ts new file mode 100644 index 000000000000..95d507b9ac4b --- /dev/null +++ b/packages/2-sql/9-family/src/core/psl-ast/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-ast/type-map.ts b/packages/2-sql/9-family/src/core/psl-ast/type-map.ts new file mode 100644 index 000000000000..5ffd45ab486d --- /dev/null +++ b/packages/2-sql/9-family/src/core/psl-ast/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..1651b15a6ce7 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-ast/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..7d3b783e4d56 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-ast/default-mapping'; +import type { PslTypeMap } from '../psl-ast/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 ee459ad97af8..1fef56a997e9 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, + SqlPslPrintContext, } from '../core/control-target-descriptor'; export type { ContractToSchemaIROptions, diff --git a/packages/2-sql/9-family/src/exports/psl-ast.ts b/packages/2-sql/9-family/src/exports/psl-ast.ts new file mode 100644 index 000000000000..f255a4ed0993 --- /dev/null +++ b/packages/2-sql/9-family/src/exports/psl-ast.ts @@ -0,0 +1,10 @@ +/** + * 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-ast/default-mapping'; +export { mapDefault } from '../core/psl-ast/default-mapping'; +export { toEnumMemberName } from '../core/psl-ast/psl-names'; +export type { PslTypeMap, PslTypeReference, PslTypeResolution } from '../core/psl-ast/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..be999b434dc9 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-ast`. */ -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.print-psl-contract.test.ts b/packages/2-sql/9-family/test/control-instance.print-psl-contract.test.ts new file mode 100644 index 000000000000..eb9ee8549d0f --- /dev/null +++ b/packages/2-sql/9-family/test/control-instance.print-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, hasPslContractPrint } 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: 'printed', + namespaces: [], + span: { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 0, line: 1, column: 1 }, + }, +}; + +function stackWithPrinter( + printPslContract: ((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' }), + ...(printPslContract === undefined ? {} : { printPslContract }), + } 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 printPslContract', () => { + it("returns the target's document and no source settings for a contract without a default control policy", () => { + const printer = vi.fn(() => DOCUMENT); + const instance = createSqlFamilyInstance(stackWithPrinter(printer)); + const contract = contractWith(); + + expect(instance.printPslContract(contract)).toEqual({ document: DOCUMENT, sourceSettings: {} }); + expect(printer).toHaveBeenCalledWith(contract, expect.anything()); + }); + + it('passes the target hook the stack parts the PSL source reads with', () => { + const printer = vi.fn(() => DOCUMENT); + const stack = stackWithPrinter(printer); + const contract = contractWith(); + + createSqlFamilyInstance(stack).printPslContract(contract); + + expect(printer).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(stackWithPrinter(() => DOCUMENT)); + + expect(instance.printPslContract(contractWith('external'))).toEqual({ + document: DOCUMENT, + sourceSettings: { defaultControlPolicy: 'external' }, + }); + }); + + it('passes the capability check contract print makes, even when the target cannot print', () => { + expect(hasPslContractPrint(createSqlFamilyInstance(stackWithPrinter(undefined)))).toBe(true); + }); + + it('raises CONTRACT.PRINT_UNSUPPORTED when the target descriptor has no printPslContract', () => { + const instance = createSqlFamilyInstance(stackWithPrinter(undefined)); + + expect(() => instance.printPslContract(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-ast/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-ast/default-mapping.test.ts index ee01e6828107..3afe3c8c02a7 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts +++ b/packages/2-sql/9-family/test/psl-ast/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-ast/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-ast/psl-names.test.ts b/packages/2-sql/9-family/test/psl-ast/psl-names.test.ts new file mode 100644 index 000000000000..87e96a1273c6 --- /dev/null +++ b/packages/2-sql/9-family/test/psl-ast/psl-names.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { toEnumMemberName } from '../../src/core/psl-ast/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..00e503fd951b 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-ast.ts', 'src/exports/psl-infer.ts', ], }); diff --git a/packages/3-targets/3-targets/postgres/README.md b/packages/3-targets/3-targets/postgres/README.md index 969a83f6a707..8fd40175c189 100644 --- a/packages/3-targets/3-targets/postgres/README.md +++ b/packages/3-targets/3-targets/postgres/README.md @@ -25,7 +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 `printPslContract` descriptor hook, which `prisma contract print` uses. It takes an assembled Postgres contract, from any source, and returns the Prisma 8 PSL document that reads back as the same contract. 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 the PSL type that reads back with its codec: the native type's usual type, or else a type constructor the configured stack contributes, such as `TimestamptzString(3)` or an extension's `pgvector.Vector(3)`; the family passes the stack's 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`); 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; it reuses that hook's literal, index, enum-block and default-mapping builders. 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. +- **Contract-to-PSL Printing**: Implements the `printPslContract` descriptor hook, which `prisma contract print` uses. It takes an assembled Postgres contract, from any source, and returns the Prisma 8 PSL document that reads back as the same contract. 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-ast/`: 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/src/core/authoring.ts b/packages/3-targets/3-targets/postgres/src/core/authoring.ts index 556d64128bc6..aaab1a5c33ea 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/psl-infer/infer-index-attributes.ts b/packages/3-targets/3-targets/postgres/src/core/psl-ast/index-attributes.ts similarity index 62% rename from packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-ast/index-attributes.ts index d0feb567e6a8..0ca4a646e636 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-index-attributes.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-ast/index-attributes.ts @@ -33,15 +33,45 @@ export interface IndexAttributeSource { 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. 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:`. + * 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) { @@ -54,20 +84,7 @@ export function buildIndexAttribute( 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)}"`)); - } + args.push(namingArg(naming, index.name)); if (index.where !== undefined) { args.push(namedArg('where', `"${escapePslString(index.where)}"`)); @@ -97,19 +114,22 @@ export interface CheckAttributeSource { } /** - * 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. + * 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): PslModelAttribute { +export function buildCheckAttribute( + check: CheckAttributeSource, + naming: AttributeNaming = { kind: 'exact' }, +): PslModelAttribute { return buildAttribute('model', 'check', [ namedArg('expression', `"${escapePslString(check.expression)}"`), - namedArg('map', `"${escapePslString(check.name)}"`), + namingArg(naming, check.name), ]); } diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-ast/native-enum-block.ts b/packages/3-targets/3-targets/postgres/src/core/psl-ast/native-enum-block.ts new file mode 100644 index 000000000000..7866d125ce85 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-ast/native-enum-block.ts @@ -0,0 +1,49 @@ +import { toEnumMemberName } from '@internal/family-sql/psl-ast'; +import type { + PslExtensionBlock, + PslExtensionBlockParamValue, +} from '@internal/framework-components/psl-ast'; +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(); + 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/postgres-type-map.ts b/packages/3-targets/3-targets/postgres/src/core/psl-ast/postgres-type-map.ts similarity index 99% 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-ast/postgres-type-map.ts index e417a617d008..b86bf4509a2f 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-ast/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-ast'; const POSTGRES_TO_PSL: Record = { text: 'String', 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-ast/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-ast/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-ast/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-ast/unique-name.ts b/packages/3-targets/3-targets/postgres/src/core/psl-ast/unique-name.ts new file mode 100644 index 000000000000..b49b7e78224a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-ast/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 80127260c540..69de56b5ee1f 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/printed-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'; @@ -42,12 +44,6 @@ export const CODEC_ID_BY_PRINTED_TYPE: ReadonlyMap = new Map([ ['Char', 'sql/char@1'], ]); -/** The data type a codec represents; an enum column's default is a member name, so it reads as text. */ -export function dataTypeForCodec(codecId: string, isEnum: boolean): DataTypeId | undefined { - return postgresCodecDescriptorRegistry.descriptorFor(isEnum ? PG_TEXT_CODEC_ID : codecId) - ?.dataType; -} - /** * The data type a column of `pslTypeName` holds values of, which is the one its codec represents. * An enum column's default is a member name, which is text either way, so it reads through the text 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 b51346224e95..f0aededb884c 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-ast/native-enum-block'; +import { createUniqueFieldName } from '../psl-ast/unique-name'; +import { buildTopLevelNameMap, type TopLevelNameResult } from './infer-names'; export const PSL_SCALAR_TYPE_NAMES = new Set([ 'String', @@ -66,44 +59,3 @@ export function buildNativeEnumBlocks( return { enumNameMap, enumBlocks }; } - -/** 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(); - 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-model-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts index 1c5c9e4dfde8..ac965e8edb71 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-ast'; +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-ast/index-attributes'; import { buildAttribute, buildMapAttribute, buildSimpleConstraintFieldAttribute, namedArg, - parseColumnDefault, parseDefaultAttributeString, positionalArg, SYNTHETIC_SPAN, -} from './psl-literals'; +} from '../psl-ast/psl-literals'; +import { createUniqueFieldName } from '../psl-ast/unique-name'; +import { dataTypeForPrintedType, printedDefaultReadsBack } from './infer-default-codec'; +import { buildDanglingForeignKeyWarning, type DanglingForeignKeyInfo } from './infer-foreign-keys'; +import { resolveColumnFieldName, type TableColumnFieldNameMap } from './infer-names'; export function buildModel( table: SqlTableIR, @@ -471,3 +471,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..498235c42f6d 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-ast/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..74880e6d190e 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-ast/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..3e82fcb0a151 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-ast/postgres-type-map'; +import { SYNTHETIC_SPAN } from '../psl-ast/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..7164db339286 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-ast/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..70f1c563576b 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-ast'; 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..e1cfa52a29a4 --- /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 { SqlPslPrintContext } from '@internal/family-sql/control'; +import { mapDefault } from '@internal/family-sql/psl-ast'; +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-ast/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: SqlPslPrintContext; +}): 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..4321a9fd2a43 --- /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-ast'; +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-ast/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 index 5acf2d8eb783..8456dea8969f 100644 --- 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 @@ -1,11 +1,18 @@ import type { Contract, ContractModelBase, CrossReference } from '@internal/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import type { SqlModelStorage, SqlStorage, StorageTable } from '@internal/sql-contract/types'; +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 { blindCast } from '@internal/utils/casts'; import { postgresError } from '../errors'; /** One domain model paired with the storage table it is bridged to. */ -export interface ModelEntry { +export interface ModelWithTable { readonly namespaceId: string; readonly name: string; readonly tableName: string; @@ -20,24 +27,27 @@ export interface ModelEntry { /** How a model relates to a polymorphic base, when it is a variant of one. */ export interface VariantInfo { - readonly base: ModelEntry; + 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: the unbound slot is `namespace unbound`. */ +/** The name a contract namespace is written under in PSL. */ export function pslNamespaceName(namespaceId: string): string { - return namespaceId === UNBOUND_NAMESPACE_ID ? 'unbound' : namespaceId; + return namespaceId === UNBOUND_NAMESPACE_ID ? UNBOUND_PSL_NAMESPACE_NAME : namespaceId; } export function modelCoordinate(namespaceId: string, modelName: string): string { - return `${namespaceId}\u0000${modelName}`; + return JSON.stringify([namespaceId, modelName]); } -/** Every domain model of the contract, in declaration order, with its storage table. */ -export function indexContractModels(contract: Contract): readonly ModelEntry[] { - const entries: ModelEntry[] = []; +/** + * Every domain model of the contract, in declaration order, with its storage table. A model whose + * domain namespace is not the namespace of its table is refused before this runs. + */ +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); @@ -46,10 +56,10 @@ export function indexContractModels(contract: Contract): readonly Mo if (table === undefined) { throw postgresError( 'CONTRACT.MODEL_UNKNOWN', - `contract print: model "${namespaceId}.${name}" is bridged to table "${storage.namespaceId}"."${storage.table}", which the contract's storage does not declare.`, + `contract print: model "${namespaceId}.${name}" is stored in table "${storage.namespaceId}"."${storage.table}", which the contract's storage does not declare.`, { why: "The printer reads each model's columns, keys and indexes off its storage table.", - fix: 'Re-emit the contract from its source.', + fix: 'The contract source produced a model without its table. Fix the model if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', meta: { namespaceId, modelName: name, table: storage.table }, }, ); @@ -72,19 +82,22 @@ export function indexContractModels(contract: Contract): readonly Mo /** Models keyed by their `(namespace, model)` coordinate. */ export function modelsByCoordinate( - entries: readonly ModelEntry[], -): ReadonlyMap { + 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 modelCoordinate(reference.namespace, reference.model); + 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: ModelEntry, - byCoordinate: ReadonlyMap, + entry: ModelWithTable, + byCoordinate: ReadonlyMap, ): VariantInfo | undefined { if (entry.model.base === undefined) return undefined; const base = byCoordinate.get(crossReferenceCoordinate(entry.model.base)); @@ -95,7 +108,7 @@ export function variantInfo( `contract print: model "${entry.namespaceId}.${entry.name}" extends base "${entry.model.base.namespace}.${entry.model.base.model}", which the contract does not declare, or which does not list it as a variant.`, { why: 'A variant is written as `@@base(Base, "value")`, and the value comes from the base model.', - fix: 'Re-emit the contract from its source.', + fix: 'The contract source produced a variant its base does not list. Fix the variant or its base if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', meta: { namespaceId: entry.namespaceId, modelName: entry.name }, }, ); @@ -106,3 +119,33 @@ export function variantInfo( 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..f41c862c52e0 --- /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 { SqlPslPrintContext } from '@internal/family-sql/control'; +import type { PslTypeMap } from '@internal/family-sql/psl-ast'; +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-ast/psl-literals'; +import { buildColumnType, type PslColumnType } from './column-types'; +import { + refuseNonIdentifier, + refuseUnwritableFieldShape, + 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: SqlPslPrintContext, +): 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: SqlPslPrintContext; + 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: SqlPslPrintContext; + 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]) => { + refuseNonIdentifier('value object', name); + return { + kind: 'compositeType', + name, + fields: Object.entries(valueObject.fields).map(([fieldName, field]): PslField => { + refuseNonIdentifier('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: SqlPslPrintContext, +): PslTypesBlock | undefined { + const declarations: PslNamedTypeDeclaration[] = []; + for (const [name, instance] of Object.entries(contract.storage.types ?? {})) { + refuseNonIdentifier('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/print-enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/enum-blocks.ts similarity index 69% rename from packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-print/enum-blocks.ts index ab202bef3d5b..8516f5bc691b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-enum-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/enum-blocks.ts @@ -1,5 +1,4 @@ import type { ContractEnum } from '@internal/contract/types'; -import { toEnumName } from '@internal/family-sql/psl-infer'; import type { PslExtensionBlock, PslExtensionBlockParamValue, @@ -7,17 +6,27 @@ import type { 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-infer/infer-enum-blocks'; -import { createUniqueFieldName } from '../psl-infer/infer-names'; -import { SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; +import { buildNativeEnumBlock } from '../psl-ast/native-enum-block'; +import { SYNTHETIC_SPAN } from '../psl-ast/psl-literals'; +import { + refuseNativeEnumControl, + refuseNativeEnumWithoutValueSet, + refuseNonIdentifier, +} from './refusals'; -/** One `enum { … }` block per domain enum, each member written as `Name = ` under `@@type`. */ +/** + * 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 => { + refuseNonIdentifier('enum', name); const parameters: Record = {}; for (const member of domainEnum.members) { + refuseNonIdentifier('enum member', member.name); parameters[member.name] = { kind: 'value', raw: JSON.stringify(member.value), @@ -52,6 +61,8 @@ 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 { @@ -62,13 +73,13 @@ function sameValues(left: readonly unknown[], right: readonly unknown[]): boolea * 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, so the block is named after - * that value set when one is found: 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 the only such value - * set. If none is found, the block name is derived from the type name the way - * `contract infer` derives one, kept apart from every other block name in the - * namespace. `@@map` carries the type name whenever the two differ. + * 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; @@ -85,9 +96,9 @@ export function buildNativeEnumBlocksForNamespace(input: { const claimed = new Set(valueSetNamesByTypeName.values()); const unreferenced: { entryName: string; nativeEnum: PostgresNativeEnum }[] = []; - const blockNamesByTypeName = new Map(); const nameByEntry = new Map(); for (const [entryName, nativeEnum] of input.nativeEnums) { + refuseNativeEnumControl(input.namespaceId, nativeEnum); const { typeName } = nativeEnum; const fromColumn = valueSetNamesByTypeName.get(typeName) ?? @@ -99,35 +110,27 @@ export function buildNativeEnumBlocksForNamespace(input: { nameByEntry.set(entryName, fromColumn); } - const unmatched: { entryName: string; nativeEnum: PostgresNativeEnum }[] = []; for (const { entryName, nativeEnum } of unreferenced) { const candidates = [...input.valueSets] .filter(([name, values]) => !claimed.has(name) && sameValues(values, nativeEnum.members)) .map(([name]) => name); - const exact = candidates.find((name) => name === entryName); - const chosen = exact ?? (candidates.length === 1 ? candidates[0] : undefined); - if (chosen === undefined) { - unmatched.push({ entryName, nativeEnum }); - continue; - } + const chosen = candidates.find((name) => name === entryName) ?? candidates[0]; + if (chosen === undefined) refuseNativeEnumWithoutValueSet(input.namespaceId, nativeEnum); claimed.add(chosen); nameByEntry.set(entryName, chosen); } - const blockNames = new Set(nameByEntry.values()); - for (const { entryName, nativeEnum } of unmatched) { - const derived = createUniqueFieldName(toEnumName(nativeEnum.typeName).name, blockNames); - blockNames.add(derived); - nameByEntry.set(entryName, derived); - } - 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; + refuseNonIdentifier('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 }; + return { blocks, blockNamesByTypeName, derivedValueSets }; } diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/generated-values.ts similarity index 60% rename from packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-print/generated-values.ts index 80ddf9e2d72b..46cf700e4cba 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-execution-defaults.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/generated-values.ts @@ -10,11 +10,19 @@ import { PG_TIMESTAMPTZ_STRING_CODEC_ID, PG_TIMESTAMPTZ_TEMPORAL_CODEC_ID, } from '../codec-ids'; -import { postgresError } from '../errors'; import { postgresNowGeneratorIdFor } from '../now-generators'; -import { buildAttribute, namedArg, positionalArg } from '../psl-infer/psl-literals'; +import { buildAttribute, namedArg, positionalArg } from '../psl-ast/psl-literals'; +import { + refuseGeneratorOnUpdate, + refuseGeneratorWithoutPslFunction, + refuseNowGeneratorPairedWithAnother, +} from './refusals'; -/** The `temporal.*` preset each codec with a "now" generator is authored through. */ +/** + * The `temporal.*` field preset each codec with a "now" generator is authored through. The target + * contributes the presets; `adapter-postgres/test/psl-print-generated-values.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'], @@ -23,7 +31,11 @@ const TEMPORAL_PRESET_NAMES: ReadonlyMap = new Map([ [PG_TIMESTAMPTZ_DATE_CODEC_ID, 'timestamptzJsDate'], ]); -/** The `@default((…))` call each id-generator is authored through. */ +/** + * The `@default((…))` call each id generator is authored through. The Postgres adapter registers + * the functions; `adapter-postgres/test/psl-print-generated-values.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([ @@ -44,20 +56,20 @@ export interface TemporalPresetPhases { readonly onUpdate: boolean; } -export type PrintedExecutionDefault = +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 - * prints as a `@default(())` attribute, and a wall-clock-now generator - * prints as the `temporal.*` preset the column's codec is authored through. + * 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 printExecutionDefault(input: { +export function buildExecutionDefault(input: { readonly executionDefault: ExecutionMutationDefault; readonly codecId: string; readonly coordinate: string; -}): PrintedExecutionDefault { +}): BuiltExecutionDefault { const { executionDefault, codecId, coordinate } = input; const onCreateId = executionDefault.onCreate?.id; const onUpdateId = executionDefault.onUpdate?.id; @@ -69,43 +81,21 @@ export function printExecutionDefault(input: { const onUpdate = onUpdateId === nowGeneratorId; if (onCreate || onUpdate) { if ((onCreateId !== undefined && !onCreate) || (onUpdateId !== undefined && !onUpdate)) { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: column ${coordinate} takes the wall-clock-now generator in one phase and "${onCreate ? onUpdateId : onCreateId}" in the other, which cannot be written in Prisma 8 PSL.`, - { - why: '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.', - fix: 'Keep authoring this contract in its current source.', - meta: { coordinate, onCreate: onCreateId, onUpdate: onUpdateId }, - }, - ); + refuseNowGeneratorPairedWithAnother({ + coordinate, + onCreate: onCreateId, + onUpdate: onUpdateId, + other: onCreate ? onUpdateId : onCreateId, + }); } return { kind: 'temporal', phases: { presetName, onCreate, onUpdate } }; } } - if (onUpdateId !== undefined) { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: column ${coordinate} generates a value on update ("${onUpdateId}"), which cannot be written in Prisma 8 PSL.`, - { - why: '`@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.', - fix: 'Keep authoring this contract in its current source.', - meta: { coordinate, onCreate: onCreateId, onUpdate: onUpdateId }, - }, - ); - } + const phases = { coordinate, onCreate: onCreateId, onUpdate: onUpdateId }; + if (onUpdateId !== undefined) refuseGeneratorOnUpdate(phases); const call = onCreateId === undefined ? undefined : GENERATOR_CALLS.get(onCreateId); - if (call === undefined) { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: column ${coordinate} is generated by "${onCreateId}", which has no PSL default function.`, - { - why: 'The printer writes each generator as the PSL default function that produces it, and this generator has none.', - fix: 'Keep authoring this contract in its current source.', - meta: { coordinate, onCreate: onCreateId, onUpdate: onUpdateId }, - }, - ); - } + if (call === undefined) refuseGeneratorWithoutPslFunction(phases); return { kind: 'attribute', attribute: buildAttribute('field', 'default', [ 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..e7aee143a9fc --- /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/resolution'; +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-ast/index-attributes'; +import { buildAttribute, buildMapAttribute, positionalArg } from '../psl-ast/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/print-column-default.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts deleted file mode 100644 index 1ca4c221d296..000000000000 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-default.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { ContractEnum } from '@internal/contract/types'; -import { mapDefault } from '@internal/family-sql/psl-infer'; -import type { PslFieldAttribute } from '@internal/framework-components/psl-ast'; -import type { StorageColumn } from '@internal/sql-contract/types'; -import { postgresError } from '../errors'; -import { dataTypeForCodec } from '../psl-infer/infer-default-codec'; -import { createPostgresDefaultMapping } from '../psl-infer/postgres-default-mapping'; -import { - buildAttribute, - parseDefaultAttributeString, - positionalArg, -} from '../psl-infer/psl-literals'; - -const defaultMapping = createPostgresDefaultMapping(); - -/** - * 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, the same mapping `contract infer` uses; 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 Postgres data type, or - * when no PSL literal of that data type reads back as the stored value. - */ -export function printColumnDefault(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; -}): 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) { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: column ${coordinate} defaults to ${JSON.stringify(columnDefault.value)}, which is not a member of enum ${input.pslTypeName}.`, - { - why: 'A default on a domain enum column is written as the member name, and no member carries this value.', - fix: 'Give the column a default that is one of the enum members, or drop it.', - meta: { coordinate, pslTypeName: input.pslTypeName }, - }, - ); - } - return buildAttribute('field', 'default', [positionalArg(memberName)]); - } - - const result = mapDefault(columnDefault, { - ...defaultMapping, - columnDataType: dataTypeForCodec(input.column.codecId, input.isEnum), - list: input.column.many === true, - }); - if (result === undefined) { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: column ${coordinate} has a default that cannot be written in Prisma 8 PSL: no data type the ${input.pslTypeName} type takes writes ${columnDefault.kind === 'literal' ? JSON.stringify(columnDefault.value) : columnDefault.expression}.`, - { - why: 'Writing the value in any other form would parse, but the PSL source would read it back as a different value.', - fix: 'Replace the default with a database expression default, or drop the default before printing.', - meta: { coordinate, pslTypeName: input.pslTypeName }, - }, - ); - } - return parseDefaultAttributeString(result.attribute); -} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts deleted file mode 100644 index 1ba023dacd23..000000000000 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-column-type.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { PslTypeMap } from '@internal/family-sql/psl-infer'; -import { - type AuthoringTypeConstructorDescriptor, - type AuthoringTypeNamespace, - instantiateAuthoringTypeConstructor, - isAuthoringArgRef, - isAuthoringTypeConstructorDescriptor, -} from '@internal/framework-components/authoring'; -import type { PslTypeConstructorCall } from '@internal/framework-components/psl-ast'; -import type { StorageColumn } from '@internal/sql-contract/types'; -import { postgresError } from '../errors'; -import { CODEC_ID_BY_PRINTED_TYPE } from '../psl-infer/infer-default-codec'; -import { positionalArg, SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; - -/** 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(', ')})`; -} - -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 sameTypeParams( - left: Readonly> | undefined, - right: Readonly> | undefined, -): boolean { - return JSON.stringify(left ?? {}) === JSON.stringify(right ?? {}); -} - -/** - * The positional arguments that make `descriptor` produce exactly the column's - * codec, native type and type parameters, when each parameter comes straight - * from one argument; `undefined` when the constructor cannot produce them. - */ -function argumentsFor( - descriptor: AuthoringTypeConstructorDescriptor, - column: StorageColumn, -): readonly unknown[] | undefined { - if (descriptor.entityRefArg !== undefined || descriptor.output.codecId !== column.codecId) { - return undefined; - } - const args: unknown[] = []; - for (const [key, template] of Object.entries(descriptor.output.typeParams ?? {})) { - const value = column.typeParams?.[key]; - if (value !== undefined && isAuthoringArgRef(template) && template.path === undefined) { - args[template.index] = value; - } - } - if (args.some((value) => value === undefined)) return undefined; - try { - const produced = instantiateAuthoringTypeConstructor(descriptor, args); - return produced.nativeType === column.nativeType && - sameTypeParams(produced.typeParams, column.typeParams) - ? args - : undefined; - } catch { - return undefined; - } -} - -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 PSL type position for a storage column. The native type's default PSL - * type, from the type map `contract infer` uses, is written when it reads back - * with the column's codec. Otherwise the first type constructor the configured - * stack contributes that produces exactly the column's codec, native type and - * type parameters is written, 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 printColumnType(input: { - readonly column: StorageColumn; - readonly typeMap: PslTypeMap; - readonly authoringTypes: AuthoringTypeNamespace; - readonly enumBlockNames: ReadonlyMap; - readonly coordinate: string; -}): PslColumnType { - const { column, typeMap, enumBlockNames, coordinate } = input; - - const enumBlockName = column.valueSet?.entityName ?? enumBlockNames.get(column.nativeType); - if (column.codecId === 'pg/enum@1' && 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 resolution = typeMap.resolve(nativeTypeText(column)); - if ( - !('unsupported' in resolution) && - CODEC_ID_BY_PRINTED_TYPE.get(resolution.pslType.name) === column.codecId - ) { - return typeCall([resolution.pslType.name], resolution.pslType.args ?? []); - } - - for (const { path, descriptor } of typeConstructors(input.authoringTypes)) { - const args = argumentsFor(descriptor, column); - if (args !== undefined) { - return typeCall( - path, - args.map((value) => (typeof value === 'string' ? JSON.stringify(value) : String(value))), - ); - } - } - - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: column ${coordinate} has native type "${column.nativeType}" with codec "${column.codecId}", and no PSL type in the configured stack produces that pair.`, - { - why: '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.', - fix: 'Add the extension that contributes this type to the config, or keep authoring this contract in its current source.', - meta: { coordinate, nativeType: column.nativeType, codecId: column.codecId }, - }, - ); -} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts deleted file mode 100644 index 55ae703266ea..000000000000 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-domain-types.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { Contract, ContractField } from '@internal/contract/types'; -import type { PslTypeMap } from '@internal/family-sql/psl-infer'; -import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; -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 { blindCast } from '@internal/utils/casts'; -import { ifDefined } from '@internal/utils/defined'; -import type { JsonValue } from '@internal/utils/json'; -import type { AnyPostgresCodecDescriptor } from '../codec-descriptor'; -import { codecDescriptorMap } from '../codec-type-map'; -import { postgresError } from '../errors'; -import { DEFAULT_NAMESPACE_ID } from '../namespace-ids'; -import { SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; -import { type PslColumnType, printColumnType } from './print-column-type'; - -const descriptorsByCodecId: ReadonlyMap = new Map( - Object.values(codecDescriptorMap).map((descriptor) => [descriptor.codecId, descriptor]), -); - -/** - * The storage column a domain field would occupy, so its PSL type prints - * through the same map as a real column. A value object's fields have no - * column of their own; their codec names the native type. - */ -function columnForDomainField(field: ContractField, coordinate: string): StorageColumn { - if (field.type.kind !== 'scalar') { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: field ${coordinate} has a ${field.type.kind} 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, or keep authoring this contract in its current source.', - meta: { coordinate, kind: field.type.kind }, - }, - ); - } - const descriptor = descriptorsByCodecId.get(field.type.codecId); - if (descriptor === undefined) { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: field ${coordinate} uses codec "${field.type.codecId}", which the Postgres printer cannot name a native type for.`, - { - why: 'A value-object field has no storage column, so its PSL type is derived from its codec; this codec is not one the Postgres target owns.', - fix: 'Keep authoring this contract in its current source.', - meta: { coordinate, codecId: field.type.codecId }, - }, - ); - } - return new StorageColumn({ - nativeType: descriptor.nativeTypeFor({ - codecId: field.type.codecId, - ...ifDefined( - 'typeParams', - blindCast(field.type.typeParams), - ), - }), - codecId: field.type.codecId, - nullable: field.nullable, - ...ifDefined('many', field.many), - ...ifDefined('typeParams', field.type.typeParams), - ...ifDefined('valueSet', field.valueSet), - }); -} - -function refuseDictField(field: ContractField, coordinate: string): void { - if (field.dict !== true) return; - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: field ${coordinate} is a dictionary, which cannot be written in Prisma 8 PSL.`, - { - why: 'PSL writes a field as one value or a list; it has no form for a keyed dictionary.', - fix: 'Keep authoring this contract in its current source.', - meta: { coordinate }, - }, - ); -} - -/** The PSL type position of a domain field: a value object by name, or a scalar through the type map. */ -export function printDomainFieldType(input: { - readonly field: ContractField; - readonly coordinate: string; - readonly typeMap: PslTypeMap; - readonly authoringTypes: AuthoringTypeNamespace; - readonly enumBlockNames: ReadonlyMap; -}): PslColumnType { - const { field, coordinate } = input; - refuseDictField(field, coordinate); - if (field.type.kind === 'valueObject') { - return { typeName: field.type.name }; - } - return printColumnType({ - column: columnForDomainField(field, coordinate), - typeMap: input.typeMap, - authoringTypes: input.authoringTypes, - 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 authoringTypes: AuthoringTypeNamespace; - readonly enumBlockNames: ReadonlyMap; -}): readonly PslCompositeType[] { - const valueObjects = input.contract.domain.namespaces[input.namespaceId]?.valueObjects ?? {}; - const names = Object.keys(valueObjects); - if (input.namespaceId !== DEFAULT_NAMESPACE_ID && names.length > 0) { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: namespace "${input.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.`, - { - why: 'A `type` block written in any namespace reads back into the default namespace, so the value object would move.', - fix: 'Move the value object to the default namespace, or keep authoring this contract in its current source.', - meta: { namespaceId: input.namespaceId, names }, - }, - ); - } - return Object.entries(valueObjects).map(([name, valueObject]) => ({ - kind: 'compositeType', - name, - fields: Object.entries(valueObject.fields).map(([fieldName, field]): PslField => { - const { typeName, typeConstructor } = printDomainFieldType({ - field, - coordinate: `"${input.namespaceId}".${name}.${fieldName}`, - typeMap: input.typeMap, - authoringTypes: input.authoringTypes, - 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, - authoringTypes: AuthoringTypeNamespace, -): PslTypesBlock | undefined { - const declarations: PslNamedTypeDeclaration[] = []; - for (const [name, instance] of Object.entries(contract.storage.types ?? {})) { - const { typeName, typeConstructor } = printColumnType({ - column: new StorageColumn({ - nativeType: instance.nativeType, - codecId: instance.codecId, - nullable: false, - ...ifDefined('typeParams', instance.typeParams), - }), - typeMap, - authoringTypes, - 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/print-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts deleted file mode 100644 index 659ba5b66476..000000000000 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-psl-contract.ts +++ /dev/null @@ -1,781 +0,0 @@ -import type { Contract, ContractEnum, ExecutionMutationDefault } from '@internal/contract/types'; -import type { PslTypeMap } from '@internal/family-sql/psl-infer'; -import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; -import type { - PslDocumentAst, - PslExtensionBlock, - PslField, - PslFieldAttribute, - PslModel, - PslModelAttribute, - PslNamespace, -} from '@internal/framework-components/psl-ast'; -import { - makePslNamespace, - makePslNamespaceEntries, - UNSPECIFIED_PSL_NAMESPACE_ID, -} from '@internal/framework-components/psl-ast'; -import type { - ForeignKey, - SqlStorage, - StorageColumn, - StorageTable, -} from '@internal/sql-contract/types'; -import { pslFieldMapName, pslModelMapName } from '@internal/sql-contract-psl/resolution'; -import { escapePslString } from '@internal/sql-relational-core/ast'; -import { - composeCheckWirePrefix, - computeCheckContentHash, - formatWireName, -} from '@internal/sql-schema-ir/naming'; -import { ifDefined } from '@internal/utils/defined'; -import { postgresRenderCheckExpressions } from '../check-expressions'; -import { postgresError } from '../errors'; -import { DEFAULT_NAMESPACE_ID } from '../namespace-ids'; -import { PostgresNativeEnum } from '../postgres-native-enum'; -import { - buildCheckAttribute, - buildIndexAttribute, - buildModelConstraintAttribute, -} from '../psl-infer/infer-index-attributes'; -import { createPostgresTypeMap } from '../psl-infer/postgres-type-map'; -import { - buildAttribute, - buildMapAttribute, - namedArg, - positionalArg, - SYNTHETIC_SPAN, -} from '../psl-infer/psl-literals'; -import { - indexContractModels, - type ModelEntry, - modelCoordinate, - modelsByCoordinate, - pslNamespaceName, - type VariantInfo, - variantInfo, -} from './contract-model-index'; -import { printColumnDefault } from './print-column-default'; -import { printColumnType } from './print-column-type'; -import { buildCompositeTypes, buildTypesBlock, printDomainFieldType } from './print-domain-types'; -import { - buildDomainEnumBlocks, - buildNativeEnumBlocksForNamespace, - type NativeEnumEmission, -} from './print-enum-blocks'; -import { printExecutionDefault, temporalPresetArguments } from './print-execution-defaults'; -import { - collectRelations, - foreignKeyFor, - isOwningRelation, - junctionParentRelation, - owningPartner, - printRelationField, - type RelationEntry, - relationKey, - relationName, -} from './print-relation-fields'; -import { buildPolicyBlocks, buildRoleBlocks, rlsEnabledTables } from './print-rls-blocks'; - -/** The storage entry kinds the printer writes; any other kind is refused, whichever component defines it. */ -const PRINTED_ENTRY_KINDS = new Set(['table', 'valueSet', 'native_enum', 'rls', 'policy', 'role']); - -function executionDefaultsByColumn( - contract: Contract, -): ReadonlyMap { - const byColumn = new Map(); - for (const entry of contract.execution?.mutations.defaults ?? []) { - byColumn.set(`${entry.ref.namespace}\u0000${entry.ref.table}\u0000${entry.ref.column}`, entry); - } - return byColumn; -} - -function refuseColumnControl(column: StorageColumn, coordinate: string): void { - if (column.control === undefined) return; - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: column ${coordinate} carries its own control policy "${column.control}", which cannot be written in Prisma 8 PSL.`, - { - why: 'PSL declares a control policy per model with `@@control`; a field has no control attribute.', - fix: 'Move the policy to the model, or keep authoring this contract in its current source.', - meta: { coordinate, control: column.control }, - }, - ); -} - -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 PslFieldAttribute[] { - const attributes: PslFieldAttribute[] = []; - if (input.isSingleColumnId) { - attributes.push( - buildAttribute( - 'field', - 'id', - input.primaryKeyName === undefined - ? [] - : [namedArg('map', `"${escapePslString(input.primaryKeyName)}"`)], - ), - ); - } - const columnDefault = - input.generatedDefault ?? - printColumnDefault({ - column: input.column, - pslTypeName: input.pslTypeName, - isEnum: input.isEnum, - domainEnum: input.domainEnum, - namespaceId: input.namespaceId, - tableName: input.tableName, - columnName: input.columnName, - }); - 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; -} - -function buildScalarFields(input: { - readonly entry: ModelEntry; - readonly variant: VariantInfo | undefined; - readonly enums: NativeEnumEmission; - readonly domainEnums: Readonly>; - readonly typeMap: PslTypeMap; - readonly authoringTypes: AuthoringTypeNamespace; - readonly executionDefaults: ReadonlyMap; -}): readonly PslField[] { - const { entry, variant, enums, domainEnums, typeMap, authoringTypes, executionDefaults } = input; - const primaryKeyColumns = variant === undefined ? (entry.table.primaryKey?.columns ?? []) : []; - const fields: PslField[] = []; - - for (const [fieldName, fieldStorage] of Object.entries(entry.storage.fields)) { - const columnName = fieldStorage.column; - const column = entry.table.columns[columnName]; - const field = entry.model.fields[fieldName]; - if (column === undefined || field === undefined) continue; - const coordinate = `"${entry.namespaceId}"."${entry.tableName}"."${columnName}"`; - refuseColumnControl(column, coordinate); - const columnType = - column.typeRef !== undefined - ? { typeName: column.typeRef } - : field.type.kind === 'valueObject' - ? printDomainFieldType({ - field, - coordinate, - typeMap, - authoringTypes, - enumBlockNames: enums.blockNamesByTypeName, - }) - : printColumnType({ - column, - typeMap, - authoringTypes, - enumBlockNames: enums.blockNamesByTypeName, - coordinate, - }); - let { typeName, typeConstructor } = columnType; - - const execution = executionDefaults.get( - `${entry.namespaceId}\u0000${entry.tableName}\u0000${columnName}`, - ); - let generatedDefault: PslFieldAttribute | undefined; - if (execution !== undefined) { - const printed = printExecutionDefault({ - executionDefault: execution, - codecId: column.codecId, - coordinate, - }); - if (printed.kind === 'temporal') { - typeName = `temporal.${printed.phases.presetName}`; - typeConstructor = { - kind: 'typeConstructor', - path: ['temporal', printed.phases.presetName], - args: temporalPresetArguments({ - phases: printed.phases, - precision: column.typeParams?.['precision'], - }), - span: SYNTHETIC_SPAN, - }; - } else { - generatedDefault = printed.attribute; - } - } - - fields.push({ - kind: 'field', - name: fieldName, - typeName, - ...ifDefined('typeConstructor', typeConstructor), - // A single-table variant's own columns are nullable in storage while the - // field is not; the field is what `?` writes. - optional: field.nullable, - list: column.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@1', - domainEnum: - column.codecId === 'pg/enum@1' || column.valueSet === undefined - ? undefined - : domainEnums[column.valueSet.entityName], - generatedDefault, - }), - span: SYNTHETIC_SPAN, - }); - } - - return fields; -} - -/** - * The checks the PSL source derives itself for this table's columns, by the - * name it gives them, so they are not also written as `@@check`. - */ -function derivedCheckNames( - table: StorageTable, - tableName: string, - valueSetMembers: ReadonlyMap, -): ReadonlySet { - const names = new Set(); - for (const [columnName, column] of Object.entries(table.columns)) { - const valueSetName = column.valueSet?.entityName; - const memberValues = valueSetName === undefined ? undefined : valueSetMembers.get(valueSetName); - const waived = new Set(column.noCheck ?? []); - for (const candidate of postgresRenderCheckExpressions({ - tableName, - columnName, - many: column.many === true, - memberValues, - })) { - if (waived.has(candidate.kind)) continue; - names.add( - formatWireName( - composeCheckWirePrefix(tableName, columnName, candidate.kind), - computeCheckContentHash(candidate.expression), - ), - ); - } - } - return names; -} - -/** - * 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: ModelEntry, - variant: VariantInfo | undefined, - singleTableVariants: readonly ModelEntry[], - 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, - ); -} - -/** The foreign key the PSL source synthesizes from a multi-table variant to its base. */ -function isVariantLinkForeignKey(fk: ForeignKey, variant: VariantInfo | undefined): boolean { - return ( - variant !== undefined && - !variant.singleTable && - fk.target.namespaceId === variant.base.namespaceId && - fk.target.tableName === variant.base.tableName - ); -} - -function buildModelAttributes(input: { - readonly entry: ModelEntry; - readonly variant: VariantInfo | undefined; - readonly singleTableVariants: readonly ModelEntry[]; - readonly derivedChecks: ReadonlySet; - readonly rlsEnabled: boolean; -}): readonly PslModelAttribute[] { - const { entry, variant, singleTableVariants, derivedChecks } = 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 (derivedChecks.has(check.name) || !owns(undefined)) continue; - attributes.push(buildCheckAttribute(check)); - } - for (const index of entry.table.indexes) { - if (!owns(index.columns)) continue; - attributes.push(buildIndexAttribute(index, index.columns?.map(fieldNameOf))); - } - 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; -} - -/** - * 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. - */ -function resolvePinnedRelations( - relations: readonly RelationEntry[], - relationsByModel: ReadonlyMap, -): ReadonlySet { - const pairCounts = new Map(); - for (const entry of relations) { - const key = `${modelCoordinate(entry.owner.namespaceId, entry.owner.name)}\u0000${entry.targetCoordinate}`; - pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1); - } - const ambiguous = (entry: RelationEntry): boolean => { - const ownerCoordinate = modelCoordinate(entry.owner.namespaceId, entry.owner.name); - const forward = pairCounts.get(`${ownerCoordinate}\u0000${entry.targetCoordinate}`) ?? 0; - const backward = pairCounts.get(`${entry.targetCoordinate}\u0000${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: ModelEntry, - relations: readonly RelationEntry[], -): readonly RelationEntry[] { - 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)); -} - -function buildRelationFields(input: { - readonly entry: ModelEntry; - readonly variant: VariantInfo | undefined; - readonly relations: readonly RelationEntry[]; - readonly relationsByModel: ReadonlyMap; - readonly modelsByCoordinate: ReadonlyMap; - readonly pinned: ReadonlySet; - readonly printedForeignKeys: Set; -}): readonly PslField[] { - const fields: PslField[] = []; - for (const relationEntry of orderedByForeignKey(input.entry, input.relations)) { - const target = input.modelsByCoordinate.get(relationEntry.targetCoordinate); - if (target === undefined) continue; - const owning = isOwningRelation(relationEntry) - ? relationEntry - : relationEntry.relation.cardinality === 'N:M' - ? junctionParentRelation(relationEntry, input.relationsByModel) - : owningPartner(relationEntry, input.relationsByModel); - const name = - owning !== undefined && input.pinned.has(relationKey(owning)) - ? relationName(owning) - : undefined; - if (isOwningRelation(relationEntry)) { - const foreignKey = foreignKeyFor(relationEntry, target); - if (foreignKey !== undefined) input.printedForeignKeys.add(foreignKey); - } - fields.push( - printRelationField({ - entry: relationEntry, - target, - name, - ownerIsSingleTableVariant: input.variant?.singleTable === true, - }), - ); - } - return fields; -} - -/** - * A foreign key no relation prints travels with no PSL form: the source - * derives every foreign key from a `@relation`, so this one would be lost. - */ -function refuseUnprintedForeignKeys( - entry: ModelEntry, - variant: VariantInfo | undefined, - printed: ReadonlySet, -): void { - for (const fk of entry.table.foreignKeys) { - if (printed.has(fk) || isVariantLinkForeignKey(fk, variant)) continue; - if (variant?.singleTable === true && !fk.source.columns.every((c) => entry.ownColumns.has(c))) { - continue; - } - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: 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.`, - { - why: 'The PSL source derives every foreign key from a `@relation` field; a foreign key without one has no PSL form.', - fix: 'Declare a relation over the foreign key, or keep authoring this contract in its current source.', - meta: { - namespaceId: entry.namespaceId, - table: entry.tableName, - columns: fk.source.columns, - }, - }, - ); - } -} - -/** - * 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. - */ -function refuseDuplicateModelNames(models: readonly ModelEntry[]): 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) { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: 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.`, - { - why: "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.", - fix: 'Rename one of the models, then print again.', - meta: { 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. - */ -function buildTopLevelDomainEnums(contract: Contract): readonly PslExtensionBlock[] { - 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 postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: 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.`, - { - why: 'The PSL source refuses an enum block inside a namespace block, so an enum outside the default namespace has no PSL form.', - fix: 'Move the enum to the default namespace, or keep authoring this contract in its current source.', - meta: { namespaceId, names }, - }, - ); - } - return buildDomainEnumBlocks(contract.domain.namespaces[DEFAULT_NAMESPACE_ID]?.enum ?? {}); -} - -function refuseModelOwner(entry: ModelEntry): void { - if (entry.model.owner === undefined) return; - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: model "${entry.namespaceId}.${entry.name}" is owned by "${entry.model.owner}", which cannot be written in Prisma 8 PSL.`, - { - why: '`owner` names the model this model is embedded in, and SQL PSL has no syntax for an owned model.', - fix: 'Keep authoring this contract in its current source.', - meta: { namespaceId: entry.namespaceId, modelName: entry.name, owner: entry.model.owner }, - }, - ); -} - -function refuseRlsWithoutModel(namespaceId: string, tableName: string): never { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: table "${namespaceId}"."${tableName}" has row-level security enabled but no model, so it cannot be written in Prisma 8 PSL.`, - { - why: 'Row-level security is written as `@@rls` on the model stored in the table.', - fix: 'Declare a model for the table, or keep authoring this contract in its current source.', - meta: { namespaceId, table: tableName }, - }, - ); -} - -function refuseExtensionEntries( - 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 postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: namespace "${namespaceId}" declares ${names.length} "${kind}" ${names.length === 1 ? 'entity' : 'entities'} (${names.join(', ')}), which the Postgres printer cannot write.`, - { - why: 'The printer writes tables, value sets, native enums, row-level security settings, policies and roles, and no other entity kind.', - fix: 'Keep authoring this contract in its current source.', - meta: { namespaceId, kind, names }, - }, - ); - } -} - -/** - * Prints 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, the document yields the contract it - * was printed from. Anything the language cannot carry is refused by name. - */ -export function printPostgresPslContract( - contract: Contract, - context: { readonly authoringTypes: AuthoringTypeNamespace }, -): PslDocumentAst { - const models = indexContractModels(contract); - refuseDuplicateModelNames(models); - const byCoordinate = modelsByCoordinate(models); - const variants = new Map(models.map((entry) => [entry, variantInfo(entry, byCoordinate)])); - const relations = collectRelations(models); - const relationsByModel = new Map(); - for (const entry of relations) { - const key = modelCoordinate(entry.owner.namespaceId, entry.owner.name); - const bucket = relationsByModel.get(key) ?? []; - bucket.push(entry); - relationsByModel.set(key, bucket); - } - const pinned = resolvePinnedRelations(relations, relationsByModel); - const executionDefaults = executionDefaultsByColumn(contract); - const typeMap = createPostgresTypeMap(); - - const namespaces: PslNamespace[] = []; - const topLevelEnums = buildTopLevelDomainEnums(contract); - if (topLevelEnums.length > 0) { - namespaces.push( - makePslNamespace({ - kind: 'namespace', - name: UNSPECIFIED_PSL_NAMESPACE_ID, - entries: makePslNamespaceEntries([], [], topLevelEnums), - span: SYNTHETIC_SPAN, - }), - ); - } - for (const [namespaceId, namespace] of Object.entries(contract.storage.namespaces)) { - refuseExtensionEntries(namespaceId, namespace.entries); - const nativeEnums = new Map(); - for (const [name, entity] of Object.entries(namespace.entries['native_enum'] ?? {})) { - if (PostgresNativeEnum.is(entity)) { - nativeEnums.set(name, entity); - } - } - const valueSets = new Map( - Object.entries(namespace.entries.valueSet ?? {}).map(([name, valueSet]) => [ - name, - valueSet.values, - ]), - ); - const enums = buildNativeEnumBlocksForNamespace({ - namespaceId, - nativeEnums, - valueSets, - columns: models - .filter((entry) => entry.namespaceId === namespaceId) - .flatMap((entry) => Object.values(entry.table.columns)), - }); - const valueSetMembers = new Map(); - for (const [name, values] of valueSets) { - const members = values.filter( - (value): value is string | number => typeof value === 'string' || typeof value === 'number', - ); - if (members.length === values.length) valueSetMembers.set(name, members); - } - - const domainEnums = contract.domain.namespaces[namespaceId]?.enum ?? {}; - const rlsTables = rlsEnabledTables(namespaceId, namespace.entries['rls']); - const namespaceModels: PslModel[] = []; - for (const entry of models) { - if (entry.namespaceId !== namespaceId) continue; - refuseModelOwner(entry); - const variant = variants.get(entry); - const singleTableVariants = models.filter( - (other) => - other !== entry && - variants.get(other)?.singleTable === true && - variants.get(other)?.base === entry, - ); - const printedForeignKeys = new Set(); - const fields = [ - ...buildScalarFields({ - entry, - variant, - enums, - domainEnums, - typeMap, - authoringTypes: context.authoringTypes, - executionDefaults, - }), - ...buildRelationFields({ - entry, - variant, - relations: relationsByModel.get(modelCoordinate(namespaceId, entry.name)) ?? [], - relationsByModel, - modelsByCoordinate: byCoordinate, - pinned, - printedForeignKeys, - }), - ]; - if (variant?.singleTable !== true) { - for (const other of singleTableVariants) { - for (const relation of relationsByModel.get(modelCoordinate(namespaceId, other.name)) ?? - []) { - const target = byCoordinate.get(relation.targetCoordinate); - const fk = target === undefined ? undefined : foreignKeyFor(relation, target); - if (fk !== undefined) printedForeignKeys.add(fk); - } - } - } - refuseUnprintedForeignKeys(entry, variant, printedForeignKeys); - namespaceModels.push({ - kind: 'model', - name: entry.name, - fields, - attributes: buildModelAttributes({ - entry, - variant, - singleTableVariants, - derivedChecks: derivedCheckNames(entry.table, entry.tableName, valueSetMembers), - rlsEnabled: rlsTables.has(entry.tableName), - }), - span: SYNTHETIC_SPAN, - }); - } - - const compositeTypes = buildCompositeTypes({ - contract, - namespaceId, - typeMap, - authoringTypes: context.authoringTypes, - 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 policyBlocks = buildPolicyBlocks({ - namespaceId, - entries: namespace.entries['policy'], - modelNameForTable, - }); - const blocks: readonly PslExtensionBlock[] = [ - ...enums.blocks, - ...buildRoleBlocks(namespaceId, namespace.entries['role']), - ...policyBlocks, - ]; - if (namespaceModels.length === 0 && blocks.length === 0 && compositeTypes.length === 0) { - continue; - } - namespaces.push( - makePslNamespace({ - kind: 'namespace', - name: pslNamespaceName(namespaceId), - entries: makePslNamespaceEntries(namespaceModels, compositeTypes, blocks), - span: SYNTHETIC_SPAN, - }), - ); - } - - const types = buildTypesBlock(contract, typeMap, context.authoringTypes); - return { - kind: 'document', - sourceId: '', - namespaces, - ...ifDefined('types', types), - span: SYNTHETIC_SPAN, - }; -} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts deleted file mode 100644 index 08eb9e176311..000000000000 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-relation-fields.ts +++ /dev/null @@ -1,214 +0,0 @@ -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 { ifDefined } from '@internal/utils/defined'; -import { postgresError } from '../errors'; -import { buildAttribute, namedArg, SYNTHETIC_SPAN } from '../psl-infer/psl-literals'; -import { - crossReferenceCoordinate, - type ModelEntry, - modelCoordinate, - pslNamespaceName, -} from './contract-model-index'; - -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 RelationEntry { - readonly owner: ModelEntry; - readonly fieldName: string; - readonly relation: ContractReferenceRelation; - readonly targetCoordinate: string; -} - -function isReferenceRelation(relation: ContractRelation): relation is ContractReferenceRelation { - return 'on' in relation; -} - -export function collectRelations(models: readonly ModelEntry[]): readonly RelationEntry[] { - const entries: RelationEntry[] = []; - for (const owner of models) { - for (const [fieldName, relation] of Object.entries(owner.model.relations)) { - if (!isReferenceRelation(relation)) continue; - 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: RelationEntry): boolean { - return entry.relation.cardinality === 'N:1'; -} - -export function relationKey(entry: RelationEntry): string { - return `${modelCoordinate(entry.owner.namespaceId, entry.owner.name)}\u0000${entry.fieldName}`; -} - -/** The relation name a pair is pinned with when the model pair carries more than one relation. */ -export function relationName(entry: RelationEntry): string { - return `${entry.owner.name}_${entry.fieldName}`; -} - -function columnsIn(model: ModelEntry, fieldNames: readonly string[]): readonly string[] { - return fieldNames.map((fieldName) => model.storage.fields[fieldName]?.column ?? fieldName); -} - -function columnsOf(entry: RelationEntry, 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: RelationEntry, target: ModelEntry): 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.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: RelationEntry, - relationsByModel: ReadonlyMap, -): RelationEntry | 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: RelationEntry, - relationsByModel: ReadonlyMap, -): RelationEntry | 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 prints as. */ -export function printRelationField(input: { - readonly entry: RelationEntry; - readonly target: ModelEntry; - 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) { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: relation "${entry.owner.name}.${entry.fieldName}" has no foreign key in storage, which Prisma 8 PSL cannot express.`, - { - why: 'A to-one relation is authored as `@relation(fields:…, references:…)`, which always lowers to a foreign key.', - fix: 'Declare a foreign key for the relation, or keep authoring this contract in its current source.', - meta: { model: entry.owner.name, field: 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 printed 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, - }; -} diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-rls-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/print-rls-blocks.ts deleted file mode 100644 index 5e9bcd3073e3..000000000000 --- a/packages/3-targets/3-targets/postgres/src/core/psl-print/print-rls-blocks.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { PslExtensionBlock } from '@internal/framework-components/psl-ast'; -import { escapePslString } from '@internal/sql-relational-core/ast'; -import { postgresError } from '../errors'; -import { PostgresRlsEnablement } from '../postgres-rls-enablement'; -import { PostgresRlsPolicy } from '../postgres-rls-policy'; -import { PostgresRole } from '../postgres-role'; -import { SYNTHETIC_SPAN } from '../psl-infer/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; - -function invalidEntry(namespaceId: string, kind: string, name: string): never { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: namespace "${namespaceId}" has a "${kind}" entry "${name}" that is not a ${kind} entity.`, - { - why: 'The printer reads each storage entry through the entity class of its kind.', - fix: 'Re-emit the contract from its source.', - meta: { namespaceId, kind, name }, - }, - ); -} - -/** The storage tables of one namespace that have row-level security enabled, each written as `@@rls` on its model. */ -export function rlsEnabledTables( - namespaceId: string, - entries: Readonly> | undefined, -): ReadonlySet { - const tables = new Set(); - for (const [name, entity] of Object.entries(entries ?? {})) { - if (!(entity instanceof PostgresRlsEnablement)) invalidEntry(namespaceId, 'rls', name); - tables.add(entity.tableName); - } - return tables; -} - -/** One `role {}` block per role; they print inside `namespace unbound`. */ -export function buildRoleBlocks( - namespaceId: string, - entries: Readonly> | undefined, -): readonly PslExtensionBlock[] { - return Object.entries(entries ?? {}).map(([name, entity]): PslExtensionBlock => { - if (!(entity instanceof PostgresRole)) invalidEntry(namespaceId, 'role', name); - return { - kind: 'role', - keyword: 'role', - name: entity.name, - parameters: {}, - blockAttributes: [], - attributes: {}, - span: SYNTHETIC_SPAN, - }; - }); -} - -function refuseUnwritablePolicy(policy: PostgresRlsPolicy, why: string, fix: string): never { - throw postgresError( - 'CONTRACT.PRINT_UNSUPPORTED', - `contract print: policy "${policy.name}" on "${policy.namespaceId}"."${policy.tableName}" cannot be written in Prisma 8 PSL.`, - { why, fix, meta: { namespaceId: policy.namespaceId, table: policy.tableName } }, - ); -} - -/** - * 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 PslExtensionBlock[] { - return Object.entries(input.entries ?? {}).map(([head, policy]): PslExtensionBlock => { - if (!(policy instanceof PostgresRlsPolicy)) invalidEntry(input.namespaceId, 'policy', head); - const modelName = input.modelNameForTable(policy.tableName); - if (modelName === undefined) { - refuseUnwritablePolicy( - 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.', - ); - } - const badRole = policy.roles.find((role) => !PSL_IDENTIFIER.test(role)); - if (badRole !== undefined) { - refuseUnwritablePolicy( - policy, - `A policy names its roles by identifier, and role "${badRole}" is not a PSL identifier.`, - 'Rename the role to a plain identifier, or keep authoring this contract in its current source.', - ); - } - - return { - kind: 'policy', - keyword: POLICY_OPERATION_KEYWORD[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/psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-print/psl-contract.ts new file mode 100644 index 000000000000..8c69a3b46119 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/psl-contract.ts @@ -0,0 +1,342 @@ +import { + type Contract, + type ContractEnum, + type ExecutionMutationDefault, + effectiveControlPolicy, +} from '@internal/contract/types'; +import type { SqlPslPrintContext } from '@internal/family-sql/control'; +import type { PslTypeMap } from '@internal/family-sql/psl-ast'; +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, SqlStorage } from '@internal/sql-contract/types'; +import { ifDefined } from '@internal/utils/defined'; +import { DEFAULT_NAMESPACE_ID } from '../namespace-ids'; +import { PostgresNativeEnum } from '../postgres-native-enum'; +import { createPostgresTypeMap } from '../psl-ast/postgres-type-map'; +import { SYNTHETIC_SPAN } from '../psl-ast/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, + refuseInvalidEntry, + refuseModelOwner, + refuseModelsOutsideTableNamespace, + refuseNonIdentifier, + refuseRlsWithoutModel, + refuseUnderivedChecks, + refuseUnderivedRoots, + refuseUnderivedValueSets, + refuseUnderivedVariantLink, + refuseUnmodelledTablesAndColumns, + refuseUnprintedEntryKinds, + refuseUntravelledForeignKeys, + 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: SqlPslPrintContext; + 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'] ?? {})) { + if (!PostgresNativeEnum.is(entity)) refuseInvalidEntry(namespaceId, 'native_enum', name); + 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; + refuseNonIdentifier('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)), + }), + ...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; + } + return makePslNamespace({ + kind: 'namespace', + name: pslNamespaceName(namespaceId), + entries: makePslNamespaceEntries(namespaceModels, compositeTypes, blocks), + span: SYNTHETIC_SPAN, + }); +} + +/** + * 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. + */ +export function buildPostgresPslContract( + contract: Contract, + context: SqlPslPrintContext, +): PslDocumentAst { + refuseModelsOutsideTableNamespace(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..dfc166a0e586 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/refusals.ts @@ -0,0 +1,982 @@ +/** + * 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 { canonicalizeJson } from '@internal/framework-components/utils'; +import { isPslIdentifier } from '@internal/psl-parser'; +import { + type ForeignKey, + type Index, + type SqlModelStorage, + type SqlStorage, + StorageColumn, +} from '@internal/sql-contract/types'; +import { escapePslString } from '@internal/sql-relational-core/ast'; +import { blindCast } from '@internal/utils/casts'; +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.table}"."${entry.ref.column}"`; + 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 }, + ); + } +} + +export function refuseStorageWithoutFieldOrColumn(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 such field or the table has no such column.`, + 'PSL declares a field together with the column it is stored in, so both must exist.', + 'The contract source produced storage for a field or column it does not declare. Fix it 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 }, + ); +} + +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 + +/** Refuses a model whose domain namespace is not the namespace of its table. */ +export function refuseModelsOutsideTableNamespace(contract: Contract): void { + for (const [namespaceId, domainNamespace] of Object.entries(contract.domain.namespaces)) { + for (const [name, model] of Object.entries(domainNamespace.models)) { + const storage = blindCast(model.storage); + if (storage.namespaceId === namespaceId) continue; + throw unsupported( + `model "${namespaceId}.${name}" is stored in namespace "${storage.namespaceId}", which cannot be written in Prisma 8 PSL.`, + 'A model written in a namespace block belongs to that namespace in both the domain and the storage.', + 'The contract source produced a model outside the namespace of its table. Fix the model if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', + { namespaceId, modelName: name, tableNamespaceId: storage.namespaceId }, + ); + } + } +} + +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 ?? {})) { + refuseNonIdentifier('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 }, + ); +} + +// 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 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 + +/** + * Refuses a name the printer writes where PSL reads an identifier: a model, field, value object, + * enum, enum member, native enum, named type, policy, role or index option key. + */ +export function refuseNonIdentifier(kind: string, name: string): void { + 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 }, + ); + } +} + +/** Refuses an entry filed under a kind whose entity class does not accept it. */ +export function refuseInvalidEntry(namespaceId: string, kind: string, name: string): never { + throw unsupported( + `namespace "${namespaceId}" has a "${kind}" entry "${name}" that is not a ${kind} entity.`, + 'The printer reads each storage entry through the entity class of its kind, and this entry does not have that shape.', + 'The contract source produced an entry the Postgres target does not accept. Fix the entry if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', + { namespaceId, kind, name }, + ); +} 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..b629e2cd1734 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/relation-fields.ts @@ -0,0 +1,381 @@ +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 { ifDefined } from '@internal/utils/defined'; +import { postgresError } from '../errors'; +import { buildAttribute, namedArg, SYNTHETIC_SPAN } from '../psl-ast/psl-literals'; +import { + crossReferenceCoordinate, + type ModelWithTable, + modelCoordinate, + pslNamespaceName, + type VariantInfo, +} from './contract-model-index'; +import { + refuseManyToManyWithoutJunctionRelation, + refuseNonIdentifier, + refuseRelationToOtherContractSpace, + refuseToOneRelationWithoutForeignKey, +} 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; +} + +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)) continue; + 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)) { + refuseNonIdentifier('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); + const name = + owning !== undefined && 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); + if (target === undefined) { + throw postgresError( + 'CONTRACT.MODEL_UNKNOWN', + `contract print: relation "${entry.owner.name}.${entry.fieldName}" targets model "${to.namespace}.${to.model}", which the contract does not declare.`, + { + why: 'A relation is written as a field typed by the model it targets.', + fix: 'The contract source produced a relation to a model it does not declare. Fix the relation if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', + meta: { + model: entry.owner.name, + field: entry.fieldName, + target: `${to.namespace}.${to.model}`, + }, + }, + ); + } + 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..dc341c3ee3ea --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/row-level-security.ts @@ -0,0 +1,185 @@ +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 { 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-ast/psl-literals'; +import { computeContentHash } from '../rls/canonicalize'; +import { + refuseEntryFiledUnderAnotherName, + refuseEntryInOtherNamespace, + refuseInvalidEntry, + refuseNonIdentifier, + refusePolicyNameNotDerived, + refusePolicyWithoutModel, + refusePolicyWithoutRls, + refuseRoleOutsideUnbound, +} 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 ?? {})) { + if (!(entity instanceof PostgresRlsEnablement)) refuseInvalidEntry(namespaceId, 'rls', name); + 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 => { + if (!(entity instanceof PostgresRole)) refuseInvalidEntry(namespaceId, 'role', name); + refuseRoleOutsideUnbound(namespaceId, name); + refuseEntryInOtherNamespace({ + namespaceId, + kind: 'role', + name, + recordedNamespaceId: entity.namespaceId, + }); + refuseEntryFiledUnderAnotherName({ namespaceId, kind: 'role', name, readsBackAs: entity.name }); + refuseNonIdentifier('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 => { + if (!(policy instanceof PostgresRlsPolicy)) + refuseInvalidEntry(input.namespaceId, 'policy', head); + refuseNonIdentifier('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) refuseNonIdentifier('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..f761c9a25754 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-print/scalar-fields.ts @@ -0,0 +1,213 @@ +import type { Contract, ContractEnum, ExecutionMutationDefault } from '@internal/contract/types'; +import type { SqlPslPrintContext } from '@internal/family-sql/control'; +import type { PslTypeMap } from '@internal/family-sql/psl-ast'; +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/resolution'; +import { escapePslString } from '@internal/sql-relational-core/ast'; +import { ifDefined } from '@internal/utils/defined'; +import { PG_ENUM_CODEC_ID } from '../codec-ids'; +import { + buildAttribute, + buildMapAttribute, + namedArg, + positionalArg, + SYNTHETIC_SPAN, +} from '../psl-ast/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, + refuseFieldColumnMismatch, + refuseFieldsWithoutColumn, + refuseGeneratorWithDatabaseDefault, + refuseNonIdentifier, + refuseStorageWithoutFieldOrColumn, + refuseUnwritableFieldShape, +} 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.table, entry.ref.column]), 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: SqlPslPrintContext; +}): 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: SqlPslPrintContext; + readonly executionDefaults: ReadonlyMap; + readonly writtenExecutionDefaults: Set; + readonly defaultDomainEnumNames: ReadonlySet; +}): 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]; + if (column === undefined || field === undefined) { + refuseStorageWithoutFieldOrColumn({ entry, fieldName, coordinate }); + } + refuseNonIdentifier('field', fieldName); + refuseUnwritableFieldShape(field, coordinate); + refuseColumnControl(column, coordinate); + refuseFieldColumnMismatch({ + field, + column, + coordinate, + modelName: entry.name, + singleTableVariant: variant?.singleTable === true, + domainEnumNames: input.defaultDomainEnumNames, + }); + 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 fac1c0319c1d..e65e26a9fb7b 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -21,7 +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 { printPostgresPslContract } from '../core/psl-print/print-psl-contract'; +import { buildPostgresPslContract } from '../core/psl-print/psl-contract'; import { PostgresDatabaseSchemaNode } from '../core/schema-ir/postgres-database-schema-node'; import { postgresDiffSubjectEntityKind, @@ -45,7 +45,7 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP return inferPostgresPslContract(schema, describedContracts); }, printPslContract(contract, context) { - return printPostgresPslContract(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-ast/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-ast/postgres-type-map.test.ts index a77d62b9cd5f..9bbdef9e0682 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-ast/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-ast/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/print-psl.data-type-defaults.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.data-type-defaults.test.ts index 14767a6f6bc0..8b0a302f1f63 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/print-psl/print-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 { PRINTED_PSL_TYPE_NAMES } from '../../../src/core/psl-ast/postgres-type-map'; import { CODEC_ID_BY_PRINTED_TYPE, dataTypeForPrintedType, } 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. */ 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/print-psl/print-psl.enums.test.ts index f33c2060d695..b84359b5786c 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/print-psl/print-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-ast/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.top-level-blocks.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts index 406790b663b1..247b49acb5aa 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts @@ -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-ast/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 57038fa0334c..3a34b9a6cf2d 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, + 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/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..90fc54335f26 --- /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 { testPrintContext } from './print-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), + testPrintContext(), + ); + 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..034be7a5a909 --- /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', table: 'Widget', column: '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-context.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-context.ts new file mode 100644 index 000000000000..9067c1186855 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/print-context.ts @@ -0,0 +1,90 @@ +import type { SqlPslPrintContext } from '@internal/family-sql/control'; +import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import { + type CodecDescriptorTemplate, + createDataTypeLookup, + voidParamsSchema, +} 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: voidParamsSchema, + 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 testPrintContext( + extra: { + readonly types?: AuthoringTypeNamespace; + readonly codecs?: readonly AnyPostgresCodecDescriptor[]; + } = {}, +): SqlPslPrintContext { + 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/print-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts deleted file mode 100644 index a1b5fdca6cab..000000000000 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-psl-contract.test.ts +++ /dev/null @@ -1,783 +0,0 @@ -import type { - ApplicationDomainNamespace, - Contract, - ContractField, - ContractRelation, - ExecutionMutationDefault, -} from '@internal/contract/types'; -import { asNamespaceId } from '@internal/contract/types'; -import type { - PslAttribute, - PslExtensionBlock, - PslField, - PslModel, -} 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 { postgresAuthoringTypes } from '../../src/core/authoring'; -import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; -import { printPostgresPslContract } from '../../src/core/psl-print/print-psl-contract'; - -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; - -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})`; -} - -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' }, -}; - -function print(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.keys(model.fields).map((fieldName) => [fieldName, INT_FIELD]), - ), - relations: model.relations ?? {}, - }, - ]), - ), - }; - const overrides = { - 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 = printPostgresPslContract( - blindCast, 'the Postgres serializer yields a SQL contract'>(contract), - { authoringTypes: postgresAuthoringTypes }, - ); - return ast.namespaces.flatMap((namespace) => namespace.models); -} - -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[]; -}): unknown { - 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 oneModel(columns: Record, fields: Record) { - return print({ - models: { Widget: { table: 'Widget', fields } }, - tables: { Widget: table({ columns, primaryKey: { columns: ['id'] } }) }, - })[0]; -} - -describe('names the PSL source cannot derive', () => { - it('maps a model whose table is not the model name', () => { - const [model] = print({ - 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] = print({ - 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] = print({ - 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] = print({ - 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] = print({ - 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] = print({ - 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])']); - }); -}); - -describe('column defaults', () => { - function defaultOf(column: Record): 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', - message: expect.stringContaining('"public"."Widget"."value"'), - }); - }); -}); - -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)', - ]); - }); -}); - -describe('generated values', () => { - function withGenerator( - column: Record, - phases: Record, - ): string | undefined { - const [model] = print({ - 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', table: 'Widget', column: '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', - message: expect.stringContaining('"public"."Widget"."value"'), - }); - expect( - refusal({ - onCreate: { kind: 'generator', id: 'uuidv4' }, - onUpdate: { kind: 'generator', id: 'plainDateTimeNow' }, - }), - ).toMatchObject({ - code: 'CONTRACT.PRINT_UNSUPPORTED', - message: expect.stringContaining('"public"."Widget"."value"'), - }); - }); -}); - -describe('relations', () => { - function postAndUser(foreignKey: Record) { - return print({ - 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 = print({ - 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)', - ]); - }); -}); - -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 domainNamespace: ApplicationDomainNamespace = { - models: { - Widget: { - storage: { table: 'Widget', namespaceId: 'public', fields }, - fields: Object.fromEntries(Object.keys(fields).map((name) => [name, INT_FIELD])), - relations: {}, - }, - }, - }; - const json = createSqlContract({ - namespaces: { public: domainNamespace }, - storage: { - namespaces: { - public: { - id: 'public', - entries: { - table: { - Widget: table({ - columns: input.columns ?? { id: INT_COLUMN }, - 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 = printPostgresPslContract( - blindCast, 'the Postgres serializer yields a SQL contract'>(contract), - { authoringTypes: postgresAuthoringTypes }, - ); - 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('derives a block name from the type name when two unreferenced enums share their members', () => { - 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 UserRole 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 as a mapped block name', () => { - expect( - enumBlocks({ - nativeEnums: { 'order status': { typeName: 'order status', members: ['A'] } }, - valueSets: {}, - }), - ).toEqual(['native_enum OrderStatus A = "A" @@map("order status")']); - }); - - it('keeps a derived block name apart from a block name a value set already claimed', () => { - expect( - enumBlocks({ - nativeEnums: { - 'order status': { typeName: 'order status', members: ['A'] }, - order_status: { typeName: 'order_status', members: ['B'] }, - }, - valueSets: { OrderStatus: { values: ['B'] } }, - }), - ).toEqual([ - 'native_enum OrderStatus2 A = "A" @@map("order status")', - 'native_enum OrderStatus B = "B" @@map("order_status")', - ]); - }); -}); - -describe('parts of a contract PSL cannot carry', () => { - function printContract(overrides: Parameters[0]) { - const contract = new PostgresContractSerializer().deserializeContract( - createSqlContract(overrides), - ); - return () => - printPostgresPslContract( - blindCast, 'the Postgres serializer yields a SQL contract'>(contract), - { authoringTypes: postgresAuthoringTypes }, - ); - } - - it('refuses a value object outside the default namespace, which the PSL source would move', () => { - const print = printContract({ - namespaces: { - public: { models: {} }, - auth: { - models: {}, - valueObjects: { Address: { fields: { street: INT_FIELD } } }, - }, - }, - storage: { - namespaces: { - public: { id: 'public', entries: { table: {} } }, - auth: { id: 'auth', entries: { table: {} } }, - }, - }, - }); - - expect(print).toThrow( - expect.objectContaining({ - code: 'CONTRACT.PRINT_UNSUPPORTED', - message: expect.stringContaining('namespace "auth" declares the value object Address'), - }), - ); - }); - - it('refuses a column whose codec no PSL type in the stack produces, naming the column', () => { - const print = printContract({ - namespaces: { - public: { - models: { - Doc: { - storage: { - table: 'Doc', - namespaceId: 'public', - fields: { id: { column: 'id' }, v: { column: 'v' } }, - }, - fields: { - id: INT_FIELD, - v: { nullable: false, type: { kind: 'scalar', codecId: 'pg/vector@1' } }, - }, - relations: {}, - }, - }, - }, - }, - storage: { - namespaces: { - public: { - id: 'public', - entries: { - table: { - Doc: table({ - columns: { - id: INT_COLUMN, - v: { - nativeType: 'vector', - codecId: 'pg/vector@1', - nullable: false, - typeParams: { length: 3 }, - }, - }, - primaryKey: { columns: ['id'] }, - }), - }, - }, - }, - }, - }, - }); - - expect(print).toThrow( - expect.objectContaining({ - code: 'CONTRACT.PRINT_UNSUPPORTED', - message: expect.stringContaining( - '"public"."Doc"."v" has native type "vector" with codec "pg/vector@1"', - ), - }), - ); - }); -}); 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..12cc54cf36e5 --- /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 { testPrintContext } from './print-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), + testPrintContext(), + ); + 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..4e8fb013b251 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusal-support.ts @@ -0,0 +1,142 @@ +import type { Contract, ContractField } from '@internal/contract/types'; +import { asNamespaceId } from '@internal/contract/types'; +import type { SqlPslPrintContext } 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 { testPrintContext } from './print-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)), + ); +} + +export function printing( + contract: Contract, + context: SqlPslPrintContext = testPrintContext(), +): () => unknown { + return () => buildPostgresPslContract(contract, context); +} + +export function printingWidget( + parts: WidgetParts = {}, + context: SqlPslPrintContext = testPrintContext(), +): () => unknown { + return printing(deserialize(widgetContract(parts)), context); +} + +/** The contract with one storage namespace's entries replaced verbatim, bypassing entity hydration. */ +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..ecfbbb0d2cfc --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-entries.test.ts @@ -0,0 +1,283 @@ +import type { Contract } from '@internal/contract/types'; +import { asNamespaceId } from '@internal/contract/types'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { blindCast } from '@internal/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { + deserialize, + INT_FIELD, + PUBLIC, + printing, + printingWidget, + refusal, + TEXT_COLUMN, + TEXT_FIELD, + widgetContract, + withRawEntries, +} 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 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('storage a model names but the contract does not declare', () => { + function withWidget(patch: (widget: Record) => Record) { + const contract = deserialize( + widgetContract({ columns: { name: TEXT_COLUMN }, fields: { name: TEXT_FIELD } }), + ); + const publicDomain = contract.domain.namespaces['public']; + const widget = blindCast, 'the test widget model'>( + publicDomain?.models['Widget'], + ); + return blindCast, 'a contract the validator would reject'>({ + ...contract, + domain: { + namespaces: { + ...contract.domain.namespaces, + public: { ...publicDomain, models: { Widget: patch(widget) } }, + }, + }, + }); + } + + it('refuses a field stored in no column', () => { + expect( + printing( + withWidget((widget) => ({ + ...widget, + fields: { ...blindCast(widget['fields']), extra: INT_FIELD }, + })), + ), + ).toThrow(refusal({ namespaceId: 'public', modelName: 'Widget', field: 'extra' })); + }); + + it('refuses a field stored in a column the table does not declare', () => { + expect( + printing( + withWidget((widget) => ({ + ...widget, + storage: { + table: 'Widget', + namespaceId: 'public', + fields: { id: { column: 'id' }, name: { column: 'missing' } }, + }, + })), + ), + ).toThrow(refusal({ namespaceId: 'public', modelName: 'Widget', field: 'name' })); + }); + + it('reports a relation to a model the contract does not declare', () => { + expect( + printing( + withWidget((widget) => ({ + ...widget, + relations: { + owner: { + to: { namespace: PUBLIC, model: 'Ghost' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['widgetId'] }, + }, + }, + })), + ), + ).toThrow( + expect.objectContaining({ + code: 'CONTRACT.MODEL_UNKNOWN', + meta: { model: 'Widget', field: 'owner', target: 'public.Ghost' }, + }), + ); + }); + + it('reports a variant whose base does not list it', () => { + expect( + printing( + withWidget((widget) => ({ + ...widget, + base: { namespace: asNamespaceId('public'), model: 'Ghost' }, + })), + ), + ).toThrow( + expect.objectContaining({ + code: 'CONTRACT.MODEL_UNKNOWN', + meta: { namespaceId: 'public', modelName: 'Widget' }, + }), + ); + }); + + it('reports a model stored in a table the storage does not declare', () => { + expect( + printing(withRawEntries(deserialize(widgetContract()), 'public', { table: {} })), + ).toThrow( + expect.objectContaining({ + code: 'CONTRACT.MODEL_UNKNOWN', + meta: { namespaceId: 'public', modelName: 'Widget', table: 'Widget' }, + }), + ); + }); +}); 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..c6f1ccbd4df3 --- /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 { testPrintContext } from './print-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 = testPrintContext({ + 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', table: 'Widget', column: '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', table: 'Widget', column: '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..bf5550c6645b --- /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 one function for each case the error reference lists', () => { + 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..a6e37a685f5a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-models.test.ts @@ -0,0 +1,436 @@ +import { asNamespaceId } from '@internal/contract/types'; +import { blindCast } from '@internal/utils/casts'; +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 whose domain namespace is not the namespace of its table', () => { + const contract = deserialize(widgetContract()); + const { public: publicNamespace, ...others } = contract.domain.namespaces; + const moved = { + ...contract, + domain: { namespaces: { ...others, app: publicNamespace } }, + }; + expect( + printing(blindCast(moved)), + ).toThrow(refusal({ namespaceId: 'app', modelName: 'Widget', tableNamespaceId: 'public' })); + }); + + 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-storage.test.ts b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-storage.test.ts new file mode 100644 index 000000000000..3eeb63bfdb88 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-print/refusals-storage.test.ts @@ -0,0 +1,380 @@ +import type { ContractField } from '@internal/contract/types'; +import { blindCast } from '@internal/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { buildPostgresPslContract } from '../../src/core/psl-print/psl-contract'; +import { extensionCodec, testPrintContext } from './print-context'; +import { + deserialize, + 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 contract = deserialize(widgetContract()); + const { public: _, ...others } = contract.storage.namespaces; + const withoutPublic = blindCast({ + ...contract, + domain: { namespaces: {} }, + roots: {}, + storage: { ...contract.storage, namespaces: others }, + }); + expect(printing(withoutPublic)).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'] })); + }); + + it.each(['rls', 'policy', 'role', 'native_enum'])( + 'refuses a %s entry that is not an entity of its kind', + (kind) => { + expect( + printing( + withRawEntries(deserialize(widgetContract()), 'public', { [kind]: { odd: { kind } } }), + ), + ).toThrow(refusal({ namespaceId: 'public', kind, name: 'odd' })); + }, + ); +}); + +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 = testPrintContext({ + 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/helpers/psl-print.ts b/packages/3-targets/6-adapters/postgres/test/helpers/psl-print.ts new file mode 100644 index 000000000000..6e509d5729e8 --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/helpers/psl-print.ts @@ -0,0 +1,129 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +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'; +import postgresAdapter from '../../src/exports/control'; + +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).printPslContract(contract); + return { + text: printPsl(document, { + pslBlockDescriptors: stack.authoringContributions.pslBlockDescriptors, + codecLookup: stack.codecLookup, + }), + sourceSettings, + }; +} + +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({ + composedExtensions: stack.extensions.map((extension) => extension.id), + composedExtensionContracts: stack.extensionContracts, + authoringContributions: stack.authoringContributions, + codecLookup: stack.codecLookup, + dataTypeLookup: stack.dataTypeLookup, + controlMutationDefaults: stack.controlMutationDefaults, + resolvedInputs: [path], + capabilities: stack.capabilities, + }); + 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/packages/3-targets/6-adapters/postgres/test/printed-type-codecs.test.ts b/packages/3-targets/6-adapters/postgres/test/printed-type-codecs.test.ts index b241a3731f8d..cddcdfe68b39 100644 --- a/packages/3-targets/6-adapters/postgres/test/printed-type-codecs.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/printed-type-codecs.test.ts @@ -6,9 +6,9 @@ import { } 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( [ diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/print-constraints.test.ts b/packages/3-targets/6-adapters/postgres/test/psl-print-constraints.test.ts similarity index 78% rename from packages/2-sql/2-authoring/contract-prisma7/test/print-constraints.test.ts rename to packages/3-targets/6-adapters/postgres/test/psl-print-constraints.test.ts index 82cbf5a37a4b..e752b43f3060 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/print-constraints.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/psl-print-constraints.test.ts @@ -8,9 +8,9 @@ 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 } from '@repo/test-utils'; +import { createSqlContract, timeouts } from '@repo/test-utils'; import { describe, expect, it } from 'vitest'; -import { loadPrintedPsl, printContractAsPsl, storageTable } from './support'; +import { printContract, readPsl } from './helpers/psl-print'; 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; @@ -26,9 +26,26 @@ interface ModelInput { 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; + readonly tables: Record; }): Contract { const domainNamespace: ApplicationDomainNamespace = { models: Object.fromEntries( @@ -37,7 +54,10 @@ function contractOf(input: { { storage: { table: model.table, namespaceId: 'public', fields: model.fields }, fields: Object.fromEntries( - Object.keys(model.fields).map((fieldName) => [fieldName, INT_FIELD]), + Object.entries(model.fields).map(([fieldName, { column }]) => [ + fieldName, + domainFieldOf(input.tables[model.table]?.columns[column]), + ]), ), relations: model.relations ?? {}, }, @@ -45,6 +65,12 @@ function contractOf(input: { ), }; 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 } } } }, }); @@ -53,14 +79,23 @@ function contractOf(input: { ); } +/** 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 columns: Record; readonly primaryKey?: { readonly columns: readonly string[] }; readonly uniques?: readonly unknown[]; readonly indexes?: readonly unknown[]; readonly foreignKeys?: readonly unknown[]; readonly checks?: readonly unknown[]; -}): unknown { +}): TableShape { return { columns: input.columns, uniques: input.uniques ?? [], @@ -85,19 +120,21 @@ function widget(overrides: Parameters[0]): Contract { const WIDGET_COLUMNS = { id: INT_COLUMN, email: TEXT_COLUMN }; -describe('table constraints survive the print and the read back', () => { +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 = printContractAsPsl(contract); + const text = printContract(contract).text; expect(text).toContain( '@@check(expression: "length(email) > 0", map: "widget_email_not_blank")', ); - const readBack = storageTable(await loadPrintedPsl(text), 'widget'); + const readBack = storageTable(await readPsl(text), 'widget'); expect(readBack.checks?.map((check) => ({ ...check }))).toEqual([ { name: 'widget_email_not_blank', expression: 'length(email) > 0' }, ]); @@ -109,10 +146,10 @@ describe('table constraints survive the print and the read back', () => { uniques: [{ columns: ['email'], name: 'widget_email_key' }], }); - const text = printContractAsPsl(contract); + const text = printContract(contract).text; expect(text).toContain('@@unique([email], map: "widget_email_key")'); - const readBack = storageTable(await loadPrintedPsl(text), 'widget'); + const readBack = storageTable(await readPsl(text), 'widget'); expect(readBack.uniques.map((unique) => ({ ...unique }))).toEqual([ { columns: ['email'], name: 'widget_email_key' }, ]); @@ -125,10 +162,10 @@ describe('table constraints survive the print and the read back', () => { indexes: [{ name: 'widget_email_idx', unique: true, columns: ['email'] }], }); - const text = printContractAsPsl(contract); + const text = printContract(contract).text; expect(text).toContain('unique: true'); - const readBack = storageTable(await loadPrintedPsl(text), 'widget'); + 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 }, @@ -184,7 +221,9 @@ function postAndUser(input: { }); } -describe('relations survive the print and the read back', () => { +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', @@ -206,10 +245,10 @@ describe('relations survive the print and the read back', () => { ], }); - const text = printContractAsPsl(contract); + const text = printContract(contract).text; expect(text).toContain('map: "post_written_by_user"'); - const readBack = storageTable(await loadPrintedPsl(text), 'post'); + const readBack = storageTable(await readPsl(text), 'post'); expect(readBack.foreignKeys.map((key) => key.name)).toEqual(['post_written_by_user']); }); @@ -246,7 +285,7 @@ describe('relations survive the print and the read back', () => { ], }); - const readBack = storageTable(await loadPrintedPsl(printContractAsPsl(contract)), 'post'); + const readBack = storageTable(await readPsl(printContract(contract).text), 'post'); expect( readBack.foreignKeys.map((key) => ({ source: [...key.source.columns], diff --git a/packages/3-targets/6-adapters/postgres/test/psl-print-generated-values.test.ts b/packages/3-targets/6-adapters/postgres/test/psl-print-generated-values.test.ts new file mode 100644 index 000000000000..376928c19ca8 --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/psl-print-generated-values.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 './helpers/psl-print'; + +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/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts b/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts index 1491b459376f..89c8088e4925 100644 --- a/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts @@ -1,22 +1,17 @@ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import type { Contract } from '@internal/contract/types'; -import postgresDriver from '@internal/driver-postgres/control'; -import sql from '@internal/family-sql/control'; -import { createControlStack } 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 { timeouts } from '@repo/test-utils'; import { dirname, join } from 'pathe'; import { describe, expect, it } from 'vitest'; -import postgresAdapter from '../src/exports/control'; +import { + printAndReadBack, + printContract, + readPsl, + serializedWithoutCapabilities, +} from './helpers/psl-print'; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../../..'); @@ -45,75 +40,11 @@ const cases: ReadonlyArray<{ readonly name: string; readonly contractJson: strin }, ]; -type SourceContext = Parameters['source']['load']>[0]; - -function sourceContext(resolvedInputs: readonly string[]): SourceContext { - const stack = createControlStack({ - family: sql, - target: postgres, - adapter: postgresAdapter, - driver: postgresDriver, - extensions: [], - }); - return { - composedExtensions: stack.extensions.map((extension) => extension.id), - composedExtensionContracts: stack.extensionContracts, - authoringContributions: stack.authoringContributions, - codecLookup: stack.codecLookup, - controlMutationDefaults: stack.controlMutationDefaults, - dataTypeLookup: stack.dataTypeLookup, - resolvedInputs, - capabilities: stack.capabilities, - }; -} - function loadContract(relativePath: string): Contract { const json: unknown = JSON.parse(readFileSync(join(repoRoot, relativePath), 'utf-8')); return new PostgresContractSerializer().deserializeContract(json); } -function printAsPsl(contract: Contract): string { - const context = sourceContext([]); - const ast = postgres.printPslContract?.(contract, { - authoringTypes: context.authoringContributions.type, - }); - if (ast === undefined) throw new Error('the Postgres target has no printPslContract hook'); - return printPsl(ast, { - pslBlockDescriptors: context.authoringContributions.pslBlockDescriptors, - codecLookup: context.codecLookup, - }); -} - -async function readBack( - text: string, - defaultControlPolicy?: Contract['defaultControlPolicy'], -): Promise> { - const printedPath = join(mkdtempSync(join(tmpdir(), 'psl-print-')), 'contract.prisma'); - writeFileSync(printedPath, text); - const result = await prismaContract(printedPath, { - target: postgresPackRef, - createNamespace: postgresCreateNamespace, - enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, - ...(defaultControlPolicy === undefined ? {} : { defaultControlPolicy }), - }).source.load(sourceContext([printedPath])); - if (!result.ok) { - throw new Error( - `the printed PSL did not load: ${JSON.stringify(result.failure.diagnostics)}\n${text}`, - ); - } - return blindCast, 'the Postgres PSL source yields a SQL contract'>( - result.value, - ); -} - -/** The serialized contract without `capabilities`, which the composed stack reports rather than the source. */ -function serialize(contract: Contract): unknown { - const { capabilities: _, ...authored } = JSON.parse( - JSON.stringify(new PostgresContractSerializer().serializeContract(contract)), - ); - return authored; -} - /** * Contracts authored in PSL, one per feature the emitted fixtures above do not * carry. Each is read, printed, read back, and compared. @@ -231,6 +162,87 @@ policy_all p_admin { @@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 +} `, }, { @@ -245,13 +257,19 @@ policy_all p_admin { ]; describe('a printed PSL contract reads back as the same contract', () => { - it.each(pslCases)('$name', async ({ schema }) => { - const authored = await readBack(`// use prisma-8\n${schema}`); - const printed = await readBack(printAsPsl(authored)); + it.each(pslCases)( + '$name', + async ({ schema }) => { + const authored = await readPsl(`// use prisma-8\n${schema}`); + const printed = await printAndReadBack(authored); - expect(serialize(printed)).toEqual(serialize(authored)); - expect(printed.storage.storageHash).toBe(authored.storage.storageHash); - }); + expect(serializedWithoutCapabilities(printed)).toEqual( + serializedWithoutCapabilities(authored), + ); + expect(printed.storage.storageHash).toBe(authored.storage.storageHash); + }, + timeouts.pslRoundTrip, + ); }); describe('a contract the language cannot carry is refused by name', () => { @@ -259,21 +277,48 @@ describe('a contract the language cannot carry is refused by name', () => { const authored = loadContract( 'test/integration/test/sql-orm-client/fixtures/generated/contract.json', ); - expect(() => printAsPsl(authored)).toThrow( + 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', - message: expect.stringContaining('"Article.reviewer"'), + 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 readBack(printAsPsl(authored), authored.defaultControlPolicy); + it.each(cases)( + '$name', + async ({ contractJson }) => { + const authored = loadContract(contractJson); + const printed = await printAndReadBack(authored); - expect(serialize(printed)).toEqual(serialize(authored)); - expect(printed.storage.storageHash).toBe(authored.storage.storageHash); - }); + expect(serializedWithoutCapabilities(printed)).toEqual( + serializedWithoutCapabilities(authored), + ); + expect(printed.storage.storageHash).toBe(authored.storage.storageHash); + }, + timeouts.pslRoundTrip, + ); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19173efeb6df..3538a8317cc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5402,6 +5402,9 @@ importers: '@internal/extension-pgvector': specifier: workspace:8.0.0-rc.11 version: link:../../packages/3-extensions/pgvector + '@internal/extension-postgis': + specifier: workspace:8.0.0-rc.11 + version: link:../../packages/3-extensions/postgis '@internal/extension-supabase': specifier: workspace:8.0.0-rc.11 version: link:../../packages/3-extensions/supabase diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index 90f9d60d8c28..258353c674ed 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -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 @@ -112,27 +112,41 @@ Recorded so they are not lost; each becomes its own project when scheduled. - `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 slice 2: the Mongo PSL interpreter silently ignores unknown top-level blocks (`view` included); slice 2 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 `prisma.config.ts` in the working directory. ### What `contract print` cannot write -`contract print` loads the contract the config names, from any source, and writes it as Prisma 8 PSL that reads back as the same contract. The proof is three round-trip tests: every Prisma 7 fixture (`contract-prisma7/test/print-roundtrip.test.ts`); emitted and PSL-authored contracts that carry what a Prisma 7 schema cannot, including value objects, polymorphism, named types, domain enums, control policies, every index argument, primary key names, non-default codecs, row-level security with roles and policies, and the Supabase contract (`adapter-postgres/test/psl-print-roundtrip.test.ts`); and a pgvector column with the extension in the stack (`test/integration/test/psl-print/extension-types-roundtrip.integration.test.ts`). Where PSL has no form for part of the contract, the printer refuses it by name with `CONTRACT.PRINT_UNSUPPORTED` and writes no file. 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. +`contract print` loads the contract the config names, from any source, and writes it 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 broadest is `test/integration/test/psl-print/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 (`contract-prisma7/test/print-roundtrip.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 (`adapter-postgres/test/psl-print-roundtrip.test.ts`); every default function and temporal preset the stack registers (`adapter-postgres/test/psl-print-generated-values.test.ts`); TypeScript-authored contracts (`test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts`); a pgvector column, with and without a literal default, with the extension in the stack (`test/integration/test/psl-print/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 (`adapter-postgres/test/helpers/psl-print.ts`). Where PSL has no form for part of the 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 union or dictionary field, a column with its own control policy, and a model with an owner. None has PSL syntax. -- A generator with no PSL default function, and a generator on update other than the wall-clock-now generator. None of the Prisma 7 generators meets either. +- 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 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. -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. +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 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. - `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. diff --git a/test/integration/package.json b/test/integration/package.json index c452bc1ffaa3..63a62722c153 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -34,6 +34,7 @@ "@internal/extension-arktype-json": "workspace:8.0.0-rc.11", "@internal/extension-paradedb": "workspace:8.0.0-rc.11", "@internal/extension-pgvector": "workspace:8.0.0-rc.11", + "@internal/extension-postgis": "workspace:8.0.0-rc.11", "@internal/extension-supabase": "workspace:8.0.0-rc.11", "@internal/family-mongo": "workspace:8.0.0-rc.11", "@internal/family-sql": "workspace:8.0.0-rc.11", 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/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..23c8795e2670 --- /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 '../../../../packages/3-targets/6-adapters/postgres/test/helpers/psl-print'; + +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 index 1ead38aad4d5..2905f3cec1be 100644 --- a/test/integration/test/psl-print/extension-types-roundtrip.integration.test.ts +++ b/test/integration/test/psl-print/extension-types-roundtrip.integration.test.ts @@ -1,66 +1,18 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import postgresAdapter from '@internal/adapter-postgres/control'; -import type { Contract } from '@internal/contract/types'; -import postgresDriver from '@internal/driver-postgres/control'; import pgvector from '@internal/extension-pgvector/control'; -import sql from '@internal/family-sql/control'; -import { createControlStack } from '@internal/framework-components/control'; -import { 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 { join } from 'pathe'; import { describe, expect, it } from 'vitest'; +import { + composePostgresStack, + printContract, + readPsl, + serializedWithoutCapabilities, +} from '../../../../packages/3-targets/6-adapters/postgres/test/helpers/psl-print'; -const stack = createControlStack({ - family: sql, - target: postgres, - adapter: postgresAdapter, - driver: postgresDriver, - extensions: [pgvector], -}); - -async function read(text: string): Promise> { - const path = join(mkdtempSync(join(tmpdir(), 'psl-print-extension-')), '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 }, - }).source.load({ - composedExtensions: stack.extensions.map((extension) => extension.id), - composedExtensionContracts: stack.extensionContracts, - authoringContributions: stack.authoringContributions, - codecLookup: stack.codecLookup, - dataTypeLookup: stack.dataTypeLookup, - controlMutationDefaults: stack.controlMutationDefaults, - resolvedInputs: [path], - capabilities: stack.capabilities, - }); - 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, - ); -} - -function serialize(contract: Contract): unknown { - const { capabilities: _, ...authored } = JSON.parse( - JSON.stringify(new PostgresContractSerializer().serializeContract(contract)), - ); - return authored; -} +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 read(`// use prisma-8 + const authored = await readPsl( + `// use prisma-8 types { Embedding = pgvector.Vector(3) } @@ -70,19 +22,55 @@ model Document { embedding pgvector.Vector(3) named Embedding } -`); - const ast = postgres.printPslContract?.(authored, { - authoringTypes: stack.authoringContributions.type, - }); - if (ast === undefined) throw new Error('the Postgres target has no printPslContract hook'); - const text = printPsl(ast, { - pslBlockDescriptors: stack.authoringContributions.pslBlockDescriptors, - codecLookup: stack.codecLookup, - }); +`, + { stack }, + ); + const { text, sourceSettings } = printContract(authored, stack); expect(text).toContain('embedding pgvector.Vector(3)'); - const printed = await read(text); - expect(serialize(printed)).toEqual(serialize(authored)); + 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/typescript-contract-roundtrip.integration.test.ts b/test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts new file mode 100644 index 000000000000..57bdbdfd2d35 --- /dev/null +++ b/test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts @@ -0,0 +1,70 @@ +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 { + printAndReadBack, + serializedWithoutCapabilities, +} from '../../../../packages/3-targets/6-adapters/postgres/test/helpers/psl-print'; +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'; + +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 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); + }); +}); diff --git a/upgrade-instructions/pending/data-types-column-defaults/extension/instructions.md b/upgrade-instructions/pending/data-types-column-defaults/extension/instructions.md index 95584981ebd1..37a23bb819ab 100644 --- a/upgrade-instructions/pending/data-types-column-defaults/extension/instructions.md +++ b/upgrade-instructions/pending/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; From b3e2427f9cd130f890d9e3d04548582c4fadd4ee Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 18:18:33 +0200 Subject: [PATCH 46/55] fix(lint): the CLI may name a Prisma version before 8 orm init sets Prisma 8 up beside a Prisma 7 project (#30291), so the CLI names Prisma 7 in its flags, help and output. The version rule of the framework vocabulary lint now skips packages/1-framework/3-tooling/cli; the family and target vocabulary rule still covers it. The rest of the framework still may not name a Prisma version before 8. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .agents/rules/README.md | 2 +- .agents/rules/no-family-vocabulary-in-framework.mdc | 6 +++--- biome-plugins/no-family-vocabulary.grit | 13 +++++++++---- scripts/lint-framework-vocabulary.test.mjs | 13 +++++++++++-- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.agents/rules/README.md b/.agents/rules/README.md index 1a18c39d4ca0..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/ { }); describe('lint-framework-vocabulary — product versions', () => { - it('counts names of Prisma versions before 8 in any framework package, including the PSL parser', () => { + it('counts names of Prisma versions before 8 in framework packages, including the PSL parser', () => { writeConfig(8); - writeRepoFile(`${SCOPE}/3-tooling/cli/src/versions.ts`, FILE_EARLIER_PRODUCT_VERSIONS); + 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(); @@ -464,6 +464,15 @@ describe('lint-framework-vocabulary — product versions', () => { 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); From b1e4f3fedf765817d42f4bd9936c36e31587b750 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 18:47:40 +0200 Subject: [PATCH 47/55] test(cli): the PSL source a journey prints carries the prisma-8 marker Since #30379 a PSL source reads only files that carry `// use prisma-8`. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- test/integration/test/cli-journeys/prisma7-print.e2e.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts index 51d2b2c8635e..9e9e9d0ab440 100644 --- a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts @@ -267,7 +267,10 @@ withTempDir(({ createTempDir }) => { const ctx = setupPrisma7Project(createTempDir, NO_DATABASE, { copyFrom: join(PRISMA7_FIXTURES, 'implicit-many-to-many-names/schema.prisma'), }); - writeFileSync(join(ctx.testDir, 'contract.prisma'), 'model User {\n id Int @id\n}\n'); + 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']); From 2b476731f849f102484587895c836f3fc9ff6512 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 25 Sep 2026 15:24:52 +0200 Subject: [PATCH 48/55] fix(postgres): contract print never writes a __proto__ name the PSL source would lose The PSL source keeps names as keys of plain objects, where `__proto__` sets the prototype instead of adding a key, so a name `__proto__` is lost when a PSL file is read. The printer refuses a model, field, enum member or other name `__proto__` by name. A native enum's member names are only labels for its values, so the shared native enum block builder never picks `__proto__` and a value `"__proto__"` reads back. Both enum block builders build their members with Object.fromEntries. The reader's handling of `__proto__` exists on main and is recorded in the project spec as found outside this project's scope. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 2 +- .../core/psl-ast/name-the-psl-source-loses.ts | 6 ++++ .../src/core/psl-ast/native-enum-block.ts | 16 ++++++----- .../src/core/psl-print/domain-types.ts | 8 +++--- .../src/core/psl-print/enum-blocks.ts | 24 ++++++++-------- .../src/core/psl-print/psl-contract.ts | 4 +-- .../postgres/src/core/psl-print/refusals.ts | 13 +++++++-- .../src/core/psl-print/relation-fields.ts | 4 +-- .../src/core/psl-print/row-level-security.ts | 8 +++--- .../src/core/psl-print/scalar-fields.ts | 4 +-- .../test/psl-print/refusals-entries.test.ts | 13 +++++++++ .../postgres/test/psl-print-roundtrip.test.ts | 28 +++++++++++++++++++ projects/prisma7-contract-source/spec.md | 1 + 13 files changed, 95 insertions(+), 36 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-ast/name-the-psl-source-loses.ts diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index d85ea4cc58a0..2164bdd45728 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -348,7 +348,7 @@ A model declares an empty unique constraint (a unique with no fields), raised du - 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 name PSL writes as an identifier is not one: a 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 name PSL writes as an identifier is not one, or is `__proto__`, which the PSL source loses when it reads it: a 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`); - a storage entry is not an entity of its kind (meta: `namespaceId`, `kind`, `name`). diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-ast/name-the-psl-source-loses.ts b/packages/3-targets/3-targets/postgres/src/core/psl-ast/name-the-psl-source-loses.ts new file mode 100644 index 000000000000..2ab75a0ce4f1 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-ast/name-the-psl-source-loses.ts @@ -0,0 +1,6 @@ +/** + * The PSL source keeps names as keys of plain objects, and assigning this key + * sets the object's prototype instead, so this name is lost when the file is + * read. + */ +export const NAME_THE_PSL_SOURCE_LOSES = '__proto__'; diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-ast/native-enum-block.ts b/packages/3-targets/3-targets/postgres/src/core/psl-ast/native-enum-block.ts index 7866d125ce85..07780d80f157 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-ast/native-enum-block.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-ast/native-enum-block.ts @@ -4,6 +4,7 @@ import type { PslExtensionBlockParamValue, } from '@internal/framework-components/psl-ast'; import { escapePslString } from '@internal/sql-relational-core/ast'; +import { NAME_THE_PSL_SOURCE_LOSES } from './name-the-psl-source-loses'; import { SYNTHETIC_SPAN } from './psl-literals'; import { createUniqueFieldName } from './unique-name'; @@ -13,13 +14,14 @@ export function buildNativeEnumBlock( 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 }; - } + 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', 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 index f41c862c52e0..d052a57eda07 100644 --- 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 @@ -14,8 +14,8 @@ import { isPostgresCodecDescriptor } from '../codec-descriptor'; import { SYNTHETIC_SPAN } from '../psl-ast/psl-literals'; import { buildColumnType, type PslColumnType } from './column-types'; import { - refuseNonIdentifier, refuseUnwritableFieldShape, + refuseUnwritableName, refuseValueObjectFieldCodecNeedingTypeParameters, refuseValueObjectFieldCodecWithoutNativeType, refuseValueObjectFieldPartsTheSourceDrops, @@ -83,12 +83,12 @@ export function buildCompositeTypes(input: { refuseValueObjectsOutsideDefaultNamespace(input.contract, input.namespaceId); const valueObjects = input.contract.domain.namespaces[input.namespaceId]?.valueObjects ?? {}; return Object.entries(valueObjects).map(([name, valueObject]) => { - refuseNonIdentifier('value object', name); + refuseUnwritableName('value object', name); return { kind: 'compositeType', name, fields: Object.entries(valueObject.fields).map(([fieldName, field]): PslField => { - refuseNonIdentifier('field', fieldName); + refuseUnwritableName('field', fieldName); const { typeName, typeConstructor } = buildDomainFieldType({ field, coordinate: `"${input.namespaceId}".${name}.${fieldName}`, @@ -121,7 +121,7 @@ export function buildTypesBlock( ): PslTypesBlock | undefined { const declarations: PslNamedTypeDeclaration[] = []; for (const [name, instance] of Object.entries(contract.storage.types ?? {})) { - refuseNonIdentifier('named type', name); + refuseUnwritableName('named type', name); const { typeName, typeConstructor } = buildColumnType({ column: new StorageColumn({ nativeType: instance.nativeType, 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 index 8516f5bc691b..6fd93e5daba9 100644 --- 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 @@ -11,7 +11,7 @@ import { SYNTHETIC_SPAN } from '../psl-ast/psl-literals'; import { refuseNativeEnumControl, refuseNativeEnumWithoutValueSet, - refuseNonIdentifier, + refuseUnwritableName, } from './refusals'; /** @@ -23,16 +23,16 @@ export function buildDomainEnumBlocks( enums: Readonly>, ): readonly PslExtensionBlock[] { return Object.entries(enums).map(([name, domainEnum]): PslExtensionBlock => { - refuseNonIdentifier('enum', name); - const parameters: Record = {}; - for (const member of domainEnum.members) { - refuseNonIdentifier('enum member', member.name); - parameters[member.name] = { - kind: 'value', - raw: JSON.stringify(member.value), - span: SYNTHETIC_SPAN, - }; - } + 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', @@ -126,7 +126,7 @@ export function buildNativeEnumBlocksForNamespace(input: { for (const [entryName, nativeEnum] of input.nativeEnums) { const { typeName } = nativeEnum; const blockName = nameByEntry.get(entryName) ?? typeName; - refuseNonIdentifier('native enum', blockName); + refuseUnwritableName('native enum', blockName); blocks.push(buildNativeEnumBlock(blockName, typeName, nativeEnum.members)); blockNamesByTypeName.set(typeName, blockName); blockNamesByTypeName.set(`${input.namespaceId}.${typeName}`, blockName); 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 index 8c69a3b46119..4cc6967d38e0 100644 --- 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 @@ -47,7 +47,6 @@ import { refuseInvalidEntry, refuseModelOwner, refuseModelsOutsideTableNamespace, - refuseNonIdentifier, refuseRlsWithoutModel, refuseUnderivedChecks, refuseUnderivedRoots, @@ -56,6 +55,7 @@ import { refuseUnmodelledTablesAndColumns, refuseUnprintedEntryKinds, refuseUntravelledForeignKeys, + refuseUnwritableName, refuseUnwrittenExecutionDefaults, refuseUnwrittenNamespaces, } from './refusals'; @@ -125,7 +125,7 @@ function buildModel( rlsTables: ReadonlySet, ): PslModel { const { models, variants, relationsByModel, byCoordinate } = all; - refuseNonIdentifier('model', entry.name); + refuseUnwritableName('model', entry.name); refuseModelOwner(entry); const variant = variants.get(entry); if (variant !== undefined && !variant.singleTable) refuseUnderivedVariantLink(entry, variant); 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 index dfc166a0e586..b661d41b775f 100644 --- 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 @@ -31,6 +31,7 @@ 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 { NAME_THE_PSL_SOURCE_LOSES } from '../psl-ast/name-the-psl-source-loses'; import { isVariantLinkForeignKey, type ModelWithTable, @@ -583,7 +584,7 @@ export function refuseUnwritableIndexOptions(entry: ModelWithTable, index: Index ); } for (const [key, value] of Object.entries(index.options ?? {})) { - refuseNonIdentifier('index option', key); + 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.`, @@ -934,7 +935,15 @@ export function refuseEntryInOtherNamespace(input: { * Refuses a name the printer writes where PSL reads an identifier: a model, field, value object, * enum, enum member, native enum, named type, policy, role or index option key. */ -export function refuseNonIdentifier(kind: string, name: string): void { +export function refuseUnwritableName(kind: string, name: string): void { + if (name === NAME_THE_PSL_SOURCE_LOSES) { + 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 }, + ); + } if (isPslIdentifier(name)) return; throw unsupported( `${kind} "${name}" is not a PSL identifier, so it cannot be written in Prisma 8 PSL.`, 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 index b629e2cd1734..fba658d54ac7 100644 --- 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 @@ -14,9 +14,9 @@ import { } from './contract-model-index'; import { refuseManyToManyWithoutJunctionRelation, - refuseNonIdentifier, refuseRelationToOtherContractSpace, refuseToOneRelationWithoutForeignKey, + refuseUnwritableName, } from './refusals'; const PSL_REFERENTIAL_ACTIONS: Readonly> = { @@ -312,7 +312,7 @@ export function buildRelationFields(input: { }): readonly PslField[] { const fields: PslField[] = []; for (const modelRelation of orderedByForeignKey(input.entry, input.relations)) { - refuseNonIdentifier('field', modelRelation.fieldName); + refuseUnwritableName('field', modelRelation.fieldName); const target = relationTarget(modelRelation, input.modelsByCoordinate); const junctionRelation = modelRelation.relation.cardinality === 'N:M' 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 index dc341c3ee3ea..57343d8bfb73 100644 --- 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 @@ -11,11 +11,11 @@ import { refuseEntryFiledUnderAnotherName, refuseEntryInOtherNamespace, refuseInvalidEntry, - refuseNonIdentifier, refusePolicyNameNotDerived, refusePolicyWithoutModel, refusePolicyWithoutRls, refuseRoleOutsideUnbound, + refuseUnwritableName, } from './refusals'; /** @@ -65,7 +65,7 @@ export function buildRoleBlocks( recordedNamespaceId: entity.namespaceId, }); refuseEntryFiledUnderAnotherName({ namespaceId, kind: 'role', name, readsBackAs: entity.name }); - refuseNonIdentifier('role', entity.name); + refuseUnwritableName('role', entity.name); return { kind: 'role', keyword: 'role', @@ -109,7 +109,7 @@ export function buildPolicyBlocks(input: { return Object.entries(input.entries ?? {}).map(([head, policy]): PslExtensionBlock => { if (!(policy instanceof PostgresRlsPolicy)) refuseInvalidEntry(input.namespaceId, 'policy', head); - refuseNonIdentifier('policy', head); + refuseUnwritableName('policy', head); refuseEntryInOtherNamespace({ namespaceId: input.namespaceId, kind: 'policy', @@ -120,7 +120,7 @@ export function buildPolicyBlocks(input: { 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) refuseNonIdentifier('role', role); + for (const role of policy.roles) refuseUnwritableName('role', role); return { kind: 'policy', 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 index f761c9a25754..aa7e2040444a 100644 --- 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 @@ -25,9 +25,9 @@ import { refuseFieldColumnMismatch, refuseFieldsWithoutColumn, refuseGeneratorWithDatabaseDefault, - refuseNonIdentifier, refuseStorageWithoutFieldOrColumn, refuseUnwritableFieldShape, + refuseUnwritableName, } from './refusals'; /** The generated values of the contract, keyed by the column they fill. */ @@ -121,7 +121,7 @@ export function buildScalarFields(input: { if (column === undefined || field === undefined) { refuseStorageWithoutFieldOrColumn({ entry, fieldName, coordinate }); } - refuseNonIdentifier('field', fieldName); + refuseUnwritableName('field', fieldName); refuseUnwritableFieldShape(field, coordinate); refuseColumnControl(column, coordinate); refuseFieldColumnMismatch({ 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 index ecfbbb0d2cfc..40727eb24f7a 100644 --- 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 @@ -53,6 +53,19 @@ describe('names PSL writes as identifiers', () => { ); }); + 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({ diff --git a/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts b/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts index 89c8088e4925..1281e87d09b4 100644 --- a/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts @@ -272,6 +272,34 @@ describe('a printed PSL contract reads back as the same contract', () => { ); }); +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( diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index 258353c674ed..17d15318d8db 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -146,6 +146,7 @@ A `Json` object or array literal default was refused in the first version and no Each exists on `main` unless the line says otherwise, so none is a regression this project caused. Each needs its own piece of work. +- 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 `symbol-table.ts` and `block-reconstruction.ts` in `@internal/psl-parser`). `contract print` refuses the name rather than write a file that reads back without it. In the PSL parser and readers. - 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. - `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. From c262ba6363e1547ccbbb3c8d83549818e8df883a Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 25 Sep 2026 15:43:50 +0200 Subject: [PATCH 49/55] test(cli): the printed-contract journey config imports definePrismaConfig @prisma/cli-engine 0.6.1, which main now uses, no longer exports the defineConfig alias. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../fixtures/cli-journeys/prisma.config.prisma7-printed.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-printed.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-printed.ts index 64e5c1b85e92..11e3bc2fef65 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-printed.ts +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-printed.ts @@ -1,9 +1,9 @@ // 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 { defineConfig } from '@prisma/cli-engine'; +import { definePrismaConfig } from '@prisma/cli-engine'; -export default defineConfig({ +export default definePrismaConfig({ orm: postgres({ contract: './contract.prisma', db: { From 787294fce003f1e1a68ac16eaf8efa6e5e1c5c20 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 25 Sep 2026 16:06:09 +0200 Subject: [PATCH 50/55] feat(cli): contract print prints the PSL unless --output names a file Without --output, contract print writes no file. In a terminal it shows the PSL as a drawing block; with --format human a pipe receives the PSL alone on standard output; the JSON result carries it as psl.text. With --output it writes the file as before, with the same protections for the source, the config file and the emitted contract files. The next step says to write the PSL to a file with --output before pointing the config at it. The command no longer has a default output path, so the helper that picks one is contract infer's alone again and takes back its name on main, inferredContractPathFor. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 4 +- examples/prisma7-adoption/README.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 24 +-- .../3-tooling/cli/src/orm/contract/infer.ts | 4 +- .../3-tooling/cli/src/orm/contract/paths.ts | 16 +- .../3-tooling/cli/src/orm/contract/print.ts | 138 +++++++++++------- .../cli/test/orm/contract-print.test.ts | 104 +++++++++++-- packages/3-extensions/postgres/README.md | 4 +- .../03-contract-to-psl-and-print/spec.md | 12 +- projects/prisma7-contract-source/spec.md | 2 +- .../cli-journeys/prisma7-print.e2e.test.ts | 10 +- 11 files changed, 221 insertions(+), 99 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 2164bdd45728..7478fb60289e 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -283,11 +283,11 @@ A model declares an empty unique constraint (a unique with no fields), raised du ### CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE -`prisma contract print` was asked to write its output over a file the project needs: `prisma.config.ts` in the invocation directory, 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. +`prisma contract print --output` was asked to write over a file the project needs: `prisma.config.ts` in the invocation directory, 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` was asked to write its output 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. 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. +`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. 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 diff --git a/examples/prisma7-adoption/README.md b/examples/prisma7-adoption/README.md index 2bcc0a8a0cb4..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, `prisma contract print` 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. +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/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 430808e49021..42852533f580 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -422,7 +422,7 @@ prisma db schema -v ### `prisma contract print` -Load the contract from the source the config names and write the Prisma 8 PSL that reads back as the same contract. 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. +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 @@ -431,32 +431,38 @@ prisma contract print [--config ] [--output ] [--json] [-v] [-q] [-- Options: - `--config `: Optional. Path to `prisma.config.ts` (defaults to `./prisma.config.ts` if present) -- `--output `: Write the printed PSL contract to the specified path -- `--json`: Output a JSON result envelope (includes `psl.path` and the `source` files it read) +- `--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`, and the `source` files it read) - `-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. The output path is chosen the same way `contract infer` chooses one, and an existing file there is overwritten with a warning. +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: -The written file opens with two comment lines: the `// use prisma-8` marker, and a line naming the source files it was printed from: +```bash +prisma contract print --format human > prisma/contract.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 written file reads back as the identical contract. Where PSL has no form for part of the contract, the command refuses, names that part, and writes nothing. It exits `2` and writes nothing in these cases: +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, or sits inside a directory of source files. Pick another path. -- `CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE`: the output path is `prisma.config.ts` in the invocation directory, or one of the files `contract emit` writes (`contract.json` and `contract.d.ts`, or whatever `contract.output` names). Pick another path. +- `CONTRACT.PRINT_OUTPUT_IS_SOURCE`: the `--output` path is a source file the config reads, or sits inside a directory of source files. Pick another path. +- `CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE`: the `--output` path is `prisma.config.ts` in the invocation directory, 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 (`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 default output path of a Prisma 7 cutover: +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 (`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 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 4ec66ee5d0f0..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 @@ -21,7 +21,7 @@ import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { normalizeError } from '../normalize-error'; import { controlProgressReporter } from '../progress'; -import { pslOutputPathFor } from './paths'; +import { inferredContractPathFor } from './paths'; interface InferDocument { readonly ok: true; @@ -175,7 +175,7 @@ export function createContractInferCommand({ await closeQuietly(client); } - const outputPath = pslOutputPathFor({ + const outputPath = inferredContractPathFor({ config: ctx.config, cwd: ctx.cwd, output: args.flags.output, 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 212de09c1d98..4dd9d612556f 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 @@ -2,15 +2,15 @@ import { realpath, stat } from 'node:fs/promises'; import type { PrismaNextConfig } from '@internal/config/config-types'; import { basename, dirname, extname, join, normalize, resolve } from 'pathe'; -/** The file `contract infer` and `contract print` write when nothing names another. */ -const PSL_CONTRACT_FILENAME = 'contract.prisma'; +/** The file `contract infer` writes when nothing names another. */ +const INFERRED_CONTRACT_FILENAME = 'contract.prisma'; /** - * Where `contract infer` and `contract print` write their PSL: `--output`, - * else `contract.prisma` beside the emitted contract, else `contract.prisma` - * in the invocation directory. + * Where `contract infer` writes the PSL it inferred: `--output`, else + * `contract.prisma` beside the emitted contract, else `contract.prisma` in the + * invocation directory. */ -export function pslOutputPathFor(inputs: { +export function inferredContractPathFor(inputs: { readonly config: PrismaNextConfig; readonly cwd: string; readonly output: string | undefined; @@ -20,9 +20,9 @@ export function pslOutputPathFor(inputs: { } const contractOutput = inputs.config.contract?.output; if (contractOutput !== undefined) { - return join(dirname(resolve(inputs.cwd, contractOutput)), PSL_CONTRACT_FILENAME); + return join(dirname(resolve(inputs.cwd, contractOutput)), INFERRED_CONTRACT_FILENAME); } - return join(inputs.cwd, PSL_CONTRACT_FILENAME); + return join(inputs.cwd, INFERRED_CONTRACT_FILENAME); } /** 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 index 4336457619fd..bb6266bc2db9 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts @@ -19,13 +19,14 @@ import { publishTextArtifact } from '../../utils/publish-text-artifact'; import { defineOrmCommand } from '../define-command'; import { baseDirFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; -import { emittedJsonPathFor, filePathKey, pslOutputPathFor } from './paths'; +import { emittedJsonPathFor, filePathKey } from './paths'; interface PrintDocument { readonly ok: true; readonly summary: string; readonly target: { readonly familyId: string; readonly id: string }; - readonly psl: { readonly path: 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[]; /** * The contract's default control policy. A PSL file cannot carry it; the @@ -41,18 +42,29 @@ interface EmittedFilesMove { 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, emittedFilesMove: EmittedFilesMove | undefined, ): readonly NextAction[] { + const policyClause = sourceSettingsClause(document.defaultControlPolicy); + const emit = runCommandAction('Emit the printed contract', '{bin} contract emit'); + if (!('path' in document.psl)) { + return [ + chooseAction( + `Write the PSL to a file with --output , then point contract in prisma.config.ts at that file${policyClause}`, + ), + emit, + ]; + } const path = document.psl.path; - const policy = document.defaultControlPolicy; return [ - chooseAction( - policy === undefined - ? `Point contract in prisma.config.ts at ${path}` - : `Point contract in prisma.config.ts at ${path}, through a PSL source that sets defaultControlPolicy: '${policy}'`, - ), + chooseAction(`Point contract in prisma.config.ts at ${path}${policyClause}`), ...(emittedFilesMove === undefined ? [] : [ @@ -60,7 +72,7 @@ function switchToPrintedActions( `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}`, ), ]), - runCommandAction('Emit the printed contract', '{bin} contract emit'), + emit, ]; } @@ -68,20 +80,34 @@ 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, emittedFilesMove: EmittedFilesMove | undefined, ): Presentations { return { - stdout: () => [], + stdout: () => ('text' in document.psl ? linesOf(document.psl.text) : []), next: () => switchToPrintedActions(document, emittedFilesMove), - human: (): readonly Block[] => [ - { - kind: 'summary', - status: 'ok', - text: [{ text: 'Contract written to ' }, { text: document.psl.path, tone: 'identifier' }], - }, - ], + 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, }; } @@ -196,17 +222,20 @@ function emittedFilesMoveFor(inputs: { export function createContractPrintCommand({ printPsl }: ContractPrintCommandDeps) { return defineOrmCommand({ help: { - summary: 'Write the configured contract as Prisma 8 PSL', + 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 writes it as a Prisma 8 PSL file. Emitting that\n' + - 'file produces the same contract: same hashes, same domain. If the\n' + - 'contract holds something PSL cannot express, the command refuses,\n' + - 'names it, and writes nothing. The command only writes the PSL file;\n' + - 'switch the config to it, then run `contract emit`. An existing file at\n' + - 'the output path is overwritten, with a warning.', + '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 > ./src/prisma/contract.prisma', 'contract print --output ./src/prisma/contract.prisma', 'contract print --json', ], @@ -214,7 +243,7 @@ export function createContractPrintCommand({ printPsl }: ContractPrintCommandDep args: { flags: { output: flag.string({ - brief: 'Write the printed PSL contract to the specified path', + brief: 'Write the PSL to this file instead of standard output', placeholder: 'path', }), }, @@ -237,21 +266,19 @@ export function createContractPrintCommand({ printPsl }: ContractPrintCommandDep const emittedJsonPath = contractConfig.output === undefined ? undefined : resolve(ctx.cwd, contractConfig.output); - const outputPath = pslOutputPathFor({ - config: ctx.config, - cwd: ctx.cwd, - output: args.flags.output, - }); - const displayPath = relative(ctx.cwd, outputPath); - const refusal = await outputPathRefusal({ - cwd: ctx.cwd, - outputPath, - sourceInputs, - configPath: resolve(baseDirFor(ctx.config), 'prisma.config.ts'), - emittedJsonPath, - }); - if (refusal !== undefined) { - return notOk(normalizeError(refusal)); + 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(baseDirFor(ctx.config), 'prisma.config.ts'), + emittedJsonPath, + }); + if (refusal !== undefined) { + return notOk(normalizeError(refusal)); + } } let printed: ContractPrintResult; @@ -270,18 +297,20 @@ export function createContractPrintCommand({ printPsl }: ContractPrintCommandDep } ctx.signal.throwIfAborted(); - if (existsSync(outputPath)) { - ctx.report({ - kind: 'message', - severity: 'warn', - text: `Overwriting existing file: ${displayPath}`, + 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()), }); } - await publishTextArtifact({ - path: outputPath, - content: printed.psl, - publicationToken: String(process.hrtime.bigint()), - }); const { defaultControlPolicy } = printed.sourceSettings; if (defaultControlPolicy !== undefined) { @@ -296,7 +325,10 @@ export function createContractPrintCommand({ printPsl }: ContractPrintCommandDep ok: true, summary: 'Contract printed successfully', target: { familyId: ctx.config.family.familyId, id: ctx.config.target.targetId }, - psl: { path: displayPath }, + psl: + outputPath === undefined + ? { text: printed.psl } + : { path: relative(ctx.cwd, outputPath) }, source: sourcePaths, ...ifDefined('defaultControlPolicy', defaultControlPolicy), timings: { total: Date.now() - startedAt }, @@ -307,7 +339,9 @@ export function createContractPrintCommand({ printPsl }: ContractPrintCommandDep { data: document }, printPresentations( document, - emittedFilesMoveFor({ cwd: ctx.cwd, outputPath, emittedJsonPath }), + outputPath === undefined + ? undefined + : emittedFilesMoveFor({ cwd: ctx.cwd, outputPath, emittedJsonPath }), ), ), ); 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 index 7f693e16408d..e1604e7a4416 100644 --- 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 @@ -18,11 +18,69 @@ import { useContractPrintDoubles(); describe('contract print', () => { - it('settles as a completed envelope carrying the written path and the schema it read', async () => { + 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'], + 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, @@ -37,7 +95,10 @@ describe('contract print', () => { it('describes the written file as printed from the source it read', async () => { const dir = await projectDir(); - await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); + await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'generated/contract.prisma', '--json'], + { cwd: dir }, + ); expect(mocks.printPsl).toHaveBeenCalledWith( { kind: 'psl-document' }, @@ -66,7 +127,10 @@ describe('contract print', () => { it('publishes through a staged rename, leaving no temporary file behind', async () => { const dir = await projectDir(); - await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); + await harness(ormConfig(dir)).run( + ['contract', 'print', '--output', 'generated/contract.prisma', '--json'], + { cwd: dir }, + ); expect(await readdir(join(dir, 'generated'))).toEqual(['contract.prisma']); }); @@ -79,10 +143,13 @@ describe('contract print', () => { return ok({ roots: {}, domain: {} }); }); - const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { - cwd: dir, - abort: controller.signal, - }); + 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'); @@ -126,10 +193,10 @@ describe('contract print', () => { 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'], { - cwd: dir, - isTty: { stdout: true, stderr: true }, - }); + 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([ { @@ -160,7 +227,10 @@ describe('contract print', () => { sourceSettings: { defaultControlPolicy: 'external' }, }); - const run = await harness(ormConfig(dir)).run(['contract', 'print', '--json'], { cwd: dir }); + 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({ @@ -196,7 +266,10 @@ describe('contract print', () => { }, }); - const run = await harness(config).run(['contract', 'print', '--json'], { cwd: dir }); + 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([ @@ -222,7 +295,10 @@ describe('contract print', () => { }, }); - const run = await harness(config).run(['contract', 'print', '--json'], { cwd: dir }); + const run = await harness(config).run( + ['contract', 'print', '--output', 'generated/contract.prisma', '--json'], + { cwd: dir }, + ); expect(run.exitCode).toBe(0); expect(mocks.load).toHaveBeenCalled(); diff --git a/packages/3-extensions/postgres/README.md b/packages/3-extensions/postgres/README.md index 645061f880f6..728ad188e7f4 100644 --- a/packages/3-extensions/postgres/README.md +++ b/packages/3-extensions/postgres/README.md @@ -73,7 +73,7 @@ Simplified `defineConfig` that pre-wires all Postgres internals (family, target, #### 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` 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: +`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 @@ -127,7 +127,7 @@ 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` writes the Prisma 8 PSL that produces the same contract, with a header naming the schema it came from. 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 writes nothing. +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: 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 index 83f787fc3dd4..b926757b1de8 100644 --- 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 @@ -17,16 +17,18 @@ Output begins: ## 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 gains a `headerComment` option the command passes; its default leaves `contract infer` output unchanged. -- **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, prints, and writes with `publishTextArtifact`. Output path resolution uses `pslOutputPathFor`, shared with `contract infer`. Refusals exit 2 and write nothing. +- **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 fixture from slices 1 and 2: 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 | |---|---| -| 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. | +| 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 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 so the config sets it on the PSL source. | @@ -37,4 +39,4 @@ Inherits `drive/calibration/dod.md`. Slice-specific: - [ ] Round trip hash equality holds for every fixture from slices 1 and 2 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. +- [ ] `--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 17d15318d8db..37ff4f51ce1d 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -124,7 +124,7 @@ Recorded so they are not lost; each becomes its own project when scheduled. ### What `contract print` cannot write -`contract print` loads the contract the config names, from any source, and writes it 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 broadest is `test/integration/test/psl-print/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 (`contract-prisma7/test/print-roundtrip.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 (`adapter-postgres/test/psl-print-roundtrip.test.ts`); every default function and temporal preset the stack registers (`adapter-postgres/test/psl-print-generated-values.test.ts`); TypeScript-authored contracts (`test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts`); a pgvector column, with and without a literal default, with the extension in the stack (`test/integration/test/psl-print/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 (`adapter-postgres/test/helpers/psl-print.ts`). Where PSL has no form for part of the 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. +`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 broadest is `test/integration/test/psl-print/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 (`contract-prisma7/test/print-roundtrip.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 (`adapter-postgres/test/psl-print-roundtrip.test.ts`); every default function and temporal preset the stack registers (`adapter-postgres/test/psl-print-generated-values.test.ts`); TypeScript-authored contracts (`test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts`); a pgvector column, with and without a literal default, with the extension in the stack (`test/integration/test/psl-print/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 (`adapter-postgres/test/helpers/psl-print.ts`). Where PSL has no form for part of the 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: diff --git a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts index 9e9e9d0ab440..d9fefbf91701 100644 --- a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts +++ b/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts @@ -158,7 +158,7 @@ async function printAndVerify(ctx: JourneyContext, connectionString: string): Pr 0, ); - const print = await runContractPrint(ctx, ['--json']); + 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, @@ -241,7 +241,11 @@ withTempDir(({ createTempDir }) => { expect(typeScriptEmit.exitCode, output(typeScriptEmit)).toBe(0); expect(emittedContractJson(testDir)).toMatchObject({ defaultControlPolicy: 'external' }); - const print = await runContractPrint(onTypeScript, ['--json']); + 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' }, @@ -284,7 +288,7 @@ withTempDir(({ createTempDir }) => { 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, ['--json']); + 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'); From 116c3ae5fdc9d9ddbe90b87adcc8871e2326cbb7 Mon Sep 17 00:00:00 2001 From: willbot Date: Sun, 27 Sep 2026 10:14:59 +0200 Subject: [PATCH 51/55] fix(scripts): check-upgrade-coverage reads a repository tree larger than 1 MiB The script lists the head tree with `git ls-tree -r`, and execFileSync buffers 1 MiB by default. The tree listing on main is 1,046,021 bytes, so any branch that adds a few dozen files fails the check with `spawnSync git ENOBUFS` before it checks anything. The git helper now allows 256 MiB. What the check verifies is unchanged; a test builds a tree whose listing is over 1 MiB and requires the check to pass. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- scripts/check-upgrade-coverage.mjs | 3 +++ scripts/check-upgrade-coverage.test.mjs | 27 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/scripts/check-upgrade-coverage.mjs b/scripts/check-upgrade-coverage.mjs index 7a89d31b7b29..9295768cc1af 100644 --- a/scripts/check-upgrade-coverage.mjs +++ b/scripts/check-upgrade-coverage.mjs @@ -54,10 +54,13 @@ const COVERED_DIRECTORIES = [ { audience: 'app', directory: 'examples/' }, { audience: 'extension', directory: 'packages/3-extensions/' }, ]; +/** Room for a whole-repository tree listing, which outgrew the 1 MiB child-process default. */ +const GIT_OUTPUT_LIMIT_BYTES = 256 * 1024 * 1024; function git(repoRoot, ...args) { return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', + maxBuffer: GIT_OUTPUT_LIMIT_BYTES, stdio: ['ignore', 'pipe', 'pipe'], }); } diff --git a/scripts/check-upgrade-coverage.test.mjs b/scripts/check-upgrade-coverage.test.mjs index 37b3bbad90d3..e8edcf4c32b6 100644 --- a/scripts/check-upgrade-coverage.test.mjs +++ b/scripts/check-upgrade-coverage.test.mjs @@ -99,6 +99,33 @@ describe('version and format helpers', () => { }); }); +describe('repository size', () => { + it('reads a head whose tree listing is larger than a child process buffers by default', () => { + const base = commit(); + const emptyBlob = execFileSync('git', ['hash-object', '-w', '--stdin'], { + cwd: repo, + input: '', + encoding: 'utf8', + }).trim(); + const entries = Array.from( + { length: 12_000 }, + (_, index) => `100644 ${emptyBlob}\tbulk/${'x'.repeat(60)}-${index}.txt`, + ); + execFileSync('git', ['update-index', '--index-info'], { + cwd: repo, + input: `${entries.join('\n')}\n`, + }); + git('commit', '-qm', 'bulk'); + const listing = execFileSync('git', ['ls-tree', '-r', '-z', 'HEAD'], { + cwd: repo, + maxBuffer: 16 * 1024 * 1024, + }); + assert.ok(listing.length > 1024 * 1024); + + passes(base); + }); +}); + describe('independent PR declarations', () => { for (const [audience, changedFile] of [ ['app', 'examples/demo.ts'], From b5417d68bfe3bcf3aab5a700954a41c29fe6116b Mon Sep 17 00:00:00 2001 From: willbot Date: Sun, 27 Sep 2026 10:43:47 +0200 Subject: [PATCH 52/55] fix(scripts): check-upgrade-coverage lists only the instruction directories it reads The check looked up tree entries only under upgrade-instructions/pending/ and skills/prisma-8/upgrading/, but listed the whole repository tree to find them. That listing passed 1 MiB, the child-process output default, and the check failed with `spawnSync git ENOBUFS`. It now lists those two directories, about 17 KB today, so its size no longer grows with the rest of the repository. This replaces the larger output buffer the previous fix added. The test that builds 12,000 unrelated files now proves that files outside those directories do not affect the check. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- scripts/check-upgrade-coverage.mjs | 7 +++---- scripts/check-upgrade-coverage.test.mjs | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/check-upgrade-coverage.mjs b/scripts/check-upgrade-coverage.mjs index 9295768cc1af..0c07936ff416 100644 --- a/scripts/check-upgrade-coverage.mjs +++ b/scripts/check-upgrade-coverage.mjs @@ -49,18 +49,16 @@ export function transitionLabel(prev, head) { const PENDING = 'upgrade-instructions/pending/'; const PENDING_INSTRUCTIONS = /^upgrade-instructions\/pending\/[^/]+\/(app|extension)\/instructions\.md$/; +const PUBLISHED_ROOT = 'skills/prisma-8/upgrading/'; const PUBLISHED_DIRECTORY = /^(skills\/prisma-8\/upgrading\/(?:app|extension)\/upgrades\/[^/]+)\//; const COVERED_DIRECTORIES = [ { audience: 'app', directory: 'examples/' }, { audience: 'extension', directory: 'packages/3-extensions/' }, ]; -/** Room for a whole-repository tree listing, which outgrew the 1 MiB child-process default. */ -const GIT_OUTPUT_LIMIT_BYTES = 256 * 1024 * 1024; function git(repoRoot, ...args) { return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', - maxBuffer: GIT_OUTPUT_LIMIT_BYTES, stdio: ['ignore', 'pipe', 'pipe'], }); } @@ -78,9 +76,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) => { diff --git a/scripts/check-upgrade-coverage.test.mjs b/scripts/check-upgrade-coverage.test.mjs index e8edcf4c32b6..fbcbb0a60d78 100644 --- a/scripts/check-upgrade-coverage.test.mjs +++ b/scripts/check-upgrade-coverage.test.mjs @@ -100,7 +100,7 @@ describe('version and format helpers', () => { }); describe('repository size', () => { - it('reads a head whose tree listing is larger than a child process buffers by default', () => { + it('checks a repository whose files outside the instruction directories list to more than 1 MiB', () => { const base = commit(); const emptyBlob = execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: repo, From eb5cfb3236bb22cdce8a54218cdabeef14fa6251 Mon Sep 17 00:00:00 2001 From: willbot Date: Sun, 27 Sep 2026 12:00:17 +0200 Subject: [PATCH 53/55] fix: second review round for contract print The printer refuses, by name, the shapes that printed a file that did not read back: a back-relation with no owning relation, a relation with no `on` part, a namespace name that is not a PSL identifier or is `unbound`, a table or column named `__proto__`, and a column whose named type does not match. Checks for contracts that validation already rejects are internal assertions now, and the error reference lists only what PSL cannot express. The hook and capability are named for what they return, a PSL document: buildPslContract, PslContractBuildCapable, SqlPslBuildContext. Printing is a consumer of the hook. The shared builders live under psl-build. contract print refuses an --output path a glob input would match once written, reports sourceSettings as the family returns it, and its help example no longer redirects onto the conventional contract source path. The default emitted path rule lives once in @internal/config. check-upgrade-coverage limits its changed-path listing to the directories it reads. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../no-family-vocabulary-in-framework.mdc | 2 +- docs/reference/error-reference.md | 23 +-- .../0-shared/publish-surface/src/shells.ts | 2 +- .../publish-surface/test/shells.test.ts | 1 + .../1-framework/1-core/config/package.json | 3 +- .../src/default-contract-output-path.ts | 25 ++++ .../1-core/config/src/exports/config-types.ts | 1 + .../test/default-contract-output-path.test.ts | 22 +++ .../src/control/control-capabilities.ts | 21 ++- .../src/exports/control.ts | 6 +- .../test/control-capabilities.test.ts | 14 +- ....ts => psl-contract-build.types.test-d.ts} | 14 +- .../2-authoring/psl-parser/README.md | 1 + .../psl-parser/src/exports/index.ts | 1 + .../src/name-the-psl-source-loses.ts | 4 + .../test/name-the-psl-source-loses.test.ts | 22 +++ packages/1-framework/3-tooling/cli/README.md | 12 +- .../3-tooling/cli/src/control-api/client.ts | 4 +- .../control-api/operations/contract-emit.ts | 4 +- .../control-api/operations/contract-print.ts | 10 +- .../operations/load-contract-source.ts | 4 +- .../3-tooling/cli/src/orm/contract/paths.ts | 11 +- .../3-tooling/cli/src/orm/contract/print.ts | 80 ++++++++--- .../test/control-api/contract-print.test.ts | 4 +- .../cli/test/orm/contract-print-support.ts | 22 ++- .../orm/contract-print.output-path.test.ts | 64 +++++++++ .../cli/test/orm/contract-print.test.ts | 50 ++++++- .../src/expand-contract-inputs.ts | 13 +- .../config-loader/src/exports/index.ts | 2 +- .../test/expand-contract-inputs.test.ts | 28 +++- .../2-authoring/contract-prisma7/package.json | 1 - .../contract-prisma7/tsconfig.json | 2 +- .../2-sql/2-authoring/contract-psl/README.md | 2 + .../2-authoring/contract-psl/package.json | 4 + .../contract-psl/src/exports/map-names.ts | 1 + .../contract-psl/src/exports/resolution.ts | 1 - .../2-authoring/contract-psl/tsdown.config.ts | 1 + packages/2-sql/9-family/README.md | 4 +- packages/2-sql/9-family/package.json | 2 +- .../9-family/src/core/control-instance.ts | 28 ++-- .../src/core/control-target-descriptor.ts | 17 +-- .../{psl-ast => psl-build}/default-mapping.ts | 0 .../core/{psl-ast => psl-build}/psl-names.ts | 0 .../core/{psl-ast => psl-build}/type-map.ts | 0 .../psl-contract-infer/name-transforms.ts | 2 +- .../core/psl-contract-infer/printer-config.ts | 4 +- .../2-sql/9-family/src/exports/control.ts | 2 +- .../src/exports/{psl-ast.ts => psl-build.ts} | 11 +- .../2-sql/9-family/src/exports/psl-infer.ts | 2 +- ...ntrol-instance.build-psl-contract.test.ts} | 42 +++--- .../default-mapping.test.ts | 2 +- .../{psl-ast => psl-build}/psl-names.test.ts | 2 +- packages/2-sql/9-family/tsdown.config.ts | 2 +- packages/3-extensions/mongo/README.md | 2 + .../mongo/src/config/define-config.ts | 31 +---- packages/3-extensions/postgres/README.md | 2 + .../postgres/src/config/define-config.ts | 31 +---- .../sqlite/src/config/define-config.ts | 29 +--- .../3-targets/3-targets/postgres/README.md | 2 +- .../3-targets/3-targets/postgres/package.json | 2 +- .../core/psl-ast/name-the-psl-source-loses.ts | 6 - .../index-attributes.ts | 0 .../native-enum-block.ts | 4 +- .../postgres-type-map.ts | 6 +- .../{psl-ast => psl-build}/psl-literals.ts | 0 .../{psl-ast => psl-build}/unique-name.ts | 0 .../src/core/psl-infer/infer-default-codec.ts | 18 +-- .../src/core/psl-infer/infer-enum-blocks.ts | 4 +- .../src/core/psl-infer/infer-model-blocks.ts | 17 ++- .../src/core/psl-infer/infer-names.ts | 2 +- .../src/core/psl-infer/infer-policy-blocks.ts | 2 +- .../src/core/psl-infer/infer-psl-contract.ts | 4 +- .../junction-relation-field-names.ts | 2 +- .../psl-infer/postgres-default-mapping.ts | 2 +- .../src/core/psl-print/column-defaults.ts | 8 +- .../src/core/psl-print/column-types.ts | 4 +- .../core/psl-print/contract-model-index.ts | 40 ++---- .../src/core/psl-print/domain-types.ts | 14 +- .../src/core/psl-print/enum-blocks.ts | 4 +- .../src/core/psl-print/generated-values.ts | 6 +- .../src/core/psl-print/model-attributes.ts | 6 +- .../src/core/psl-print/psl-contract.ts | 45 ++++-- .../postgres/src/core/psl-print/refusals.ts | 131 ++++++++++++------ .../src/core/psl-print/relation-fields.ts | 41 +++--- .../src/core/psl-print/row-level-security.ts | 20 ++- .../src/core/psl-print/scalar-fields.ts | 32 +++-- .../3-targets/postgres/src/exports/control.ts | 2 +- .../postgres-type-map.test.ts | 2 +- ...est.ts => inferred-psl.round-trip.test.ts} | 6 +- .../inferred-psl.check.test.ts} | 0 .../inferred-psl.core.test.ts} | 0 .../inferred-psl.data-type-defaults.test.ts} | 22 +-- .../inferred-psl.defaults-and-types.test.ts} | 0 .../inferred-psl.enums.test.ts} | 2 +- .../inferred-psl.literal-defaults.test.ts} | 0 ...ferred-psl.naming-and-constraints.test.ts} | 0 .../inferred-psl.no-check.test.ts} | 0 .../inferred-psl.nullable-list.test.ts} | 0 .../inferred-psl.relations.test.ts} | 0 .../inferred-psl.top-level-blocks.test.ts} | 2 +- .../{print-context.ts => build-context.ts} | 6 +- .../test/psl-print/enum-blocks.test.ts | 4 +- .../postgres/test/psl-print/print-support.ts | 4 +- .../test/psl-print/refusal-support.ts | 25 +++- .../test/psl-print/refusals-entries.test.ts | 110 ++------------- .../test/psl-print/refusals-fields.test.ts | 4 +- .../test/psl-print/refusals-list.test.ts | 2 +- .../test/psl-print/refusals-models.test.ts | 13 -- .../psl-print/refusals-named-types.test.ts | 62 +++++++++ .../test/psl-print/refusals-names.test.ts | 124 +++++++++++++++++ .../test/psl-print/refusals-relations.test.ts | 68 +++++++++ .../test/psl-print/refusals-storage.test.ts | 32 ++--- .../6-adapters/postgres/package.json | 1 - ...s.test.ts => inferred-type-codecs.test.ts} | 10 +- .../@prisma/orm-family-sql/package.json | 3 +- .../@prisma/orm-framework/package.json | 1 + .../@prisma/orm-postgres/package.json | 2 +- .../9-public/@prisma/orm-sqlite/package.json | 2 +- pnpm-lock.yaml | 18 +-- .../03-contract-to-psl-and-print/spec.md | 3 +- projects/prisma7-contract-source/spec.md | 11 +- scripts/check-upgrade-coverage.mjs | 31 ++++- scripts/check-upgrade-coverage.test.mjs | 59 +++++--- ...e2e.test.ts => contract-print.e2e.test.ts} | 4 +- ...d.ts => prisma.config.printed-contract.ts} | 0 ...red-contract-roundtrip.integration.test.ts | 4 +- .../constraints-roundtrip.integration.test.ts | 2 +- ...res-contract-roundtrip.integration.test.ts | 2 +- ...ension-types-roundtrip.integration.test.ts | 2 +- ...rated-values-roundtrip.integration.test.ts | 2 +- .../test/psl-print/print-and-read-back.ts | 33 +++-- ...sma7-fixture-roundtrip.integration.test.ts | 14 +- ...ipt-contract-roundtrip.integration.test.ts | 32 ++++- .../contract-print/app/instructions.md | 2 +- .../contract-print/extension/instructions.md | 8 +- 135 files changed, 1181 insertions(+), 674 deletions(-) create mode 100644 packages/1-framework/1-core/config/src/default-contract-output-path.ts create mode 100644 packages/1-framework/1-core/config/test/default-contract-output-path.test.ts rename packages/1-framework/1-core/framework-components/test/{psl-contract-print.types.test-d.ts => psl-contract-build.types.test-d.ts} (57%) create mode 100644 packages/1-framework/2-authoring/psl-parser/src/name-the-psl-source-loses.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/name-the-psl-source-loses.test.ts create mode 100644 packages/2-sql/2-authoring/contract-psl/src/exports/map-names.ts rename packages/2-sql/9-family/src/core/{psl-ast => psl-build}/default-mapping.ts (100%) rename packages/2-sql/9-family/src/core/{psl-ast => psl-build}/psl-names.ts (100%) rename packages/2-sql/9-family/src/core/{psl-ast => psl-build}/type-map.ts (100%) rename packages/2-sql/9-family/src/exports/{psl-ast.ts => psl-build.ts} (52%) rename packages/2-sql/9-family/test/{control-instance.print-psl-contract.test.ts => control-instance.build-psl-contract.test.ts} (70%) rename packages/2-sql/9-family/test/{psl-ast => psl-build}/default-mapping.test.ts (99%) rename packages/2-sql/9-family/test/{psl-ast => psl-build}/psl-names.test.ts (93%) delete mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-ast/name-the-psl-source-loses.ts rename packages/3-targets/3-targets/postgres/src/core/{psl-ast => psl-build}/index-attributes.ts (100%) rename packages/3-targets/3-targets/postgres/src/core/{psl-ast => psl-build}/native-enum-block.ts (91%) rename packages/3-targets/3-targets/postgres/src/core/{psl-ast => psl-build}/postgres-type-map.ts (95%) rename packages/3-targets/3-targets/postgres/src/core/{psl-ast => psl-build}/psl-literals.ts (100%) rename packages/3-targets/3-targets/postgres/src/core/{psl-ast => psl-build}/unique-name.ts (100%) rename packages/3-targets/3-targets/postgres/test/{psl-ast => psl-build}/postgres-type-map.test.ts (98%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl.round-trip.test.ts => inferred-psl.round-trip.test.ts} (97%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl/print-psl.check.test.ts => inferred-psl/inferred-psl.check.test.ts} (100%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl/print-psl.core.test.ts => inferred-psl/inferred-psl.core.test.ts} (100%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl/print-psl.data-type-defaults.test.ts => inferred-psl/inferred-psl.data-type-defaults.test.ts} (92%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl/print-psl.defaults-and-types.test.ts => inferred-psl/inferred-psl.defaults-and-types.test.ts} (100%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl/print-psl.enums.test.ts => inferred-psl/inferred-psl.enums.test.ts} (99%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl/print-psl.literal-defaults.test.ts => inferred-psl/inferred-psl.literal-defaults.test.ts} (100%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl/print-psl.naming-and-constraints.test.ts => inferred-psl/inferred-psl.naming-and-constraints.test.ts} (100%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl/print-psl.no-check.test.ts => inferred-psl/inferred-psl.no-check.test.ts} (100%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl/print-psl.nullable-list.test.ts => inferred-psl/inferred-psl.nullable-list.test.ts} (100%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl/print-psl.relations.test.ts => inferred-psl/inferred-psl.relations.test.ts} (100%) rename packages/3-targets/3-targets/postgres/test/psl-infer/{print-psl/print-psl.top-level-blocks.test.ts => inferred-psl/inferred-psl.top-level-blocks.test.ts} (99%) rename packages/3-targets/3-targets/postgres/test/psl-print/{print-context.ts => build-context.ts} (96%) create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/refusals-named-types.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/refusals-names.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-print/refusals-relations.test.ts rename packages/3-targets/6-adapters/postgres/test/{printed-type-codecs.test.ts => inferred-type-codecs.test.ts} (72%) rename test/integration/test/cli-journeys/{prisma7-print.e2e.test.ts => contract-print.e2e.test.ts} (98%) rename test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/{prisma.config.prisma7-printed.ts => prisma.config.printed-contract.ts} (100%) rename packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts => test/integration/test/psl-print/authored-contract-roundtrip.integration.test.ts (99%) rename packages/3-targets/6-adapters/postgres/test/psl-print-constraints.test.ts => test/integration/test/psl-print/constraints-roundtrip.integration.test.ts (99%) rename packages/3-targets/6-adapters/postgres/test/psl-print-generated-values.test.ts => test/integration/test/psl-print/generated-values-roundtrip.integration.test.ts (98%) rename packages/3-targets/6-adapters/postgres/test/helpers/psl-print.ts => test/integration/test/psl-print/print-and-read-back.ts (84%) rename packages/2-sql/2-authoring/contract-prisma7/test/print-roundtrip.test.ts => test/integration/test/psl-print/prisma7-fixture-roundtrip.integration.test.ts (88%) diff --git a/.agents/rules/no-family-vocabulary-in-framework.mdc b/.agents/rules/no-family-vocabulary-in-framework.mdc index f34c8132d90e..6a2d88e62b31 100644 --- a/.agents/rules/no-family-vocabulary-in-framework.mdc +++ b/.agents/rules/no-family-vocabulary-in-framework.mdc @@ -18,7 +18,7 @@ The [`no-family-vocabulary` Biome plugin](../../biome-plugins/no-family-vocabula **Comments and JSDoc are deliberately not counted.** Prose that documents family-blind code correctly ("family-parameterized (SQL, Mongo, …)") is good documentation, and the previous line-scanning checker taxed it — the majority of what it flagged was prose, not surface. A leak that matters is a real type, field, identifier, string, or import path, and the plugin only sees those. -Test files and `test/` directories are not scanned. Outside them there are two exemptions, and no others: a framework-neutral compound listed in the plugin's `not` guards (`SymbolTable`, `MigrationLogTable`, the formatter's alignment columns), and a line suppression. The plugin has no path exemptions; do not add one. +Test files and `test/` directories are not scanned. Outside them the plugin has one path exemption: the CLI (`packages/1-framework/3-tooling/cli`) may name a Prisma version before 8. That exemption covers version names only; family and target terms are flagged in the CLI as everywhere else. Do not add another path exemption. There are two other exemptions, and no others: a framework-neutral compound listed in the plugin's `not` guards (`SymbolTable`, `MigrationLogTable`, the formatter's alignment columns), and a line suppression. Suppress a genuinely family-blind site at the line, with a reason: diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 1c0ac4d16bcd..c053944127f2 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -283,15 +283,15 @@ A model declares an empty unique constraint (a unique with no fields), raised du ### CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE -`prisma contract print --output` was asked to write over a file the project needs: `prisma.config.ts` in the invocation directory, 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. +`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. 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. +`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 `printPslContract` 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. +`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`); @@ -311,11 +311,11 @@ A model declares an empty unique constraint (a unique with no fields), raised du - 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's storage names a field or column that does not exist (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's domain namespace is not the namespace of its table (meta: `namespaceId`, `modelName`, `tableNamespaceId`); - 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`); @@ -329,7 +329,9 @@ A model declares an empty unique constraint (a unique with no fields), raised du - 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 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`); @@ -337,6 +339,7 @@ A model declares an empty unique constraint (a unique with no fields), raised du - 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: @@ -348,9 +351,9 @@ A model declares an empty unique constraint (a unique with no fields), raised du - 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 name PSL writes as an identifier is not one, or is `__proto__`, which the PSL source loses when it reads it: a 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`); - - a storage entry is not an entity of its kind (meta: `namespaceId`, `kind`, `name`). + - 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 @@ -484,8 +487,6 @@ An emitted model type name, formed as `_`, is not a TypeScript A relation, foreign key, junction (`through`) reference, or context declaration names a model that is not declared in the contract. Raised while lowering/building a SQL contract. Payload: `sourceModel`, `relationName`, `targetModel`. -Also raised by `contract print` when the loaded contract names a model or table it does not declare: a model is stored in a table the storage does not declare (payload: `namespaceId`, `modelName`, `table`), a variant's base is not declared or does not list it as a variant (payload: `namespaceId`, `modelName`), or a relation targets a model the contract does not declare (payload: `model`, `field`, `target`). `contract print` loads the contract from its source on every run, so the source produced the broken contract: fix the model if the source is a TypeScript contract, and otherwise report the bug to the source that produced it. - ### CONTRACT.MODEL_VARIANT_MISSING A polymorphic base names a variant that is not a model in the same namespace, so the `Any` union in `contract.d.ts` cannot be formed. Raised while emitting `contract.d.ts`. Payload: `base`, `variantName`. 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/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/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 d231c6a6d556..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 @@ -16,3 +16,4 @@ export type { PslContractSourceProvider, TypeScriptContractSourceProvider, } from '../contract-source-types'; +export { defaultContractOutputPath } from '../default-contract-output-path'; 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/framework-components/src/control/control-capabilities.ts b/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts index b19e32ce482b..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 @@ -56,33 +56,32 @@ export function hasPslContractInfer( /** * Settings a PSL contract source takes from the config, because a PSL file - * cannot carry them. Emitting the printed file produces the same contract only + * 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; } -/** A contract printed as a PSL document, and the settings its PSL source must carry. */ -export interface PrintedPslContract { +/** 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 print a contract as a PSL document - * AST. Consumed by `prisma contract print`. + * 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 PslContractPrintCapable { - printPslContract(contract: TContract): PrintedPslContract; +export interface PslContractBuildCapable { + buildPslContract(contract: TContract): PslContractDocument; } -export function hasPslContractPrint( +export function hasPslContractBuild( instance: ControlFamilyInstance, -): instance is ControlFamilyInstance & PslContractPrintCapable { +): instance is ControlFamilyInstance & PslContractBuildCapable { return ( - 'printPslContract' in instance && - typeof Reflect.get(instance, 'printPslContract') === 'function' + 'buildPslContract' in instance && + typeof Reflect.get(instance, 'buildPslContract') === 'function' ); } 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 9e9f56824694..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,9 +10,9 @@ export type { DiffSubjectGranularity, MigratableTargetDescriptor, OperationPreviewCapable, - PrintedPslContract, + PslContractBuildCapable, + PslContractDocument, PslContractInferCapable, - PslContractPrintCapable, PslSourceSettings, SchemaSubjectClassifierCapable, SchemaViewCapable, @@ -20,8 +20,8 @@ export type { export { hasMigrations, hasOperationPreview, + hasPslContractBuild, hasPslContractInfer, - hasPslContractPrint, hasSchemaSubjectClassifier, hasSchemaView, } from '../control/control-capabilities'; 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 048ee92cd021..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,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { hasOperationPreview, + hasPslContractBuild, hasPslContractInfer, - hasPslContractPrint, hasSchemaView, } from '../src/control/control-capabilities'; import type { ControlFamilyInstance } from '../src/control/control-instances'; @@ -49,18 +49,18 @@ describe('hasPslContractInfer', () => { }); }); -describe('hasPslContractPrint', () => { - it('returns true when instance exposes printPslContract function', () => { +describe('hasPslContractBuild', () => { + it('returns true when instance exposes buildPslContract function', () => { const instance = { ...baseInstance, - printPslContract: () => ({ document: SYNTHETIC_AST, sourceSettings: {} }), + buildPslContract: () => ({ document: SYNTHETIC_AST, sourceSettings: {} }), } as ControlFamilyInstance<'sql', unknown>; - expect(hasPslContractPrint(instance)).toBe(true); + expect(hasPslContractBuild(instance)).toBe(true); }); - it('returns false when instance does not declare printPslContract', () => { - expect(hasPslContractPrint(baseInstance)).toBe(false); + it('returns false when instance does not declare buildPslContract', () => { + expect(hasPslContractBuild(baseInstance)).toBe(false); }); }); diff --git a/packages/1-framework/1-core/framework-components/test/psl-contract-print.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/psl-contract-build.types.test-d.ts similarity index 57% rename from packages/1-framework/1-core/framework-components/test/psl-contract-print.types.test-d.ts rename to packages/1-framework/1-core/framework-components/test/psl-contract-build.types.test-d.ts index 2ad33bcff4f1..029b0beddc0b 100644 --- a/packages/1-framework/1-core/framework-components/test/psl-contract-print.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/psl-contract-build.types.test-d.ts @@ -1,20 +1,20 @@ import type { Contract, ControlPolicy } from '@internal/contract/types'; import { expectTypeOf, test } from 'vitest'; import type { - PrintedPslContract, - PslContractPrintCapable, + PslContractBuildCapable, + PslContractDocument, PslSourceSettings, } from '../src/control/control-capabilities'; import type { PslDocumentAst } from '../src/control/psl-ast'; -test('printing a contract returns the PSL document and the settings its PSL source must carry', () => { - expectTypeOf().parameters.toEqualTypeOf< +test('building the PSL document of a contract returns the document and the settings its PSL source must carry', () => { + expectTypeOf().parameters.toEqualTypeOf< [Contract] >(); expectTypeOf< - PslContractPrintCapable['printPslContract'] - >().returns.toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<{ + PslContractBuildCapable['buildPslContract'] + >().returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ readonly document: PslDocumentAst; readonly sourceSettings: PslSourceSettings; }>(); diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index 53c0fb0d76a4..cfe0695ac5ba 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -51,6 +51,7 @@ Interpretation/validation (for example `@internal/sql-contract-psl`) is responsi 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: 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 96cf6d36aeea..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, 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/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/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index fee96a32f830..ff6204a5c872 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -432,7 +432,7 @@ prisma contract print [--config ] [--output ] [--json] [-v] [-q] [-- 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`, and the `source` files it read) +- `--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) @@ -441,7 +441,7 @@ Options: 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 > prisma/contract.prisma +prisma contract print --format human > printed.prisma ``` With `--output`, an existing file at that path is overwritten with a warning. @@ -457,12 +457,12 @@ The printed PSL reads back as the identical contract. Where PSL has no form for - `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, or sits inside a directory of source files. Pick another path. -- `CONTRACT.PRINT_OUTPUT_IS_PROJECT_FILE`: the `--output` path is `prisma.config.ts` in the invocation directory, or one of the files `contract emit` writes (`contract.json` and `contract.d.ts`, or whatever `contract.output` names). Pick another path. +- `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 (`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`: +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 @@ -486,7 +486,7 @@ export default definePrismaConfig({ }); ``` -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. 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: +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 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 7a816416e22a..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 @@ -41,7 +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 { resolveContractSource } from './operations/load-contract-source'; +import { loadContractSourceWithStack } from './operations/load-contract-source'; import { executeMigrate } from './operations/migrate'; import type { RenderContractDtsOptions, RenderContractDtsResult } from './render-contract-dts'; @@ -640,7 +640,7 @@ class ControlClientImpl implements ControlClient { label: 'Resolving contract source...', }); - const loaded = await resolveContractSource({ + const loaded = await loadContractSourceWithStack({ stack: this.stack!, source: contractConfig.source, reportWarning: (diagnostic) => { 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 2b836bf31e50..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 @@ -19,9 +19,9 @@ import type { OnControlProgress, } from '../types'; import { + loadContractSourceWithStack, requireContractConfig, requireSourceProvider, - resolveContractSource, } from './load-contract-source'; import { validateLoadedContract } from './validate-loaded-contract'; @@ -107,7 +107,7 @@ export async function executeContractEmit( let contract: Contract; try { stack = createControlStack(config); - const loaded = await resolveContractSource({ + const loaded = await loadContractSourceWithStack({ stack, source: contractConfig.source, signal, 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 index 45d3dc91c67c..dc2e5d024807 100644 --- 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 @@ -1,10 +1,10 @@ import type { ContractSourceDiagnostic, PrismaNextConfig } from '@internal/config/config-types'; import type { PslSourceSettings } from '@internal/framework-components/control'; -import { createControlStack, hasPslContractPrint } 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 { resolveContractSource } from './load-contract-source'; +import { loadContractSourceWithStack } from './load-contract-source'; import { validateLoadedContract } from './validate-loaded-contract'; type ContractConfig = NonNullable; @@ -47,7 +47,7 @@ export async function executeContractPrint( const { config, contractConfig, description, signal } = options; const stack = createControlStack(config); const sourceWarnings: ContractSourceDiagnostic[] = []; - const loaded = await resolveContractSource({ + const loaded = await loadContractSourceWithStack({ stack, source: contractConfig.source, ...ifDefined('signal', signal), @@ -61,7 +61,7 @@ export async function executeContractPrint( const familyInstance = config.family.create(stack); const contract = validateLoadedContract({ config, familyInstance, contract: loaded.value }); - if (!hasPslContractPrint(familyInstance)) { + if (!hasPslContractBuild(familyInstance)) { throw errorRuntime( 'CONTRACT.PRINT_UNSUPPORTED', 'contract print is not supported for this family', @@ -71,7 +71,7 @@ export async function executeContractPrint( }, ); } - const { document, sourceSettings } = familyInstance.printPslContract(contract); + const { document, sourceSettings } = familyInstance.buildPslContract(contract); return { psl: dependencies.printPsl(document, { 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 index 30f7f4e760c5..19ad6a1fc745 100644 --- 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 @@ -223,7 +223,7 @@ function validateProviderResult( * * @throws {DOMException} `AbortError` if cancelled via `signal` */ -export async function resolveContractSource(inputs: { +export async function loadContractSourceWithStack(inputs: { readonly stack: ControlStack; readonly source: ContractSourceProvider; readonly signal?: AbortSignal; @@ -311,7 +311,7 @@ export async function loadContractSource( ): Promise> { const contractConfig = requireContractConfig(config); requireSourceProvider(contractConfig); - const loaded = await resolveContractSource({ + const loaded = await loadContractSourceWithStack({ stack: createControlStack(config), source: contractConfig.source, ...ifDefined('signal', options.signal), 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 4dd9d612556f..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,6 +1,6 @@ import { realpath, stat } from 'node:fs/promises'; import type { PrismaNextConfig } from '@internal/config/config-types'; -import { basename, dirname, extname, join, normalize, 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'; @@ -25,15 +25,6 @@ export function inferredContractPathFor(inputs: { return join(inputs.cwd, INFERRED_CONTRACT_FILENAME); } -/** - * Where a config that names `contractPath` and sets no `output` has - * `contract emit` write its JSON: beside the contract file, named after it. - */ -export function emittedJsonPathFor(contractPath: string): string { - const extension = extname(contractPath); - return `${extension.length === 0 ? contractPath : contractPath.slice(0, -extension.length)}.json`; -} - /** * The nearest part of `path` that exists, with symbolic links resolved, and * the names below it that do not exist yet. 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 index 049ef99cc236..8586726be9d8 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/contract/print.ts @@ -1,9 +1,14 @@ import { existsSync } from 'node:fs'; -import { expandContractInputs, ormConfigSection } from '@internal/config-loader'; +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 { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations } from '@prisma/cli-engine'; import { flag } from '@prisma/cli-engine'; import type { NextAction } from '@prisma/cli-engine/protocol'; @@ -20,7 +25,7 @@ import { publishTextArtifact } from '../../utils/publish-text-artifact'; import { defineOrmCommand } from '../define-command'; import { baseDirFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; -import { emittedJsonPathFor, filePathKey } from './paths'; +import { filePathKey } from './paths'; interface PrintDocument { readonly ok: true; @@ -30,13 +35,23 @@ interface PrintDocument { readonly psl: { readonly path: string } | { readonly text: string }; readonly source: readonly string[]; /** - * The contract's default control policy. A PSL file cannot carry it; the - * config sets it on the PSL source, or the emitted contract loses it. + * What a PSL file cannot carry. The config sets each on the PSL source, or + * the emitted contract loses it. */ - readonly defaultControlPolicy?: string; + 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 }; @@ -51,11 +66,11 @@ function sourceSettingsClause(policy: string | undefined): string { function switchToPrintedActions( document: PrintDocument, - emittedFilesMove: EmittedFilesMove | undefined, + configSwitch: ConfigSwitch | undefined, ): readonly NextAction[] { - const policyClause = sourceSettingsClause(document.defaultControlPolicy); + const policyClause = sourceSettingsClause(document.sourceSettings.defaultControlPolicy); const emit = runCommandAction('Emit the printed contract', '{bin} contract emit'); - if (!('path' in document.psl)) { + if (configSwitch === undefined) { return [ chooseAction( `Write the PSL to a file with --output , then point contract in prisma.config.ts at that file${policyClause}`, @@ -63,7 +78,7 @@ function switchToPrintedActions( emit, ]; } - const path = document.psl.path; + const { contractPath: path, emittedFilesMove } = configSwitch; return [ chooseAction(`Point contract in prisma.config.ts at ${path}${policyClause}`), ...(emittedFilesMove === undefined @@ -88,11 +103,11 @@ function linesOf(text: string): readonly string[] { function printPresentations( document: PrintDocument, - emittedFilesMove: EmittedFilesMove | undefined, + configSwitch: ConfigSwitch | undefined, ): Presentations { return { stdout: () => ('text' in document.psl ? linesOf(document.psl.text) : []), - next: () => switchToPrintedActions(document, emittedFilesMove), + next: () => switchToPrintedActions(document, configSwitch), human: (): readonly Block[] => 'path' in document.psl ? [ @@ -163,6 +178,22 @@ async function outputPathRefusal(inputs: { } } + 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); @@ -199,7 +230,7 @@ async function outputPathRefusal(inputs: { } function emittedFilesMoveFor(inputs: { - readonly cwd: string; + readonly configDir: string; readonly outputPath: string; readonly emittedJsonPath: string | undefined; }): EmittedFilesMove | undefined { @@ -207,16 +238,19 @@ function emittedFilesMoveFor(inputs: { return undefined; } const before = getEmittedArtifactPaths(inputs.emittedJsonPath); - const after = getEmittedArtifactPaths(emittedJsonPathFor(inputs.outputPath)); + const after = getEmittedArtifactPaths(defaultContractOutputPath(inputs.outputPath)); if (after.jsonPath === before.jsonPath) { return undefined; } return { before: { - json: relative(inputs.cwd, before.jsonPath), - dts: relative(inputs.cwd, before.dtsPath), + 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), }, - after: { json: relative(inputs.cwd, after.jsonPath), dts: relative(inputs.cwd, after.dtsPath) }, }; } @@ -236,7 +270,7 @@ export function createContractPrintCommand({ printPsl }: ContractPrintCommandDep 'with a warning.', examples: [ 'contract print', - 'contract print --format human > ./src/prisma/contract.prisma', + 'contract print --format human > printed.prisma', 'contract print --output ./src/prisma/contract.prisma', 'contract print --json', ], @@ -267,6 +301,7 @@ export function createContractPrintCommand({ printPsl }: ContractPrintCommandDep 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) { @@ -274,7 +309,7 @@ export function createContractPrintCommand({ printPsl }: ContractPrintCommandDep cwd: ctx.cwd, outputPath, sourceInputs, - configPath: resolve(baseDirFor(ctx.config), 'prisma.config.ts'), + configPath: resolve(configDir, 'prisma.config.ts'), emittedJsonPath, }); if (refusal !== undefined) { @@ -338,7 +373,7 @@ export function createContractPrintCommand({ printPsl }: ContractPrintCommandDep ? { text: printed.psl } : { path: relative(ctx.cwd, outputPath) }, source: sourcePaths, - ...ifDefined('defaultControlPolicy', defaultControlPolicy), + sourceSettings: printed.sourceSettings, timings: { total: Date.now() - startedAt }, }; @@ -349,7 +384,10 @@ export function createContractPrintCommand({ printPsl }: ContractPrintCommandDep document, outputPath === undefined ? undefined - : emittedFilesMoveFor({ cwd: ctx.cwd, outputPath, emittedJsonPath }), + : { + contractPath: relative(configDir, outputPath), + emittedFilesMove: emittedFilesMoveFor({ configDir, outputPath, emittedJsonPath }), + }, ), ), ); 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 index 9124e22e9a84..f7a0e28cc206 100644 --- 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 @@ -21,7 +21,7 @@ function configWithMalformedContract(output: string) { deserializeContract: vi.fn(() => { throw malformed; }), - printPslContract: vi.fn(), + buildPslContract: vi.fn(), }; const config = { family: { @@ -71,6 +71,6 @@ describe('executeContractPrint', () => { await expect( executeContractPrint({ config, contractConfig, description: 'printed' }), ).rejects.toBe(malformed); - expect(familyInstance.printPslContract).not.toHaveBeenCalled(); + expect(familyInstance.buildPslContract).not.toHaveBeenCalled(); }); }); 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 index e657d3b02547..0ec7411332d7 100644 --- 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 @@ -12,19 +12,19 @@ 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 `printPslContract` is a + * 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' | 'printPslContract' | 'printPsl' | 'load', + 'createFamilyInstance' | 'deserializeContract' | 'buildPslContract' | 'printPsl' | 'load', Mock > > = { createFamilyInstance: vi.fn(), deserializeContract: vi.fn(), - printPslContract: vi.fn(), + buildPslContract: vi.fn(), printPsl: vi.fn(), load: vi.fn(), }; @@ -85,6 +85,18 @@ 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) { @@ -102,13 +114,13 @@ export function useContractPrintDoubles(): void { }); beforeEach(() => { - mocks.printPslContract + mocks.buildPslContract .mockReset() .mockReturnValue({ document: { kind: 'psl-document' }, sourceSettings: {} }); mocks.deserializeContract.mockReset().mockReturnValue(VALIDATED_CONTRACT); mocks.createFamilyInstance.mockReset().mockReturnValue({ deserializeContract: mocks.deserializeContract, - printPslContract: mocks.printPslContract, + 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 index 9ee148c8be55..a5de9b961326 100644 --- 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 @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { erroredEnvelope, harness, + harnessWithConfigIn, mocks, ormConfig, PSL, @@ -105,6 +106,69 @@ describe('contract print output path', () => { 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'; 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 index b49b8fc46e9e..671d078e0b83 100644 --- 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 @@ -1,4 +1,4 @@ -import { readdir, readFile, writeFile } from 'node:fs/promises'; +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'; @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'; import { erroredEnvelope, harness, + harnessWithConfigIn, mocks, ormConfig, PSL, @@ -68,6 +69,7 @@ describe('contract print', () => { target: { familyId: 'sql', id: 'postgres' }, psl: { text: PSL }, source: ['prisma/schema.prisma'], + sourceSettings: {}, timings: { total: expect.any(Number) }, }); expect(await readdir(dir)).toEqual([]); @@ -88,6 +90,7 @@ describe('contract print', () => { target: { familyId: 'sql', id: 'postgres' }, psl: { path: 'generated/contract.prisma' }, source: ['prisma/schema.prisma'], + sourceSettings: {}, timings: { total: expect.any(Number) }, }); }); @@ -121,7 +124,7 @@ describe('contract print', () => { expect(sourceContext.authoringContributions).toBe(stack.authoringContributions); expect(printOptions.codecLookup).toBe(stack.codecLookup); expect(printOptions.pslBlockDescriptors).toBe(stack.authoringContributions.pslBlockDescriptors); - expect(mocks.printPslContract).toHaveBeenCalledWith(VALIDATED_CONTRACT); + expect(mocks.buildPslContract).toHaveBeenCalledWith(VALIDATED_CONTRACT); }); it('publishes through a staged rename, leaving no temporary file behind', async () => { @@ -222,7 +225,7 @@ describe('contract print', () => { it('warns that the config must set the default control policy, because a PSL file cannot carry it', async () => { const dir = await projectDir(); - mocks.printPslContract.mockReturnValue({ + mocks.buildPslContract.mockReturnValue({ document: { kind: 'psl-document' }, sourceSettings: { defaultControlPolicy: 'external' }, }); @@ -238,7 +241,10 @@ describe('contract print', () => { 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({ defaultControlPolicy: 'external' }); + expect(run.presented?.data).toMatchObject({ + sourceSettings: { defaultControlPolicy: 'external' }, + }); + expect(run.presented?.data).not.toHaveProperty('defaultControlPolicy'); expect(run.presented?.presentation.next).toEqual([ { kind: 'user-choice', @@ -314,6 +320,38 @@ describe('contract print', () => { ]); }); + 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, { @@ -369,7 +407,7 @@ describe('contract print', () => { it('writes no file at the output path when the target refuses part of the contract', async () => { const dir = await projectDir(); - mocks.printPslContract.mockImplementation(() => { + 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.', @@ -396,7 +434,7 @@ describe('contract print', () => { it('reports the code, summary and next actions of a refusal the target raised', async () => { const dir = await projectDir(); - mocks.printPslContract.mockImplementation(() => { + 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.', 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/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/2-sql/2-authoring/contract-prisma7/package.json b/packages/2-sql/2-authoring/contract-prisma7/package.json index d1ae2791219f..a02925e755db 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/package.json +++ b/packages/2-sql/2-authoring/contract-prisma7/package.json @@ -35,7 +35,6 @@ "@internal/config-loader": "workspace:8.0.0-rc.12", "@internal/driver-postgres": "workspace:8.0.0-rc.12", "@internal/family-sql": "workspace:8.0.0-rc.12", - "@internal/psl-printer": "workspace:8.0.0-rc.12", "@internal/target-postgres": "workspace:8.0.0-rc.12", "@repo/test-utils": "workspace:8.0.0-rc.12", "@repo/tsconfig": "workspace:8.0.0-rc.12", diff --git a/packages/2-sql/2-authoring/contract-prisma7/tsconfig.json b/packages/2-sql/2-authoring/contract-prisma7/tsconfig.json index b39bfbe462c1..a5d18dc62396 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/tsconfig.json +++ b/packages/2-sql/2-authoring/contract-prisma7/tsconfig.json @@ -1,7 +1,7 @@ { "extends": ["@repo/tsconfig/base"], "compilerOptions": { - "rootDir": "../../.." + "rootDir": "." }, "include": ["src", "test", "*.config.*", "package.json"], "exclude": ["dist", "dist-tsc", "dist-tsc-prod", "node_modules"] 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/exports/resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts index 32653e2ce4d8..770dea4d25fc 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/exports/resolution.ts @@ -13,7 +13,6 @@ export { type ResolveFieldTypeResult, resolveFieldTypeDescriptor, } from '../psl-column-resolution'; -export { pslFieldMapName, pslModelMapName } from '../psl-name-mapping'; export { applyBackrelationCandidates, type FkRelationMetadata, 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/9-family/README.md b/packages/2-sql/9-family/README.md index 843f4c8e2621..f187bcef33ef 100644 --- a/packages/2-sql/9-family/README.md +++ b/packages/2-sql/9-family/README.md @@ -104,7 +104,7 @@ Family instances implement domain actions: - **`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. -- **`printPslContract(contract)`**: Builds the PSL document AST that reads back as the same contract, for `contract print`. Delegates to the target descriptor's optional `printPslContract` hook, and passes it the stack's authoring contributions, codecs and data types (`SqlPslPrintContext`), 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. +- **`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. @@ -136,7 +136,7 @@ The runner returns structured errors with the following codes: - **`./control`**: Control plane entry point for CLI/config usage (exports `SqlFamilyDescriptor`) - **`./control-adapter`**: SQL control adapter interface (`SqlControlAdapter`, `SqlControlAdapterDescriptor`) for target-specific adapters -- **`./psl-ast`**: 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-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 54448b582dfd..66b6d4f13b22 100644 --- a/packages/2-sql/9-family/package.json +++ b/packages/2-sql/9-family/package.json @@ -63,7 +63,7 @@ "./ir": "./dist/ir.mjs", "./migration": "./dist/migration.mjs", "./pack": "./dist/pack.mjs", - "./psl-ast": "./dist/psl-ast.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 80a8c7e436c1..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,9 +11,9 @@ import type { MigrationPlanOperation, OperationPreview, OperationPreviewCapable, - PrintedPslContract, + PslContractBuildCapable, + PslContractDocument, PslContractInferCapable, - PslContractPrintCapable, SchemaDiffIssue, SchemaViewCapable, SignDatabaseResult, @@ -209,7 +209,7 @@ export interface SqlControlFamilyInstance extends ControlFamilyInstance<'sql', SqlSchemaIRNode>, SchemaViewCapable, PslContractInferCapable, - PslContractPrintCapable>, + PslContractBuildCapable>, OperationPreviewCapable, SqlFamilyInstanceState { /** @@ -587,12 +587,12 @@ export function createSqlFamilyInstance( SqlControlTargetDescriptor, 'reading the optional target-descriptor inferPslContract hook' >(target).inferPslContract; - // Contract→PSL printing is read off the descriptor the same way. Absent for - // targets without `contract print`. - const targetPrintPslContract = blindCast< + // 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 printPslContract hook' - >(target).printPslContract; + '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. @@ -1024,20 +1024,20 @@ export function createSqlFamilyInstance( return targetInferPslContract(schemaIR, describedContracts); }, - printPslContract(contract: Contract): PrintedPslContract { - if (!targetPrintPslContract) { + buildPslContract(contract: Contract): PslContractDocument { + if (!targetBuildPslContract) { throw sqlFamilyError( 'CONTRACT.PRINT_UNSUPPORTED', - `Target "${target.targetId}" does not support contract print (no printPslContract on its descriptor).`, + `Target "${target.targetId}" does not support contract print (no buildPslContract on its descriptor).`, { - why: 'The target descriptor does not provide the printPslContract hook, so the contract cannot be printed as a Prisma 8 PSL file.', - fix: 'Use a target whose descriptor provides printPslContract, or write the Prisma 8 PSL file by hand.', + 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: targetPrintPslContract(contract, { + document: targetBuildPslContract(contract, { authoringContributions: stack.authoringContributions, codecLookup: stack.codecLookup, dataTypeLookup: stack.dataTypeLookup, 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 19dfaf7ea425..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 @@ -32,12 +32,9 @@ export interface SqlDescribedContractSpace { } /** - * The parts of the composed stack a printer writes a PSL contract with. The PSL contract source - * reads the printed file with the same parts, so the printer 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. + * 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 SqlPslPrintContext { +export interface SqlPslBuildContext { readonly authoringContributions: Pick; readonly codecLookup: CodecLookup; readonly dataTypeLookup: DataTypeLookup; @@ -77,15 +74,9 @@ export interface SqlControlTargetDescriptor< describedContracts?: readonly SqlDescribedContractSpace[], ) => PslDocumentAst; /** - * Contract→PSL printing for `contract print`. 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 printer 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. + * 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 printPslContract?: (contract: TContract, context: SqlPslPrintContext) => PslDocumentAst; + 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/psl-ast/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-ast/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-ast/psl-names.ts b/packages/2-sql/9-family/src/core/psl-build/psl-names.ts similarity index 100% rename from packages/2-sql/9-family/src/core/psl-ast/psl-names.ts rename to packages/2-sql/9-family/src/core/psl-build/psl-names.ts diff --git a/packages/2-sql/9-family/src/core/psl-ast/type-map.ts b/packages/2-sql/9-family/src/core/psl-build/type-map.ts similarity index 100% rename from packages/2-sql/9-family/src/core/psl-ast/type-map.ts rename to packages/2-sql/9-family/src/core/psl-build/type-map.ts 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 1651b15a6ce7..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 @@ -7,7 +7,7 @@ import { needsEscaping, snakeToCamelCase, snakeToPascalCase, -} from '../psl-ast/psl-names'; +} from '../psl-build/psl-names'; type NameResult = { readonly name: string; 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 7d3b783e4d56..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,6 +1,6 @@ import type { ColumnDefault } from '@internal/contract/types'; -import type { DefaultMappingOptions } from '../psl-ast/default-mapping'; -import type { PslTypeMap } from '../psl-ast/type-map'; +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 3b88f4a4654c..36e7fe0fd360 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -18,7 +18,7 @@ export type { SqlControlFamilyInstance } from '../core/control-instance'; export type { SqlControlTargetDescriptor, SqlDescribedContractSpace, - SqlPslPrintContext, + SqlPslBuildContext, } from '../core/control-target-descriptor'; export type { ContractToSchemaIROptions, diff --git a/packages/2-sql/9-family/src/exports/psl-ast.ts b/packages/2-sql/9-family/src/exports/psl-build.ts similarity index 52% rename from packages/2-sql/9-family/src/exports/psl-ast.ts rename to packages/2-sql/9-family/src/exports/psl-build.ts index f255a4ed0993..5febaf8795ff 100644 --- a/packages/2-sql/9-family/src/exports/psl-ast.ts +++ b/packages/2-sql/9-family/src/exports/psl-build.ts @@ -4,7 +4,10 @@ * the dialect maps imports them. */ -export type { DefaultMappingOptions, DefaultMappingResult } from '../core/psl-ast/default-mapping'; -export { mapDefault } from '../core/psl-ast/default-mapping'; -export { toEnumMemberName } from '../core/psl-ast/psl-names'; -export type { PslTypeMap, PslTypeReference, PslTypeResolution } from '../core/psl-ast/type-map'; +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 be999b434dc9..7bfd5a9614aa 100644 --- a/packages/2-sql/9-family/src/exports/psl-infer.ts +++ b/packages/2-sql/9-family/src/exports/psl-infer.ts @@ -4,7 +4,7 @@ * 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-ast`. + * under `@internal/family-sql/psl-build`. */ export { diff --git a/packages/2-sql/9-family/test/control-instance.print-psl-contract.test.ts b/packages/2-sql/9-family/test/control-instance.build-psl-contract.test.ts similarity index 70% rename from packages/2-sql/9-family/test/control-instance.print-psl-contract.test.ts rename to packages/2-sql/9-family/test/control-instance.build-psl-contract.test.ts index eb9ee8549d0f..3ec2459059f7 100644 --- a/packages/2-sql/9-family/test/control-instance.print-psl-contract.test.ts +++ b/packages/2-sql/9-family/test/control-instance.build-psl-contract.test.ts @@ -4,7 +4,7 @@ import type { ControlStack, ControlTargetDescriptor, } from '@internal/framework-components/control'; -import { createControlStack, hasPslContractPrint } 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'; @@ -12,7 +12,7 @@ import { createSqlFamilyInstance } from '../src/core/control-instance'; const DOCUMENT: PslDocumentAst = { kind: 'document', - sourceId: 'printed', + sourceId: 'built', namespaces: [], span: { start: { offset: 0, line: 1, column: 1 }, @@ -20,8 +20,8 @@ const DOCUMENT: PslDocumentAst = { }, }; -function stackWithPrinter( - printPslContract: ((contract: Contract) => PslDocumentAst) | undefined, +function stackWithBuilder( + buildPslContract: ((contract: Contract) => PslDocumentAst) | undefined, ): ControlStack<'sql', 'postgres'> { return createControlStack({ family: { @@ -38,7 +38,7 @@ function stackWithPrinter( familyId: 'sql', targetId: 'postgres', create: () => ({ familyId: 'sql', targetId: 'postgres' }), - ...(printPslContract === undefined ? {} : { printPslContract }), + ...(buildPslContract === undefined ? {} : { buildPslContract }), } as ControlTargetDescriptor<'sql', 'postgres'>, adapter: { kind: 'adapter', @@ -61,24 +61,24 @@ function contractWith(defaultControlPolicy?: ControlPolicy): Contract; } -describe('sql family printPslContract', () => { +describe('sql family buildPslContract', () => { it("returns the target's document and no source settings for a contract without a default control policy", () => { - const printer = vi.fn(() => DOCUMENT); - const instance = createSqlFamilyInstance(stackWithPrinter(printer)); + const builder = vi.fn(() => DOCUMENT); + const instance = createSqlFamilyInstance(stackWithBuilder(builder)); const contract = contractWith(); - expect(instance.printPslContract(contract)).toEqual({ document: DOCUMENT, sourceSettings: {} }); - expect(printer).toHaveBeenCalledWith(contract, expect.anything()); + 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 printer = vi.fn(() => DOCUMENT); - const stack = stackWithPrinter(printer); + const builder = vi.fn(() => DOCUMENT); + const stack = stackWithBuilder(builder); const contract = contractWith(); - createSqlFamilyInstance(stack).printPslContract(contract); + createSqlFamilyInstance(stack).buildPslContract(contract); - expect(printer).toHaveBeenCalledWith(contract, { + expect(builder).toHaveBeenCalledWith(contract, { authoringContributions: stack.authoringContributions, codecLookup: stack.codecLookup, dataTypeLookup: stack.dataTypeLookup, @@ -86,22 +86,22 @@ describe('sql family printPslContract', () => { }); it('returns the default control policy as a setting the PSL source must carry', () => { - const instance = createSqlFamilyInstance(stackWithPrinter(() => DOCUMENT)); + const instance = createSqlFamilyInstance(stackWithBuilder(() => DOCUMENT)); - expect(instance.printPslContract(contractWith('external'))).toEqual({ + expect(instance.buildPslContract(contractWith('external'))).toEqual({ document: DOCUMENT, sourceSettings: { defaultControlPolicy: 'external' }, }); }); - it('passes the capability check contract print makes, even when the target cannot print', () => { - expect(hasPslContractPrint(createSqlFamilyInstance(stackWithPrinter(undefined)))).toBe(true); + 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 printPslContract', () => { - const instance = createSqlFamilyInstance(stackWithPrinter(undefined)); + it('raises CONTRACT.PRINT_UNSUPPORTED when the target descriptor has no buildPslContract', () => { + const instance = createSqlFamilyInstance(stackWithBuilder(undefined)); - expect(() => instance.printPslContract(contractWith())).toThrow( + expect(() => instance.buildPslContract(contractWith())).toThrow( expect.objectContaining({ code: 'CONTRACT.PRINT_UNSUPPORTED', meta: { targetId: 'postgres' }, diff --git a/packages/2-sql/9-family/test/psl-ast/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-ast/default-mapping.test.ts rename to packages/2-sql/9-family/test/psl-build/default-mapping.test.ts index 3afe3c8c02a7..a0d50490b7e3 100644 --- a/packages/2-sql/9-family/test/psl-ast/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-ast/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-ast/psl-names.test.ts b/packages/2-sql/9-family/test/psl-build/psl-names.test.ts similarity index 93% rename from packages/2-sql/9-family/test/psl-ast/psl-names.test.ts rename to packages/2-sql/9-family/test/psl-build/psl-names.test.ts index 87e96a1273c6..ec1f5ead0355 100644 --- a/packages/2-sql/9-family/test/psl-ast/psl-names.test.ts +++ b/packages/2-sql/9-family/test/psl-build/psl-names.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { toEnumMemberName } from '../../src/core/psl-ast/psl-names'; +import { toEnumMemberName } from '../../src/core/psl-build/psl-names'; describe('toEnumMemberName', () => { it('keeps a valid identifier value verbatim, preserving case', () => { diff --git a/packages/2-sql/9-family/tsdown.config.ts b/packages/2-sql/9-family/tsdown.config.ts index 00e503fd951b..a8099136c15b 100644 --- a/packages/2-sql/9-family/tsdown.config.ts +++ b/packages/2-sql/9-family/tsdown.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ 'src/exports/runtime.ts', 'src/exports/verify.ts', 'src/exports/diff.ts', - 'src/exports/psl-ast.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/postgres/README.md b/packages/3-extensions/postgres/README.md index 728ad188e7f4..2b86f23938b6 100644 --- a/packages/3-extensions/postgres/README.md +++ b/packages/3-extensions/postgres/README.md @@ -103,6 +103,8 @@ export default definePrismaConfig({ `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'; 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/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-targets/3-targets/postgres/README.md b/packages/3-targets/3-targets/postgres/README.md index e49cca864b67..fc549f894f6c 100644 --- a/packages/3-targets/3-targets/postgres/README.md +++ b/packages/3-targets/3-targets/postgres/README.md @@ -25,7 +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 `printPslContract` descriptor hook, which `prisma contract print` uses. It takes an assembled Postgres contract, from any source, and returns the Prisma 8 PSL document that reads back as the same contract. 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-ast/`: 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. +- **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/psl-ast/name-the-psl-source-loses.ts b/packages/3-targets/3-targets/postgres/src/core/psl-ast/name-the-psl-source-loses.ts deleted file mode 100644 index 2ab75a0ce4f1..000000000000 --- a/packages/3-targets/3-targets/postgres/src/core/psl-ast/name-the-psl-source-loses.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * The PSL source keeps names as keys of plain objects, and assigning this key - * sets the object's prototype instead, so this name is lost when the file is - * read. - */ -export const NAME_THE_PSL_SOURCE_LOSES = '__proto__'; diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-ast/index-attributes.ts b/packages/3-targets/3-targets/postgres/src/core/psl-build/index-attributes.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/src/core/psl-ast/index-attributes.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-build/index-attributes.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-ast/native-enum-block.ts b/packages/3-targets/3-targets/postgres/src/core/psl-build/native-enum-block.ts similarity index 91% rename from packages/3-targets/3-targets/postgres/src/core/psl-ast/native-enum-block.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-build/native-enum-block.ts index 07780d80f157..59cfc33cf140 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-ast/native-enum-block.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-build/native-enum-block.ts @@ -1,10 +1,10 @@ -import { toEnumMemberName } from '@internal/family-sql/psl-ast'; +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 { NAME_THE_PSL_SOURCE_LOSES } from './name-the-psl-source-loses'; import { SYNTHETIC_SPAN } from './psl-literals'; import { createUniqueFieldName } from './unique-name'; diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-ast/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-ast/postgres-type-map.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-build/postgres-type-map.ts index b86bf4509a2f..681cdaea0414 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-ast/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-ast'; +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-ast/psl-literals.ts b/packages/3-targets/3-targets/postgres/src/core/psl-build/psl-literals.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/src/core/psl-ast/psl-literals.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-build/psl-literals.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-ast/unique-name.ts b/packages/3-targets/3-targets/postgres/src/core/psl-build/unique-name.ts similarity index 100% rename from packages/3-targets/3-targets/postgres/src/core/psl-ast/unique-name.ts rename to packages/3-targets/3-targets/postgres/src/core/psl-build/unique-name.ts 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 69de56b5ee1f..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 @@ -6,7 +6,7 @@ * 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/printed-type-codecs.test.ts` fails if the two disagree or if the type map + * `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. */ @@ -20,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'], @@ -49,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; } @@ -61,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; @@ -81,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 f0aededb884c..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,7 +1,7 @@ import { toEnumName } from '@internal/family-sql/psl-infer'; import type { PslExtensionBlock } from '@internal/framework-components/psl-ast'; -import { buildNativeEnumBlock } from '../psl-ast/native-enum-block'; -import { createUniqueFieldName } from '../psl-ast/unique-name'; +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([ 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 ac965e8edb71..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 @@ -7,7 +7,7 @@ import { type DefaultMappingOptions, mapDefault, type PslTypeMap, -} from '@internal/family-sql/psl-ast'; +} 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 { @@ -31,7 +31,7 @@ import { buildCheckAttribute, buildIndexAttribute, buildModelConstraintAttribute, -} from '../psl-ast/index-attributes'; +} from '../psl-build/index-attributes'; import { buildAttribute, buildMapAttribute, @@ -40,9 +40,9 @@ import { parseDefaultAttributeString, positionalArg, SYNTHETIC_SPAN, -} from '../psl-ast/psl-literals'; -import { createUniqueFieldName } from '../psl-ast/unique-name'; -import { dataTypeForPrintedType, printedDefaultReadsBack } from './infer-default-codec'; +} 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'; @@ -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)); 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 498235c42f6d..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,7 +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-ast/unique-name'; +import { createUniqueFieldName } from '../psl-build/unique-name'; export type ResolvedColumnFieldName = { readonly fieldName: string; 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 74880e6d190e..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 @@ -4,7 +4,7 @@ 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-ast/psl-literals'; +import { SYNTHETIC_SPAN } from '../psl-build/psl-literals'; import type { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; /** Replaces invalid character runs with `_`; prepends `_` when the first character is invalid. */ 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 3e82fcb0a151..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,8 +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-ast/postgres-type-map'; -import { SYNTHETIC_SPAN } from '../psl-ast/psl-literals'; +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'; 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 7164db339286..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,7 +2,7 @@ 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-ast/postgres-type-map'; +import { createPostgresTypeMap } from '../psl-build/postgres-type-map'; import { buildPslDocumentAst } from './infer-psl-contract'; import { createPostgresDefaultMapping } from './postgres-default-mapping'; 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 70f1c563576b..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-ast'; +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 index e1cfa52a29a4..ec5e0bebc51b 100644 --- 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 @@ -1,6 +1,6 @@ import type { ContractEnum } from '@internal/contract/types'; -import type { SqlPslPrintContext } from '@internal/family-sql/control'; -import { mapDefault } from '@internal/family-sql/psl-ast'; +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'; @@ -8,7 +8,7 @@ import { buildAttribute, parseDefaultAttributeString, positionalArg, -} from '../psl-ast/psl-literals'; +} from '../psl-build/psl-literals'; import { refuseDefaultOutsideEnum, refuseUnwritableLiteralDefault } from './refusals'; /** @@ -33,7 +33,7 @@ export function buildColumnDefault(input: { readonly namespaceId: string; readonly tableName: string; readonly columnName: string; - readonly context: SqlPslPrintContext; + readonly context: SqlPslBuildContext; }): PslFieldAttribute | undefined { const columnDefault = input.column.default; if (columnDefault === undefined) { 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 index 4321a9fd2a43..f0b69c343041 100644 --- 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 @@ -1,4 +1,4 @@ -import type { PslTypeMap } from '@internal/family-sql/psl-ast'; +import type { PslTypeMap } from '@internal/family-sql/psl-build'; import { type AuthoringTypeConstructorCall, type AuthoringTypeNamespace, @@ -7,7 +7,7 @@ import { 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-ast/psl-literals'; +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. */ 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 index 8456dea8969f..dbc20563ccfb 100644 --- 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 @@ -8,8 +8,8 @@ import type { SqlStorage, StorageTable, } from '@internal/sql-contract/types'; +import { assertDefined } from '@internal/utils/assertions'; import { blindCast } from '@internal/utils/casts'; -import { postgresError } from '../errors'; /** One domain model paired with the storage table it is bridged to. */ export interface ModelWithTable { @@ -43,8 +43,8 @@ export function modelCoordinate(namespaceId: string, modelName: string): string } /** - * Every domain model of the contract, in declaration order, with its storage table. A model whose - * domain namespace is not the namespace of its table is refused before this runs. + * 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[] = []; @@ -53,17 +53,10 @@ export function indexContractModels(contract: Contract): readonly Mo const storage = blindCast(model.storage); const table = contract.storage.namespaces[storage.namespaceId]?.entries.table?.[storage.table]; - if (table === undefined) { - throw postgresError( - 'CONTRACT.MODEL_UNKNOWN', - `contract print: model "${namespaceId}.${name}" is stored in table "${storage.namespaceId}"."${storage.table}", which the contract's storage does not declare.`, - { - why: "The printer reads each model's columns, keys and indexes off its storage table.", - fix: 'The contract source produced a model without its table. Fix the model if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', - meta: { namespaceId, modelName: name, 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, @@ -102,17 +95,14 @@ export function variantInfo( if (entry.model.base === undefined) return undefined; const base = byCoordinate.get(crossReferenceCoordinate(entry.model.base)); const value = base?.model.variants?.[entry.name]?.value; - if (base === undefined || value === undefined) { - throw postgresError( - 'CONTRACT.MODEL_UNKNOWN', - `contract print: model "${entry.namespaceId}.${entry.name}" extends base "${entry.model.base.namespace}.${entry.model.base.model}", which the contract does not declare, or which does not list it as a variant.`, - { - why: 'A variant is written as `@@base(Base, "value")`, and the value comes from the base model.', - fix: 'The contract source produced a variant its base does not list. Fix the variant or its base if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', - meta: { namespaceId: entry.namespaceId, modelName: entry.name }, - }, - ); - } + 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, 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 index d052a57eda07..8a6db34023f2 100644 --- 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 @@ -1,6 +1,6 @@ import type { Contract, ContractField, ScalarFieldType } from '@internal/contract/types'; -import type { SqlPslPrintContext } from '@internal/family-sql/control'; -import type { PslTypeMap } from '@internal/family-sql/psl-ast'; +import type { SqlPslBuildContext } from '@internal/family-sql/control'; +import type { PslTypeMap } from '@internal/family-sql/psl-build'; import type { PslCompositeType, PslField, @@ -11,7 +11,7 @@ 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-ast/psl-literals'; +import { SYNTHETIC_SPAN } from '../psl-build/psl-literals'; import { buildColumnType, type PslColumnType } from './column-types'; import { refuseUnwritableFieldShape, @@ -29,7 +29,7 @@ import { function nativeTypeOfValueObjectField( field: ContractField & { readonly type: ScalarFieldType }, coordinate: string, - context: SqlPslPrintContext, + context: SqlPslBuildContext, ): string { const { codecId } = field.type; const descriptor = context.codecLookup.descriptorFor?.(codecId); @@ -48,7 +48,7 @@ export function buildDomainFieldType(input: { readonly field: ContractField; readonly coordinate: string; readonly typeMap: PslTypeMap; - readonly context: SqlPslPrintContext; + readonly context: SqlPslBuildContext; readonly enumBlockNames: ReadonlyMap; }): PslColumnType { const { field, coordinate } = input; @@ -77,7 +77,7 @@ export function buildCompositeTypes(input: { readonly contract: Contract; readonly namespaceId: string; readonly typeMap: PslTypeMap; - readonly context: SqlPslPrintContext; + readonly context: SqlPslBuildContext; readonly enumBlockNames: ReadonlyMap; }): readonly PslCompositeType[] { refuseValueObjectsOutsideDefaultNamespace(input.contract, input.namespaceId); @@ -117,7 +117,7 @@ export function buildCompositeTypes(input: { export function buildTypesBlock( contract: Contract, typeMap: PslTypeMap, - context: SqlPslPrintContext, + context: SqlPslBuildContext, ): PslTypesBlock | undefined { const declarations: PslNamedTypeDeclaration[] = []; for (const [name, instance] of Object.entries(contract.storage.types ?? {})) { 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 index 6fd93e5daba9..d59aea4f006c 100644 --- 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 @@ -6,8 +6,8 @@ import type { 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-ast/native-enum-block'; -import { SYNTHETIC_SPAN } from '../psl-ast/psl-literals'; +import { buildNativeEnumBlock } from '../psl-build/native-enum-block'; +import { SYNTHETIC_SPAN } from '../psl-build/psl-literals'; import { refuseNativeEnumControl, refuseNativeEnumWithoutValueSet, 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 index 46cf700e4cba..22d81fabbe55 100644 --- 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 @@ -11,7 +11,7 @@ import { PG_TIMESTAMPTZ_TEMPORAL_CODEC_ID, } from '../codec-ids'; import { postgresNowGeneratorIdFor } from '../now-generators'; -import { buildAttribute, namedArg, positionalArg } from '../psl-ast/psl-literals'; +import { buildAttribute, namedArg, positionalArg } from '../psl-build/psl-literals'; import { refuseGeneratorOnUpdate, refuseGeneratorWithoutPslFunction, @@ -20,7 +20,7 @@ import { /** * The `temporal.*` field preset each codec with a "now" generator is authored through. The target - * contributes the presets; `adapter-postgres/test/psl-print-generated-values.test.ts` writes and + * 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([ @@ -33,7 +33,7 @@ const TEMPORAL_PRESET_NAMES: ReadonlyMap = new Map([ /** * The `@default((…))` call each id generator is authored through. The Postgres adapter registers - * the functions; `adapter-postgres/test/psl-print-generated-values.test.ts` writes and reads back + * 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; 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 index e7aee143a9fc..006d7ed8f07f 100644 --- 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 @@ -1,6 +1,6 @@ import type { PslModelAttribute } from '@internal/framework-components/psl-ast'; import type { StorageTable } from '@internal/sql-contract/types'; -import { pslModelMapName } from '@internal/sql-contract-psl/resolution'; +import { pslModelMapName } from '@internal/sql-contract-psl/map-names'; import { escapePslString } from '@internal/sql-relational-core/ast'; import { composeCheckWirePrefix, @@ -15,8 +15,8 @@ import { buildCheckAttribute, buildIndexAttribute, buildModelConstraintAttribute, -} from '../psl-ast/index-attributes'; -import { buildAttribute, buildMapAttribute, positionalArg } from '../psl-ast/psl-literals'; +} 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'; 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 index 4cc6967d38e0..63c5471a8a0b 100644 --- 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 @@ -4,8 +4,8 @@ import { type ExecutionMutationDefault, effectiveControlPolicy, } from '@internal/contract/types'; -import type { SqlPslPrintContext } from '@internal/family-sql/control'; -import type { PslTypeMap } from '@internal/family-sql/psl-ast'; +import type { SqlPslBuildContext } from '@internal/family-sql/control'; +import type { PslTypeMap } from '@internal/family-sql/psl-build'; import type { PslDocumentAst, PslExtensionBlock, @@ -17,12 +17,14 @@ import { makePslNamespaceEntries, UNSPECIFIED_PSL_NAMESPACE_ID, } from '@internal/framework-components/psl-ast'; -import type { ForeignKey, SqlStorage } from '@internal/sql-contract/types'; +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-ast/postgres-type-map'; -import { SYNTHETIC_SPAN } from '../psl-ast/psl-literals'; +import { createPostgresTypeMap } from '../psl-build/postgres-type-map'; +import { SYNTHETIC_SPAN } from '../psl-build/psl-literals'; import { indexContractModels, type ModelWithTable, @@ -44,9 +46,8 @@ import { refuseDuplicateModelNames, refuseEnumAndNativeEnumSharingName, refuseEnumsOutsideDefaultNamespace, - refuseInvalidEntry, refuseModelOwner, - refuseModelsOutsideTableNamespace, + refuseNamespaceNamedUnbound, refuseRlsWithoutModel, refuseUnderivedChecks, refuseUnderivedRoots, @@ -55,6 +56,7 @@ import { refuseUnmodelledTablesAndColumns, refuseUnprintedEntryKinds, refuseUntravelledForeignKeys, + refuseUnwritableMappedName, refuseUnwritableName, refuseUnwrittenExecutionDefaults, refuseUnwrittenNamespaces, @@ -73,7 +75,7 @@ import { buildScalarFields, executionDefaultsByColumn } from './scalar-fields'; /** What building every model needs from the whole contract, computed once. */ interface ContractModels { readonly contract: Contract; - readonly context: SqlPslPrintContext; + readonly context: SqlPslBuildContext; readonly typeMap: PslTypeMap; readonly models: readonly ModelWithTable[]; readonly byCoordinate: ReadonlyMap; @@ -93,7 +95,10 @@ function nativeEnumsOf( ): ReadonlyMap { const nativeEnums = new Map(); for (const [name, entity] of Object.entries(entries['native_enum'] ?? {})) { - if (!PostgresNativeEnum.is(entity)) refuseInvalidEntry(namespaceId, 'native_enum', name); + invariant( + PostgresNativeEnum.is(entity), + `"native_enum" entry "${name}" in namespace "${namespaceId}" is not a native enum`, + ); nativeEnums.set(name, entity); } return nativeEnums; @@ -147,6 +152,7 @@ function buildModel( executionDefaults: all.executionDefaults, writtenExecutionDefaults: all.writtenExecutionDefaults, defaultDomainEnumNames: new Set(Object.keys(all.defaultDomainEnums)), + namedTypes: all.contract.storage.types ?? {}, }), ...buildRelationFields({ entry, @@ -257,27 +263,40 @@ function buildNamespace( 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: pslNamespaceName(namespaceId), + 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. + * 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: SqlPslPrintContext, + context: SqlPslBuildContext, ): PslDocumentAst { - refuseModelsOutsideTableNamespace(contract); + refuseUnwritableTableNames(contract); const models = indexContractModels(contract); refuseDuplicateModelNames(models); const byCoordinate = modelsByCoordinate(models); 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 index 6eed09289fdd..a194080b8d6c 100644 --- 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 @@ -14,24 +14,22 @@ import type { 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 } from '@internal/psl-parser'; +import { isPslIdentifier, NAME_THE_PSL_SOURCE_LOSES } from '@internal/psl-parser'; import { type ForeignKey, type Index, - type SqlModelStorage, type SqlStorage, StorageColumn, } from '@internal/sql-contract/types'; import { escapePslString } from '@internal/sql-relational-core/ast'; -import { blindCast } from '@internal/utils/casts'; 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 { NAME_THE_PSL_SOURCE_LOSES } from '../psl-ast/name-the-psl-source-loses'; import { isVariantLinkForeignKey, type ModelWithTable, @@ -340,20 +338,44 @@ export function refuseFieldsWithoutColumn(entry: ModelWithTable): void { } } -export function refuseStorageWithoutFieldOrColumn(input: { +/** 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 such field or the table has no such column.`, - 'PSL declares a field together with the column it is stored in, so both must exist.', - 'The contract source produced storage for a field or column it does not declare. Fix it if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', + `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( @@ -406,22 +428,6 @@ export function refuseUnmodelledTablesAndColumns( // Models -/** Refuses a model whose domain namespace is not the namespace of its table. */ -export function refuseModelsOutsideTableNamespace(contract: Contract): void { - for (const [namespaceId, domainNamespace] of Object.entries(contract.domain.namespaces)) { - for (const [name, model] of Object.entries(domainNamespace.models)) { - const storage = blindCast(model.storage); - if (storage.namespaceId === namespaceId) continue; - throw unsupported( - `model "${namespaceId}.${name}" is stored in namespace "${storage.namespaceId}", which cannot be written in Prisma 8 PSL.`, - 'A model written in a namespace block belongs to that namespace in both the domain and the storage.', - 'The contract source produced a model outside the namespace of its table. Fix the model if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', - { namespaceId, modelName: name, tableNamespaceId: storage.namespaceId }, - ); - } - } -} - export function refuseModelOwner(entry: ModelWithTable): void { if (entry.model.owner === undefined) return; throw unsupported( @@ -656,6 +662,28 @@ export function refuseManyToManyWithoutJunctionRelation( ); } +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 /** @@ -778,6 +806,20 @@ export function refuseUnwrittenNamespaces(input: { } } +/** + * 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); @@ -931,19 +973,30 @@ export function refuseEntryInOtherNamespace(input: { // 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 name the printer writes where PSL reads an identifier: a model, field, value object, - * enum, enum member, native enum, named type, policy, role or index option key. + * 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 { - if (name === NAME_THE_PSL_SOURCE_LOSES) { - 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 }, - ); - } + 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.`, @@ -979,13 +1032,3 @@ export function refuseUnprintedEntryKinds( ); } } - -/** Refuses an entry filed under a kind whose entity class does not accept it. */ -export function refuseInvalidEntry(namespaceId: string, kind: string, name: string): never { - throw unsupported( - `namespace "${namespaceId}" has a "${kind}" entry "${name}" that is not a ${kind} entity.`, - 'The printer reads each storage entry through the entity class of its kind, and this entry does not have that shape.', - 'The contract source produced an entry the Postgres target does not accept. Fix the entry if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', - { namespaceId, kind, name }, - ); -} 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 index fba658d54ac7..97cc3177407e 100644 --- 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 @@ -2,9 +2,9 @@ import type { ContractReferenceRelation, ContractRelation } from '@internal/cont 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 { postgresError } from '../errors'; -import { buildAttribute, namedArg, SYNTHETIC_SPAN } from '../psl-ast/psl-literals'; +import { buildAttribute, namedArg, SYNTHETIC_SPAN } from '../psl-build/psl-literals'; import { crossReferenceCoordinate, type ModelWithTable, @@ -13,8 +13,10 @@ import { type VariantInfo, } from './contract-model-index'; import { + refuseBackRelationWithoutOwningRelation, refuseManyToManyWithoutJunctionRelation, refuseRelationToOtherContractSpace, + refuseRelationWithoutJoin, refuseToOneRelationWithoutForeignKey, refuseUnwritableName, } from './refusals'; @@ -36,14 +38,14 @@ export interface ModelRelation { } function isReferenceRelation(relation: ContractRelation): relation is ContractReferenceRelation { - return 'on' in relation; + 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)) continue; + if (!isReferenceRelation(relation)) refuseRelationWithoutJoin(owner.name, fieldName); entries.push({ owner, fieldName, @@ -326,10 +328,14 @@ export function buildRelationFields(input: { : modelRelation.relation.cardinality === 'N:M' ? junctionRelation : owningPartner(modelRelation, input.relationsByModel); - const name = - owning !== undefined && input.pinned.has(relationKey(owning)) - ? relationName(owning) - : undefined; + 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); @@ -362,20 +368,9 @@ function relationTarget( space: to.space, }); const target = modelsByCoordinate.get(entry.targetCoordinate); - if (target === undefined) { - throw postgresError( - 'CONTRACT.MODEL_UNKNOWN', - `contract print: relation "${entry.owner.name}.${entry.fieldName}" targets model "${to.namespace}.${to.model}", which the contract does not declare.`, - { - why: 'A relation is written as a field typed by the model it targets.', - fix: 'The contract source produced a relation to a model it does not declare. Fix the relation if the source is a TypeScript contract; otherwise report the bug to the source that produced it.', - meta: { - model: entry.owner.name, - field: entry.fieldName, - target: `${to.namespace}.${to.model}`, - }, - }, - ); - } + 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 index 57343d8bfb73..9e64279aa598 100644 --- 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 @@ -1,16 +1,16 @@ 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-ast/psl-literals'; +import { SYNTHETIC_SPAN } from '../psl-build/psl-literals'; import { computeContentHash } from '../rls/canonicalize'; import { refuseEntryFiledUnderAnotherName, refuseEntryInOtherNamespace, - refuseInvalidEntry, refusePolicyNameNotDerived, refusePolicyWithoutModel, refusePolicyWithoutRls, @@ -29,7 +29,10 @@ export function rlsEnabledTables( ): ReadonlySet { const tables = new Set(); for (const [name, entity] of Object.entries(entries ?? {})) { - if (!(entity instanceof PostgresRlsEnablement)) refuseInvalidEntry(namespaceId, 'rls', name); + invariant( + entity instanceof PostgresRlsEnablement, + `"rls" entry "${name}" in namespace "${namespaceId}" is not a row-level security setting`, + ); refuseEntryInOtherNamespace({ namespaceId, kind: 'rls', @@ -56,7 +59,10 @@ export function buildRoleBlocks( entries: Readonly> | undefined, ): readonly PslExtensionBlock[] { return Object.entries(entries ?? {}).map(([name, entity]): PslExtensionBlock => { - if (!(entity instanceof PostgresRole)) refuseInvalidEntry(namespaceId, 'role', name); + invariant( + entity instanceof PostgresRole, + `"role" entry "${name}" in namespace "${namespaceId}" is not a role`, + ); refuseRoleOutsideUnbound(namespaceId, name); refuseEntryInOtherNamespace({ namespaceId, @@ -107,8 +113,10 @@ export function buildPolicyBlocks(input: { readonly rlsTables: ReadonlySet; }): readonly PslExtensionBlock[] { return Object.entries(input.entries ?? {}).map(([head, policy]): PslExtensionBlock => { - if (!(policy instanceof PostgresRlsPolicy)) - refuseInvalidEntry(input.namespaceId, 'policy', head); + invariant( + policy instanceof PostgresRlsPolicy, + `"policy" entry "${head}" in namespace "${input.namespaceId}" is not a policy`, + ); refuseUnwritableName('policy', head); refuseEntryInOtherNamespace({ namespaceId: input.namespaceId, 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 index 34bd8364c09e..46b79d059cb6 100644 --- 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 @@ -1,10 +1,11 @@ import type { Contract, ContractEnum, ExecutionMutationDefault } from '@internal/contract/types'; -import type { SqlPslPrintContext } from '@internal/family-sql/control'; -import type { PslTypeMap } from '@internal/family-sql/psl-ast'; +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/resolution'; +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 { @@ -13,7 +14,7 @@ import { namedArg, positionalArg, SYNTHETIC_SPAN, -} from '../psl-ast/psl-literals'; +} from '../psl-build/psl-literals'; import { buildColumnDefault } from './column-defaults'; import { buildColumnType } from './column-types'; import type { ModelWithTable, VariantInfo } from './contract-model-index'; @@ -22,11 +23,13 @@ import type { NativeEnumEmission } from './enum-blocks'; import { buildExecutionDefault, temporalPresetArguments } from './generated-values'; import { refuseColumnControl, + refuseColumnDifferingFromNamedType, refuseFieldColumnMismatch, refuseFieldsWithoutColumn, refuseGeneratorWithDatabaseDefault, - refuseStorageWithoutFieldOrColumn, + refuseStorageOfUndeclaredField, refuseUnwritableFieldShape, + refuseUnwritableMappedName, refuseUnwritableName, } from './refusals'; @@ -53,7 +56,7 @@ function scalarFieldAttributes(input: { readonly isEnum: boolean; readonly domainEnum: ContractEnum | undefined; readonly generatedDefault: PslFieldAttribute | undefined; - readonly context: SqlPslPrintContext; + readonly context: SqlPslBuildContext; }): readonly PslFieldAttribute[] { const attributes: PslFieldAttribute[] = []; if (input.isSingleColumnId) { @@ -102,10 +105,11 @@ export function buildScalarFields(input: { readonly enums: NativeEnumEmission; readonly domainEnums: Readonly>; readonly typeMap: PslTypeMap; - readonly context: SqlPslPrintContext; + 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 ?? []) : []; @@ -118,10 +122,10 @@ export function buildScalarFields(input: { const coordinate = `"${entry.namespaceId}"."${entry.tableName}"."${columnName}"`; const column = entry.table.columns[columnName]; const field = entry.model.fields[fieldName]; - if (column === undefined || field === undefined) { - refuseStorageWithoutFieldOrColumn({ entry, fieldName, coordinate }); - } + 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({ @@ -132,6 +136,14 @@ export function buildScalarFields(input: { 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 } 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 e65e26a9fb7b..36e19bf017aa 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -44,7 +44,7 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP PostgresDatabaseSchemaNode.assert(schema); return inferPostgresPslContract(schema, describedContracts); }, - printPslContract(contract, context) { + buildPslContract(contract, context) { return buildPostgresPslContract(contract, context); }, diffSchema(input) { diff --git a/packages/3-targets/3-targets/postgres/test/psl-ast/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-ast/postgres-type-map.test.ts rename to packages/3-targets/3-targets/postgres/test/psl-build/postgres-type-map.test.ts index 9bbdef9e0682..6bebf52c6b7c 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-ast/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-ast/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/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 8b0a302f1f63..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,10 +2,10 @@ 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 { PRINTED_PSL_TYPE_NAMES } from '../../../src/core/psl-ast/postgres-type-map'; +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 { printPslFromFlat } from '../fixtures'; @@ -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 b84359b5786c..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,7 +17,7 @@ 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-ast/postgres-type-map'; +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'; 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 442d93e08a8e..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,7 +46,7 @@ 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-ast/postgres-type-map'; +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 { inferPslAstFromFlat } from '../fixtures'; diff --git a/packages/3-targets/3-targets/postgres/test/psl-print/print-context.ts b/packages/3-targets/3-targets/postgres/test/psl-print/build-context.ts similarity index 96% rename from packages/3-targets/3-targets/postgres/test/psl-print/print-context.ts rename to packages/3-targets/3-targets/postgres/test/psl-print/build-context.ts index ce2e2a0f07ec..98f617f50043 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-print/print-context.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-print/build-context.ts @@ -1,4 +1,4 @@ -import type { SqlPslPrintContext } from '@internal/family-sql/control'; +import type { SqlPslBuildContext } from '@internal/family-sql/control'; import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; import { type CodecDescriptorTemplate, @@ -65,12 +65,12 @@ export const extensionCodec: AnyPostgresCodecDescriptor = postgresCodec(citextTe * 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 testPrintContext( +export function testBuildContext( extra: { readonly types?: AuthoringTypeNamespace; readonly codecs?: readonly AnyPostgresCodecDescriptor[]; } = {}, -): SqlPslPrintContext { +): SqlPslBuildContext { const extraCodecs = new Map((extra.codecs ?? []).map((codec) => [codec.codecId, codec])); return { authoringContributions: { 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 index 90fc54335f26..0a0703f9980f 100644 --- 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 @@ -8,7 +8,7 @@ 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 { testPrintContext } from './print-context'; +import { testBuildContext } from './build-context'; import { type ColumnShape, domainFieldOf, INT_COLUMN, table } from './print-support'; describe('native enum blocks', () => { @@ -75,7 +75,7 @@ describe('native enum blocks', () => { const contract = new PostgresContractSerializer().deserializeContract(json); const ast = buildPostgresPslContract( blindCast, 'the Postgres serializer yields a SQL contract'>(contract), - testPrintContext(), + testBuildContext(), ); return ast.namespaces .flatMap((namespace) => namespacePslExtensionBlocks(namespace)) 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 index 12cc54cf36e5..5c93f30bbcfa 100644 --- 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 @@ -12,7 +12,7 @@ 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 { testPrintContext } from './print-context'; +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; @@ -122,7 +122,7 @@ export function buildModels(input: { const contract = new PostgresContractSerializer().deserializeContract(json); const ast = buildPostgresPslContract( blindCast, 'the Postgres serializer yields a SQL contract'>(contract), - testPrintContext(), + testBuildContext(), ); return ast.namespaces.flatMap((namespace) => namespace.models); } 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 index 4e8fb013b251..67cef1e9dddf 100644 --- 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 @@ -1,13 +1,13 @@ import type { Contract, ContractField } from '@internal/contract/types'; import { asNamespaceId } from '@internal/contract/types'; -import type { SqlPslPrintContext } from '@internal/family-sql/control'; +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 { testPrintContext } from './print-context'; +import { testBuildContext } from './build-context'; export type Overrides = NonNullable[0]>; @@ -102,21 +102,36 @@ export function deserialize(overrides: Overrides): Contract { ); } +/** 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: SqlPslPrintContext = testPrintContext(), + context: SqlPslBuildContext = testBuildContext(), ): () => unknown { return () => buildPostgresPslContract(contract, context); } export function printingWidget( parts: WidgetParts = {}, - context: SqlPslPrintContext = testPrintContext(), + context: SqlPslBuildContext = testBuildContext(), ): () => unknown { return printing(deserialize(widgetContract(parts)), context); } -/** The contract with one storage namespace's entries replaced verbatim, bypassing entity hydration. */ +/** + * 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, 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 index 40727eb24f7a..61c19698c75a 100644 --- 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 @@ -1,20 +1,5 @@ -import type { Contract } from '@internal/contract/types'; -import { asNamespaceId } from '@internal/contract/types'; -import type { SqlStorage } from '@internal/sql-contract/types'; -import { blindCast } from '@internal/utils/casts'; import { describe, expect, it } from 'vitest'; -import { - deserialize, - INT_FIELD, - PUBLIC, - printing, - printingWidget, - refusal, - TEXT_COLUMN, - TEXT_FIELD, - widgetContract, - withRawEntries, -} from './refusal-support'; +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', () => { @@ -199,98 +184,19 @@ describe('row-level security entries the PSL source would file differently', () }); }); -describe('storage a model names but the contract does not declare', () => { - function withWidget(patch: (widget: Record) => Record) { - const contract = deserialize( - widgetContract({ columns: { name: TEXT_COLUMN }, fields: { name: TEXT_FIELD } }), - ); - const publicDomain = contract.domain.namespaces['public']; - const widget = blindCast, 'the test widget model'>( - publicDomain?.models['Widget'], - ); - return blindCast, 'a contract the validator would reject'>({ - ...contract, - domain: { - namespaces: { - ...contract.domain.namespaces, - public: { ...publicDomain, models: { Widget: patch(widget) } }, - }, - }, - }); - } - +describe('fields and the columns they are stored in', () => { it('refuses a field stored in no column', () => { expect( - printing( - withWidget((widget) => ({ - ...widget, - fields: { ...blindCast(widget['fields']), extra: INT_FIELD }, - })), - ), + printingWidget({ fields: { extra: INT_FIELD }, storageFields: { id: { column: 'id' } } }), ).toThrow(refusal({ namespaceId: 'public', modelName: 'Widget', field: 'extra' })); }); - it('refuses a field stored in a column the table does not declare', () => { - expect( - printing( - withWidget((widget) => ({ - ...widget, - storage: { - table: 'Widget', - namespaceId: 'public', - fields: { id: { column: 'id' }, name: { column: 'missing' } }, - }, - })), - ), - ).toThrow(refusal({ namespaceId: 'public', modelName: 'Widget', field: 'name' })); - }); - - it('reports a relation to a model the contract does not declare', () => { - expect( - printing( - withWidget((widget) => ({ - ...widget, - relations: { - owner: { - to: { namespace: PUBLIC, model: 'Ghost' }, - cardinality: '1:N', - on: { localFields: ['id'], targetFields: ['widgetId'] }, - }, - }, - })), - ), - ).toThrow( - expect.objectContaining({ - code: 'CONTRACT.MODEL_UNKNOWN', - meta: { model: 'Widget', field: 'owner', target: 'public.Ghost' }, - }), - ); - }); - - it('reports a variant whose base does not list it', () => { - expect( - printing( - withWidget((widget) => ({ - ...widget, - base: { namespace: asNamespaceId('public'), model: 'Ghost' }, - })), - ), - ).toThrow( - expect.objectContaining({ - code: 'CONTRACT.MODEL_UNKNOWN', - meta: { namespaceId: 'public', modelName: 'Widget' }, - }), - ); - }); - - it('reports a model stored in a table the storage does not declare', () => { + it('refuses a column stored under a field name the model does not declare', () => { expect( - printing(withRawEntries(deserialize(widgetContract()), 'public', { table: {} })), - ).toThrow( - expect.objectContaining({ - code: 'CONTRACT.MODEL_UNKNOWN', - meta: { namespaceId: 'public', modelName: 'Widget', table: 'Widget' }, + 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 index 43f191d2a6ea..490a3c88164a 100644 --- 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 @@ -1,6 +1,6 @@ import { blindCast } from '@internal/utils/casts'; import { describe, expect, it } from 'vitest'; -import { testPrintContext } from './print-context'; +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', () => { @@ -102,7 +102,7 @@ describe('columns and fields', () => { }); describe('a column written through a type constructor the stack contributes', () => { - const context = testPrintContext({ + const context = testBuildContext({ types: { geo: { Shape: { 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 index bf5550c6645b..1954b4f479e2 100644 --- 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 @@ -21,7 +21,7 @@ function documentedCases(): readonly string[] { } describe('the refusals of contract print', () => { - it('has one function for each case the error reference lists', () => { + 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', ); 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 index a6e37a685f5a..7e0603b41959 100644 --- 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 @@ -1,5 +1,4 @@ import { asNamespaceId } from '@internal/contract/types'; -import { blindCast } from '@internal/utils/casts'; import { describe, expect, it } from 'vitest'; import { deserialize, @@ -40,18 +39,6 @@ describe('models and relations', () => { ).toThrow(refusal({ namespaceId: 'public', modelName: 'Widget', owner: 'Other' })); }); - it('refuses a model whose domain namespace is not the namespace of its table', () => { - const contract = deserialize(widgetContract()); - const { public: publicNamespace, ...others } = contract.domain.namespaces; - const moved = { - ...contract, - domain: { namespaces: { ...others, app: publicNamespace } }, - }; - expect( - printing(blindCast(moved)), - ).toThrow(refusal({ namespaceId: 'app', modelName: 'Widget', tableNamespaceId: 'public' })); - }); - it('refuses a model name that is not a PSL identifier', () => { expect( printing( 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 index 3eeb63bfdb88..ceac1c96f4a4 100644 --- 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 @@ -1,10 +1,10 @@ import type { ContractField } from '@internal/contract/types'; -import { blindCast } from '@internal/utils/casts'; import { describe, expect, it } from 'vitest'; import { buildPostgresPslContract } from '../../src/core/psl-print/psl-contract'; -import { extensionCodec, testPrintContext } from './print-context'; +import { extensionCodec, testBuildContext } from './build-context'; import { deserialize, + deserializeEdited, INT_COLUMN, PUBLIC, printing, @@ -118,15 +118,14 @@ describe('parts of the contract no model carries', () => { }); it('refuses a contract without the default namespace the PSL source always creates', () => { - const contract = deserialize(widgetContract()); - const { public: _, ...others } = contract.storage.namespaces; - const withoutPublic = blindCast({ - ...contract, - domain: { namespaces: {} }, - roots: {}, - storage: { ...contract.storage, namespaces: others }, + const empty = deserializeEdited(widgetContract(), (json) => { + const contract = JSON.parse(json); + contract.roots = {}; + contract.domain.namespaces = {}; + contract.storage.namespaces = {}; + return JSON.stringify(contract); }); - expect(printing(withoutPublic)).toThrow(refusal({ plane: 'storage', namespaceId: 'public' })); + expect(printing(empty)).toThrow(refusal({ plane: 'storage', namespaceId: 'public' })); }); it('refuses top-level meta entries', () => { @@ -155,17 +154,6 @@ describe('parts of the contract no model carries', () => { ), ).toThrow(refusal({ namespaceId: 'public', kind: 'sequence', names: ['counter'] })); }); - - it.each(['rls', 'policy', 'role', 'native_enum'])( - 'refuses a %s entry that is not an entity of its kind', - (kind) => { - expect( - printing( - withRawEntries(deserialize(widgetContract()), 'public', { [kind]: { odd: { kind } } }), - ), - ).toThrow(refusal({ namespaceId: 'public', kind, name: 'odd' })); - }, - ); }); describe('row-level security', () => { @@ -327,7 +315,7 @@ describe('value objects', () => { }); it('writes a value-object field typed by a codec only the stack knows, as the type constructor that produces it', () => { - const context = testPrintContext({ + const context = testBuildContext({ codecs: [extensionCodec], types: { ext: { diff --git a/packages/3-targets/6-adapters/postgres/package.json b/packages/3-targets/6-adapters/postgres/package.json index fb175450e842..90d5e7139f35 100644 --- a/packages/3-targets/6-adapters/postgres/package.json +++ b/packages/3-targets/6-adapters/postgres/package.json @@ -37,7 +37,6 @@ "@internal/cli": "workspace:8.0.0-rc.12", "@internal/driver-postgres": "workspace:8.0.0-rc.12", "@internal/migration-tools": "workspace:8.0.0-rc.12", - "@internal/psl-printer": "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/6-adapters/postgres/test/printed-type-codecs.test.ts b/packages/3-targets/6-adapters/postgres/test/inferred-type-codecs.test.ts similarity index 72% 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 cddcdfe68b39..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,5 +1,5 @@ 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, @@ -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 2968b7676fc3..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,7 +89,7 @@ "./family/ir": "./dist/family__ir.mjs", "./family/migration": "./dist/family__migration.mjs", "./family/pack": "./dist/family__pack.mjs", - "./family/psl-ast": "./dist/family__psl-ast.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 eb9ba7df6cc6..c402b8a9baf8 100644 --- a/packages/9-public/@prisma/orm-postgres/package.json +++ b/packages/9-public/@prisma/orm-postgres/package.json @@ -87,7 +87,7 @@ "./family/ir": "./dist/family__ir.mjs", "./family/migration": "./dist/family__migration.mjs", "./family/pack": "./dist/family__pack.mjs", - "./family/psl-ast": "./dist/family__psl-ast.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 31f7e0f42407..b348c94c0079 100644 --- a/packages/9-public/@prisma/orm-sqlite/package.json +++ b/packages/9-public/@prisma/orm-sqlite/package.json @@ -84,7 +84,7 @@ "./family/ir": "./dist/family__ir.mjs", "./family/migration": "./dist/family__migration.mjs", "./family/pack": "./dist/family__pack.mjs", - "./family/psl-ast": "./dist/family__psl-ast.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 32144f270bbe..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 @@ -2580,9 +2583,6 @@ importers: '@internal/family-sql': specifier: workspace:8.0.0-rc.12 version: link:../../9-family - '@internal/psl-printer': - specifier: workspace:8.0.0-rc.12 - version: link:../../../1-framework/2-authoring/psl-printer '@internal/target-postgres': specifier: workspace:8.0.0-rc.12 version: link:../../../3-targets/3-targets/postgres @@ -4061,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 @@ -4098,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 @@ -4256,9 +4256,6 @@ importers: '@internal/migration-tools': specifier: workspace:8.0.0-rc.12 version: link:../../../1-framework/3-tooling/migration - '@internal/psl-printer': - specifier: workspace:8.0.0-rc.12 - version: link:../../../1-framework/2-authoring/psl-printer '@repo/test-utils': specifier: workspace:8.0.0-rc.12 version: link:../../../../test/utils @@ -4974,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 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 index ddff9a96be38..0ac11067ec60 100644 --- 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 @@ -28,9 +28,10 @@ Output begins: | 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 so the config sets it on the PSL source. | +| 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 diff --git a/projects/prisma7-contract-source/spec.md b/projects/prisma7-contract-source/spec.md index 920ffd1d06af..988aefce3379 100644 --- a/projects/prisma7-contract-source/spec.md +++ b/projects/prisma7-contract-source/spec.md @@ -120,11 +120,12 @@ Recorded so they are not lost; each becomes its own project when scheduled. - **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 `prisma.config.ts` in the working directory. +- **`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 broadest is `test/integration/test/psl-print/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 (`contract-prisma7/test/print-roundtrip.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 (`adapter-postgres/test/psl-print-roundtrip.test.ts`); every default function and temporal preset the stack registers (`adapter-postgres/test/psl-print-generated-values.test.ts`); TypeScript-authored contracts (`test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts`); a pgvector column, with and without a literal default, with the extension in the stack (`test/integration/test/psl-print/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 (`adapter-postgres/test/helpers/psl-print.ts`). Where PSL has no form for part of the 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. +`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: @@ -133,6 +134,10 @@ The refusals that a reader or language change would lift: - 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. @@ -146,7 +151,7 @@ A `Json` object or array literal default was refused in the first version and no Each exists on `main` unless the line says otherwise, so none is a regression this project caused. Each needs its own piece of work. -- 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 `symbol-table.ts` and `block-reconstruction.ts` in `@internal/psl-parser`). `contract print` refuses the name rather than write a file that reads back without it. In the PSL parser and readers. +- 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. - `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. diff --git a/scripts/check-upgrade-coverage.mjs b/scripts/check-upgrade-coverage.mjs index 0c07936ff416..6ac4b145dcaa 100644 --- a/scripts/check-upgrade-coverage.mjs +++ b/scripts/check-upgrade-coverage.mjs @@ -50,18 +50,25 @@ const PENDING = 'upgrade-instructions/pending/'; const PENDING_INSTRUCTIONS = /^upgrade-instructions\/pending\/[^/]+\/(app|extension)\/instructions\.md$/; const PUBLISHED_ROOT = 'skills/prisma-8/upgrading/'; -const PUBLISHED_DIRECTORY = /^(skills\/prisma-8\/upgrading\/(?:app|extension)\/upgrades\/[^/]+)\//; +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(); @@ -88,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); } @@ -284,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 fbcbb0a60d78..bbfc050cf86c 100644 --- a/scripts/check-upgrade-coverage.test.mjs +++ b/scripts/check-upgrade-coverage.test.mjs @@ -99,31 +99,52 @@ 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 repository whose files outside the instruction directories list to more than 1 MiB', () => { + it('checks a change whose files outside the directories it reads list to more than 1 MiB', () => { const base = commit(); - const emptyBlob = execFileSync('git', ['hash-object', '-w', '--stdin'], { - cwd: repo, - input: '', - encoding: 'utf8', - }).trim(); - const entries = Array.from( - { length: 12_000 }, - (_, index) => `100644 ${emptyBlob}\tbulk/${'x'.repeat(60)}-${index}.txt`, + 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, ); - execFileSync('git', ['update-index', '--index-info'], { - cwd: repo, - input: `${entries.join('\n')}\n`, - }); - git('commit', '-qm', 'bulk'); - const listing = execFileSync('git', ['ls-tree', '-r', '-z', 'HEAD'], { - cwd: repo, - maxBuffer: 16 * 1024 * 1024, - }); - assert.ok(listing.length > 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', () => { diff --git a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts b/test/integration/test/cli-journeys/contract-print.e2e.test.ts similarity index 98% rename from test/integration/test/cli-journeys/prisma7-print.e2e.test.ts rename to test/integration/test/cli-journeys/contract-print.e2e.test.ts index d9fefbf91701..8998781b0a91 100644 --- a/test/integration/test/cli-journeys/prisma7-print.e2e.test.ts +++ b/test/integration/test/cli-journeys/contract-print.e2e.test.ts @@ -119,7 +119,7 @@ function setupPrisma7Project( function onPrintedContract(ctx: JourneyContext, connectionString: string): JourneyContext { return { ...ctx, - configPath: writeConfig(ctx.testDir, 'prisma.config.prisma7-printed.ts', connectionString), + configPath: writeConfig(ctx.testDir, 'prisma.config.printed-contract.ts', connectionString), }; } @@ -249,7 +249,7 @@ withTempDir(({ createTempDir }) => { expect(print.exitCode, output(print)).toBe(0); expect(print.presented?.data).toMatchObject({ psl: { path: 'prisma/contract.prisma' }, - defaultControlPolicy: 'external', + sourceSettings: { defaultControlPolicy: 'external' }, }); expect(print.events).toContainEqual( expect.objectContaining({ diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-printed.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.printed-contract.ts similarity index 100% rename from test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.prisma7-printed.ts rename to test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.printed-contract.ts diff --git a/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts b/test/integration/test/psl-print/authored-contract-roundtrip.integration.test.ts similarity index 99% rename from packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts rename to test/integration/test/psl-print/authored-contract-roundtrip.integration.test.ts index 1281e87d09b4..42291805f54c 100644 --- a/packages/3-targets/6-adapters/postgres/test/psl-print-roundtrip.test.ts +++ b/test/integration/test/psl-print/authored-contract-roundtrip.integration.test.ts @@ -11,9 +11,9 @@ import { printContract, readPsl, serializedWithoutCapabilities, -} from './helpers/psl-print'; +} from './print-and-read-back'; -const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../../..'); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../..'); /** * Emitted contract fixtures, one per feature a Prisma 7 schema cannot carry. diff --git a/packages/3-targets/6-adapters/postgres/test/psl-print-constraints.test.ts b/test/integration/test/psl-print/constraints-roundtrip.integration.test.ts similarity index 99% rename from packages/3-targets/6-adapters/postgres/test/psl-print-constraints.test.ts rename to test/integration/test/psl-print/constraints-roundtrip.integration.test.ts index e752b43f3060..80896c85b66f 100644 --- a/packages/3-targets/6-adapters/postgres/test/psl-print-constraints.test.ts +++ b/test/integration/test/psl-print/constraints-roundtrip.integration.test.ts @@ -10,7 +10,7 @@ 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 './helpers/psl-print'; +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; 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 index 23c8795e2670..4c8078ba6cb1 100644 --- 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 @@ -24,7 +24,7 @@ import { type PostgresStack, printContract, readPsl, -} from '../../../../packages/3-targets/6-adapters/postgres/test/helpers/psl-print'; +} from './print-and-read-back'; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../..'); 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 index 2905f3cec1be..e88640d691cd 100644 --- a/test/integration/test/psl-print/extension-types-roundtrip.integration.test.ts +++ b/test/integration/test/psl-print/extension-types-roundtrip.integration.test.ts @@ -5,7 +5,7 @@ import { printContract, readPsl, serializedWithoutCapabilities, -} from '../../../../packages/3-targets/6-adapters/postgres/test/helpers/psl-print'; +} from './print-and-read-back'; const stack = composePostgresStack([pgvector]); diff --git a/packages/3-targets/6-adapters/postgres/test/psl-print-generated-values.test.ts b/test/integration/test/psl-print/generated-values-roundtrip.integration.test.ts similarity index 98% rename from packages/3-targets/6-adapters/postgres/test/psl-print-generated-values.test.ts rename to test/integration/test/psl-print/generated-values-roundtrip.integration.test.ts index 376928c19ca8..6f9fedd1d762 100644 --- a/packages/3-targets/6-adapters/postgres/test/psl-print-generated-values.test.ts +++ b/test/integration/test/psl-print/generated-values-roundtrip.integration.test.ts @@ -9,7 +9,7 @@ import { printAndReadBack, readPsl, serializedWithoutCapabilities, -} from './helpers/psl-print'; +} from './print-and-read-back'; const stack = composePostgresStack(); diff --git a/packages/3-targets/6-adapters/postgres/test/helpers/psl-print.ts b/test/integration/test/psl-print/print-and-read-back.ts similarity index 84% rename from packages/3-targets/6-adapters/postgres/test/helpers/psl-print.ts rename to test/integration/test/psl-print/print-and-read-back.ts index 6e509d5729e8..9336de652c2d 100644 --- a/packages/3-targets/6-adapters/postgres/test/helpers/psl-print.ts +++ b/test/integration/test/psl-print/print-and-read-back.ts @@ -1,5 +1,7 @@ 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'; @@ -21,7 +23,6 @@ import { postgresCreateNamespace } from '@internal/target-postgres/types'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import { join } from 'pathe'; -import postgresAdapter from '../../src/exports/control'; export type PostgresStack = ControlStack<'sql', 'postgres'>; @@ -52,7 +53,7 @@ export function printContract( contract: Contract, stack: PostgresStack = composePostgresStack(), ): PrintedContract { - const { document, sourceSettings } = sql.create(stack).printPslContract(contract); + const { document, sourceSettings } = sql.create(stack).buildPslContract(contract); return { text: printPsl(document, { pslBlockDescriptors: stack.authoringContributions.pslBlockDescriptors, @@ -62,6 +63,23 @@ export function printContract( }; } +/** 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. */ @@ -88,16 +106,7 @@ export async function readPsl( enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, ...ifDefined('composedExtensionPackRefs', options.packRefs), ...ifDefined('defaultControlPolicy', options.sourceSettings?.defaultControlPolicy), - }).source.load({ - composedExtensions: stack.extensions.map((extension) => extension.id), - composedExtensionContracts: stack.extensionContracts, - authoringContributions: stack.authoringContributions, - codecLookup: stack.codecLookup, - dataTypeLookup: stack.dataTypeLookup, - controlMutationDefaults: stack.controlMutationDefaults, - resolvedInputs: [path], - capabilities: stack.capabilities, - }); + }).source.load(sourceContext(stack, [path])); if (!result.ok) { throw new Error( `the PSL did not load: ${JSON.stringify(result.failure.diagnostics)}\n${text}`, diff --git a/packages/2-sql/2-authoring/contract-prisma7/test/print-roundtrip.test.ts b/test/integration/test/psl-print/prisma7-fixture-roundtrip.integration.test.ts similarity index 88% rename from packages/2-sql/2-authoring/contract-prisma7/test/print-roundtrip.test.ts rename to test/integration/test/psl-print/prisma7-fixture-roundtrip.integration.test.ts index f17cf3f4ae9d..d340c6338729 100644 --- a/packages/2-sql/2-authoring/contract-prisma7/test/print-roundtrip.test.ts +++ b/test/integration/test/psl-print/prisma7-fixture-roundtrip.integration.test.ts @@ -2,19 +2,23 @@ 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, -} from '../../../../3-targets/6-adapters/postgres/test/helpers/psl-print'; -import { prisma7Contract } from '../src/provider'; -import { postgresSourceContext } from './support'; + sourceContext, +} from './print-and-read-back'; -const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); +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()) @@ -39,7 +43,7 @@ async function loadPrisma7Fixture( ): Promise> { const result = await prisma7Contract(schemaPath, { binding: prisma7PostgresBinding, - }).source.load(postgresSourceContext([schemaPath])); + }).source.load(sourceContext(composePostgresStack(), [schemaPath])); if (!result.ok) { throw new Error( `Prisma 7 fixture "${caseName}" did not load: ${JSON.stringify(result.failure.diagnostics)}`, 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 index 57bdbdfd2d35..4bbcf3c098a3 100644 --- a/test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts +++ b/test/integration/test/psl-print/typescript-contract-roundtrip.integration.test.ts @@ -10,14 +10,15 @@ import { } from '@internal/postgres/contract-builder'; import type { SqlStorage } from '@internal/sql-contract/types'; import { describe, expect, it } from 'vitest'; -import { - printAndReadBack, - serializedWithoutCapabilities, -} from '../../../../packages/3-targets/6-adapters/postgres/test/helpers/psl-print'; 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: { @@ -49,6 +50,20 @@ const Session = model('Session', { 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', @@ -67,4 +82,13 @@ describe('a TypeScript-authored contract printed as PSL reads back as the same c 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/upgrade-instructions/pending/contract-print/app/instructions.md b/upgrade-instructions/pending/contract-print/app/instructions.md index 3d8bb7b098cd..5dab789502aa 100644 --- a/upgrade-instructions/pending/contract-print/app/instructions.md +++ b/upgrade-instructions/pending/contract-print/app/instructions.md @@ -10,7 +10,7 @@ changes: # `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 without writing when it does not (for example, a schema with a `view` block). +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. diff --git a/upgrade-instructions/pending/contract-print/extension/instructions.md b/upgrade-instructions/pending/contract-print/extension/instructions.md index 0864bb0598ec..368283a511a2 100644 --- a/upgrade-instructions/pending/contract-print/extension/instructions.md +++ b/upgrade-instructions/pending/contract-print/extension/instructions.md @@ -6,8 +6,8 @@ changes: 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-ast-export - summary: mapDefault, its option types, the PslTypeMap types and toEnumMemberName moved from @internal/family-sql/psl-infer to @internal/family-sql/psl-ast. + - 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 @@ -30,6 +30,6 @@ printPsl(ast, { }); ``` -# PSL building blocks moved to `@internal/family-sql/psl-ast` +# 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-ast`: `mapDefault`, `DefaultMappingOptions`, `DefaultMappingResult`, `PslTypeMap`, `PslTypeReference`, `PslTypeResolution` and `toEnumMemberName`. Import them from `@internal/family-sql/psl-ast`. Everything else stays in `@internal/family-sql/psl-infer`. +`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`. From 64ca0152deecd9498c40e5d381035a423b48eccc Mon Sep 17 00:00:00 2001 From: willbot Date: Sun, 27 Sep 2026 12:06:46 +0200 Subject: [PATCH 54/55] fix(cli): contract format formats every .prisma file under a directory input A Prisma 7 or Prisma 6 source may name a directory of schema files, and both are PSL sources now. contract format read the directory as a file and failed. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/control-api/operations/format.ts | 20 +++++++++++++++++-- .../cli/test/control-api/format.test.ts | 19 +++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) 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/test/control-api/format.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/format.test.ts index ce7f3c42ba98..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'; @@ -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'); From 0a442bcbdad2be22f83806cff761f1a72950ca24 Mon Sep 17 00:00:00 2001 From: willbot Date: Sun, 27 Sep 2026 12:22:12 +0200 Subject: [PATCH 55/55] docs(rules): the vocabulary rule card states the CLI exemption in fewer words The rules folder was 0.2 KB over its size budget. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .agents/rules/no-family-vocabulary-in-framework.mdc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/rules/no-family-vocabulary-in-framework.mdc b/.agents/rules/no-family-vocabulary-in-framework.mdc index 6a2d88e62b31..ce468abc525f 100644 --- a/.agents/rules/no-family-vocabulary-in-framework.mdc +++ b/.agents/rules/no-family-vocabulary-in-framework.mdc @@ -10,7 +10,7 @@ alwaysApply: false **The test for every new framework export, field, hook, or member:** *would Mongo populate or consume this meaningfully?* If not, it belongs in the family layer (e.g. `@internal/sql-contract`), behind a structural interface + type predicate at the boundary. -The framework also must not know which Prisma version wrote a document. It reads every PSL document the same way, and each family or target reader decides what it accepts. Code that exists for an earlier version's schema belongs in the package that reads that schema (e.g. `@internal/sql-contract-prisma7`). The CLI (`packages/1-framework/3-tooling/cli`) is exempt from this part: it talks to users about their projects, and `orm init` sets Prisma 8 up beside a Prisma 7 project, so it must name the version. +The framework also must not know which Prisma version wrote a document: code for an earlier version's schema belongs in the package that reads it (e.g. `@internal/sql-contract-prisma7`). The CLI (`packages/1-framework/3-tooling/cli`) may name such a version, because it talks to users about their projects. ## How it is enforced @@ -18,7 +18,7 @@ The [`no-family-vocabulary` Biome plugin](../../biome-plugins/no-family-vocabula **Comments and JSDoc are deliberately not counted.** Prose that documents family-blind code correctly ("family-parameterized (SQL, Mongo, …)") is good documentation, and the previous line-scanning checker taxed it — the majority of what it flagged was prose, not surface. A leak that matters is a real type, field, identifier, string, or import path, and the plugin only sees those. -Test files and `test/` directories are not scanned. Outside them the plugin has one path exemption: the CLI (`packages/1-framework/3-tooling/cli`) may name a Prisma version before 8. That exemption covers version names only; family and target terms are flagged in the CLI as everywhere else. Do not add another path exemption. There are two other exemptions, and no others: a framework-neutral compound listed in the plugin's `not` guards (`SymbolTable`, `MigrationLogTable`, the formatter's alignment columns), and a line suppression. +Test files and `test/` directories are not scanned. The CLI's allowance for version names is the only path exemption; do not add another. There are two other exemptions, and no others: a framework-neutral compound listed in the plugin's `not` guards (`SymbolTable`, `MigrationLogTable`, the formatter's alignment columns), and a line suppression. Suppress a genuinely family-blind site at the line, with a reason: