diff --git a/apps/telemetry-backend/src/prisma/contract.d.ts b/apps/telemetry-backend/src/prisma/contract.d.ts index f042b7ee50..3da2fd6608 100644 --- a/apps/telemetry-backend/src/prisma/contract.d.ts +++ b/apps/telemetry-backend/src/prisma/contract.d.ts @@ -50,7 +50,7 @@ export type FieldOutputTypes = { readonly installationId: CodecTypes['pg/text@1']['output']; readonly version: CodecTypes['pg/text@1']['output']; readonly command: CodecTypes['pg/text@1']['output']; - readonly flags: ReadonlyArray; + readonly flags: CodecTypes['pg/jsonb@1']['output']; readonly runtimeName: CodecTypes['pg/text@1']['output']; readonly runtimeVersion: CodecTypes['pg/text@1']['output']; readonly os: CodecTypes['pg/text@1']['output']; @@ -59,7 +59,7 @@ export type FieldOutputTypes = { readonly databaseTarget: CodecTypes['pg/text@1']['output'] | null; readonly tsVersion: CodecTypes['pg/text@1']['output'] | null; readonly agent: CodecTypes['pg/text@1']['output'] | null; - readonly extensions: ReadonlyArray; + readonly extensions: CodecTypes['pg/jsonb@1']['output']; }; }; }; @@ -71,7 +71,7 @@ export type FieldInputTypes = { readonly installationId: CodecTypes['pg/text@1']['input']; readonly version: CodecTypes['pg/text@1']['input']; readonly command: CodecTypes['pg/text@1']['input']; - readonly flags: ReadonlyArray; + readonly flags: CodecTypes['pg/jsonb@1']['input']; readonly runtimeName: CodecTypes['pg/text@1']['input']; readonly runtimeVersion: CodecTypes['pg/text@1']['input']; readonly os: CodecTypes['pg/text@1']['input']; @@ -80,7 +80,7 @@ export type FieldInputTypes = { readonly databaseTarget: CodecTypes['pg/text@1']['input'] | null; readonly tsVersion: CodecTypes['pg/text@1']['input'] | null; readonly agent: CodecTypes['pg/text@1']['input'] | null; - readonly extensions: ReadonlyArray; + readonly extensions: CodecTypes['pg/jsonb@1']['input']; }; }; }; @@ -275,8 +275,7 @@ type ContractBase = Omit< }; readonly flags: { readonly nullable: false; - readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; - readonly many: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/jsonb@1' }; }; readonly runtimeName: { readonly nullable: false; @@ -312,8 +311,7 @@ type ContractBase = Omit< }; readonly extensions: { readonly nullable: false; - readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; - readonly many: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/jsonb@1' }; }; }; readonly relations: Record; diff --git a/apps/telemetry-backend/src/prisma/contract.json b/apps/telemetry-backend/src/prisma/contract.json index aea969b101..8d9fb29235 100644 --- a/apps/telemetry-backend/src/prisma/contract.json +++ b/apps/telemetry-backend/src/prisma/contract.json @@ -44,18 +44,16 @@ } }, "extensions": { - "many": true, "nullable": false, "type": { - "codecId": "pg/text@1", + "codecId": "pg/jsonb@1", "kind": "scalar" } }, "flags": { - "many": true, "nullable": false, "type": { - "codecId": "pg/text@1", + "codecId": "pg/jsonb@1", "kind": "scalar" } }, diff --git a/apps/telemetry-backend/src/prisma/contract.prisma b/apps/telemetry-backend/src/prisma/contract.prisma index d88e066164..dc7155294d 100644 --- a/apps/telemetry-backend/src/prisma/contract.prisma +++ b/apps/telemetry-backend/src/prisma/contract.prisma @@ -6,7 +6,7 @@ model TelemetryEvent { installationId String version String command String - flags String[] + flags Json runtimeName String runtimeVersion String os String @@ -15,7 +15,7 @@ model TelemetryEvent { databaseTarget String? tsVersion String? agent String? - extensions String[] + extensions Json @@index([ingestedAt]) @@map("telemetry_event") diff --git a/apps/telemetry-backend/test/handler.test.ts b/apps/telemetry-backend/test/handler.test.ts index ff3775eaa6..3d8828f51a 100644 --- a/apps/telemetry-backend/test/handler.test.ts +++ b/apps/telemetry-backend/test/handler.test.ts @@ -162,10 +162,8 @@ describe('telemetry POST /events', () => { const row = await fetchSingleRow(); expect(row.installationId).toBe(maxString); expect(row.command).toBe(maxString); - expect(row.flags).toHaveLength(64); - expect(row.flags[0]).toBe(maxArrayItem); - expect(row.extensions).toHaveLength(64); - expect(row.extensions[0]).toBe(maxArrayItem); + expect(row.flags).toEqual(manyArrayItems); + expect(row.extensions).toEqual(manyArrayItems); }); it('rejects strings beyond the configured schema bounds with 400', async () => { diff --git a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/migration.ts b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/migration.ts index 71e2a936e1..a53513d00c 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/migration.ts +++ b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/migration.ts @@ -121,15 +121,13 @@ export default class M extends Migration { schema: 'public', table: 'user', constraint: 'user_kind_check', - column: 'kind', - values: ['admin', 'user'], + payload: { kind: 'valueSet', column: 'kind', values: ['admin', 'user'] }, }), this.addCheckConstraint({ schema: 'public', table: 'post', constraint: 'post_priority_check', - column: 'priority', - values: ['low', 'high', 'urgent'], + payload: { kind: 'valueSet', column: 'priority', values: ['low', 'high', 'urgent'] }, }), this.addForeignKey({ schema: 'public', 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 04d2290575..e71d84070a 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 @@ -1,5 +1,6 @@ import type { Contract } from '@prisma-next/contract/types'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; +import type { CapabilityMatrix } from '@prisma-next/framework-components/components'; import type { AssembledAuthoringContributions, ControlMutationDefaults, @@ -45,6 +46,7 @@ export interface ContractSourceContext { readonly codecLookup: CodecLookup; readonly controlMutationDefaults: ControlMutationDefaults; readonly resolvedInputs: readonly string[]; + readonly capabilities: CapabilityMatrix; } /** Lets format-aware tooling avoid file-extension sniffing and opaque loader introspection. */ diff --git a/packages/1-framework/1-core/framework-components/src/control/control-stack.ts b/packages/1-framework/1-core/framework-components/src/control/control-stack.ts index 13cd068069..0e748186f5 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-stack.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-stack.ts @@ -1,5 +1,7 @@ import type { JsonValue } from '@prisma-next/contract/types'; import { blindCast } from '@prisma-next/utils/casts'; +import type { CapabilityMatrix } from '../shared/capabilities'; +import { mergeCapabilityMatrices } from '../shared/capabilities'; import type { Codec } from '../shared/codec'; import type { AnyCodecDescriptor } from '../shared/codec-descriptor'; import type { CodecLookup, CodecMeta, CodecRef, CodecRegistry } from '../shared/codec-types'; @@ -62,6 +64,7 @@ export interface ControlStack< readonly authoringContributions: AssembledAuthoringContributions; readonly scalarTypeDescriptors: ReadonlyMap; readonly controlMutationDefaults: ControlMutationDefaults; + readonly capabilities: CapabilityMatrix; } export interface CreateControlStackInput< @@ -522,5 +525,10 @@ export function createControlStack>; +export type CapabilityMatrix = Record>; function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/packages/1-framework/2-authoring/psl-parser/src/resolve.ts b/packages/1-framework/2-authoring/psl-parser/src/resolve.ts index bb1ea092ed..c88209fbdc 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/resolve.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/resolve.ts @@ -15,6 +15,7 @@ export interface ResolvedAttributeArg { readonly kind: 'positional' | 'named'; readonly name?: string; readonly value: string; + readonly expression?: ExpressionAst; readonly span: PslSpan; } @@ -69,10 +70,12 @@ function readResolvedArgList( const args: ResolvedAttributeArg[] = []; for (const arg of argList.args()) { const name = arg.name()?.name(); + const expression = arg.value(); args.push({ kind: name !== undefined ? 'named' : 'positional', ...(name !== undefined ? { name } : {}), - value: renderExpression(arg.value()), + value: renderExpression(expression), + ...(expression !== undefined ? { expression } : {}), span: nodePslSpan(arg.syntax, sourceFile), }); } 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 001ab88e12..a6157c6c79 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 @@ -631,6 +631,7 @@ class ControlClientImpl implements ControlClient { codecLookup: stack.codecLookup, controlMutationDefaults: stack.controlMutationDefaults, resolvedInputs: contractConfig.source.inputs ?? [], + capabilities: stack.capabilities, }; const providerResult = await contractConfig.source.load(sourceContext); if (!providerResult.ok) { 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 5a1297ae74..21aa578916 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 @@ -221,6 +221,7 @@ export async function executeContractEmit( codecLookup: stack.codecLookup, controlMutationDefaults: stack.controlMutationDefaults, resolvedInputs: contractConfig.source.inputs ?? [], + capabilities: stack.capabilities, }; startSpan(onProgress, 'resolveSource', 'Resolving contract source...'); 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 c8f1d9d4d8..562112026a 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 @@ -217,6 +217,7 @@ describe('defineConfig', () => { }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), generatorDescriptors: [] }, resolvedInputs: [], + capabilities: {}, }); expect(config.output).toBe('output/contract.json'); 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 87cdf05523..7aa5ad9f19 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 @@ -26,6 +26,7 @@ function createMongoTestContext(overrides?: Partial): Con generatorDescriptors: [], }, resolvedInputs: [], + capabilities: {}, ...overrides, }; } diff --git a/packages/2-mongo-family/2-authoring/contract-ts/test/config-types.test.ts b/packages/2-mongo-family/2-authoring/contract-ts/test/config-types.test.ts index c6ff6e458c..32744990d2 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/test/config-types.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/test/config-types.test.ts @@ -19,6 +19,7 @@ const emptyContext: ContractSourceContext = { generatorDescriptors: [], }, resolvedInputs: [], + capabilities: {}, }; function minimalMongoContract(overrides?: { diff --git a/packages/2-sql/1-core/schema-ir/src/exports/types.ts b/packages/2-sql/1-core/schema-ir/src/exports/types.ts index 6a37320b73..f2e2ac4bf6 100644 --- a/packages/2-sql/1-core/schema-ir/src/exports/types.ts +++ b/packages/2-sql/1-core/schema-ir/src/exports/types.ts @@ -3,6 +3,7 @@ export type { SqlAnnotations, SqlCheckConstraintIRInput, SqlColumnIRInput, + SqlExpressionCheckIRInput, SqlForeignKeyIRInput, SqlIndexIRInput, SqlReferentialAction, @@ -12,16 +13,20 @@ export type { SqlTypeMetadata, SqlTypeMetadataRegistry, SqlUniqueIRInput, + SqlValueSetCheckIRInput, } from '../types'; export { PrimaryKey, SqlCheckConstraintIR, SqlColumnIR, + SqlExpressionCheckIR, SqlForeignKeyIR, SqlIndexIR, SqlSchemaIR, SqlSchemaIRNode, SqlTableIR, SqlUniqueIR, + SqlValueSetCheckIR, + sqlCheckConstraintIR, } from '../types'; diff --git a/packages/2-sql/1-core/schema-ir/src/ir/sql-check-constraint-ir.ts b/packages/2-sql/1-core/schema-ir/src/ir/sql-check-constraint-ir.ts index 47625a8897..7bb5fd2514 100644 --- a/packages/2-sql/1-core/schema-ir/src/ir/sql-check-constraint-ir.ts +++ b/packages/2-sql/1-core/schema-ir/src/ir/sql-check-constraint-ir.ts @@ -1,7 +1,12 @@ -import { freezeNode } from '@prisma-next/framework-components/ir'; -import { SqlSchemaIRNode } from './sql-schema-ir-node'; +import { freezeNode, IRNodeBase } from '@prisma-next/framework-components/ir'; -export interface SqlCheckConstraintIRInput { +/** + * Input for a value-set (enum-style `IN (...)`) check constraint. Carries the + * **resolved values** rather than a raw SQL predicate so callers can compare + * value-sets without parsing SQL. + */ +export interface SqlValueSetCheckIRInput { + readonly kind: 'valueSet'; /** Constraint name as stored in the database catalog. */ readonly name: string; /** Column the check restricts. */ @@ -11,22 +16,75 @@ export interface SqlCheckConstraintIRInput { } /** - * Schema IR node for a table-level check constraint that restricts a - * column to a set of permitted values (an enum-style `IN (...)` check). + * Input for an expression check constraint that carries a canonical SQL + * predicate string (e.g. the scalar-array element-non-null check + * `array_position("tags", NULL) IS NULL`). Compared by strict string equality + * on the canonical predicate. + */ +export interface SqlExpressionCheckIRInput { + readonly kind: 'expression'; + /** Constraint name as stored in the database catalog. */ + readonly name: string; + /** Canonical SQL predicate emitted verbatim into `CHECK ()`. */ + readonly expression: string; +} + +/** + * A table-level check constraint, discriminated on `kind`: + * + * - `valueSet` — an enum-style `column IN (...)` restriction carrying the + * resolved permitted values. + * - `expression` — a canonical SQL predicate (the scalar-array + * element-non-null check) compared by strict string equality. + */ +export type SqlCheckConstraintIRInput = SqlValueSetCheckIRInput | SqlExpressionCheckIRInput; + +/** + * Schema IR node for a table-level check constraint. * - * Carries the **resolved values** rather than a raw SQL predicate so - * callers can compare value-sets without parsing SQL. + * A leaf that earns polymorphic dispatch: value-set and expression checks are + * switched over at the verifier, planner, and introspection sites, so the leaf + * carries its own literal `kind` (per the `IRNodeBase` alphabet contract). */ -export class SqlCheckConstraintIR extends SqlSchemaIRNode { +export abstract class SqlCheckConstraintIR extends IRNodeBase { + abstract override readonly kind: 'valueSet' | 'expression'; readonly name: string; + + protected constructor(name: string) { + super(); + this.name = name; + } +} + +/** Value-set (`column IN (...)`) check constraint. */ +export class SqlValueSetCheckIR extends SqlCheckConstraintIR { + override readonly kind = 'valueSet' as const; readonly column: string; readonly permittedValues: readonly string[]; - constructor(input: SqlCheckConstraintIRInput) { - super(); - this.name = input.name; + constructor(input: Omit) { + super(input.name); this.column = input.column; this.permittedValues = Object.freeze([...input.permittedValues]); freezeNode(this); } } + +/** Expression (canonical SQL predicate) check constraint. */ +export class SqlExpressionCheckIR extends SqlCheckConstraintIR { + override readonly kind = 'expression' as const; + readonly expression: string; + + constructor(input: Omit) { + super(input.name); + this.expression = input.expression; + freezeNode(this); + } +} + +/** Builds the concrete check-constraint IR node for a discriminated input. */ +export function sqlCheckConstraintIR(input: SqlCheckConstraintIRInput): SqlCheckConstraintIR { + return input.kind === 'valueSet' + ? new SqlValueSetCheckIR(input) + : new SqlExpressionCheckIR(input); +} diff --git a/packages/2-sql/1-core/schema-ir/src/ir/sql-table-ir.ts b/packages/2-sql/1-core/schema-ir/src/ir/sql-table-ir.ts index 24e11e9235..6686db8251 100644 --- a/packages/2-sql/1-core/schema-ir/src/ir/sql-table-ir.ts +++ b/packages/2-sql/1-core/schema-ir/src/ir/sql-table-ir.ts @@ -1,6 +1,10 @@ import { freezeNode } from '@prisma-next/framework-components/ir'; import { PrimaryKey, type PrimaryKeyInput } from './primary-key'; -import { SqlCheckConstraintIR, type SqlCheckConstraintIRInput } from './sql-check-constraint-ir'; +import { + SqlCheckConstraintIR, + type SqlCheckConstraintIRInput, + sqlCheckConstraintIR, +} from './sql-check-constraint-ir'; import { type SqlAnnotations, SqlColumnIR, type SqlColumnIRInput } from './sql-column-ir'; import { SqlForeignKeyIR, type SqlForeignKeyIRInput } from './sql-foreign-key-ir'; import { SqlIndexIR, type SqlIndexIRInput } from './sql-index-ir'; @@ -72,9 +76,7 @@ export class SqlTableIR extends SqlSchemaIRNode { if (input.annotations !== undefined) this.annotations = input.annotations; if (input.checks !== undefined && input.checks.length > 0) { this.checks = Object.freeze( - input.checks.map((c) => - c instanceof SqlCheckConstraintIR ? c : new SqlCheckConstraintIR(c), - ), + input.checks.map((c) => (c instanceof SqlCheckConstraintIR ? c : sqlCheckConstraintIR(c))), ); } freezeNode(this); diff --git a/packages/2-sql/1-core/schema-ir/src/types.ts b/packages/2-sql/1-core/schema-ir/src/types.ts index 7d87da3c97..e29488f75a 100644 --- a/packages/2-sql/1-core/schema-ir/src/types.ts +++ b/packages/2-sql/1-core/schema-ir/src/types.ts @@ -13,6 +13,11 @@ export { PrimaryKey, type PrimaryKeyInput } from './ir/primary-key'; export { SqlCheckConstraintIR, type SqlCheckConstraintIRInput, + SqlExpressionCheckIR, + type SqlExpressionCheckIRInput, + SqlValueSetCheckIR, + type SqlValueSetCheckIRInput, + sqlCheckConstraintIR, } from './ir/sql-check-constraint-ir'; export { type SqlAnnotations, 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 ebdedb647a..3d548b4c4f 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -24,7 +24,11 @@ import { isAuthoringPslBlockDescriptor, } from '@prisma-next/framework-components/authoring'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; -import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; +import type { + CapabilityMatrix, + ExtensionPackRef, + TargetPackRef, +} from '@prisma-next/framework-components/components'; import type { ControlMutationDefaultRegistry, ControlMutationDefaults, @@ -129,6 +133,7 @@ export interface InterpretPslDocumentToSqlContractInput { readonly createNamespace: (input: SqlNamespaceInput) => SqlNamespaceBase; readonly codecLookup?: CodecLookup; readonly seedDiagnostics?: readonly ContractSourceDiagnostic[]; + readonly capabilities: CapabilityMatrix; } function buildComposedExtensionPackRefs( @@ -443,6 +448,7 @@ interface BuildModelNodeInput { /** Resolved namespace id keyed by model name — used to stamp the target namespace on FKs. */ readonly modelNamespaceIds: ReadonlyMap; readonly enumHandles?: ReadonlyMap; + readonly capabilities: CapabilityMatrix; } interface BuildModelNodeResult { @@ -475,6 +481,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult sourceId, scalarTypeDescriptors: input.scalarTypeDescriptors, ...ifDefined('enumHandles', input.enumHandles), + capabilities: input.capabilities, }); const inlineIdFields = resolvedFields.filter((field) => field.isId); @@ -1151,6 +1158,9 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult columnName: resolvedField.columnName, descriptor: resolvedField.descriptor, nullable: resolvedField.nullable, + ...(resolvedField.many && resolvedField.valueObjectTypeName === undefined + ? { many: true as const } + : {}), ...ifDefined('default', resolvedField.defaultValue), ...ifDefined('executionDefaults', resolvedField.executionDefaults), ...ifDefined('enumTypeHandle', enumHandle), @@ -1917,6 +1927,7 @@ export function interpretPslDocumentToSqlContract( diagnostics, modelNamespaceIds, ...(enumHandlesByName.size > 0 ? { enumHandles: enumHandlesByName } : {}), + capabilities: input.capabilities, }); modelNodes.push( namespaceId !== undefined ? { ...result.modelNode, namespaceId } : result.modelNode, diff --git a/packages/2-sql/2-authoring/contract-psl/src/provider.ts b/packages/2-sql/2-authoring/contract-psl/src/provider.ts index 9080471228..95e0d27831 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/provider.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/provider.ts @@ -143,6 +143,7 @@ export function prismaContract(schemaPath: string, options: PrismaContractOption ), controlMutationDefaults: context.controlMutationDefaults, createNamespace: options.createNamespace, + capabilities: context.capabilities, codecLookup: context.codecLookup, }); if (!interpreted.ok) { diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts index 88154337fd..ea67c3fd71 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts @@ -2,6 +2,7 @@ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types' import type { ControlPolicy } from '@prisma-next/contract/types'; import type { PslSpan, ResolvedAttribute } from '@prisma-next/psl-parser'; import { parseQuotedStringLiteral } from '@prisma-next/psl-parser'; +import type { ExpressionAst } from '@prisma-next/psl-parser/syntax'; export { parseQuotedStringLiteral }; @@ -32,7 +33,7 @@ export function getNamedArgument(attribute: ResolvedAttribute, name: string): st export function getPositionalArgumentEntry( attribute: ResolvedAttribute, index = 0, -): { value: string; span: PslSpan } | undefined { +): { value: string; expression?: ExpressionAst; span: PslSpan } | undefined { const entries = attribute.args.filter((arg) => arg.kind === 'positional'); const entry = entries[index]; if (!entry || entry.kind !== 'positional') { @@ -40,6 +41,7 @@ export function getPositionalArgumentEntry( } return { value: entry.value, + ...(entry.expression !== undefined ? { expression: entry.expression } : {}), span: entry.span, }; } diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts index 2a5712b56e..6b03aa30d5 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts @@ -25,6 +25,13 @@ import type { ResolvedAttribute, ResolvedTypeConstructorCall, } from '@prisma-next/psl-parser'; +import { + ArrayLiteralAst, + BooleanLiteralExprAst, + type ExpressionAst, + NumberLiteralExprAst, + StringLiteralExprAst, +} from '@prisma-next/psl-parser/syntax'; import { blindCast } from '@prisma-next/utils/casts'; import { @@ -705,6 +712,44 @@ export function parseDefaultLiteralValue(expression: string): ColumnDefault | un return undefined; } +/** + * Result of interpreting a list-field `@default(...)` expression: + * - `{ kind: 'array', value }` — an array-literal default (possibly empty) + * - `{ kind: 'scalar' }` — a scalar literal where an array was expected + * - `undefined` — not an array of literals (e.g. a function call like `now()`), + * so the caller falls through to function-default handling. + */ +type ListDefaultParse = + | { readonly kind: 'array'; readonly value: readonly (string | number | boolean)[] } + | { readonly kind: 'scalar' } + | undefined; + +function decodeLiteralElement(element: ExpressionAst): string | number | boolean | undefined { + if (element instanceof StringLiteralExprAst) return element.value(); + if (element instanceof NumberLiteralExprAst) return element.value(); + if (element instanceof BooleanLiteralExprAst) return element.value(); + return undefined; +} + +function parseListDefaultExpression(expression: ExpressionAst | undefined): ListDefaultParse { + if (expression === undefined) return undefined; + if ( + expression instanceof StringLiteralExprAst || + expression instanceof NumberLiteralExprAst || + expression instanceof BooleanLiteralExprAst + ) { + return { kind: 'scalar' }; + } + if (!(expression instanceof ArrayLiteralAst)) return undefined; + const value: (string | number | boolean)[] = []; + for (const element of expression.elements()) { + const decoded = decodeLiteralElement(element); + if (decoded === undefined) return undefined; + value.push(decoded); + } + return { kind: 'array', value }; +} + export function lowerDefaultForField(input: { readonly modelName: string; readonly fieldName: string; @@ -714,6 +759,7 @@ export function lowerDefaultForField(input: { readonly sourceId: string; readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; readonly diagnostics: ContractSourceDiagnostic[]; + readonly isList?: boolean; }): { readonly defaultValue?: ColumnDefault; readonly executionDefaults?: ExecutionMutationDefaultPhases; @@ -742,6 +788,24 @@ export function lowerDefaultForField(input: { return {}; } + if (input.isList) { + const listParse = parseListDefaultExpression(expressionEntry.expression); + if (listParse?.kind === 'array') { + return { defaultValue: { kind: 'literal', value: [...listParse.value] } }; + } + if (listParse?.kind === 'scalar') { + input.diagnostics.push({ + code: 'PSL_LIST_DEFAULT_NOT_ARRAY', + message: `Field "${input.modelName}.${input.fieldName}" is a list and its @default must be an array literal like [] or ["a", "b"], not a scalar value.`, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + }); + return {}; + } + // Not a literal at all (e.g. a function call) — fall through to the + // function-default path, which the list execution-default guard rejects. + } + const literalDefault = parseDefaultLiteralValue(expressionEntry.value); if (literalDefault) { return { defaultValue: literalDefault }; diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts index 87cb695454..dc17051eae 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts @@ -5,6 +5,7 @@ import type { ExecutionMutationDefaultPhases, } from '@prisma-next/contract/types'; import type { AuthoringContributions } from '@prisma-next/framework-components/authoring'; +import type { CapabilityMatrix } from '@prisma-next/framework-components/components'; import type { ControlMutationDefaultRegistry, MutationDefaultGeneratorDescriptor, @@ -147,6 +148,7 @@ export interface CollectResolvedFieldsInput { readonly sourceId: string; readonly scalarTypeDescriptors: ReadonlyMap; readonly enumHandles?: ReadonlyMap; + readonly capabilities: CapabilityMatrix; } const BUILTIN_FIELD_ATTRIBUTE_NAMES: ReadonlySet = new Set([ @@ -297,6 +299,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv sourceId, scalarTypeDescriptors, enumHandles, + capabilities, } = input; const resolvedFields: ResolvedField[] = []; @@ -352,6 +355,15 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv if (isValueObjectField) { descriptor = scalarTypeDescriptors.get('Json'); } else if (isListField) { + if (capabilities['sql']?.['scalarList'] !== true) { + diagnostics.push({ + code: 'PSL_SCALAR_LIST_UNSUPPORTED_TARGET', + message: `Field "${model.name}.${field.name}" is a scalar list, but target "${targetId}" does not support scalar lists (the adapter does not report the "scalarList" capability). Remove the list or author it against a target that supports scalar lists.`, + sourceId, + span: field.span, + }); + continue; + } const resolved = resolveFieldTypeDescriptor(resolveInput); if (!resolved.ok) { if (!resolved.alreadyReported) { @@ -376,7 +388,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv continue; } scalarCodecId = resolved.descriptor.codecId; - descriptor = scalarTypeDescriptors.get('Json'); + descriptor = resolved.descriptor; } else { const resolved = resolveFieldTypeDescriptor(resolveInput); if (!resolved.ok) { @@ -442,9 +454,23 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv sourceId, defaultFunctionRegistry, diagnostics, + isList: isListField, }) : {}; const loweredOnCreate = loweredDefault.executionDefaults?.onCreate; + const loweredFunctionDefault = loweredDefault.defaultValue?.kind === 'function'; + if (isListField && (loweredOnCreate || loweredFunctionDefault)) { + const defaultExpression = + defaultAttribute?.args.find((arg) => arg.kind === 'positional')?.value.trim() ?? + 'this function'; + diagnostics.push({ + code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', + message: `Field "${model.name}.${field.name}" is a list and cannot use an execution default ("${defaultExpression}"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.`, + sourceId, + span: defaultAttribute?.span ?? field.span, + }); + continue; + } if (field.optional && loweredOnCreate) { const generatorDescription = loweredOnCreate.kind === 'generator' ? `"${loweredOnCreate.id}"` : 'for this field'; @@ -474,6 +500,15 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv diagnostics, }); let isIdField = Boolean(idAttribute); + if (idAttribute && isListField) { + diagnostics.push({ + code: 'PSL_LIST_ID_UNSUPPORTED', + message: `Field "${model.name}.${field.name}" is a list and cannot be a primary key. Remove @id; a list cannot be an identity column.`, + sourceId, + span: idAttribute.span, + }); + continue; + } if (idAttribute && field.optional) { diagnostics.push({ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', diff --git a/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts index 5824b9452b..4cb22bd029 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts @@ -18,7 +18,11 @@ describe('composed mutation default registries', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'capabilities' > & Partial>, ) => @@ -27,6 +31,7 @@ describe('composed mutation default registries', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts index df220086c9..a6286d0367 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -416,6 +416,7 @@ export function createPostgresTestContext( codecLookup: postgresCodecLookup, controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), resolvedInputs: [], + capabilities: { sql: { scalarList: true } }, ...overrides, }; } diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.capability-gating.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.capability-gating.test.ts new file mode 100644 index 0000000000..b932275a4a --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.capability-gating.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; +import { interpretPslDocumentToSqlContract } from '../src/interpreter'; +import { + createBuiltinLikeControlMutationDefaults, + modelsOf, + postgresScalarTypeDescriptors, + postgresTarget, + sqliteScalarTypeDescriptors, + sqliteTarget, + symbolTableInputFromParseArgs, +} from './fixtures'; + +const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); + +const postgresCapabilities = { sql: { scalarList: true } } as const; +const sqliteCapabilities = { sql: {} } as const; + +const listSchema = `model User { + id Int @id + tags String[] +}`; + +describe('interpretPslDocumentToSqlContract scalar-list capability gating', () => { + it('rejects a scalar list field against a target whose adapter lacks the scalarList capability', () => { + const document = symbolTableInputFromParseArgs({ + schema: listSchema, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + target: sqliteTarget, + scalarTypeDescriptors: sqliteScalarTypeDescriptors, + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, + capabilities: sqliteCapabilities, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_SCALAR_LIST_UNSUPPORTED_TARGET', + message: + 'Field "User.tags" is a scalar list, but target "sqlite" does not support scalar lists (the adapter does not report the "scalarList" capability). Remove the list or author it against a target that supports scalar lists.', + }), + ]), + ); + }); + + it('authors a scalar list field cleanly against a target whose adapter reports the scalarList capability', () => { + const document = symbolTableInputFromParseArgs({ + schema: listSchema, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, + capabilities: postgresCapabilities, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(modelsOf(result.value)).toMatchObject({ + User: { + fields: { + tags: { + nullable: false, + type: { kind: 'scalar', codecId: 'pg/text@1' }, + many: true, + }, + }, + }, + }); + }); + + it('rejects a scalar list against an empty capability matrix (fail-closed)', () => { + const document = symbolTableInputFromParseArgs({ + schema: listSchema, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, + capabilities: {}, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'PSL_SCALAR_LIST_UNSUPPORTED_TARGET' }), + ]), + ); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts index bb5cc2ed95..1331cb36aa 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts @@ -24,6 +24,7 @@ function interpretSchema(schema: string) { composedExtensionContracts: new Map(), controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); } diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts index 782ab185ef..65e7be9b0c 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts @@ -20,7 +20,11 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'capabilities' > & Partial>, ) => @@ -29,6 +33,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, ...input, }); it('lowers supported default functions into execution and storage contract shapes', () => { @@ -498,6 +503,7 @@ model UuidNativeBad { controlMutationDefaults: builtinControlMutationDefaults, authoringContributions: sqliteTemporalContributions, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts index 6945f4269d..c777722a28 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts @@ -7,18 +7,21 @@ import { import { createBuiltinLikeControlMutationDefaults, documentScopedTypes, + modelsOf, postgresScalarTypeDescriptors, postgresTarget, sqliteScalarTypeDescriptors, sqliteTarget, symbolTableInputFromParseArgs, } from './fixtures'; +import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contract-storage'; const baseInput = { target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, } as const; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); @@ -1085,6 +1088,7 @@ model User { ...document, controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(false); @@ -1121,6 +1125,7 @@ model User { ...document, controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(false); @@ -1192,3 +1197,258 @@ namespace auth { }); }); }); + +describe('interpretPslDocumentToSqlContract list-field constructs', () => { + it('rejects an execution default now() on a list field', () => { + expectDiagnosticForSchema( + `model Post { + id Int @id + tags String[] @default(now()) +} +`, + { + code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', + message: + 'Field "Post.tags" is a list and cannot use an execution default ("now()"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.', + }, + ); + }); + + it('rejects an execution default uuid() on a list field', () => { + expectDiagnosticForSchema( + `model Post { + id Int @id + tags String[] @default(uuid()) +} +`, + { + code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', + message: + 'Field "Post.tags" is a list and cannot use an execution default ("uuid()"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.', + }, + ); + }); + + it('rejects an execution default autoincrement() on a list field', () => { + expectDiagnosticForSchema( + `model Post { + id Int @id + tags Int[] @default(autoincrement()) +} +`, + { + code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', + message: + 'Field "Post.tags" is a list and cannot use an execution default ("autoincrement()"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.', + }, + ); + }); + + it('rejects @id on a list field', () => { + expectDiagnosticForSchema( + `model Post { + tags String[] @id +} +`, + { + code: 'PSL_LIST_ID_UNSUPPORTED', + message: + 'Field "Post.tags" is a list and cannot be a primary key. Remove @id; a list cannot be an identity column.', + }, + ); + }); + + it('authors a plain scalar list field with no diagnostics', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + tags String[] +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(modelsOf(result.value)).toMatchObject({ + Post: { + fields: { + tags: { + nullable: false, + type: { kind: 'scalar', codecId: 'pg/text@1' }, + many: true, + }, + }, + }, + }); + }); + + it('rejects a scalar literal default on a list field', () => { + expectDiagnosticForSchema( + `model Post { + id Int @id + tags String[] @default("x") +} +`, + { + code: 'PSL_LIST_DEFAULT_NOT_ARRAY', + message: + 'Field "Post.tags" is a list and its @default must be an array literal like [] or ["a", "b"], not a scalar value.', + }, + ); + }); + + it('rejects a scalar numeric default on a list field', () => { + expectDiagnosticForSchema( + `model Post { + id Int @id + scores Int[] @default(5) +} +`, + { + code: 'PSL_LIST_DEFAULT_NOT_ARRAY', + message: + 'Field "Post.scores" is a list and its @default must be an array literal like [] or ["a", "b"], not a scalar value.', + }, + ); + }); + + it('lowers an empty-array default on a list field to a literal empty array', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + tags String[] @default([]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(storage.namespaces['public']?.entries.table?.['post']?.columns['tags']).toMatchObject({ + nativeType: 'text', + codecId: 'pg/text@1', + many: true, + default: { kind: 'literal', value: [] }, + }); + }); + + it('lowers a literal-list default encoding each element against the element codec', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + tags String[] @default(["a", "b"]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(storage.namespaces['public']?.entries.table?.['post']?.columns['tags']).toMatchObject({ + many: true, + default: { kind: 'literal', value: ['a', 'b'] }, + }); + }); + + it('lowers a numeric-list default to a literal number array', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + scores Int[] @default([1, 2]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(storage.namespaces['public']?.entries.table?.['post']?.columns['scores']).toMatchObject({ + many: true, + default: { kind: 'literal', value: [1, 2] }, + }); + }); + + it('lowers a boolean-list default to a literal boolean array', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + flags Boolean[] @default([true, false]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(storage.namespaces['public']?.entries.table?.['post']?.columns['flags']).toMatchObject({ + many: true, + default: { kind: 'literal', value: [true, false] }, + }); + }); + + it('preserves commas inside a quoted list-default element', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + tags String[] @default(["a,b", "c"]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(storage.namespaces['public']?.entries.table?.['post']?.columns['tags']).toMatchObject({ + many: true, + default: { kind: 'literal', value: ['a,b', 'c'] }, + }); + }); +}); 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 881958c154..899aa17750 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 @@ -98,6 +98,7 @@ function interpret(schema: string, overrides?: Partial { @@ -585,6 +586,7 @@ namespace public { composedExtensionContracts: new Map(), authoringContributions, createNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts index fc0100565b..6a8b87ee25 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts @@ -102,6 +102,7 @@ const baseInput = { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, } as const; describe('un-namespaced PG model defaults to public namespace (TML-2916)', () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index 7cd9a2051f..7b49fc644a 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -21,7 +21,11 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'capabilities' > & Partial>, ) => @@ -30,6 +34,7 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts index d2aa4a706d..5ba7894b82 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts @@ -19,6 +19,7 @@ const baseInput = { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, } as const; function interpretSchema(schema: string) { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts index 62c74619f6..d335fac908 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts @@ -17,6 +17,7 @@ const baseInput = { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, } as const; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts index 3d3b3ad30a..d89863d3b0 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts @@ -32,7 +32,11 @@ describe('interpretPslDocumentToSqlContract', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'capabilities' > & Partial>, ) => @@ -42,6 +46,7 @@ describe('interpretPslDocumentToSqlContract', () => { authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, ...input, }); @@ -64,6 +69,7 @@ describe('interpretPslDocumentToSqlContract', () => { composedExtensionContracts: new Map(), controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); @@ -130,6 +136,7 @@ describe('interpretPslDocumentToSqlContract', () => { authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); @@ -154,6 +161,7 @@ describe('interpretPslDocumentToSqlContract', () => { target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), + capabilities: { sql: { scalarList: true } }, controlMutationDefaults: { defaultFunctionRegistry: new Map([ [ diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts index 3db4fb6c64..3cb8af0f31 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts @@ -17,6 +17,7 @@ const baseInput = { authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, } as const; describe('interpretPslDocumentToSqlContract types', () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts index 71a41b5e7e..0d6848bb0f 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts @@ -18,7 +18,11 @@ describe('interpretPslDocumentToSqlContract value objects and list fields', () = const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'capabilities' > & Partial>, ) => @@ -27,6 +31,7 @@ describe('interpretPslDocumentToSqlContract value objects and list fields', () = scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, ...input, }); @@ -150,7 +155,7 @@ model User { }); }); - it('emits scalar list fields with many: true', () => { + it('lowers scalar list fields to native array storage columns', () => { const document = symbolTableInputFromParseArgs({ schema: `model User { id Int @id @@ -187,8 +192,9 @@ model User { user: { columns: { tags: { - nativeType: 'jsonb', - codecId: 'pg/jsonb@1', + nativeType: 'text', + codecId: 'pg/text@1', + many: true, nullable: false, }, }, @@ -200,6 +206,57 @@ model User { }); }); + it('lowers nullable scalar list fields to native array storage columns', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model User { + id Int @id + tags String[]? +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(modelsOf(result.value)).toMatchObject({ + User: { + fields: { + tags: { + nullable: true, + type: { kind: 'scalar', codecId: 'pg/text@1' }, + many: true, + }, + }, + }, + }); + + expect(result.value.storage).toMatchObject({ + namespaces: { + public: { + entries: { + table: { + user: { + columns: { + tags: { + nativeType: 'text', + codecId: 'pg/text@1', + many: true, + nullable: true, + }, + }, + }, + }, + }, + }, + }, + }); + }); + it('emits value object list fields with many: true and valueObject domain type', () => { const document = symbolTableInputFromParseArgs({ schema: `type Address { diff --git a/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts index d7be8ada9d..d4f5faf70b 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts @@ -58,6 +58,7 @@ namespace public { composedExtensionContracts: new Map(), controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(pslResult.ok).toBe(true); @@ -183,6 +184,7 @@ namespace public { composedExtensionPacks: ['supabase'], composedExtensionContracts: new Map([['supabase', syntheticExtensionContract]]), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(pslResult.ok).toBe(true); @@ -253,6 +255,7 @@ namespace public { composedExtensionPacks: ['supabase'], composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(false); diff --git a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts index ff577264c6..8d4aeddb2e 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts @@ -379,6 +379,7 @@ describe('TS and PSL authoring parity', () => { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), authoringContributions: target.authoringContributions, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(interpreted.ok).toBe(true); @@ -430,6 +431,7 @@ model Post { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), authoringContributions, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(pslContract.ok).toBe(true); diff --git a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts index 145263ec4a..060d5b0ad2 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts @@ -73,10 +73,23 @@ function encodeColumnDefault( defaultInput: ColumnDefault, codecId: string, codecLookup?: CodecLookup, + many = false, ): ColumnDefault { if (defaultInput.kind === 'function') { return { kind: 'function', expression: defaultInput.expression }; } + if (many) { + if (!Array.isArray(defaultInput.value)) { + throw new Error( + `Literal default on a list column must be an array; received ${typeof defaultInput.value}. ` + + 'A scalar default on a list field must be rejected at the authoring surface.', + ); + } + return { + kind: 'literal', + value: defaultInput.value.map((element) => encodeViaCodec(element, codecId, codecLookup)), + }; + } return { kind: 'literal', value: encodeViaCodec(defaultInput.value, codecId, codecLookup), @@ -234,24 +247,17 @@ function buildStorageColumn( }; } - if (field.many) { - return { - nativeType: JSONB_NATIVE_TYPE, - codecId: JSONB_CODEC_ID, - nullable: field.nullable, - }; - } - const codecId = field.descriptor.codecId; const encodedDefault = field.default !== undefined - ? encodeColumnDefault(field.default, codecId, codecLookup) + ? encodeColumnDefault(field.default, codecId, codecLookup, field.many === true) : undefined; return { nativeType: field.descriptor.nativeType, codecId, nullable: field.nullable, + ...(field.many ? { many: true as const } : {}), ...ifDefined('typeParams', field.descriptor.typeParams), ...ifDefined('default', encodedDefault), ...ifDefined('typeRef', field.descriptor.typeRef), diff --git a/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts b/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts index 1021badf2a..101b625359 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts @@ -35,6 +35,7 @@ const stubContext: ContractSourceContext = { }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), generatorDescriptors: [] }, resolvedInputs: [], + capabilities: {}, }; describe('source format discriminator', () => { diff --git a/packages/2-sql/2-authoring/contract-ts/test/scalar-list-output-types.test-d.ts b/packages/2-sql/2-authoring/contract-ts/test/scalar-list-output-types.test-d.ts index 65b03f1141..2360816048 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/scalar-list-output-types.test-d.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/scalar-list-output-types.test-d.ts @@ -13,7 +13,12 @@ import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; import type { FamilyPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; -import type { ExtractCodecTypes, ExtractFieldOutputTypes } from '@prisma-next/sql-contract/types'; +import type { + ExtractCodecTypes, + ExtractFieldOutputTypes, + ExtractStorageColumnInputTypes, + ExtractStorageColumnTypes, +} from '@prisma-next/sql-contract/types'; import { expectTypeOf, test } from 'vitest'; import { field, model } from '../src/contract-builder'; import type { SqlContractResult } from '../src/contract-types'; @@ -55,6 +60,8 @@ const definition = { type Contract = SqlContractResult; type FieldTypes = ExtractFieldOutputTypes['public']['Post']; +type StorageOutputTypes = ExtractStorageColumnTypes['public']['posts']; +type StorageInputTypes = ExtractStorageColumnInputTypes['public']['posts']; test('codec types include pg/text@1', () => { expectTypeOf< @@ -73,3 +80,17 @@ test('nullable-container list field resolves to ReadonlyArray | null', ( test('scalar field is unaffected', () => { expectTypeOf().toEqualTypeOf(); }); + +test('non-null list storage column resolves to ReadonlyArray', () => { + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); +}); + +test('nullable-container list storage column resolves to ReadonlyArray | null', () => { + expectTypeOf().toEqualTypeOf | null>(); + expectTypeOf().toEqualTypeOf | null>(); +}); + +test('scalar storage column is unaffected', () => { + expectTypeOf().toEqualTypeOf(); +}); diff --git a/packages/2-sql/3-tooling/emitter/src/index.ts b/packages/2-sql/3-tooling/emitter/src/index.ts index d88d93c248..561d86baf1 100644 --- a/packages/2-sql/3-tooling/emitter/src/index.ts +++ b/packages/2-sql/3-tooling/emitter/src/index.ts @@ -466,6 +466,7 @@ function computeColumnType( if (base === undefined) { base = renderRefinedCodecType(column, side, columnTypeParams(storage, column), codecLookup); } + if (column.many === true) base = `ReadonlyArray<${base}>`; return column.nullable ? `${base} | null` : base; } diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts index 1529181f3e..453afb18f5 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts @@ -867,4 +867,56 @@ describe('StorageColumnTypes', () => { const storageColumnMatch = dts.match(/export type StorageColumnTypes = ({.+?});/s); expect(storageColumnMatch![0]).toContain("readonly level: 'low' | 'high' | 'urgent'"); }); + + it('wraps StorageColumnTypes/StorageColumnInputTypes in ReadonlyArray for a native many[] column', () => { + const contract = createContract({ + models: { + Post: { + storage: { + table: 'post', + fields: { tags: { column: 'tags' }, labels: { column: 'labels' } }, + }, + fields: { + tags: { nullable: false, many: true, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + labels: { nullable: true, many: true, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + }, + relations: {}, + }, + }, + storage: { + tables: { + post: { + columns: { + tags: { nativeType: 'text', codecId: 'pg/text@1', nullable: false, many: true }, + labels: { nativeType: 'text', codecId: 'pg/text@1', nullable: true, many: true }, + }, + primaryKey: { columns: ['tags'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }); + + const dts = generateContractDts(contract, sqlEmission, [], testHashes); + + const outputMatch = dts.match(/export type StorageColumnTypes = ({.+?});/s); + expect(outputMatch).not.toBeNull(); + expect(outputMatch![0]).toContain( + "readonly tags: ReadonlyArray", + ); + expect(outputMatch![0]).toContain( + "readonly labels: ReadonlyArray | null", + ); + + const inputMatch = dts.match(/export type StorageColumnInputTypes = ({.+?});/s); + expect(inputMatch).not.toBeNull(); + expect(inputMatch![0]).toContain( + "readonly tags: ReadonlyArray", + ); + expect(inputMatch![0]).toContain( + "readonly labels: ReadonlyArray | null", + ); + }); }); diff --git a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts index d599053d5a..a166fec503 100644 --- a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts +++ b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts @@ -188,12 +188,83 @@ export function resolveValueSetValues( function convertCheck(check: CheckConstraint, storage: SqlStorage): SqlCheckConstraintIRInput { const permittedValues = resolveValueSetValues(check.valueSet, storage, `check "${check.name}"`); return { + kind: 'valueSet', name: check.name, column: check.column, permittedValues, }; } +/** + * Quotes a SQL identifier for embedding in a canonical check predicate. Byte- + * identical to the Postgres target's `quoteIdentifier` (simple double-quote + * doubling) so the projected predicate string matches the introspection + * recognizer's re-canonicalized form exactly. + */ +function quoteCheckIdentifier(identifier: string): string { + return `"${identifier.replace(/"/g, '""')}"`; +} + +/** + * Deterministic name for the element-non-null CHECK constraint on a scalar-array + * column. The distinct `_elem_not_null` suffix avoids collision with the enum + * value-set `_check` constraints. Re-emitting the same schema produces the same + * name, so `pg_get_constraintdef`-based verify sees no drift. + */ +export function elementNonNullCheckName(tableName: string, columnName: string): string { + return `${tableName}_${columnName}_elem_not_null`; +} + +/** + * Canonical predicate enforcing that a scalar-array column carries no NULL + * element. The array column itself may be NULL (container nullability is the + * column's NOT NULL clause); `array_position` over a NULL array yields NULL, + * which a CHECK treats as satisfied, so a nullable array column is unaffected. + * + * This is the single source of truth for the element-non-null predicate: the + * projection sites below emit it, and the Postgres introspection recognizer + * re-canonicalizes the stored `pg_get_constraintdef` string back through this + * function so drift comparison is canonical-to-canonical. + */ +export function elementNonNullCheckExpression(columnName: string): string { + return `array_position(${quoteCheckIdentifier(columnName)}, NULL) IS NULL`; +} + +/** + * Projects the full set of check constraints a contract table requires into + * discriminated `SqlCheckConstraintIRInput`s: + * + * - one `valueSet` check per declared `StorageTable.checks` entry (enum-style + * `IN (...)` restrictions), and + * - one `expression` check per `many: true` column (the element-non-null + * scalar-array guard), synthesized at projection time because it is a + * Postgres physical detail rather than a Contract IR construct. + * + * This is the single lock-step projection shared by the verifier, the + * expected-IR builder, and the migration planner so all three agree on the + * exact checks (names + predicates) a table should carry. + */ +export function projectContractChecks( + table: StorageTable, + tableName: string, + storage: SqlStorage, +): SqlCheckConstraintIRInput[] { + const checks: SqlCheckConstraintIRInput[] = []; + for (const check of table.checks ?? []) { + checks.push(convertCheck(check, storage)); + } + for (const [columnName, column] of Object.entries(table.columns)) { + if (column.many === true) { + checks.push({ + kind: 'expression', + name: elementNonNullCheckName(tableName, columnName), + expression: elementNonNullCheckExpression(columnName), + }); + } + } + return checks; +} + function convertUnique(unique: UniqueConstraint): SqlUniqueIR { return { columns: unique.columns, @@ -258,10 +329,9 @@ function convertTable( satisfiedIndexColumns.add(key); } + const projectedChecks = projectContractChecks(table, name, storage); const checks: SqlCheckConstraintIRInput[] | undefined = - table.checks && table.checks.length > 0 - ? table.checks.map((c) => convertCheck(c, storage)) - : undefined; + projectedChecks.length > 0 ? projectedChecks : undefined; return { name, diff --git a/packages/2-sql/9-family/src/core/schema-verify/verify-helpers.ts b/packages/2-sql/9-family/src/core/schema-verify/verify-helpers.ts index 0f3d819b5b..e41df063f6 100644 --- a/packages/2-sql/9-family/src/core/schema-verify/verify-helpers.ts +++ b/packages/2-sql/9-family/src/core/schema-verify/verify-helpers.ts @@ -17,10 +17,12 @@ import type { } from '@prisma-next/sql-contract/types'; import type { SqlCheckConstraintIR, + SqlCheckConstraintIRInput, SqlForeignKeyIR, SqlIndexIR, SqlUniqueIR, } from '@prisma-next/sql-schema-ir/types'; +import { SqlExpressionCheckIR, SqlValueSetCheckIR } from '@prisma-next/sql-schema-ir/types'; import { emitIssueAndNodeUnderControlPolicy, emitIssueUnderControlPolicy, @@ -677,6 +679,53 @@ function valueSetsEqual(a: readonly string[], b: readonly string[]): boolean { return [...aSet].every((v) => bSet.has(v)); } +/** + * Compares two canonical check predicates by strict string equality. Both sides + * are re-canonicalized to the identical predicate form (the projection emits it, + * the introspection recognizer re-canonicalizes `pg_get_constraintdef` back to + * it), so a byte-for-byte match is the correct equality. + */ +function expressionsEqual(a: string, b: string): boolean { + return a === b; +} + +/** Human-readable label for a contract-projected check (expected side). */ +function contractCheckLabel(check: SqlCheckConstraintIRInput): string { + return check.kind === 'valueSet' ? check.permittedValues.join(', ') : check.expression; +} + +/** Human-readable label for an introspected live check (actual side). */ +function liveCheckLabel(check: SqlCheckConstraintIR): string { + if (check instanceof SqlValueSetCheckIR) return check.permittedValues.join(', '); + if (check instanceof SqlExpressionCheckIR) return check.expression; + return ''; +} + +/** Predicate describing what a contract-projected check requires (for messages). */ +function contractCheckDescription(check: SqlCheckConstraintIRInput): string { + return check.kind === 'valueSet' + ? `column "${check.column}" IN (${check.permittedValues.join(', ')})` + : `CHECK (${check.expression})`; +} + +/** + * Whether an introspected live check has the same content as the contract check. + * Cross-kind (e.g. contract expects a value-set, live is an expression) is never + * equal — a mismatch is reported so the planner can drop+recreate. + */ +export function checkContentEqual( + contractCheck: SqlCheckConstraintIRInput, + liveCheck: SqlCheckConstraintIR, +): boolean { + if (contractCheck.kind === 'valueSet' && liveCheck instanceof SqlValueSetCheckIR) { + return valueSetsEqual(contractCheck.permittedValues, liveCheck.permittedValues); + } + if (contractCheck.kind === 'expression' && liveCheck instanceof SqlExpressionCheckIR) { + return expressionsEqual(contractCheck.expression, liveCheck.expression); + } + return false; +} + /** * Verifies check constraints match between contract-projected checks and * introspected live checks. @@ -696,11 +745,7 @@ function valueSetsEqual(a: readonly string[], b: readonly string[]): boolean { * verification (the normal path) does not complain about extra constraints. */ export function verifyCheckConstraints( - contractChecks: ReadonlyArray<{ - readonly name: string; - readonly column: string; - readonly permittedValues: readonly string[]; - }>, + contractChecks: ReadonlyArray, schemaChecks: ReadonlyArray, tableName: string, namespaceId: string, @@ -721,8 +766,8 @@ export function verifyCheckConstraints( table: tableName, namespaceId, indexOrConstraint: contractCheck.name, - expected: contractCheck.permittedValues.join(', '), - message: `Table "${tableName}" is missing check constraint "${contractCheck.name}" (column "${contractCheck.column}" IN (${contractCheck.permittedValues.join(', ')}))`, + expected: contractCheckLabel(contractCheck), + message: `Table "${tableName}" is missing check constraint "${contractCheck.name}" (${contractCheckDescription(contractCheck)})`, }; emitIssueAndNodeUnderControlPolicy( tableControlPolicy, @@ -741,15 +786,15 @@ export function verifyCheckConstraints( issues, nodes, ); - } else if (!valueSetsEqual(contractCheck.permittedValues, liveCheck.permittedValues)) { + } else if (!checkContentEqual(contractCheck, liveCheck)) { const issue: SchemaIssue = { kind: 'check_mismatch', table: tableName, namespaceId, indexOrConstraint: contractCheck.name, - expected: contractCheck.permittedValues.join(', '), - actual: liveCheck.permittedValues.join(', '), - message: `Table "${tableName}" check constraint "${contractCheck.name}" has different permitted values: expected [${contractCheck.permittedValues.join(', ')}], got [${liveCheck.permittedValues.join(', ')}]`, + expected: contractCheckLabel(contractCheck), + actual: liveCheckLabel(liveCheck), + message: `Table "${tableName}" check constraint "${contractCheck.name}" differs: expected [${contractCheckLabel(contractCheck)}], got [${liveCheckLabel(liveCheck)}]`, }; emitIssueAndNodeUnderControlPolicy( tableControlPolicy, @@ -792,7 +837,7 @@ export function verifyCheckConstraints( table: tableName, namespaceId, indexOrConstraint: liveCheck.name, - actual: liveCheck.permittedValues.join(', '), + actual: liveCheckLabel(liveCheck), message: `Table "${tableName}" has extra check constraint "${liveCheck.name}" in database (not in contract)`, }; emitIssueAndNodeUnderControlPolicy( diff --git a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts index c84de38d81..0f08580fcd 100644 --- a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts +++ b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts @@ -29,7 +29,7 @@ import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { canonicalStringify } from '@prisma-next/utils/canonical-stringify'; import { ifDefined } from '@prisma-next/utils/defined'; import { extractCodecControlHooks } from '../assembly'; -import { resolveValueSetValues } from '../migrations/contract-to-schema-ir'; +import { projectContractChecks } from '../migrations/contract-to-schema-ir'; import type { CodecControlHooks } from '../migrations/types'; import { emitIssueAndNodeUnderControlPolicy } from './control-verify-emit'; import { verifierDisposition } from './verifier-disposition'; @@ -591,11 +591,7 @@ function verifyTableChildren(options: { // Verify check constraints when the contract declares checks for this table OR // when strict mode is on (so extra live checks on zero-check tables are detected). // schemaTable.checks carries the introspected live checks (parsed value sets). - const contractCheckIRs = (contractTable.checks ?? []).map((c) => ({ - name: c.name, - column: c.column, - permittedValues: resolveValueSetValues(c.valueSet, contractStorage, `check "${c.name}"`), - })); + const contractCheckIRs = projectContractChecks(contractTable, tableName, contractStorage); if (strict || contractCheckIRs.length > 0) { const checkStatuses = verifyCheckConstraints( contractCheckIRs, diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 412e0687bf..81881bec55 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -24,6 +24,9 @@ export type { export { contractToSchemaIR, detectDestructiveChanges, + elementNonNullCheckExpression, + elementNonNullCheckName, + projectContractChecks, resolveValueSetValues, } from '../core/migrations/contract-to-schema-ir'; export type { ControlPolicySubject } from '../core/migrations/control-policy'; diff --git a/packages/2-sql/9-family/src/exports/schema-verify.ts b/packages/2-sql/9-family/src/exports/schema-verify.ts index 04ecacd7a5..262415f429 100644 --- a/packages/2-sql/9-family/src/exports/schema-verify.ts +++ b/packages/2-sql/9-family/src/exports/schema-verify.ts @@ -8,6 +8,7 @@ export { arraysEqual, + checkContentEqual, isIndexSatisfied, isUniqueConstraintSatisfied, } from '../core/schema-verify/verify-helpers'; diff --git a/packages/2-sql/9-family/test/schema-verify.check-constraints.test.ts b/packages/2-sql/9-family/test/schema-verify.check-constraints.test.ts index acb0d42677..0791a9027b 100644 --- a/packages/2-sql/9-family/test/schema-verify.check-constraints.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.check-constraints.test.ts @@ -8,7 +8,7 @@ import { StorageTable, StorageValueSet, } from '@prisma-next/sql-contract/types'; -import { SqlCheckConstraintIR } from '@prisma-next/sql-schema-ir/types'; +import { type SqlCheckConstraintIR, sqlCheckConstraintIR } from '@prisma-next/sql-schema-ir/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; @@ -26,7 +26,11 @@ import { // --------------------------------------------------------------------------- function makeContractCheck(name: string, column: string, permittedValues: readonly string[]) { - return { name, column, permittedValues }; + return { kind: 'valueSet' as const, name, column, permittedValues }; +} + +function makeExpressionContractCheck(name: string, expression: string) { + return { kind: 'expression' as const, name, expression }; } function makeSchemaCheck( @@ -34,7 +38,11 @@ function makeSchemaCheck( column: string, permittedValues: readonly string[], ): SqlCheckConstraintIR { - return new SqlCheckConstraintIR({ name, column, permittedValues }); + return sqlCheckConstraintIR({ kind: 'valueSet', name, column, permittedValues }); +} + +function makeExpressionSchemaCheck(name: string, expression: string): SqlCheckConstraintIR { + return sqlCheckConstraintIR({ kind: 'expression', name, expression }); } describe('verifyCheckConstraints', () => { @@ -170,6 +178,100 @@ describe('verifyCheckConstraints', () => { }); }); +describe('verifyCheckConstraints — expression checks', () => { + const EXPR = 'array_position("tags", NULL) IS NULL'; + + it('passes when an expression check matches the live predicate', () => { + const issues: SchemaIssue[] = []; + const nodes = verifyCheckConstraints( + [makeExpressionContractCheck('post_tags_elem_not_null', EXPR)], + [makeExpressionSchemaCheck('post_tags_elem_not_null', EXPR)], + 'post', + UNBOUND_NAMESPACE_ID, + 'namespaces[__unbound__].tables[post]', + 'managed', + issues, + false, + ); + expect(issues).toHaveLength(0); + expect(nodes[0]?.status).toBe('pass'); + }); + + it('emits check_missing when an expression check is absent from the live schema', () => { + const issues: SchemaIssue[] = []; + verifyCheckConstraints( + [makeExpressionContractCheck('post_tags_elem_not_null', EXPR)], + [], + 'post', + UNBOUND_NAMESPACE_ID, + 'namespaces[__unbound__].tables[post]', + 'managed', + issues, + false, + ); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + kind: 'check_missing', + table: 'post', + expected: EXPR, + }); + expect(issues[0]?.message).toContain(`CHECK (${EXPR})`); + }); + + it('emits check_mismatch when the live expression predicate differs', () => { + const issues: SchemaIssue[] = []; + verifyCheckConstraints( + [makeExpressionContractCheck('post_tags_elem_not_null', EXPR)], + [ + makeExpressionSchemaCheck( + 'post_tags_elem_not_null', + 'array_position("labels", NULL) IS NULL', + ), + ], + 'post', + UNBOUND_NAMESPACE_ID, + 'namespaces[__unbound__].tables[post]', + 'managed', + issues, + false, + ); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ kind: 'check_mismatch', table: 'post' }); + }); + + it('emits check_removed in strict mode for an extra live expression check', () => { + const issues: SchemaIssue[] = []; + verifyCheckConstraints( + [], + [makeExpressionSchemaCheck('post_tags_elem_not_null', EXPR)], + 'post', + UNBOUND_NAMESPACE_ID, + 'namespaces[__unbound__].tables[post]', + 'managed', + issues, + true, + ); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ kind: 'check_removed', table: 'post', actual: EXPR }); + }); + + it('emits check_mismatch when a contract expression check meets a live value-set check (cross-kind)', () => { + const issues: SchemaIssue[] = []; + verifyCheckConstraints( + [makeExpressionContractCheck('post_tags_elem_not_null', EXPR)], + [makeSchemaCheck('post_tags_elem_not_null', 'tags', ['a', 'b'])], + 'post', + UNBOUND_NAMESPACE_ID, + 'namespaces[__unbound__].tables[post]', + 'managed', + issues, + false, + ); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ kind: 'check_mismatch', table: 'post' }); + }); +}); + // --------------------------------------------------------------------------- // classifySqlVerifierIssueKind — check constraint kinds // --------------------------------------------------------------------------- @@ -256,7 +358,8 @@ describe('verifySqlSchema — check constraints', () => { post: { ...createSchemaTable('post', { status: { nativeType: 'text', nullable: false } }), checks: [ - new SqlCheckConstraintIR({ + sqlCheckConstraintIR({ + kind: 'valueSet', name: 'post_status_check', column: 'status', permittedValues: ['draft', 'published'], @@ -337,7 +440,8 @@ describe('verifySqlSchema — check constraints', () => { post: { ...createSchemaTable('post', { status: { nativeType: 'text', nullable: false } }), checks: [ - new SqlCheckConstraintIR({ + sqlCheckConstraintIR({ + kind: 'valueSet', name: 'post_status_check', column: 'status', permittedValues: ['draft', 'published'], diff --git a/packages/3-extensions/postgres/test/psl-namespace-qualifier-routing.test.ts b/packages/3-extensions/postgres/test/psl-namespace-qualifier-routing.test.ts index 8d3ad81290..eac4fc4b99 100644 --- a/packages/3-extensions/postgres/test/psl-namespace-qualifier-routing.test.ts +++ b/packages/3-extensions/postgres/test/psl-namespace-qualifier-routing.test.ts @@ -70,6 +70,7 @@ describe('PSL → SqlStorage.namespaces qualifier routing (FR15 slice 3 + FR16a scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); if (!result.ok) { @@ -106,6 +107,7 @@ describe('PSL → SqlStorage.namespaces qualifier routing (FR15 slice 3 + FR16a scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); if (!result.ok) { @@ -135,6 +137,7 @@ describe('PSL → SqlStorage.namespaces qualifier routing (FR15 slice 3 + FR16a scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); if (!result.ok) { diff --git a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts index 49954f23c5..54dbd1f419 100644 --- a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts +++ b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts @@ -58,6 +58,63 @@ function canonicalizeTimestampDefault(expr: string): string | undefined { return undefined; } +type ArrayElementToken = { readonly value: string; readonly quoted: boolean }; + +/** + * Splits a Postgres array literal body (without the enclosing braces) into its + * element tokens, honouring quoting. A comma only separates elements when it is + * outside double quotes; inside a quoted element a doubled quote (`""`) or a + * backslash-escaped quote (`\"`) is a literal quote, and a backslash escapes the + * next character. Returns undefined if the body is malformed (e.g. an unbalanced + * quote). + */ +function splitArrayElements(inner: string): readonly ArrayElementToken[] | undefined { + const tokens: ArrayElementToken[] = []; + let current = ''; + let inQuotes = false; + let quoted = false; + + for (let i = 0; i < inner.length; i++) { + const char = inner[i]; + if (inQuotes) { + if (char === '\\') { + const next = inner[i + 1]; + if (next === undefined) return undefined; + current += next; + i++; + continue; + } + if (char === '"') { + if (inner[i + 1] === '"') { + current += '"'; + i++; + continue; + } + inQuotes = false; + continue; + } + current += char; + continue; + } + if (char === '"') { + inQuotes = true; + quoted = true; + continue; + } + if (char === ',') { + tokens.push({ value: current, quoted }); + current = ''; + quoted = false; + continue; + } + current += char; + } + + if (inQuotes) return undefined; + tokens.push({ value: current, quoted }); + return tokens; +} + /** * Parses a Postgres array literal body (`{...}`) into a JS array of primitives. * Returns undefined if the body cannot be reliably parsed. @@ -65,14 +122,23 @@ function canonicalizeTimestampDefault(expr: string): string | undefined { * Handles: * - `{}` → `[]` * - `{elem1,elem2,...}` → `[elem1, elem2, ...]` with numeric and string element coercion + * - quoted elements that contain commas, doubled/escaped quotes, and the literal + * strings `NULL`/`true`/`false` (a quoted token is always a string) */ function parseArrayLiteralBody(body: string): readonly JsonValue[] | undefined { const inner = body.slice(1, -1).trim(); if (inner === '') return []; - const elements = inner.split(','); + const tokens = splitArrayElements(inner); + if (tokens === undefined) return undefined; const result: JsonValue[] = []; - for (const rawEl of elements) { - const el = rawEl.trim(); + for (const token of tokens) { + if (token.quoted) { + // A quoted token is always a string — `"NULL"`, `"true"`, `"1"` are the + // literal text, never the keyword/number. + result.push(token.value); + continue; + } + const el = token.value.trim(); if (el.toUpperCase() === 'NULL') { result.push(null); continue; @@ -89,10 +155,6 @@ function parseArrayLiteralBody(body: string): readonly JsonValue[] | undefined { result.push(Number(el)); continue; } - if (el.startsWith('"') && el.endsWith('"')) { - result.push(el.slice(1, -1).replace(/""/g, '"')); - continue; - } return undefined; } return result; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 7404386c2b..4402887dc8 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts @@ -257,13 +257,21 @@ function mapIssueToCall( const ddlColumns: DdlColumn[] = Object.entries(contractTable.columns).map(([name, column]) => toDdlColumn(name, column, codecHooks, storageTypes), ); - const ddlConstraints: DdlTableConstraint[] | undefined = contractTable.primaryKey + const primaryKeyConstraints: DdlTableConstraint[] = contractTable.primaryKey ? [ contractFree.primaryKey(contractTable.primaryKey.columns, { ...(contractTable.primaryKey.name ? { name: contractTable.primaryKey.name } : {}), }), ] - : undefined; + : []; + // Element-non-null CHECKs for scalar-array columns are no longer inlined + // into CREATE TABLE: they flow through the shared check projection → + // `checkConstraintPlanCallStrategy` → `AddCheckConstraintCall` (expression), + // emitted as an ALTER after the table is created (the addCheckConstraint + // bucket sorts after the table bucket), so they are drift-managed like the + // enum value-set checks. + const ddlConstraints: DdlTableConstraint[] | undefined = + primaryKeyConstraints.length > 0 ? primaryKeyConstraints : undefined; const calls: PostgresOpFactoryCall[] = [ new CreateTableCall(schemaForTable, issue.table, ddlColumns, ddlConstraints), ]; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts index 386a8816b4..bad7d98964 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts @@ -991,30 +991,44 @@ export class DropConstraintCall extends PostgresOpFactoryCallNode { } } +/** + * Discriminated payload for a check constraint the planner adds: + * + * - `valueSet` — an enum-style `column IN (...)` restriction, and + * - `expression` — a canonical SQL predicate (the scalar-array element-non-null + * guard), emitted verbatim into `CHECK ()`. + */ +export type AddCheckConstraintPayload = + | { readonly kind: 'valueSet'; readonly column: string; readonly values: readonly string[] } + | { readonly kind: 'expression'; readonly expression: string }; + +function checkPayloadLabelSuffix(tableName: string, payload: AddCheckConstraintPayload): string { + return payload.kind === 'valueSet' + ? `on "${tableName}"."${payload.column}"` + : `on "${tableName}"`; +} + export class AddCheckConstraintCall extends PostgresOpFactoryCallNode { readonly factoryName = 'addCheckConstraint' as const; readonly operationClass = 'additive' as const; readonly schemaName: string; readonly tableName: string; readonly constraintName: string; - readonly column: string; - readonly values: readonly string[]; + readonly payload: AddCheckConstraintPayload; readonly label: string; constructor( schemaName: string, tableName: string, constraintName: string, - column: string, - values: readonly string[], + payload: AddCheckConstraintPayload, ) { super(); this.schemaName = schemaName; this.tableName = tableName; this.constraintName = constraintName; - this.column = column; - this.values = values; - this.label = `Add check constraint "${constraintName}" on "${tableName}"."${column}"`; + this.payload = payload; + this.label = `Add check constraint "${constraintName}" ${checkPayloadLabelSuffix(tableName, payload)}`; this.freeze(); } @@ -1028,14 +1042,13 @@ export class AddCheckConstraintCall extends PostgresOpFactoryCallNode { this.schemaName, this.tableName, this.constraintName, - this.column, - this.values, + this.payload, lowerer, ); } renderTypeScript(): string { - return `this.addCheckConstraint({ ${constraintCallOptions(this.schemaName, this.tableName, this.constraintName)}, column: ${jsonToTsSource(this.column)}, values: ${jsonToTsSource(this.values)} })`; + return `this.addCheckConstraint({ ${constraintCallOptions(this.schemaName, this.tableName, this.constraintName)}, payload: ${jsonToTsSource(this.payload)} })`; } override importRequirements(): readonly ImportRequirement[] { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts index d35747ec78..86a4bd2092 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts @@ -2,9 +2,19 @@ import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adap import { REFERENTIAL_ACTION_SQL } from '@prisma-next/sql-contract/referential-action-sql'; import { constraintExistsAst } from '../../../contract-free/checks'; import { escapeLiteral, quoteIdentifier } from '../../sql-utils'; +import type { AddCheckConstraintPayload } from '../op-factory-call'; import { qualifyTableName } from '../planner-sql-checks'; import { type ForeignKeySpec, type Op, step, targetDetails } from './shared'; +/** Renders the `CHECK (...)` body for a discriminated check-constraint payload. */ +function renderCheckPredicate(payload: AddCheckConstraintPayload): string { + if (payload.kind === 'valueSet') { + const valueList = payload.values.map((v) => `'${escapeLiteral(v)}'`).join(', '); + return `${quoteIdentifier(payload.column)} IN (${valueList})`; + } + return payload.expression; +} + async function constraintCheckSteps( lowerer: ExecuteRequestLowerer, options: { constraintName: string; schema: string; table: string }, @@ -133,12 +143,13 @@ export async function addCheckConstraint( schemaName: string, tableName: string, constraintName: string, - column: string, - values: readonly string[], + payload: AddCheckConstraintPayload, lowerer: ExecuteRequestLowerer, ): Promise { const qualified = qualifyTableName(schemaName, tableName); - const valueList = values.map((v) => `'${escapeLiteral(v)}'`).join(', '); + const predicate = renderCheckPredicate(payload); + const labelSuffix = + payload.kind === 'valueSet' ? `on "${tableName}"."${payload.column}"` : `on "${tableName}"`; const { absent, present } = await constraintCheckSteps(lowerer, { constraintName, schema: schemaName, @@ -146,7 +157,7 @@ export async function addCheckConstraint( }); return { id: `checkConstraint.${tableName}.${constraintName}`, - label: `Add check constraint "${constraintName}" on "${tableName}"."${column}"`, + label: `Add check constraint "${constraintName}" ${labelSuffix}`, operationClass: 'additive', target: targetDetails('checkConstraint', constraintName, schemaName, tableName), precheck: [ @@ -155,7 +166,7 @@ export async function addCheckConstraint( execute: [ step( `add check constraint "${constraintName}"`, - `ALTER TABLE ${qualified} ADD CONSTRAINT ${quoteIdentifier(constraintName)} CHECK (${quoteIdentifier(column)} IN (${valueList}))`, + `ALTER TABLE ${qualified} ADD CONSTRAINT ${quoteIdentifier(constraintName)} CHECK (${predicate})`, ), ], postcheck: [step(`verify constraint "${constraintName}" exists`, present.sql, present.params)], diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts index fec7be6b99..ad51c014b3 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts @@ -24,24 +24,27 @@ import type { MigrationOperationPolicy, SqlMigrationPlanOperation, } from '@prisma-next/family-sql/control'; -import { resolveValueSetValues } from '@prisma-next/family-sql/control'; +import { projectContractChecks } from '@prisma-next/family-sql/control'; +import { checkContentEqual } from '@prisma-next/family-sql/schema-verify'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { SchemaIssue } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { + isStorageTable, type SqlStorage, - StorageTable, + type StorageTable, type StorageTypeInstance, } from '@prisma-next/sql-contract/types'; import type { CodecRef, DdlColumn } from '@prisma-next/sql-relational-core/ast'; import { col } from '@prisma-next/sql-relational-core/contract-free'; -import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import type { SqlCheckConstraintIRInput, SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { JsonValue } from '@prisma-next/utils/json'; import { isPostgresSchema } from '../postgres-schema'; import { AddCheckConstraintCall, + type AddCheckConstraintPayload, AddColumnCall, AddNotNullColumnDirectCall, AddNotNullColumnWithTempDefaultCall, @@ -348,37 +351,27 @@ export const nullableTighteningCallStrategy: CallMigrationStrategy = (issues, ct }; /** - * Collects every check constraint from a table in the contract storage. - * Returns an empty array when the table has no checks or the table is absent. + * Collects every check constraint a table requires — declared value-set checks + * plus the synthesized scalar-array element-non-null expression checks — using + * the shared projection so the planner agrees byte-for-byte with the verifier + * and expected-IR builder. Returns an empty array when the table is absent. */ function collectContractChecks( storage: SqlStorage, namespaceId: string, tableName: string, -): ReadonlyArray<{ name: string; column: string; permittedValues: readonly string[] }> { +): ReadonlyArray { const ns = storage.namespaces[namespaceId]; const tableRaw = ns !== undefined ? ns.entries.table?.[tableName] : undefined; - if (!(tableRaw instanceof StorageTable)) return []; - const checks = tableRaw.checks; - if (!checks || checks.length === 0) return []; - return checks.map((c) => ({ - name: c.name, - column: c.column, - permittedValues: resolveValueSetValues( - c.valueSet, - storage, - `check "${c.name}" on "${tableName}"`, - ), - })); + if (!isStorageTable(tableRaw)) return []; + return projectContractChecks(tableRaw, tableName, storage); } -/** - * Compares two value arrays as unordered sets. - */ -function checkValueSetsEqual(a: readonly string[], b: readonly string[]): boolean { - if (a.length !== b.length) return false; - const bSet = new Set(b); - return a.every((v) => bSet.has(v)); +/** Builds the `AddCheckConstraintCall` payload for a projected contract check. */ +function checkPayload(check: SqlCheckConstraintIRInput): AddCheckConstraintPayload { + return check.kind === 'valueSet' + ? { kind: 'valueSet', column: check.column, values: check.permittedValues } + : { kind: 'expression', expression: check.expression }; } /** @@ -418,20 +411,18 @@ export const checkConstraintPlanCallStrategy: CallMigrationStrategy = (issues, c ddlSchema, tableName, contractCheck.name, - contractCheck.column, - contractCheck.permittedValues, + checkPayload(contractCheck), ), ); handledIssueKeys.add(issueKey); - } else if (!checkValueSetsEqual(contractCheck.permittedValues, liveCheck.permittedValues)) { + } else if (!checkContentEqual(contractCheck, liveCheck)) { calls.push( new DropCheckConstraintCall(ddlSchema, tableName, contractCheck.name), new AddCheckConstraintCall( ddlSchema, tableName, contractCheck.name, - contractCheck.column, - contractCheck.permittedValues, + checkPayload(contractCheck), ), ); handledIssueKeys.add(issueKey); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts index 20f1a173f6..e4348d3188 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts @@ -10,6 +10,7 @@ import { errorPostgresMigrationStackMissing } from '../errors'; import { PostgresContractView } from '../postgres-contract-view'; import { AddCheckConstraintCall, + type AddCheckConstraintPayload, AddColumnCall, AddForeignKeyCall, AddPrimaryKeyCall, @@ -232,8 +233,7 @@ export abstract class PostgresMigration< readonly schema: string; readonly table: string; readonly constraint: string; - readonly column: string; - readonly values: readonly string[]; + readonly payload: AddCheckConstraintPayload; }): Promise> { if (!this.controlAdapter) { throw errorPostgresMigrationStackMissing(); @@ -242,8 +242,7 @@ export abstract class PostgresMigration< options.schema, options.table, options.constraint, - options.column, - options.values, + options.payload, ).toOp(this.controlAdapter); } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-ir.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-ir.ts index 8e0399edbb..fe92b5ca6c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-ir.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-table-ir.ts @@ -10,6 +10,7 @@ import { SqlSchemaIRNode, type SqlTableIRInput, SqlUniqueIR, + sqlCheckConstraintIR, } from '@prisma-next/sql-schema-ir/types'; import type { PostgresRlsPolicy } from './postgres-rls-policy'; @@ -69,9 +70,7 @@ export class PostgresTableIR extends SqlSchemaIRNode implements DiffableNode { if (input.annotations !== undefined) this.annotations = input.annotations; if (input.checks !== undefined && input.checks.length > 0) { this.checks = Object.freeze( - input.checks.map((c) => - c instanceof SqlCheckConstraintIR ? c : new SqlCheckConstraintIR(c), - ), + input.checks.map((c) => (c instanceof SqlCheckConstraintIR ? c : sqlCheckConstraintIR(c))), ); } this.rlsPolicies = Object.freeze([...(input.rlsPolicies ?? [])]); diff --git a/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts b/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts index 45a764453c..9cafd12d27 100644 --- a/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts +++ b/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts @@ -46,4 +46,32 @@ describe('parsePostgresDefault array literals', () => { const result = parsePostgresDefault("'{hello world}'::text[]", 'text[]'); expect(result?.kind).not.toBe('literal'); }); + + it('keeps a comma inside a quoted element as part of that element', () => { + expect(parsePostgresDefault('\'{"a,b","c"}\'::text[]', 'text[]')).toEqual({ + kind: 'literal', + value: ['a,b', 'c'], + }); + }); + + it('unescapes a doubled quote inside a quoted element', () => { + expect(parsePostgresDefault('\'{"a""b"}\'::text[]', 'text[]')).toEqual({ + kind: 'literal', + value: ['a"b'], + }); + }); + + it('unescapes a backslash-escaped quote inside a quoted element', () => { + expect(parsePostgresDefault('\'{"a\\"b"}\'::text[]', 'text[]')).toEqual({ + kind: 'literal', + value: ['a"b'], + }); + }); + + it('keeps a quoted element that looks like NULL as the literal string', () => { + expect(parsePostgresDefault('\'{"NULL"}\'::text[]', 'text[]')).toEqual({ + kind: 'literal', + value: ['NULL'], + }); + }); }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/op-factory-call.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/op-factory-call.test.ts index baa52f0a00..5c6ac72192 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/op-factory-call.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/op-factory-call.test.ts @@ -145,12 +145,13 @@ describe('AddForeignKeyCall', () => { }); describe('AddCheckConstraintCall', () => { - it('lowers typed checks and renders this.addCheckConstraint', async () => { + it('lowers a value-set payload and renders this.addCheckConstraint', async () => { const { lowerer, received } = recordingCheckLowerer(); - const call = new AddCheckConstraintCall('public', 'post', 'post_priority_check', 'priority', [ - 'low', - 'high', - ]); + const call = new AddCheckConstraintCall('public', 'post', 'post_priority_check', { + kind: 'valueSet', + column: 'priority', + values: ['low', 'high'], + }); const op = await call.toOp(lowerer); const checks = () => constraintExistsAst({ @@ -162,10 +163,25 @@ describe('AddCheckConstraintCall', () => { expect(op.execute[0]?.sql).toContain("CHECK (\"priority\" IN ('low', 'high'))"); expect(op.precheck[0]).toMatchObject({ sql: 'LOWERED 1', params: ['p1'] }); expect(call.renderTypeScript()).toBe( - 'this.addCheckConstraint({ schema: "public", table: "post", constraint: "post_priority_check", column: "priority", values: ["low", "high"] })', + 'this.addCheckConstraint({ schema: "public", table: "post", constraint: "post_priority_check", payload: { kind: "valueSet", column: "priority", values: ["low", "high"] } })', ); expect(call.importRequirements()).toEqual([]); }); + + it('lowers an expression payload and renders CHECK ()', async () => { + const { lowerer } = recordingCheckLowerer(); + const call = new AddCheckConstraintCall('public', 'post', 'post_tags_elem_not_null', { + kind: 'expression', + expression: 'array_position("tags", NULL) IS NULL', + }); + const op = await call.toOp(lowerer); + expect(op.execute[0]?.sql).toContain('CHECK (array_position("tags", NULL) IS NULL)'); + expect(op.label).toBe('Add check constraint "post_tags_elem_not_null" on "post"'); + const rendered = call.renderTypeScript(); + expect(rendered).toContain('this.addCheckConstraint({'); + expect(rendered).toContain('kind: "expression"'); + expect(rendered).toContain('expression: "array_position(\\"tags\\", NULL) IS NULL"'); + }); }); describe('DropCheckConstraintCall', () => { diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.check-constraints.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.check-constraints.test.ts index f3b0e9a11f..b3cb9dc10c 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.check-constraints.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.check-constraints.test.ts @@ -8,7 +8,7 @@ import { StorageValueSet, } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; -import { SqlCheckConstraintIR } from '@prisma-next/sql-schema-ir/types'; +import { sqlCheckConstraintIR } from '@prisma-next/sql-schema-ir/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { planIssues } from '../../src/core/migrations/issue-planner'; @@ -121,7 +121,8 @@ function schemaWithCheck(values: readonly string[]): SqlSchemaIR { uniques: [], indexes: [], checks: [ - new SqlCheckConstraintIR({ + sqlCheckConstraintIR({ + kind: 'valueSet', name: CHECK_NAME, column: COLUMN_NAME, permittedValues: [...values], @@ -146,6 +147,73 @@ function schemaWithoutCheck(): SqlSchemaIR { }; } +const ARRAY_COLUMN = 'tags'; +const ELEM_CHECK_NAME = `${TABLE_NAME}_${ARRAY_COLUMN}_elem_not_null`; +const ELEM_CHECK_EXPR = `array_position("${ARRAY_COLUMN}", NULL) IS NULL`; + +function makeContractWithArrayColumn(): Contract { + const ns = postgresCreateNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: { + [TABLE_NAME]: new StorageTable({ + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + [ARRAY_COLUMN]: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: false, + many: true, + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }), + }, + }, + }); + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('sha256:test'), + storage: new SqlStorage({ + storageHash: coreHash('sha256:contract'), + namespaces: { [UNBOUND_NAMESPACE_ID]: ns }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensionPacks: {}, + meta: {}, + }; +} + +function schemaWithElementCheck(): SqlSchemaIR { + return { + tables: { + [TABLE_NAME]: { + name: TABLE_NAME, + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + [ARRAY_COLUMN]: { name: ARRAY_COLUMN, nativeType: 'text[]', nullable: false }, + }, + foreignKeys: [], + uniques: [], + indexes: [], + checks: [ + sqlCheckConstraintIR({ + kind: 'expression', + name: ELEM_CHECK_NAME, + expression: ELEM_CHECK_EXPR, + }), + ], + }, + }, + }; +} + const defaultCtx = { schemaName: SCHEMA_NAME, codecHooks: new Map(), @@ -183,8 +251,11 @@ describe('checkConstraintPlanCallStrategy', () => { factoryName: 'addCheckConstraint', tableName: TABLE_NAME, constraintName: CHECK_NAME, - column: COLUMN_NAME, - values: expect.arrayContaining(['active', 'inactive']), + payload: { + kind: 'valueSet', + column: COLUMN_NAME, + values: expect.arrayContaining(['active', 'inactive']), + }, }); expect(result.issues).toHaveLength(0); }); @@ -225,7 +296,10 @@ describe('checkConstraintPlanCallStrategy', () => { factoryName: 'addCheckConstraint', tableName: TABLE_NAME, constraintName: CHECK_NAME, - values: expect.arrayContaining(['active', 'inactive', 'pending']), + payload: { + kind: 'valueSet', + values: expect.arrayContaining(['active', 'inactive', 'pending']), + }, }); expect(result.issues).toHaveLength(0); }); @@ -348,6 +422,115 @@ describe('planIssues — check constraint strategy', () => { }); }); + it('emits an expression AddCheckConstraintCall for a new scalar-array column absent from live', () => { + const contract = makeContractWithArrayColumn(); + const result = checkConstraintPlanCallStrategy([], { + ...defaultCtx, + toContract: contract, + fromContract: null, + schema: schemaWithoutCheck(), + policy: { allowedOperationClasses: ['additive'] }, + frameworkComponents: [], + }); + + expect(result.kind).toBe('match'); + if (result.kind !== 'match') return; + expect(result.calls).toHaveLength(1); + expect(result.calls[0]).toMatchObject({ + factoryName: 'addCheckConstraint', + tableName: TABLE_NAME, + constraintName: ELEM_CHECK_NAME, + payload: { kind: 'expression', expression: ELEM_CHECK_EXPR }, + }); + }); + + it('emits no calls when the element-non-null check already matches live', () => { + const contract = makeContractWithArrayColumn(); + const result = checkConstraintPlanCallStrategy([], { + ...defaultCtx, + toContract: contract, + fromContract: null, + schema: schemaWithElementCheck(), + policy: { allowedOperationClasses: ['additive'] }, + frameworkComponents: [], + }); + + if (result.kind === 'no_match') return; + expect(result.calls).toHaveLength(0); + }); + + it('drops and re-adds the element check when the live predicate drifts', () => { + const contract = makeContractWithArrayColumn(); + const drifted: SqlSchemaIR = { + tables: { + [TABLE_NAME]: { + ...schemaWithElementCheck().tables[TABLE_NAME], + name: TABLE_NAME, + columns: schemaWithElementCheck().tables[TABLE_NAME]?.columns ?? {}, + foreignKeys: [], + uniques: [], + indexes: [], + checks: [ + sqlCheckConstraintIR({ + kind: 'expression', + name: ELEM_CHECK_NAME, + expression: 'array_position("labels", NULL) IS NULL', + }), + ], + }, + }, + }; + const result = checkConstraintPlanCallStrategy([], { + ...defaultCtx, + toContract: contract, + fromContract: null, + schema: drifted, + policy: { allowedOperationClasses: ['additive', 'destructive'] }, + frameworkComponents: [], + }); + + expect(result.kind).toBe('match'); + if (result.kind !== 'match') return; + expect(result.calls.map((c) => c.factoryName)).toEqual([ + 'dropCheckConstraint', + 'addCheckConstraint', + ]); + }); + + it('emits the element-non-null ALTER after CREATE TABLE for a new scalar-array table', () => { + const contract = makeContractWithArrayColumn(); + const result = planIssues({ + ...defaultCtx, + issues: [ + { + kind: 'missing_table', + table: TABLE_NAME, + namespaceId: UNBOUND_NAMESPACE_ID, + message: `Table "${TABLE_NAME}" is missing`, + }, + ], + toContract: contract, + fromContract: null, + schema: { tables: {} }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const createIdx = result.value.calls.findIndex((c) => c.factoryName === 'createTable'); + const addCheckIdx = result.value.calls.findIndex((c) => c.factoryName === 'addCheckConstraint'); + expect(createIdx).toBeGreaterThanOrEqual(0); + expect(addCheckIdx).toBeGreaterThan(createIdx); + expect(result.value.calls[addCheckIdx]).toMatchObject({ + constraintName: ELEM_CHECK_NAME, + payload: { kind: 'expression', expression: ELEM_CHECK_EXPR }, + }); + // The CREATE TABLE itself no longer inlines the element CHECK constraint. + const createCall = result.value.calls[createIdx] as { + constraints?: readonly { kind: string }[]; + }; + expect((createCall.constraints ?? []).some((c) => c.kind === 'check-expression')).toBe(false); + }); + it('check_mismatch produces DropCheckConstraintCall + AddCheckConstraintCall', () => { const contract = makeContractWithCheck(['active', 'inactive', 'pending']); const result = planIssues({ 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 844ec8477d..b8a6873c6f 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 @@ -244,6 +244,7 @@ namespace public { authoringContributions: assembled, composedExtensionContracts: new Map(), createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index 661901f516..fa61cd6b38 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -8,6 +8,7 @@ import { rethrowMarkerReadError, withMarkerReadErrorHandling, } from '@prisma-next/errors/execution'; +import { elementNonNullCheckExpression } from '@prisma-next/family-sql/control'; import type { SqlControlAdapter } from '@prisma-next/family-sql/control-adapter'; import { parseContractMarkerRow } from '@prisma-next/family-sql/verify'; import type { CodecLookup, CodecRegistry } from '@prisma-next/framework-components/codec'; @@ -1122,11 +1123,20 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { for (const checkRow of checksByTable.get(tableName) ?? []) { const parsed = parseCheckConstraintDef(checkRow.constraintdef); if (parsed) { - checksForTable.push({ - name: checkRow.constraint_name, - column: parsed.column, - permittedValues: parsed.permittedValues, - }); + checksForTable.push( + parsed.kind === 'valueSet' + ? { + kind: 'valueSet', + name: checkRow.constraint_name, + column: parsed.column, + permittedValues: parsed.permittedValues, + } + : { + kind: 'expression', + name: checkRow.constraint_name, + expression: parsed.expression, + }, + ); } } @@ -1445,13 +1455,25 @@ function groupBy(items: readonly T[], key: K): Map { const result = parseCheckConstraintDef( "CHECK ((status = ANY (ARRAY['active'::text, 'inactive'::text])))", ); - expect(result).toEqual({ column: 'status', permittedValues: ['active', 'inactive'] }); + expect(result).toEqual({ + kind: 'valueSet', + column: 'status', + permittedValues: ['active', 'inactive'], + }); }); it('parses = ANY (ARRAY[...]) with a single value', () => { const result = parseCheckConstraintDef("CHECK ((role = ANY (ARRAY['admin'::text])))"); - expect(result).toEqual({ column: 'role', permittedValues: ['admin'] }); + expect(result).toEqual({ kind: 'valueSet', column: 'role', permittedValues: ['admin'] }); }); it('parses IN (...) shape', () => { const result = parseCheckConstraintDef("CHECK ((status IN ('draft', 'published')))"); - expect(result).toEqual({ column: 'status', permittedValues: ['draft', 'published'] }); + expect(result).toEqual({ + kind: 'valueSet', + column: 'status', + permittedValues: ['draft', 'published'], + }); }); it('strips casts like ::character varying from array literals', () => { const result = parseCheckConstraintDef( "CHECK ((color = ANY (ARRAY['red'::character varying, 'blue'::character varying])))", ); - expect(result).toEqual({ column: 'color', permittedValues: ['red', 'blue'] }); + expect(result).toEqual({ kind: 'valueSet', column: 'color', permittedValues: ['red', 'blue'] }); }); it('returns undefined for a free-form predicate it cannot parse', () => { @@ -47,12 +55,20 @@ describe('parseCheckConstraintDef', () => { const result = parseCheckConstraintDef( "CHECK ((last_name = ANY (ARRAY['O''Brien'::text, 'Smith'::text])))", ); - expect(result).toEqual({ column: 'last_name', permittedValues: ["O'Brien", 'Smith'] }); + expect(result).toEqual({ + kind: 'valueSet', + column: 'last_name', + permittedValues: ["O'Brien", 'Smith'], + }); }); it('un-escapes doubled single-quotes in IN list values', () => { const result = parseCheckConstraintDef("CHECK ((last_name IN ('O''Brien', 'Smith')))"); - expect(result).toEqual({ column: 'last_name', permittedValues: ["O'Brien", 'Smith'] }); + expect(result).toEqual({ + kind: 'valueSet', + column: 'last_name', + permittedValues: ["O'Brien", 'Smith'], + }); }); // F2: double-quoted (non-identifier) column names @@ -60,12 +76,12 @@ describe('parseCheckConstraintDef', () => { const result = parseCheckConstraintDef( "CHECK ((\"my-col\" = ANY (ARRAY['a'::text, 'b'::text])))", ); - expect(result).toEqual({ column: 'my-col', permittedValues: ['a', 'b'] }); + expect(result).toEqual({ kind: 'valueSet', column: 'my-col', permittedValues: ['a', 'b'] }); }); it('parses a double-quoted column name in IN shape', () => { const result = parseCheckConstraintDef("CHECK ((\"my-col\" IN ('a', 'b')))"); - expect(result).toEqual({ column: 'my-col', permittedValues: ['a', 'b'] }); + expect(result).toEqual({ kind: 'valueSet', column: 'my-col', permittedValues: ['a', 'b'] }); }); // Composite predicates: must NOT match either shape @@ -82,6 +98,72 @@ describe('parseCheckConstraintDef', () => { ); expect(result).toBeUndefined(); }); + + // Scalar-array element-non-null: array_position(col, NULL) IS NULL. + // Postgres stores an element-type cast on NULL and drops quotes on simple + // identifiers; the recognizer re-canonicalizes back to the projected form. + it('recognizes the element-non-null shape and re-canonicalizes the predicate (text[])', () => { + const result = parseCheckConstraintDef('CHECK ((array_position(tags, NULL::text) IS NULL))'); + expect(result).toEqual({ + kind: 'expression', + expression: 'array_position("tags", NULL) IS NULL', + }); + }); + + it('re-canonicalizes with the element-type cast for a non-text array (integer[])', () => { + const result = parseCheckConstraintDef( + 'CHECK ((array_position(scores, NULL::integer) IS NULL))', + ); + expect(result).toEqual({ + kind: 'expression', + expression: 'array_position("scores", NULL) IS NULL', + }); + }); + + it('re-canonicalizes with a multi-word element-type cast (timestamptz[])', () => { + const result = parseCheckConstraintDef( + 'CHECK ((array_position(dates, NULL::timestamp with time zone) IS NULL))', + ); + expect(result).toEqual({ + kind: 'expression', + expression: 'array_position("dates", NULL) IS NULL', + }); + }); + + it('re-canonicalizes with a multi-word element-type cast (double precision[])', () => { + const result = parseCheckConstraintDef( + 'CHECK ((array_position(measurements, NULL::double precision) IS NULL))', + ); + expect(result).toEqual({ + kind: 'expression', + expression: 'array_position("measurements", NULL) IS NULL', + }); + }); + + it('preserves quoting for a mixed-case element-non-null column', () => { + const result = parseCheckConstraintDef( + 'CHECK ((array_position("myTags", NULL::text) IS NULL))', + ); + expect(result).toEqual({ + kind: 'expression', + expression: 'array_position("myTags", NULL) IS NULL', + }); + }); + + it('recognizes the element-non-null shape without a NULL cast', () => { + const result = parseCheckConstraintDef('CHECK ((array_position(tags, NULL) IS NULL))'); + expect(result).toEqual({ + kind: 'expression', + expression: 'array_position("tags", NULL) IS NULL', + }); + }); + + it('returns undefined for a different array_position predicate (not IS NULL)', () => { + const result = parseCheckConstraintDef( + 'CHECK ((array_position(tags, NULL::text) IS NOT NULL))', + ); + expect(result).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- @@ -136,6 +218,7 @@ describe('PostgresControlAdapter.introspect — check constraints', () => { expect(result.tables['post']?.checks).toEqual([ { + kind: 'valueSet', name: 'post_status_check', column: 'status', permittedValues: ['draft', 'published'], diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/native-array-columns.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/native-array-columns.integration.test.ts index 8c9473705e..47c4e9f80e 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/native-array-columns.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/native-array-columns.integration.test.ts @@ -39,6 +39,12 @@ function buildArrayContract(): Contract { tags: { nativeType: 'text', codecId: 'pg/text@1', nullable: false, many: true }, labels: { nativeType: 'text', codecId: 'pg/text@1', nullable: true, many: true }, scores: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false, many: true }, + dates: { + nativeType: 'timestamptz', + codecId: 'pg/timestamptz@1', + nullable: true, + many: true, + }, tagsWithDefault: { nativeType: 'text', codecId: 'pg/text@1', @@ -277,6 +283,98 @@ describe.sequential('native array columns DDL', () => { expect(rows.rows[0]?.tags_default).toEqual([]); }); + it('emits a non-null-element CHECK constraint on every array column', { + timeout: testTimeout, + }, async () => { + const contract = buildArrayContract(); + const planner = postgresTargetDescriptor.createPlanner(controlAdapter); + const runner = postgresTargetDescriptor.createRunner(familyInstance); + + const planResult = planner.plan({ + contract, + schema: emptySchema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents, + spaceId: APP_SPACE_ID, + }); + if (planResult.kind !== 'success') throw new Error('planner failed'); + + await runner.execute({ + driver: driver!, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan: planResult.plan, + migrationEdges: synthEdges(planResult.plan), + driver: driver!, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + }, + ], + }); + + const checkRows = await driver!.query<{ constraint_name: string; constraintdef: string }>( + `SELECT c.conname AS constraint_name, pg_get_constraintdef(c.oid) AS constraintdef + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_class t ON t.oid = c.conrelid + JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'public' AND t.relname = 'ArrayTest' AND c.contype = 'c' + ORDER BY c.conname`, + ); + const checkNames = checkRows.rows.map((r) => r.constraint_name).sort(); + expect(checkNames).toEqual( + [ + 'ArrayTest_dates_elem_not_null', + 'ArrayTest_labels_elem_not_null', + 'ArrayTest_scores_elem_not_null', + 'ArrayTest_tags_elem_not_null', + 'ArrayTest_tagsWithDefault_elem_not_null', + ].sort(), + ); + expect(checkRows.rows.every((r) => /array_position/i.test(r.constraintdef))).toBe(true); + }); + + it('rejects an INSERT carrying a NULL array element', { + timeout: testTimeout, + }, async () => { + const contract = buildArrayContract(); + const planner = postgresTargetDescriptor.createPlanner(controlAdapter); + const runner = postgresTargetDescriptor.createRunner(familyInstance); + + const planResult = planner.plan({ + contract, + schema: emptySchema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents, + spaceId: APP_SPACE_ID, + }); + if (planResult.kind !== 'success') throw new Error('planner failed'); + + await runner.execute({ + driver: driver!, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan: planResult.plan, + migrationEdges: synthEdges(planResult.plan), + driver: driver!, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + }, + ], + }); + + await expect( + driver!.query( + `INSERT INTO "ArrayTest" (id, tags, scores) VALUES (1, ARRAY['a', NULL, 'c']::text[], ARRAY[]::integer[])`, + ), + ).rejects.toThrow(); + }); + it('schema verification passes after migrating array columns', { timeout: testTimeout, }, async () => { @@ -323,4 +421,57 @@ describe.sequential('native array columns DDL', () => { ); } }); + + it('strict schema verification reports zero drift after migrating array columns', { + timeout: testTimeout, + }, async () => { + // L10 closure: the element-non-null CHECKs are now drift-managed like enum + // value-set checks, so a migrate → introspect → strict-verify round-trip + // must surface no check_missing/check_mismatch/check_removed drift for the + // array element guards. Under the old inline-CREATE-TABLE synthesis the + // introspected checks were unknown to the verifier and strict mode flagged + // them as check_removed. + const contract = buildArrayContract(); + const planner = postgresTargetDescriptor.createPlanner(controlAdapter); + const runner = postgresTargetDescriptor.createRunner(familyInstance); + + const planResult = planner.plan({ + contract, + schema: emptySchema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents, + spaceId: APP_SPACE_ID, + }); + if (planResult.kind !== 'success') throw new Error('planner failed'); + + await runner.execute({ + driver: driver!, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan: planResult.plan, + migrationEdges: synthEdges(planResult.plan), + driver: driver!, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + }, + ], + }); + + const schema = await familyInstance.introspect({ driver: driver!, contract }); + const verifyResult = familyInstance.verifySchema({ + contract, + schema, + strict: true, + frameworkComponents, + }); + if (!verifyResult.ok) { + throw new Error( + `strict verifySchema failed: ${JSON.stringify(verifyResult.schema.issues, null, 2)}`, + ); + } + expect(verifyResult.schema.issues.filter((i) => i.kind.startsWith('check'))).toHaveLength(0); + }); }); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts index 0b63ad2cd4..28b88dfe9b 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts @@ -128,6 +128,7 @@ function buildContractFromPsl(psl: string): Contract { authoringContributions: assembled, composedExtensionContracts: new Map(), createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); if (!result.ok) throw new Error(`PSL interpretation failed: ${JSON.stringify(result)}`); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts index 7397e343b0..72c39234c7 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts @@ -83,6 +83,7 @@ function buildPslContract() { authoringContributions: assembled, composedExtensionContracts: new Map(), createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); } diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton-psl.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton-psl.integration.test.ts index 6486a7994e..4357acbf10 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton-psl.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton-psl.integration.test.ts @@ -96,6 +96,7 @@ function buildPslContract() { authoringContributions: assembled, composedExtensionContracts: new Map(), createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); } diff --git a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md index 8e71e1e888..d42e04a798 100644 --- a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md +++ b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md @@ -194,3 +194,19 @@ it from there instead of defining a local copy. Behaviour-preserving — the run facade surface (`db.enums`, `sql`, `orm`) is byte-identical. No extension-author API or surface change; nothing to migrate. Incidental substrate diff only. --> + + diff --git a/skills/upgrade/prisma-next-upgrade/upgrades/0.14-to-0.15/instructions.md b/skills/upgrade/prisma-next-upgrade/upgrades/0.14-to-0.15/instructions.md index 5b70668846..ddc8513d4b 100644 --- a/skills/upgrade/prisma-next-upgrade/upgrades/0.14-to-0.15/instructions.md +++ b/skills/upgrade/prisma-next-upgrade/upgrades/0.14-to-0.15/instructions.md @@ -1,7 +1,25 @@ --- from: "0.14" to: "0.15" -changes: [] +changes: + - id: migration-addcheckconstraint-payload + summary: | + The migration builder's `this.addCheckConstraint({...})` call now takes a + discriminated `payload` instead of top-level `column` + `values`. A committed + migration that adds an enum value-set check must move `column`/`values` under + `payload: { kind: 'valueSet', column, values }`: + - this.addCheckConstraint({ schema, table, constraint, column: 'kind', values: ['a', 'b'] }) + + this.addCheckConstraint({ schema, table, constraint, payload: { kind: 'valueSet', column: 'kind', values: ['a', 'b'] } }) + The unified call also accepts `payload: { kind: 'expression', expression }` for + arbitrary CHECK predicates (e.g. scalar-array element-non-null constraints), + which the planner now emits and drift-verifies through this same path. + Re-running `prisma-next contract emit` / migration generation produces the new + shape automatically; only hand-preserved migration files need the edit. + detection: + glob: "**/*.{ts,mts,cts}" + contains: + - "addCheckConstraint(" + anyMatch: true ---