From 4f9a92c2699c7d1e94667a16977e2d28a2bf43fa Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 11:40:19 +0200 Subject: [PATCH 1/6] feat(target-postgres): harvest single-quoted literals from CHECK reprints A text scan that collects single-quoted string literals in order of appearance and unescapes doubled quotes. It recognizes no predicate shape; casts, operators, and identifiers are skipped. Feeds Path A domain-enum recovery (domain-enum-inference slice 2, dispatch 1). Signed-off-by: willbot Signed-off-by: Will Madden --- .../core/psl-infer/harvest-check-literals.ts | 35 +++++++ .../psl-infer/harvest-check-literals.test.ts | 93 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-infer/harvest-check-literals.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-infer/harvest-check-literals.test.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/harvest-check-literals.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/harvest-check-literals.ts new file mode 100644 index 000000000000..6dc80a60b1ad --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/harvest-check-literals.ts @@ -0,0 +1,35 @@ +/** + * Extracts the single-quoted string literals from a live CHECK expression + * reprint, in order of appearance, with doubled quotes unescaped + * (`'O''Brien'` → `O'Brien`). A text scan only — no predicate shape is + * recognized; casts, operators, and identifiers are skipped. An expression + * with no literals yields an empty list. + */ +export function harvestCheckLiterals(expression: string): string[] { + const literals: string[] = []; + let index = 0; + while (index < expression.length) { + if (expression[index] !== `'`) { + index += 1; + continue; + } + index += 1; + let value = ''; + while (index < expression.length) { + if (expression[index] !== `'`) { + value += expression[index]; + index += 1; + continue; + } + if (expression[index + 1] === `'`) { + value += `'`; + index += 2; + continue; + } + index += 1; + literals.push(value); + break; + } + } + return literals; +} diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/harvest-check-literals.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/harvest-check-literals.test.ts new file mode 100644 index 000000000000..d8dbe2e369f3 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/harvest-check-literals.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { harvestCheckLiterals } from '../../src/core/psl-infer/harvest-check-literals'; + +describe('harvestCheckLiterals', () => { + describe('captured reprint corpus shapes', () => { + it('text one-member', () => { + expect(harvestCheckLiterals(`(role = 'user'::text)`)).toEqual(['user']); + }); + + it('text many-member', () => { + expect(harvestCheckLiterals(`(role = ANY (ARRAY['user'::text, 'admin'::text]))`)).toEqual([ + 'user', + 'admin', + ]); + }); + + it('varchar one-member', () => { + expect(harvestCheckLiterals(`((status)::text = 'a'::text)`)).toEqual(['a']); + }); + + it('varchar many-member', () => { + expect( + harvestCheckLiterals( + `((status)::text = ANY ((ARRAY['a'::character varying, 'b'::character varying])::text[]))`, + ), + ).toEqual(['a', 'b']); + }); + + it('array containment', () => { + expect(harvestCheckLiterals(`(tags <@ ARRAY['user'::text, 'admin'::text])`)).toEqual([ + 'user', + 'admin', + ]); + }); + + it('doubled quote inside a member unescapes', () => { + expect( + harvestCheckLiterals(`(surname = ANY (ARRAY['O''Brien'::text, 'plain'::text]))`), + ).toEqual([`O'Brien`, 'plain']); + }); + }); + + describe('predicates without literals', () => { + it('a numeric comparison yields nothing', () => { + expect(harvestCheckLiterals('(cardinality(tags) > 0)')).toEqual([]); + }); + + it('an empty string yields nothing', () => { + expect(harvestCheckLiterals('')).toEqual([]); + }); + }); + + describe('free-form corpus predicates', () => { + it('a composite AND harvests its literals in order', () => { + expect(harvestCheckLiterals(`((a > 0) AND (b <> ''::text))`)).toEqual(['']); + }); + + it('a cast-wrapped numeric predicate yields nothing', () => { + expect(harvestCheckLiterals('(price > (0)::numeric)')).toEqual([]); + }); + }); + + describe('quoting edge cases', () => { + it('a literal that is only a doubled quote', () => { + expect(harvestCheckLiterals(`(x = '''')`)).toEqual([`'`]); + }); + + it('an empty literal', () => { + expect(harvestCheckLiterals(`(x = '')`)).toEqual(['']); + }); + + it('consecutive doubled quotes inside a member', () => { + expect(harvestCheckLiterals(`(x = 'a''''b')`)).toEqual([`a''b`]); + }); + + it('cast type names are never harvested', () => { + expect(harvestCheckLiterals(`(role = 'user'::text)`)).not.toContain('text'); + expect(harvestCheckLiterals(`((status)::text = 'a'::character varying)`)).toEqual(['a']); + }); + + it('double-quoted identifiers are not literals', () => { + expect(harvestCheckLiterals(`("role" = 'user'::text)`)).toEqual(['user']); + }); + + it('literals separated by non-literal text keep their order', () => { + expect(harvestCheckLiterals(`(a = 'first' OR b = 'second' OR c = 'third')`)).toEqual([ + 'first', + 'second', + 'third', + ]); + }); + }); +}); From 13f24b1c93fbd87db2959ed20ff46b6bd4d11cdf Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 11:50:24 +0200 Subject: [PATCH 2/6] test(target-postgres): pin dropped unterminated literals in harvest Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/psl-infer/harvest-check-literals.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/harvest-check-literals.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/harvest-check-literals.test.ts index d8dbe2e369f3..734e0eee919b 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/harvest-check-literals.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/harvest-check-literals.test.ts @@ -82,6 +82,14 @@ describe('harvestCheckLiterals', () => { expect(harvestCheckLiterals(`("role" = 'user'::text)`)).toEqual(['user']); }); + it('an unterminated literal is dropped', () => { + expect(harvestCheckLiterals(`(x = 'abc`)).toEqual([]); + }); + + it('a trailing lone quote after a doubled quote is dropped', () => { + expect(harvestCheckLiterals(`(x = 'a''`)).toEqual([]); + }); + it('literals separated by non-literal text keep their order', () => { expect(harvestCheckLiterals(`(a = 'first' OR b = 'second' OR c = 'third')`)).toEqual([ 'first', From f504e53035a270c96e4d5ce4e5a1f4dc7d82a8c4 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 11:56:24 +0200 Subject: [PATCH 3/6] feat(target-postgres): recover hash-verified domain enums in contract infer Path A of domain-enum inference: a live membership CHECK whose wire name verifies against the predicate re-rendered from its own harvested literals yields a top-level enum block with @@type, a column typed by its bare name, and neither @@check nor @noCheck for the proven constraint. Recovered names uniquify against the full top-level scope, whose reserved scalar-name set now derives from the type map and the target pack instead of the nine framework names. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/psl-infer/infer-enum-blocks.ts | 61 +- .../src/core/psl-infer/infer-model-blocks.ts | 45 +- .../src/core/psl-infer/infer-psl-contract.ts | 79 +- .../src/core/psl-infer/postgres-type-map.ts | 11 + .../core/psl-infer/recover-domain-enums.ts | 90 +++ .../infer-psl-contract.enum-recovery.test.ts | 742 ++++++++++++++++++ 6 files changed, 1001 insertions(+), 27 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/src/core/psl-infer/recover-domain-enums.ts create mode 100644 packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-recovery.test.ts 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 2185b3cdcd4d..a80b9bea43b4 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,16 +1,19 @@ import { toEnumMemberName, toEnumName } from '@internal/family-sql/psl-infer'; +import { collectScalarTypeConstructors } from '@internal/framework-components/authoring'; import type { PslExtensionBlock, PslExtensionBlockParamValue, } from '@internal/framework-components/psl-ast'; +import { postgresAuthoringTypes } from '../authoring'; import { buildTopLevelNameMap, createUniqueFieldName, type TopLevelNameResult, } from './infer-names'; +import { POSTGRES_PSL_TYPE_NAMES } from './postgres-type-map'; import { escapePslString, SYNTHETIC_SPAN } from './psl-literals'; -export const PSL_SCALAR_TYPE_NAMES = new Set([ +const FRAMEWORK_SCALAR_TYPE_NAMES = [ 'String', 'Boolean', 'Int', @@ -20,6 +23,20 @@ export const PSL_SCALAR_TYPE_NAMES = new Set([ 'DateTime', 'Json', 'Bytes', +] as const; + +/** + * Every name that resolves as a type in column position of inferred output: + * the framework scalars, every PSL name the Postgres type map can emit + * (`Uuid`, `VarChar`, `Timestamptz`, …), and the target pack's own zero-arg + * type constructors (`BigIntNumber`, `UnboundedInt`). An inferred enum block + * claiming one of these names would silently retype every column of that + * type, so enum naming treats the whole set as reserved. + */ +export const PSL_SCALAR_TYPE_NAMES: ReadonlySet = new Set([ + ...FRAMEWORK_SCALAR_TYPE_NAMES, + ...POSTGRES_PSL_TYPE_NAMES, + ...collectScalarTypeConstructors(postgresAuthoringTypes).keys(), ]); type NativeEnumBlockResult = { @@ -66,6 +83,48 @@ export function buildNativeEnumBlocks( return { enumNameMap, enumBlocks }; } +/** + * Builds the family `enum` extension-block AST node for a recovered domain + * enum. Members print as ` = ""` pairs (names + * deduplicated within the block, values JSON-encoded verbatim) and the block + * carries `@@type("")`. The caller owns name allocation — recovered + * names uniquify against the whole top-level scope before this is called. + */ +export function buildRecoveredEnumBlock( + name: string, + memberValues: readonly string[], + codecId: string, +): PslExtensionBlock { + const usedMemberNames = new Set(); + const parameters: Record = {}; + for (const value of memberValues) { + const memberName = createUniqueFieldName(toEnumMemberName(value), usedMemberNames); + usedMemberNames.add(memberName); + parameters[memberName] = { kind: 'value', raw: JSON.stringify(value), span: SYNTHETIC_SPAN }; + } + + return { + kind: 'enum', + keyword: 'enum', + name, + parameters, + blockAttributes: [ + { + name: 'type', + args: [ + { + kind: 'positional', + value: `"${escapePslString(codecId)}"`, + span: SYNTHETIC_SPAN, + }, + ], + span: SYNTHETIC_SPAN, + }, + ], + span: SYNTHETIC_SPAN, + }; +} + function buildNativeEnumBlock( name: string, typeName: string, 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 75605d2a5b5a..ce12c79dbad7 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 @@ -45,6 +45,19 @@ import { SYNTHETIC_SPAN, } from './psl-literals'; +/** + * A recovered domain enum, keyed by column name per table: the allocated + * top-level PSL block name the column's field is typed by, and the proven + * member values `computeDerivedCheckNames` re-renders the membership check + * from. + */ +export interface RecoveredEnumField { + readonly pslName: string; + readonly memberValues: readonly string[]; +} + +const NO_RECOVERED_ENUMS: ReadonlyMap = new Map(); + export function buildModel( table: SqlTableIR, typeMap: PslTypeMap, @@ -56,6 +69,7 @@ export function buildModel( danglingForeignKeys: readonly DanglingForeignKeyInfo[], rlsEnabled = false, policySkipNotes: readonly string[] = [], + recoveredEnums: ReadonlyMap = NO_RECOVERED_ENUMS, ): PslModel { const { name: modelName, map: mapName } = toModelName(table.name); const fieldNameMap = fieldNamesByTable.get(table.name); @@ -75,7 +89,7 @@ export function buildModel( } } - const derivedCheckNames = computeDerivedCheckNames(table); + const derivedCheckNames = computeDerivedCheckNames(table, recoveredEnums); const fields: PslField[] = []; for (const column of Object.values(table.columns)) { @@ -93,6 +107,7 @@ export function buildModel( singlePkConstraintName, uniqueColumns, derivedCheckNames, + recoveredEnums.get(column.name), ), ); } @@ -184,12 +199,17 @@ export function buildModel( * the `@noCheck` waiver below and `@@check` exclusion in `buildModel`, so the * two decisions cannot drift apart. * - * `membership` is unreachable here today: infer never emits domain enums - * (`enumType()` is not inferred), so no inferred column has member values and - * no membership check is ever derived. The day domain-enum inference exists, - * its slice extends this by threading the column's member values through. + * A `membership` check is derived only for a column Path A recovery proved: + * `recoveredEnums` carries its member values, so the re-render below produces + * the exact authored predicate and the live check's wire name lands in the + * derived set — which is what suppresses its `@@check` and its `@noCheck` + * waiver with no recovery-specific exclusion code. A column with no + * recovered enum renders no membership candidate at all. */ -function computeDerivedCheckNames(table: SqlTableIR): ReadonlySet { +function computeDerivedCheckNames( + table: SqlTableIR, + recoveredEnums: ReadonlyMap, +): ReadonlySet { const liveCheckNames = new Set((table.checks ?? []).map((check) => check.name)); const derivedCheckNames = new Set(); for (const column of Object.values(table.columns)) { @@ -197,7 +217,7 @@ function computeDerivedCheckNames(table: SqlTableIR): ReadonlySet { tableName: table.name, columnName: column.name, many: column.many === true, - memberValues: undefined, + memberValues: recoveredEnums.get(column.name)?.memberValues, })) { const derivedName = formatWireName( composeCheckWirePrefix(table.name, column.name, candidate.kind), @@ -224,6 +244,7 @@ function buildScalarField( singlePkConstraintName: string | undefined, uniqueColumns: ReadonlyMap, derivedCheckNames: ReadonlySet, + recoveredEnum: RecoveredEnumField | undefined, ): PslField { const resolvedField = fieldNameMap?.get(column.name); const fieldName = resolvedField?.fieldName ?? toFieldName(column.name).name; @@ -271,6 +292,14 @@ function buildScalarField( }; } + // A recovered domain-enum column takes the authored form: the enum's bare + // top-level name, no type constructor — the storage type comes from the + // enum block's `@@type`, not from the scalar resolution above. + if (recoveredEnum !== undefined) { + typeName = recoveredEnum.pslName; + typeConstructor = undefined; + } + const attributes: PslFieldAttribute[] = []; const isId = isSinglePk && pkColumns.has(column.name); if (isId) { @@ -329,7 +358,7 @@ function buildScalarField( tableName: table.name, columnName: column.name, many: true, - memberValues: undefined, + memberValues: recoveredEnum?.memberValues, }) .filter( (candidate) => 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 f8a935aa5769..1d8ed40ae17d 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 @@ -1,6 +1,11 @@ import type { SqlDescribedContractSpace } from '@internal/family-sql/control'; import type { EnumInfo, PslPrinterOptions } from '@internal/family-sql/psl-infer'; -import { inferRelations, parseRawDefault, toModelName } from '@internal/family-sql/psl-infer'; +import { + inferRelations, + parseRawDefault, + toEnumName, + toModelName, +} from '@internal/family-sql/psl-infer'; import { coordinateKey } from '@internal/framework-components/ir'; import type { PslDocumentAst, @@ -17,18 +22,29 @@ import { SqlSchemaIR, SqlTableIR } from '@internal/sql-schema-ir/types'; import { postgresError } from '../errors'; 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'; +import { + buildNativeEnumBlocks, + buildRecoveredEnumBlock, + PSL_SCALAR_TYPE_NAMES, +} from './infer-enum-blocks'; import { describedContractOwners, type ForeignKeyResolution, resolveForeignKeys, } from './infer-foreign-keys'; +import type { RecoveredEnumField } from './infer-model-blocks'; import { buildModel } from './infer-model-blocks'; -import { buildFieldNamesByTable, buildTopLevelNameMap, topologicalSort } from './infer-names'; +import { + buildFieldNamesByTable, + buildTopLevelNameMap, + createUniqueFieldName, + topologicalSort, +} from './infer-names'; import { buildPolicyBlocks } from './infer-policy-blocks'; import { createPostgresDefaultMapping } from './postgres-default-mapping'; import { createPostgresTypeMap } from './postgres-type-map'; import { SYNTHETIC_SPAN } from './psl-literals'; +import { recoverDomainEnumColumns } from './recover-domain-enums'; /** * Infers a PSL AST (for `printPsl`) from an introspected Postgres schema tree. @@ -308,16 +324,6 @@ export function buildPslDocumentAst( ]); const { relationsByTable } = inferRelations(schemaIR.tables, modelNameMap); - const policyEmission = buildPolicyBlocks( - rlsExtras?.policiesByTable ?? new Map(), - modelNameMap, - new Set([ - ...modelNameMap.values(), - ...bareEnumNameMap.values(), - ...topLevelExtensionBlocks.map((block) => block.name), - ]), - ); - // A caller's block shares one top-level name scope with the models, the // native enums, PSL's scalar type names, and the caller's other blocks. A // clash costs differently depending on the partner: against a scalar name @@ -325,8 +331,8 @@ export function buildPslDocumentAst( // (`psl-column-resolution` consults enums before scalars), while against a // model it is two declarations claiming one top-level name, which does not // parse back. Either way the block cannot be renamed here, so it is refused. - // Policies are absent from this set on purpose — they were handed these - // names above and have already renamed themselves out of the way. + // Policies are absent from this set on purpose — they are handed these + // names below and rename themselves out of the way. const claimedTopLevelNames = new Set([ ...PSL_SCALAR_TYPE_NAMES, ...modelNameMap.values(), @@ -345,6 +351,42 @@ export function buildPslDocumentAst( claimedTopLevelNames.add(block.name); } + // Path A domain-enum recovery. Names are allocated against the fully + // claimed top-level scope with the numeric-suffix disambiguator, so a + // recovered block can never trip the collision throw above (which stays + // for external callers of `topLevelExtensionBlocks`). + const recoveredColumnsByTable = recoverDomainEnumColumns(schemaIR.tables); + const recoveredEnumBlocks: PslExtensionBlock[] = []; + const recoveredEnumsByTable = new Map>(); + for (const table of Object.values(schemaIR.tables)) { + const recoveredColumns = recoveredColumnsByTable.get(table.name); + if (recoveredColumns === undefined) continue; + const byColumn = new Map(); + for (const column of Object.values(table.columns)) { + const entry = recoveredColumns.get(column.name); + if (entry === undefined) continue; + const pslName = createUniqueFieldName( + toEnumName(`${table.name}_${column.name}`).name, + claimedTopLevelNames, + ); + claimedTopLevelNames.add(pslName); + recoveredEnumBlocks.push(buildRecoveredEnumBlock(pslName, entry.memberValues, entry.codecId)); + byColumn.set(column.name, { pslName, memberValues: entry.memberValues }); + } + recoveredEnumsByTable.set(table.name, byColumn); + } + const allTopLevelBlocks = [...topLevelExtensionBlocks, ...recoveredEnumBlocks]; + + const policyEmission = buildPolicyBlocks( + rlsExtras?.policiesByTable ?? new Map(), + modelNameMap, + new Set([ + ...modelNameMap.values(), + ...bareEnumNameMap.values(), + ...allTopLevelBlocks.map((block) => block.name), + ]), + ); + const models: PslModel[] = []; for (const table of Object.values(schemaIR.tables)) { models.push( @@ -362,6 +404,7 @@ export function buildPslDocumentAst( danglingForeignKeysByTable.get(table.name) ?? [], rlsExtras?.rlsEnabledTables.has(table.name) ?? false, policyEmission.skipNotesByTable.get(table.name) ?? [], + recoveredEnumsByTable.get(table.name), ), ); } @@ -394,7 +437,7 @@ export function buildPslDocumentAst( const separateTopLevelBucket = namespaceName !== undefined && namespaceName !== UNSPECIFIED_PSL_NAMESPACE_ID && - topLevelExtensionBlocks.length > 0; + allTopLevelBlocks.length > 0; const namespaces: PslNamespace[] = []; if (separateTopLevelBucket) { @@ -402,7 +445,7 @@ export function buildPslDocumentAst( makePslNamespace({ kind: 'namespace', name: UNSPECIFIED_PSL_NAMESPACE_ID, - entries: makePslNamespaceEntries([], [], topLevelExtensionBlocks), + entries: makePslNamespaceEntries([], [], allTopLevelBlocks), span: SYNTHETIC_SPAN, }), ); @@ -415,7 +458,7 @@ export function buildPslDocumentAst( sortedModels, [], [ - ...(separateTopLevelBucket ? [] : topLevelExtensionBlocks), + ...(separateTopLevelBucket ? [] : allTopLevelBlocks), ...enumBlocks, ...policyEmission.blocks, ], diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts index 769786748f4e..41f419dfba65 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts @@ -52,6 +52,17 @@ const PARAMETERIZED_NATIVE_TYPES: Record = { timetz: 'Timetz', }; +/** + * Every PSL type name this map can put in column position. Derived from the + * mapping tables themselves so the reserved-name set used by enum naming can + * never drift from what inference actually emits. + */ +export const POSTGRES_PSL_TYPE_NAMES: ReadonlySet = new Set([ + ...Object.values(POSTGRES_TO_PSL), + ...Object.values(PRESERVED_NATIVE_TYPES), + ...Object.values(PARAMETERIZED_NATIVE_TYPES), +]); + const PARAMETERIZED_TYPE_PATTERN = /^(.+?)\((.+)\)$/; function getOwnMappingValue(map: Record, key: string): string | undefined { diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/recover-domain-enums.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/recover-domain-enums.ts new file mode 100644 index 000000000000..be2cf5151d99 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/recover-domain-enums.ts @@ -0,0 +1,90 @@ +import { + composeCheckWirePrefix, + computeCheckContentHash, + formatWireName, + parseWireName, +} from '@internal/sql-schema-ir/naming'; +import type { SqlTableIR } from '@internal/sql-schema-ir/types'; +import { postgresRenderCheckExpressions } from '../check-expressions'; +import { PG_CHAR_CODEC_ID, PG_TEXT_CODEC_ID, PG_VARCHAR_CODEC_ID } from '../codec-ids'; +import { harvestCheckLiterals } from './harvest-check-literals'; + +/** A column proven to carry a toolchain-derived membership check. */ +export interface RecoveredEnumColumn { + readonly memberValues: readonly string[]; + readonly codecId: string; +} + +const RECOVERABLE_NATIVE_TYPE_CODECS: Readonly> = { + text: PG_TEXT_CODEC_ID, + varchar: PG_VARCHAR_CODEC_ID, + 'character varying': PG_VARCHAR_CODEC_ID, + char: PG_CHAR_CODEC_ID, + character: PG_CHAR_CODEC_ID, +}; + +/** + * The codec id a recovered enum's `@@type` carries for a column of this + * native type, or undefined when no text-backed codec maps — an unmapped + * type is simply not recovered, never an error. Parameterized spellings + * (`varchar(20)`, `character varying(20)`) map by their base type. + */ +function recoveredEnumCodecId(nativeType: string): string | undefined { + const baseType = nativeType.match(/^(.+?)\(.+\)$/)?.[1] ?? nativeType; + return Object.hasOwn(RECOVERABLE_NATIVE_TYPE_CODECS, baseType) + ? RECOVERABLE_NATIVE_TYPE_CODECS[baseType] + : undefined; +} + +/** + * Path A verification (project spec): for each live check whose name is + * wire-shaped with the membership prefix of some column of its table, + * harvest the reprint's string literals, re-render the membership predicate + * from them through the real authoring renderer, and recompute the wire + * name. An exact full-name match proves the check was derived from a domain + * enum with exactly those member values, in that order — the hash was + * computed over the authored render, which this reconstructs byte-for-byte. + * + * Anything that fails a step — non-wire name, empty harvest, hash mismatch, + * or a column native type with no text-backed codec — recovers nothing and + * leaves the check to today's `@@check` emission. + */ +export function recoverDomainEnumColumns( + tables: Readonly>, +): ReadonlyMap> { + const recoveredByTable = new Map>(); + for (const table of Object.values(tables)) { + const recovered = new Map(); + for (const check of table.checks ?? []) { + const wire = parseWireName(check.name); + if (wire === undefined) continue; + for (const column of Object.values(table.columns)) { + if (recovered.has(column.name)) continue; + if (wire.prefix !== composeCheckWirePrefix(table.name, column.name, 'membership')) { + continue; + } + const memberValues = harvestCheckLiterals(check.expression); + if (memberValues.length === 0) continue; + const candidate = postgresRenderCheckExpressions({ + tableName: table.name, + columnName: column.name, + many: column.many === true, + memberValues, + }).find((c) => c.kind === 'membership'); + if (candidate === undefined) continue; + const derivedName = formatWireName( + wire.prefix, + computeCheckContentHash(candidate.expression), + ); + if (derivedName !== check.name) continue; + const codecId = recoveredEnumCodecId(column.nativeType); + if (codecId === undefined) continue; + recovered.set(column.name, { memberValues, codecId }); + } + } + if (recovered.size > 0) { + recoveredByTable.set(table.name, recovered); + } + } + return recoveredByTable; +} diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-recovery.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-recovery.test.ts new file mode 100644 index 000000000000..520c96aefb16 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-recovery.test.ts @@ -0,0 +1,742 @@ +/** + * Path A domain-enum recovery at the `contract infer` entry: a live CHECK + * whose wire name hash-verifies against the membership predicate re-rendered + * from its own harvested literals proves the check was derived from a domain + * enum, so infer emits a top-level `enum` block, types the column by it, and + * emits neither `@@check` nor `@noCheck` for the proven constraint. Live + * check names in every fixture are computed with the real naming helpers — + * never hand-spelled hashes. + */ +import sqlFamilyPack from '@internal/family-sql/pack'; +import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import { collectScalarTypeConstructors } from '@internal/framework-components/authoring'; +import type { Codec, CodecLookup } from '@internal/framework-components/codec'; +import { assembleAuthoringContributions } from '@internal/framework-components/control'; +import { UNSPECIFIED_PSL_NAMESPACE_ID } from '@internal/framework-components/psl-ast'; +import { buildSymbolTable } from '@internal/psl-parser'; +import { parse } from '@internal/psl-parser/syntax'; +import { printPsl } from '@internal/psl-printer'; +import { interpretPslDocumentToSqlContract } from '@internal/sql-contract-psl'; +import { + composeCheckWirePrefix, + computeCheckContentHash, + formatWireName, +} from '@internal/sql-schema-ir/naming'; +import type { SqlCheckConstraintIRInput, SqlColumnIRInput } from '@internal/sql-schema-ir/types'; +import { assert, describe, expect, it } from 'vitest'; +import { + postgresAuthoringEntityTypes, + postgresAuthoringPslBlockDescriptors, +} from '../../src/core/authoring'; +import { postgresRenderCheckExpressions } from '../../src/core/check-expressions'; +import { isPostgresSchema, postgresCreateNamespace } from '../../src/core/postgres-schema'; +import { inferPostgresPslContract } from '../../src/core/psl-infer/infer-psl-contract'; +import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; +import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; +import { PostgresNativeEnumSchemaNode } from '../../src/core/schema-ir/postgres-native-enum-schema-node'; +import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; + +// --------------------------------------------------------------------------- +// Tree fixtures +// --------------------------------------------------------------------------- + +function table( + name: string, + columns: Record, + checks: readonly SqlCheckConstraintIRInput[] = [], +) { + return new PostgresTableSchemaNode({ + name, + columns, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + checks, + policies: [], + rlsEnabled: false, + }); +} + +function namespaceNode( + schemaName: string, + tables: Record, + nativeEnums: readonly { typeName: string; values: readonly string[] }[] = [], +) { + return new PostgresNamespaceSchemaNode({ + schemaName, + tables, + nativeEnums: nativeEnums.map( + (entry) => + new PostgresNativeEnumSchemaNode({ + typeName: entry.typeName, + namespaceId: schemaName, + members: entry.values, + }), + ), + }); +} + +function tree(namespaces: Record) { + return new PostgresDatabaseSchemaNode({ + namespaces, + roles: [], + existingSchemas: Object.keys(namespaces), + pgVersion: '', + }); +} + +const idColumn: SqlColumnIRInput = { name: 'id', nativeType: 'int4', nullable: false }; + +/** + * The wire name the toolchain gave the membership check it derived for this + * column and member list — the authored render is hashed, never the reprint. + */ +function membershipWireName( + tableName: string, + columnName: string, + many: boolean, + memberValues: readonly string[], +): { prefix: string; hash: string } { + const candidate = postgresRenderCheckExpressions({ + tableName, + columnName, + many, + memberValues, + }).find((c) => c.kind === 'membership'); + assert.ok(candidate, 'membership candidate must render for a non-empty member list'); + return { + prefix: composeCheckWirePrefix(tableName, columnName, 'membership'), + hash: computeCheckContentHash(candidate.expression), + }; +} + +/** A live membership check: wire name from the real helpers, body a reprint. */ +function membershipCheck( + tableName: string, + columnName: string, + many: boolean, + memberValues: readonly string[], + reprint: string, +): SqlCheckConstraintIRInput { + const { prefix, hash } = membershipWireName(tableName, columnName, many, memberValues); + return { naming: { kind: 'wire', prefix, hash }, expression: reprint, dependsOn: undefined }; +} + +function elementNotNullCheck(tableName: string, columnName: string): SqlCheckConstraintIRInput { + const candidate = postgresRenderCheckExpressions({ + tableName, + columnName, + many: true, + memberValues: undefined, + }).find((c) => c.kind === 'elementNotNull'); + assert.ok(candidate, 'elementNotNull candidate must render for a list column'); + return { + naming: { + kind: 'wire', + prefix: composeCheckWirePrefix(tableName, columnName, 'elementNotNull'), + hash: computeCheckContentHash(candidate.expression), + }, + expression: `(array_position(${columnName}, NULL) IS NULL)`, + dependsOn: undefined, + }; +} + +// The print sites must know the family `enum` block descriptor — the +// target-only set has no descriptor for the keyword. +const printDescriptors = { + ...sqlFamilyPack.authoring.pslBlockDescriptors, + ...postgresAuthoringPslBlockDescriptors, +}; + +function inferAndPrint(dbTree: PostgresDatabaseSchemaNode): string { + return printPsl(inferPostgresPslContract(dbTree), { pslBlockDescriptors: printDescriptors }); +} + +// --------------------------------------------------------------------------- +// Recovery — positive cases +// --------------------------------------------------------------------------- + +describe('Path A recovery — text scalar', () => { + it('recovers a two-member enum: top-level block, typed column, no @@check, no @noCheck', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + accounts: table( + 'accounts', + { + id: idColumn, + role: { name: 'role', nativeType: 'text', nullable: false }, + }, + [ + membershipCheck( + 'accounts', + 'role', + false, + ['user', 'admin'], + `(role = ANY (ARRAY['user'::text, 'admin'::text]))`, + ), + ], + ), + }), + }), + ); + + expect(output).toContain('enum AccountsRole {'); + expect(output).toContain('@@type("pg/text@1")'); + expect(output).toContain('user = "user"'); + expect(output).toContain('admin = "admin"'); + expect(output).toMatch(/role\s+AccountsRole\n/); + expect(output).not.toContain('pg.enum'); + expect(output).not.toContain('@@check'); + expect(output).not.toContain('@noCheck'); + }); + + it('recovers a one-member enum whose reprint collapsed to `=`', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + accounts: table( + 'accounts', + { + id: idColumn, + role: { name: 'role', nativeType: 'text', nullable: false }, + }, + [membershipCheck('accounts', 'role', false, ['user'], `(role = 'user'::text)`)], + ), + }), + }), + ); + + expect(output).toContain('enum AccountsRole {'); + expect(output).toContain('user = "user"'); + expect(output).not.toContain('@@check'); + }); + + it('round-trips a doubled-quote member', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + people: table( + 'people', + { + id: idColumn, + surname: { name: 'surname', nativeType: 'text', nullable: false }, + }, + [ + membershipCheck( + 'people', + 'surname', + false, + [`O'Brien`, 'plain'], + `(surname = ANY (ARRAY['O''Brien'::text, 'plain'::text]))`, + ), + ], + ), + }), + }), + ); + + expect(output).toContain('enum PeopleSurname {'); + expect(output).toContain(`"O'Brien"`); + expect(output).not.toContain('@@check'); + }); +}); + +describe('Path A recovery — varchar scalar', () => { + it('recovers a varchar(20) column with the pg/varchar@1 codec', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + orders: table( + 'orders', + { + id: idColumn, + status: { name: 'status', nativeType: 'varchar(20)', nullable: false }, + }, + [ + membershipCheck( + 'orders', + 'status', + false, + ['open', 'closed'], + `((status)::text = ANY ((ARRAY['open'::character varying, 'closed'::character varying])::text[]))`, + ), + ], + ), + }), + }), + ); + + expect(output).toContain('enum OrdersStatus {'); + expect(output).toContain('@@type("pg/varchar@1")'); + expect(output).toMatch(/status\s+OrdersStatus\n/); + expect(output).not.toContain('VarChar(20)'); + expect(output).not.toContain('@@check'); + }); + + it('recovers the `character varying(20)` spelling too', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + orders: table( + 'orders', + { + id: idColumn, + status: { name: 'status', nativeType: 'character varying(20)', nullable: false }, + }, + [ + membershipCheck( + 'orders', + 'status', + false, + ['open'], + `((status)::text = 'open'::text)`, + ), + ], + ), + }), + }), + ); + + expect(output).toContain('enum OrdersStatus {'); + expect(output).toContain('@@type("pg/varchar@1")'); + }); +}); + +describe('Path A recovery — list column', () => { + it('recovers a text[] column; the live elementNotNull check is skipped without @noCheck', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + users: table( + 'users', + { + id: idColumn, + tags: { name: 'tags', nativeType: 'text', nullable: false, many: true }, + }, + [ + membershipCheck( + 'users', + 'tags', + true, + ['user', 'admin'], + `(tags <@ ARRAY['user'::text, 'admin'::text])`, + ), + elementNotNullCheck('users', 'tags'), + ], + ), + }), + }), + ); + + expect(output).toContain('enum UsersTags {'); + expect(output).toContain('@@type("pg/text@1")'); + expect(output).toMatch(/tags\s+UsersTags\[\]/); + expect(output).not.toContain('@@check'); + expect(output).not.toContain('@noCheck'); + }); +}); + +describe('Path A recovery — coexistence with a native enum', () => { + it('prints the recovered enum top-level and the native enum inside the namespace wrap', () => { + const dbTree = tree({ + public: namespaceNode( + 'public', + { + accounts: table( + 'accounts', + { + id: idColumn, + role: { name: 'role', nativeType: 'text', nullable: false }, + aal: { name: 'aal', nativeType: 'aal_level', nullable: true }, + }, + [membershipCheck('accounts', 'role', false, ['user'], `(role = 'user'::text)`)], + ), + }, + [{ typeName: 'aal_level', values: ['aal1', 'aal2'] }], + ), + }); + + const ast = inferPostgresPslContract(dbTree); + const flatBucket = ast.namespaces.find((n) => n.name === UNSPECIFIED_PSL_NAMESPACE_ID); + const namedBucket = ast.namespaces.find((n) => n.name === 'public'); + expect(Object.keys(flatBucket?.entries?.['enum'] ?? {})).toEqual(['AccountsRole']); + expect(Object.keys(namedBucket?.entries?.['native_enum'] ?? {})).toEqual(['AalLevel']); + + const output = printPsl(ast, { pslBlockDescriptors: printDescriptors }); + expect(output).toContain('enum AccountsRole {'); + expect(output).toContain('namespace public {'); + expect(output).toContain('native_enum AalLevel {'); + expect(output.indexOf('enum AccountsRole {')).toBeLessThan( + output.indexOf('namespace public {'), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Naming collisions — numeric suffix, never a throw +// --------------------------------------------------------------------------- + +describe('recovered enum naming collisions', () => { + const roleCheck = (tableName: string) => + membershipCheck(tableName, 'role', false, ['user'], `(role = 'user'::text)`); + + it('a name a model claims gets a numeric suffix', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + accounts: table( + 'accounts', + { + id: idColumn, + role: { name: 'role', nativeType: 'text', nullable: false }, + }, + [roleCheck('accounts')], + ), + accounts_role: table('accounts_role', { id: idColumn }), + }), + }), + ); + + expect(output).toContain('model AccountsRole {'); + expect(output).toContain('enum AccountsRole2 {'); + expect(output).toMatch(/role\s+AccountsRole2\n/); + }); + + it('a name a native enum claims gets a numeric suffix', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode( + 'public', + { + accounts: table( + 'accounts', + { + id: idColumn, + role: { name: 'role', nativeType: 'text', nullable: false }, + kind: { name: 'kind', nativeType: 'accounts_role', nullable: true }, + }, + [roleCheck('accounts')], + ), + }, + [{ typeName: 'accounts_role', values: ['a', 'b'] }], + ), + }), + ); + + expect(output).toContain('native_enum AccountsRole {'); + expect(output).toContain('enum AccountsRole2 {'); + }); + + it('a name equal to a target-contributed scalar type gets a numeric suffix', () => { + // toEnumName('var_char') is exactly `VarChar` — a name absent from the + // old nine-name framework set, present in the completed reserved set. + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + var: table( + 'var', + { + id: idColumn, + char: { name: 'char', nativeType: 'text', nullable: false }, + }, + [membershipCheck('var', 'char', false, ['x'], `(char = 'x'::text)`)], + ), + }), + }), + ); + + expect(output).toContain('enum VarChar2 {'); + expect(output).toMatch(/char\s+VarChar2\n/); + }); +}); + +// --------------------------------------------------------------------------- +// Negative cases — not recovered, `@@check` emits as today +// --------------------------------------------------------------------------- + +describe('Path A recovery — negative cases', () => { + it('a wire-shaped name whose hash does not verify recovers nothing', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + t: table( + 't', + { + id: idColumn, + role: { name: 'role', nativeType: 'text', nullable: false }, + }, + [ + { + naming: { + kind: 'wire', + prefix: composeCheckWirePrefix('t', 'role', 'membership'), + hash: '0a1b2c3d', + }, + expression: `(role = ANY (ARRAY['user'::text, 'admin'::text]))`, + dependsOn: undefined, + }, + ], + ), + }), + }), + ); + + expect(output).not.toContain('enum '); + expect(output).toContain( + `@@check(expression: "(role = ANY (ARRAY['user'::text, 'admin'::text]))", map: "${formatWireName(composeCheckWirePrefix('t', 'role', 'membership'), '0a1b2c3d')}")`, + ); + }); + + it('an empty harvest recovers nothing', () => { + const prefix = composeCheckWirePrefix('t', 'role', 'membership'); + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + t: table( + 't', + { + id: idColumn, + role: { name: 'role', nativeType: 'text', nullable: false }, + }, + [ + { + naming: { kind: 'wire', prefix, hash: 'deadbeef' }, + expression: '(length(role) > 0)', + dependsOn: undefined, + }, + ], + ), + }), + }), + ); + + expect(output).not.toContain('enum '); + expect(output).toContain('@@check(expression: "(length(role) > 0)"'); + }); + + it('a verified name on an unmapped native type recovers nothing', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + t: table( + 't', + { + id: idColumn, + role: { name: 'role', nativeType: 'citext', nullable: false }, + }, + [membershipCheck('t', 'role', false, ['user'], `(role = 'user'::text)`)], + ), + }), + }), + ); + + expect(output).not.toContain('enum '); + expect(output).toContain('@@check'); + }); + + it('a wire-named elementNotNull check alone triggers no recovery', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + users: table( + 'users', + { + id: idColumn, + tags: { name: 'tags', nativeType: 'text', nullable: false, many: true }, + }, + [elementNotNullCheck('users', 'tags')], + ), + }), + }), + ); + + expect(output).not.toContain('enum '); + expect(output).not.toContain('@@check'); + expect(output).not.toContain('@noCheck'); + }); + + it('a membership-prefixed check naming no column of the table is untouched', () => { + const { prefix, hash } = membershipWireName('users', 'ghost', false, ['user']); + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + users: table( + 'users', + { + id: idColumn, + role: { name: 'role', nativeType: 'text', nullable: false }, + }, + [ + { + naming: { kind: 'wire', prefix, hash }, + expression: `(role = 'user'::text)`, + dependsOn: undefined, + }, + ], + ), + }), + }), + ); + + expect(output).not.toContain('enum '); + expect(output).toContain('@@check'); + }); + + it('an input with checks but no verified membership check prints exactly as before', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + orders: table( + 'orders', + { + id: idColumn, + total: { name: 'total', nativeType: 'int4', nullable: false }, + }, + [ + { + naming: { kind: 'exact', name: 'positive_total' }, + expression: '(total > (0)::numeric)', + dependsOn: undefined, + }, + ], + ), + }), + }), + ); + + expect(output).toMatchInlineSnapshot(` + "// use prisma-next + // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. + + model Orders { + id Int @id + total Int + + @@check(expression: "(total > (0)::numeric)", map: "positive_total") + @@map("orders") + } + " + `); + }); +}); + +// --------------------------------------------------------------------------- +// Recovered output re-parses and re-interprets +// --------------------------------------------------------------------------- + +const authoringTypes = { + Int: { kind: 'typeConstructor', output: { codecId: 'pg/int4@1', nativeType: 'int4' } }, + String: { kind: 'typeConstructor', output: { codecId: 'pg/text@1', nativeType: 'text' } }, +} as const satisfies AuthoringTypeNamespace; + +const assembled = assembleAuthoringContributions([ + { authoring: sqlFamilyPack.authoring }, + { + authoring: { + entityTypes: postgresAuthoringEntityTypes, + type: authoringTypes, + pslBlockDescriptors: postgresAuthoringPslBlockDescriptors, + }, + }, +]); + +const target = { + kind: 'target' as const, + familyId: 'sql' as const, + targetId: 'postgres' as const, + id: 'postgres', + version: '0.0.1', + capabilities: {}, + defaultNamespaceId: 'public', + authoring: { type: authoringTypes }, +}; + +const textCodec: Codec = { + id: 'pg/text@1', + encode: async (v: unknown) => v, + decode: async (w: unknown) => w, + encodeJson: (value) => value as never, + decodeJson(json) { + if (typeof json !== 'string') throw new Error(`expected string, got ${typeof json}`); + return json; + }, +}; + +const codecLookup: CodecLookup = { + get: (id) => (id === 'pg/text@1' ? textCodec : undefined), + targetTypesFor: (id) => (id === 'pg/text@1' ? ['text'] : undefined), + renderOutputTypeFor: () => undefined, + descriptorFor: () => undefined, +}; + +function parseAndInterpret(source: string) { + const { document, sourceFile, diagnostics: parseDiagnostics } = parse(source); + const { table: symbolTable, diagnostics: symbolTableDiagnostics } = buildSymbolTable({ + document, + sourceFile, + pslBlockDescriptors: assembled.pslBlockDescriptors, + }); + const interpreted = interpretPslDocumentToSqlContract({ + symbolTable, + sourceFile, + sourceId: 'schema.prisma', + capabilities: {}, + target, + scalarColumnDescriptors: collectScalarTypeConstructors(authoringTypes), + authoringContributions: assembled, + composedExtensionContracts: new Map(), + createNamespace: postgresCreateNamespace, + codecLookup, + }); + return { interpreted, sourceDiagnostics: [...parseDiagnostics, ...symbolTableDiagnostics] }; +} + +describe('recovered output re-parses and re-interprets without diagnostics', () => { + it('the recovered text enum lowers to a value set and a valueSet-typed column', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + accounts: table( + 'accounts', + { + id: idColumn, + role: { name: 'role', nativeType: 'text', nullable: false }, + }, + [ + membershipCheck( + 'accounts', + 'role', + false, + ['user', 'admin'], + `(role = ANY (ARRAY['user'::text, 'admin'::text]))`, + ), + ], + ), + }), + }), + ); + + const { interpreted, sourceDiagnostics } = parseAndInterpret(output); + expect(sourceDiagnostics.map((d) => `${d.code}: ${d.message}`)).toEqual([]); + if (!interpreted.ok) { + assert.fail(interpreted.failure.diagnostics.map((d) => `${d.code}: ${d.message}`).join('\n')); + } + + const publicStorage = interpreted.value.storage.namespaces['public']; + assert.ok(isPostgresSchema(publicStorage), 'the value set must land in the public namespace'); + expect(publicStorage.valueSet?.['AccountsRole']).toMatchObject({ + values: ['user', 'admin'], + }); + expect(publicStorage.table?.['accounts']?.columns['role']).toMatchObject({ + nullable: false, + valueSet: { + plane: 'storage', + entityKind: 'valueSet', + namespaceId: 'public', + entityName: 'AccountsRole', + }, + }); + }); +}); From 1f3ef294818c29e42e7442a404128a1ca761e96b Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:09:11 +0200 Subject: [PATCH 4/6] docs(domain-enum-inference): slice 2 spec and dispatch plan Signed-off-by: willbot Signed-off-by: Will Madden --- projects/domain-enum-inference/plan.md | 4 +- .../recover-enums-from-derived-checks/plan.md | 33 +++++ .../recover-enums-from-derived-checks/spec.md | 113 ++++++++++++++++++ 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 projects/domain-enum-inference/slices/recover-enums-from-derived-checks/plan.md create mode 100644 projects/domain-enum-inference/slices/recover-enums-from-derived-checks/spec.md diff --git a/projects/domain-enum-inference/plan.md b/projects/domain-enum-inference/plan.md index 7fc827c53b9f..4bed86f3235c 100644 --- a/projects/domain-enum-inference/plan.md +++ b/projects/domain-enum-inference/plan.md @@ -8,8 +8,8 @@ Three slices. Slice 1 is a prerequisite the other two both need; slices 2 and 3 | # | Slice | Delivers | Status | | --- | --- | --- | --- | -| 1 | `top-level-blocks-in-inferred-psl` | `contract infer` can emit top-level PSL blocks alongside a namespace-wrapped one, and the reprint corpus is captured against a real database. | ⬜ to spec | -| 2 | `recover-enums-from-derived-checks` | A database Prisma Next migrated round-trips its domain enums: the harvest is hash-verified, and the enum alone re-derives the live check. | ⬜ to spec | +| 1 | `top-level-blocks-in-inferred-psl` | `contract infer` can emit top-level PSL blocks alongside a namespace-wrapped one, and the reprint corpus is captured against a real database. | ✅ merged ([#30012](https://github.com/prisma/prisma/pull/30012)) | +| 2 | `recover-enums-from-derived-checks` | A database Prisma Next migrated round-trips its domain enums: the harvest is hash-verified, and the enum alone re-derives the live check. | 🔨 in progress ([spec](./slices/recover-enums-from-derived-checks/spec.md)) | | 3 | `recover-enums-from-adopted-checks` | A never-migrated database pulls its value sets into enums, with the live constraint still declared verbatim and no duplicate derived. | ⬜ to spec | ## Sequencing diff --git a/projects/domain-enum-inference/slices/recover-enums-from-derived-checks/plan.md b/projects/domain-enum-inference/slices/recover-enums-from-derived-checks/plan.md new file mode 100644 index 000000000000..2e0df84b9c46 --- /dev/null +++ b/projects/domain-enum-inference/slices/recover-enums-from-derived-checks/plan.md @@ -0,0 +1,33 @@ +# Slice 2 — `recover-enums-from-derived-checks` — Dispatch plan + +**Spec:** [`./spec.md`](./spec.md) + +Three sequential dispatches. + +## Dispatch 1 — harvest helper + +- **Outcome:** a literal-harvest helper exists in `packages/3-targets/3-targets/postgres/src/core/psl-infer/` that extracts single-quoted literals in order (doubled quotes unescaped, everything else ignored) from a check expression, with unit tests covering every pinned reprint corpus shape plus empty/no-literal predicates. +- **Builds on:** slice 1's captured corpus (`check-introspection.integration.test.ts`). +- **Hands to:** dispatch 2 — a proven extractor whose output feeds Path A verification. +- **Focus:** the helper is a text scan, never a predicate parser. Test literals are the corpus's captured strings verbatim. +- **Gate:** `cd packages/3-targets/3-targets/postgres && pnpm test` (package suite), `pnpm typecheck`. + +## Dispatch 2 — Path A recovery and emission + +- **Outcome:** `inferPostgresPslContract` recovers a hash-verified domain enum: the enum block prints top-level, the column is typed by it, no `@@check`/`@noCheck` emits for the proven constraint, names uniquify (never throw), and the reserved scalar-name set covers the target's contributed type names. +- **Builds on:** dispatch 1's harvest helper. +- **Hands to:** dispatch 3 — recovery machinery complete at the unit level. +- **Focus:** recovery runs inside `buildPslDocumentAst` (per-column threading into `buildModel`/`buildScalarField`, member values into `computeDerivedCheckNames`). Live check names in test fixtures are computed with the real naming helpers, never hand-spelled. Negative cases: wire-shaped fake hash, empty harvest, unmapped native type, `elementNotNull` names. Byte-identical output for inputs without a verified membership check. +- **Gate:** package suite + `pnpm build` + `pnpm typecheck` + package `lint` + `pnpm lint:deps` + `node scripts/lint-casts.mjs` (delta 0). + +## Dispatch 3 — round-trip proof + +- **Outcome:** an integration/e2e test proves emit → migrate → infer returns the same enum, same member order, and a contract that verifies clean with no pending operations; recovery coexists with a native enum or RLS policy (top-level enum + wrapped rest); any positional `namespaces[0]` assertion a new fixture affects is rewritten name-based. +- **Builds on:** dispatch 2. +- **Hands to:** slice DoD; PR-open. +- **Focus:** extend `test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts` or the `packages/3-targets/6-adapters/postgres` integration suite — whichever already drives this loop. Watch for print sites lacking the family `enum` descriptor (spec § edge cases). +- **Gate:** the touched integration suites + full DoD floor (`pnpm build`, `pnpm typecheck`, `pnpm lint:deps`, `pnpm install && pnpm fixtures:check` with the double-install gotcha, cast ratchet). + +## Open items + +- Handoff hazards 3, 4 (Supabase generator), and the remaining hazard-6 print sites that only Path B can trip: deferred to slice 3 per spec § Out. diff --git a/projects/domain-enum-inference/slices/recover-enums-from-derived-checks/spec.md b/projects/domain-enum-inference/slices/recover-enums-from-derived-checks/spec.md new file mode 100644 index 000000000000..d139d384ca0a --- /dev/null +++ b/projects/domain-enum-inference/slices/recover-enums-from-derived-checks/spec.md @@ -0,0 +1,113 @@ +# Slice 2 — `recover-enums-from-derived-checks` — Spec + +**Project:** [`../../spec.md`](../../spec.md) · **Plan:** [`../../plan.md`](../../plan.md) + +## Purpose + +`contract infer` recovers a domain enum from a check constraint **Prisma Next itself created** (spec § Path A). A database migrated from a contract with `enumType()` round-trips: pull it and the enum block comes back, the column is typed by it, and the next `contract emit` re-derives the identical wire-named check — no `@@check`, no `@noCheck`, nothing pending on verify. + +This is the first slice that emits an enum, so the naming policy, member derivation, codec-id mapping, and collision handling (project spec § Locked decisions 5–8) all land here. Slice 3 reuses them. + +## Chosen design + +### The harvest + +A new helper (in `psl-infer/`) extracts candidate member values from a live check expression: + +- collect the single-quoted string literals, **in order of appearance**; +- unescape doubled quotes (`'O''Brien'` → `O'Brien`); +- everything else — casts, parentheses, operators, `ANY`/`ARRAY`/`<@`/`IN`/`=` — is ignored, not matched. + +It is a literal scan, not a parse: no predicate shape is recognized, per the project spec § The mechanism. Zero literals harvested means nothing recovered (Locked decision 1). + +### Path A verification + +For each table, for each live check whose name is wire-shaped (`parseWireName`) with prefix equal to `composeCheckWirePrefix(table, column, 'membership')` for some column of that table: + +1. Harvest the check's expression. Empty harvest → not recovered; the check stays a plain `@@check` exactly as today. +2. Render the membership candidate through `postgresRenderCheckExpressions` with `memberValues` = the harvested list and `many` = `column.many === true`. +3. `formatWireName(prefix, computeCheckContentHash(candidate.expression))` and compare to the live constraint's **full name**. Equal → proven; the harvested list is exactly what some contract declared. Not equal → not recovered here; the check stays a plain `@@check` (slice 3's Path B will pick these up). + +Both scalar and list columns are covered — the render helper already produces the `IN` and `<@` forms, and the reprint corpus pins both. + +### Emission + +A proven column yields: + +- An `enum` extension block (keyword `enum`, the family descriptor) in the **top-level flat bucket** slice 1 built: name from `toEnumName(`${table}_${column}`)`, members as ` = ""` pairs (member names deduplicated within the block via `createUniqueFieldName`, values JSON-encoded verbatim), and a `@@type("")` block attribute where the codec id comes from the column's `nativeType` (`text` → `pg/text@1`, `varchar`/`character varying` → `pg/varchar@1`, `char`/`character` → `pg/char@1`). A native type outside that map recovers nothing — safe fallback, never an error. +- The column typed by the enum's PSL name (bare name, no type constructor — the authored form a domain-enum column takes), replacing the scalar type. +- **No `@@check` and no `@noCheck`**: the harvested values are threaded into `computeDerivedCheckNames` (replacing today's `memberValues: undefined`), so the live membership check lands in the derived set and the existing `@@check` exclusion and `@noCheck` waiver logic skip it with no new exclusion code. This is the mechanism the project spec § Structural work names. + +### Where the recovery runs + +Inside `buildPslDocumentAst`, not before it. The slice-1 `topLevelExtensionBlocks` parameter delivers a *declaration*, but a recovered column must also *reference* it, and `buildModel` types columns from `typeMap`/`enumNameMap` only — there is no per-column override. So `buildPslDocumentAst` grows the recovery step internally: + +1. After model and native-enum names are allocated, walk `schemaIR.tables` and run Path A verification. +2. Allocate each recovered enum's PSL name against the claimed top-level scope (models, native enums, caller blocks, scalar type names) with `createUniqueFieldName` — the same numeric-suffix disambiguation `buildNativeEnumBlocks` uses. +3. Thread a per-table, per-column map of recovered enum names (and their member values) into `buildModel`/`buildScalarField` and into `computeDerivedCheckNames`. +4. Append the recovered blocks to the top-level extension blocks, so slice 1's bucket split applies unchanged. + +The `topLevelExtensionBlocks` parameter and its collision throw stay for external callers; recovery allocates collision-free names upstream of it, so the throw is unreachable on the infer path. + +### Collision policy: uniquify, never throw + +Project spec Locked decision 6 and its DoD line ("Enum naming never throws") govern: a recovered enum whose derived name collides with a model, native enum, scalar type name, or another recovered block gets the numeric-suffix disambiguator. The handoff's open question ("throw or uniquify") is answered by the project spec — uniquify. + +### The reserved scalar-name set is completed + +`PSL_SCALAR_TYPE_NAMES` (nine framework names) is not the whole set of names that resolve as types in column position: the target contributes more (`Uuid`, `VarChar`, `Char`, `Numeric`, `SmallInt`, `Real`, `Date`, `Time`, `Timetz`, `Timestamp`, `Timestamptz`, `Inet`, `BigIntNumber`, `UnboundedInt`, …). A recovered enum named `Uuid` would silently retype every `Uuid` column — exactly the harm the guard exists to prevent. This slice derives the reserved set from the target's authoring types (`collectScalarTypeConstructors(postgresAuthoringTypes)` keys, or an equivalent single source that cannot drift from `postgres-type-map.ts`) merged with the framework names, and the export becomes `ReadonlySet`. + +## Coherence rationale + +One reviewer sitting: the harvest helper, the verification loop, the emission threading, and the naming policy are one mechanism — each is meaningless without the others, and together they are one PR-sized diff over `psl-infer/` plus tests. Path B (slice 3) is deliberately excluded; it reuses this machinery but adds its own waiver and verbatim-`@@check` emission. + +## Scope + +**In:** + +- The harvest helper + unit tests over every corpus shape (`packages/3-targets/6-adapters/postgres/test/migrations/check-introspection.integration.test.ts` pins the literals: text one/many, varchar one/many, `<@` array, doubled-quote member). +- Path A verification, emission, per-column threading, `computeDerivedCheckNames` member-value threading, in `buildPslDocumentAst` internals. +- Naming/collision policy incl. the completed scalar-name reserve set. +- Unit tests at the `inferPostgresPslContract` level (tree fixtures, live check names computed with the real naming helpers — never hand-spelled hashes). +- The negative case: a wire-*shaped* name whose hash does not verify (the existing `t_role_check_0a1b2c3d` fixture shape) recovers nothing and its `@@check` still emits. +- Round-trip integration proof: emit a contract with `enumType()` → migrate a real database → infer → the enum block, member order, and typed column come back, and verify is clean with no pending operations (extend `infer-roundtrip-fidelity.e2e.test.ts` or the adapter integration suite — whichever already exercises this loop). +- Recovery coexists with a native enum or RLS policy on the same database: recovered enum prints top-level, the rest wraps (slice 1's seam, now carrying a real payload). + +**Out:** + +- Path B / adopted checks, `@noCheck(membership)` on scalar columns, the containment rule, and the verbatim `@@check(map:)` pairing — slice 3. +- The Supabase generator's second-bucket drop and its target-only descriptors (handoff hazards 3–4): its reference fixture's membership checks are hand-written exact names, which Path A never recovers, so those defects cannot fire in this slice. They are slice 3's to fix. +- Merging identical value sets, recovering member names, native-enum inference changes (project non-goals). +- The `__unspecified__`-named live schema quirk and the cross-namespace model-ordering defect (handoff item 8) — pre-existing, orthogonal. + +## Pre-investigated edge cases + +| Case | Behaviour | +| --- | --- | +| One-member check (`(role = 'user'::text)` reprint) | Harvest `['user']`, re-render `"role" IN ('user')` — hashes match the authored form because the hash was computed over the authored render, not the reprint. | +| Doubled-quote member (`'O''Brien'`) | Unescape on harvest; `escapeLiteral` re-doubles on render; hash matches. | +| Wire-shaped name, wrong hash (`t_role_check_0a1b2c3d`) | Not recovered; `@@check` emits as today. | +| Wrong member order in harvest | Different render → different hash → not recovered. Order-sensitivity is the proof working as designed. | +| `elementNotNull` wire-named check | Kind suffix `elem_not_null` never matches a membership prefix; ignored by recovery, still skipped from `@@check` by the existing derived-name logic. | +| Column nativeType with no codec mapping (e.g. a membership check on `citext`) | Not recovered; plain `@@check`. | +| Recovered name collides with model/native enum/scalar/another recovery | Numeric suffix; never a throw. | +| Test print sites lacking the family `enum` descriptor (handoff hazard 6) | Only fires if a fixture recovers — i.e. carries a correctly-hashed wire name. Any test this slice adds that prints must pass descriptors that include `sqlFamilyPslBlockDescriptors`' `enum`; existing fixtures cannot trip it by accident. | +| Positional `namespaces[0]` assertions (handoff hazard 7) | The split changes bucket order only when recovery fires under a wrap. New tests assert buckets **by name**, and any existing assertion a new fixture affects is updated to name-based in the same diff. | + +## Slice-specific done conditions + +- The round-trip DoD line from the project spec holds: emit → migrate → infer returns the same enum, same member order, and a contract that verifies clean with no pending operations. +- Every existing `contract infer` output without a verified membership check is byte-identical to before. + +## Open questions + +None — the throw-vs-uniquify question the handoff left open is settled by project spec Locked decision 6. + +## References + +- Project spec § Path A, § Locked decisions, § Structural work: [`../../spec.md`](../../spec.md) +- Reprint corpus: `packages/3-targets/6-adapters/postgres/test/migrations/check-introspection.integration.test.ts` +- Naming helpers: `packages/2-sql/1-core/schema-ir/src/naming.ts` (`composeCheckWirePrefix`, `computeCheckContentHash`, `formatWireName`, `parseWireName`) +- Render helper: `packages/3-targets/3-targets/postgres/src/core/check-expressions.ts` +- Emission seam: `packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts` (`buildPslDocumentAst`), `infer-model-blocks.ts`, `infer-enum-blocks.ts` +- Family `enum` block/descriptor: `packages/2-sql/9-family/src/core/authoring-entity-types.ts` From b4ab744fe0b2939cf6bb277488c89f9a7fc2ce03 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:20:11 +0200 Subject: [PATCH 5/6] fix(target-postgres): recover only exact codec target spellings; test the pack-contributed reserved name A parameterized native type (varchar(20)) no longer recovers: @@type re-emits the codec's bare target type, so recovery would silently drop the length and the planner would widen the column. Such columns keep their @@check, the same fallback as an unmapped type. Also adds a naming-collision case whose derived name (BigIntNumber) is reserved only by collectScalarTypeConstructors, so deleting that union member turns a test red. Signed-off-by: willbot Signed-off-by: Will Madden --- .../core/psl-infer/recover-domain-enums.ts | 11 ++- .../infer-psl-contract.enum-recovery.test.ts | 92 ++++++++++++++++++- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/recover-domain-enums.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/recover-domain-enums.ts index be2cf5151d99..4bd5590d075a 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/recover-domain-enums.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/recover-domain-enums.ts @@ -26,13 +26,14 @@ const RECOVERABLE_NATIVE_TYPE_CODECS: Readonly> = { /** * The codec id a recovered enum's `@@type` carries for a column of this * native type, or undefined when no text-backed codec maps — an unmapped - * type is simply not recovered, never an error. Parameterized spellings - * (`varchar(20)`, `character varying(20)`) map by their base type. + * type is simply not recovered, never an error. Only the exact codec target + * spellings map: a parameterized spelling like `varchar(20)` must keep its + * `@@check`, because `@@type` re-emits the codec's bare target type and the + * planner would widen the column to it. */ function recoveredEnumCodecId(nativeType: string): string | undefined { - const baseType = nativeType.match(/^(.+?)\(.+\)$/)?.[1] ?? nativeType; - return Object.hasOwn(RECOVERABLE_NATIVE_TYPE_CODECS, baseType) - ? RECOVERABLE_NATIVE_TYPE_CODECS[baseType] + return Object.hasOwn(RECOVERABLE_NATIVE_TYPE_CODECS, nativeType) + ? RECOVERABLE_NATIVE_TYPE_CODECS[nativeType] : undefined; } diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-recovery.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-recovery.test.ts index 520c96aefb16..c797a991286e 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-recovery.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.enum-recovery.test.ts @@ -244,7 +244,7 @@ describe('Path A recovery — text scalar', () => { }); describe('Path A recovery — varchar scalar', () => { - it('recovers a varchar(20) column with the pg/varchar@1 codec', () => { + it('recovers a bare varchar column with the pg/varchar@1 codec', () => { const output = inferAndPrint( tree({ public: namespaceNode('public', { @@ -252,7 +252,7 @@ describe('Path A recovery — varchar scalar', () => { 'orders', { id: idColumn, - status: { name: 'status', nativeType: 'varchar(20)', nullable: false }, + status: { name: 'status', nativeType: 'varchar', nullable: false }, }, [ membershipCheck( @@ -271,11 +271,10 @@ describe('Path A recovery — varchar scalar', () => { expect(output).toContain('enum OrdersStatus {'); expect(output).toContain('@@type("pg/varchar@1")'); expect(output).toMatch(/status\s+OrdersStatus\n/); - expect(output).not.toContain('VarChar(20)'); expect(output).not.toContain('@@check'); }); - it('recovers the `character varying(20)` spelling too', () => { + it('recovers the bare `character varying` spelling too', () => { const output = inferAndPrint( tree({ public: namespaceNode('public', { @@ -283,7 +282,7 @@ describe('Path A recovery — varchar scalar', () => { 'orders', { id: idColumn, - status: { name: 'status', nativeType: 'character varying(20)', nullable: false }, + status: { name: 'status', nativeType: 'character varying', nullable: false }, }, [ membershipCheck( @@ -302,6 +301,66 @@ describe('Path A recovery — varchar scalar', () => { expect(output).toContain('enum OrdersStatus {'); expect(output).toContain('@@type("pg/varchar@1")'); }); + + it('a varchar(20) column recovers nothing — recovery would drop the length', () => { + // `@@type("pg/varchar@1")` re-emits as bare `character varying`; the + // planner would then see a native-type mismatch and widen the column. + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + orders: table( + 'orders', + { + id: idColumn, + status: { name: 'status', nativeType: 'varchar(20)', nullable: false }, + }, + [ + membershipCheck( + 'orders', + 'status', + false, + ['open', 'closed'], + `((status)::text = ANY ((ARRAY['open'::character varying, 'closed'::character varying])::text[]))`, + ), + ], + ), + }), + }), + ); + + expect(output).not.toContain('enum '); + expect(output).toContain('VarChar(20)'); + expect(output).toContain('@@check'); + }); + + it('a character varying(20) column recovers nothing either', () => { + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + orders: table( + 'orders', + { + id: idColumn, + status: { name: 'status', nativeType: 'character varying(20)', nullable: false }, + }, + [ + membershipCheck( + 'orders', + 'status', + false, + ['open'], + `((status)::text = 'open'::text)`, + ), + ], + ), + }), + }), + ); + + expect(output).not.toContain('enum '); + expect(output).toContain('VarChar(20)'); + expect(output).toContain('@@check'); + }); }); describe('Path A recovery — list column', () => { @@ -450,6 +509,29 @@ describe('recovered enum naming collisions', () => { expect(output).toContain('enum VarChar2 {'); expect(output).toMatch(/char\s+VarChar2\n/); }); + + it('a name equal to a pack-contributed type constructor gets a numeric suffix', () => { + // toEnumName('big_int_number') is exactly `BigIntNumber` — a name that + // only `collectScalarTypeConstructors(postgresAuthoringTypes)` reserves; + // it appears in no type-map table. + const output = inferAndPrint( + tree({ + public: namespaceNode('public', { + big_int: table( + 'big_int', + { + id: idColumn, + number: { name: 'number', nativeType: 'text', nullable: false }, + }, + [membershipCheck('big_int', 'number', false, ['x'], `(number = 'x'::text)`)], + ), + }), + }), + ); + + expect(output).toContain('enum BigIntNumber2 {'); + expect(output).toMatch(/number\s+BigIntNumber2\n/); + }); }); // --------------------------------------------------------------------------- From 4683d6287c11a7e003b981b13b915e948078024d Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:27:27 +0200 Subject: [PATCH 6/6] =?UTF-8?q?test(integration):=20prove=20the=20domain-e?= =?UTF-8?q?num=20emit=20=E2=86=92=20migrate=20=E2=86=92=20infer=20round-tr?= =?UTF-8?q?ip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A greenfield contract with a text-backed domain enum (non-alphabetical member order) and a native enum on the same table: db init installs the derived wire-named membership check, and the re-pull recovers the same enum under its derived name, in the authored order, with the recovered block top-level and the native enum inside the namespace wrap. The re-pulled contract emits, verifies clean, and plans zero operations. Verified by disabling recovery and watching the journey fail. Signed-off-by: willbot Signed-off-by: Will Madden --- .../infer-roundtrip-fidelity.e2e.test.ts | 82 +++++++++++++++++++ .../cli-journeys/contract-domain-enum.prisma | 25 ++++++ .../test/utils/journey-test-helpers.ts | 1 + 3 files changed, 108 insertions(+) create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-domain-enum.prisma diff --git a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts index ae864d43528e..6f65f1da6c9b 100644 --- a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts +++ b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts @@ -31,10 +31,13 @@ import { parseJsonOutput, runContractEmit, runContractInfer, + runDbInit, runDbSign, runDbUpdate, runDbVerify, setupJourney, + sql, + swapPslContract, timeouts, useDevDatabase, } from '../utils/journey-test-helpers'; @@ -660,6 +663,85 @@ withTempDir(({ createTempDir }) => { ); }); + describe('Journey: emit → migrate → infer round-trips a domain enum', () => { + // Path A end to end: the toolchain itself installs the derived, + // wire-named membership check (never a precomputed name), and the re-pull + // recovers the same enum — same name, same member order — with the + // constraint proven by the content hash, not re-parsed. A native enum on + // the same table proves the recovered enum prints top-level while the + // rest stays inside the namespace wrap. + const db = useDevDatabase(); + + it( + 'the re-pull returns the authored enum in order and verifies clean with no pending operations', + async () => { + const ctx: JourneyContext = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + + swapPslContract(ctx, 'contract-domain-enum'); + + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `contract emit\n${stripAnsi(emit.stderr)}`).toBe(0); + const init = await runDbInit(ctx); + expect(init.exitCode, `db init\n${stripAnsi(init.stderr)}`).toBe(0); + + // The toolchain installed the derived wire-named membership check. + const live = await sql( + db.connectionString, + `SELECT conname FROM pg_catalog.pg_constraint WHERE contype = 'c' AND conrelid = 'accounts'::regclass`, + ); + expect(live.rows.map((row) => row['conname'])).toEqual([ + expect.stringMatching(/^accounts_role_check_[0-9a-f]{8}$/), + ]); + + const infer = await runContractInfer(ctx); + expect(infer.exitCode, `contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); + const psl = readContractPsl(ctx); + + expect(psl, 'the enum comes back under its derived name').toContain('enum AccountsRole {'); + expect(psl).toContain('@@type("pg/text@1")'); + expect(psl, 'the column is typed by the recovered enum').toMatch(/role\s+AccountsRole\n/); + expect(psl, 'the proven check emits neither @@check nor @noCheck').not.toContain('@@check'); + expect(psl).not.toContain('@noCheck'); + expect( + psl.indexOf('user = "user"'), + 'members keep the authored, non-alphabetical order', + ).toBeGreaterThan(0); + expect(psl.indexOf('user = "user"')).toBeLessThan(psl.indexOf('admin = "admin"')); + + expect(psl, 'the native enum survives inside the namespace wrap').toContain( + 'native_enum AalLevel {', + ); + expect( + psl.indexOf('enum AccountsRole {'), + 'the recovered enum prints top-level, before the namespace wrap', + ).toBeLessThan(psl.indexOf('namespace public {')); + + const emitAfterInfer = await runContractEmit(ctx); + expect( + emitAfterInfer.exitCode, + `contract emit after infer\n${stripAnsi(emitAfterInfer.stderr)}`, + ).toBe(0); + + await expectVerifiesCleanAfterPull(ctx, 'domain enum round-trip'); + + const dryRun = await runDbUpdate(ctx, ['--dry-run', '--json']); + expect(dryRun.exitCode, `db update --dry-run\n${stripAnsi(dryRun.stderr)}`).toBe(0); + const plan = parseJsonOutput<{ + readonly plan: { readonly operations: readonly { readonly id: string }[] }; + }>(dryRun); + expect( + plan.plan.operations.map((op) => op.id), + 'the re-pulled contract plans no operations', + ).toEqual([]); + }, + timeouts.spinUpPpgDev, + ); + }); + describe('Journey: a hand-written check is declared by infer and survives a destructive plan', () => { // A live check whose name is not a derived wire shape is a hand-written // constraint: infer declares it via `@@check(expression: , map: diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-domain-enum.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-domain-enum.prisma new file mode 100644 index 000000000000..298f5d318631 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-domain-enum.prisma @@ -0,0 +1,25 @@ +// use prisma-next + +// Member order is deliberately non-alphabetical: the round-trip must return +// the authored order, not a sorted one. +enum AccountsRole { + user = "user" + admin = "admin" + + @@type("pg/text@1") +} + +namespace public { + native_enum AalLevel { + aal1 = "aal1" + aal2 = "aal2" + } + + model Accounts { + id Int @id + role AccountsRole + aal pg.enum(AalLevel)? + + @@map("accounts") + } +} diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index ff97eb914389..135aee7b0c7f 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -218,6 +218,7 @@ export const pslContractFixtures = { ), 'contract-rls-adopted': join(JOURNEY_FIXTURES_DIR, 'contract-rls-adopted.prisma'), 'contract-rls-wire': join(JOURNEY_FIXTURES_DIR, 'contract-rls-wire.prisma'), + 'contract-domain-enum': join(JOURNEY_FIXTURES_DIR, 'contract-domain-enum.prisma'), } as const; export type PslContractVariant = keyof typeof pslContractFixtures;