From 87d6cc9e6de7e73ff9be33029d823bc62b346cc5 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 24 Jun 2026 15:42:40 +0000 Subject: [PATCH 01/18] feat(scalar-arrays): lower PSL scalar lists to native array columns (slice 2 D1) Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../src/prisma/contract.d.ts | 18 +++--- .../src/prisma/contract.json | 12 ++-- .../contract-psl/src/interpreter.ts | 6 ++ .../contract-psl/src/psl-field-resolution.ts | 2 +- .../test/interpreter.value-objects.test.ts | 58 ++++++++++++++++++- .../contract-ts/src/build-contract.ts | 9 +-- 6 files changed, 79 insertions(+), 26 deletions(-) diff --git a/apps/telemetry-backend/src/prisma/contract.d.ts b/apps/telemetry-backend/src/prisma/contract.d.ts index f042b7ee50..ce111570cb 100644 --- a/apps/telemetry-backend/src/prisma/contract.d.ts +++ b/apps/telemetry-backend/src/prisma/contract.d.ts @@ -30,7 +30,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:be92c3521871ea0d863f15c8889093bd38747d60e97bd1dca404da27d0b13bc7'>; + StorageHashBase<'sha256:4b58da96bcee8697563d149dd99bc639c62e0760b47a6b860bdfa83e44705f1d'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; @@ -91,8 +91,8 @@ export type StorageColumnTypes = { readonly arch: CodecTypes['pg/text@1']['output']; readonly command: CodecTypes['pg/text@1']['output']; readonly databaseTarget: CodecTypes['pg/text@1']['output'] | null; - readonly extensions: CodecTypes['pg/jsonb@1']['output']; - readonly flags: CodecTypes['pg/jsonb@1']['output']; + readonly extensions: CodecTypes['pg/text@1']['output']; + readonly flags: CodecTypes['pg/text@1']['output']; readonly id: CodecTypes['pg/int8@1']['output']; readonly ingestedAt: CodecTypes['pg/timestamptz@1']['output']; readonly installationId: CodecTypes['pg/text@1']['output']; @@ -112,8 +112,8 @@ export type StorageColumnInputTypes = { readonly arch: CodecTypes['pg/text@1']['input']; readonly command: CodecTypes['pg/text@1']['input']; readonly databaseTarget: CodecTypes['pg/text@1']['input'] | null; - readonly extensions: CodecTypes['pg/jsonb@1']['input']; - readonly flags: CodecTypes['pg/jsonb@1']['input']; + readonly extensions: CodecTypes['pg/text@1']['input']; + readonly flags: CodecTypes['pg/text@1']['input']; readonly id: CodecTypes['pg/int8@1']['input']; readonly ingestedAt: CodecTypes['pg/timestamptz@1']['input']; readonly installationId: CodecTypes['pg/text@1']['input']; @@ -176,8 +176,8 @@ type ContractBase = Omit< readonly nullable: false; }; readonly flags: { - readonly nativeType: 'jsonb'; - readonly codecId: 'pg/jsonb@1'; + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; readonly nullable: false; }; readonly runtimeName: { @@ -221,8 +221,8 @@ type ContractBase = Omit< readonly nullable: true; }; readonly extensions: { - readonly nativeType: 'jsonb'; - readonly codecId: 'pg/jsonb@1'; + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; readonly nullable: false; }; }; diff --git a/apps/telemetry-backend/src/prisma/contract.json b/apps/telemetry-backend/src/prisma/contract.json index aea969b101..009b90c828 100644 --- a/apps/telemetry-backend/src/prisma/contract.json +++ b/apps/telemetry-backend/src/prisma/contract.json @@ -208,13 +208,15 @@ "nullable": true }, "extensions": { - "codecId": "pg/jsonb@1", - "nativeType": "jsonb", + "codecId": "pg/text@1", + "many": true, + "nativeType": "text", "nullable": false }, "flags": { - "codecId": "pg/jsonb@1", - "nativeType": "jsonb", + "codecId": "pg/text@1", + "many": true, + "nativeType": "text", "nullable": false }, "id": { @@ -292,7 +294,7 @@ "kind": "postgres-schema" } }, - "storageHash": "sha256:be92c3521871ea0d863f15c8889093bd38747d60e97bd1dca404da27d0b13bc7" + "storageHash": "sha256:4b58da96bcee8697563d149dd99bc639c62e0760b47a6b860bdfa83e44705f1d" }, "capabilities": { "postgres": { 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..558a77439a 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -1151,6 +1151,12 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult columnName: resolvedField.columnName, descriptor: resolvedField.descriptor, nullable: resolvedField.nullable, + ...ifDefined( + 'many', + resolvedField.many && resolvedField.valueObjectTypeName === undefined + ? (true as const) + : undefined, + ), ...ifDefined('default', resolvedField.defaultValue), ...ifDefined('executionDefaults', resolvedField.executionDefaults), ...ifDefined('enumTypeHandle', enumHandle), 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..ad8edf665c 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 @@ -376,7 +376,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) { 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..f5af3bbf74 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 @@ -150,7 +150,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 +187,9 @@ model User { user: { columns: { tags: { - nativeType: 'jsonb', - codecId: 'pg/jsonb@1', + nativeType: 'text', + codecId: 'pg/text@1', + many: true, nullable: false, }, }, @@ -200,6 +201,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-ts/src/build-contract.ts b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts index 145263ec4a..e34c59bfec 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 @@ -234,14 +234,6 @@ 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 @@ -252,6 +244,7 @@ function buildStorageColumn( 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), From b88d8eb83c81bec34335a3b1399213f606b58d55 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 24 Jun 2026 15:52:05 +0000 Subject: [PATCH 02/18] feat(scalar-arrays): reject incoherent list authoring constructs (slice 2 D2) Signed-off-by: Serhii Tatarintsev --- .../contract-psl/src/psl-field-resolution.ts | 22 +++++ .../test/interpreter.diagnostics.test.ts | 94 +++++++++++++++++++ 2 files changed, 116 insertions(+) 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 ad8edf665c..f0aeed39ef 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 @@ -445,6 +445,19 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv }) : {}; 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 +487,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/interpreter.diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts index 6945f4269d..28c6098847 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,6 +7,7 @@ import { import { createBuiltinLikeControlMutationDefaults, documentScopedTypes, + modelsOf, postgresScalarTypeDescriptors, postgresTarget, sqliteScalarTypeDescriptors, @@ -1192,3 +1193,96 @@ 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, + }, + }, + }, + }); + }); +}); From 611af144f840a828f15278c0ec891af6cb7ba844 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 24 Jun 2026 16:12:36 +0000 Subject: [PATCH 03/18] feat(scalar-arrays): gate scalar-list fields on adapter scalarList capability (slice 2 D3) Thread a merged CapabilityMatrix (built via mergeCapabilityMatrices over [target, adapter, ...extensionPacks], the same merge enrichContract performs) from the ControlStack through ContractSourceContext into the PSL interpreter, and reject a scalar-list field whose target does not report sql.scalarList with PSL_SCALAR_LIST_UNSUPPORTED_TARGET. An absent matrix means do not gate, so non-adapter authoring paths stay valid; the CLI emit path always populates it. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../config/src/contract-source-types.ts | 8 ++ .../src/control/control-stack.ts | 15 +++ .../src/exports/components.ts | 1 + .../src/shared/capabilities.ts | 2 +- .../3-tooling/cli/src/control-api/client.ts | 1 + .../control-api/operations/contract-emit.ts | 1 + .../contract-psl/src/interpreter.ts | 17 ++- .../2-authoring/contract-psl/src/provider.ts | 1 + .../contract-psl/src/psl-field-resolution.ts | 21 ++++ .../interpreter.capability-gating.test.ts | 102 ++++++++++++++++++ 10 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 packages/2-sql/2-authoring/contract-psl/test/interpreter.capability-gating.test.ts 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..f1d19ff415 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,13 @@ export interface ContractSourceContext { readonly codecLookup: CodecLookup; readonly controlMutationDefaults: ControlMutationDefaults; readonly resolvedInputs: readonly string[]; + /** + * Merged capability matrix from the control stack's `[target, adapter, ...extensionPacks]` + * descriptors — the same merge `enrichContract` performs at emit time. Optional so + * non-adapter authoring paths (tests, bespoke providers) may omit it; an absent matrix + * means "do not gate". The CLI emit path always populates it. + */ + 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..1bdb9953fd 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,14 @@ export interface ControlStack< readonly authoringContributions: AssembledAuthoringContributions; readonly scalarTypeDescriptors: ReadonlyMap; readonly controlMutationDefaults: ControlMutationDefaults; + /** + * Merged capability matrix folded from the `[target, adapter, ...extensionPacks]` + * descriptors — the same merge `enrichContract` performs at emit time. Authoring + * paths read this to gate features (e.g. scalar lists) before the contract is + * produced, so an unsupported construct is rejected with a diagnostic rather than + * silently emitted. + */ + readonly capabilities: CapabilityMatrix; } export interface CreateControlStackInput< @@ -522,5 +532,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/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/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index 558a77439a..3c5f6a4d4a 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,14 @@ export interface InterpretPslDocumentToSqlContractInput { readonly createNamespace: (input: SqlNamespaceInput) => SqlNamespaceBase; readonly codecLookup?: CodecLookup; readonly seedDiagnostics?: readonly ContractSourceDiagnostic[]; + /** + * Merged capability matrix from the control stack's + * `[target, adapter, ...extensionPacks]` descriptors. Authoring-time gating + * (e.g. scalar lists) reads `capabilities.sql?.scalarList`. Optional: an absent + * matrix means "do not gate" so non-adapter authoring paths stay valid; the CLI + * emit path always populates it. + */ + readonly capabilities?: CapabilityMatrix; } function buildComposedExtensionPackRefs( @@ -443,6 +455,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 +488,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult sourceId, scalarTypeDescriptors: input.scalarTypeDescriptors, ...ifDefined('enumHandles', input.enumHandles), + ...ifDefined('capabilities', input.capabilities), }); const inlineIdFields = resolvedFields.filter((field) => field.isId); @@ -1923,6 +1937,7 @@ export function interpretPslDocumentToSqlContract( diagnostics, modelNamespaceIds, ...(enumHandlesByName.size > 0 ? { enumHandles: enumHandlesByName } : {}), + ...ifDefined('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..3305819b22 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, + ...ifDefined('capabilities', context.capabilities), codecLookup: context.codecLookup, }); if (!interpreted.ok) { 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 f0aeed39ef..737b8f3c72 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,12 @@ export interface CollectResolvedFieldsInput { readonly sourceId: string; readonly scalarTypeDescriptors: ReadonlyMap; readonly enumHandles?: ReadonlyMap; + /** + * Merged adapter capability matrix. When present, a scalar list field whose + * target does not report `sql.scalarList` is rejected with + * `PSL_SCALAR_LIST_UNSUPPORTED_TARGET`. Absent means "do not gate". + */ + readonly capabilities?: CapabilityMatrix; } const BUILTIN_FIELD_ATTRIBUTE_NAMES: ReadonlySet = new Set([ @@ -297,6 +304,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv sourceId, scalarTypeDescriptors, enumHandles, + capabilities, } = input; const resolvedFields: ResolvedField[] = []; @@ -352,6 +360,19 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv if (isValueObjectField) { descriptor = scalarTypeDescriptors.get('Json'); } else if (isListField) { + // Scalar lists lower to a native array storage column; gate them on the + // adapter-reported `sql.scalarList` capability. An absent matrix means the + // authoring path did not thread capabilities (e.g. a bespoke provider), so + // do not gate. A present matrix that omits the capability (SQLite) rejects. + if (capabilities !== undefined && 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) { 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..873d7ffc69 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.capability-gating.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +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(), + 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(), + 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('does not gate when no capability matrix is threaded (non-adapter authoring path)', () => { + const document = symbolTableInputFromParseArgs({ + schema: listSchema, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + composedExtensionContracts: new Map(), + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(modelsOf(result.value)).toMatchObject({ + User: { fields: { tags: { many: true } } }, + }); + }); +}); From 59ed908cb869bd241d310fa3a03043fda1066644 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 24 Jun 2026 16:25:31 +0000 Subject: [PATCH 04/18] fix(scalar-arrays): make storage column type maps many-aware (slice 2 D1b) Signed-off-by: Serhii Tatarintsev --- .../src/prisma/contract.d.ts | 8 +-- .../test/scalar-list-output-types.test-d.ts | 23 +++++++- packages/2-sql/3-tooling/emitter/src/index.ts | 1 + .../emitter-hook.storage-column-types.test.ts | 52 +++++++++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/apps/telemetry-backend/src/prisma/contract.d.ts b/apps/telemetry-backend/src/prisma/contract.d.ts index ce111570cb..80b3e4668b 100644 --- a/apps/telemetry-backend/src/prisma/contract.d.ts +++ b/apps/telemetry-backend/src/prisma/contract.d.ts @@ -91,8 +91,8 @@ export type StorageColumnTypes = { readonly arch: CodecTypes['pg/text@1']['output']; readonly command: CodecTypes['pg/text@1']['output']; readonly databaseTarget: CodecTypes['pg/text@1']['output'] | null; - readonly extensions: CodecTypes['pg/text@1']['output']; - readonly flags: CodecTypes['pg/text@1']['output']; + readonly extensions: ReadonlyArray; + readonly flags: ReadonlyArray; readonly id: CodecTypes['pg/int8@1']['output']; readonly ingestedAt: CodecTypes['pg/timestamptz@1']['output']; readonly installationId: CodecTypes['pg/text@1']['output']; @@ -112,8 +112,8 @@ export type StorageColumnInputTypes = { readonly arch: CodecTypes['pg/text@1']['input']; readonly command: CodecTypes['pg/text@1']['input']; readonly databaseTarget: CodecTypes['pg/text@1']['input'] | null; - readonly extensions: CodecTypes['pg/text@1']['input']; - readonly flags: CodecTypes['pg/text@1']['input']; + readonly extensions: ReadonlyArray; + readonly flags: ReadonlyArray; readonly id: CodecTypes['pg/int8@1']['input']; readonly ingestedAt: CodecTypes['pg/timestamptz@1']['input']; readonly installationId: CodecTypes['pg/text@1']['input']; 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", + ); + }); }); From 7a3d6c4bb7bcaa4bb8e0ea8c6dd49f7ac57a0653 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 24 Jun 2026 16:57:48 +0000 Subject: [PATCH 05/18] feat(scalar-arrays): element-non-null CHECK + array-typed defaults for PSL lists (slice 2 D4) Part A (FR13, AC8 PSL half): every native scalar-array column gets a deterministic CHECK (array_position(col, NULL) IS NULL) constraint named __elem_not_null, emitted at the migration/DDL layer in the postgres issue-planner createTable path via a new CheckExpressionConstraint DDL node. Introspection-based verify skips non-enum check predicates, so the constraint round-trips without false drift and is invisible to infer. Part B (FR14, AC9): PSL array-typed @default lowering. @default([]) -> empty literal array (DEFAULT *{}*); @default(["a","b"]) -> literal array encoded element-wise against the element codec (build-contract encodeColumnDefault is now many-aware). A scalar literal default on a list field is rejected at authoring time with PSL_LIST_DEFAULT_NOT_ARRAY (closes slice-1 D2 carry-in). Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../contract-psl/src/psl-column-resolution.ts | 98 +++++++++++++++++++ .../contract-psl/src/psl-field-resolution.ts | 1 + .../test/interpreter.diagnostics.test.ts | 85 ++++++++++++++++ .../contract-ts/src/build-contract.ts | 15 ++- .../relational-core/src/ast/ddl-types.ts | 27 ++++- .../src/contract-free/column.ts | 5 + .../src/exports/contract-free.ts | 1 + .../src/core/migrations/issue-planner.ts | 37 ++++++- .../src/core/migrations/op-factory-call.ts | 3 + .../postgres/src/exports/migration.ts | 1 + .../src/core/migrations/op-factory-call.ts | 5 + .../postgres/src/core/control-adapter.ts | 3 + .../native-array-columns.integration.test.ts | 91 +++++++++++++++++ .../sqlite/src/core/control-adapter.ts | 6 ++ 14 files changed, 374 insertions(+), 4 deletions(-) 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..c2d84279ce 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 @@ -705,6 +705,85 @@ export function parseDefaultLiteralValue(expression: string): ColumnDefault | un return undefined; } +/** + * Result of parsing 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 a literal at all (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 | null)[] } + | { readonly kind: 'scalar' } + | undefined; + +function splitTopLevelArrayElements(body: string): readonly string[] { + const elements: string[] = []; + let depth = 0; + let quote: string | undefined; + let current = ''; + for (let index = 0; index < body.length; index += 1) { + const character = body[index]; + if (quote !== undefined) { + current += character; + if (character === '\\' && index + 1 < body.length) { + current += body[index + 1]; + index += 1; + } else if (character === quote) { + quote = undefined; + } + continue; + } + if (character === '"' || character === "'") { + quote = character; + current += character; + continue; + } + if (character === '[' || character === '{' || character === '(') depth += 1; + if (character === ']' || character === '}' || character === ')') depth -= 1; + if (character === ',' && depth === 0) { + elements.push(current); + current = ''; + continue; + } + current += character; + } + if (current.trim().length > 0) elements.push(current); + return elements; +} + +function parseListDefaultLiteralValue(expression: string): ListDefaultParse { + const trimmed = expression.trim(); + if (!trimmed.startsWith('[')) { + return parseDefaultLiteralValue(trimmed) ? { kind: 'scalar' } : undefined; + } + if (!trimmed.endsWith(']')) { + return undefined; + } + const body = trimmed.slice(1, -1).trim(); + if (body.length === 0) { + return { kind: 'array', value: [] }; + } + const elements: (string | number | boolean | null)[] = []; + for (const rawElement of splitTopLevelArrayElements(body)) { + const parsed = parseDefaultLiteralValue(rawElement.trim()); + if (!parsed || parsed.kind !== 'literal') { + return undefined; + } + const value = parsed.value; + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' && + value !== null + ) { + return undefined; + } + elements.push(value); + } + return { kind: 'array', value: elements }; +} + export function lowerDefaultForField(input: { readonly modelName: string; readonly fieldName: string; @@ -714,6 +793,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 +822,24 @@ export function lowerDefaultForField(input: { return {}; } + if (input.isList) { + const listParse = parseListDefaultLiteralValue(expressionEntry.value); + 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 737b8f3c72..1026cefc0a 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 @@ -463,6 +463,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv sourceId, defaultFunctionRegistry, diagnostics, + isList: isListField, }) : {}; const loweredOnCreate = loweredDefault.executionDefaults?.onCreate; 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 28c6098847..0c4ed97027 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 @@ -14,6 +14,7 @@ import { sqliteTarget, symbolTableInputFromParseArgs, } from './fixtures'; +import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contract-storage'; const baseInput = { target: postgresTarget, @@ -1285,4 +1286,88 @@ describe('interpretPslDocumentToSqlContract list-field constructs', () => { }, }); }); + + 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'] }, + }); + }); }); 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 e34c59bfec..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), @@ -237,7 +250,7 @@ function buildStorageColumn( 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 { diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/ddl-types.ts b/packages/2-sql/4-lanes/relational-core/src/ast/ddl-types.ts index 8130e9d57a..be33a27aa0 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/ddl-types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/ddl-types.ts @@ -200,4 +200,29 @@ export class UniqueConstraint { } } -export type DdlTableConstraint = PrimaryKeyConstraint | ForeignKeyConstraint | UniqueConstraint; +/** + * A table-level CHECK constraint carrying a raw SQL predicate expression. Used + * for checks that are not enum value-set restrictions — e.g. the element-non-null + * constraint on a scalar-array column (`array_position(col, NULL) IS NULL`). + * The `expression` is emitted verbatim, so callers must supply safe, + * pre-validated SQL. + * + * Frozen on construction — immutable after creation. + */ +export class CheckExpressionConstraint { + readonly kind = 'check-expression' as const; + readonly name: string; + readonly expression: string; + + constructor(options: { readonly name: string; readonly expression: string }) { + this.name = options.name; + this.expression = options.expression; + Object.freeze(this); + } +} + +export type DdlTableConstraint = + | PrimaryKeyConstraint + | ForeignKeyConstraint + | UniqueConstraint + | CheckExpressionConstraint; diff --git a/packages/2-sql/4-lanes/relational-core/src/contract-free/column.ts b/packages/2-sql/4-lanes/relational-core/src/contract-free/column.ts index effee14f1c..3faa8beb8b 100644 --- a/packages/2-sql/4-lanes/relational-core/src/contract-free/column.ts +++ b/packages/2-sql/4-lanes/relational-core/src/contract-free/column.ts @@ -3,6 +3,7 @@ import type { ReferentialAction } from '@prisma-next/sql-contract/types'; import type { CodecRef } from '../ast/codec-types'; import type { AnyDdlColumnDefault } from '../ast/ddl-types'; import { + CheckExpressionConstraint, DdlColumn, ForeignKeyConstraint, FunctionColumnDefault, @@ -56,3 +57,7 @@ export function unique( ): UniqueConstraint { return new UniqueConstraint({ columns, ...options }); } + +export function checkExpression(name: string, expression: string): CheckExpressionConstraint { + return new CheckExpressionConstraint({ name, expression }); +} diff --git a/packages/2-sql/4-lanes/relational-core/src/exports/contract-free.ts b/packages/2-sql/4-lanes/relational-core/src/exports/contract-free.ts index 25b000be6d..65b1920a6b 100644 --- a/packages/2-sql/4-lanes/relational-core/src/exports/contract-free.ts +++ b/packages/2-sql/4-lanes/relational-core/src/exports/contract-free.ts @@ -1,4 +1,5 @@ export { + checkExpression, col, type DdlColumnOptions, fn, 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..1138046faa 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 @@ -31,6 +31,7 @@ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { Result } from '@prisma-next/utils/result'; import { notOk, ok } from '@prisma-next/utils/result'; +import { quoteIdentifier } from '../sql-utils'; import { AddColumnCall, AddForeignKeyCall, @@ -67,6 +68,26 @@ import { resolveColumnTypeMetadata } from './planner-type-resolution'; export type { CallMigrationStrategy, StrategyContext }; +/** + * Deterministic name for the element-non-null CHECK constraint on a scalar-array + * column. 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. + */ +function elementNonNullCheckName(tableName: string, columnName: string): string { + return `${tableName}_${columnName}_elem_not_null`; +} + +/** + * 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. + */ +function elementNonNullCheckExpression(columnName: string): string { + return `array_position(${quoteIdentifier(columnName)}, NULL) IS NULL`; +} + // ============================================================================ // Issue kind ordering (dependency order) // ============================================================================ @@ -254,16 +275,28 @@ function mapIssueToCall( ); } const schemaForTable = tableSchema(issue); + const missingTableName = issue.table; 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; + : []; + const elementNonNullChecks: DdlTableConstraint[] = Object.entries(contractTable.columns) + .filter(([, column]) => column.many === true) + .map(([columnName]) => + contractFree.checkExpression( + elementNonNullCheckName(missingTableName, columnName), + elementNonNullCheckExpression(columnName), + ), + ); + const allTableConstraints = [...primaryKeyConstraints, ...elementNonNullChecks]; + const ddlConstraints: DdlTableConstraint[] | undefined = + allTableConstraints.length > 0 ? allTableConstraints : 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..f4dc4f9e61 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 @@ -168,6 +168,8 @@ function renderDdlConstraintAsTsCall(constraint: DdlTableConstraint): string { const nameOpt = constraint.name ? `, { name: ${jsonToTsSource(constraint.name)} }` : ''; return `unique(${jsonToTsSource(constraint.columns)}${nameOpt})`; } + case 'check-expression': + return `checkExpression(${jsonToTsSource(constraint.name)}, ${jsonToTsSource(constraint.expression)})`; } } @@ -182,6 +184,7 @@ function constraintImportSymbols(constraints: readonly DdlTableConstraint[] | un if (c.kind === 'primary-key') symbols.add('primaryKey'); else if (c.kind === 'foreign-key') symbols.add('foreignKey'); else if (c.kind === 'unique') symbols.add('unique'); + else if (c.kind === 'check-expression') symbols.add('checkExpression'); } return [...symbols]; } diff --git a/packages/3-targets/3-targets/postgres/src/exports/migration.ts b/packages/3-targets/3-targets/postgres/src/exports/migration.ts index f34e7373a9..5216fc561b 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/migration.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/migration.ts @@ -10,6 +10,7 @@ export { MigrationCLI } from '@prisma-next/cli/migration-cli'; // directly. The planner emits an import from this same module. export { placeholder } from '@prisma-next/errors/migration'; export { + checkExpression, col, fn, foreignKey, diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts index 933c0b3e5f..40a7b08482 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts @@ -96,6 +96,11 @@ function renderDdlConstraintAsTsCall(constraint: DdlTableConstraint): string { const nameOpt = constraint.name ? `, { name: ${jsonToTsSource(constraint.name)} }` : ''; return `unique(${jsonToTsSource(constraint.columns)}${nameOpt})`; } + case 'check-expression': + throw new Error( + `SQLite does not support expression CHECK constraints (constraint "${constraint.name}"). ` + + 'Scalar-array columns and their element-non-null checks are Postgres-only.', + ); } } 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..dea3cc7945 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 @@ -1657,6 +1657,9 @@ function pgRenderDdlConstraint(constraint: DdlTableConstraint): string { } return sql; } + if (constraint.kind === 'check-expression') { + return `CONSTRAINT ${quoteIdentifier(constraint.name)} CHECK (${constraint.expression})`; + } const cols = constraint.columns.map(quoteIdentifier).join(', '); if (constraint.name !== undefined) { return `CONSTRAINT ${quoteIdentifier(constraint.name)} UNIQUE (${cols})`; 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..2cc53bd158 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 @@ -277,6 +277,97 @@ 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_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 () => { diff --git a/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts b/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts index dd4fd89fc5..568b909b78 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts @@ -730,6 +730,12 @@ function sqliteRenderDdlConstraint(constraint: DdlTableConstraint): string { } return sql; } + if (constraint.kind === 'check-expression') { + throw new Error( + `SQLite does not support expression CHECK constraints (constraint "${constraint.name}"). ` + + 'Scalar-array columns and their element-non-null checks are Postgres-only.', + ); + } const cols = constraint.columns.map(quoteIdentifier).join(', '); if (constraint.name !== undefined) { return `CONSTRAINT ${quoteIdentifier(constraint.name)} UNIQUE (${cols})`; From 11ea91421bd8bf1ad6e02f00e935863fcaa3be77 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 24 Jun 2026 17:29:55 +0000 Subject: [PATCH 06/18] test(scalar-arrays): end-to-end PSL list milestone + Mongo parity (slice 2 D5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC1: a `posts.tags String[]` schema authored in PSL emits a native array storage column (pg/text@1, many:true, not jsonb), migrates onto a fresh Postgres database as text[] with no manual edits, and round-trips through `contract infer` back to a `tags String[]` PSL field. AC2/NFR1: DateTime[]/Bytes[]/Decimal[] list fields authored in PSL (not hand-built typed contracts) insert and select rows whose decoded element values deep-equal the originals, proving per-element codec application through the whole authored path. NFR4: the same list schema yields matching observable semantics on SQL and Mongo — both author cleanly, both generate a ReadonlyArray<...> domain type over the string element codec, and list element values round-trip. The live Mongo decode runs on mongodb-memory-server (CI; nixos binary download is local env-noise). Also hardens the introspection-side array-literal parser (parseArrayLiteralBody in default-normalizer.ts) so a quoted element containing a comma or an escaped quote round-trips through the migration-diff layer without spurious drift (slice-1 carry-in). Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../postgres/src/core/default-normalizer.ts | 76 ++++- .../postgres/test/default-normalizer.test.ts | 28 ++ .../test/scalar-lists/psl-list-authoring.ts | 206 +++++++++++++ .../psl-list-mongo-parity.integration.test.ts | 154 ++++++++++ .../psl-list-roundtrip.integration.test.ts | 286 ++++++++++++++++++ 5 files changed, 743 insertions(+), 7 deletions(-) create mode 100644 test/integration/test/scalar-lists/psl-list-authoring.ts create mode 100644 test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts create mode 100644 test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts 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/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/test/integration/test/scalar-lists/psl-list-authoring.ts b/test/integration/test/scalar-lists/psl-list-authoring.ts new file mode 100644 index 0000000000..071a1c86a0 --- /dev/null +++ b/test/integration/test/scalar-lists/psl-list-authoring.ts @@ -0,0 +1,206 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import mongoAdapter from '@prisma-next/adapter-mongo/control'; +import postgresAdapter from '@prisma-next/adapter-postgres/control'; +import type { ContractSourceContext } from '@prisma-next/cli/config-types'; +import { enrichContract } from '@prisma-next/cli/control-api'; +import type { Contract, JsonValue } from '@prisma-next/contract/types'; +import { mongoFamilyDescriptor } from '@prisma-next/family-mongo/control'; +import sql from '@prisma-next/family-sql/control'; +import { createControlStack } from '@prisma-next/framework-components/control'; +import type { MongoContract } from '@prisma-next/mongo-contract'; +import { mongoContract } from '@prisma-next/mongo-contract-psl/provider'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { prismaContract } from '@prisma-next/sql-contract-psl/provider'; +import { mongoTargetDescriptor } from '@prisma-next/target-mongo/control'; +import postgres from '@prisma-next/target-postgres/control'; +import postgresPackRef from '@prisma-next/target-postgres/pack'; +import { postgresCreateNamespace } from '@prisma-next/target-postgres/types'; +import { join } from 'pathe'; + +const sqlStack = createControlStack({ + family: sql, + target: postgres, + adapter: postgresAdapter, +}); + +const mongoStack = createControlStack({ + family: mongoFamilyDescriptor, + target: mongoTargetDescriptor, + adapter: mongoAdapter, +}); + +const sqlSourceContext: ContractSourceContext = { + composedExtensionPacks: [], + composedExtensionContracts: new Map(), + scalarTypeDescriptors: sqlStack.scalarTypeDescriptors, + authoringContributions: sqlStack.authoringContributions, + codecLookup: sqlStack.codecLookup, + controlMutationDefaults: sqlStack.controlMutationDefaults, + resolvedInputs: [], +}; + +const mongoSourceContext: ContractSourceContext = { + composedExtensionPacks: [], + composedExtensionContracts: new Map(), + scalarTypeDescriptors: mongoStack.scalarTypeDescriptors, + authoringContributions: mongoStack.authoringContributions, + codecLookup: mongoStack.codecLookup, + controlMutationDefaults: mongoStack.controlMutationDefaults, + resolvedInputs: [], +}; + +export const postgresFrameworkComponents = [postgres, postgresAdapter] as const; +export const mongoFrameworkComponents = [mongoTargetDescriptor, mongoAdapter] as const; + +function writeSchemaToTempFile(schema: string): string { + const dir = mkdtempSync(join(tmpdir(), 'psl-list-')); + const path = join(dir, 'schema.prisma'); + writeFileSync(path, schema, 'utf-8'); + return path; +} + +export interface SqlAuthoringResult { + readonly ok: boolean; + readonly diagnostics: ReadonlyArray<{ readonly code: string; readonly message: string }>; + readonly contract?: Contract; +} + +/** + * Authors a PSL document to a deserialized SQL contract through the production + * provider + enrichment path the CLI uses. Returns diagnostics on failure so + * callers can assert authoring acceptance/rejection. + */ +export async function authorSqlContractFromPsl(schema: string): Promise { + const schemaPath = writeSchemaToTempFile(schema); + const provider = prismaContract(schemaPath, { + target: postgresPackRef, + createNamespace: postgresCreateNamespace, + }); + + const providerResult = await provider.source.load({ + ...sqlSourceContext, + resolvedInputs: [schemaPath], + }); + + if (!providerResult.ok) { + return { ok: false, diagnostics: providerResult.failure.diagnostics }; + } + + const familyInstance = sql.create(sqlStack); + const contract = familyInstance.deserializeContract( + enrichContract(providerResult.value, postgresFrameworkComponents), + ) as Contract; + + return { ok: true, diagnostics: [], contract }; +} + +/** + * Walks every table in the contract's storage namespaces and returns the first + * column matching `columnName`. List schemas in these tests use a single model, + * so the table name (model-derived) is incidental to the assertion. + */ +export function findStorageColumn( + contract: Contract, + columnName: string, +): Record | undefined { + for (const namespace of Object.values(contract.storage.namespaces)) { + const tables = namespace.entries.table ?? {}; + for (const table of Object.values(tables)) { + const column = table.columns[columnName]; + if (column) { + return column as unknown as Record; + } + } + } + return undefined; +} + +export interface MongoAuthoringResult { + readonly ok: boolean; + readonly diagnostics: ReadonlyArray<{ readonly code: string; readonly message: string }>; + readonly contract?: MongoContract; +} + +/** + * Authors a PSL document to a deserialized Mongo contract through the + * production provider + enrichment path, mirroring `authorSqlContractFromPsl` + * so SQL/Mongo parity can be asserted on identical input. + */ +export async function authorMongoContractFromPsl(schema: string): Promise { + const schemaPath = writeSchemaToTempFile(schema); + const provider = mongoContract(schemaPath); + + const providerResult = await provider.source.load({ + ...mongoSourceContext, + resolvedInputs: [schemaPath], + }); + + if (!providerResult.ok) { + return { ok: false, diagnostics: providerResult.failure.diagnostics }; + } + + const familyInstance = mongoFamilyDescriptor.create(mongoStack); + const contract = familyInstance.deserializeContract( + enrichContract(providerResult.value, mongoFrameworkComponents), + ) as MongoContract; + + return { ok: true, diagnostics: [], contract }; +} + +/** + * Returns the storage table name (the DDL identifier) for the table that owns + * `columnName`. PSL lowercases model names into table keys, so tests must drive + * the table name from the contract rather than the PSL model name. + */ +export function tableNameForColumn(contract: Contract, columnName: string): string { + for (const namespace of Object.values(contract.storage.namespaces)) { + const tables = namespace.entries.table ?? {}; + for (const [tableName, table] of Object.entries(tables)) { + if (table.columns[columnName]) { + return tableName; + } + } + } + throw new Error(`no table owns column "${columnName}"`); +} + +export interface ListCodecRef { + readonly codecId: string; + readonly many: true; + readonly typeParams?: JsonValue; +} + +/** + * Derives the `{ codecId, typeParams, many }` codec reference for a list column + * straight from the authored contract, so AST params/projections match what the + * PSL path actually emitted. Parameterized codecs (e.g. `pg/numeric@1`) carry + * their typeParams either inline on the column or via a `typeRef` into + * `storage.types`; both are resolved here. + */ +export function listCodecRefFor(contract: Contract, columnName: string): ListCodecRef { + const column = findStorageColumn(contract, columnName); + if (!column) { + throw new Error(`column "${columnName}" not found in authored contract`); + } + const codecId = column['codecId']; + if (typeof codecId !== 'string') { + throw new Error(`column "${columnName}" has no codecId`); + } + + let typeParams = column['typeParams'] as JsonValue | undefined; + const typeRef = column['typeRef']; + if (typeParams === undefined && typeof typeRef === 'string') { + const storageTypes = (contract.storage as unknown as { types?: Record }).types; + const typeEntry = storageTypes?.[typeRef] as { typeParams?: JsonValue } | undefined; + typeParams = typeEntry?.typeParams; + } + + return { + codecId, + many: true, + ...(typeParams !== undefined ? { typeParams } : {}), + }; +} + +export { mongoStack, sqlStack }; diff --git a/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts b/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts new file mode 100644 index 0000000000..ded2da6ee3 --- /dev/null +++ b/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts @@ -0,0 +1,154 @@ +/** + * Slice-2 NFR4 — Mongo parity for PSL scalar lists. + * + * The same PSL list schema must yield matching *observable* semantics on SQL + * and Mongo: + * 1. Authoring acceptance — both families author the schema with no + * diagnostics. + * 2. Generated domain type — the emitted `contract.d.ts` types the list field + * as `ReadonlyArray<...>` on both families. + * 3. Decoded element values — element values round-trip through insert/select + * equal to the originals on both families. + * + * Assertions 1 and 2 require no database and run everywhere. Assertion 3 for + * Mongo runs against `mongodb-memory-server`, which fails to spin up on some + * local sandboxes (UnknownLinuxDistro "nixos"); the assertion is written for + * CI, where the memory server runs. The SQL half of assertion 3 is proven in + * `psl-list-roundtrip.integration.test.ts` (AC2). + */ +import type { SerializeContract } from '@prisma-next/contract/hashing'; +import { emit } from '@prisma-next/emitter'; +import { mongoFamilyDescriptor } from '@prisma-next/family-mongo/control'; +import sql from '@prisma-next/family-sql/control'; +import { mongoContractCanonicalizationHooks } from '@prisma-next/mongo-contract/canonicalization-hooks'; +import { sqlContractCanonicalizationHooks } from '@prisma-next/sql-contract/canonicalization-hooks'; +import { type MongoTargetContract, mongoTargetDescriptor } from '@prisma-next/target-mongo/control'; +import postgres from '@prisma-next/target-postgres/control'; +import { timeouts } from '@prisma-next/test-utils'; +import type { JsonObject } from '@prisma-next/utils/json'; +import { MongoClient } from 'mongodb'; +import { MongoMemoryReplSet } from 'mongodb-memory-server'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + authorMongoContractFromPsl, + authorSqlContractFromPsl, + mongoStack, + sqlStack, +} from './psl-list-authoring'; + +// The parity subject is the `tags String[]` list field; the id line differs +// only by each family's id convention (SQL integer id vs Mongo ObjectId `_id`). +const SQL_LIST_SCHEMA = `model Note { + id Int @id + tags String[] +}`; + +const MONGO_LIST_SCHEMA = `model Note { + id ObjectId @id @map("_id") + tags String[] +}`; + +const sqlSerializeContract: SerializeContract = (contract) => + postgres.contractSerializer.serializeContract( + contract as Parameters[0], + ); + +const mongoSerializeContract: SerializeContract = (contract) => + mongoTargetDescriptor.contractSerializer.serializeContract(contract as MongoTargetContract); + +describe('PSL scalar-list Mongo parity (NFR4)', () => { + it( + 'both SQL and Mongo author the same list schema with no diagnostics', + async () => { + const sqlResult = await authorSqlContractFromPsl(SQL_LIST_SCHEMA); + const mongoResult = await authorMongoContractFromPsl(MONGO_LIST_SCHEMA); + + expect(sqlResult.diagnostics).toEqual([]); + expect(sqlResult.ok).toBe(true); + expect(mongoResult.diagnostics).toEqual([]); + expect(mongoResult.ok).toBe(true); + }, + timeouts.typeScriptCompilation, + ); + + it( + 'both SQL and Mongo generate ReadonlyArray for the list field', + async () => { + const sqlResult = await authorSqlContractFromPsl(SQL_LIST_SCHEMA); + const mongoResult = await authorMongoContractFromPsl(MONGO_LIST_SCHEMA); + if (!sqlResult.contract || !mongoResult.contract) { + throw new Error('authoring produced no contract'); + } + + const sqlEmitted = await emit(sqlResult.contract, sqlStack, sql.emission, { + serializeContract: (c) => sqlSerializeContract(c) as unknown as JsonObject, + ...sqlContractCanonicalizationHooks, + }); + const mongoEmitted = await emit( + mongoResult.contract, + mongoStack, + mongoFamilyDescriptor.emission, + { + serializeContract: (c) => mongoSerializeContract(c) as unknown as JsonObject, + ...mongoContractCanonicalizationHooks, + }, + ); + + // Both families render the list field as a `ReadonlyArray<...>` domain + // type over their string element codec's output type — the observable + // generated-type parity (the element codec id differs by family). + expect(sqlEmitted.contractDts).toContain( + "readonly tags: ReadonlyArray", + ); + expect(mongoEmitted.contractDts).toContain( + "readonly tags: ReadonlyArray", + ); + }, + timeouts.typeScriptCompilation, + ); + + describe('decoded element values round-trip on Mongo', () => { + let replSet: MongoMemoryReplSet | undefined; + let client: MongoClient | undefined; + + beforeAll(async () => { + replSet = await MongoMemoryReplSet.create({ + instanceOpts: [ + { launchTimeout: timeouts.spinUpMongoMemoryServer, storageEngine: 'wiredTiger' }, + ], + replSet: { count: 1, storageEngine: 'wiredTiger' }, + }); + client = new MongoClient(replSet.getUri()); + await client.connect(); + }, timeouts.spinUpMongoMemoryServer); + + afterAll(async () => { + try { + await client?.close(); + await replSet?.stop(); + } catch { + // ignore teardown failures + } + }, timeouts.spinUpMongoMemoryServer); + + it( + 'a list value inserted into Mongo round-trips element-for-element', + async () => { + if (!client) throw new Error('mongo client not initialised'); + + const mongoResult = await authorMongoContractFromPsl(MONGO_LIST_SCHEMA); + expect(mongoResult.ok).toBe(true); + + const tags = ['react', 'typescript', 'prisma']; + const db = client.db('psl_list_parity'); + await db.dropDatabase(); + const collection = db.collection('Note'); + await collection.insertOne({ tags }); + + const stored = await collection.findOne({}); + expect(stored?.['tags']).toEqual(tags); + }, + timeouts.spinUpMongoMemoryServer, + ); + }); +}); diff --git a/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts new file mode 100644 index 0000000000..d906576e5c --- /dev/null +++ b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts @@ -0,0 +1,286 @@ +/** + * Slice-2 milestone checkpoint (AC1) + element fidelity through the PSL path + * (AC2 / NFR1). + * + * AC1: a `posts.tags String[]` schema authored in PSL emits a contract whose + * `tags` storage column is a native array column (`pg/text@1`, `many:true`, NOT + * jsonb), migrates onto a fresh Postgres database as a `text[]` column with no + * manual edits, and round-trips through `contract infer` back to a `tags + * String[]` PSL field. + * + * AC2/NFR1: a schema with `DateTime[]`, `Bytes[]`, and `Decimal[]` list fields + * — authored in PSL, not a hand-built typed contract — inserts and selects + * rows whose decoded element values deep-equal the originals, proving the + * element codec is applied per element through the whole authored path. + */ +import postgresAdapter from '@prisma-next/adapter-postgres/control'; +import type { Contract } from '@prisma-next/contract/types'; +import postgresControlDriver from '@prisma-next/driver-postgres/control'; +import sql, { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; +import { APP_SPACE_ID, createControlStack } from '@prisma-next/framework-components/control'; +import { flatPslModels } from '@prisma-next/framework-components/psl-ast'; +import { buildSynthMigrationEdge } from '@prisma-next/migration-tools/aggregate'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { + BinaryExpr, + ColumnRef, + InsertAst, + ParamRef, + ProjectionItem, + SelectAst, + TableSource, +} from '@prisma-next/sql-relational-core/ast'; +import { planFromAst } from '@prisma-next/sql-relational-core/plan'; +import postgres from '@prisma-next/target-postgres/control'; +import { createDevDatabase, type DevDatabase, timeouts, withClient } from '@prisma-next/test-utils'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createTestRuntimeFromClient } from '../utils'; +import { + authorSqlContractFromPsl, + findStorageColumn, + listCodecRefFor, + postgresFrameworkComponents, + tableNameForColumn, +} from './psl-list-authoring'; + +const controlStack = createControlStack({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresControlDriver, + extensionPacks: [], +}); +const familyInstance = sql.create(controlStack); + +async function migrateContract( + connectionString: string, + contract: Contract, +): Promise { + const driver = await postgresControlDriver.create(connectionString); + try { + const schema = await familyInstance.introspect({ driver }); + const planner = postgres.createPlanner(postgresAdapter.create(controlStack)); + const planResult = planner.plan({ + contract, + schema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents: postgresFrameworkComponents, + spaceId: APP_SPACE_ID, + }); + if (planResult.kind !== 'success') { + throw new Error(`planner failed: ${JSON.stringify(planResult)}`); + } + + const runner = postgres.createRunner(familyInstance); + const runResult = await runner.execute({ + driver, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan: planResult.plan, + migrationEdges: [ + buildSynthMigrationEdge({ + currentMarkerStorageHash: planResult.plan.origin?.storageHash, + destinationStorageHash: planResult.plan.destination.storageHash, + operationCount: planResult.plan.operations.length, + }), + ], + driver, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents: postgresFrameworkComponents, + }, + ], + }); + if (!runResult.ok) { + throw new Error(`runner failed: ${JSON.stringify(runResult.failure)}`); + } + } finally { + await driver.close(); + } +} + +describe.sequential('PSL scalar-list milestone (AC1 + AC2/NFR1)', () => { + let database: DevDatabase | undefined; + + beforeAll(async () => { + database = await createDevDatabase(); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) await database.close(); + }, timeouts.spinUpPpgDev); + + it( + 'AC1: posts.tags String[] authors → migrates as text[] → infers back to tags String[]', + async () => { + if (!database) throw new Error('database not initialised'); + + // --- author --- + const authored = await authorSqlContractFromPsl(`model Post { + id Int @id + tags String[] +}`); + expect(authored.ok).toBe(true); + const contract = authored.contract; + if (!contract) throw new Error('authoring produced no contract'); + + // The flip: tags is a native array column, not the jsonb fallback. + const tagsColumn = findStorageColumn(contract, 'tags'); + expect(tagsColumn).toMatchObject({ + codecId: 'pg/text@1', + nativeType: 'text', + many: true, + }); + expect(tagsColumn?.['nativeType']).not.toBe('jsonb'); + + // --- migrate onto a fresh database, no manual edits --- + await withClient(database.connectionString, async (client) => { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + }); + await migrateContract(database.connectionString, contract); + + // The migrated column is a real Postgres array (`text[]`). + await withClient(database.connectionString, async (client) => { + const formatted = await client.query<{ attname: string; formatted_type: string }>( + `SELECT a.attname, format_type(a.atttypid, a.atttypmod) AS formatted_type + FROM pg_catalog.pg_attribute a + JOIN pg_catalog.pg_class c ON c.oid = a.attrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND a.attname = 'tags' + AND a.attnum > 0 AND NOT a.attisdropped`, + ); + expect(formatted.rows[0]?.formatted_type).toBe('text[]'); + }); + + // --- infer: live DB → schema IR → PSL AST round-trips to tags String[] --- + const driver = await postgresControlDriver.create(database.connectionString); + try { + const schemaIR = await familyInstance.introspect({ driver }); + const inferredAst = familyInstance.inferPslContract(schemaIR); + const models = flatPslModels(inferredAst); + const postModel = models.find((model) => + model.fields.some((field) => field.name === 'tags'), + ); + expect(postModel).toBeDefined(); + const tagsField = postModel?.fields.find((field) => field.name === 'tags'); + // `tags String[]` — a non-null list of a String element. + expect(tagsField).toMatchObject({ + name: 'tags', + typeName: 'String', + list: true, + optional: false, + }); + } finally { + await driver.close(); + } + }, + timeouts.spinUpPpgDev, + ); + + it( + 'AC2/NFR1: DateTime[]/Bytes[]/Decimal[] authored in PSL round-trip element values', + async () => { + if (!database) throw new Error('database not initialised'); + + // `Decimal` is a parameterized codec (`pg/numeric@1`); a bare list element + // would carry no precision/scale, so the element type is pinned via a named + // type carrying `@db.Numeric(...)` — the same way scalar Decimal fields are + // authored when fidelity matters. + const authored = await authorSqlContractFromPsl(`types { + Amount = Decimal @db.Numeric(30, 10) +} + +model Reading { + id Int @id + dates DateTime[] + payloads Bytes[] + amounts Amount[] +}`); + expect(authored.ok).toBe(true); + const contract = authored.contract; + if (!contract) throw new Error('authoring produced no contract'); + + // Sanity: each list field lowered to a native array column (element codec + // retained), not the jsonb fallback. + expect(findStorageColumn(contract, 'dates')).toMatchObject({ + codecId: 'pg/timestamptz@1', + many: true, + }); + expect(findStorageColumn(contract, 'payloads')).toMatchObject({ + codecId: 'pg/bytea@1', + many: true, + }); + expect(findStorageColumn(contract, 'amounts')).toMatchObject({ + codecId: 'pg/numeric@1', + many: true, + }); + + await withClient(database.connectionString, async (client) => { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + }); + await migrateContract(database.connectionString, contract); + + const tableName = tableNameForColumn(contract, 'dates'); + const table = TableSource.named(tableName); + const datesRef = listCodecRefFor(contract, 'dates'); + const payloadsRef = listCodecRefFor(contract, 'payloads'); + const amountsRef = listCodecRefFor(contract, 'amounts'); + + const dates = [new Date('2026-01-02T03:04:05.000Z'), new Date('2025-06-15T12:00:00.000Z')]; + const payloads = [new Uint8Array([1, 2, 3]), new Uint8Array([255, 0, 127])]; + const amounts = ['1.5', '999999999999.99', '-0.001']; + + await withClient(database.connectionString, async (client) => { + const runtime = await createTestRuntimeFromClient(contract, client, { + verifyMarker: false, + }); + + const insert = InsertAst.into(table).withRows([ + { + id: ParamRef.of(1, { codec: { codecId: 'pg/int4@1' } }), + dates: ParamRef.of(dates, { codec: datesRef }), + payloads: ParamRef.of(payloads, { codec: payloadsRef }), + amounts: ParamRef.of(amounts, { codec: amountsRef }), + }, + ]); + await runtime.execute(planFromAst(insert, contract)).toArray(); + + const select = SelectAst.from(table) + .withProjection([ + ProjectionItem.of('dates', ColumnRef.of(tableName, 'dates'), datesRef), + ProjectionItem.of('payloads', ColumnRef.of(tableName, 'payloads'), payloadsRef), + ProjectionItem.of('amounts', ColumnRef.of(tableName, 'amounts'), amountsRef), + ]) + .withWhere( + BinaryExpr.eq( + ColumnRef.of(tableName, 'id'), + ParamRef.of(1, { codec: { codecId: 'pg/int4@1' } }), + ), + ); + + const rows = await runtime.execute(planFromAst(select, contract)).toArray(); + expect(rows).toHaveLength(1); + const row = rows[0] as unknown as { + dates: Date[]; + payloads: Uint8Array[]; + amounts: string[]; + }; + + expect(row.dates.map((value) => value.toISOString())).toEqual( + dates.map((value) => value.toISOString()), + ); + expect(row.payloads.map((value) => [...value])).toEqual( + payloads.map((value) => [...value]), + ); + expect(row.amounts).toEqual(amounts); + }); + }, + timeouts.spinUpPpgDev, + ); +}); From d0e9ce795c6948624dc7eed0394ae3074030021c Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 1 Jul 2026 09:01:07 +0000 Subject: [PATCH 07/18] fix(scalar-arrays): supply required createNamespace in capability-gating test (rebase on #864) Main #864 made SqlNamespace abstract and createNamespace required on InterpretPslDocumentToSqlContractInput. Thread createTestSqlNamespace through the three D3 capability-gating interpret calls, matching the sibling interpreter tests updated by that refactor. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../contract-psl/test/interpreter.capability-gating.test.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 index 873d7ffc69..80d581efcd 100644 --- 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 @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, @@ -31,6 +32,7 @@ describe('interpretPslDocumentToSqlContract scalar-list capability gating', () = target: sqliteTarget, scalarTypeDescriptors: sqliteScalarTypeDescriptors, composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, capabilities: sqliteCapabilities, ...document, controlMutationDefaults: builtinControlMutationDefaults, @@ -59,6 +61,7 @@ describe('interpretPslDocumentToSqlContract scalar-list capability gating', () = target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, capabilities: postgresCapabilities, ...document, controlMutationDefaults: builtinControlMutationDefaults, @@ -89,6 +92,7 @@ describe('interpretPslDocumentToSqlContract scalar-list capability gating', () = target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, ...document, controlMutationDefaults: builtinControlMutationDefaults, }); From 4b2a89b1cff09e9803b5b8e7259478f1ccdc9277 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 1 Jul 2026 10:11:39 +0000 Subject: [PATCH 08/18] fix(scalar-arrays): address PR review on PSL native scalar lists Respond to reviewer feedback on the scalar-list authoring path: - Simplify the `many` emission to the file's own `...(rf.many ? { many: true as const } : {})` idiom; `many` stays omitted (never `false`) for non-list fields, keeping canonical emission byte-stable. - Consume the parser's structured expression AST for list-field `@default([...])` instead of re-parsing a stringified form. The PSL parser now decodes each attribute-arg expression into a `ResolvedExpr` discriminated union (string/number/boolean/array/ object/call/identifier) exposed on `ResolvedAttributeArg`, and the SQL column resolver reads it directly. Deletes the hand-rolled `splitTopLevelArrayElements` tokenizer and the array-literal string parsing; array/scalar/function-default behavior is preserved, including quoted elements containing commas. - Make the adapter capability matrix required end-to-end so the scalar-list gate has no test-only undefined branch and fails closed: an empty matrix rejects scalar lists. `ContractSourceContext`, `InterpretPslDocumentToSqlContractInput`, and the internal resolution inputs now require `capabilities`; production already threads it via the control stack. Test call sites pass an explicit matrix; the former "does not gate when no matrix is threaded" case becomes a fail-closed empty-matrix rejection. - Drop transient milestone codes from the scalar-list integration test comments and titles. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../config/src/contract-source-types.ts | 8 +- .../psl-parser/src/exports/index.ts | 1 + .../2-authoring/psl-parser/src/resolve.ts | 67 ++++++++++++++- .../psl-parser/src/symbol-table.ts | 1 + .../3-tooling/cli/test/config-types.test.ts | 1 + .../contract-psl/test/provider.test.ts | 1 + .../contract-ts/test/config-types.test.ts | 1 + .../contract-psl/src/interpreter.ts | 22 ++--- .../2-authoring/contract-psl/src/provider.ts | 2 +- .../contract-psl/src/psl-attribute-parsing.ts | 5 +- .../contract-psl/src/psl-column-resolution.ts | 84 +++++-------------- .../contract-psl/src/psl-field-resolution.ts | 15 ++-- .../test/composed-mutation-defaults.test.ts | 7 +- .../2-authoring/contract-psl/test/fixtures.ts | 1 + .../interpreter.capability-gating.test.ts | 15 ++-- .../test/interpreter.control-policy.test.ts | 1 + .../test/interpreter.defaults.test.ts | 8 +- .../test/interpreter.diagnostics.test.ts | 81 ++++++++++++++++++ .../test/interpreter.enum.test.ts | 1 + .../test/interpreter.extensions.test.ts | 2 + .../test/interpreter.namespaces.test.ts | 1 + .../test/interpreter.polymorphism.test.ts | 7 +- ...interpreter.relations.many-to-many.test.ts | 1 + .../test/interpreter.relations.test.ts | 1 + .../contract-psl/test/interpreter.test.ts | 10 ++- .../test/interpreter.types.test.ts | 1 + .../test/interpreter.value-objects.test.ts | 7 +- .../test/psl-ts-namespace-parity.test.ts | 3 + .../contract-psl/test/ts-psl-parity.test.ts | 2 + .../contract-ts/test/config-types.test.ts | 1 + .../psl-namespace-qualifier-routing.test.ts | 3 + .../test/psl-policy-authoring.test.ts | 1 + .../rls-lifecycle-e2e.integration.test.ts | 1 + .../rls-migration-plan.integration.test.ts | 1 + ...s-walking-skeleton-psl.integration.test.ts | 1 + .../cli.emit-parity-fixtures.test.ts | 1 + .../parity/ts-psl-parity.real-packs.test.ts | 1 + ...psl-index-type-options.integration.test.ts | 1 + .../authoring/psl.pgvector-dbinit.test.ts | 1 + .../authoring/side-by-side-contracts.test.ts | 2 + .../test/cli.emit-command.additional.test.ts | 1 + .../test/cli.emit-contract.test.ts | 1 + .../test/scalar-lists/psl-list-authoring.ts | 2 + .../psl-list-mongo-parity.integration.test.ts | 6 +- .../psl-list-roundtrip.integration.test.ts | 13 ++- .../value-objects.integration.test.ts | 1 + 46 files changed, 279 insertions(+), 116 deletions(-) diff --git a/packages/1-framework/1-core/config/src/contract-source-types.ts b/packages/1-framework/1-core/config/src/contract-source-types.ts index f1d19ff415..195053af2e 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 @@ -48,11 +48,11 @@ export interface ContractSourceContext { readonly resolvedInputs: readonly string[]; /** * Merged capability matrix from the control stack's `[target, adapter, ...extensionPacks]` - * descriptors — the same merge `enrichContract` performs at emit time. Optional so - * non-adapter authoring paths (tests, bespoke providers) may omit it; an absent matrix - * means "do not gate". The CLI emit path always populates it. + * descriptors — the same merge `enrichContract` performs at emit time. Authoring-time + * gating (e.g. scalar lists) reads `capabilities.sql?.scalarList`; an empty matrix + * fails closed and rejects gated features. The control stack always populates it. */ - readonly capabilities?: CapabilityMatrix; + readonly capabilities: CapabilityMatrix; } /** Lets format-aware tooling avoid file-extension sniffing and opaque loader introspection. */ diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts index 7ff2425a76..35d3ce89c6 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts @@ -54,6 +54,7 @@ export type { NamespaceSymbol, ResolvedAttribute, ResolvedAttributeArg, + ResolvedExpr, ResolvedNamedTypeBinding, ResolvedTypeConstructorCall, ScalarSymbol, 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..9e9efa4f78 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/resolve.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/resolve.ts @@ -5,16 +5,40 @@ import type { FieldAttributeAst, ModelAttributeAst, } from './syntax/ast/attributes'; -import type { ExpressionAst } from './syntax/ast/expressions'; +import { + ArrayLiteralAst, + BooleanLiteralExprAst, + type ExpressionAst, + FunctionCallAst, + NumberLiteralExprAst, + ObjectLiteralExprAst, + StringLiteralExprAst, +} from './syntax/ast/expressions'; +import { IdentifierAst } from './syntax/ast/identifier'; import type { QualifiedNameAst } from './syntax/ast/qualified-name'; import type { TypeAnnotationAst } from './syntax/ast/type-annotation'; import { printSyntax } from './syntax/ast-helpers'; import type { SyntaxNode } from './syntax/red'; +/** + * A structurally decoded attribute-argument expression. Consumers that need the + * shape of an expression (e.g. array-literal `@default([...])`) read this instead + * of re-parsing the stringified {@link ResolvedAttributeArg.value}. + */ +export type ResolvedExpr = + | { readonly kind: 'string'; readonly value: string } + | { readonly kind: 'number'; readonly value: number } + | { readonly kind: 'boolean'; readonly value: boolean } + | { readonly kind: 'array'; readonly elements: readonly ResolvedExpr[] } + | { readonly kind: 'object' } + | { readonly kind: 'call'; readonly path: readonly string[] } + | { readonly kind: 'identifier'; readonly name: string }; + export interface ResolvedAttributeArg { readonly kind: 'positional' | 'named'; readonly name?: string; readonly value: string; + readonly expression?: ResolvedExpr; readonly span: PslSpan; } @@ -69,16 +93,55 @@ function readResolvedArgList( const args: ResolvedAttributeArg[] = []; for (const arg of argList.args()) { const name = arg.name()?.name(); + const value = arg.value(); + const expression = decodeExpression(value); args.push({ kind: name !== undefined ? 'named' : 'positional', ...(name !== undefined ? { name } : {}), - value: renderExpression(arg.value()), + value: renderExpression(value), + ...(expression !== undefined ? { expression } : {}), span: nodePslSpan(arg.syntax, sourceFile), }); } return args; } +function decodeExpression(expression: ExpressionAst | undefined): ResolvedExpr | undefined { + if (expression === undefined) return undefined; + if (expression instanceof StringLiteralExprAst) { + const value = expression.value(); + return value === undefined ? undefined : { kind: 'string', value }; + } + if (expression instanceof NumberLiteralExprAst) { + const value = expression.value(); + return value === undefined ? undefined : { kind: 'number', value }; + } + if (expression instanceof BooleanLiteralExprAst) { + const value = expression.value(); + return value === undefined ? undefined : { kind: 'boolean', value }; + } + if (expression instanceof ArrayLiteralAst) { + const elements: ResolvedExpr[] = []; + for (const element of expression.elements()) { + const decoded = decodeExpression(element); + if (decoded === undefined) return undefined; + elements.push(decoded); + } + return { kind: 'array', elements }; + } + if (expression instanceof FunctionCallAst) { + return { kind: 'call', path: expression.path() }; + } + if (expression instanceof IdentifierAst) { + const name = expression.name(); + return name === undefined ? undefined : { kind: 'identifier', name }; + } + if (expression instanceof ObjectLiteralExprAst) { + return { kind: 'object' }; + } + return undefined; +} + function attributeName(name: QualifiedNameAst | undefined): string { return name?.path().join('.') ?? ''; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts b/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts index d3fb80297e..18e7b6ab2d 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts @@ -27,6 +27,7 @@ import type { SyntaxNode } from './syntax/red'; export type { ResolvedAttribute, ResolvedAttributeArg, + ResolvedExpr, ResolvedTypeConstructorCall, } from './resolve'; 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/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index 3c5f6a4d4a..b7da970c9c 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -136,11 +136,10 @@ export interface InterpretPslDocumentToSqlContractInput { /** * Merged capability matrix from the control stack's * `[target, adapter, ...extensionPacks]` descriptors. Authoring-time gating - * (e.g. scalar lists) reads `capabilities.sql?.scalarList`. Optional: an absent - * matrix means "do not gate" so non-adapter authoring paths stay valid; the CLI - * emit path always populates it. + * (e.g. scalar lists) reads `capabilities.sql?.scalarList`; an empty matrix + * fails closed and rejects gated features. The control stack always populates it. */ - readonly capabilities?: CapabilityMatrix; + readonly capabilities: CapabilityMatrix; } function buildComposedExtensionPackRefs( @@ -455,7 +454,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; + readonly capabilities: CapabilityMatrix; } interface BuildModelNodeResult { @@ -488,7 +487,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult sourceId, scalarTypeDescriptors: input.scalarTypeDescriptors, ...ifDefined('enumHandles', input.enumHandles), - ...ifDefined('capabilities', input.capabilities), + capabilities: input.capabilities, }); const inlineIdFields = resolvedFields.filter((field) => field.isId); @@ -1165,12 +1164,9 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult columnName: resolvedField.columnName, descriptor: resolvedField.descriptor, nullable: resolvedField.nullable, - ...ifDefined( - 'many', - resolvedField.many && resolvedField.valueObjectTypeName === undefined - ? (true as const) - : undefined, - ), + ...(resolvedField.many && resolvedField.valueObjectTypeName === undefined + ? { many: true as const } + : {}), ...ifDefined('default', resolvedField.defaultValue), ...ifDefined('executionDefaults', resolvedField.executionDefaults), ...ifDefined('enumTypeHandle', enumHandle), @@ -1937,7 +1933,7 @@ export function interpretPslDocumentToSqlContract( diagnostics, modelNamespaceIds, ...(enumHandlesByName.size > 0 ? { enumHandles: enumHandlesByName } : {}), - ...ifDefined('capabilities', input.capabilities), + 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 3305819b22..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,7 +143,7 @@ export function prismaContract(schemaPath: string, options: PrismaContractOption ), controlMutationDefaults: context.controlMutationDefaults, createNamespace: options.createNamespace, - ...ifDefined('capabilities', context.capabilities), + 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..92db06319c 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 @@ -1,6 +1,6 @@ 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 type { PslSpan, ResolvedAttribute, ResolvedExpr } from '@prisma-next/psl-parser'; import { parseQuotedStringLiteral } from '@prisma-next/psl-parser'; export { parseQuotedStringLiteral }; @@ -32,7 +32,7 @@ export function getNamedArgument(attribute: ResolvedAttribute, name: string): st export function getPositionalArgumentEntry( attribute: ResolvedAttribute, index = 0, -): { value: string; span: PslSpan } | undefined { +): { value: string; expression?: ResolvedExpr; span: PslSpan } | undefined { const entries = attribute.args.filter((arg) => arg.kind === 'positional'); const entry = entries[index]; if (!entry || entry.kind !== 'positional') { @@ -40,6 +40,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 c2d84279ce..d911885194 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 @@ -23,6 +23,7 @@ import type { FieldSymbol, PslSpan, ResolvedAttribute, + ResolvedExpr, ResolvedTypeConstructorCall, } from '@prisma-next/psl-parser'; import { blindCast } from '@prisma-next/utils/casts'; @@ -706,82 +707,35 @@ export function parseDefaultLiteralValue(expression: string): ColumnDefault | un } /** - * Result of parsing a list-field `@default(...)` expression: + * 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 a literal at all (e.g. a function call like `now()`), + * - `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 | null)[] } + | { readonly kind: 'array'; readonly value: readonly (string | number | boolean)[] } | { readonly kind: 'scalar' } | undefined; -function splitTopLevelArrayElements(body: string): readonly string[] { - const elements: string[] = []; - let depth = 0; - let quote: string | undefined; - let current = ''; - for (let index = 0; index < body.length; index += 1) { - const character = body[index]; - if (quote !== undefined) { - current += character; - if (character === '\\' && index + 1 < body.length) { - current += body[index + 1]; - index += 1; - } else if (character === quote) { - quote = undefined; - } - continue; - } - if (character === '"' || character === "'") { - quote = character; - current += character; - continue; - } - if (character === '[' || character === '{' || character === '(') depth += 1; - if (character === ']' || character === '}' || character === ')') depth -= 1; - if (character === ',' && depth === 0) { - elements.push(current); - current = ''; - continue; - } - current += character; - } - if (current.trim().length > 0) elements.push(current); - return elements; -} - -function parseListDefaultLiteralValue(expression: string): ListDefaultParse { - const trimmed = expression.trim(); - if (!trimmed.startsWith('[')) { - return parseDefaultLiteralValue(trimmed) ? { kind: 'scalar' } : undefined; - } - if (!trimmed.endsWith(']')) { - return undefined; - } - const body = trimmed.slice(1, -1).trim(); - if (body.length === 0) { - return { kind: 'array', value: [] }; +function parseListDefaultExpression(expression: ResolvedExpr | undefined): ListDefaultParse { + if (expression === undefined) return undefined; + if ( + expression.kind === 'string' || + expression.kind === 'number' || + expression.kind === 'boolean' + ) { + return { kind: 'scalar' }; } - const elements: (string | number | boolean | null)[] = []; - for (const rawElement of splitTopLevelArrayElements(body)) { - const parsed = parseDefaultLiteralValue(rawElement.trim()); - if (!parsed || parsed.kind !== 'literal') { - return undefined; - } - const value = parsed.value; - if ( - typeof value !== 'string' && - typeof value !== 'number' && - typeof value !== 'boolean' && - value !== null - ) { + if (expression.kind !== 'array') return undefined; + const value: (string | number | boolean)[] = []; + for (const element of expression.elements) { + if (element.kind !== 'string' && element.kind !== 'number' && element.kind !== 'boolean') { return undefined; } - elements.push(value); + value.push(element.value); } - return { kind: 'array', value: elements }; + return { kind: 'array', value }; } export function lowerDefaultForField(input: { @@ -823,7 +777,7 @@ export function lowerDefaultForField(input: { } if (input.isList) { - const listParse = parseListDefaultLiteralValue(expressionEntry.value); + const listParse = parseListDefaultExpression(expressionEntry.expression); if (listParse?.kind === 'array') { return { defaultValue: { kind: 'literal', value: [...listParse.value] } }; } 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 1026cefc0a..6d927030fe 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 @@ -149,11 +149,11 @@ export interface CollectResolvedFieldsInput { readonly scalarTypeDescriptors: ReadonlyMap; readonly enumHandles?: ReadonlyMap; /** - * Merged adapter capability matrix. When present, a scalar list field whose - * target does not report `sql.scalarList` is rejected with - * `PSL_SCALAR_LIST_UNSUPPORTED_TARGET`. Absent means "do not gate". + * Merged adapter capability matrix. A scalar list field whose target does not + * report `sql.scalarList` is rejected with `PSL_SCALAR_LIST_UNSUPPORTED_TARGET`; + * an empty matrix fails closed and rejects scalar lists. */ - readonly capabilities?: CapabilityMatrix; + readonly capabilities: CapabilityMatrix; } const BUILTIN_FIELD_ATTRIBUTE_NAMES: ReadonlySet = new Set([ @@ -361,10 +361,9 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv descriptor = scalarTypeDescriptors.get('Json'); } else if (isListField) { // Scalar lists lower to a native array storage column; gate them on the - // adapter-reported `sql.scalarList` capability. An absent matrix means the - // authoring path did not thread capabilities (e.g. a bespoke provider), so - // do not gate. A present matrix that omits the capability (SQLite) rejects. - if (capabilities !== undefined && capabilities['sql']?.['scalarList'] !== true) { + // adapter-reported `sql.scalarList` capability. The gate fails closed: a + // matrix that omits the capability (SQLite, or an empty matrix) rejects. + 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.`, 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 index 80d581efcd..b932275a4a 100644 --- 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 @@ -82,7 +82,7 @@ describe('interpretPslDocumentToSqlContract scalar-list capability gating', () = }); }); - it('does not gate when no capability matrix is threaded (non-adapter authoring path)', () => { + it('rejects a scalar list against an empty capability matrix (fail-closed)', () => { const document = symbolTableInputFromParseArgs({ schema: listSchema, sourceId: 'schema.prisma', @@ -93,14 +93,17 @@ describe('interpretPslDocumentToSqlContract scalar-list capability gating', () = scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: {}, ...document, controlMutationDefaults: builtinControlMutationDefaults, }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(modelsOf(result.value)).toMatchObject({ - User: { fields: { tags: { many: true } } }, - }); + 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 0c4ed97027..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 @@ -21,6 +21,7 @@ const baseInput = { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, } as const; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); @@ -1087,6 +1088,7 @@ model User { ...document, controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(false); @@ -1123,6 +1125,7 @@ model User { ...document, controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(false); @@ -1370,4 +1373,82 @@ describe('interpretPslDocumentToSqlContract list-field constructs', () => { 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 f5af3bbf74..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, }); 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/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/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/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/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/test/integration/test/authoring/cli.emit-parity-fixtures.test.ts b/test/integration/test/authoring/cli.emit-parity-fixtures.test.ts index cfcf4ede7e..fa8d0cec2f 100644 --- a/test/integration/test/authoring/cli.emit-parity-fixtures.test.ts +++ b/test/integration/test/authoring/cli.emit-parity-fixtures.test.ts @@ -34,6 +34,7 @@ function sourceContextFromConfig(config: PrismaNextConfig): ContractSourceContex codecLookup: stack.codecLookup, controlMutationDefaults: stack.controlMutationDefaults, resolvedInputs: config.contract?.source.inputs ?? [], + capabilities: stack.capabilities, }; } diff --git a/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts b/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts index 9d9b753e0d..88972eef10 100644 --- a/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts +++ b/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts @@ -55,6 +55,7 @@ function interpretWithRealPacks(schema: string) { composedExtensionPacks: [pgvectorControl.id], composedExtensionPackRefs: [pgvectorPack], createNamespace: postgresCreateNamespace, + capabilities: stack.capabilities, }); } diff --git a/test/integration/test/authoring/psl-index-type-options.integration.test.ts b/test/integration/test/authoring/psl-index-type-options.integration.test.ts index 6b3bfdf177..e892fe9be4 100644 --- a/test/integration/test/authoring/psl-index-type-options.integration.test.ts +++ b/test/integration/test/authoring/psl-index-type-options.integration.test.ts @@ -31,6 +31,7 @@ function interpret(schema: string) { composedExtensionPacks: [paradedbPack.id], composedExtensionPackRefs: [paradedbPack], createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); } diff --git a/test/integration/test/authoring/psl.pgvector-dbinit.test.ts b/test/integration/test/authoring/psl.pgvector-dbinit.test.ts index 7591970df2..ecf51f4ed1 100644 --- a/test/integration/test/authoring/psl.pgvector-dbinit.test.ts +++ b/test/integration/test/authoring/psl.pgvector-dbinit.test.ts @@ -91,6 +91,7 @@ describe( codecLookup: stack.codecLookup, controlMutationDefaults: stack.controlMutationDefaults, resolvedInputs: [schemaPath], + capabilities: stack.capabilities, }); expect(pslResult.ok).toBe(true); if (!pslResult.ok) { diff --git a/test/integration/test/authoring/side-by-side-contracts.test.ts b/test/integration/test/authoring/side-by-side-contracts.test.ts index ac1e7acf81..c4c391b4e6 100644 --- a/test/integration/test/authoring/side-by-side-contracts.test.ts +++ b/test/integration/test/authoring/side-by-side-contracts.test.ts @@ -50,6 +50,7 @@ const sqlSourceContext: ContractSourceContext = { codecLookup: sqlStack.codecLookup, controlMutationDefaults: sqlStack.controlMutationDefaults, resolvedInputs: [], + capabilities: sqlStack.capabilities, }; const mongoSourceContext: ContractSourceContext = { @@ -60,6 +61,7 @@ const mongoSourceContext: ContractSourceContext = { codecLookup: mongoStack.codecLookup, controlMutationDefaults: mongoStack.controlMutationDefaults, resolvedInputs: [], + capabilities: mongoStack.capabilities, }; type FixtureName = 'postgres' | 'mongo'; diff --git a/test/integration/test/cli.emit-command.additional.test.ts b/test/integration/test/cli.emit-command.additional.test.ts index db80bcd17c..49f907ebb5 100644 --- a/test/integration/test/cli.emit-command.additional.test.ts +++ b/test/integration/test/cli.emit-command.additional.test.ts @@ -166,6 +166,7 @@ describe('emit command: additional fixtures', () => { codecLookup: stack.codecLookup, controlMutationDefaults: stack.controlMutationDefaults, resolvedInputs: contractConfig!.source.inputs ?? [], + capabilities: stack.capabilities, }); } finally { process.chdir(originalCwd); diff --git a/test/integration/test/cli.emit-contract.test.ts b/test/integration/test/cli.emit-contract.test.ts index 0c1121aeb2..5bc318fcaf 100644 --- a/test/integration/test/cli.emit-contract.test.ts +++ b/test/integration/test/cli.emit-contract.test.ts @@ -35,6 +35,7 @@ function buildSourceContext( codecLookup: stack.codecLookup, controlMutationDefaults: stack.controlMutationDefaults, resolvedInputs, + capabilities: stack.capabilities, }; } diff --git a/test/integration/test/scalar-lists/psl-list-authoring.ts b/test/integration/test/scalar-lists/psl-list-authoring.ts index 071a1c86a0..802e6a4aa5 100644 --- a/test/integration/test/scalar-lists/psl-list-authoring.ts +++ b/test/integration/test/scalar-lists/psl-list-authoring.ts @@ -38,6 +38,7 @@ const sqlSourceContext: ContractSourceContext = { codecLookup: sqlStack.codecLookup, controlMutationDefaults: sqlStack.controlMutationDefaults, resolvedInputs: [], + capabilities: sqlStack.capabilities, }; const mongoSourceContext: ContractSourceContext = { @@ -48,6 +49,7 @@ const mongoSourceContext: ContractSourceContext = { codecLookup: mongoStack.codecLookup, controlMutationDefaults: mongoStack.controlMutationDefaults, resolvedInputs: [], + capabilities: mongoStack.capabilities, }; export const postgresFrameworkComponents = [postgres, postgresAdapter] as const; diff --git a/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts b/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts index ded2da6ee3..03f5dd591b 100644 --- a/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts +++ b/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts @@ -1,5 +1,5 @@ /** - * Slice-2 NFR4 — Mongo parity for PSL scalar lists. + * Mongo parity for PSL scalar lists. * * The same PSL list schema must yield matching *observable* semantics on SQL * and Mongo: @@ -14,7 +14,7 @@ * Mongo runs against `mongodb-memory-server`, which fails to spin up on some * local sandboxes (UnknownLinuxDistro "nixos"); the assertion is written for * CI, where the memory server runs. The SQL half of assertion 3 is proven in - * `psl-list-roundtrip.integration.test.ts` (AC2). + * `psl-list-roundtrip.integration.test.ts`. */ import type { SerializeContract } from '@prisma-next/contract/hashing'; import { emit } from '@prisma-next/emitter'; @@ -56,7 +56,7 @@ const sqlSerializeContract: SerializeContract = (contract) => const mongoSerializeContract: SerializeContract = (contract) => mongoTargetDescriptor.contractSerializer.serializeContract(contract as MongoTargetContract); -describe('PSL scalar-list Mongo parity (NFR4)', () => { +describe('PSL scalar-list Mongo parity', () => { it( 'both SQL and Mongo author the same list schema with no diagnostics', async () => { diff --git a/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts index d906576e5c..683a3dfd6e 100644 --- a/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts +++ b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts @@ -1,14 +1,13 @@ /** - * Slice-2 milestone checkpoint (AC1) + element fidelity through the PSL path - * (AC2 / NFR1). + * End-to-end PSL scalar-list checkpoint + element fidelity through the PSL path. * - * AC1: a `posts.tags String[]` schema authored in PSL emits a contract whose + * A `posts.tags String[]` schema authored in PSL emits a contract whose * `tags` storage column is a native array column (`pg/text@1`, `many:true`, NOT * jsonb), migrates onto a fresh Postgres database as a `text[]` column with no * manual edits, and round-trips through `contract infer` back to a `tags * String[]` PSL field. * - * AC2/NFR1: a schema with `DateTime[]`, `Bytes[]`, and `Decimal[]` list fields + * A schema with `DateTime[]`, `Bytes[]`, and `Decimal[]` list fields * — authored in PSL, not a hand-built typed contract — inserts and selects * rows whose decoded element values deep-equal the originals, proving the * element codec is applied per element through the whole authored path. @@ -101,7 +100,7 @@ async function migrateContract( } } -describe.sequential('PSL scalar-list milestone (AC1 + AC2/NFR1)', () => { +describe.sequential('PSL scalar-list end-to-end', () => { let database: DevDatabase | undefined; beforeAll(async () => { @@ -113,7 +112,7 @@ describe.sequential('PSL scalar-list milestone (AC1 + AC2/NFR1)', () => { }, timeouts.spinUpPpgDev); it( - 'AC1: posts.tags String[] authors → migrates as text[] → infers back to tags String[]', + 'posts.tags String[] authors → migrates as text[] → infers back to tags String[]', async () => { if (!database) throw new Error('database not initialised'); @@ -182,7 +181,7 @@ describe.sequential('PSL scalar-list milestone (AC1 + AC2/NFR1)', () => { ); it( - 'AC2/NFR1: DateTime[]/Bytes[]/Decimal[] authored in PSL round-trip element values', + 'DateTime[]/Bytes[]/Decimal[] authored in PSL round-trip element values', async () => { if (!database) throw new Error('database not initialised'); diff --git a/test/integration/test/value-objects/value-objects.integration.test.ts b/test/integration/test/value-objects/value-objects.integration.test.ts index 168acaa5de..5c18b158c7 100644 --- a/test/integration/test/value-objects/value-objects.integration.test.ts +++ b/test/integration/test/value-objects/value-objects.integration.test.ts @@ -104,6 +104,7 @@ function interpretSqlPsl(schema: string) { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); } From e251290ac2e6148f2c3f4a145c0ac92b03c90610 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 1 Jul 2026 13:44:58 +0000 Subject: [PATCH 09/18] chore(scalar-arrays): declare incidental extension-upgrade diff for PR #870 The PR touches one packages/3-extensions/ test file (threading the now- required capabilities matrix into a test ContractSourceContext), which trips the check:upgrade-coverage per-pr-declaration gate. Record it as an incidental substrate diff: the affected types are framework-internal contract-emission seams the control stack always populates; no extension-author API changed. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../upgrades/0.14-to-0.15/instructions.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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. --> + + From 22c4dcb0c24cb6d533f1cf8c98c208b4858942f0 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 1 Jul 2026 13:58:03 +0000 Subject: [PATCH 10/18] refactor(scalar-arrays): expose raw expression AST on ResolvedAttributeArg (slice 2 N1) Replace the decoded ResolvedExpr union with the parser ExpressionAst node directly on ResolvedAttributeArg, per code-owner review. List-default parsing narrows via the AST classes (ArrayLiteralAst/StringLiteralExprAst/...) instead of a bespoke decoded shape. The stringified value is kept, as it is still consumed by scalar/function-call/relation-name/authoring-arg parsing. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../psl-parser/src/exports/index.ts | 1 - .../2-authoring/psl-parser/src/resolve.ts | 68 ++----------------- .../psl-parser/src/symbol-table.ts | 1 - .../contract-psl/src/psl-attribute-parsing.ts | 5 +- .../contract-psl/src/psl-column-resolution.ts | 34 +++++++--- 5 files changed, 30 insertions(+), 79 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts index 35d3ce89c6..7ff2425a76 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts @@ -54,7 +54,6 @@ export type { NamespaceSymbol, ResolvedAttribute, ResolvedAttributeArg, - ResolvedExpr, ResolvedNamedTypeBinding, ResolvedTypeConstructorCall, ScalarSymbol, 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 9e9efa4f78..c88209fbdc 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/resolve.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/resolve.ts @@ -5,40 +5,17 @@ import type { FieldAttributeAst, ModelAttributeAst, } from './syntax/ast/attributes'; -import { - ArrayLiteralAst, - BooleanLiteralExprAst, - type ExpressionAst, - FunctionCallAst, - NumberLiteralExprAst, - ObjectLiteralExprAst, - StringLiteralExprAst, -} from './syntax/ast/expressions'; -import { IdentifierAst } from './syntax/ast/identifier'; +import type { ExpressionAst } from './syntax/ast/expressions'; import type { QualifiedNameAst } from './syntax/ast/qualified-name'; import type { TypeAnnotationAst } from './syntax/ast/type-annotation'; import { printSyntax } from './syntax/ast-helpers'; import type { SyntaxNode } from './syntax/red'; -/** - * A structurally decoded attribute-argument expression. Consumers that need the - * shape of an expression (e.g. array-literal `@default([...])`) read this instead - * of re-parsing the stringified {@link ResolvedAttributeArg.value}. - */ -export type ResolvedExpr = - | { readonly kind: 'string'; readonly value: string } - | { readonly kind: 'number'; readonly value: number } - | { readonly kind: 'boolean'; readonly value: boolean } - | { readonly kind: 'array'; readonly elements: readonly ResolvedExpr[] } - | { readonly kind: 'object' } - | { readonly kind: 'call'; readonly path: readonly string[] } - | { readonly kind: 'identifier'; readonly name: string }; - export interface ResolvedAttributeArg { readonly kind: 'positional' | 'named'; readonly name?: string; readonly value: string; - readonly expression?: ResolvedExpr; + readonly expression?: ExpressionAst; readonly span: PslSpan; } @@ -93,12 +70,11 @@ function readResolvedArgList( const args: ResolvedAttributeArg[] = []; for (const arg of argList.args()) { const name = arg.name()?.name(); - const value = arg.value(); - const expression = decodeExpression(value); + const expression = arg.value(); args.push({ kind: name !== undefined ? 'named' : 'positional', ...(name !== undefined ? { name } : {}), - value: renderExpression(value), + value: renderExpression(expression), ...(expression !== undefined ? { expression } : {}), span: nodePslSpan(arg.syntax, sourceFile), }); @@ -106,42 +82,6 @@ function readResolvedArgList( return args; } -function decodeExpression(expression: ExpressionAst | undefined): ResolvedExpr | undefined { - if (expression === undefined) return undefined; - if (expression instanceof StringLiteralExprAst) { - const value = expression.value(); - return value === undefined ? undefined : { kind: 'string', value }; - } - if (expression instanceof NumberLiteralExprAst) { - const value = expression.value(); - return value === undefined ? undefined : { kind: 'number', value }; - } - if (expression instanceof BooleanLiteralExprAst) { - const value = expression.value(); - return value === undefined ? undefined : { kind: 'boolean', value }; - } - if (expression instanceof ArrayLiteralAst) { - const elements: ResolvedExpr[] = []; - for (const element of expression.elements()) { - const decoded = decodeExpression(element); - if (decoded === undefined) return undefined; - elements.push(decoded); - } - return { kind: 'array', elements }; - } - if (expression instanceof FunctionCallAst) { - return { kind: 'call', path: expression.path() }; - } - if (expression instanceof IdentifierAst) { - const name = expression.name(); - return name === undefined ? undefined : { kind: 'identifier', name }; - } - if (expression instanceof ObjectLiteralExprAst) { - return { kind: 'object' }; - } - return undefined; -} - function attributeName(name: QualifiedNameAst | undefined): string { return name?.path().join('.') ?? ''; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts b/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts index 18e7b6ab2d..d3fb80297e 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts @@ -27,7 +27,6 @@ import type { SyntaxNode } from './syntax/red'; export type { ResolvedAttribute, ResolvedAttributeArg, - ResolvedExpr, ResolvedTypeConstructorCall, } from './resolve'; 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 92db06319c..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 @@ -1,7 +1,8 @@ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types'; import type { ControlPolicy } from '@prisma-next/contract/types'; -import type { PslSpan, ResolvedAttribute, ResolvedExpr } from '@prisma-next/psl-parser'; +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; expression?: ResolvedExpr; 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') { 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 d911885194..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 @@ -23,9 +23,15 @@ import type { FieldSymbol, PslSpan, ResolvedAttribute, - ResolvedExpr, 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 { @@ -718,22 +724,28 @@ type ListDefaultParse = | { readonly kind: 'scalar' } | undefined; -function parseListDefaultExpression(expression: ResolvedExpr | undefined): ListDefaultParse { +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.kind === 'string' || - expression.kind === 'number' || - expression.kind === 'boolean' + expression instanceof StringLiteralExprAst || + expression instanceof NumberLiteralExprAst || + expression instanceof BooleanLiteralExprAst ) { return { kind: 'scalar' }; } - if (expression.kind !== 'array') return undefined; + if (!(expression instanceof ArrayLiteralAst)) return undefined; const value: (string | number | boolean)[] = []; - for (const element of expression.elements) { - if (element.kind !== 'string' && element.kind !== 'number' && element.kind !== 'boolean') { - return undefined; - } - value.push(element.value); + for (const element of expression.elements()) { + const decoded = decodeLiteralElement(element); + if (decoded === undefined) return undefined; + value.push(decoded); } return { kind: 'array', value }; } From 3cacbb85423c58c27c745590342412907c6bf759 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 2 Jul 2026 09:56:25 +0000 Subject: [PATCH 11/18] test(scalar-arrays): prove String[]/Int[] read-back via SQL runtime and ORM client Add a sibling to the DateTime[]/Bytes[]/Decimal[] roundtrip covering the plain scalar element types: author `tags String[]`/`scores Int[]` in PSL, migrate onto real Postgres, insert, and SELECT back, asserting element-wise decode for pg/text@1 and pg/int4@1. Add an ORM-client read-back test: over the same PSL-authored contract, read the native array columns through orm()...select(...).all(), proving the ORM projects and decodes scalar many:true columns as JS arrays (not just to-many relations). The PSL path yields a generic Contract, so accessors are index signatures; the narrow row-type array inference is covered by the emitted contract path. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../orm-list-read.integration.test.ts | 184 ++++++++++++++++++ .../psl-list-roundtrip.integration.test.ts | 79 ++++++++ 2 files changed, 263 insertions(+) create mode 100644 test/integration/test/scalar-lists/orm-list-read.integration.test.ts diff --git a/test/integration/test/scalar-lists/orm-list-read.integration.test.ts b/test/integration/test/scalar-lists/orm-list-read.integration.test.ts new file mode 100644 index 0000000000..f328c52b9b --- /dev/null +++ b/test/integration/test/scalar-lists/orm-list-read.integration.test.ts @@ -0,0 +1,184 @@ +/** + * Reads a native scalar-list column (`tags String[]`, `scores Int[]`) back + * through the ORM client. A model authored in PSL emits array storage columns + * (`pg/text@1`/`pg/int4@1`, `many:true`); after migrating onto a real Postgres + * database and inserting a row, `orm()...select(...).all()` surfaces + * each list column as a decoded JS array — proving the ORM read path projects + * and decodes scalar `many` columns element-wise, not just to-many relations. + */ +import postgresAdapter from '@prisma-next/adapter-postgres/control'; +import postgresRuntimeAdapter from '@prisma-next/adapter-postgres/runtime'; +import type { Contract } from '@prisma-next/contract/types'; +import postgresControlDriver from '@prisma-next/driver-postgres/control'; +import sql, { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; +import { APP_SPACE_ID, createControlStack } from '@prisma-next/framework-components/control'; +import { buildSynthMigrationEdge } from '@prisma-next/migration-tools/aggregate'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { orm } from '@prisma-next/sql-orm-client'; +import { InsertAst, ParamRef, TableSource } from '@prisma-next/sql-relational-core/ast'; +import { planFromAst } from '@prisma-next/sql-relational-core/plan'; +import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime'; +import postgres from '@prisma-next/target-postgres/control'; +import postgresRuntimeTarget from '@prisma-next/target-postgres/runtime'; +import { createDevDatabase, type DevDatabase, timeouts, withClient } from '@prisma-next/test-utils'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createTestRuntimeFromClient } from '../utils'; +import { + authorSqlContractFromPsl, + findStorageColumn, + listCodecRefFor, + postgresFrameworkComponents, + tableNameForColumn, +} from './psl-list-authoring'; + +const controlStack = createControlStack({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresControlDriver, + extensionPacks: [], +}); +const familyInstance = sql.create(controlStack); + +async function migrateContract( + connectionString: string, + contract: Contract, +): Promise { + const driver = await postgresControlDriver.create(connectionString); + try { + const schema = await familyInstance.introspect({ driver }); + const planner = postgres.createPlanner(postgresAdapter.create(controlStack)); + const planResult = planner.plan({ + contract, + schema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents: postgresFrameworkComponents, + spaceId: APP_SPACE_ID, + }); + if (planResult.kind !== 'success') { + throw new Error(`planner failed: ${JSON.stringify(planResult)}`); + } + + const runner = postgres.createRunner(familyInstance); + const runResult = await runner.execute({ + driver, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan: planResult.plan, + migrationEdges: [ + buildSynthMigrationEdge({ + currentMarkerStorageHash: planResult.plan.origin?.storageHash, + destinationStorageHash: planResult.plan.destination.storageHash, + operationCount: planResult.plan.operations.length, + }), + ], + driver, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents: postgresFrameworkComponents, + }, + ], + }); + if (!runResult.ok) { + throw new Error(`runner failed: ${JSON.stringify(runResult.failure)}`); + } + } finally { + await driver.close(); + } +} + +describe.sequential('ORM scalar-list read-back', () => { + let database: DevDatabase | undefined; + + beforeAll(async () => { + database = await createDevDatabase(); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) await database.close(); + }, timeouts.spinUpPpgDev); + + it( + 'orm()..select(...).all() surfaces String[]/Int[] columns as arrays', + async () => { + if (!database) throw new Error('database not initialised'); + + const authored = await authorSqlContractFromPsl(`model Item { + id Int @id + tags String[] + scores Int[] +}`); + expect(authored.ok).toBe(true); + const contract = authored.contract; + if (!contract) throw new Error('authoring produced no contract'); + + // Sanity: native array columns (not the jsonb fallback). + expect(findStorageColumn(contract, 'tags')).toMatchObject({ + codecId: 'pg/text@1', + many: true, + }); + expect(findStorageColumn(contract, 'scores')).toMatchObject({ + codecId: 'pg/int4@1', + many: true, + }); + + await withClient(database.connectionString, async (client) => { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + }); + await migrateContract(database.connectionString, contract); + + const tableName = tableNameForColumn(contract, 'tags'); + const table = TableSource.named(tableName); + const tagsRef = listCodecRefFor(contract, 'tags'); + const scoresRef = listCodecRefFor(contract, 'scores'); + const tags = ['a', 'b', 'c']; + const scores = [1, 2, 3]; + + await withClient(database.connectionString, async (client) => { + const runtime = await createTestRuntimeFromClient(contract, client, { + verifyMarker: false, + }); + + // Seed via the raw runtime; the ORM read path is what's under test. + const insert = InsertAst.into(table).withRows([ + { + id: ParamRef.of(1, { codec: { codecId: 'pg/int4@1' } }), + tags: ParamRef.of(tags, { codec: tagsRef }), + scores: ParamRef.of(scores, { codec: scoresRef }), + }, + ]); + await runtime.execute(planFromAst(insert, contract)).toArray(); + + const context = createExecutionContext>({ + contract, + stack: createSqlExecutionStack({ + target: postgresRuntimeTarget, + adapter: postgresRuntimeAdapter, + extensionPacks: [], + }), + }); + const db = orm({ runtime, context }); + + // The PSL path yields a generic `Contract`, so the ORM's + // namespace/model accessors are index signatures (bracket access). The + // narrow row-type array inference for `many` columns is proven by the + // emitted-contract path; here we prove the runtime read: the ORM + // projects and decodes the scalar `many` columns as JS arrays. + const publicNamespace = db['public']; + if (!publicNamespace) throw new Error('public namespace missing from ORM client'); + const items = publicNamespace['Item']; + if (!items) throw new Error('Item collection missing from ORM client'); + + // Single seeded row — result order is trivially deterministic. + const rows = await items.select('id', 'tags', 'scores').all(); + + expect(rows).toEqual([{ id: 1, tags, scores }]); + }); + }, + timeouts.spinUpPpgDev, + ); +}); diff --git a/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts index 683a3dfd6e..e61cfe2be9 100644 --- a/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts +++ b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts @@ -282,4 +282,83 @@ model Reading { }, timeouts.spinUpPpgDev, ); + + it( + 'String[]/Int[] authored in PSL round-trip element values', + async () => { + if (!database) throw new Error('database not initialised'); + + const authored = await authorSqlContractFromPsl(`model Item { + id Int @id + tags String[] + scores Int[] +}`); + expect(authored.ok).toBe(true); + const contract = authored.contract; + if (!contract) throw new Error('authoring produced no contract'); + + // Sanity: each list field lowered to a native array column (not jsonb). + expect(findStorageColumn(contract, 'tags')).toMatchObject({ + codecId: 'pg/text@1', + many: true, + }); + expect(findStorageColumn(contract, 'scores')).toMatchObject({ + codecId: 'pg/int4@1', + many: true, + }); + + await withClient(database.connectionString, async (client) => { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + }); + await migrateContract(database.connectionString, contract); + + const tableName = tableNameForColumn(contract, 'tags'); + const table = TableSource.named(tableName); + const tagsRef = listCodecRefFor(contract, 'tags'); + const scoresRef = listCodecRefFor(contract, 'scores'); + + const tags = ['a', 'b', 'c']; + const scores = [1, 2, 3]; + + await withClient(database.connectionString, async (client) => { + const runtime = await createTestRuntimeFromClient(contract, client, { + verifyMarker: false, + }); + + const insert = InsertAst.into(table).withRows([ + { + id: ParamRef.of(1, { codec: { codecId: 'pg/int4@1' } }), + tags: ParamRef.of(tags, { codec: tagsRef }), + scores: ParamRef.of(scores, { codec: scoresRef }), + }, + ]); + await runtime.execute(planFromAst(insert, contract)).toArray(); + + const select = SelectAst.from(table) + .withProjection([ + ProjectionItem.of('tags', ColumnRef.of(tableName, 'tags'), tagsRef), + ProjectionItem.of('scores', ColumnRef.of(tableName, 'scores'), scoresRef), + ]) + .withWhere( + BinaryExpr.eq( + ColumnRef.of(tableName, 'id'), + ParamRef.of(1, { codec: { codecId: 'pg/int4@1' } }), + ), + ); + + const rows = await runtime.execute(planFromAst(select, contract)).toArray(); + expect(rows).toHaveLength(1); + const row = rows[0] as unknown as { + tags: string[]; + scores: number[]; + }; + + expect(row.tags).toEqual(tags); + expect(row.scores).toEqual(scores); + }); + }, + timeouts.spinUpPpgDev, + ); }); From 633ed88be320dd85c2481792c8c39cee6e055aa4 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 2 Jul 2026 15:27:42 +0000 Subject: [PATCH 12/18] test(scalar-arrays): typed ORM write->read round-trip over emitted contract Replace the runtime-authored, generically-typed ORM scalar-list read test with a strongly-typed write->read round-trip over an emitted contract fixture. The previous test authored the contract at runtime via authorSqlContractFromPsl, yielding a widened Contract whose ORM namespace/model accessors were index signatures (bracket access) and whose row type was loose; it also seeded via a raw InsertAst rather than the ORM. New coverage: - Add fixture test/sql-orm-client/fixtures/scalar-lists/ (Item with tags String[] / scores Int[]) emitted to a committed contract.json + contract.d.ts. Storage columns are native arrays (pg/text@1 / pg/int4@1, many: true) and the generated types render each field as ReadonlyArray<...>. - Wire the fixture's config into the integration `emit` script so it is deterministically regenerable and covered by fixtures:check. - Rewrite orm-list-read.integration.test.ts to migrate the emitted contract onto a real Postgres database, seed a row through the typed ORM (db.public.Item .create with tags/scores arrays), read it back through dotted strongly-typed accessors, assert the whole row shape, and assert at the type level that the read row infers tags: ReadonlyArray and scores: ReadonlyArray. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- test/integration/package.json | 2 +- .../orm-list-read.integration.test.ts | 132 +++++------- .../fixtures/scalar-lists/contract.prisma | 15 ++ .../scalar-lists/generated/contract.d.ts | 195 ++++++++++++++++++ .../scalar-lists/generated/contract.json | 129 ++++++++++++ .../scalar-lists/prisma-next.config.ts | 6 + 6 files changed, 399 insertions(+), 80 deletions(-) create mode 100644 test/integration/test/sql-orm-client/fixtures/scalar-lists/contract.prisma create mode 100644 test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.d.ts create mode 100644 test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.json create mode 100644 test/integration/test/sql-orm-client/fixtures/scalar-lists/prisma-next.config.ts diff --git a/test/integration/package.json b/test/integration/package.json index 6c19352224..0976bfcddf 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "emit": "node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/fixtures/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/namespaced-accessors/fixtures/prisma-next.config.ts && (node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/mongo/fixtures/prisma-next.config.ts || true) && pnpm emit:authoring", + "emit": "node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/fixtures/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/namespaced-accessors/fixtures/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/scalar-lists/prisma-next.config.ts && (node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/mongo/fixtures/prisma-next.config.ts || true) && pnpm emit:authoring", "emit:authoring": "UPDATE_AUTHORING_PARITY_EXPECTED=1 UPDATE_SIDE_BY_SIDE_CONTRACTS=1 vitest run test/authoring/cli.emit-parity-fixtures.test.ts test/authoring/side-by-side-contracts.test.ts && biome format --write test/authoring/parity test/authoring/side-by-side", "emit:check": "pnpm emit && git diff --exit-code test/fixtures/contract.json test/fixtures/contract.d.ts test/authoring/parity test/authoring/side-by-side", "pretest": "pnpm -w build", diff --git a/test/integration/test/scalar-lists/orm-list-read.integration.test.ts b/test/integration/test/scalar-lists/orm-list-read.integration.test.ts index f328c52b9b..8311f51b68 100644 --- a/test/integration/test/scalar-lists/orm-list-read.integration.test.ts +++ b/test/integration/test/scalar-lists/orm-list-read.integration.test.ts @@ -1,35 +1,42 @@ /** - * Reads a native scalar-list column (`tags String[]`, `scores Int[]`) back - * through the ORM client. A model authored in PSL emits array storage columns - * (`pg/text@1`/`pg/int4@1`, `many:true`); after migrating onto a real Postgres - * database and inserting a row, `orm()...select(...).all()` surfaces - * each list column as a decoded JS array — proving the ORM read path projects - * and decodes scalar `many` columns element-wise, not just to-many relations. + * Strongly-typed ORM write->read round-trip over native scalar-list columns. + * + * The model (`Item { id Int @id; tags String[]; scores Int[] }`) is authored in + * PSL and emitted to a committed contract fixture + * (`../sql-orm-client/fixtures/scalar-lists/generated`). Its storage columns are + * native arrays (`pg/text@1` / `pg/int4@1`, `many: true`, not the jsonb + * fallback) and the generated `contract.d.ts` types each field as + * `ReadonlyArray<...>`. + * + * Because the contract is the precise emitted type (not a widened + * `Contract`), the ORM's namespace/model accessors are fully typed: + * `db.public.Item` is a real `Item` collection, not an index signature. The test + * migrates the contract onto a real Postgres database, seeds a row through the + * typed ORM (`create`), reads it back through dotted, strongly-typed accessors, + * and asserts both the value round-trip and the `many` -> array type inference. */ import postgresAdapter from '@prisma-next/adapter-postgres/control'; import postgresRuntimeAdapter from '@prisma-next/adapter-postgres/runtime'; -import type { Contract } from '@prisma-next/contract/types'; +import type { Contract as FrameworkContract } from '@prisma-next/contract/types'; import postgresControlDriver from '@prisma-next/driver-postgres/control'; import sql, { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; import { APP_SPACE_ID, createControlStack } from '@prisma-next/framework-components/control'; import { buildSynthMigrationEdge } from '@prisma-next/migration-tools/aggregate'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { orm } from '@prisma-next/sql-orm-client'; -import { InsertAst, ParamRef, TableSource } from '@prisma-next/sql-relational-core/ast'; -import { planFromAst } from '@prisma-next/sql-relational-core/plan'; import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime'; import postgres from '@prisma-next/target-postgres/control'; -import postgresRuntimeTarget from '@prisma-next/target-postgres/runtime'; +import postgresRuntimeTarget, { + PostgresContractSerializer, +} from '@prisma-next/target-postgres/runtime'; import { createDevDatabase, type DevDatabase, timeouts, withClient } from '@prisma-next/test-utils'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, expectTypeOf, it } from 'vitest'; +import type { Contract } from '../sql-orm-client/fixtures/scalar-lists/generated/contract'; +import contractJson from '../sql-orm-client/fixtures/scalar-lists/generated/contract.json' with { + type: 'json', +}; import { createTestRuntimeFromClient } from '../utils'; -import { - authorSqlContractFromPsl, - findStorageColumn, - listCodecRefFor, - postgresFrameworkComponents, - tableNameForColumn, -} from './psl-list-authoring'; +import { postgresFrameworkComponents } from './psl-list-authoring'; const controlStack = createControlStack({ family: sql, @@ -40,16 +47,19 @@ const controlStack = createControlStack({ }); const familyInstance = sql.create(controlStack); -async function migrateContract( - connectionString: string, - contract: Contract, -): Promise { +// Deserialization runs the full sql contract validation pipeline (structure + +// domain + storage semantics), so a contract that failed to round-trip +// validation would throw here. The result carries the precise emitted `Contract` +// type, which is what makes the ORM accessors below strongly typed. +const contract = new PostgresContractSerializer().deserializeContract(contractJson) as Contract; + +async function migrateContract(connectionString: string): Promise { const driver = await postgresControlDriver.create(connectionString); try { const schema = await familyInstance.introspect({ driver }); const planner = postgres.createPlanner(postgresAdapter.create(controlStack)); const planResult = planner.plan({ - contract, + contract: contract as FrameworkContract, schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, @@ -75,7 +85,7 @@ async function migrateContract( }), ], driver, - destinationContract: contract, + destinationContract: contract as FrameworkContract, policy: INIT_ADDITIVE_POLICY, frameworkComponents: postgresFrameworkComponents, }, @@ -89,7 +99,7 @@ async function migrateContract( } } -describe.sequential('ORM scalar-list read-back', () => { +describe.sequential('ORM scalar-list round-trip', () => { let database: DevDatabase | undefined; beforeAll(async () => { @@ -101,59 +111,25 @@ describe.sequential('ORM scalar-list read-back', () => { }, timeouts.spinUpPpgDev); it( - 'orm()..select(...).all() surfaces String[]/Int[] columns as arrays', + 'writes and reads String[]/Int[] columns through the typed ORM, inferring arrays', async () => { if (!database) throw new Error('database not initialised'); - const authored = await authorSqlContractFromPsl(`model Item { - id Int @id - tags String[] - scores Int[] -}`); - expect(authored.ok).toBe(true); - const contract = authored.contract; - if (!contract) throw new Error('authoring produced no contract'); - - // Sanity: native array columns (not the jsonb fallback). - expect(findStorageColumn(contract, 'tags')).toMatchObject({ - codecId: 'pg/text@1', - many: true, - }); - expect(findStorageColumn(contract, 'scores')).toMatchObject({ - codecId: 'pg/int4@1', - many: true, - }); - await withClient(database.connectionString, async (client) => { await client.query('DROP SCHEMA IF EXISTS public CASCADE'); await client.query('CREATE SCHEMA public'); await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); }); - await migrateContract(database.connectionString, contract); - - const tableName = tableNameForColumn(contract, 'tags'); - const table = TableSource.named(tableName); - const tagsRef = listCodecRefFor(contract, 'tags'); - const scoresRef = listCodecRefFor(contract, 'scores'); - const tags = ['a', 'b', 'c']; - const scores = [1, 2, 3]; + await migrateContract(database.connectionString); await withClient(database.connectionString, async (client) => { - const runtime = await createTestRuntimeFromClient(contract, client, { - verifyMarker: false, - }); + const runtime = await createTestRuntimeFromClient( + contract as FrameworkContract, + client, + { verifyMarker: false }, + ); - // Seed via the raw runtime; the ORM read path is what's under test. - const insert = InsertAst.into(table).withRows([ - { - id: ParamRef.of(1, { codec: { codecId: 'pg/int4@1' } }), - tags: ParamRef.of(tags, { codec: tagsRef }), - scores: ParamRef.of(scores, { codec: scoresRef }), - }, - ]); - await runtime.execute(planFromAst(insert, contract)).toArray(); - - const context = createExecutionContext>({ + const context = createExecutionContext({ contract, stack: createSqlExecutionStack({ target: postgresRuntimeTarget, @@ -163,20 +139,18 @@ describe.sequential('ORM scalar-list read-back', () => { }); const db = orm({ runtime, context }); - // The PSL path yields a generic `Contract`, so the ORM's - // namespace/model accessors are index signatures (bracket access). The - // narrow row-type array inference for `many` columns is proven by the - // emitted-contract path; here we prove the runtime read: the ORM - // projects and decodes the scalar `many` columns as JS arrays. - const publicNamespace = db['public']; - if (!publicNamespace) throw new Error('public namespace missing from ORM client'); - const items = publicNamespace['Item']; - if (!items) throw new Error('Item collection missing from ORM client'); + // Seed through the typed ORM: `tags`/`scores` are typed as + // `ReadonlyArray` / `ReadonlyArray` on the create input. + await db.public.Item.create({ id: 1, tags: ['a', 'b', 'c'], scores: [1, 2, 3] }); + + const rows = await db.public.Item.select('id', 'tags', 'scores').all(); - // Single seeded row — result order is trivially deterministic. - const rows = await items.select('id', 'tags', 'scores').all(); + expect(rows).toEqual([{ id: 1, tags: ['a', 'b', 'c'], scores: [1, 2, 3] }]); - expect(rows).toEqual([{ id: 1, tags, scores }]); + // The whole point: `many` list columns infer as arrays at the type level. + type Row = (typeof rows)[number]; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); }); }, timeouts.spinUpPpgDev, diff --git a/test/integration/test/sql-orm-client/fixtures/scalar-lists/contract.prisma b/test/integration/test/sql-orm-client/fixtures/scalar-lists/contract.prisma new file mode 100644 index 0000000000..ca82ececa8 --- /dev/null +++ b/test/integration/test/sql-orm-client/fixtures/scalar-lists/contract.prisma @@ -0,0 +1,15 @@ +// Real emitted fixture for the scalar-list ORM round-trip integration test. +// +// A single model with two native scalar-list columns: `tags String[]` and +// `scores Int[]`. The postgres emitter lowers these to array storage columns +// (`pg/text@1` / `pg/int4@1`, `many: true`) rather than the jsonb fallback, +// and types each field as `ReadonlyArray<...>` in the generated contract.d.ts. +// The integration test migrates this contract onto a real Postgres database, +// writes a row through the typed ORM, and reads it back — proving `many` list +// columns infer as arrays end-to-end through the strongly-typed accessors. + +model Item { + id Int @id + tags String[] + scores Int[] +} diff --git a/test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.d.ts b/test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.d.ts new file mode 100644 index 0000000000..d59cdb3a82 --- /dev/null +++ b/test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.d.ts @@ -0,0 +1,195 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + Timestamp, + Timestamptz, + Timetz, + VarBit, + Varchar, +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:7758ae9bfcd5aae8da4f48c4ee9165e5d91e8313bb15131b2cd02cc10fe949e9'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly Item: { + readonly id: CodecTypes['pg/int4@1']['output']; + readonly tags: ReadonlyArray; + readonly scores: ReadonlyArray; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly Item: { + readonly id: CodecTypes['pg/int4@1']['input']; + readonly tags: ReadonlyArray; + readonly scores: ReadonlyArray; + }; + }; +}; +export type StorageColumnTypes = { + readonly public: { + readonly item: { + readonly id: CodecTypes['pg/int4@1']['output']; + readonly scores: ReadonlyArray; + readonly tags: ReadonlyArray; + }; + }; +}; +export type StorageColumnInputTypes = { + readonly public: { + readonly item: { + readonly id: CodecTypes['pg/int4@1']['input']; + readonly scores: ReadonlyArray; + readonly tags: ReadonlyArray; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes, + StorageColumnTypes, + StorageColumnInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly item: { + columns: { + readonly id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly tags: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly scores: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly item: { readonly namespace: 'public' & NamespaceId; readonly model: 'Item' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly Item: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly tags: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + readonly many: true; + }; + readonly scores: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + readonly many: true; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'item'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly tags: { readonly column: 'tags' }; + readonly scores: { readonly column: 'scores' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + readonly scalarList: true; + }; + }; + readonly extensionPacks: {}; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.json b/test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.json new file mode 100644 index 0000000000..d1cba54f76 --- /dev/null +++ b/test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.json @@ -0,0 +1,129 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", + "roots": { + "item": { + "model": "Item", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "Item": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "scores": { + "many": true, + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "tags": { + "many": true, + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "scores": { + "column": "scores" + }, + "tags": { + "column": "tags" + } + }, + "namespaceId": "public", + "table": "item" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "item": { + "columns": { + "id": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "scores": { + "codecId": "pg/int4@1", + "many": true, + "nativeType": "int4", + "nullable": false + }, + "tags": { + "codecId": "pg/text@1", + "many": true, + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "sha256:7758ae9bfcd5aae8da4f48c4ee9165e5d91e8313bb15131b2cd02cc10fe949e9" + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true, + "scalarList": true + } + }, + "extensionPacks": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} \ No newline at end of file diff --git a/test/integration/test/sql-orm-client/fixtures/scalar-lists/prisma-next.config.ts b/test/integration/test/sql-orm-client/fixtures/scalar-lists/prisma-next.config.ts new file mode 100644 index 0000000000..00be097bb0 --- /dev/null +++ b/test/integration/test/sql-orm-client/fixtures/scalar-lists/prisma-next.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@prisma-next/postgres/config'; + +export default defineConfig({ + contract: './contract.prisma', + outputPath: 'generated', +}); From 2880a10db1ac29f688eb5f4e5f9381bb94469faa Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 2 Jul 2026 15:47:56 +0000 Subject: [PATCH 13/18] refactor(scalar-arrays): remove low-value comments per review Remove two inline test comments that merely restated the field types / assertion already visible in the code (no rationale carried). Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../orm-list-read.integration.test.ts | 24 ++++--------------- .../test/scalar-lists/psl-list-authoring.ts | 12 ++-------- .../psl-list-mongo-parity.integration.test.ts | 21 +++++----------- .../psl-list-roundtrip.integration.test.ts | 24 +++---------------- 4 files changed, 15 insertions(+), 66 deletions(-) diff --git a/test/integration/test/scalar-lists/orm-list-read.integration.test.ts b/test/integration/test/scalar-lists/orm-list-read.integration.test.ts index 8311f51b68..24da8242c5 100644 --- a/test/integration/test/scalar-lists/orm-list-read.integration.test.ts +++ b/test/integration/test/scalar-lists/orm-list-read.integration.test.ts @@ -1,19 +1,10 @@ /** * Strongly-typed ORM write->read round-trip over native scalar-list columns. * - * The model (`Item { id Int @id; tags String[]; scores Int[] }`) is authored in - * PSL and emitted to a committed contract fixture - * (`../sql-orm-client/fixtures/scalar-lists/generated`). Its storage columns are - * native arrays (`pg/text@1` / `pg/int4@1`, `many: true`, not the jsonb - * fallback) and the generated `contract.d.ts` types each field as - * `ReadonlyArray<...>`. - * - * Because the contract is the precise emitted type (not a widened - * `Contract`), the ORM's namespace/model accessors are fully typed: - * `db.public.Item` is a real `Item` collection, not an index signature. The test - * migrates the contract onto a real Postgres database, seeds a row through the - * typed ORM (`create`), reads it back through dotted, strongly-typed accessors, - * and asserts both the value round-trip and the `many` -> array type inference. + * The test consumes the precise emitted contract fixture (not a widened + * `Contract`), which is what makes the ORM's namespace/model + * accessors fully typed: `db.public.Item` is a real `Item` collection rather + * than an index signature. */ import postgresAdapter from '@prisma-next/adapter-postgres/control'; import postgresRuntimeAdapter from '@prisma-next/adapter-postgres/runtime'; @@ -47,10 +38,6 @@ const controlStack = createControlStack({ }); const familyInstance = sql.create(controlStack); -// Deserialization runs the full sql contract validation pipeline (structure + -// domain + storage semantics), so a contract that failed to round-trip -// validation would throw here. The result carries the precise emitted `Contract` -// type, which is what makes the ORM accessors below strongly typed. const contract = new PostgresContractSerializer().deserializeContract(contractJson) as Contract; async function migrateContract(connectionString: string): Promise { @@ -139,15 +126,12 @@ describe.sequential('ORM scalar-list round-trip', () => { }); const db = orm({ runtime, context }); - // Seed through the typed ORM: `tags`/`scores` are typed as - // `ReadonlyArray` / `ReadonlyArray` on the create input. await db.public.Item.create({ id: 1, tags: ['a', 'b', 'c'], scores: [1, 2, 3] }); const rows = await db.public.Item.select('id', 'tags', 'scores').all(); expect(rows).toEqual([{ id: 1, tags: ['a', 'b', 'c'], scores: [1, 2, 3] }]); - // The whole point: `many` list columns infer as arrays at the type level. type Row = (typeof rows)[number]; expectTypeOf().toEqualTypeOf>(); expectTypeOf().toEqualTypeOf>(); diff --git a/test/integration/test/scalar-lists/psl-list-authoring.ts b/test/integration/test/scalar-lists/psl-list-authoring.ts index 802e6a4aa5..98460e33de 100644 --- a/test/integration/test/scalar-lists/psl-list-authoring.ts +++ b/test/integration/test/scalar-lists/psl-list-authoring.ts @@ -68,11 +68,7 @@ export interface SqlAuthoringResult { readonly contract?: Contract; } -/** - * Authors a PSL document to a deserialized SQL contract through the production - * provider + enrichment path the CLI uses. Returns diagnostics on failure so - * callers can assert authoring acceptance/rejection. - */ +/** Authors PSL through the production provider + enrichment path the CLI uses. */ export async function authorSqlContractFromPsl(schema: string): Promise { const schemaPath = writeSchemaToTempFile(schema); const provider = prismaContract(schemaPath, { @@ -97,11 +93,7 @@ export async function authorSqlContractFromPsl(schema: string): Promise, columnName: string, diff --git a/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts b/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts index 03f5dd591b..a166f04163 100644 --- a/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts +++ b/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts @@ -1,20 +1,11 @@ /** - * Mongo parity for PSL scalar lists. + * Mongo parity for PSL scalar lists: the same PSL list schema yields matching + * observable semantics on SQL and Mongo. * - * The same PSL list schema must yield matching *observable* semantics on SQL - * and Mongo: - * 1. Authoring acceptance — both families author the schema with no - * diagnostics. - * 2. Generated domain type — the emitted `contract.d.ts` types the list field - * as `ReadonlyArray<...>` on both families. - * 3. Decoded element values — element values round-trip through insert/select - * equal to the originals on both families. - * - * Assertions 1 and 2 require no database and run everywhere. Assertion 3 for - * Mongo runs against `mongodb-memory-server`, which fails to spin up on some - * local sandboxes (UnknownLinuxDistro "nixos"); the assertion is written for - * CI, where the memory server runs. The SQL half of assertion 3 is proven in - * `psl-list-roundtrip.integration.test.ts`. + * The decoded-element-values assertion for Mongo runs against + * `mongodb-memory-server`, which fails to spin up on some local sandboxes + * (UnknownLinuxDistro "nixos"); it is written for CI, where the memory server + * runs. */ import type { SerializeContract } from '@prisma-next/contract/hashing'; import { emit } from '@prisma-next/emitter'; diff --git a/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts index e61cfe2be9..460a5a3178 100644 --- a/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts +++ b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts @@ -1,16 +1,7 @@ /** - * End-to-end PSL scalar-list checkpoint + element fidelity through the PSL path. - * - * A `posts.tags String[]` schema authored in PSL emits a contract whose - * `tags` storage column is a native array column (`pg/text@1`, `many:true`, NOT - * jsonb), migrates onto a fresh Postgres database as a `text[]` column with no - * manual edits, and round-trips through `contract infer` back to a `tags - * String[]` PSL field. - * - * A schema with `DateTime[]`, `Bytes[]`, and `Decimal[]` list fields - * — authored in PSL, not a hand-built typed contract — inserts and selects - * rows whose decoded element values deep-equal the originals, proving the - * element codec is applied per element through the whole authored path. + * PSL-authored scalar lists lower to native array columns, and element values + * round-trip with fidelity through the authored path — proven end-to-end against + * a real Postgres database over the production authoring/migration/infer flow. */ import postgresAdapter from '@prisma-next/adapter-postgres/control'; import type { Contract } from '@prisma-next/contract/types'; @@ -116,7 +107,6 @@ describe.sequential('PSL scalar-list end-to-end', () => { async () => { if (!database) throw new Error('database not initialised'); - // --- author --- const authored = await authorSqlContractFromPsl(`model Post { id Int @id tags String[] @@ -125,7 +115,6 @@ describe.sequential('PSL scalar-list end-to-end', () => { const contract = authored.contract; if (!contract) throw new Error('authoring produced no contract'); - // The flip: tags is a native array column, not the jsonb fallback. const tagsColumn = findStorageColumn(contract, 'tags'); expect(tagsColumn).toMatchObject({ codecId: 'pg/text@1', @@ -134,7 +123,6 @@ describe.sequential('PSL scalar-list end-to-end', () => { }); expect(tagsColumn?.['nativeType']).not.toBe('jsonb'); - // --- migrate onto a fresh database, no manual edits --- await withClient(database.connectionString, async (client) => { await client.query('DROP SCHEMA IF EXISTS public CASCADE'); await client.query('CREATE SCHEMA public'); @@ -142,7 +130,6 @@ describe.sequential('PSL scalar-list end-to-end', () => { }); await migrateContract(database.connectionString, contract); - // The migrated column is a real Postgres array (`text[]`). await withClient(database.connectionString, async (client) => { const formatted = await client.query<{ attname: string; formatted_type: string }>( `SELECT a.attname, format_type(a.atttypid, a.atttypmod) AS formatted_type @@ -155,7 +142,6 @@ describe.sequential('PSL scalar-list end-to-end', () => { expect(formatted.rows[0]?.formatted_type).toBe('text[]'); }); - // --- infer: live DB → schema IR → PSL AST round-trips to tags String[] --- const driver = await postgresControlDriver.create(database.connectionString); try { const schemaIR = await familyInstance.introspect({ driver }); @@ -166,7 +152,6 @@ describe.sequential('PSL scalar-list end-to-end', () => { ); expect(postModel).toBeDefined(); const tagsField = postModel?.fields.find((field) => field.name === 'tags'); - // `tags String[]` — a non-null list of a String element. expect(tagsField).toMatchObject({ name: 'tags', typeName: 'String', @@ -203,8 +188,6 @@ model Reading { const contract = authored.contract; if (!contract) throw new Error('authoring produced no contract'); - // Sanity: each list field lowered to a native array column (element codec - // retained), not the jsonb fallback. expect(findStorageColumn(contract, 'dates')).toMatchObject({ codecId: 'pg/timestamptz@1', many: true, @@ -297,7 +280,6 @@ model Reading { const contract = authored.contract; if (!contract) throw new Error('authoring produced no contract'); - // Sanity: each list field lowered to a native array column (not jsonb). expect(findStorageColumn(contract, 'tags')).toMatchObject({ codecId: 'pg/text@1', many: true, From 400f6d7d7a365bf9faa424395f7654fcfa37cbeb Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 2 Jul 2026 16:13:11 +0000 Subject: [PATCH 14/18] fix(telemetry-backend): keep extensions/flags on jsonb storage (no DB migration) Slice 2 lowered PSL scalar lists to native Postgres arrays, flipping the telemetry-backend extensions/flags fields from jsonb to text[] and drifting the emitted storageHash away from the committed migration and production DB. Author both fields as Json so they emit as pg/jsonb@1 / jsonb again, restoring the pre-slice-2 storageHash without touching the committed migration. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../src/prisma/contract.d.ts | 32 +++++++++---------- .../src/prisma/contract.json | 18 ++++------- .../src/prisma/contract.prisma | 4 +-- apps/telemetry-backend/test/handler.test.ts | 6 ++-- 4 files changed, 26 insertions(+), 34 deletions(-) diff --git a/apps/telemetry-backend/src/prisma/contract.d.ts b/apps/telemetry-backend/src/prisma/contract.d.ts index 80b3e4668b..3da2fd6608 100644 --- a/apps/telemetry-backend/src/prisma/contract.d.ts +++ b/apps/telemetry-backend/src/prisma/contract.d.ts @@ -30,7 +30,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:4b58da96bcee8697563d149dd99bc639c62e0760b47a6b860bdfa83e44705f1d'>; + StorageHashBase<'sha256:be92c3521871ea0d863f15c8889093bd38747d60e97bd1dca404da27d0b13bc7'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; @@ -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']; }; }; }; @@ -91,8 +91,8 @@ export type StorageColumnTypes = { readonly arch: CodecTypes['pg/text@1']['output']; readonly command: CodecTypes['pg/text@1']['output']; readonly databaseTarget: CodecTypes['pg/text@1']['output'] | null; - readonly extensions: ReadonlyArray; - readonly flags: ReadonlyArray; + readonly extensions: CodecTypes['pg/jsonb@1']['output']; + readonly flags: CodecTypes['pg/jsonb@1']['output']; readonly id: CodecTypes['pg/int8@1']['output']; readonly ingestedAt: CodecTypes['pg/timestamptz@1']['output']; readonly installationId: CodecTypes['pg/text@1']['output']; @@ -112,8 +112,8 @@ export type StorageColumnInputTypes = { readonly arch: CodecTypes['pg/text@1']['input']; readonly command: CodecTypes['pg/text@1']['input']; readonly databaseTarget: CodecTypes['pg/text@1']['input'] | null; - readonly extensions: ReadonlyArray; - readonly flags: ReadonlyArray; + readonly extensions: CodecTypes['pg/jsonb@1']['input']; + readonly flags: CodecTypes['pg/jsonb@1']['input']; readonly id: CodecTypes['pg/int8@1']['input']; readonly ingestedAt: CodecTypes['pg/timestamptz@1']['input']; readonly installationId: CodecTypes['pg/text@1']['input']; @@ -176,8 +176,8 @@ type ContractBase = Omit< readonly nullable: false; }; readonly flags: { - readonly nativeType: 'text'; - readonly codecId: 'pg/text@1'; + readonly nativeType: 'jsonb'; + readonly codecId: 'pg/jsonb@1'; readonly nullable: false; }; readonly runtimeName: { @@ -221,8 +221,8 @@ type ContractBase = Omit< readonly nullable: true; }; readonly extensions: { - readonly nativeType: 'text'; - readonly codecId: 'pg/text@1'; + readonly nativeType: 'jsonb'; + readonly codecId: 'pg/jsonb@1'; readonly nullable: false; }; }; @@ -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 009b90c828..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" } }, @@ -208,15 +206,13 @@ "nullable": true }, "extensions": { - "codecId": "pg/text@1", - "many": true, - "nativeType": "text", + "codecId": "pg/jsonb@1", + "nativeType": "jsonb", "nullable": false }, "flags": { - "codecId": "pg/text@1", - "many": true, - "nativeType": "text", + "codecId": "pg/jsonb@1", + "nativeType": "jsonb", "nullable": false }, "id": { @@ -294,7 +290,7 @@ "kind": "postgres-schema" } }, - "storageHash": "sha256:4b58da96bcee8697563d149dd99bc639c62e0760b47a6b860bdfa83e44705f1d" + "storageHash": "sha256:be92c3521871ea0d863f15c8889093bd38747d60e97bd1dca404da27d0b13bc7" }, "capabilities": { "postgres": { 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 () => { From 31880a98d6ed92193577698f56aacaa2fb389670 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 2 Jul 2026 16:39:40 +0000 Subject: [PATCH 15/18] refactor(scalar-arrays): drop comments describing cross-codebase field usage Remove the capability-matrix field JSDocs (and the scalar-list gate comment) that described how the field is produced (control stack / enrichContract) and consumed (authoring-time gating) elsewhere. The `capabilities: CapabilityMatrix` field and the gate diagnostic are self-documenting. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../1-core/config/src/contract-source-types.ts | 6 ------ .../framework-components/src/control/control-stack.ts | 7 ------- .../2-sql/2-authoring/contract-psl/src/interpreter.ts | 6 ------ .../2-authoring/contract-psl/src/psl-field-resolution.ts | 8 -------- 4 files changed, 27 deletions(-) diff --git a/packages/1-framework/1-core/config/src/contract-source-types.ts b/packages/1-framework/1-core/config/src/contract-source-types.ts index 195053af2e..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 @@ -46,12 +46,6 @@ export interface ContractSourceContext { readonly codecLookup: CodecLookup; readonly controlMutationDefaults: ControlMutationDefaults; readonly resolvedInputs: readonly string[]; - /** - * Merged capability matrix from the control stack's `[target, adapter, ...extensionPacks]` - * descriptors — the same merge `enrichContract` performs at emit time. Authoring-time - * gating (e.g. scalar lists) reads `capabilities.sql?.scalarList`; an empty matrix - * fails closed and rejects gated features. The control stack always populates it. - */ readonly capabilities: CapabilityMatrix; } 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 1bdb9953fd..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 @@ -64,13 +64,6 @@ export interface ControlStack< readonly authoringContributions: AssembledAuthoringContributions; readonly scalarTypeDescriptors: ReadonlyMap; readonly controlMutationDefaults: ControlMutationDefaults; - /** - * Merged capability matrix folded from the `[target, adapter, ...extensionPacks]` - * descriptors — the same merge `enrichContract` performs at emit time. Authoring - * paths read this to gate features (e.g. scalar lists) before the contract is - * produced, so an unsupported construct is rejected with a diagnostic rather than - * silently emitted. - */ readonly capabilities: CapabilityMatrix; } 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 b7da970c9c..3d548b4c4f 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -133,12 +133,6 @@ export interface InterpretPslDocumentToSqlContractInput { readonly createNamespace: (input: SqlNamespaceInput) => SqlNamespaceBase; readonly codecLookup?: CodecLookup; readonly seedDiagnostics?: readonly ContractSourceDiagnostic[]; - /** - * Merged capability matrix from the control stack's - * `[target, adapter, ...extensionPacks]` descriptors. Authoring-time gating - * (e.g. scalar lists) reads `capabilities.sql?.scalarList`; an empty matrix - * fails closed and rejects gated features. The control stack always populates it. - */ readonly capabilities: CapabilityMatrix; } 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 6d927030fe..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 @@ -148,11 +148,6 @@ export interface CollectResolvedFieldsInput { readonly sourceId: string; readonly scalarTypeDescriptors: ReadonlyMap; readonly enumHandles?: ReadonlyMap; - /** - * Merged adapter capability matrix. A scalar list field whose target does not - * report `sql.scalarList` is rejected with `PSL_SCALAR_LIST_UNSUPPORTED_TARGET`; - * an empty matrix fails closed and rejects scalar lists. - */ readonly capabilities: CapabilityMatrix; } @@ -360,9 +355,6 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv if (isValueObjectField) { descriptor = scalarTypeDescriptors.get('Json'); } else if (isListField) { - // Scalar lists lower to a native array storage column; gate them on the - // adapter-reported `sql.scalarList` capability. The gate fails closed: a - // matrix that omits the capability (SQLite, or an empty matrix) rejects. if (capabilities['sql']?.['scalarList'] !== true) { diagnostics.push({ code: 'PSL_SCALAR_LIST_UNSUPPORTED_TARGET', From 7e230461856f61ad98837ce8d60ba52a41fa95f1 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 1 Jul 2026 16:04:00 +0000 Subject: [PATCH 16/18] refactor(scalar-arrays): unify check-constraint mechanisms + drift-manage element checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two parallel check-constraint mechanisms into one and make the scalar-array element-non-null CHECK drift-managed like enum value-set checks, closing the L10 strict-verify gap. - Discriminate SqlCheckConstraintIR(Input) into valueSet | expression leaves (abstract base + two frozen subclasses + `sqlCheckConstraintIR` factory). - Payload-discriminate AddCheckConstraintCall / addCheckConstraint / migration descriptor: `{kind:'valueSet',column,values} | {kind:'expression',expression}`. - Add one shared `projectContractChecks` + a single element-non-null name/predicate canonicalizer in the family layer; wire it into the verifier, expected-IR builder, and planner so all three agree byte-for-byte. - Recognize `array_position(col, NULL) IS NULL` in parseCheckConstraintDef and re-canonicalize (handles the stored NULL::type cast + quoting) so drift is compared canonical-to-canonical. - Generalize verifyCheckConstraints (expression equality, cross-kind mismatch, per-kind messages) and route element checks through the diff → AddCheckConstraintCall (expression) instead of inline CREATE TABLE synthesis. - Remove the now-dead CheckExpressionConstraint path (DDL union, factory, renderers, SQLite throws), leaving one check-constraint mechanism. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../app/20260422T0720_initial/migration.ts | 6 +- .../1-core/schema-ir/src/exports/types.ts | 5 + .../src/ir/sql-check-constraint-ir.ts | 80 +++++++- .../1-core/schema-ir/src/ir/sql-table-ir.ts | 10 +- packages/2-sql/1-core/schema-ir/src/types.ts | 5 + .../relational-core/src/ast/ddl-types.ts | 27 +-- .../src/contract-free/column.ts | 5 - .../src/exports/contract-free.ts | 1 - .../core/migrations/contract-to-schema-ir.ts | 76 ++++++- .../src/core/schema-verify/verify-helpers.ts | 69 +++++-- .../core/schema-verify/verify-sql-schema.ts | 8 +- .../2-sql/9-family/src/exports/control.ts | 3 + .../9-family/src/exports/schema-verify.ts | 1 + .../schema-verify.check-constraints.test.ts | 114 ++++++++++- .../src/core/migrations/issue-planner.ts | 39 +--- .../src/core/migrations/op-factory-call.ts | 36 ++-- .../core/migrations/operations/constraints.ts | 21 +- .../src/core/migrations/planner-strategies.ts | 51 ++--- .../src/core/migrations/postgres-migration.ts | 7 +- .../src/core/schema-ir/postgres-table-ir.ts | 5 +- .../postgres/src/exports/migration.ts | 1 - .../test/migrations/op-factory-call.test.ts | 28 ++- .../planner.check-constraints.test.ts | 193 +++++++++++++++++- .../src/core/migrations/op-factory-call.ts | 5 - .../postgres/src/core/control-adapter.ts | 67 ++++-- .../control-adapter.check-constraints.test.ts | 79 ++++++- .../native-array-columns.integration.test.ts | 53 +++++ .../sqlite/src/core/control-adapter.ts | 6 - 28 files changed, 790 insertions(+), 211 deletions(-) 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/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/4-lanes/relational-core/src/ast/ddl-types.ts b/packages/2-sql/4-lanes/relational-core/src/ast/ddl-types.ts index be33a27aa0..8130e9d57a 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/ddl-types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/ddl-types.ts @@ -200,29 +200,4 @@ export class UniqueConstraint { } } -/** - * A table-level CHECK constraint carrying a raw SQL predicate expression. Used - * for checks that are not enum value-set restrictions — e.g. the element-non-null - * constraint on a scalar-array column (`array_position(col, NULL) IS NULL`). - * The `expression` is emitted verbatim, so callers must supply safe, - * pre-validated SQL. - * - * Frozen on construction — immutable after creation. - */ -export class CheckExpressionConstraint { - readonly kind = 'check-expression' as const; - readonly name: string; - readonly expression: string; - - constructor(options: { readonly name: string; readonly expression: string }) { - this.name = options.name; - this.expression = options.expression; - Object.freeze(this); - } -} - -export type DdlTableConstraint = - | PrimaryKeyConstraint - | ForeignKeyConstraint - | UniqueConstraint - | CheckExpressionConstraint; +export type DdlTableConstraint = PrimaryKeyConstraint | ForeignKeyConstraint | UniqueConstraint; diff --git a/packages/2-sql/4-lanes/relational-core/src/contract-free/column.ts b/packages/2-sql/4-lanes/relational-core/src/contract-free/column.ts index 3faa8beb8b..effee14f1c 100644 --- a/packages/2-sql/4-lanes/relational-core/src/contract-free/column.ts +++ b/packages/2-sql/4-lanes/relational-core/src/contract-free/column.ts @@ -3,7 +3,6 @@ import type { ReferentialAction } from '@prisma-next/sql-contract/types'; import type { CodecRef } from '../ast/codec-types'; import type { AnyDdlColumnDefault } from '../ast/ddl-types'; import { - CheckExpressionConstraint, DdlColumn, ForeignKeyConstraint, FunctionColumnDefault, @@ -57,7 +56,3 @@ export function unique( ): UniqueConstraint { return new UniqueConstraint({ columns, ...options }); } - -export function checkExpression(name: string, expression: string): CheckExpressionConstraint { - return new CheckExpressionConstraint({ name, expression }); -} diff --git a/packages/2-sql/4-lanes/relational-core/src/exports/contract-free.ts b/packages/2-sql/4-lanes/relational-core/src/exports/contract-free.ts index 65b1920a6b..25b000be6d 100644 --- a/packages/2-sql/4-lanes/relational-core/src/exports/contract-free.ts +++ b/packages/2-sql/4-lanes/relational-core/src/exports/contract-free.ts @@ -1,5 +1,4 @@ export { - checkExpression, col, type DdlColumnOptions, fn, 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-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 1138046faa..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 @@ -31,7 +31,6 @@ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { Result } from '@prisma-next/utils/result'; import { notOk, ok } from '@prisma-next/utils/result'; -import { quoteIdentifier } from '../sql-utils'; import { AddColumnCall, AddForeignKeyCall, @@ -68,26 +67,6 @@ import { resolveColumnTypeMetadata } from './planner-type-resolution'; export type { CallMigrationStrategy, StrategyContext }; -/** - * Deterministic name for the element-non-null CHECK constraint on a scalar-array - * column. 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. - */ -function elementNonNullCheckName(tableName: string, columnName: string): string { - return `${tableName}_${columnName}_elem_not_null`; -} - -/** - * 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. - */ -function elementNonNullCheckExpression(columnName: string): string { - return `array_position(${quoteIdentifier(columnName)}, NULL) IS NULL`; -} - // ============================================================================ // Issue kind ordering (dependency order) // ============================================================================ @@ -275,7 +254,6 @@ function mapIssueToCall( ); } const schemaForTable = tableSchema(issue); - const missingTableName = issue.table; const ddlColumns: DdlColumn[] = Object.entries(contractTable.columns).map(([name, column]) => toDdlColumn(name, column, codecHooks, storageTypes), ); @@ -286,17 +264,14 @@ function mapIssueToCall( }), ] : []; - const elementNonNullChecks: DdlTableConstraint[] = Object.entries(contractTable.columns) - .filter(([, column]) => column.many === true) - .map(([columnName]) => - contractFree.checkExpression( - elementNonNullCheckName(missingTableName, columnName), - elementNonNullCheckExpression(columnName), - ), - ); - const allTableConstraints = [...primaryKeyConstraints, ...elementNonNullChecks]; + // 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 = - allTableConstraints.length > 0 ? allTableConstraints : 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 f4dc4f9e61..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 @@ -168,8 +168,6 @@ function renderDdlConstraintAsTsCall(constraint: DdlTableConstraint): string { const nameOpt = constraint.name ? `, { name: ${jsonToTsSource(constraint.name)} }` : ''; return `unique(${jsonToTsSource(constraint.columns)}${nameOpt})`; } - case 'check-expression': - return `checkExpression(${jsonToTsSource(constraint.name)}, ${jsonToTsSource(constraint.expression)})`; } } @@ -184,7 +182,6 @@ function constraintImportSymbols(constraints: readonly DdlTableConstraint[] | un if (c.kind === 'primary-key') symbols.add('primaryKey'); else if (c.kind === 'foreign-key') symbols.add('foreignKey'); else if (c.kind === 'unique') symbols.add('unique'); - else if (c.kind === 'check-expression') symbols.add('checkExpression'); } return [...symbols]; } @@ -994,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(); } @@ -1031,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/src/exports/migration.ts b/packages/3-targets/3-targets/postgres/src/exports/migration.ts index 5216fc561b..f34e7373a9 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/migration.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/migration.ts @@ -10,7 +10,6 @@ export { MigrationCLI } from '@prisma-next/cli/migration-cli'; // directly. The planner emits an import from this same module. export { placeholder } from '@prisma-next/errors/migration'; export { - checkExpression, col, fn, foreignKey, 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/sqlite/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts index 40a7b08482..933c0b3e5f 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts @@ -96,11 +96,6 @@ function renderDdlConstraintAsTsCall(constraint: DdlTableConstraint): string { const nameOpt = constraint.name ? `, { name: ${jsonToTsSource(constraint.name)} }` : ''; return `unique(${jsonToTsSource(constraint.columns)}${nameOpt})`; } - case 'check-expression': - throw new Error( - `SQLite does not support expression CHECK constraints (constraint "${constraint.name}"). ` + - 'Scalar-array columns and their element-non-null checks are Postgres-only.', - ); } } 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 dea3cc7945..a7358e6b5f 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,52 @@ 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('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 +198,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 2cc53bd158..868bc0511b 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 @@ -414,4 +414,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/sqlite/src/core/control-adapter.ts b/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts index 568b909b78..dd4fd89fc5 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts @@ -730,12 +730,6 @@ function sqliteRenderDdlConstraint(constraint: DdlTableConstraint): string { } return sql; } - if (constraint.kind === 'check-expression') { - throw new Error( - `SQLite does not support expression CHECK constraints (constraint "${constraint.name}"). ` + - 'Scalar-array columns and their element-non-null checks are Postgres-only.', - ); - } const cols = constraint.columns.map(quoteIdentifier).join(', '); if (constraint.name !== undefined) { return `CONSTRAINT ${quoteIdentifier(constraint.name)} UNIQUE (${cols})`; From 3683911e0f776980f72f2256e4723a9b62903cc9 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 1 Jul 2026 16:12:53 +0000 Subject: [PATCH 17/18] docs(upgrade): record addCheckConstraint payload migration for 0.14->0.15 Unifying the check-constraint mechanisms changed the migration builder call `this.addCheckConstraint({column, values})` to a discriminated `{payload: {kind: "valueSet", column, values}}` (and adds a `kind: "expression"` form). Record the consumer transform + detection so the upgrade skill can migrate hand-preserved migration files. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Serhii Tatarintsev --- .../upgrades/0.14-to-0.15/instructions.md | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) 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 ---