diff --git a/apps/telemetry-backend/src/prisma/contract.d.ts b/apps/telemetry-backend/src/prisma/contract.d.ts index f042b7ee50..3da2fd6608 100644 --- a/apps/telemetry-backend/src/prisma/contract.d.ts +++ b/apps/telemetry-backend/src/prisma/contract.d.ts @@ -50,7 +50,7 @@ export type FieldOutputTypes = { readonly installationId: CodecTypes['pg/text@1']['output']; readonly version: CodecTypes['pg/text@1']['output']; readonly command: CodecTypes['pg/text@1']['output']; - readonly flags: ReadonlyArray; + readonly flags: CodecTypes['pg/jsonb@1']['output']; readonly runtimeName: CodecTypes['pg/text@1']['output']; readonly runtimeVersion: CodecTypes['pg/text@1']['output']; readonly os: CodecTypes['pg/text@1']['output']; @@ -59,7 +59,7 @@ export type FieldOutputTypes = { readonly databaseTarget: CodecTypes['pg/text@1']['output'] | null; readonly tsVersion: CodecTypes['pg/text@1']['output'] | null; readonly agent: CodecTypes['pg/text@1']['output'] | null; - readonly extensions: ReadonlyArray; + readonly extensions: CodecTypes['pg/jsonb@1']['output']; }; }; }; @@ -71,7 +71,7 @@ export type FieldInputTypes = { readonly installationId: CodecTypes['pg/text@1']['input']; readonly version: CodecTypes['pg/text@1']['input']; readonly command: CodecTypes['pg/text@1']['input']; - readonly flags: ReadonlyArray; + readonly flags: CodecTypes['pg/jsonb@1']['input']; readonly runtimeName: CodecTypes['pg/text@1']['input']; readonly runtimeVersion: CodecTypes['pg/text@1']['input']; readonly os: CodecTypes['pg/text@1']['input']; @@ -80,7 +80,7 @@ export type FieldInputTypes = { readonly databaseTarget: CodecTypes['pg/text@1']['input'] | null; readonly tsVersion: CodecTypes['pg/text@1']['input'] | null; readonly agent: CodecTypes['pg/text@1']['input'] | null; - readonly extensions: ReadonlyArray; + readonly extensions: CodecTypes['pg/jsonb@1']['input']; }; }; }; @@ -275,8 +275,7 @@ type ContractBase = Omit< }; readonly flags: { readonly nullable: false; - readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; - readonly many: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/jsonb@1' }; }; readonly runtimeName: { readonly nullable: false; @@ -312,8 +311,7 @@ type ContractBase = Omit< }; readonly extensions: { readonly nullable: false; - readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; - readonly many: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/jsonb@1' }; }; }; readonly relations: Record; diff --git a/apps/telemetry-backend/src/prisma/contract.json b/apps/telemetry-backend/src/prisma/contract.json index aea969b101..8d9fb29235 100644 --- a/apps/telemetry-backend/src/prisma/contract.json +++ b/apps/telemetry-backend/src/prisma/contract.json @@ -44,18 +44,16 @@ } }, "extensions": { - "many": true, "nullable": false, "type": { - "codecId": "pg/text@1", + "codecId": "pg/jsonb@1", "kind": "scalar" } }, "flags": { - "many": true, "nullable": false, "type": { - "codecId": "pg/text@1", + "codecId": "pg/jsonb@1", "kind": "scalar" } }, diff --git a/apps/telemetry-backend/src/prisma/contract.prisma b/apps/telemetry-backend/src/prisma/contract.prisma index d88e066164..dc7155294d 100644 --- a/apps/telemetry-backend/src/prisma/contract.prisma +++ b/apps/telemetry-backend/src/prisma/contract.prisma @@ -6,7 +6,7 @@ model TelemetryEvent { installationId String version String command String - flags String[] + flags Json runtimeName String runtimeVersion String os String @@ -15,7 +15,7 @@ model TelemetryEvent { databaseTarget String? tsVersion String? agent String? - extensions String[] + extensions Json @@index([ingestedAt]) @@map("telemetry_event") diff --git a/apps/telemetry-backend/test/handler.test.ts b/apps/telemetry-backend/test/handler.test.ts index ff3775eaa6..3d8828f51a 100644 --- a/apps/telemetry-backend/test/handler.test.ts +++ b/apps/telemetry-backend/test/handler.test.ts @@ -162,10 +162,8 @@ describe('telemetry POST /events', () => { const row = await fetchSingleRow(); expect(row.installationId).toBe(maxString); expect(row.command).toBe(maxString); - expect(row.flags).toHaveLength(64); - expect(row.flags[0]).toBe(maxArrayItem); - expect(row.extensions).toHaveLength(64); - expect(row.extensions[0]).toBe(maxArrayItem); + expect(row.flags).toEqual(manyArrayItems); + expect(row.extensions).toEqual(manyArrayItems); }); it('rejects strings beyond the configured schema bounds with 400', async () => { diff --git a/packages/1-framework/1-core/config/src/contract-source-types.ts b/packages/1-framework/1-core/config/src/contract-source-types.ts index 04d2290575..e71d84070a 100644 --- a/packages/1-framework/1-core/config/src/contract-source-types.ts +++ b/packages/1-framework/1-core/config/src/contract-source-types.ts @@ -1,5 +1,6 @@ import type { Contract } from '@prisma-next/contract/types'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; +import type { CapabilityMatrix } from '@prisma-next/framework-components/components'; import type { AssembledAuthoringContributions, ControlMutationDefaults, @@ -45,6 +46,7 @@ export interface ContractSourceContext { readonly codecLookup: CodecLookup; readonly controlMutationDefaults: ControlMutationDefaults; readonly resolvedInputs: readonly string[]; + readonly capabilities: CapabilityMatrix; } /** Lets format-aware tooling avoid file-extension sniffing and opaque loader introspection. */ diff --git a/packages/1-framework/1-core/framework-components/src/control/control-stack.ts b/packages/1-framework/1-core/framework-components/src/control/control-stack.ts index 13cd068069..0e748186f5 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-stack.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-stack.ts @@ -1,5 +1,7 @@ import type { JsonValue } from '@prisma-next/contract/types'; import { blindCast } from '@prisma-next/utils/casts'; +import type { CapabilityMatrix } from '../shared/capabilities'; +import { mergeCapabilityMatrices } from '../shared/capabilities'; import type { Codec } from '../shared/codec'; import type { AnyCodecDescriptor } from '../shared/codec-descriptor'; import type { CodecLookup, CodecMeta, CodecRef, CodecRegistry } from '../shared/codec-types'; @@ -62,6 +64,7 @@ export interface ControlStack< readonly authoringContributions: AssembledAuthoringContributions; readonly scalarTypeDescriptors: ReadonlyMap; readonly controlMutationDefaults: ControlMutationDefaults; + readonly capabilities: CapabilityMatrix; } export interface CreateControlStackInput< @@ -522,5 +525,10 @@ export function createControlStack>; +export type CapabilityMatrix = Record>; function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/packages/1-framework/2-authoring/psl-parser/src/resolve.ts b/packages/1-framework/2-authoring/psl-parser/src/resolve.ts index bb1ea092ed..c88209fbdc 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/resolve.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/resolve.ts @@ -15,6 +15,7 @@ export interface ResolvedAttributeArg { readonly kind: 'positional' | 'named'; readonly name?: string; readonly value: string; + readonly expression?: ExpressionAst; readonly span: PslSpan; } @@ -69,10 +70,12 @@ function readResolvedArgList( const args: ResolvedAttributeArg[] = []; for (const arg of argList.args()) { const name = arg.name()?.name(); + const expression = arg.value(); args.push({ kind: name !== undefined ? 'named' : 'positional', ...(name !== undefined ? { name } : {}), - value: renderExpression(arg.value()), + value: renderExpression(expression), + ...(expression !== undefined ? { expression } : {}), span: nodePslSpan(arg.syntax, sourceFile), }); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/client.ts b/packages/1-framework/3-tooling/cli/src/control-api/client.ts index 001ab88e12..a6157c6c79 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/client.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/client.ts @@ -631,6 +631,7 @@ class ControlClientImpl implements ControlClient { codecLookup: stack.codecLookup, controlMutationDefaults: stack.controlMutationDefaults, resolvedInputs: contractConfig.source.inputs ?? [], + capabilities: stack.capabilities, }; const providerResult = await contractConfig.source.load(sourceContext); if (!providerResult.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts index 5a1297ae74..21aa578916 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts @@ -221,6 +221,7 @@ export async function executeContractEmit( codecLookup: stack.codecLookup, controlMutationDefaults: stack.controlMutationDefaults, resolvedInputs: contractConfig.source.inputs ?? [], + capabilities: stack.capabilities, }; startSpan(onProgress, 'resolveSource', 'Resolving contract source...'); diff --git a/packages/1-framework/3-tooling/cli/test/config-types.test.ts b/packages/1-framework/3-tooling/cli/test/config-types.test.ts index c8f1d9d4d8..562112026a 100644 --- a/packages/1-framework/3-tooling/cli/test/config-types.test.ts +++ b/packages/1-framework/3-tooling/cli/test/config-types.test.ts @@ -217,6 +217,7 @@ describe('defineConfig', () => { }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), generatorDescriptors: [] }, resolvedInputs: [], + capabilities: {}, }); expect(config.output).toBe('output/contract.json'); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts index 87cdf05523..7aa5ad9f19 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/provider.test.ts @@ -26,6 +26,7 @@ function createMongoTestContext(overrides?: Partial): Con generatorDescriptors: [], }, resolvedInputs: [], + capabilities: {}, ...overrides, }; } diff --git a/packages/2-mongo-family/2-authoring/contract-ts/test/config-types.test.ts b/packages/2-mongo-family/2-authoring/contract-ts/test/config-types.test.ts index c6ff6e458c..32744990d2 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/test/config-types.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/test/config-types.test.ts @@ -19,6 +19,7 @@ const emptyContext: ContractSourceContext = { generatorDescriptors: [], }, resolvedInputs: [], + capabilities: {}, }; function minimalMongoContract(overrides?: { diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index ebdedb647a..3d548b4c4f 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -24,7 +24,11 @@ import { isAuthoringPslBlockDescriptor, } from '@prisma-next/framework-components/authoring'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; -import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; +import type { + CapabilityMatrix, + ExtensionPackRef, + TargetPackRef, +} from '@prisma-next/framework-components/components'; import type { ControlMutationDefaultRegistry, ControlMutationDefaults, @@ -129,6 +133,7 @@ export interface InterpretPslDocumentToSqlContractInput { readonly createNamespace: (input: SqlNamespaceInput) => SqlNamespaceBase; readonly codecLookup?: CodecLookup; readonly seedDiagnostics?: readonly ContractSourceDiagnostic[]; + readonly capabilities: CapabilityMatrix; } function buildComposedExtensionPackRefs( @@ -443,6 +448,7 @@ interface BuildModelNodeInput { /** Resolved namespace id keyed by model name — used to stamp the target namespace on FKs. */ readonly modelNamespaceIds: ReadonlyMap; readonly enumHandles?: ReadonlyMap; + readonly capabilities: CapabilityMatrix; } interface BuildModelNodeResult { @@ -475,6 +481,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult sourceId, scalarTypeDescriptors: input.scalarTypeDescriptors, ...ifDefined('enumHandles', input.enumHandles), + capabilities: input.capabilities, }); const inlineIdFields = resolvedFields.filter((field) => field.isId); @@ -1151,6 +1158,9 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult columnName: resolvedField.columnName, descriptor: resolvedField.descriptor, nullable: resolvedField.nullable, + ...(resolvedField.many && resolvedField.valueObjectTypeName === undefined + ? { many: true as const } + : {}), ...ifDefined('default', resolvedField.defaultValue), ...ifDefined('executionDefaults', resolvedField.executionDefaults), ...ifDefined('enumTypeHandle', enumHandle), @@ -1917,6 +1927,7 @@ export function interpretPslDocumentToSqlContract( diagnostics, modelNamespaceIds, ...(enumHandlesByName.size > 0 ? { enumHandles: enumHandlesByName } : {}), + capabilities: input.capabilities, }); modelNodes.push( namespaceId !== undefined ? { ...result.modelNode, namespaceId } : result.modelNode, diff --git a/packages/2-sql/2-authoring/contract-psl/src/provider.ts b/packages/2-sql/2-authoring/contract-psl/src/provider.ts index 9080471228..95e0d27831 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/provider.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/provider.ts @@ -143,6 +143,7 @@ export function prismaContract(schemaPath: string, options: PrismaContractOption ), controlMutationDefaults: context.controlMutationDefaults, createNamespace: options.createNamespace, + capabilities: context.capabilities, codecLookup: context.codecLookup, }); if (!interpreted.ok) { diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts index 88154337fd..ea67c3fd71 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts @@ -2,6 +2,7 @@ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types' import type { ControlPolicy } from '@prisma-next/contract/types'; import type { PslSpan, ResolvedAttribute } from '@prisma-next/psl-parser'; import { parseQuotedStringLiteral } from '@prisma-next/psl-parser'; +import type { ExpressionAst } from '@prisma-next/psl-parser/syntax'; export { parseQuotedStringLiteral }; @@ -32,7 +33,7 @@ export function getNamedArgument(attribute: ResolvedAttribute, name: string): st export function getPositionalArgumentEntry( attribute: ResolvedAttribute, index = 0, -): { value: string; span: PslSpan } | undefined { +): { value: string; expression?: ExpressionAst; span: PslSpan } | undefined { const entries = attribute.args.filter((arg) => arg.kind === 'positional'); const entry = entries[index]; if (!entry || entry.kind !== 'positional') { @@ -40,6 +41,7 @@ export function getPositionalArgumentEntry( } return { value: entry.value, + ...(entry.expression !== undefined ? { expression: entry.expression } : {}), span: entry.span, }; } diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts index 2a5712b56e..6b03aa30d5 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts @@ -25,6 +25,13 @@ import type { ResolvedAttribute, ResolvedTypeConstructorCall, } from '@prisma-next/psl-parser'; +import { + ArrayLiteralAst, + BooleanLiteralExprAst, + type ExpressionAst, + NumberLiteralExprAst, + StringLiteralExprAst, +} from '@prisma-next/psl-parser/syntax'; import { blindCast } from '@prisma-next/utils/casts'; import { @@ -705,6 +712,44 @@ export function parseDefaultLiteralValue(expression: string): ColumnDefault | un return undefined; } +/** + * Result of interpreting a list-field `@default(...)` expression: + * - `{ kind: 'array', value }` — an array-literal default (possibly empty) + * - `{ kind: 'scalar' }` — a scalar literal where an array was expected + * - `undefined` — not an array of literals (e.g. a function call like `now()`), + * so the caller falls through to function-default handling. + */ +type ListDefaultParse = + | { readonly kind: 'array'; readonly value: readonly (string | number | boolean)[] } + | { readonly kind: 'scalar' } + | undefined; + +function decodeLiteralElement(element: ExpressionAst): string | number | boolean | undefined { + if (element instanceof StringLiteralExprAst) return element.value(); + if (element instanceof NumberLiteralExprAst) return element.value(); + if (element instanceof BooleanLiteralExprAst) return element.value(); + return undefined; +} + +function parseListDefaultExpression(expression: ExpressionAst | undefined): ListDefaultParse { + if (expression === undefined) return undefined; + if ( + expression instanceof StringLiteralExprAst || + expression instanceof NumberLiteralExprAst || + expression instanceof BooleanLiteralExprAst + ) { + return { kind: 'scalar' }; + } + if (!(expression instanceof ArrayLiteralAst)) return undefined; + const value: (string | number | boolean)[] = []; + for (const element of expression.elements()) { + const decoded = decodeLiteralElement(element); + if (decoded === undefined) return undefined; + value.push(decoded); + } + return { kind: 'array', value }; +} + export function lowerDefaultForField(input: { readonly modelName: string; readonly fieldName: string; @@ -714,6 +759,7 @@ export function lowerDefaultForField(input: { readonly sourceId: string; readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; readonly diagnostics: ContractSourceDiagnostic[]; + readonly isList?: boolean; }): { readonly defaultValue?: ColumnDefault; readonly executionDefaults?: ExecutionMutationDefaultPhases; @@ -742,6 +788,24 @@ export function lowerDefaultForField(input: { return {}; } + if (input.isList) { + const listParse = parseListDefaultExpression(expressionEntry.expression); + if (listParse?.kind === 'array') { + return { defaultValue: { kind: 'literal', value: [...listParse.value] } }; + } + if (listParse?.kind === 'scalar') { + input.diagnostics.push({ + code: 'PSL_LIST_DEFAULT_NOT_ARRAY', + message: `Field "${input.modelName}.${input.fieldName}" is a list and its @default must be an array literal like [] or ["a", "b"], not a scalar value.`, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + }); + return {}; + } + // Not a literal at all (e.g. a function call) — fall through to the + // function-default path, which the list execution-default guard rejects. + } + const literalDefault = parseDefaultLiteralValue(expressionEntry.value); if (literalDefault) { return { defaultValue: literalDefault }; diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts index 87cb695454..dc17051eae 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts @@ -5,6 +5,7 @@ import type { ExecutionMutationDefaultPhases, } from '@prisma-next/contract/types'; import type { AuthoringContributions } from '@prisma-next/framework-components/authoring'; +import type { CapabilityMatrix } from '@prisma-next/framework-components/components'; import type { ControlMutationDefaultRegistry, MutationDefaultGeneratorDescriptor, @@ -147,6 +148,7 @@ export interface CollectResolvedFieldsInput { readonly sourceId: string; readonly scalarTypeDescriptors: ReadonlyMap; readonly enumHandles?: ReadonlyMap; + readonly capabilities: CapabilityMatrix; } const BUILTIN_FIELD_ATTRIBUTE_NAMES: ReadonlySet = new Set([ @@ -297,6 +299,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv sourceId, scalarTypeDescriptors, enumHandles, + capabilities, } = input; const resolvedFields: ResolvedField[] = []; @@ -352,6 +355,15 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv if (isValueObjectField) { descriptor = scalarTypeDescriptors.get('Json'); } else if (isListField) { + if (capabilities['sql']?.['scalarList'] !== true) { + diagnostics.push({ + code: 'PSL_SCALAR_LIST_UNSUPPORTED_TARGET', + message: `Field "${model.name}.${field.name}" is a scalar list, but target "${targetId}" does not support scalar lists (the adapter does not report the "scalarList" capability). Remove the list or author it against a target that supports scalar lists.`, + sourceId, + span: field.span, + }); + continue; + } const resolved = resolveFieldTypeDescriptor(resolveInput); if (!resolved.ok) { if (!resolved.alreadyReported) { @@ -376,7 +388,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv continue; } scalarCodecId = resolved.descriptor.codecId; - descriptor = scalarTypeDescriptors.get('Json'); + descriptor = resolved.descriptor; } else { const resolved = resolveFieldTypeDescriptor(resolveInput); if (!resolved.ok) { @@ -442,9 +454,23 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv sourceId, defaultFunctionRegistry, diagnostics, + isList: isListField, }) : {}; const loweredOnCreate = loweredDefault.executionDefaults?.onCreate; + const loweredFunctionDefault = loweredDefault.defaultValue?.kind === 'function'; + if (isListField && (loweredOnCreate || loweredFunctionDefault)) { + const defaultExpression = + defaultAttribute?.args.find((arg) => arg.kind === 'positional')?.value.trim() ?? + 'this function'; + diagnostics.push({ + code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', + message: `Field "${model.name}.${field.name}" is a list and cannot use an execution default ("${defaultExpression}"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.`, + sourceId, + span: defaultAttribute?.span ?? field.span, + }); + continue; + } if (field.optional && loweredOnCreate) { const generatorDescription = loweredOnCreate.kind === 'generator' ? `"${loweredOnCreate.id}"` : 'for this field'; @@ -474,6 +500,15 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv diagnostics, }); let isIdField = Boolean(idAttribute); + if (idAttribute && isListField) { + diagnostics.push({ + code: 'PSL_LIST_ID_UNSUPPORTED', + message: `Field "${model.name}.${field.name}" is a list and cannot be a primary key. Remove @id; a list cannot be an identity column.`, + sourceId, + span: idAttribute.span, + }); + continue; + } if (idAttribute && field.optional) { diagnostics.push({ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', diff --git a/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts index 5824b9452b..4cb22bd029 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts @@ -18,7 +18,11 @@ describe('composed mutation default registries', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'capabilities' > & Partial>, ) => @@ -27,6 +31,7 @@ describe('composed mutation default registries', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts index df220086c9..a6286d0367 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -416,6 +416,7 @@ export function createPostgresTestContext( codecLookup: postgresCodecLookup, controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), resolvedInputs: [], + capabilities: { sql: { scalarList: true } }, ...overrides, }; } diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.capability-gating.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.capability-gating.test.ts new file mode 100644 index 0000000000..b932275a4a --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.capability-gating.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; +import { interpretPslDocumentToSqlContract } from '../src/interpreter'; +import { + createBuiltinLikeControlMutationDefaults, + modelsOf, + postgresScalarTypeDescriptors, + postgresTarget, + sqliteScalarTypeDescriptors, + sqliteTarget, + symbolTableInputFromParseArgs, +} from './fixtures'; + +const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); + +const postgresCapabilities = { sql: { scalarList: true } } as const; +const sqliteCapabilities = { sql: {} } as const; + +const listSchema = `model User { + id Int @id + tags String[] +}`; + +describe('interpretPslDocumentToSqlContract scalar-list capability gating', () => { + it('rejects a scalar list field against a target whose adapter lacks the scalarList capability', () => { + const document = symbolTableInputFromParseArgs({ + schema: listSchema, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + target: sqliteTarget, + scalarTypeDescriptors: sqliteScalarTypeDescriptors, + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, + capabilities: sqliteCapabilities, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_SCALAR_LIST_UNSUPPORTED_TARGET', + message: + 'Field "User.tags" is a scalar list, but target "sqlite" does not support scalar lists (the adapter does not report the "scalarList" capability). Remove the list or author it against a target that supports scalar lists.', + }), + ]), + ); + }); + + it('authors a scalar list field cleanly against a target whose adapter reports the scalarList capability', () => { + const document = symbolTableInputFromParseArgs({ + schema: listSchema, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, + capabilities: postgresCapabilities, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(modelsOf(result.value)).toMatchObject({ + User: { + fields: { + tags: { + nullable: false, + type: { kind: 'scalar', codecId: 'pg/text@1' }, + many: true, + }, + }, + }, + }); + }); + + it('rejects a scalar list against an empty capability matrix (fail-closed)', () => { + const document = symbolTableInputFromParseArgs({ + schema: listSchema, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, + capabilities: {}, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'PSL_SCALAR_LIST_UNSUPPORTED_TARGET' }), + ]), + ); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts index bb5cc2ed95..1331cb36aa 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts @@ -24,6 +24,7 @@ function interpretSchema(schema: string) { composedExtensionContracts: new Map(), controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); } diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts index 782ab185ef..65e7be9b0c 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts @@ -20,7 +20,11 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'capabilities' > & Partial>, ) => @@ -29,6 +33,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, ...input, }); it('lowers supported default functions into execution and storage contract shapes', () => { @@ -498,6 +503,7 @@ model UuidNativeBad { controlMutationDefaults: builtinControlMutationDefaults, authoringContributions: sqliteTemporalContributions, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts index 6945f4269d..c777722a28 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts @@ -7,18 +7,21 @@ import { import { createBuiltinLikeControlMutationDefaults, documentScopedTypes, + modelsOf, postgresScalarTypeDescriptors, postgresTarget, sqliteScalarTypeDescriptors, sqliteTarget, symbolTableInputFromParseArgs, } from './fixtures'; +import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contract-storage'; const baseInput = { target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, } as const; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); @@ -1085,6 +1088,7 @@ model User { ...document, controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(false); @@ -1121,6 +1125,7 @@ model User { ...document, controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(false); @@ -1192,3 +1197,258 @@ namespace auth { }); }); }); + +describe('interpretPslDocumentToSqlContract list-field constructs', () => { + it('rejects an execution default now() on a list field', () => { + expectDiagnosticForSchema( + `model Post { + id Int @id + tags String[] @default(now()) +} +`, + { + code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', + message: + 'Field "Post.tags" is a list and cannot use an execution default ("now()"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.', + }, + ); + }); + + it('rejects an execution default uuid() on a list field', () => { + expectDiagnosticForSchema( + `model Post { + id Int @id + tags String[] @default(uuid()) +} +`, + { + code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', + message: + 'Field "Post.tags" is a list and cannot use an execution default ("uuid()"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.', + }, + ); + }); + + it('rejects an execution default autoincrement() on a list field', () => { + expectDiagnosticForSchema( + `model Post { + id Int @id + tags Int[] @default(autoincrement()) +} +`, + { + code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', + message: + 'Field "Post.tags" is a list and cannot use an execution default ("autoincrement()"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.', + }, + ); + }); + + it('rejects @id on a list field', () => { + expectDiagnosticForSchema( + `model Post { + tags String[] @id +} +`, + { + code: 'PSL_LIST_ID_UNSUPPORTED', + message: + 'Field "Post.tags" is a list and cannot be a primary key. Remove @id; a list cannot be an identity column.', + }, + ); + }); + + it('authors a plain scalar list field with no diagnostics', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + tags String[] +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(modelsOf(result.value)).toMatchObject({ + Post: { + fields: { + tags: { + nullable: false, + type: { kind: 'scalar', codecId: 'pg/text@1' }, + many: true, + }, + }, + }, + }); + }); + + it('rejects a scalar literal default on a list field', () => { + expectDiagnosticForSchema( + `model Post { + id Int @id + tags String[] @default("x") +} +`, + { + code: 'PSL_LIST_DEFAULT_NOT_ARRAY', + message: + 'Field "Post.tags" is a list and its @default must be an array literal like [] or ["a", "b"], not a scalar value.', + }, + ); + }); + + it('rejects a scalar numeric default on a list field', () => { + expectDiagnosticForSchema( + `model Post { + id Int @id + scores Int[] @default(5) +} +`, + { + code: 'PSL_LIST_DEFAULT_NOT_ARRAY', + message: + 'Field "Post.scores" is a list and its @default must be an array literal like [] or ["a", "b"], not a scalar value.', + }, + ); + }); + + it('lowers an empty-array default on a list field to a literal empty array', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + tags String[] @default([]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(storage.namespaces['public']?.entries.table?.['post']?.columns['tags']).toMatchObject({ + nativeType: 'text', + codecId: 'pg/text@1', + many: true, + default: { kind: 'literal', value: [] }, + }); + }); + + it('lowers a literal-list default encoding each element against the element codec', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + tags String[] @default(["a", "b"]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(storage.namespaces['public']?.entries.table?.['post']?.columns['tags']).toMatchObject({ + many: true, + default: { kind: 'literal', value: ['a', 'b'] }, + }); + }); + + it('lowers a numeric-list default to a literal number array', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + scores Int[] @default([1, 2]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(storage.namespaces['public']?.entries.table?.['post']?.columns['scores']).toMatchObject({ + many: true, + default: { kind: 'literal', value: [1, 2] }, + }); + }); + + it('lowers a boolean-list default to a literal boolean array', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + flags Boolean[] @default([true, false]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(storage.namespaces['public']?.entries.table?.['post']?.columns['flags']).toMatchObject({ + many: true, + default: { kind: 'literal', value: [true, false] }, + }); + }); + + it('preserves commas inside a quoted list-default element', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + tags String[] @default(["a,b", "c"]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(storage.namespaces['public']?.entries.table?.['post']?.columns['tags']).toMatchObject({ + many: true, + default: { kind: 'literal', value: ['a,b', 'c'] }, + }); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts index 881958c154..899aa17750 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts @@ -98,6 +98,7 @@ function interpret(schema: string, overrides?: Partial { @@ -585,6 +586,7 @@ namespace public { composedExtensionContracts: new Map(), authoringContributions, createNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts index fc0100565b..6a8b87ee25 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts @@ -102,6 +102,7 @@ const baseInput = { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, } as const; describe('un-namespaced PG model defaults to public namespace (TML-2916)', () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index 7cd9a2051f..7b49fc644a 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -21,7 +21,11 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'capabilities' > & Partial>, ) => @@ -30,6 +34,7 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts index d2aa4a706d..5ba7894b82 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts @@ -19,6 +19,7 @@ const baseInput = { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, } as const; function interpretSchema(schema: string) { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts index 62c74619f6..d335fac908 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts @@ -17,6 +17,7 @@ const baseInput = { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, } as const; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts index 3d3b3ad30a..d89863d3b0 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts @@ -32,7 +32,11 @@ describe('interpretPslDocumentToSqlContract', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'capabilities' > & Partial>, ) => @@ -42,6 +46,7 @@ describe('interpretPslDocumentToSqlContract', () => { authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, ...input, }); @@ -64,6 +69,7 @@ describe('interpretPslDocumentToSqlContract', () => { composedExtensionContracts: new Map(), controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); @@ -130,6 +136,7 @@ describe('interpretPslDocumentToSqlContract', () => { authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); @@ -154,6 +161,7 @@ describe('interpretPslDocumentToSqlContract', () => { target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), + capabilities: { sql: { scalarList: true } }, controlMutationDefaults: { defaultFunctionRegistry: new Map([ [ diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts index 3db4fb6c64..3cb8af0f31 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts @@ -17,6 +17,7 @@ const baseInput = { authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, } as const; describe('interpretPslDocumentToSqlContract types', () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts index 71a41b5e7e..0d6848bb0f 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts @@ -18,7 +18,11 @@ describe('interpretPslDocumentToSqlContract value objects and list fields', () = const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'capabilities' > & Partial>, ) => @@ -27,6 +31,7 @@ describe('interpretPslDocumentToSqlContract value objects and list fields', () = scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, ...input, }); @@ -150,7 +155,7 @@ model User { }); }); - it('emits scalar list fields with many: true', () => { + it('lowers scalar list fields to native array storage columns', () => { const document = symbolTableInputFromParseArgs({ schema: `model User { id Int @id @@ -187,8 +192,9 @@ model User { user: { columns: { tags: { - nativeType: 'jsonb', - codecId: 'pg/jsonb@1', + nativeType: 'text', + codecId: 'pg/text@1', + many: true, nullable: false, }, }, @@ -200,6 +206,57 @@ model User { }); }); + it('lowers nullable scalar list fields to native array storage columns', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model User { + id Int @id + tags String[]? +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(modelsOf(result.value)).toMatchObject({ + User: { + fields: { + tags: { + nullable: true, + type: { kind: 'scalar', codecId: 'pg/text@1' }, + many: true, + }, + }, + }, + }); + + expect(result.value.storage).toMatchObject({ + namespaces: { + public: { + entries: { + table: { + user: { + columns: { + tags: { + nativeType: 'text', + codecId: 'pg/text@1', + many: true, + nullable: true, + }, + }, + }, + }, + }, + }, + }, + }); + }); + it('emits value object list fields with many: true and valueObject domain type', () => { const document = symbolTableInputFromParseArgs({ schema: `type Address { diff --git a/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts index d7be8ada9d..d4f5faf70b 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts @@ -58,6 +58,7 @@ namespace public { composedExtensionContracts: new Map(), controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(pslResult.ok).toBe(true); @@ -183,6 +184,7 @@ namespace public { composedExtensionPacks: ['supabase'], composedExtensionContracts: new Map([['supabase', syntheticExtensionContract]]), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(pslResult.ok).toBe(true); @@ -253,6 +255,7 @@ namespace public { composedExtensionPacks: ['supabase'], composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(false); diff --git a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts index ff577264c6..8d4aeddb2e 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts @@ -379,6 +379,7 @@ describe('TS and PSL authoring parity', () => { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), authoringContributions: target.authoringContributions, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(interpreted.ok).toBe(true); @@ -430,6 +431,7 @@ model Post { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), authoringContributions, createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(pslContract.ok).toBe(true); diff --git a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts index 145263ec4a..060d5b0ad2 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts @@ -73,10 +73,23 @@ function encodeColumnDefault( defaultInput: ColumnDefault, codecId: string, codecLookup?: CodecLookup, + many = false, ): ColumnDefault { if (defaultInput.kind === 'function') { return { kind: 'function', expression: defaultInput.expression }; } + if (many) { + if (!Array.isArray(defaultInput.value)) { + throw new Error( + `Literal default on a list column must be an array; received ${typeof defaultInput.value}. ` + + 'A scalar default on a list field must be rejected at the authoring surface.', + ); + } + return { + kind: 'literal', + value: defaultInput.value.map((element) => encodeViaCodec(element, codecId, codecLookup)), + }; + } return { kind: 'literal', value: encodeViaCodec(defaultInput.value, codecId, codecLookup), @@ -234,24 +247,17 @@ function buildStorageColumn( }; } - if (field.many) { - return { - nativeType: JSONB_NATIVE_TYPE, - codecId: JSONB_CODEC_ID, - nullable: field.nullable, - }; - } - const codecId = field.descriptor.codecId; const encodedDefault = field.default !== undefined - ? encodeColumnDefault(field.default, codecId, codecLookup) + ? encodeColumnDefault(field.default, codecId, codecLookup, field.many === true) : undefined; return { nativeType: field.descriptor.nativeType, codecId, nullable: field.nullable, + ...(field.many ? { many: true as const } : {}), ...ifDefined('typeParams', field.descriptor.typeParams), ...ifDefined('default', encodedDefault), ...ifDefined('typeRef', field.descriptor.typeRef), diff --git a/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts b/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts index 1021badf2a..101b625359 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts @@ -35,6 +35,7 @@ const stubContext: ContractSourceContext = { }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), generatorDescriptors: [] }, resolvedInputs: [], + capabilities: {}, }; describe('source format discriminator', () => { diff --git a/packages/2-sql/2-authoring/contract-ts/test/scalar-list-output-types.test-d.ts b/packages/2-sql/2-authoring/contract-ts/test/scalar-list-output-types.test-d.ts index 65b03f1141..2360816048 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/scalar-list-output-types.test-d.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/scalar-list-output-types.test-d.ts @@ -13,7 +13,12 @@ import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; import type { FamilyPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; -import type { ExtractCodecTypes, ExtractFieldOutputTypes } from '@prisma-next/sql-contract/types'; +import type { + ExtractCodecTypes, + ExtractFieldOutputTypes, + ExtractStorageColumnInputTypes, + ExtractStorageColumnTypes, +} from '@prisma-next/sql-contract/types'; import { expectTypeOf, test } from 'vitest'; import { field, model } from '../src/contract-builder'; import type { SqlContractResult } from '../src/contract-types'; @@ -55,6 +60,8 @@ const definition = { type Contract = SqlContractResult; type FieldTypes = ExtractFieldOutputTypes['public']['Post']; +type StorageOutputTypes = ExtractStorageColumnTypes['public']['posts']; +type StorageInputTypes = ExtractStorageColumnInputTypes['public']['posts']; test('codec types include pg/text@1', () => { expectTypeOf< @@ -73,3 +80,17 @@ test('nullable-container list field resolves to ReadonlyArray | null', ( test('scalar field is unaffected', () => { expectTypeOf().toEqualTypeOf(); }); + +test('non-null list storage column resolves to ReadonlyArray', () => { + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); +}); + +test('nullable-container list storage column resolves to ReadonlyArray | null', () => { + expectTypeOf().toEqualTypeOf | null>(); + expectTypeOf().toEqualTypeOf | null>(); +}); + +test('scalar storage column is unaffected', () => { + expectTypeOf().toEqualTypeOf(); +}); diff --git a/packages/2-sql/3-tooling/emitter/src/index.ts b/packages/2-sql/3-tooling/emitter/src/index.ts index d88d93c248..561d86baf1 100644 --- a/packages/2-sql/3-tooling/emitter/src/index.ts +++ b/packages/2-sql/3-tooling/emitter/src/index.ts @@ -466,6 +466,7 @@ function computeColumnType( if (base === undefined) { base = renderRefinedCodecType(column, side, columnTypeParams(storage, column), codecLookup); } + if (column.many === true) base = `ReadonlyArray<${base}>`; return column.nullable ? `${base} | null` : base; } diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts index 1529181f3e..453afb18f5 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts @@ -867,4 +867,56 @@ describe('StorageColumnTypes', () => { const storageColumnMatch = dts.match(/export type StorageColumnTypes = ({.+?});/s); expect(storageColumnMatch![0]).toContain("readonly level: 'low' | 'high' | 'urgent'"); }); + + it('wraps StorageColumnTypes/StorageColumnInputTypes in ReadonlyArray for a native many[] column', () => { + const contract = createContract({ + models: { + Post: { + storage: { + table: 'post', + fields: { tags: { column: 'tags' }, labels: { column: 'labels' } }, + }, + fields: { + tags: { nullable: false, many: true, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + labels: { nullable: true, many: true, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + }, + relations: {}, + }, + }, + storage: { + tables: { + post: { + columns: { + tags: { nativeType: 'text', codecId: 'pg/text@1', nullable: false, many: true }, + labels: { nativeType: 'text', codecId: 'pg/text@1', nullable: true, many: true }, + }, + primaryKey: { columns: ['tags'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }); + + const dts = generateContractDts(contract, sqlEmission, [], testHashes); + + const outputMatch = dts.match(/export type StorageColumnTypes = ({.+?});/s); + expect(outputMatch).not.toBeNull(); + expect(outputMatch![0]).toContain( + "readonly tags: ReadonlyArray", + ); + expect(outputMatch![0]).toContain( + "readonly labels: ReadonlyArray | null", + ); + + const inputMatch = dts.match(/export type StorageColumnInputTypes = ({.+?});/s); + expect(inputMatch).not.toBeNull(); + expect(inputMatch![0]).toContain( + "readonly tags: ReadonlyArray", + ); + expect(inputMatch![0]).toContain( + "readonly labels: ReadonlyArray | null", + ); + }); }); diff --git a/packages/2-sql/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-extensions/postgres/test/psl-namespace-qualifier-routing.test.ts b/packages/3-extensions/postgres/test/psl-namespace-qualifier-routing.test.ts index 8d3ad81290..eac4fc4b99 100644 --- a/packages/3-extensions/postgres/test/psl-namespace-qualifier-routing.test.ts +++ b/packages/3-extensions/postgres/test/psl-namespace-qualifier-routing.test.ts @@ -70,6 +70,7 @@ describe('PSL → SqlStorage.namespaces qualifier routing (FR15 slice 3 + FR16a scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); if (!result.ok) { @@ -106,6 +107,7 @@ describe('PSL → SqlStorage.namespaces qualifier routing (FR15 slice 3 + FR16a scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); if (!result.ok) { @@ -135,6 +137,7 @@ describe('PSL → SqlStorage.namespaces qualifier routing (FR15 slice 3 + FR16a scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: postgresCreateNamespace, + capabilities: { sql: { scalarList: true } }, }); expect(result.ok).toBe(true); if (!result.ok) { diff --git a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts index 49954f23c5..54dbd1f419 100644 --- a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts +++ b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts @@ -58,6 +58,63 @@ function canonicalizeTimestampDefault(expr: string): string | undefined { return undefined; } +type ArrayElementToken = { readonly value: string; readonly quoted: boolean }; + +/** + * Splits a Postgres array literal body (without the enclosing braces) into its + * element tokens, honouring quoting. A comma only separates elements when it is + * outside double quotes; inside a quoted element a doubled quote (`""`) or a + * backslash-escaped quote (`\"`) is a literal quote, and a backslash escapes the + * next character. Returns undefined if the body is malformed (e.g. an unbalanced + * quote). + */ +function splitArrayElements(inner: string): readonly ArrayElementToken[] | undefined { + const tokens: ArrayElementToken[] = []; + let current = ''; + let inQuotes = false; + let quoted = false; + + for (let i = 0; i < inner.length; i++) { + const char = inner[i]; + if (inQuotes) { + if (char === '\\') { + const next = inner[i + 1]; + if (next === undefined) return undefined; + current += next; + i++; + continue; + } + if (char === '"') { + if (inner[i + 1] === '"') { + current += '"'; + i++; + continue; + } + inQuotes = false; + continue; + } + current += char; + continue; + } + if (char === '"') { + inQuotes = true; + quoted = true; + continue; + } + if (char === ',') { + tokens.push({ value: current, quoted }); + current = ''; + quoted = false; + continue; + } + current += char; + } + + if (inQuotes) return undefined; + tokens.push({ value: current, quoted }); + return tokens; +} + /** * Parses a Postgres array literal body (`{...}`) into a JS array of primitives. * Returns undefined if the body cannot be reliably parsed. @@ -65,14 +122,23 @@ function canonicalizeTimestampDefault(expr: string): string | undefined { * Handles: * - `{}` → `[]` * - `{elem1,elem2,...}` → `[elem1, elem2, ...]` with numeric and string element coercion + * - quoted elements that contain commas, doubled/escaped quotes, and the literal + * strings `NULL`/`true`/`false` (a quoted token is always a string) */ function parseArrayLiteralBody(body: string): readonly JsonValue[] | undefined { const inner = body.slice(1, -1).trim(); if (inner === '') return []; - const elements = inner.split(','); + const tokens = splitArrayElements(inner); + if (tokens === undefined) return undefined; const result: JsonValue[] = []; - for (const rawEl of elements) { - const el = rawEl.trim(); + for (const token of tokens) { + if (token.quoted) { + // A quoted token is always a string — `"NULL"`, `"true"`, `"1"` are the + // literal text, never the keyword/number. + result.push(token.value); + continue; + } + const el = token.value.trim(); if (el.toUpperCase() === 'NULL') { result.push(null); continue; @@ -89,10 +155,6 @@ function parseArrayLiteralBody(body: string): readonly JsonValue[] | undefined { result.push(Number(el)); continue; } - if (el.startsWith('"') && el.endsWith('"')) { - result.push(el.slice(1, -1).replace(/""/g, '"')); - continue; - } return undefined; } return result; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 7404386c2b..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/postgres/test/default-normalizer.test.ts b/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts index 45a764453c..9cafd12d27 100644 --- a/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts +++ b/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts @@ -46,4 +46,32 @@ describe('parsePostgresDefault array literals', () => { const result = parsePostgresDefault("'{hello world}'::text[]", 'text[]'); expect(result?.kind).not.toBe('literal'); }); + + it('keeps a comma inside a quoted element as part of that element', () => { + expect(parsePostgresDefault('\'{"a,b","c"}\'::text[]', 'text[]')).toEqual({ + kind: 'literal', + value: ['a,b', 'c'], + }); + }); + + it('unescapes a doubled quote inside a quoted element', () => { + expect(parsePostgresDefault('\'{"a""b"}\'::text[]', 'text[]')).toEqual({ + kind: 'literal', + value: ['a"b'], + }); + }); + + it('unescapes a backslash-escaped quote inside a quoted element', () => { + expect(parsePostgresDefault('\'{"a\\"b"}\'::text[]', 'text[]')).toEqual({ + kind: 'literal', + value: ['a"b'], + }); + }); + + it('keeps a quoted element that looks like NULL as the literal string', () => { + expect(parsePostgresDefault('\'{"NULL"}\'::text[]', 'text[]')).toEqual({ + kind: 'literal', + value: ['NULL'], + }); + }); }); diff --git a/packages/3-targets/3-targets/postgres/test/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/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/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/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})`; diff --git a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md index 8e71e1e888..d42e04a798 100644 --- a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md +++ b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md @@ -194,3 +194,19 @@ it from there instead of defining a local copy. Behaviour-preserving — the run facade surface (`db.enums`, `sql`, `orm`) is byte-identical. No extension-author API or surface change; nothing to migrate. Incidental substrate diff only. --> + + diff --git a/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/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/orm-list-read.integration.test.ts b/test/integration/test/scalar-lists/orm-list-read.integration.test.ts new file mode 100644 index 0000000000..24da8242c5 --- /dev/null +++ b/test/integration/test/scalar-lists/orm-list-read.integration.test.ts @@ -0,0 +1,142 @@ +/** + * Strongly-typed ORM write->read round-trip over native scalar-list columns. + * + * 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'; +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 { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime'; +import postgres from '@prisma-next/target-postgres/control'; +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, 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 { postgresFrameworkComponents } from './psl-list-authoring'; + +const controlStack = createControlStack({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresControlDriver, + extensionPacks: [], +}); +const familyInstance = sql.create(controlStack); + +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 as FrameworkContract, + 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 as FrameworkContract, + 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 round-trip', () => { + let database: DevDatabase | undefined; + + beforeAll(async () => { + database = await createDevDatabase(); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) await database.close(); + }, timeouts.spinUpPpgDev); + + it( + 'writes and reads String[]/Int[] columns through the typed ORM, inferring arrays', + async () => { + if (!database) throw new Error('database not initialised'); + + 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); + + await withClient(database.connectionString, async (client) => { + const runtime = await createTestRuntimeFromClient( + contract as FrameworkContract, + client, + { verifyMarker: false }, + ); + + const context = createExecutionContext({ + contract, + stack: createSqlExecutionStack({ + target: postgresRuntimeTarget, + adapter: postgresRuntimeAdapter, + extensionPacks: [], + }), + }); + const db = orm({ runtime, context }); + + 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] }]); + + type Row = (typeof rows)[number]; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); + }); + }, + timeouts.spinUpPpgDev, + ); +}); 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..98460e33de --- /dev/null +++ b/test/integration/test/scalar-lists/psl-list-authoring.ts @@ -0,0 +1,200 @@ +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: [], + capabilities: sqlStack.capabilities, +}; + +const mongoSourceContext: ContractSourceContext = { + composedExtensionPacks: [], + composedExtensionContracts: new Map(), + scalarTypeDescriptors: mongoStack.scalarTypeDescriptors, + authoringContributions: mongoStack.authoringContributions, + codecLookup: mongoStack.codecLookup, + controlMutationDefaults: mongoStack.controlMutationDefaults, + resolvedInputs: [], + capabilities: mongoStack.capabilities, +}; + +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 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, { + 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 }; +} + +/** Returns the first column matching `columnName` across all storage tables. */ +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..a166f04163 --- /dev/null +++ b/test/integration/test/scalar-lists/psl-list-mongo-parity.integration.test.ts @@ -0,0 +1,145 @@ +/** + * Mongo parity for PSL scalar lists: the same PSL list schema yields matching + * observable semantics on SQL and Mongo. + * + * 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'; +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', () => { + 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..460a5a3178 --- /dev/null +++ b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts @@ -0,0 +1,346 @@ +/** + * 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'; +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 end-to-end', () => { + let database: DevDatabase | undefined; + + beforeAll(async () => { + database = await createDevDatabase(); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) await database.close(); + }, timeouts.spinUpPpgDev); + + it( + 'posts.tags String[] authors → migrates as text[] → infers back to tags String[]', + async () => { + if (!database) throw new Error('database not initialised'); + + 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'); + + const tagsColumn = findStorageColumn(contract, 'tags'); + expect(tagsColumn).toMatchObject({ + codecId: 'pg/text@1', + nativeType: 'text', + many: true, + }); + expect(tagsColumn?.['nativeType']).not.toBe('jsonb'); + + 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); + + 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[]'); + }); + + 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'); + expect(tagsField).toMatchObject({ + name: 'tags', + typeName: 'String', + list: true, + optional: false, + }); + } finally { + await driver.close(); + } + }, + timeouts.spinUpPpgDev, + ); + + it( + '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'); + + 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, + ); + + 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'); + + 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, + ); +}); 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', +}); 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 } }, }); }