@prisma-next/sql-contract-psl 0.3.0-dev.71 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,1505 +3,1194 @@ import type {
3
3
  ContractSourceDiagnosticSpan,
4
4
  ContractSourceDiagnostics,
5
5
  } from '@prisma-next/config/config-types';
6
- import type { TargetPackRef } from '@prisma-next/contract/framework-components';
7
- import type { ContractIR } from '@prisma-next/contract/ir';
8
- import type { ColumnDefault, ExecutionMutationDefaultValue } from '@prisma-next/contract/types';
6
+ import type {
7
+ Contract,
8
+ ContractField,
9
+ ContractModel,
10
+ ContractValueObject,
11
+ } from '@prisma-next/contract/types';
12
+ import type {
13
+ AuthoringContributions,
14
+ AuthoringTypeConstructorDescriptor,
15
+ } from '@prisma-next/framework-components/authoring';
16
+ import { instantiateAuthoringTypeConstructor } from '@prisma-next/framework-components/authoring';
17
+ import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components';
9
18
  import type {
10
19
  ParsePslDocumentResult,
11
20
  PslAttribute,
21
+ PslCompositeType,
22
+ PslEnum,
12
23
  PslField,
13
24
  PslModel,
14
- PslSpan,
25
+ PslNamedTypeDeclaration,
15
26
  } from '@prisma-next/psl-parser';
16
- import { defineContract } from '@prisma-next/sql-contract-ts/contract-builder';
17
- import { assertDefined, invariant } from '@prisma-next/utils/assertions';
27
+ import type { StorageTypeInstance } from '@prisma-next/sql-contract/types';
28
+ import {
29
+ buildSqlContractFromDefinition,
30
+ type ForeignKeyNode,
31
+ type IndexNode,
32
+ type ModelNode,
33
+ type UniqueConstraintNode,
34
+ } from '@prisma-next/sql-contract-ts/contract-builder';
18
35
  import { ifDefined } from '@prisma-next/utils/defined';
19
36
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
20
- import {
21
- createBuiltinDefaultFunctionRegistry,
22
- type DefaultFunctionRegistry,
23
- lowerDefaultFunctionWithRegistry,
24
- parseDefaultFunctionCall,
37
+ import type {
38
+ ControlMutationDefaultRegistry,
39
+ ControlMutationDefaults,
40
+ MutationDefaultGeneratorDescriptor,
25
41
  } from './default-function-registry';
26
-
27
- type ColumnDescriptor = {
28
- readonly codecId: string;
29
- readonly nativeType: string;
30
- readonly typeRef?: string;
31
- readonly typeParams?: Record<string, unknown>;
32
- };
33
-
34
- export interface InterpretPslDocumentToSqlContractIRInput {
42
+ import {
43
+ getAttribute,
44
+ getPositionalArgument,
45
+ mapFieldNamesToColumns,
46
+ parseAttributeFieldList,
47
+ parseConstraintMapArgument,
48
+ parseMapName,
49
+ parseQuotedStringLiteral,
50
+ } from './psl-attribute-parsing';
51
+ import type { ColumnDescriptor } from './psl-column-resolution';
52
+ import {
53
+ checkUncomposedNamespace,
54
+ getAuthoringTypeConstructor,
55
+ instantiatePslTypeConstructor,
56
+ reportUncomposedNamespace,
57
+ resolveDbNativeTypeAttribute,
58
+ resolveFieldTypeDescriptor,
59
+ resolvePslTypeConstructorDescriptor,
60
+ toNamedTypeFieldDescriptor,
61
+ } from './psl-column-resolution';
62
+ import {
63
+ buildModelMappings,
64
+ collectResolvedFields,
65
+ type ModelNameMapping,
66
+ type ResolvedField,
67
+ } from './psl-field-resolution';
68
+ import {
69
+ applyBackrelationCandidates,
70
+ type FkRelationMetadata,
71
+ indexFkRelations,
72
+ type ModelBackrelationCandidate,
73
+ normalizeReferentialAction,
74
+ parseRelationAttribute,
75
+ validateNavigationListFieldAttributes,
76
+ } from './psl-relation-resolution';
77
+
78
+ export interface InterpretPslDocumentToSqlContractInput {
35
79
  readonly document: ParsePslDocumentResult;
36
- readonly target?: TargetPackRef<'sql', 'postgres'>;
80
+ readonly target: TargetPackRef<'sql', 'postgres'>;
81
+ readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
37
82
  readonly composedExtensionPacks?: readonly string[];
38
- readonly defaultFunctionRegistry?: DefaultFunctionRegistry;
39
- }
40
-
41
- const DEFAULT_POSTGRES_TARGET: TargetPackRef<'sql', 'postgres'> = {
42
- kind: 'target',
43
- familyId: 'sql',
44
- targetId: 'postgres',
45
- id: 'postgres',
46
- version: '0.0.1',
47
- capabilities: {},
48
- };
49
-
50
- const SCALAR_COLUMN_MAP: Record<string, ColumnDescriptor> = {
51
- String: { codecId: 'pg/text@1', nativeType: 'text' },
52
- Boolean: { codecId: 'pg/bool@1', nativeType: 'bool' },
53
- Int: { codecId: 'pg/int4@1', nativeType: 'int4' },
54
- BigInt: { codecId: 'pg/int8@1', nativeType: 'int8' },
55
- Float: { codecId: 'pg/float8@1', nativeType: 'float8' },
56
- Decimal: { codecId: 'pg/numeric@1', nativeType: 'numeric' },
57
- DateTime: { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz' },
58
- Json: { codecId: 'pg/jsonb@1', nativeType: 'jsonb' },
59
- Bytes: { codecId: 'pg/bytea@1', nativeType: 'bytea' },
60
- };
61
-
62
- const GENERATED_ID_COLUMN_MAP: Partial<Record<string, ColumnDescriptor>> = {
63
- ulid: { codecId: 'sql/char@1', nativeType: 'character', typeParams: { length: 26 } },
64
- uuidv7: { codecId: 'sql/char@1', nativeType: 'character', typeParams: { length: 36 } },
65
- uuidv4: { codecId: 'sql/char@1', nativeType: 'character', typeParams: { length: 36 } },
66
- cuid2: { codecId: 'sql/char@1', nativeType: 'character', typeParams: { length: 24 } },
67
- };
68
-
69
- function resolveGeneratedColumnDescriptor(
70
- executionDefault: ExecutionMutationDefaultValue,
71
- ): ColumnDescriptor | undefined {
72
- if (executionDefault.kind !== 'generator') {
73
- return undefined;
74
- }
75
-
76
- if (executionDefault.id === 'nanoid') {
77
- const rawSize = executionDefault.params?.['size'];
78
- const length =
79
- typeof rawSize === 'number' && Number.isInteger(rawSize) && rawSize >= 2 && rawSize <= 255
80
- ? rawSize
81
- : 21;
82
- return { codecId: 'sql/char@1', nativeType: 'character', typeParams: { length } };
83
- }
84
-
85
- return GENERATED_ID_COLUMN_MAP[executionDefault.id];
86
- }
87
-
88
- const REFERENTIAL_ACTION_MAP = {
89
- NoAction: 'noAction',
90
- Restrict: 'restrict',
91
- Cascade: 'cascade',
92
- SetNull: 'setNull',
93
- SetDefault: 'setDefault',
94
- noAction: 'noAction',
95
- restrict: 'restrict',
96
- cascade: 'cascade',
97
- setNull: 'setNull',
98
- setDefault: 'setDefault',
99
- } as const;
100
-
101
- type ResolvedField = {
102
- readonly field: PslField;
103
- readonly columnName: string;
104
- readonly descriptor: ColumnDescriptor;
105
- readonly defaultValue?: ColumnDefault;
106
- readonly executionDefault?: ExecutionMutationDefaultValue;
107
- readonly isId: boolean;
108
- readonly isUnique: boolean;
109
- };
110
-
111
- type ParsedRelationAttribute = {
112
- readonly relationName?: string;
113
- readonly fields?: readonly string[];
114
- readonly references?: readonly string[];
115
- readonly onDelete?: string;
116
- readonly onUpdate?: string;
117
- };
118
-
119
- type FkRelationMetadata = {
120
- readonly declaringModelName: string;
121
- readonly declaringFieldName: string;
122
- readonly declaringTableName: string;
123
- readonly targetModelName: string;
124
- readonly targetTableName: string;
125
- readonly relationName?: string;
126
- readonly localColumns: readonly string[];
127
- readonly referencedColumns: readonly string[];
128
- };
129
-
130
- type ModelBackrelationCandidate = {
131
- readonly modelName: string;
132
- readonly tableName: string;
133
- readonly field: PslField;
134
- readonly targetModelName: string;
135
- readonly relationName?: string;
136
- };
137
-
138
- type ModelRelationMetadata = {
139
- readonly fieldName: string;
140
- readonly toModel: string;
141
- readonly toTable: string;
142
- readonly cardinality: '1:N' | 'N:1';
143
- readonly parentTable: string;
144
- readonly parentColumns: readonly string[];
145
- readonly childTable: string;
146
- readonly childColumns: readonly string[];
147
- };
148
-
149
- type ResolvedModelEntry = {
150
- readonly model: PslModel;
151
- readonly mapping: ModelNameMapping;
152
- readonly resolvedFields: readonly ResolvedField[];
153
- };
154
-
155
- function fkRelationPairKey(declaringModelName: string, targetModelName: string): string {
156
- // NOTE: We assume PSL model identifiers do not contain the `::` separator.
157
- return `${declaringModelName}::${targetModelName}`;
158
- }
159
-
160
- type ModelNameMapping = {
161
- readonly model: PslModel;
162
- readonly tableName: string;
163
- readonly fieldColumns: Map<string, string>;
164
- };
165
-
166
- type DynamicTableBuilder = {
167
- column(
168
- name: string,
169
- options: { type: ColumnDescriptor; nullable?: true; default?: ColumnDefault },
170
- ): DynamicTableBuilder;
171
- generated(
172
- name: string,
173
- options: { type: ColumnDescriptor; generated: ExecutionMutationDefaultValue },
174
- ): DynamicTableBuilder;
175
- unique(columns: readonly string[]): DynamicTableBuilder;
176
- primaryKey(columns: readonly string[]): DynamicTableBuilder;
177
- index(columns: readonly string[]): DynamicTableBuilder;
178
- foreignKey(
179
- columns: readonly string[],
180
- references: { table: string; columns: readonly string[] },
181
- options?: { onDelete?: string; onUpdate?: string },
182
- ): DynamicTableBuilder;
183
- };
184
-
185
- type DynamicModelBuilder = {
186
- field(name: string, column: string): DynamicModelBuilder;
187
- relation(
188
- name: string,
189
- options: {
190
- toModel: string;
191
- toTable: string;
192
- cardinality: '1:1' | '1:N' | 'N:1';
193
- on: {
194
- parentTable: string;
195
- parentColumns: readonly string[];
196
- childTable: string;
197
- childColumns: readonly string[];
198
- };
199
- },
200
- ): DynamicModelBuilder;
201
- };
202
-
203
- type DynamicContractBuilder = {
204
- target(target: TargetPackRef<'sql', 'postgres'>): DynamicContractBuilder;
205
- storageType(
206
- name: string,
207
- typeInstance: {
208
- codecId: string;
209
- nativeType: string;
210
- typeParams: Record<string, unknown>;
211
- },
212
- ): DynamicContractBuilder;
213
- table(
214
- name: string,
215
- callback: (tableBuilder: DynamicTableBuilder) => DynamicTableBuilder,
216
- ): DynamicContractBuilder;
217
- model(
218
- name: string,
219
- table: string,
220
- callback: (modelBuilder: DynamicModelBuilder) => DynamicModelBuilder,
221
- ): DynamicContractBuilder;
222
- build(): ContractIR;
223
- };
224
-
225
- function lowerFirst(value: string): string {
226
- if (value.length === 0) return value;
227
- return value[0]?.toLowerCase() + value.slice(1);
228
- }
229
-
230
- function getAttribute(attributes: readonly PslAttribute[], name: string): PslAttribute | undefined {
231
- return attributes.find((attribute) => attribute.name === name);
83
+ readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', 'postgres'>[];
84
+ readonly controlMutationDefaults?: ControlMutationDefaults;
85
+ readonly authoringContributions?: AuthoringContributions;
232
86
  }
233
87
 
234
- function getNamedArgument(attribute: PslAttribute, name: string): string | undefined {
235
- const entry = attribute.args.find((arg) => arg.kind === 'named' && arg.name === name);
236
- if (!entry || entry.kind !== 'named') {
88
+ function buildComposedExtensionPackRefs(
89
+ target: TargetPackRef<'sql', 'postgres'>,
90
+ extensionIds: readonly string[],
91
+ extensionPackRefs: readonly ExtensionPackRef<'sql', 'postgres'>[] = [],
92
+ ): Record<string, ExtensionPackRef<'sql', 'postgres'>> | undefined {
93
+ if (extensionIds.length === 0) {
237
94
  return undefined;
238
95
  }
239
- return entry.value;
240
- }
241
96
 
242
- function getPositionalArgument(attribute: PslAttribute, index = 0): string | undefined {
243
- const entries = attribute.args.filter((arg) => arg.kind === 'positional');
244
- const entry = entries[index];
245
- if (!entry || entry.kind !== 'positional') {
246
- return undefined;
247
- }
248
- return entry.value;
97
+ const extensionPackRefById = new Map(extensionPackRefs.map((packRef) => [packRef.id, packRef]));
98
+
99
+ return Object.fromEntries(
100
+ extensionIds.map((extensionId) => [
101
+ extensionId,
102
+ extensionPackRefById.get(extensionId) ??
103
+ ({
104
+ kind: 'extension',
105
+ id: extensionId,
106
+ familyId: target.familyId,
107
+ targetId: target.targetId,
108
+ version: '0.0.1',
109
+ } satisfies ExtensionPackRef<'sql', 'postgres'>),
110
+ ]),
111
+ );
249
112
  }
250
113
 
251
- function getPositionalArgumentEntry(
252
- attribute: PslAttribute,
253
- index = 0,
254
- ): { value: string; span: PslSpan } | undefined {
255
- const entries = attribute.args.filter((arg) => arg.kind === 'positional');
256
- const entry = entries[index];
257
- if (!entry || entry.kind !== 'positional') {
258
- return undefined;
259
- }
260
- return {
261
- value: entry.value,
262
- span: entry.span,
263
- };
114
+ function diagnosticDedupKey(diagnostic: ContractSourceDiagnostic): string {
115
+ const span = diagnostic.span;
116
+ const spanKey = span
117
+ ? `${span.start.offset}:${span.end.offset}:${span.start.line}:${span.end.line}`
118
+ : '';
119
+ return `${diagnostic.code}\u0000${diagnostic.sourceId}\u0000${spanKey}\u0000${diagnostic.message}`;
264
120
  }
265
121
 
266
- function unquoteStringLiteral(value: string): string {
267
- const trimmed = value.trim();
268
- const match = trimmed.match(/^(['"])(.*)\1$/);
269
- if (!match) {
270
- return trimmed;
122
+ function dedupeDiagnostics(
123
+ diagnostics: readonly ContractSourceDiagnostic[],
124
+ ): ContractSourceDiagnostic[] {
125
+ const seen = new Map<string, ContractSourceDiagnostic>();
126
+ for (const diagnostic of diagnostics) {
127
+ const key = diagnosticDedupKey(diagnostic);
128
+ if (!seen.has(key)) {
129
+ seen.set(key, diagnostic);
130
+ }
271
131
  }
272
- return match[2] ?? '';
132
+ return [...seen.values()];
273
133
  }
274
134
 
275
- function parseQuotedStringLiteral(value: string): string | undefined {
276
- const trimmed = value.trim();
277
- // This intentionally accepts either '...' or "..." and relies on PSL's
278
- // own string literal rules to disallow unescaped interior delimiters.
279
- const match = trimmed.match(/^(['"])(.*)\1$/);
280
- if (!match) {
281
- return undefined;
135
+ function compareStrings(left: string, right: string): -1 | 0 | 1 {
136
+ if (left < right) {
137
+ return -1;
282
138
  }
283
- return match[2] ?? '';
284
- }
285
-
286
- function parseFieldList(value: string): readonly string[] | undefined {
287
- const trimmed = value.trim();
288
- if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
289
- return undefined;
139
+ if (left > right) {
140
+ return 1;
290
141
  }
291
- const body = trimmed.slice(1, -1);
292
- const parts = body
293
- .split(',')
294
- .map((entry) => entry.trim())
295
- .filter((entry) => entry.length > 0);
296
- return parts;
142
+ return 0;
297
143
  }
298
144
 
299
- function parseDefaultLiteralValue(expression: string): ColumnDefault | undefined {
300
- const trimmed = expression.trim();
301
- if (trimmed === 'true' || trimmed === 'false') {
302
- return { kind: 'literal', value: trimmed === 'true' };
303
- }
304
- const numericValue = Number(trimmed);
305
- if (!Number.isNaN(numericValue) && trimmed.length > 0 && !/^(['"]).*\1$/.test(trimmed)) {
306
- return { kind: 'literal', value: numericValue };
307
- }
308
- if (/^(['"]).*\1$/.test(trimmed)) {
309
- return { kind: 'literal', value: unquoteStringLiteral(trimmed) };
310
- }
311
- return undefined;
145
+ function mapParserDiagnostics(document: ParsePslDocumentResult): ContractSourceDiagnostic[] {
146
+ return document.diagnostics.map((diagnostic) => ({
147
+ code: diagnostic.code,
148
+ message: diagnostic.message,
149
+ sourceId: diagnostic.sourceId,
150
+ span: diagnostic.span,
151
+ }));
312
152
  }
313
153
 
314
- function lowerDefaultForField(input: {
315
- readonly modelName: string;
316
- readonly fieldName: string;
317
- readonly defaultAttribute: PslAttribute;
154
+ interface ProcessEnumDeclarationsInput {
155
+ readonly enums: readonly PslEnum[];
318
156
  readonly sourceId: string;
319
- readonly defaultFunctionRegistry: DefaultFunctionRegistry;
157
+ readonly enumTypeConstructor: AuthoringTypeConstructorDescriptor | undefined;
320
158
  readonly diagnostics: ContractSourceDiagnostic[];
321
- }): {
322
- readonly defaultValue?: ColumnDefault;
323
- readonly executionDefault?: ExecutionMutationDefaultValue;
324
- } {
325
- const positionalEntries = input.defaultAttribute.args.filter((arg) => arg.kind === 'positional');
326
- const namedEntries = input.defaultAttribute.args.filter((arg) => arg.kind === 'named');
327
-
328
- if (namedEntries.length > 0 || positionalEntries.length !== 1) {
329
- input.diagnostics.push({
330
- code: 'PSL_INVALID_DEFAULT_FUNCTION_ARGUMENT',
331
- message: `Field "${input.modelName}.${input.fieldName}" requires exactly one positional @default(...) expression.`,
332
- sourceId: input.sourceId,
333
- span: input.defaultAttribute.span,
334
- });
335
- return {};
336
- }
337
-
338
- const expressionEntry = getPositionalArgumentEntry(input.defaultAttribute);
339
- if (!expressionEntry) {
340
- input.diagnostics.push({
341
- code: 'PSL_INVALID_DEFAULT_FUNCTION_ARGUMENT',
342
- message: `Field "${input.modelName}.${input.fieldName}" requires a positional @default(...) expression.`,
343
- sourceId: input.sourceId,
344
- span: input.defaultAttribute.span,
345
- });
346
- return {};
347
- }
159
+ }
348
160
 
349
- const literalDefault = parseDefaultLiteralValue(expressionEntry.value);
350
- if (literalDefault) {
351
- return { defaultValue: literalDefault };
352
- }
161
+ function processEnumDeclarations(input: ProcessEnumDeclarationsInput): {
162
+ readonly storageTypes: Record<string, StorageTypeInstance>;
163
+ readonly enumTypeDescriptors: Map<string, ColumnDescriptor>;
164
+ } {
165
+ const storageTypes: Record<string, StorageTypeInstance> = {};
166
+ const enumTypeDescriptors = new Map<string, ColumnDescriptor>();
353
167
 
354
- const defaultFunctionCall = parseDefaultFunctionCall(expressionEntry.value, expressionEntry.span);
355
- if (!defaultFunctionCall) {
356
- input.diagnostics.push({
357
- code: 'PSL_INVALID_DEFAULT_VALUE',
358
- message: `Unsupported default value "${expressionEntry.value}"`,
168
+ for (const enumDeclaration of input.enums) {
169
+ const nativeType = parseMapName({
170
+ attribute: getAttribute(enumDeclaration.attributes, 'map'),
171
+ defaultValue: enumDeclaration.name,
359
172
  sourceId: input.sourceId,
360
- span: input.defaultAttribute.span,
173
+ diagnostics: input.diagnostics,
174
+ entityLabel: `Enum "${enumDeclaration.name}"`,
175
+ span: enumDeclaration.span,
361
176
  });
362
- return {};
177
+ const enumStorageType = input.enumTypeConstructor
178
+ ? instantiateAuthoringTypeConstructor(input.enumTypeConstructor, [
179
+ nativeType,
180
+ enumDeclaration.values.map((value) => value.name),
181
+ ])
182
+ : {
183
+ codecId: 'pg/enum@1',
184
+ nativeType,
185
+ typeParams: { values: enumDeclaration.values.map((value) => value.name) },
186
+ };
187
+ const descriptor: ColumnDescriptor = {
188
+ codecId: enumStorageType.codecId,
189
+ nativeType: enumStorageType.nativeType,
190
+ typeRef: enumDeclaration.name,
191
+ };
192
+ enumTypeDescriptors.set(enumDeclaration.name, descriptor);
193
+ storageTypes[enumDeclaration.name] = {
194
+ codecId: enumStorageType.codecId,
195
+ nativeType: enumStorageType.nativeType,
196
+ typeParams: enumStorageType.typeParams ?? {
197
+ values: enumDeclaration.values.map((value) => value.name),
198
+ },
199
+ };
363
200
  }
364
201
 
365
- const lowered = lowerDefaultFunctionWithRegistry({
366
- call: defaultFunctionCall,
367
- registry: input.defaultFunctionRegistry,
368
- context: {
369
- sourceId: input.sourceId,
370
- modelName: input.modelName,
371
- fieldName: input.fieldName,
372
- },
373
- });
374
-
375
- if (!lowered.ok) {
376
- input.diagnostics.push(lowered.diagnostic);
377
- return {};
378
- }
202
+ return { storageTypes, enumTypeDescriptors };
203
+ }
379
204
 
380
- if (lowered.value.kind === 'storage') {
381
- return { defaultValue: lowered.value.defaultValue };
382
- }
383
- return { executionDefault: lowered.value.generated };
205
+ interface ResolveNamedTypeDeclarationsInput {
206
+ readonly declarations: readonly PslNamedTypeDeclaration[];
207
+ readonly sourceId: string;
208
+ readonly enumTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
209
+ readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
210
+ readonly composedExtensions: ReadonlySet<string>;
211
+ readonly familyId: string;
212
+ readonly targetId: string;
213
+ readonly authoringContributions: AuthoringContributions | undefined;
214
+ readonly diagnostics: ContractSourceDiagnostic[];
384
215
  }
385
216
 
386
- function parseMapName(input: {
387
- readonly attribute: PslAttribute | undefined;
388
- readonly defaultValue: string;
217
+ function validateNamedTypeAttributes(input: {
218
+ readonly declaration: PslNamedTypeDeclaration;
389
219
  readonly sourceId: string;
390
220
  readonly diagnostics: ContractSourceDiagnostic[];
391
- readonly entityLabel: string;
392
- readonly span: PslSpan;
393
- }): string {
394
- if (!input.attribute) {
395
- return input.defaultValue;
396
- }
221
+ readonly composedExtensions: ReadonlySet<string>;
222
+ readonly allowDbNativeType: boolean;
223
+ readonly familyId: string;
224
+ readonly targetId: string;
225
+ }): {
226
+ readonly dbNativeTypeAttribute: PslAttribute | undefined;
227
+ readonly hasUnsupportedNamedTypeAttribute: boolean;
228
+ } {
229
+ const dbNativeTypeAttributes = input.allowDbNativeType
230
+ ? input.declaration.attributes.filter((attribute) => attribute.name.startsWith('db.'))
231
+ : [];
232
+ const [dbNativeTypeAttribute, ...extraDbNativeTypeAttributes] = dbNativeTypeAttributes;
233
+ let hasUnsupportedNamedTypeAttribute = false;
397
234
 
398
- const value = getPositionalArgument(input.attribute);
399
- if (!value) {
400
- input.diagnostics.push({
401
- code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
402
- message: `${input.entityLabel} @map requires a positional quoted string literal argument`,
403
- sourceId: input.sourceId,
404
- span: input.attribute.span,
405
- });
406
- return input.defaultValue;
407
- }
408
- const parsed = parseQuotedStringLiteral(value);
409
- if (parsed === undefined) {
235
+ for (const extra of extraDbNativeTypeAttributes) {
410
236
  input.diagnostics.push({
411
237
  code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
412
- message: `${input.entityLabel} @map requires a positional quoted string literal argument`,
238
+ message: `Named type "${input.declaration.name}" can declare at most one @db.* attribute`,
413
239
  sourceId: input.sourceId,
414
- span: input.attribute.span,
240
+ span: extra.span,
415
241
  });
416
- return input.defaultValue;
242
+ hasUnsupportedNamedTypeAttribute = true;
417
243
  }
418
- return parsed;
419
- }
420
244
 
421
- function parsePgvectorLength(input: {
422
- readonly attribute: PslAttribute;
423
- readonly diagnostics: ContractSourceDiagnostic[];
424
- readonly sourceId: string;
425
- }): number | undefined {
426
- const namedLength = getNamedArgument(input.attribute, 'length');
427
- const namedDim = getNamedArgument(input.attribute, 'dim');
428
- const positional = getPositionalArgument(input.attribute);
429
- const raw = namedLength ?? namedDim ?? positional;
430
- if (!raw) {
431
- input.diagnostics.push({
432
- code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
433
- message: '@pgvector.column requires length/dim argument',
434
- sourceId: input.sourceId,
435
- span: input.attribute.span,
245
+ for (const attribute of input.declaration.attributes) {
246
+ if (input.allowDbNativeType && attribute.name.startsWith('db.')) {
247
+ continue;
248
+ }
249
+
250
+ const uncomposedNamespace = checkUncomposedNamespace(attribute.name, input.composedExtensions, {
251
+ familyId: input.familyId,
252
+ targetId: input.targetId,
436
253
  });
437
- return undefined;
438
- }
439
- const parsed = Number(unquoteStringLiteral(raw));
440
- if (!Number.isInteger(parsed) || parsed < 1) {
254
+ if (uncomposedNamespace) {
255
+ reportUncomposedNamespace({
256
+ subjectLabel: `Attribute "@${attribute.name}"`,
257
+ namespace: uncomposedNamespace,
258
+ sourceId: input.sourceId,
259
+ span: attribute.span,
260
+ diagnostics: input.diagnostics,
261
+ });
262
+ hasUnsupportedNamedTypeAttribute = true;
263
+ continue;
264
+ }
265
+
441
266
  input.diagnostics.push({
442
- code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
443
- message: '@pgvector.column length/dim must be a positive integer',
267
+ code: 'PSL_UNSUPPORTED_NAMED_TYPE_ATTRIBUTE',
268
+ message: `Named type "${input.declaration.name}" uses unsupported attribute "${attribute.name}"`,
444
269
  sourceId: input.sourceId,
445
- span: input.attribute.span,
270
+ span: attribute.span,
446
271
  });
447
- return undefined;
272
+ hasUnsupportedNamedTypeAttribute = true;
448
273
  }
449
- return parsed;
450
- }
451
274
 
452
- function resolveColumnDescriptor(
453
- field: PslField,
454
- enumTypeDescriptors: Map<string, ColumnDescriptor>,
455
- namedTypeDescriptors: Map<string, ColumnDescriptor>,
456
- ): ColumnDescriptor | undefined {
457
- if (field.typeRef && namedTypeDescriptors.has(field.typeRef)) {
458
- return namedTypeDescriptors.get(field.typeRef);
459
- }
460
- if (namedTypeDescriptors.has(field.typeName)) {
461
- return namedTypeDescriptors.get(field.typeName);
462
- }
463
- if (enumTypeDescriptors.has(field.typeName)) {
464
- return enumTypeDescriptors.get(field.typeName);
465
- }
466
- return SCALAR_COLUMN_MAP[field.typeName];
275
+ return { dbNativeTypeAttribute, hasUnsupportedNamedTypeAttribute };
467
276
  }
468
277
 
469
- function collectResolvedFields(
470
- model: PslModel,
471
- mapping: ModelNameMapping,
472
- enumTypeDescriptors: Map<string, ColumnDescriptor>,
473
- namedTypeDescriptors: Map<string, ColumnDescriptor>,
474
- namedTypeBaseTypes: Map<string, string>,
475
- modelNames: Set<string>,
476
- composedExtensions: Set<string>,
477
- defaultFunctionRegistry: DefaultFunctionRegistry,
478
- diagnostics: ContractSourceDiagnostic[],
479
- sourceId: string,
480
- ): ResolvedField[] {
481
- const resolvedFields: ResolvedField[] = [];
278
+ function resolveNamedTypeDeclarations(input: ResolveNamedTypeDeclarationsInput): {
279
+ readonly storageTypes: Record<string, StorageTypeInstance>;
280
+ readonly namedTypeDescriptors: Map<string, ColumnDescriptor>;
281
+ } {
282
+ const storageTypes: Record<string, StorageTypeInstance> = {};
283
+ const namedTypeDescriptors = new Map<string, ColumnDescriptor>();
482
284
 
483
- for (const field of model.fields) {
484
- if (field.list) {
485
- if (modelNames.has(field.typeName)) {
285
+ for (const declaration of input.declarations) {
286
+ if (declaration.typeConstructor) {
287
+ const { hasUnsupportedNamedTypeAttribute } = validateNamedTypeAttributes({
288
+ declaration,
289
+ sourceId: input.sourceId,
290
+ diagnostics: input.diagnostics,
291
+ composedExtensions: input.composedExtensions,
292
+ allowDbNativeType: false,
293
+ familyId: input.familyId,
294
+ targetId: input.targetId,
295
+ });
296
+ if (hasUnsupportedNamedTypeAttribute) {
486
297
  continue;
487
298
  }
488
- diagnostics.push({
489
- code: 'PSL_UNSUPPORTED_FIELD_LIST',
490
- message: `Field "${model.name}.${field.name}" uses a scalar/storage list type, which is not supported in SQL PSL provider v1. Model-typed lists are only supported as backrelation navigation fields when they match an FK-side relation.`,
491
- sourceId,
492
- span: field.span,
493
- });
494
- continue;
495
- }
496
299
 
497
- for (const attribute of field.attributes) {
498
- if (
499
- attribute.name === 'id' ||
500
- attribute.name === 'unique' ||
501
- attribute.name === 'default' ||
502
- attribute.name === 'relation' ||
503
- attribute.name === 'map' ||
504
- attribute.name === 'pgvector.column'
505
- ) {
300
+ const helperPath = declaration.typeConstructor.path.join('.');
301
+ const typeConstructor = resolvePslTypeConstructorDescriptor({
302
+ call: declaration.typeConstructor,
303
+ authoringContributions: input.authoringContributions,
304
+ composedExtensions: input.composedExtensions,
305
+ familyId: input.familyId,
306
+ targetId: input.targetId,
307
+ diagnostics: input.diagnostics,
308
+ sourceId: input.sourceId,
309
+ unsupportedCode: 'PSL_UNSUPPORTED_NAMED_TYPE_CONSTRUCTOR',
310
+ unsupportedMessage: `Named type "${declaration.name}" references unsupported constructor "${helperPath}"`,
311
+ });
312
+ if (!typeConstructor) {
506
313
  continue;
507
314
  }
508
- if (attribute.name.startsWith('pgvector.') && !composedExtensions.has('pgvector')) {
509
- diagnostics.push({
510
- code: 'PSL_EXTENSION_NAMESPACE_NOT_COMPOSED',
511
- message: `Attribute "@${attribute.name}" uses unrecognized namespace "pgvector". Add extension pack "pgvector" to extensionPacks in prisma-next.config.ts.`,
512
- sourceId,
513
- span: attribute.span,
514
- });
315
+
316
+ const storageType = instantiatePslTypeConstructor({
317
+ call: declaration.typeConstructor,
318
+ descriptor: typeConstructor,
319
+ diagnostics: input.diagnostics,
320
+ sourceId: input.sourceId,
321
+ entityLabel: `Named type "${declaration.name}"`,
322
+ });
323
+ if (!storageType) {
515
324
  continue;
516
325
  }
517
- diagnostics.push({
518
- code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE',
519
- message: `Field "${model.name}.${field.name}" uses unsupported attribute "@${attribute.name}"`,
520
- sourceId,
521
- span: attribute.span,
522
- });
523
- }
524
326
 
525
- const relationAttribute = getAttribute(field.attributes, 'relation');
526
- if (relationAttribute && modelNames.has(field.typeName)) {
327
+ namedTypeDescriptors.set(
328
+ declaration.name,
329
+ toNamedTypeFieldDescriptor(declaration.name, storageType),
330
+ );
331
+ storageTypes[declaration.name] = {
332
+ codecId: storageType.codecId,
333
+ nativeType: storageType.nativeType,
334
+ typeParams: storageType.typeParams ?? {},
335
+ };
527
336
  continue;
528
337
  }
529
338
 
530
- let descriptor = resolveColumnDescriptor(field, enumTypeDescriptors, namedTypeDescriptors);
531
- const pgvectorColumnAttribute = getAttribute(field.attributes, 'pgvector.column');
532
- if (pgvectorColumnAttribute) {
533
- if (!composedExtensions.has('pgvector')) {
534
- diagnostics.push({
535
- code: 'PSL_EXTENSION_NAMESPACE_NOT_COMPOSED',
536
- message:
537
- 'Attribute "@pgvector.column" uses unrecognized namespace "pgvector". Add extension pack "pgvector" to extensionPacks in prisma-next.config.ts.',
538
- sourceId,
539
- span: pgvectorColumnAttribute.span,
540
- });
541
- } else {
542
- const isBytesBase =
543
- field.typeName === 'Bytes' ||
544
- namedTypeBaseTypes.get(field.typeRef ?? field.typeName) === 'Bytes';
545
- if (!isBytesBase) {
546
- diagnostics.push({
547
- code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
548
- message: `Field "${model.name}.${field.name}" uses @pgvector.column on unsupported base type "${field.typeName}"`,
549
- sourceId,
550
- span: pgvectorColumnAttribute.span,
551
- });
552
- } else {
553
- const length = parsePgvectorLength({
554
- attribute: pgvectorColumnAttribute,
555
- diagnostics,
556
- sourceId,
557
- });
558
- if (length !== undefined) {
559
- descriptor = {
560
- codecId: 'pg/vector@1',
561
- nativeType: `vector(${length})`,
562
- typeParams: { length },
563
- };
564
- }
565
- }
566
- }
339
+ // Parser invariant: when typeConstructor is absent, baseType is defined.
340
+ // The check below narrows `baseType` for TypeScript and guards against a
341
+ // parser regression; it is unreachable under a correct parser.
342
+ if (declaration.baseType === undefined) {
343
+ input.diagnostics.push({
344
+ code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE',
345
+ message: `Named type "${declaration.name}" must declare a base type or constructor`,
346
+ sourceId: input.sourceId,
347
+ span: declaration.span,
348
+ });
349
+ continue;
567
350
  }
568
-
569
- if (!descriptor) {
570
- diagnostics.push({
571
- code: 'PSL_UNSUPPORTED_FIELD_TYPE',
572
- message: `Field "${model.name}.${field.name}" type "${field.typeName}" is not supported in SQL PSL provider v1`,
573
- sourceId,
574
- span: field.span,
351
+ const { baseType } = declaration;
352
+ const baseDescriptor =
353
+ input.enumTypeDescriptors.get(baseType) ?? input.scalarTypeDescriptors.get(baseType);
354
+ if (!baseDescriptor) {
355
+ input.diagnostics.push({
356
+ code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE',
357
+ message: `Named type "${declaration.name}" references unsupported base type "${baseType}"`,
358
+ sourceId: input.sourceId,
359
+ span: declaration.span,
575
360
  });
576
361
  continue;
577
362
  }
578
363
 
579
- const defaultAttribute = getAttribute(field.attributes, 'default');
580
- const loweredDefault = defaultAttribute
581
- ? lowerDefaultForField({
582
- modelName: model.name,
583
- fieldName: field.name,
584
- defaultAttribute,
585
- sourceId,
586
- defaultFunctionRegistry,
587
- diagnostics,
588
- })
589
- : {};
590
- if (field.optional && loweredDefault.executionDefault) {
591
- const generatorDescription =
592
- loweredDefault.executionDefault.kind === 'generator'
593
- ? `"${loweredDefault.executionDefault.id}"`
594
- : 'for this field';
595
- diagnostics.push({
596
- code: 'PSL_INVALID_DEFAULT_FUNCTION_ARGUMENT',
597
- message: `Field "${model.name}.${field.name}" cannot be optional when using execution default ${generatorDescription}. Remove "?" or use a storage default.`,
598
- sourceId,
599
- span: defaultAttribute?.span ?? field.span,
600
- });
364
+ const { dbNativeTypeAttribute, hasUnsupportedNamedTypeAttribute } = validateNamedTypeAttributes(
365
+ {
366
+ declaration,
367
+ sourceId: input.sourceId,
368
+ diagnostics: input.diagnostics,
369
+ composedExtensions: input.composedExtensions,
370
+ allowDbNativeType: true,
371
+ familyId: input.familyId,
372
+ targetId: input.targetId,
373
+ },
374
+ );
375
+ if (hasUnsupportedNamedTypeAttribute) {
601
376
  continue;
602
377
  }
603
- if (loweredDefault.executionDefault) {
604
- const generatedDescriptor = resolveGeneratedColumnDescriptor(loweredDefault.executionDefault);
605
- if (generatedDescriptor) {
606
- descriptor = generatedDescriptor;
378
+
379
+ if (dbNativeTypeAttribute) {
380
+ const descriptor = resolveDbNativeTypeAttribute({
381
+ attribute: dbNativeTypeAttribute,
382
+ baseType,
383
+ baseDescriptor,
384
+ diagnostics: input.diagnostics,
385
+ sourceId: input.sourceId,
386
+ entityLabel: `Named type "${declaration.name}"`,
387
+ });
388
+ if (!descriptor) {
389
+ continue;
607
390
  }
391
+ namedTypeDescriptors.set(
392
+ declaration.name,
393
+ toNamedTypeFieldDescriptor(declaration.name, descriptor),
394
+ );
395
+ storageTypes[declaration.name] = {
396
+ codecId: descriptor.codecId,
397
+ nativeType: descriptor.nativeType,
398
+ typeParams: descriptor.typeParams ?? {},
399
+ };
400
+ continue;
608
401
  }
609
- const mappedColumnName = mapping.fieldColumns.get(field.name) ?? field.name;
610
- resolvedFields.push({
611
- field,
612
- columnName: mappedColumnName,
613
- descriptor,
614
- ...ifDefined('defaultValue', loweredDefault.defaultValue),
615
- ...ifDefined('executionDefault', loweredDefault.executionDefault),
616
- isId: Boolean(getAttribute(field.attributes, 'id')),
617
- isUnique: Boolean(getAttribute(field.attributes, 'unique')),
618
- });
402
+
403
+ const descriptor = toNamedTypeFieldDescriptor(declaration.name, baseDescriptor);
404
+ namedTypeDescriptors.set(declaration.name, descriptor);
405
+ storageTypes[declaration.name] = {
406
+ codecId: baseDescriptor.codecId,
407
+ nativeType: baseDescriptor.nativeType,
408
+ typeParams: {},
409
+ };
619
410
  }
620
411
 
621
- return resolvedFields;
412
+ return { storageTypes, namedTypeDescriptors };
622
413
  }
623
414
 
624
- function hasSameSpan(a: PslSpan, b: ContractSourceDiagnosticSpan): boolean {
625
- return (
626
- a.start.offset === b.start.offset &&
627
- a.end.offset === b.end.offset &&
628
- a.start.line === b.start.line &&
629
- a.end.line === b.end.line
630
- );
415
+ interface BuildModelNodeInput {
416
+ readonly model: PslModel;
417
+ readonly mapping: ModelNameMapping;
418
+ readonly modelMappings: ReadonlyMap<string, ModelNameMapping>;
419
+ readonly modelNames: Set<string>;
420
+ readonly compositeTypeNames: ReadonlySet<string>;
421
+ readonly enumTypeDescriptors: Map<string, ColumnDescriptor>;
422
+ readonly namedTypeDescriptors: Map<string, ColumnDescriptor>;
423
+ readonly composedExtensions: Set<string>;
424
+ readonly familyId: string;
425
+ readonly targetId: string;
426
+ readonly authoringContributions: AuthoringContributions | undefined;
427
+ readonly defaultFunctionRegistry: ControlMutationDefaultRegistry;
428
+ readonly generatorDescriptorById: ReadonlyMap<string, MutationDefaultGeneratorDescriptor>;
429
+ readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
430
+ readonly sourceId: string;
431
+ readonly diagnostics: ContractSourceDiagnostic[];
631
432
  }
632
433
 
633
- function compareStrings(left: string, right: string): -1 | 0 | 1 {
634
- if (left < right) {
635
- return -1;
636
- }
637
- if (left > right) {
638
- return 1;
639
- }
640
- return 0;
434
+ interface BuildModelNodeResult {
435
+ readonly modelNode: ModelNode;
436
+ readonly fkRelationMetadata: FkRelationMetadata[];
437
+ readonly backrelationCandidates: ModelBackrelationCandidate[];
438
+ readonly resolvedFields: readonly ResolvedField[];
641
439
  }
642
440
 
643
- function indexFkRelations(input: { readonly fkRelationMetadata: readonly FkRelationMetadata[] }): {
644
- readonly modelRelations: Map<string, ModelRelationMetadata[]>;
645
- readonly fkRelationsByPair: Map<string, FkRelationMetadata[]>;
646
- } {
647
- const modelRelations = new Map<string, ModelRelationMetadata[]>();
648
- const fkRelationsByPair = new Map<string, FkRelationMetadata[]>();
649
-
650
- for (const relation of input.fkRelationMetadata) {
651
- const existing = modelRelations.get(relation.declaringModelName);
652
- const current = existing ?? [];
653
- if (!existing) {
654
- modelRelations.set(relation.declaringModelName, current);
655
- }
656
- current.push({
657
- fieldName: relation.declaringFieldName,
658
- toModel: relation.targetModelName,
659
- toTable: relation.targetTableName,
660
- cardinality: 'N:1',
661
- parentTable: relation.declaringTableName,
662
- parentColumns: relation.localColumns,
663
- childTable: relation.targetTableName,
664
- childColumns: relation.referencedColumns,
665
- });
441
+ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult {
442
+ const { model, mapping, sourceId, diagnostics } = input;
443
+ const tableName = mapping.tableName;
444
+
445
+ const resolvedFields = collectResolvedFields({
446
+ model,
447
+ mapping,
448
+ enumTypeDescriptors: input.enumTypeDescriptors,
449
+ namedTypeDescriptors: input.namedTypeDescriptors,
450
+ modelNames: input.modelNames,
451
+ compositeTypeNames: input.compositeTypeNames,
452
+ composedExtensions: input.composedExtensions,
453
+ authoringContributions: input.authoringContributions,
454
+ familyId: input.familyId,
455
+ targetId: input.targetId,
456
+ defaultFunctionRegistry: input.defaultFunctionRegistry,
457
+ generatorDescriptorById: input.generatorDescriptorById,
458
+ diagnostics,
459
+ sourceId,
460
+ scalarTypeDescriptors: input.scalarTypeDescriptors,
461
+ });
666
462
 
667
- const pairKey = fkRelationPairKey(relation.declaringModelName, relation.targetModelName);
668
- const pairRelations = fkRelationsByPair.get(pairKey);
669
- if (!pairRelations) {
670
- fkRelationsByPair.set(pairKey, [relation]);
671
- continue;
672
- }
673
- pairRelations.push(relation);
674
- }
675
-
676
- return { modelRelations, fkRelationsByPair };
677
- }
678
-
679
- function applyBackrelationCandidates(input: {
680
- readonly backrelationCandidates: readonly ModelBackrelationCandidate[];
681
- readonly fkRelationsByPair: Map<string, readonly FkRelationMetadata[]>;
682
- readonly modelRelations: Map<string, ModelRelationMetadata[]>;
683
- readonly diagnostics: ContractSourceDiagnostic[];
684
- readonly sourceId: string;
685
- }): void {
686
- for (const candidate of input.backrelationCandidates) {
687
- const pairKey = fkRelationPairKey(candidate.targetModelName, candidate.modelName);
688
- const pairMatches = input.fkRelationsByPair.get(pairKey) ?? [];
689
- const matches = candidate.relationName
690
- ? pairMatches.filter((relation) => relation.relationName === candidate.relationName)
691
- : [...pairMatches];
692
-
693
- if (matches.length === 0) {
694
- input.diagnostics.push({
695
- code: 'PSL_ORPHANED_BACKRELATION_LIST',
696
- message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(fields: [...], references: [...]) on the FK-side relation or use an explicit join model for many-to-many.`,
697
- sourceId: input.sourceId,
698
- span: candidate.field.span,
699
- });
700
- continue;
701
- }
702
- if (matches.length > 1) {
703
- input.diagnostics.push({
704
- code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
705
- message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation(name: "...") (or @relation("...")) to both sides to disambiguate.`,
706
- sourceId: input.sourceId,
707
- span: candidate.field.span,
708
- });
709
- continue;
710
- }
711
-
712
- invariant(matches.length === 1, 'Backrelation matching requires exactly one match');
713
- const matched = matches[0];
714
- assertDefined(matched, 'Backrelation matching requires a defined relation match');
715
-
716
- const existing = input.modelRelations.get(candidate.modelName);
717
- const current = existing ?? [];
718
- if (!existing) {
719
- input.modelRelations.set(candidate.modelName, current);
720
- }
721
- current.push({
722
- fieldName: candidate.field.name,
723
- toModel: matched.declaringModelName,
724
- toTable: matched.declaringTableName,
725
- cardinality: '1:N',
726
- parentTable: candidate.tableName,
727
- parentColumns: matched.referencedColumns,
728
- childTable: matched.declaringTableName,
729
- childColumns: matched.localColumns,
730
- });
731
- }
732
- }
733
-
734
- function emitModelsWithRelations(input: {
735
- readonly builder: DynamicContractBuilder;
736
- readonly resolvedModels: ResolvedModelEntry[];
737
- readonly modelRelations: Map<string, readonly ModelRelationMetadata[]>;
738
- }): DynamicContractBuilder {
739
- let nextBuilder = input.builder;
740
-
741
- const sortedModels = input.resolvedModels.sort((left, right) => {
742
- const tableComparison = compareStrings(left.mapping.tableName, right.mapping.tableName);
743
- if (tableComparison === 0) {
744
- return compareStrings(left.model.name, right.model.name);
745
- }
746
- return tableComparison;
747
- });
748
-
749
- for (const entry of sortedModels) {
750
- const relationEntries = [...(input.modelRelations.get(entry.model.name) ?? [])].sort(
751
- (left, right) => compareStrings(left.fieldName, right.fieldName),
752
- );
753
- nextBuilder = nextBuilder.model(
754
- entry.model.name,
755
- entry.mapping.tableName,
756
- (modelBuilder: DynamicModelBuilder) => {
757
- let next = modelBuilder;
758
- for (const resolvedField of entry.resolvedFields) {
759
- next = next.field(resolvedField.field.name, resolvedField.columnName);
760
- }
761
- for (const relation of relationEntries) {
762
- next = next.relation(relation.fieldName, {
763
- toModel: relation.toModel,
764
- toTable: relation.toTable,
765
- cardinality: relation.cardinality,
766
- on: {
767
- parentTable: relation.parentTable,
768
- parentColumns: relation.parentColumns,
769
- childTable: relation.childTable,
770
- childColumns: relation.childColumns,
771
- },
772
- });
773
- }
774
- return next;
775
- },
776
- );
777
- }
778
-
779
- return nextBuilder;
780
- }
781
-
782
- function mapParserDiagnostics(document: ParsePslDocumentResult): ContractSourceDiagnostic[] {
783
- return document.diagnostics.map((diagnostic) => ({
784
- code: diagnostic.code,
785
- message: diagnostic.message,
786
- sourceId: diagnostic.sourceId,
787
- span: diagnostic.span,
788
- }));
789
- }
790
-
791
- function normalizeReferentialAction(input: {
792
- readonly modelName: string;
793
- readonly fieldName: string;
794
- readonly actionName: 'onDelete' | 'onUpdate';
795
- readonly actionToken: string;
796
- readonly sourceId: string;
797
- readonly span: PslSpan;
798
- readonly diagnostics: ContractSourceDiagnostic[];
799
- }): string | undefined {
800
- const normalized =
801
- REFERENTIAL_ACTION_MAP[input.actionToken as keyof typeof REFERENTIAL_ACTION_MAP];
802
- if (normalized) {
803
- return normalized;
804
- }
805
-
806
- input.diagnostics.push({
807
- code: 'PSL_UNSUPPORTED_REFERENTIAL_ACTION',
808
- message: `Relation field "${input.modelName}.${input.fieldName}" has unsupported ${input.actionName} action "${input.actionToken}"`,
809
- sourceId: input.sourceId,
810
- span: input.span,
811
- });
812
- return undefined;
813
- }
814
-
815
- function parseAttributeFieldList(input: {
816
- readonly attribute: PslAttribute;
817
- readonly sourceId: string;
818
- readonly diagnostics: ContractSourceDiagnostic[];
819
- readonly code: string;
820
- readonly messagePrefix: string;
821
- }): readonly string[] | undefined {
822
- const raw = getNamedArgument(input.attribute, 'fields') ?? getPositionalArgument(input.attribute);
823
- if (!raw) {
824
- input.diagnostics.push({
825
- code: input.code,
826
- message: `${input.messagePrefix} requires fields list argument`,
827
- sourceId: input.sourceId,
828
- span: input.attribute.span,
829
- });
830
- return undefined;
831
- }
832
- const fields = parseFieldList(raw);
833
- if (!fields || fields.length === 0) {
834
- input.diagnostics.push({
835
- code: input.code,
836
- message: `${input.messagePrefix} requires bracketed field list argument`,
837
- sourceId: input.sourceId,
838
- span: input.attribute.span,
463
+ const primaryKeyFields = resolvedFields.filter((field) => field.isId);
464
+ const primaryKeyColumns = primaryKeyFields.map((field) => field.columnName);
465
+ const primaryKeyName = primaryKeyFields.length === 1 ? primaryKeyFields[0]?.idName : undefined;
466
+ const isVariantModel = model.attributes.some((attr) => attr.name === 'base');
467
+ if (primaryKeyColumns.length === 0 && !isVariantModel) {
468
+ diagnostics.push({
469
+ code: 'PSL_MISSING_PRIMARY_KEY',
470
+ message: `Model "${model.name}" must declare at least one @id field for SQL provider`,
471
+ sourceId,
472
+ span: model.span,
839
473
  });
840
- return undefined;
841
474
  }
842
- return fields;
843
- }
844
475
 
845
- function mapFieldNamesToColumns(input: {
846
- readonly modelName: string;
847
- readonly fieldNames: readonly string[];
848
- readonly mapping: ModelNameMapping;
849
- readonly sourceId: string;
850
- readonly diagnostics: ContractSourceDiagnostic[];
851
- readonly span: PslSpan;
852
- readonly contextLabel: string;
853
- }): readonly string[] | undefined {
854
- const columns: string[] = [];
855
- for (const fieldName of input.fieldNames) {
856
- const columnName = input.mapping.fieldColumns.get(fieldName);
857
- if (!columnName) {
858
- input.diagnostics.push({
859
- code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
860
- message: `${input.contextLabel} references unknown field "${input.modelName}.${fieldName}"`,
861
- sourceId: input.sourceId,
862
- span: input.span,
863
- });
864
- return undefined;
476
+ const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
477
+ for (const field of model.fields) {
478
+ if (!field.list || !input.modelNames.has(field.typeName)) {
479
+ continue;
865
480
  }
866
- columns.push(columnName);
867
- }
868
- return columns;
869
- }
870
-
871
- function buildModelMappings(
872
- models: readonly PslModel[],
873
- diagnostics: ContractSourceDiagnostic[],
874
- sourceId: string,
875
- ): Map<string, ModelNameMapping> {
876
- const result = new Map<string, ModelNameMapping>();
877
- for (const model of models) {
878
- const mapAttribute = getAttribute(model.attributes, 'map');
879
- const tableName = parseMapName({
880
- attribute: mapAttribute,
881
- defaultValue: lowerFirst(model.name),
481
+ const attributesValid = validateNavigationListFieldAttributes({
482
+ modelName: model.name,
483
+ field,
882
484
  sourceId,
485
+ composedExtensions: input.composedExtensions,
883
486
  diagnostics,
884
- entityLabel: `Model "${model.name}"`,
885
- span: model.span,
487
+ familyId: input.familyId,
488
+ targetId: input.targetId,
886
489
  });
887
- const fieldColumns = new Map<string, string>();
888
- for (const field of model.fields) {
889
- const fieldMapAttribute = getAttribute(field.attributes, 'map');
890
- const columnName = parseMapName({
891
- attribute: fieldMapAttribute,
892
- defaultValue: field.name,
490
+ const relationAttribute = getAttribute(field.attributes, 'relation');
491
+ let relationName: string | undefined;
492
+ if (relationAttribute) {
493
+ const parsedRelation = parseRelationAttribute({
494
+ attribute: relationAttribute,
495
+ modelName: model.name,
496
+ fieldName: field.name,
893
497
  sourceId,
894
498
  diagnostics,
895
- entityLabel: `Field "${model.name}.${field.name}"`,
896
- span: field.span,
897
499
  });
898
- fieldColumns.set(field.name, columnName);
500
+ if (!parsedRelation) {
501
+ continue;
502
+ }
503
+ if (parsedRelation.fields || parsedRelation.references) {
504
+ diagnostics.push({
505
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
506
+ message: `Backrelation list field "${model.name}.${field.name}" cannot declare fields/references; define them on the FK-side relation field`,
507
+ sourceId,
508
+ span: relationAttribute.span,
509
+ });
510
+ continue;
511
+ }
512
+ if (parsedRelation.onDelete || parsedRelation.onUpdate) {
513
+ diagnostics.push({
514
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
515
+ message: `Backrelation list field "${model.name}.${field.name}" cannot declare onDelete/onUpdate; define referential actions on the FK-side relation field`,
516
+ sourceId,
517
+ span: relationAttribute.span,
518
+ });
519
+ continue;
520
+ }
521
+ relationName = parsedRelation.relationName;
899
522
  }
900
- result.set(model.name, {
901
- model,
523
+ if (!attributesValid) {
524
+ continue;
525
+ }
526
+
527
+ resultBackrelationCandidates.push({
528
+ modelName: model.name,
902
529
  tableName,
903
- fieldColumns,
530
+ field,
531
+ targetModelName: field.typeName,
532
+ ...ifDefined('relationName', relationName),
904
533
  });
905
534
  }
906
- return result;
907
- }
908
535
 
909
- function validateNavigationListFieldAttributes(input: {
910
- readonly modelName: string;
911
- readonly field: PslField;
912
- readonly sourceId: string;
913
- readonly composedExtensions: Set<string>;
914
- readonly diagnostics: ContractSourceDiagnostic[];
915
- }): boolean {
916
- let valid = true;
917
- for (const attribute of input.field.attributes) {
918
- if (attribute.name === 'relation') {
536
+ const relationAttributes = model.fields
537
+ .map((field) => ({
538
+ field,
539
+ relation: getAttribute(field.attributes, 'relation'),
540
+ }))
541
+ .filter((entry): entry is { field: PslField; relation: PslAttribute } =>
542
+ Boolean(entry.relation),
543
+ );
544
+ const uniqueConstraints: UniqueConstraintNode[] = resolvedFields
545
+ .filter((field) => field.isUnique)
546
+ .map((field) => ({
547
+ columns: [field.columnName],
548
+ ...ifDefined('name', field.uniqueName),
549
+ }));
550
+ const indexNodes: IndexNode[] = [];
551
+ const foreignKeyNodes: ForeignKeyNode[] = [];
552
+
553
+ for (const modelAttribute of model.attributes) {
554
+ if (modelAttribute.name === 'map') {
919
555
  continue;
920
556
  }
921
- if (attribute.name.startsWith('pgvector.') && !input.composedExtensions.has('pgvector')) {
922
- input.diagnostics.push({
923
- code: 'PSL_EXTENSION_NAMESPACE_NOT_COMPOSED',
924
- message: `Attribute "@${attribute.name}" uses unrecognized namespace "pgvector". Add extension pack "pgvector" to extensionPacks in prisma-next.config.ts.`,
925
- sourceId: input.sourceId,
926
- span: attribute.span,
927
- });
928
- valid = false;
557
+ if (modelAttribute.name === 'discriminator' || modelAttribute.name === 'base') {
929
558
  continue;
930
559
  }
931
- input.diagnostics.push({
932
- code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE',
933
- message: `Field "${input.modelName}.${input.field.name}" uses unsupported attribute "@${attribute.name}"`,
934
- sourceId: input.sourceId,
935
- span: attribute.span,
936
- });
937
- valid = false;
938
- }
939
- return valid;
940
- }
941
-
942
- function parseRelationAttribute(input: {
943
- readonly attribute: PslAttribute;
944
- readonly modelName: string;
945
- readonly fieldName: string;
946
- readonly sourceId: string;
947
- readonly diagnostics: ContractSourceDiagnostic[];
948
- }): ParsedRelationAttribute | undefined {
949
- const positionalEntries = input.attribute.args.filter((arg) => arg.kind === 'positional');
950
- if (positionalEntries.length > 1) {
951
- input.diagnostics.push({
952
- code: 'PSL_INVALID_RELATION_ATTRIBUTE',
953
- message: `Relation field "${input.modelName}.${input.fieldName}" has too many positional arguments`,
954
- sourceId: input.sourceId,
955
- span: input.attribute.span,
956
- });
957
- return undefined;
958
- }
959
-
960
- let relationNameFromPositional: string | undefined;
961
- const positionalNameEntry = getPositionalArgumentEntry(input.attribute);
962
- if (positionalNameEntry) {
963
- const parsedName = parseQuotedStringLiteral(positionalNameEntry.value);
964
- if (!parsedName) {
965
- input.diagnostics.push({
966
- code: 'PSL_INVALID_RELATION_ATTRIBUTE',
967
- message: `Relation field "${input.modelName}.${input.fieldName}" positional relation name must be a quoted string literal`,
968
- sourceId: input.sourceId,
969
- span: positionalNameEntry.span,
560
+ if (modelAttribute.name === 'unique' || modelAttribute.name === 'index') {
561
+ const fieldNames = parseAttributeFieldList({
562
+ attribute: modelAttribute,
563
+ sourceId,
564
+ diagnostics,
565
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
566
+ messagePrefix: `Model "${model.name}" @@${modelAttribute.name}`,
970
567
  });
971
- return undefined;
972
- }
973
- relationNameFromPositional = parsedName;
974
- }
975
-
976
- for (const arg of input.attribute.args) {
977
- if (arg.kind === 'positional') {
568
+ if (!fieldNames) {
569
+ continue;
570
+ }
571
+ const columnNames = mapFieldNamesToColumns({
572
+ modelName: model.name,
573
+ fieldNames,
574
+ mapping,
575
+ sourceId,
576
+ diagnostics,
577
+ span: modelAttribute.span,
578
+ contextLabel: `Model "${model.name}" @@${modelAttribute.name}`,
579
+ });
580
+ if (!columnNames) {
581
+ continue;
582
+ }
583
+ const constraintName = parseConstraintMapArgument({
584
+ attribute: modelAttribute,
585
+ sourceId,
586
+ diagnostics,
587
+ entityLabel: `Model "${model.name}" @@${modelAttribute.name}`,
588
+ span: modelAttribute.span,
589
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
590
+ });
591
+ if (modelAttribute.name === 'unique') {
592
+ uniqueConstraints.push({
593
+ columns: columnNames,
594
+ ...ifDefined('name', constraintName),
595
+ });
596
+ } else {
597
+ indexNodes.push({
598
+ columns: columnNames,
599
+ ...ifDefined('name', constraintName),
600
+ });
601
+ }
978
602
  continue;
979
603
  }
980
- if (
981
- arg.name !== 'name' &&
982
- arg.name !== 'fields' &&
983
- arg.name !== 'references' &&
984
- arg.name !== 'onDelete' &&
985
- arg.name !== 'onUpdate'
986
- ) {
987
- input.diagnostics.push({
988
- code: 'PSL_INVALID_RELATION_ATTRIBUTE',
989
- message: `Relation field "${input.modelName}.${input.fieldName}" has unsupported argument "${arg.name}"`,
990
- sourceId: input.sourceId,
991
- span: arg.span,
604
+ const uncomposedNamespace = checkUncomposedNamespace(
605
+ modelAttribute.name,
606
+ input.composedExtensions,
607
+ { familyId: input.familyId, targetId: input.targetId },
608
+ );
609
+ if (uncomposedNamespace) {
610
+ reportUncomposedNamespace({
611
+ subjectLabel: `Attribute "@@${modelAttribute.name}"`,
612
+ namespace: uncomposedNamespace,
613
+ sourceId,
614
+ span: modelAttribute.span,
615
+ diagnostics,
992
616
  });
993
- return undefined;
617
+ continue;
994
618
  }
995
- }
996
-
997
- const namedRelationNameRaw = getNamedArgument(input.attribute, 'name');
998
- const namedRelationName = namedRelationNameRaw
999
- ? parseQuotedStringLiteral(namedRelationNameRaw)
1000
- : undefined;
1001
- if (namedRelationNameRaw && !namedRelationName) {
1002
- input.diagnostics.push({
1003
- code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1004
- message: `Relation field "${input.modelName}.${input.fieldName}" named relation name must be a quoted string literal`,
1005
- sourceId: input.sourceId,
1006
- span: input.attribute.span,
1007
- });
1008
- return undefined;
1009
- }
1010
-
1011
- if (
1012
- relationNameFromPositional &&
1013
- namedRelationName &&
1014
- relationNameFromPositional !== namedRelationName
1015
- ) {
1016
- input.diagnostics.push({
1017
- code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1018
- message: `Relation field "${input.modelName}.${input.fieldName}" has conflicting positional and named relation names`,
1019
- sourceId: input.sourceId,
1020
- span: input.attribute.span,
1021
- });
1022
- return undefined;
1023
- }
1024
- const relationName = namedRelationName ?? relationNameFromPositional;
1025
-
1026
- const fieldsRaw = getNamedArgument(input.attribute, 'fields');
1027
- const referencesRaw = getNamedArgument(input.attribute, 'references');
1028
- if ((fieldsRaw && !referencesRaw) || (!fieldsRaw && referencesRaw)) {
1029
- input.diagnostics.push({
1030
- code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1031
- message: `Relation field "${input.modelName}.${input.fieldName}" requires fields and references arguments`,
1032
- sourceId: input.sourceId,
1033
- span: input.attribute.span,
619
+ diagnostics.push({
620
+ code: 'PSL_UNSUPPORTED_MODEL_ATTRIBUTE',
621
+ message: `Model "${model.name}" uses unsupported attribute "@@${modelAttribute.name}"`,
622
+ sourceId,
623
+ span: modelAttribute.span,
1034
624
  });
1035
- return undefined;
1036
625
  }
1037
626
 
1038
- let fields: readonly string[] | undefined;
1039
- let references: readonly string[] | undefined;
1040
- if (fieldsRaw && referencesRaw) {
1041
- const parsedFields = parseFieldList(fieldsRaw);
1042
- const parsedReferences = parseFieldList(referencesRaw);
1043
- if (
1044
- !parsedFields ||
1045
- !parsedReferences ||
1046
- parsedFields.length === 0 ||
1047
- parsedReferences.length === 0
1048
- ) {
1049
- input.diagnostics.push({
1050
- code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1051
- message: `Relation field "${input.modelName}.${input.fieldName}" requires bracketed fields and references lists`,
1052
- sourceId: input.sourceId,
1053
- span: input.attribute.span,
1054
- });
1055
- return undefined;
627
+ const resultFkRelationMetadata: FkRelationMetadata[] = [];
628
+ for (const relationAttribute of relationAttributes) {
629
+ if (relationAttribute.field.list) {
630
+ continue;
1056
631
  }
1057
- fields = parsedFields;
1058
- references = parsedReferences;
1059
- }
1060
-
1061
- const onDeleteArgument = getNamedArgument(input.attribute, 'onDelete');
1062
- const onUpdateArgument = getNamedArgument(input.attribute, 'onUpdate');
1063
632
 
1064
- return {
1065
- ...ifDefined('relationName', relationName),
1066
- ...ifDefined('fields', fields),
1067
- ...ifDefined('references', references),
1068
- ...ifDefined('onDelete', onDeleteArgument ? unquoteStringLiteral(onDeleteArgument) : undefined),
1069
- ...ifDefined('onUpdate', onUpdateArgument ? unquoteStringLiteral(onUpdateArgument) : undefined),
1070
- };
1071
- }
1072
-
1073
- export function interpretPslDocumentToSqlContractIR(
1074
- input: InterpretPslDocumentToSqlContractIRInput,
1075
- ): Result<ContractIR, ContractSourceDiagnostics> {
1076
- const diagnostics: ContractSourceDiagnostic[] = mapParserDiagnostics(input.document);
1077
- const modelNames = new Set(input.document.ast.models.map((model) => model.name));
1078
- const sourceId = input.document.ast.sourceId;
1079
- const composedExtensions = new Set(input.composedExtensionPacks ?? []);
1080
- const defaultFunctionRegistry =
1081
- input.defaultFunctionRegistry ?? createBuiltinDefaultFunctionRegistry();
1082
-
1083
- let builder = defineContract().target(
1084
- input.target ?? DEFAULT_POSTGRES_TARGET,
1085
- ) as unknown as DynamicContractBuilder;
1086
- const enumTypeDescriptors = new Map<string, ColumnDescriptor>();
1087
- const namedTypeDescriptors = new Map<string, ColumnDescriptor>();
1088
- const namedTypeBaseTypes = new Map<string, string>();
1089
-
1090
- for (const enumDeclaration of input.document.ast.enums) {
1091
- const nativeType = enumDeclaration.name.toLowerCase();
1092
- const descriptor: ColumnDescriptor = {
1093
- codecId: 'pg/enum@1',
1094
- nativeType,
1095
- typeRef: enumDeclaration.name,
1096
- };
1097
- enumTypeDescriptors.set(enumDeclaration.name, descriptor);
1098
- builder = builder.storageType(enumDeclaration.name, {
1099
- codecId: 'pg/enum@1',
1100
- nativeType,
1101
- typeParams: { values: enumDeclaration.values.map((value) => value.name) },
1102
- });
1103
- }
1104
-
1105
- for (const declaration of input.document.ast.types?.declarations ?? []) {
1106
- const baseDescriptor =
1107
- enumTypeDescriptors.get(declaration.baseType) ?? SCALAR_COLUMN_MAP[declaration.baseType];
1108
- if (!baseDescriptor) {
633
+ if (!input.modelNames.has(relationAttribute.field.typeName)) {
1109
634
  diagnostics.push({
1110
- code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE',
1111
- message: `Named type "${declaration.name}" references unsupported base type "${declaration.baseType}"`,
635
+ code: 'PSL_INVALID_RELATION_TARGET',
636
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${relationAttribute.field.typeName}"`,
1112
637
  sourceId,
1113
- span: declaration.span,
638
+ span: relationAttribute.field.span,
1114
639
  });
1115
640
  continue;
1116
641
  }
1117
- namedTypeBaseTypes.set(declaration.name, declaration.baseType);
1118
642
 
1119
- const pgvectorAttribute = getAttribute(declaration.attributes, 'pgvector.column');
1120
- const unsupportedNamedTypeAttribute = declaration.attributes.find(
1121
- (attribute) => attribute.name !== 'pgvector.column',
1122
- );
1123
- if (unsupportedNamedTypeAttribute) {
643
+ const parsedRelation = parseRelationAttribute({
644
+ attribute: relationAttribute.relation,
645
+ modelName: model.name,
646
+ fieldName: relationAttribute.field.name,
647
+ sourceId,
648
+ diagnostics,
649
+ });
650
+ if (!parsedRelation) {
651
+ continue;
652
+ }
653
+ if (!parsedRelation.fields || !parsedRelation.references) {
1124
654
  diagnostics.push({
1125
- code: 'PSL_UNSUPPORTED_NAMED_TYPE_ATTRIBUTE',
1126
- message: `Named type "${declaration.name}" uses unsupported attribute "${unsupportedNamedTypeAttribute.name}"`,
655
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
656
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`,
1127
657
  sourceId,
1128
- span: unsupportedNamedTypeAttribute.span,
658
+ span: relationAttribute.relation.span,
1129
659
  });
1130
660
  continue;
1131
661
  }
1132
662
 
1133
- if (pgvectorAttribute) {
1134
- if (!composedExtensions.has('pgvector')) {
1135
- diagnostics.push({
1136
- code: 'PSL_EXTENSION_NAMESPACE_NOT_COMPOSED',
1137
- message:
1138
- 'Attribute "@pgvector.column" uses unrecognized namespace "pgvector". Add extension pack "pgvector" to extensionPacks in prisma-next.config.ts.',
1139
- sourceId,
1140
- span: pgvectorAttribute.span,
1141
- });
1142
- continue;
1143
- }
1144
- if (declaration.baseType !== 'Bytes') {
1145
- diagnostics.push({
1146
- code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
1147
- message: `Named type "${declaration.name}" uses @pgvector.column on unsupported base type "${declaration.baseType}"`,
1148
- sourceId,
1149
- span: pgvectorAttribute.span,
1150
- });
1151
- continue;
1152
- }
1153
- const length = parsePgvectorLength({
1154
- attribute: pgvectorAttribute,
1155
- diagnostics,
663
+ const targetMapping = input.modelMappings.get(relationAttribute.field.typeName);
664
+ if (!targetMapping) {
665
+ diagnostics.push({
666
+ code: 'PSL_INVALID_RELATION_TARGET',
667
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${relationAttribute.field.typeName}"`,
1156
668
  sourceId,
1157
- });
1158
- if (length === undefined) {
1159
- continue;
1160
- }
1161
- namedTypeDescriptors.set(declaration.name, {
1162
- codecId: 'pg/vector@1',
1163
- nativeType: `vector(${length})`,
1164
- typeRef: declaration.name,
1165
- });
1166
- builder = builder.storageType(declaration.name, {
1167
- codecId: 'pg/vector@1',
1168
- nativeType: `vector(${length})`,
1169
- typeParams: { length },
669
+ span: relationAttribute.field.span,
1170
670
  });
1171
671
  continue;
1172
672
  }
1173
673
 
1174
- const descriptor: ColumnDescriptor = {
1175
- codecId: baseDescriptor.codecId,
1176
- nativeType: baseDescriptor.nativeType,
1177
- typeRef: declaration.name,
1178
- };
1179
- namedTypeDescriptors.set(declaration.name, descriptor);
1180
- builder = builder.storageType(declaration.name, {
1181
- codecId: baseDescriptor.codecId,
1182
- nativeType: baseDescriptor.nativeType,
1183
- typeParams: {},
674
+ const localColumns = mapFieldNamesToColumns({
675
+ modelName: model.name,
676
+ fieldNames: parsedRelation.fields,
677
+ mapping,
678
+ sourceId,
679
+ diagnostics,
680
+ span: relationAttribute.relation.span,
681
+ contextLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
1184
682
  });
1185
- }
1186
-
1187
- const modelMappings = buildModelMappings(input.document.ast.models, diagnostics, sourceId);
1188
- const resolvedModels: Array<{
1189
- model: PslModel;
1190
- mapping: ModelNameMapping;
1191
- resolvedFields: ResolvedField[];
1192
- }> = [];
1193
- const fkRelationMetadata: FkRelationMetadata[] = [];
1194
- const backrelationCandidates: ModelBackrelationCandidate[] = [];
1195
-
1196
- for (const model of input.document.ast.models) {
1197
- const mapping = modelMappings.get(model.name);
1198
- if (!mapping) {
683
+ if (!localColumns) {
1199
684
  continue;
1200
685
  }
1201
- const tableName = mapping.tableName;
1202
- const resolvedFields = collectResolvedFields(
1203
- model,
1204
- mapping,
1205
- enumTypeDescriptors,
1206
- namedTypeDescriptors,
1207
- namedTypeBaseTypes,
1208
- modelNames,
1209
- composedExtensions,
1210
- defaultFunctionRegistry,
1211
- diagnostics,
686
+ const referencedColumns = mapFieldNamesToColumns({
687
+ modelName: targetMapping.model.name,
688
+ fieldNames: parsedRelation.references,
689
+ mapping: targetMapping,
1212
690
  sourceId,
1213
- );
1214
- resolvedModels.push({ model, mapping, resolvedFields });
1215
-
1216
- const primaryKeyColumns = resolvedFields
1217
- .filter((field) => field.isId)
1218
- .map((field) => field.columnName);
1219
- if (primaryKeyColumns.length === 0) {
691
+ diagnostics,
692
+ span: relationAttribute.relation.span,
693
+ contextLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
694
+ });
695
+ if (!referencedColumns) {
696
+ continue;
697
+ }
698
+ if (localColumns.length !== referencedColumns.length) {
1220
699
  diagnostics.push({
1221
- code: 'PSL_MISSING_PRIMARY_KEY',
1222
- message: `Model "${model.name}" must declare at least one @id field for SQL provider`,
700
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
701
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" must provide the same number of fields and references`,
1223
702
  sourceId,
1224
- span: model.span,
703
+ span: relationAttribute.relation.span,
1225
704
  });
705
+ continue;
1226
706
  }
1227
707
 
1228
- for (const field of model.fields) {
1229
- if (!field.list || !modelNames.has(field.typeName)) {
708
+ const onDelete = parsedRelation.onDelete
709
+ ? normalizeReferentialAction({
710
+ modelName: model.name,
711
+ fieldName: relationAttribute.field.name,
712
+ actionName: 'onDelete',
713
+ actionToken: parsedRelation.onDelete,
714
+ sourceId,
715
+ span: relationAttribute.field.span,
716
+ diagnostics,
717
+ })
718
+ : undefined;
719
+ const onUpdate = parsedRelation.onUpdate
720
+ ? normalizeReferentialAction({
721
+ modelName: model.name,
722
+ fieldName: relationAttribute.field.name,
723
+ actionName: 'onUpdate',
724
+ actionToken: parsedRelation.onUpdate,
725
+ sourceId,
726
+ span: relationAttribute.field.span,
727
+ diagnostics,
728
+ })
729
+ : undefined;
730
+
731
+ foreignKeyNodes.push({
732
+ columns: localColumns,
733
+ references: {
734
+ model: targetMapping.model.name,
735
+ table: targetMapping.tableName,
736
+ columns: referencedColumns,
737
+ },
738
+ ...ifDefined('name', parsedRelation.constraintName),
739
+ ...ifDefined('onDelete', onDelete),
740
+ ...ifDefined('onUpdate', onUpdate),
741
+ });
742
+
743
+ resultFkRelationMetadata.push({
744
+ declaringModelName: model.name,
745
+ declaringFieldName: relationAttribute.field.name,
746
+ declaringTableName: tableName,
747
+ targetModelName: targetMapping.model.name,
748
+ targetTableName: targetMapping.tableName,
749
+ ...ifDefined('relationName', parsedRelation.relationName),
750
+ localColumns,
751
+ referencedColumns,
752
+ });
753
+ }
754
+
755
+ return {
756
+ modelNode: {
757
+ modelName: model.name,
758
+ tableName,
759
+ fields: resolvedFields.map((resolvedField) => ({
760
+ fieldName: resolvedField.field.name,
761
+ columnName: resolvedField.columnName,
762
+ descriptor: resolvedField.descriptor,
763
+ nullable: resolvedField.field.optional,
764
+ ...ifDefined('default', resolvedField.defaultValue),
765
+ ...ifDefined('executionDefault', resolvedField.executionDefault),
766
+ })),
767
+ ...(primaryKeyColumns.length > 0
768
+ ? {
769
+ id: {
770
+ columns: primaryKeyColumns,
771
+ ...ifDefined('name', primaryKeyName),
772
+ },
773
+ }
774
+ : {}),
775
+ ...(uniqueConstraints.length > 0 ? { uniques: uniqueConstraints } : {}),
776
+ ...(indexNodes.length > 0 ? { indexes: indexNodes } : {}),
777
+ ...(foreignKeyNodes.length > 0 ? { foreignKeys: foreignKeyNodes } : {}),
778
+ },
779
+ fkRelationMetadata: resultFkRelationMetadata,
780
+ backrelationCandidates: resultBackrelationCandidates,
781
+ resolvedFields,
782
+ };
783
+ }
784
+
785
+ interface BuildValueObjectsInput {
786
+ readonly compositeTypes: readonly PslCompositeType[];
787
+ readonly enumTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
788
+ readonly namedTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
789
+ readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
790
+ readonly composedExtensions: ReadonlySet<string>;
791
+ readonly familyId: string;
792
+ readonly targetId: string;
793
+ readonly authoringContributions: AuthoringContributions | undefined;
794
+ readonly diagnostics: ContractSourceDiagnostic[];
795
+ readonly sourceId: string;
796
+ }
797
+
798
+ function buildValueObjects(input: BuildValueObjectsInput): Record<string, ContractValueObject> {
799
+ const {
800
+ compositeTypes,
801
+ enumTypeDescriptors,
802
+ namedTypeDescriptors,
803
+ scalarTypeDescriptors,
804
+ composedExtensions,
805
+ familyId,
806
+ targetId,
807
+ authoringContributions,
808
+ diagnostics,
809
+ sourceId,
810
+ } = input;
811
+ const valueObjects: Record<string, ContractValueObject> = {};
812
+ const compositeTypeNames = new Set(compositeTypes.map((ct) => ct.name));
813
+
814
+ for (const compositeType of compositeTypes) {
815
+ const fields: Record<string, ContractField> = {};
816
+ for (const field of compositeType.fields) {
817
+ if (compositeTypeNames.has(field.typeName)) {
818
+ const result: ContractField = {
819
+ type: { kind: 'valueObject', name: field.typeName },
820
+ nullable: field.optional,
821
+ };
822
+ fields[field.name] = field.list ? { ...result, many: true } : result;
1230
823
  continue;
1231
824
  }
1232
- const attributesValid = validateNavigationListFieldAttributes({
1233
- modelName: model.name,
825
+ const resolved = resolveFieldTypeDescriptor({
1234
826
  field,
1235
- sourceId,
827
+ enumTypeDescriptors,
828
+ namedTypeDescriptors,
829
+ scalarTypeDescriptors,
830
+ authoringContributions,
1236
831
  composedExtensions,
832
+ familyId,
833
+ targetId,
1237
834
  diagnostics,
835
+ sourceId,
836
+ entityLabel: `Field "${compositeType.name}.${field.name}"`,
1238
837
  });
1239
- const relationAttribute = getAttribute(field.attributes, 'relation');
1240
- let relationName: string | undefined;
1241
- if (relationAttribute) {
1242
- const parsedRelation = parseRelationAttribute({
1243
- attribute: relationAttribute,
1244
- modelName: model.name,
1245
- fieldName: field.name,
1246
- sourceId,
1247
- diagnostics,
1248
- });
1249
- if (!parsedRelation) {
1250
- continue;
1251
- }
1252
- if (parsedRelation.fields || parsedRelation.references) {
838
+ if (!resolved.ok) {
839
+ if (!resolved.alreadyReported) {
1253
840
  diagnostics.push({
1254
- code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1255
- message: `Backrelation list field "${model.name}.${field.name}" cannot declare fields/references; define them on the FK-side relation field`,
841
+ code: 'PSL_UNSUPPORTED_FIELD_TYPE',
842
+ message: `Field "${compositeType.name}.${field.name}" type "${field.typeName}" is not supported`,
1256
843
  sourceId,
1257
- span: relationAttribute.span,
844
+ span: field.span,
1258
845
  });
1259
- continue;
1260
846
  }
1261
- if (parsedRelation.onDelete || parsedRelation.onUpdate) {
1262
- diagnostics.push({
1263
- code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1264
- message: `Backrelation list field "${model.name}.${field.name}" cannot declare onDelete/onUpdate; define referential actions on the FK-side relation field`,
1265
- sourceId,
1266
- span: relationAttribute.span,
1267
- });
1268
- continue;
1269
- }
1270
- relationName = parsedRelation.relationName;
1271
- }
1272
- if (!attributesValid) {
1273
847
  continue;
1274
848
  }
849
+ const scalarField: ContractField = {
850
+ nullable: field.optional,
851
+ type: { kind: 'scalar', codecId: resolved.descriptor.codecId },
852
+ };
853
+ fields[field.name] = field.list ? { ...scalarField, many: true } : scalarField;
854
+ }
855
+ valueObjects[compositeType.name] = { fields };
856
+ }
1275
857
 
1276
- backrelationCandidates.push({
1277
- modelName: model.name,
1278
- tableName,
1279
- field,
1280
- targetModelName: field.typeName,
1281
- ...ifDefined('relationName', relationName),
1282
- });
858
+ return valueObjects;
859
+ }
860
+
861
+ function patchModelDomainFields(
862
+ models: Record<string, ContractModel>,
863
+ modelResolvedFields: ReadonlyMap<string, readonly ResolvedField[]>,
864
+ ): Record<string, ContractModel> {
865
+ let patched = models;
866
+
867
+ for (const [modelName, resolvedFields] of modelResolvedFields) {
868
+ const model = patched[modelName];
869
+ if (!model) continue;
870
+
871
+ let needsPatch = false;
872
+ const patchedFields: Record<string, ContractField> = { ...model.fields };
873
+
874
+ for (const rf of resolvedFields) {
875
+ if (rf.valueObjectTypeName) {
876
+ needsPatch = true;
877
+ patchedFields[rf.field.name] = {
878
+ nullable: rf.field.optional,
879
+ type: { kind: 'valueObject', name: rf.valueObjectTypeName },
880
+ ...(rf.many ? { many: true as const } : {}),
881
+ };
882
+ } else if (rf.many && rf.scalarCodecId) {
883
+ needsPatch = true;
884
+ patchedFields[rf.field.name] = {
885
+ nullable: rf.field.optional,
886
+ type: { kind: 'scalar', codecId: rf.scalarCodecId },
887
+ many: true as const,
888
+ };
889
+ }
1283
890
  }
1284
891
 
1285
- const relationAttributes = model.fields
1286
- .map((field) => ({
1287
- field,
1288
- relation: getAttribute(field.attributes, 'relation'),
1289
- }))
1290
- .filter((entry): entry is { field: PslField; relation: PslAttribute } =>
1291
- Boolean(entry.relation),
1292
- );
892
+ if (needsPatch) {
893
+ patched = { ...patched, [modelName]: { ...model, fields: patchedFields } };
894
+ }
895
+ }
1293
896
 
1294
- builder = builder.table(tableName, (tableBuilder: DynamicTableBuilder) => {
1295
- let table = tableBuilder;
897
+ return patched;
898
+ }
1296
899
 
1297
- for (const resolvedField of resolvedFields) {
1298
- if (resolvedField.executionDefault) {
1299
- table = table.generated(resolvedField.columnName, {
1300
- type: resolvedField.descriptor,
1301
- generated: resolvedField.executionDefault,
1302
- });
1303
- } else {
1304
- const options: {
1305
- type: ColumnDescriptor;
1306
- nullable?: true;
1307
- default?: ColumnDefault;
1308
- } = {
1309
- type: resolvedField.descriptor,
1310
- ...ifDefined('nullable', resolvedField.field.optional ? (true as const) : undefined),
1311
- ...ifDefined('default', resolvedField.defaultValue),
1312
- };
1313
- table = table.column(resolvedField.columnName, options);
1314
- }
900
+ type DiscriminatorDeclaration = {
901
+ readonly fieldName: string;
902
+ readonly span: ContractSourceDiagnosticSpan;
903
+ };
1315
904
 
1316
- if (resolvedField.isUnique) {
1317
- table = table.unique([resolvedField.columnName]);
1318
- }
1319
- }
905
+ type BaseDeclaration = {
906
+ readonly baseName: string;
907
+ readonly value: string;
908
+ readonly span: ContractSourceDiagnosticSpan;
909
+ };
1320
910
 
1321
- if (primaryKeyColumns.length > 0) {
1322
- table = table.primaryKey(primaryKeyColumns);
1323
- }
911
+ function collectPolymorphismDeclarations(
912
+ models: readonly PslModel[],
913
+ sourceId: string,
914
+ diagnostics: ContractSourceDiagnostic[],
915
+ ): {
916
+ discriminatorDeclarations: Map<string, DiscriminatorDeclaration>;
917
+ baseDeclarations: Map<string, BaseDeclaration>;
918
+ } {
919
+ const discriminatorDeclarations = new Map<string, DiscriminatorDeclaration>();
920
+ const baseDeclarations = new Map<string, BaseDeclaration>();
1324
921
 
1325
- for (const modelAttribute of model.attributes) {
1326
- if (modelAttribute.name === 'map') {
1327
- continue;
1328
- }
1329
- if (modelAttribute.name === 'unique' || modelAttribute.name === 'index') {
1330
- const fieldNames = parseAttributeFieldList({
1331
- attribute: modelAttribute,
1332
- sourceId,
1333
- diagnostics,
922
+ for (const model of models) {
923
+ for (const attr of model.attributes) {
924
+ if (attr.name === 'discriminator') {
925
+ const fieldName = getPositionalArgument(attr);
926
+ if (!fieldName) {
927
+ diagnostics.push({
1334
928
  code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
1335
- messagePrefix: `Model "${model.name}" @@${modelAttribute.name}`,
1336
- });
1337
- if (!fieldNames) {
1338
- continue;
1339
- }
1340
- const columnNames = mapFieldNamesToColumns({
1341
- modelName: model.name,
1342
- fieldNames,
1343
- mapping,
929
+ message: `Model "${model.name}" @@discriminator requires a field name argument`,
1344
930
  sourceId,
1345
- diagnostics,
1346
- span: modelAttribute.span,
1347
- contextLabel: `Model "${model.name}" @@${modelAttribute.name}`,
931
+ span: attr.span,
1348
932
  });
1349
- if (!columnNames) {
1350
- continue;
1351
- }
1352
- if (modelAttribute.name === 'unique') {
1353
- table = table.unique(columnNames);
1354
- } else {
1355
- table = table.index(columnNames);
1356
- }
1357
933
  continue;
1358
934
  }
1359
- if (modelAttribute.name.startsWith('pgvector.') && !composedExtensions.has('pgvector')) {
935
+ const discField = model.fields.find((f) => f.name === fieldName);
936
+ if (discField && discField.typeName !== 'String') {
1360
937
  diagnostics.push({
1361
- code: 'PSL_EXTENSION_NAMESPACE_NOT_COMPOSED',
1362
- message: `Attribute "@@${modelAttribute.name}" uses unrecognized namespace "pgvector". Add extension pack "pgvector" to extensionPacks in prisma-next.config.ts.`,
938
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
939
+ message: `Discriminator field "${fieldName}" on model "${model.name}" must be of type String, but is "${discField.typeName}"`,
1363
940
  sourceId,
1364
- span: modelAttribute.span,
941
+ span: attr.span,
1365
942
  });
1366
943
  continue;
1367
944
  }
1368
- diagnostics.push({
1369
- code: 'PSL_UNSUPPORTED_MODEL_ATTRIBUTE',
1370
- message: `Model "${model.name}" uses unsupported attribute "@@${modelAttribute.name}"`,
1371
- sourceId,
1372
- span: modelAttribute.span,
1373
- });
945
+ discriminatorDeclarations.set(model.name, { fieldName, span: attr.span });
1374
946
  }
1375
947
 
1376
- for (const relationAttribute of relationAttributes) {
1377
- if (relationAttribute.field.list) {
1378
- continue;
1379
- }
1380
-
1381
- if (!modelNames.has(relationAttribute.field.typeName)) {
948
+ if (attr.name === 'base') {
949
+ const baseName = getPositionalArgument(attr, 0);
950
+ const rawValue = getPositionalArgument(attr, 1);
951
+ if (!baseName || !rawValue) {
1382
952
  diagnostics.push({
1383
- code: 'PSL_INVALID_RELATION_TARGET',
1384
- message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${relationAttribute.field.typeName}"`,
953
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
954
+ message: `Model "${model.name}" @@base requires two arguments: base model name and discriminator value`,
1385
955
  sourceId,
1386
- span: relationAttribute.field.span,
956
+ span: attr.span,
1387
957
  });
1388
958
  continue;
1389
959
  }
1390
-
1391
- const parsedRelation = parseRelationAttribute({
1392
- attribute: relationAttribute.relation,
1393
- modelName: model.name,
1394
- fieldName: relationAttribute.field.name,
1395
- sourceId,
1396
- diagnostics,
1397
- });
1398
- if (!parsedRelation) {
1399
- continue;
1400
- }
1401
- if (!parsedRelation.fields || !parsedRelation.references) {
960
+ const value = parseQuotedStringLiteral(rawValue);
961
+ if (value === undefined) {
1402
962
  diagnostics.push({
1403
- code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1404
- message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`,
963
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
964
+ message: `Model "${model.name}" @@base discriminator value must be a quoted string literal`,
1405
965
  sourceId,
1406
- span: relationAttribute.relation.span,
966
+ span: attr.span,
1407
967
  });
1408
968
  continue;
1409
969
  }
970
+ baseDeclarations.set(model.name, { baseName, value, span: attr.span });
971
+ }
972
+ }
973
+ }
1410
974
 
1411
- const targetMapping = modelMappings.get(relationAttribute.field.typeName);
1412
- if (!targetMapping) {
1413
- diagnostics.push({
1414
- code: 'PSL_INVALID_RELATION_TARGET',
1415
- message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${relationAttribute.field.typeName}"`,
1416
- sourceId,
1417
- span: relationAttribute.field.span,
1418
- });
1419
- continue;
1420
- }
975
+ return { discriminatorDeclarations, baseDeclarations };
976
+ }
1421
977
 
1422
- const localColumns = mapFieldNamesToColumns({
1423
- modelName: model.name,
1424
- fieldNames: parsedRelation.fields,
1425
- mapping,
978
+ function resolvePolymorphism(
979
+ models: Record<string, ContractModel>,
980
+ discriminatorDeclarations: Map<string, DiscriminatorDeclaration>,
981
+ baseDeclarations: Map<string, BaseDeclaration>,
982
+ modelNames: Set<string>,
983
+ modelMappings: ReadonlyMap<string, ModelNameMapping>,
984
+ sourceId: string,
985
+ diagnostics: ContractSourceDiagnostic[],
986
+ ): Record<string, ContractModel> {
987
+ let patched = models;
988
+
989
+ for (const [modelName, decl] of discriminatorDeclarations) {
990
+ if (baseDeclarations.has(modelName)) {
991
+ diagnostics.push({
992
+ code: 'PSL_DISCRIMINATOR_AND_BASE',
993
+ message: `Model "${modelName}" cannot have both @@discriminator and @@base`,
994
+ sourceId,
995
+ span: decl.span,
996
+ });
997
+ continue;
998
+ }
999
+
1000
+ const model = patched[modelName];
1001
+ if (!model) continue;
1002
+
1003
+ if (!Object.hasOwn(model.fields, decl.fieldName)) {
1004
+ diagnostics.push({
1005
+ code: 'PSL_DISCRIMINATOR_FIELD_NOT_FOUND',
1006
+ message: `Discriminator field "${decl.fieldName}" is not a field on model "${modelName}"`,
1007
+ sourceId,
1008
+ span: decl.span,
1009
+ });
1010
+ continue;
1011
+ }
1012
+
1013
+ const variants: Record<string, { readonly value: string }> = {};
1014
+ const seenValues = new Map<string, string>();
1015
+
1016
+ for (const [variantName, baseDecl] of baseDeclarations) {
1017
+ if (baseDecl.baseName !== modelName) continue;
1018
+
1019
+ const existingVariant = seenValues.get(baseDecl.value);
1020
+ if (existingVariant) {
1021
+ diagnostics.push({
1022
+ code: 'PSL_DUPLICATE_DISCRIMINATOR_VALUE',
1023
+ message: `Discriminator value "${baseDecl.value}" is used by both "${existingVariant}" and "${variantName}" on base model "${modelName}"`,
1426
1024
  sourceId,
1427
- diagnostics,
1428
- span: relationAttribute.relation.span,
1429
- contextLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
1025
+ span: baseDecl.span,
1430
1026
  });
1431
- if (!localColumns) {
1432
- continue;
1433
- }
1434
- const referencedColumns = mapFieldNamesToColumns({
1435
- modelName: targetMapping.model.name,
1436
- fieldNames: parsedRelation.references,
1437
- mapping: targetMapping,
1027
+ continue;
1028
+ }
1029
+ seenValues.set(baseDecl.value, variantName);
1030
+ variants[variantName] = { value: baseDecl.value };
1031
+ }
1032
+
1033
+ if (Object.keys(variants).length === 0) {
1034
+ diagnostics.push({
1035
+ code: 'PSL_ORPHANED_DISCRIMINATOR',
1036
+ message: `Model "${modelName}" has @@discriminator but no variant models declare @@base(${modelName}, ...)`,
1037
+ sourceId,
1038
+ span: decl.span,
1039
+ });
1040
+ continue;
1041
+ }
1042
+
1043
+ patched = {
1044
+ ...patched,
1045
+ [modelName]: { ...model, discriminator: { field: decl.fieldName }, variants },
1046
+ };
1047
+ }
1048
+
1049
+ for (const [variantName, baseDecl] of baseDeclarations) {
1050
+ if (!modelNames.has(baseDecl.baseName)) {
1051
+ diagnostics.push({
1052
+ code: 'PSL_BASE_TARGET_NOT_FOUND',
1053
+ message: `Model "${variantName}" @@base references non-existent model "${baseDecl.baseName}"`,
1054
+ sourceId,
1055
+ span: baseDecl.span,
1056
+ });
1057
+ continue;
1058
+ }
1059
+
1060
+ if (!discriminatorDeclarations.has(baseDecl.baseName)) {
1061
+ diagnostics.push({
1062
+ code: 'PSL_ORPHANED_BASE',
1063
+ message: `Model "${variantName}" declares @@base(${baseDecl.baseName}, ...) but "${baseDecl.baseName}" has no @@discriminator`,
1064
+ sourceId,
1065
+ span: baseDecl.span,
1066
+ });
1067
+ continue;
1068
+ }
1069
+
1070
+ if (discriminatorDeclarations.has(variantName)) {
1071
+ continue;
1072
+ }
1073
+
1074
+ const variantModel = patched[variantName];
1075
+ if (!variantModel) continue;
1076
+
1077
+ const baseMapping = modelMappings.get(baseDecl.baseName);
1078
+ const variantMapping = modelMappings.get(variantName);
1079
+ const hasExplicitMap =
1080
+ variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
1081
+ const resolvedTable = hasExplicitMap ? variantMapping?.tableName : baseMapping?.tableName;
1082
+
1083
+ patched = {
1084
+ ...patched,
1085
+ [variantName]: {
1086
+ ...variantModel,
1087
+ base: baseDecl.baseName,
1088
+ ...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
1089
+ },
1090
+ };
1091
+ }
1092
+
1093
+ return patched;
1094
+ }
1095
+
1096
+ export function interpretPslDocumentToSqlContract(
1097
+ input: InterpretPslDocumentToSqlContractInput,
1098
+ ): Result<Contract, ContractSourceDiagnostics> {
1099
+ const sourceId = input.document.ast.sourceId;
1100
+ if (!input.target) {
1101
+ return notOk({
1102
+ summary: 'PSL to SQL contract interpretation failed',
1103
+ diagnostics: [
1104
+ {
1105
+ code: 'PSL_TARGET_CONTEXT_REQUIRED',
1106
+ message: 'PSL interpretation requires an explicit target context from composition.',
1438
1107
  sourceId,
1439
- diagnostics,
1440
- span: relationAttribute.relation.span,
1441
- contextLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
1442
- });
1443
- if (!referencedColumns) {
1444
- continue;
1445
- }
1446
- if (localColumns.length !== referencedColumns.length) {
1447
- diagnostics.push({
1448
- code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1449
- message: `Relation field "${model.name}.${relationAttribute.field.name}" must provide the same number of fields and references`,
1450
- sourceId,
1451
- span: relationAttribute.relation.span,
1452
- });
1453
- continue;
1454
- }
1108
+ },
1109
+ ],
1110
+ });
1111
+ }
1112
+ if (!input.scalarTypeDescriptors) {
1113
+ return notOk({
1114
+ summary: 'PSL to SQL contract interpretation failed',
1115
+ diagnostics: [
1116
+ {
1117
+ code: 'PSL_SCALAR_TYPE_CONTEXT_REQUIRED',
1118
+ message: 'PSL interpretation requires composed scalar type descriptors.',
1119
+ sourceId,
1120
+ },
1121
+ ],
1122
+ });
1123
+ }
1455
1124
 
1456
- const onDelete = parsedRelation.onDelete
1457
- ? normalizeReferentialAction({
1458
- modelName: model.name,
1459
- fieldName: relationAttribute.field.name,
1460
- actionName: 'onDelete',
1461
- actionToken: parsedRelation.onDelete,
1462
- sourceId,
1463
- span: relationAttribute.field.span,
1464
- diagnostics,
1465
- })
1466
- : undefined;
1467
- const onUpdate = parsedRelation.onUpdate
1468
- ? normalizeReferentialAction({
1469
- modelName: model.name,
1470
- fieldName: relationAttribute.field.name,
1471
- actionName: 'onUpdate',
1472
- actionToken: parsedRelation.onUpdate,
1473
- sourceId,
1474
- span: relationAttribute.field.span,
1475
- diagnostics,
1476
- })
1477
- : undefined;
1478
-
1479
- table = table.foreignKey(
1480
- localColumns,
1481
- {
1482
- table: targetMapping.tableName,
1483
- columns: referencedColumns,
1484
- },
1485
- {
1486
- ...ifDefined('onDelete', onDelete),
1487
- ...ifDefined('onUpdate', onUpdate),
1488
- },
1489
- );
1490
-
1491
- fkRelationMetadata.push({
1492
- declaringModelName: model.name,
1493
- declaringFieldName: relationAttribute.field.name,
1494
- declaringTableName: tableName,
1495
- targetModelName: targetMapping.model.name,
1496
- targetTableName: targetMapping.tableName,
1497
- ...ifDefined('relationName', parsedRelation.relationName),
1498
- localColumns,
1499
- referencedColumns,
1500
- });
1501
- }
1125
+ const diagnostics: ContractSourceDiagnostic[] = mapParserDiagnostics(input.document);
1126
+ const models = input.document.ast.models ?? [];
1127
+ const enums = input.document.ast.enums ?? [];
1128
+ const compositeTypes = input.document.ast.compositeTypes ?? [];
1129
+ const modelNames = new Set(models.map((model) => model.name));
1130
+ const compositeTypeNames = new Set(compositeTypes.map((ct) => ct.name));
1131
+ const composedExtensions = new Set(input.composedExtensionPacks ?? []);
1132
+ const defaultFunctionRegistry: ControlMutationDefaultRegistry =
1133
+ input.controlMutationDefaults?.defaultFunctionRegistry ?? new Map();
1134
+ const generatorDescriptors = input.controlMutationDefaults?.generatorDescriptors ?? [];
1135
+ const generatorDescriptorById = new Map<string, MutationDefaultGeneratorDescriptor>();
1136
+ for (const descriptor of generatorDescriptors) {
1137
+ generatorDescriptorById.set(descriptor.id, descriptor);
1138
+ }
1502
1139
 
1503
- return table;
1140
+ const enumResult = processEnumDeclarations({
1141
+ enums,
1142
+ sourceId,
1143
+ enumTypeConstructor: getAuthoringTypeConstructor(input.authoringContributions, ['enum']),
1144
+ diagnostics,
1145
+ });
1146
+
1147
+ const namedTypeResult = resolveNamedTypeDeclarations({
1148
+ declarations: input.document.ast.types?.declarations ?? [],
1149
+ sourceId,
1150
+ enumTypeDescriptors: enumResult.enumTypeDescriptors,
1151
+ scalarTypeDescriptors: input.scalarTypeDescriptors,
1152
+ composedExtensions,
1153
+ familyId: input.target.familyId,
1154
+ targetId: input.target.targetId,
1155
+ authoringContributions: input.authoringContributions,
1156
+ diagnostics,
1157
+ });
1158
+
1159
+ const storageTypes = { ...enumResult.storageTypes, ...namedTypeResult.storageTypes };
1160
+
1161
+ const modelMappings = buildModelMappings(models, diagnostics, sourceId);
1162
+ const modelNodes: ModelNode[] = [];
1163
+ const fkRelationMetadata: FkRelationMetadata[] = [];
1164
+ const backrelationCandidates: ModelBackrelationCandidate[] = [];
1165
+ const modelResolvedFields = new Map<string, readonly ResolvedField[]>();
1166
+
1167
+ for (const model of models) {
1168
+ const mapping = modelMappings.get(model.name);
1169
+ if (!mapping) {
1170
+ continue;
1171
+ }
1172
+ const result = buildModelNodeFromPsl({
1173
+ model,
1174
+ mapping,
1175
+ modelMappings,
1176
+ modelNames,
1177
+ compositeTypeNames,
1178
+ enumTypeDescriptors: enumResult.enumTypeDescriptors,
1179
+ namedTypeDescriptors: namedTypeResult.namedTypeDescriptors,
1180
+ composedExtensions,
1181
+ familyId: input.target.familyId,
1182
+ targetId: input.target.targetId,
1183
+ authoringContributions: input.authoringContributions,
1184
+ defaultFunctionRegistry,
1185
+ generatorDescriptorById,
1186
+ scalarTypeDescriptors: input.scalarTypeDescriptors,
1187
+ sourceId,
1188
+ diagnostics,
1504
1189
  });
1190
+ modelNodes.push(result.modelNode);
1191
+ fkRelationMetadata.push(...result.fkRelationMetadata);
1192
+ backrelationCandidates.push(...result.backrelationCandidates);
1193
+ modelResolvedFields.set(model.name, result.resolvedFields);
1505
1194
  }
1506
1195
 
1507
1196
  const { modelRelations, fkRelationsByPair } = indexFkRelations({ fkRelationMetadata });
@@ -1512,31 +1201,90 @@ export function interpretPslDocumentToSqlContractIR(
1512
1201
  diagnostics,
1513
1202
  sourceId,
1514
1203
  });
1515
- builder = emitModelsWithRelations({
1516
- builder,
1517
- resolvedModels,
1518
- modelRelations,
1204
+
1205
+ const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations(
1206
+ models,
1207
+ sourceId,
1208
+ diagnostics,
1209
+ );
1210
+
1211
+ const valueObjects = buildValueObjects({
1212
+ compositeTypes,
1213
+ enumTypeDescriptors: enumResult.enumTypeDescriptors,
1214
+ namedTypeDescriptors: namedTypeResult.namedTypeDescriptors,
1215
+ scalarTypeDescriptors: input.scalarTypeDescriptors,
1216
+ composedExtensions,
1217
+ familyId: input.target.familyId,
1218
+ targetId: input.target.targetId,
1219
+ authoringContributions: input.authoringContributions,
1220
+ diagnostics,
1221
+ sourceId,
1519
1222
  });
1520
1223
 
1521
1224
  if (diagnostics.length > 0) {
1522
- const dedupedDiagnostics = diagnostics.filter(
1523
- (diagnostic, index, allDiagnostics) =>
1524
- allDiagnostics.findIndex(
1525
- (candidate) =>
1526
- candidate.code === diagnostic.code &&
1527
- candidate.message === diagnostic.message &&
1528
- candidate.sourceId === diagnostic.sourceId &&
1529
- ((candidate.span && diagnostic.span && hasSameSpan(candidate.span, diagnostic.span)) ||
1530
- (!candidate.span && !diagnostic.span)),
1531
- ) === index,
1532
- );
1225
+ return notOk({
1226
+ summary: 'PSL to SQL contract interpretation failed',
1227
+ diagnostics: dedupeDiagnostics(diagnostics),
1228
+ });
1229
+ }
1230
+
1231
+ const contract = buildSqlContractFromDefinition({
1232
+ target: input.target,
1233
+ ...ifDefined(
1234
+ 'extensionPacks',
1235
+ buildComposedExtensionPackRefs(
1236
+ input.target,
1237
+ [...composedExtensions].sort(compareStrings),
1238
+ input.composedExtensionPackRefs,
1239
+ ),
1240
+ ),
1241
+ ...(Object.keys(storageTypes).length > 0 ? { storageTypes } : {}),
1242
+ models: modelNodes.map((model) => ({
1243
+ ...model,
1244
+ ...(modelRelations.has(model.modelName)
1245
+ ? {
1246
+ relations: [...(modelRelations.get(model.modelName) ?? [])].sort((left, right) =>
1247
+ compareStrings(left.fieldName, right.fieldName),
1248
+ ),
1249
+ }
1250
+ : {}),
1251
+ })),
1252
+ });
1253
+
1254
+ let patchedModels = patchModelDomainFields(
1255
+ contract.models as Record<string, ContractModel>,
1256
+ modelResolvedFields,
1257
+ );
1533
1258
 
1259
+ const polyDiagnostics: ContractSourceDiagnostic[] = [];
1260
+ patchedModels = resolvePolymorphism(
1261
+ patchedModels,
1262
+ discriminatorDeclarations,
1263
+ baseDeclarations,
1264
+ modelNames,
1265
+ modelMappings,
1266
+ sourceId,
1267
+ polyDiagnostics,
1268
+ );
1269
+
1270
+ if (polyDiagnostics.length > 0) {
1534
1271
  return notOk({
1535
- summary: 'PSL to SQL Contract IR normalization failed',
1536
- diagnostics: dedupedDiagnostics,
1272
+ summary: 'PSL to SQL contract interpretation failed',
1273
+ diagnostics: polyDiagnostics,
1537
1274
  });
1538
1275
  }
1539
1276
 
1540
- const contract = builder.build() as ContractIR;
1541
- return ok(contract);
1277
+ const variantModelNames = new Set(baseDeclarations.keys());
1278
+ const filteredRoots = Object.fromEntries(
1279
+ Object.entries(contract.roots).filter(([, modelName]) => !variantModelNames.has(modelName)),
1280
+ );
1281
+
1282
+ const patchedContract: Contract = {
1283
+ ...contract,
1284
+ roots: filteredRoots,
1285
+ models: patchedModels,
1286
+ ...(Object.keys(valueObjects).length > 0 ? { valueObjects } : {}),
1287
+ };
1288
+
1289
+ return ok(patchedContract);
1542
1290
  }