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

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.
@@ -5,7 +5,7 @@ import type {
5
5
  } from '@prisma-next/config/config-types';
6
6
  import type { TargetPackRef } from '@prisma-next/contract/framework-components';
7
7
  import type { ContractIR } from '@prisma-next/contract/ir';
8
- import type { ColumnDefault } from '@prisma-next/contract/types';
8
+ import type { ColumnDefault, ExecutionMutationDefaultValue } from '@prisma-next/contract/types';
9
9
  import type {
10
10
  ParsePslDocumentResult,
11
11
  PslAttribute,
@@ -14,7 +14,16 @@ import type {
14
14
  PslSpan,
15
15
  } from '@prisma-next/psl-parser';
16
16
  import { defineContract } from '@prisma-next/sql-contract-ts/contract-builder';
17
+ import { assertDefined, invariant } from '@prisma-next/utils/assertions';
18
+ import { ifDefined } from '@prisma-next/utils/defined';
17
19
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
20
+ import {
21
+ type ControlMutationDefaultRegistry,
22
+ type ControlMutationDefaults,
23
+ lowerDefaultFunctionWithRegistry,
24
+ type MutationDefaultGeneratorDescriptor,
25
+ parseDefaultFunctionCall,
26
+ } from './default-function-registry';
18
27
 
19
28
  type ColumnDescriptor = {
20
29
  readonly codecId: string;
@@ -25,31 +34,12 @@ type ColumnDescriptor = {
25
34
 
26
35
  export interface InterpretPslDocumentToSqlContractIRInput {
27
36
  readonly document: ParsePslDocumentResult;
28
- readonly target?: TargetPackRef<'sql', 'postgres'>;
37
+ readonly target: TargetPackRef<'sql', 'postgres'>;
38
+ readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
29
39
  readonly composedExtensionPacks?: readonly string[];
40
+ readonly controlMutationDefaults?: ControlMutationDefaults;
30
41
  }
31
42
 
32
- const DEFAULT_POSTGRES_TARGET: TargetPackRef<'sql', 'postgres'> = {
33
- kind: 'target',
34
- familyId: 'sql',
35
- targetId: 'postgres',
36
- id: 'postgres',
37
- version: '0.0.1',
38
- capabilities: {},
39
- };
40
-
41
- const SCALAR_COLUMN_MAP: Record<string, ColumnDescriptor> = {
42
- String: { codecId: 'pg/text@1', nativeType: 'text' },
43
- Boolean: { codecId: 'pg/bool@1', nativeType: 'bool' },
44
- Int: { codecId: 'pg/int4@1', nativeType: 'int4' },
45
- BigInt: { codecId: 'pg/int8@1', nativeType: 'int8' },
46
- Float: { codecId: 'pg/float8@1', nativeType: 'float8' },
47
- Decimal: { codecId: 'pg/numeric@1', nativeType: 'numeric' },
48
- DateTime: { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz' },
49
- Json: { codecId: 'pg/jsonb@1', nativeType: 'jsonb' },
50
- Bytes: { codecId: 'pg/bytea@1', nativeType: 'bytea' },
51
- };
52
-
53
43
  const REFERENTIAL_ACTION_MAP = {
54
44
  NoAction: 'noAction',
55
45
  Restrict: 'restrict',
@@ -68,10 +58,61 @@ type ResolvedField = {
68
58
  readonly columnName: string;
69
59
  readonly descriptor: ColumnDescriptor;
70
60
  readonly defaultValue?: ColumnDefault;
61
+ readonly executionDefault?: ExecutionMutationDefaultValue;
71
62
  readonly isId: boolean;
72
63
  readonly isUnique: boolean;
73
64
  };
74
65
 
66
+ type ParsedRelationAttribute = {
67
+ readonly relationName?: string;
68
+ readonly fields?: readonly string[];
69
+ readonly references?: readonly string[];
70
+ readonly constraintName?: string;
71
+ readonly onDelete?: string;
72
+ readonly onUpdate?: string;
73
+ };
74
+
75
+ type FkRelationMetadata = {
76
+ readonly declaringModelName: string;
77
+ readonly declaringFieldName: string;
78
+ readonly declaringTableName: string;
79
+ readonly targetModelName: string;
80
+ readonly targetTableName: string;
81
+ readonly relationName?: string;
82
+ readonly localColumns: readonly string[];
83
+ readonly referencedColumns: readonly string[];
84
+ };
85
+
86
+ type ModelBackrelationCandidate = {
87
+ readonly modelName: string;
88
+ readonly tableName: string;
89
+ readonly field: PslField;
90
+ readonly targetModelName: string;
91
+ readonly relationName?: string;
92
+ };
93
+
94
+ type ModelRelationMetadata = {
95
+ readonly fieldName: string;
96
+ readonly toModel: string;
97
+ readonly toTable: string;
98
+ readonly cardinality: '1:N' | 'N:1';
99
+ readonly parentTable: string;
100
+ readonly parentColumns: readonly string[];
101
+ readonly childTable: string;
102
+ readonly childColumns: readonly string[];
103
+ };
104
+
105
+ type ResolvedModelEntry = {
106
+ readonly model: PslModel;
107
+ readonly mapping: ModelNameMapping;
108
+ readonly resolvedFields: readonly ResolvedField[];
109
+ };
110
+
111
+ function fkRelationPairKey(declaringModelName: string, targetModelName: string): string {
112
+ // NOTE: We assume PSL model identifiers do not contain the `::` separator.
113
+ return `${declaringModelName}::${targetModelName}`;
114
+ }
115
+
75
116
  type ModelNameMapping = {
76
117
  readonly model: PslModel;
77
118
  readonly tableName: string;
@@ -83,18 +124,36 @@ type DynamicTableBuilder = {
83
124
  name: string,
84
125
  options: { type: ColumnDescriptor; nullable?: true; default?: ColumnDefault },
85
126
  ): DynamicTableBuilder;
127
+ generated(
128
+ name: string,
129
+ options: { type: ColumnDescriptor; generated: ExecutionMutationDefaultValue },
130
+ ): DynamicTableBuilder;
86
131
  unique(columns: readonly string[]): DynamicTableBuilder;
87
132
  primaryKey(columns: readonly string[]): DynamicTableBuilder;
88
133
  index(columns: readonly string[]): DynamicTableBuilder;
89
134
  foreignKey(
90
135
  columns: readonly string[],
91
136
  references: { table: string; columns: readonly string[] },
92
- options?: { onDelete?: string; onUpdate?: string },
137
+ options?: { name?: string; onDelete?: string; onUpdate?: string },
93
138
  ): DynamicTableBuilder;
94
139
  };
95
140
 
96
141
  type DynamicModelBuilder = {
97
142
  field(name: string, column: string): DynamicModelBuilder;
143
+ relation(
144
+ name: string,
145
+ options: {
146
+ toModel: string;
147
+ toTable: string;
148
+ cardinality: '1:1' | '1:N' | 'N:1';
149
+ on: {
150
+ parentTable: string;
151
+ parentColumns: readonly string[];
152
+ childTable: string;
153
+ childColumns: readonly string[];
154
+ };
155
+ },
156
+ ): DynamicModelBuilder;
98
157
  };
99
158
 
100
159
  type DynamicContractBuilder = {
@@ -124,8 +183,11 @@ function lowerFirst(value: string): string {
124
183
  return value[0]?.toLowerCase() + value.slice(1);
125
184
  }
126
185
 
127
- function getAttribute(attributes: readonly PslAttribute[], name: string): PslAttribute | undefined {
128
- return attributes.find((attribute) => attribute.name === name);
186
+ function getAttribute(
187
+ attributes: readonly PslAttribute[] | undefined,
188
+ name: string,
189
+ ): PslAttribute | undefined {
190
+ return attributes?.find((attribute) => attribute.name === name);
129
191
  }
130
192
 
131
193
  function getNamedArgument(attribute: PslAttribute, name: string): string | undefined {
@@ -145,6 +207,21 @@ function getPositionalArgument(attribute: PslAttribute, index = 0): string | und
145
207
  return entry.value;
146
208
  }
147
209
 
210
+ function getPositionalArgumentEntry(
211
+ attribute: PslAttribute,
212
+ index = 0,
213
+ ): { value: string; span: PslSpan } | undefined {
214
+ const entries = attribute.args.filter((arg) => arg.kind === 'positional');
215
+ const entry = entries[index];
216
+ if (!entry || entry.kind !== 'positional') {
217
+ return undefined;
218
+ }
219
+ return {
220
+ value: entry.value,
221
+ span: entry.span,
222
+ };
223
+ }
224
+
148
225
  function unquoteStringLiteral(value: string): string {
149
226
  const trimmed = value.trim();
150
227
  const match = trimmed.match(/^(['"])(.*)\1$/);
@@ -156,6 +233,8 @@ function unquoteStringLiteral(value: string): string {
156
233
 
157
234
  function parseQuotedStringLiteral(value: string): string | undefined {
158
235
  const trimmed = value.trim();
236
+ // This intentionally accepts either '...' or "..." and relies on PSL's
237
+ // own string literal rules to disallow unescaped interior delimiters.
159
238
  const match = trimmed.match(/^(['"])(.*)\1$/);
160
239
  if (!match) {
161
240
  return undefined;
@@ -176,14 +255,8 @@ function parseFieldList(value: string): readonly string[] | undefined {
176
255
  return parts;
177
256
  }
178
257
 
179
- function parseDefaultValueExpression(expression: string): ColumnDefault | undefined {
258
+ function parseDefaultLiteralValue(expression: string): ColumnDefault | undefined {
180
259
  const trimmed = expression.trim();
181
- if (trimmed === 'autoincrement()') {
182
- return { kind: 'function', expression: 'autoincrement()' };
183
- }
184
- if (trimmed === 'now()') {
185
- return { kind: 'function', expression: 'now()' };
186
- }
187
260
  if (trimmed === 'true' || trimmed === 'false') {
188
261
  return { kind: 'literal', value: trimmed === 'true' };
189
262
  }
@@ -197,6 +270,103 @@ function parseDefaultValueExpression(expression: string): ColumnDefault | undefi
197
270
  return undefined;
198
271
  }
199
272
 
273
+ function lowerDefaultForField(input: {
274
+ readonly modelName: string;
275
+ readonly fieldName: string;
276
+ readonly defaultAttribute: PslAttribute;
277
+ readonly columnDescriptor: ColumnDescriptor;
278
+ readonly generatorDescriptorById: ReadonlyMap<string, MutationDefaultGeneratorDescriptor>;
279
+ readonly sourceId: string;
280
+ readonly defaultFunctionRegistry: ControlMutationDefaultRegistry;
281
+ readonly diagnostics: ContractSourceDiagnostic[];
282
+ }): {
283
+ readonly defaultValue?: ColumnDefault;
284
+ readonly executionDefault?: ExecutionMutationDefaultValue;
285
+ } {
286
+ const positionalEntries = input.defaultAttribute.args.filter((arg) => arg.kind === 'positional');
287
+ const namedEntries = input.defaultAttribute.args.filter((arg) => arg.kind === 'named');
288
+
289
+ if (namedEntries.length > 0 || positionalEntries.length !== 1) {
290
+ input.diagnostics.push({
291
+ code: 'PSL_INVALID_DEFAULT_FUNCTION_ARGUMENT',
292
+ message: `Field "${input.modelName}.${input.fieldName}" requires exactly one positional @default(...) expression.`,
293
+ sourceId: input.sourceId,
294
+ span: input.defaultAttribute.span,
295
+ });
296
+ return {};
297
+ }
298
+
299
+ const expressionEntry = getPositionalArgumentEntry(input.defaultAttribute);
300
+ if (!expressionEntry) {
301
+ input.diagnostics.push({
302
+ code: 'PSL_INVALID_DEFAULT_FUNCTION_ARGUMENT',
303
+ message: `Field "${input.modelName}.${input.fieldName}" requires a positional @default(...) expression.`,
304
+ sourceId: input.sourceId,
305
+ span: input.defaultAttribute.span,
306
+ });
307
+ return {};
308
+ }
309
+
310
+ const literalDefault = parseDefaultLiteralValue(expressionEntry.value);
311
+ if (literalDefault) {
312
+ return { defaultValue: literalDefault };
313
+ }
314
+
315
+ const defaultFunctionCall = parseDefaultFunctionCall(expressionEntry.value, expressionEntry.span);
316
+ if (!defaultFunctionCall) {
317
+ input.diagnostics.push({
318
+ code: 'PSL_INVALID_DEFAULT_VALUE',
319
+ message: `Unsupported default value "${expressionEntry.value}"`,
320
+ sourceId: input.sourceId,
321
+ span: input.defaultAttribute.span,
322
+ });
323
+ return {};
324
+ }
325
+
326
+ const lowered = lowerDefaultFunctionWithRegistry({
327
+ call: defaultFunctionCall,
328
+ registry: input.defaultFunctionRegistry,
329
+ context: {
330
+ sourceId: input.sourceId,
331
+ modelName: input.modelName,
332
+ fieldName: input.fieldName,
333
+ columnCodecId: input.columnDescriptor.codecId,
334
+ },
335
+ });
336
+
337
+ if (!lowered.ok) {
338
+ input.diagnostics.push(lowered.diagnostic);
339
+ return {};
340
+ }
341
+
342
+ if (lowered.value.kind === 'storage') {
343
+ return { defaultValue: lowered.value.defaultValue };
344
+ }
345
+
346
+ const generatorDescriptor = input.generatorDescriptorById.get(lowered.value.generated.id);
347
+ if (!generatorDescriptor) {
348
+ input.diagnostics.push({
349
+ code: 'PSL_INVALID_DEFAULT_APPLICABILITY',
350
+ message: `Default generator "${lowered.value.generated.id}" is not available in the composed mutation default registry.`,
351
+ sourceId: input.sourceId,
352
+ span: expressionEntry.span,
353
+ });
354
+ return {};
355
+ }
356
+
357
+ if (!generatorDescriptor.applicableCodecIds.includes(input.columnDescriptor.codecId)) {
358
+ input.diagnostics.push({
359
+ code: 'PSL_INVALID_DEFAULT_APPLICABILITY',
360
+ message: `Default generator "${generatorDescriptor.id}" is not applicable to "${input.modelName}.${input.fieldName}" with codecId "${input.columnDescriptor.codecId}".`,
361
+ sourceId: input.sourceId,
362
+ span: expressionEntry.span,
363
+ });
364
+ return {};
365
+ }
366
+
367
+ return { executionDefault: lowered.value.generated };
368
+ }
369
+
200
370
  function parseMapName(input: {
201
371
  readonly attribute: PslAttribute | undefined;
202
372
  readonly defaultValue: string;
@@ -263,10 +433,313 @@ function parsePgvectorLength(input: {
263
433
  return parsed;
264
434
  }
265
435
 
436
+ function getPositionalArguments(attribute: PslAttribute): readonly string[] {
437
+ return attribute.args
438
+ .filter((arg) => arg.kind === 'positional')
439
+ .map((arg) => (arg.kind === 'positional' ? arg.value : ''));
440
+ }
441
+
442
+ function pushInvalidAttributeArgument(input: {
443
+ readonly diagnostics: ContractSourceDiagnostic[];
444
+ readonly sourceId: string;
445
+ readonly span: PslSpan;
446
+ readonly message: string;
447
+ }): undefined {
448
+ input.diagnostics.push({
449
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
450
+ message: input.message,
451
+ sourceId: input.sourceId,
452
+ span: input.span,
453
+ });
454
+ return undefined;
455
+ }
456
+
457
+ function parseOptionalSingleIntegerArgument(input: {
458
+ readonly attribute: PslAttribute;
459
+ readonly diagnostics: ContractSourceDiagnostic[];
460
+ readonly sourceId: string;
461
+ readonly entityLabel: string;
462
+ readonly minimum: number;
463
+ readonly valueLabel: string;
464
+ }): number | null | undefined {
465
+ if (input.attribute.args.some((arg) => arg.kind === 'named')) {
466
+ return pushInvalidAttributeArgument({
467
+ diagnostics: input.diagnostics,
468
+ sourceId: input.sourceId,
469
+ span: input.attribute.span,
470
+ message: `${input.entityLabel} @${input.attribute.name} accepts zero or one positional integer argument.`,
471
+ });
472
+ }
473
+
474
+ const positionalArguments = getPositionalArguments(input.attribute);
475
+ if (positionalArguments.length > 1) {
476
+ return pushInvalidAttributeArgument({
477
+ diagnostics: input.diagnostics,
478
+ sourceId: input.sourceId,
479
+ span: input.attribute.span,
480
+ message: `${input.entityLabel} @${input.attribute.name} accepts zero or one positional integer argument.`,
481
+ });
482
+ }
483
+ if (positionalArguments.length === 0) {
484
+ return null;
485
+ }
486
+
487
+ const parsed = Number(unquoteStringLiteral(positionalArguments[0] ?? ''));
488
+ if (!Number.isInteger(parsed) || parsed < input.minimum) {
489
+ return pushInvalidAttributeArgument({
490
+ diagnostics: input.diagnostics,
491
+ sourceId: input.sourceId,
492
+ span: input.attribute.span,
493
+ message: `${input.entityLabel} @${input.attribute.name} requires a ${input.valueLabel}.`,
494
+ });
495
+ }
496
+
497
+ return parsed;
498
+ }
499
+
500
+ function parseOptionalNumericArguments(input: {
501
+ readonly attribute: PslAttribute;
502
+ readonly diagnostics: ContractSourceDiagnostic[];
503
+ readonly sourceId: string;
504
+ readonly entityLabel: string;
505
+ }): { precision: number; scale?: number } | null | undefined {
506
+ if (input.attribute.args.some((arg) => arg.kind === 'named')) {
507
+ return pushInvalidAttributeArgument({
508
+ diagnostics: input.diagnostics,
509
+ sourceId: input.sourceId,
510
+ span: input.attribute.span,
511
+ message: `${input.entityLabel} @${input.attribute.name} accepts zero, one, or two positional integer arguments.`,
512
+ });
513
+ }
514
+
515
+ const positionalArguments = getPositionalArguments(input.attribute);
516
+ if (positionalArguments.length > 2) {
517
+ return pushInvalidAttributeArgument({
518
+ diagnostics: input.diagnostics,
519
+ sourceId: input.sourceId,
520
+ span: input.attribute.span,
521
+ message: `${input.entityLabel} @${input.attribute.name} accepts zero, one, or two positional integer arguments.`,
522
+ });
523
+ }
524
+ if (positionalArguments.length === 0) {
525
+ return null;
526
+ }
527
+
528
+ const precision = Number(unquoteStringLiteral(positionalArguments[0] ?? ''));
529
+ if (!Number.isInteger(precision) || precision < 1) {
530
+ return pushInvalidAttributeArgument({
531
+ diagnostics: input.diagnostics,
532
+ sourceId: input.sourceId,
533
+ span: input.attribute.span,
534
+ message: `${input.entityLabel} @${input.attribute.name} requires a positive integer precision.`,
535
+ });
536
+ }
537
+
538
+ if (positionalArguments.length === 1) {
539
+ return { precision };
540
+ }
541
+
542
+ const scale = Number(unquoteStringLiteral(positionalArguments[1] ?? ''));
543
+ if (!Number.isInteger(scale) || scale < 0) {
544
+ return pushInvalidAttributeArgument({
545
+ diagnostics: input.diagnostics,
546
+ sourceId: input.sourceId,
547
+ span: input.attribute.span,
548
+ message: `${input.entityLabel} @${input.attribute.name} requires a non-negative integer scale.`,
549
+ });
550
+ }
551
+
552
+ return { precision, scale };
553
+ }
554
+
555
+ /**
556
+ * Declarative specification for @db.* native type attributes.
557
+ *
558
+ * Argument kinds:
559
+ * - `noArgs`: No arguments accepted; `codecId: null` means inherit from baseDescriptor.
560
+ * - `optionalLength`: Zero or one positional integer (minimum 1), stored as `{ length }`.
561
+ * - `optionalPrecision`: Zero or one positional integer (minimum 0), stored as `{ precision }`.
562
+ * - `optionalNumeric`: Zero, one, or two positional integers (precision + scale).
563
+ */
564
+ type NativeTypeSpec =
565
+ | {
566
+ readonly args: 'noArgs';
567
+ readonly baseType: string;
568
+ readonly codecId: string | null;
569
+ readonly nativeType: string;
570
+ }
571
+ | {
572
+ readonly args: 'optionalLength';
573
+ readonly baseType: string;
574
+ readonly codecId: string;
575
+ readonly nativeType: string;
576
+ }
577
+ | {
578
+ readonly args: 'optionalPrecision';
579
+ readonly baseType: string;
580
+ readonly codecId: string;
581
+ readonly nativeType: string;
582
+ }
583
+ | {
584
+ readonly args: 'optionalNumeric';
585
+ readonly baseType: string;
586
+ readonly codecId: string;
587
+ readonly nativeType: string;
588
+ };
589
+
590
+ const NATIVE_TYPE_SPECS: Readonly<Record<string, NativeTypeSpec>> = {
591
+ 'db.VarChar': {
592
+ args: 'optionalLength',
593
+ baseType: 'String',
594
+ codecId: 'sql/varchar@1',
595
+ nativeType: 'character varying',
596
+ },
597
+ 'db.Char': {
598
+ args: 'optionalLength',
599
+ baseType: 'String',
600
+ codecId: 'sql/char@1',
601
+ nativeType: 'character',
602
+ },
603
+ 'db.Uuid': { args: 'noArgs', baseType: 'String', codecId: null, nativeType: 'uuid' },
604
+ 'db.SmallInt': { args: 'noArgs', baseType: 'Int', codecId: 'pg/int2@1', nativeType: 'int2' },
605
+ 'db.Real': { args: 'noArgs', baseType: 'Float', codecId: 'pg/float4@1', nativeType: 'float4' },
606
+ 'db.Numeric': {
607
+ args: 'optionalNumeric',
608
+ baseType: 'Decimal',
609
+ codecId: 'pg/numeric@1',
610
+ nativeType: 'numeric',
611
+ },
612
+ 'db.Timestamp': {
613
+ args: 'optionalPrecision',
614
+ baseType: 'DateTime',
615
+ codecId: 'pg/timestamp@1',
616
+ nativeType: 'timestamp',
617
+ },
618
+ 'db.Timestamptz': {
619
+ args: 'optionalPrecision',
620
+ baseType: 'DateTime',
621
+ codecId: 'pg/timestamptz@1',
622
+ nativeType: 'timestamptz',
623
+ },
624
+ 'db.Date': { args: 'noArgs', baseType: 'DateTime', codecId: null, nativeType: 'date' },
625
+ 'db.Time': {
626
+ args: 'optionalPrecision',
627
+ baseType: 'DateTime',
628
+ codecId: 'pg/time@1',
629
+ nativeType: 'time',
630
+ },
631
+ 'db.Timetz': {
632
+ args: 'optionalPrecision',
633
+ baseType: 'DateTime',
634
+ codecId: 'pg/timetz@1',
635
+ nativeType: 'timetz',
636
+ },
637
+ 'db.Json': { args: 'noArgs', baseType: 'Json', codecId: 'pg/json@1', nativeType: 'json' },
638
+ };
639
+
640
+ function resolveDbNativeTypeAttribute(input: {
641
+ readonly attribute: PslAttribute;
642
+ readonly baseType: string;
643
+ readonly baseDescriptor: ColumnDescriptor;
644
+ readonly diagnostics: ContractSourceDiagnostic[];
645
+ readonly sourceId: string;
646
+ readonly entityLabel: string;
647
+ }): ColumnDescriptor | undefined {
648
+ const spec = NATIVE_TYPE_SPECS[input.attribute.name];
649
+ if (!spec) {
650
+ input.diagnostics.push({
651
+ code: 'PSL_UNSUPPORTED_NAMED_TYPE_ATTRIBUTE',
652
+ message: `${input.entityLabel} uses unsupported attribute "@${input.attribute.name}"`,
653
+ sourceId: input.sourceId,
654
+ span: input.attribute.span,
655
+ });
656
+ return undefined;
657
+ }
658
+
659
+ if (input.baseType !== spec.baseType) {
660
+ return pushInvalidAttributeArgument({
661
+ diagnostics: input.diagnostics,
662
+ sourceId: input.sourceId,
663
+ span: input.attribute.span,
664
+ message: `${input.entityLabel} uses @${input.attribute.name} on unsupported base type "${input.baseType}". Expected "${spec.baseType}".`,
665
+ });
666
+ }
667
+
668
+ switch (spec.args) {
669
+ case 'noArgs': {
670
+ if (getPositionalArguments(input.attribute).length > 0 || input.attribute.args.length > 0) {
671
+ return pushInvalidAttributeArgument({
672
+ diagnostics: input.diagnostics,
673
+ sourceId: input.sourceId,
674
+ span: input.attribute.span,
675
+ message: `${input.entityLabel} @${input.attribute.name} does not accept arguments.`,
676
+ });
677
+ }
678
+ return {
679
+ codecId: spec.codecId ?? input.baseDescriptor.codecId,
680
+ nativeType: spec.nativeType,
681
+ };
682
+ }
683
+ case 'optionalLength': {
684
+ const length = parseOptionalSingleIntegerArgument({
685
+ attribute: input.attribute,
686
+ diagnostics: input.diagnostics,
687
+ sourceId: input.sourceId,
688
+ entityLabel: input.entityLabel,
689
+ minimum: 1,
690
+ valueLabel: 'positive integer length',
691
+ });
692
+ if (length === undefined) {
693
+ return undefined;
694
+ }
695
+ return {
696
+ codecId: spec.codecId,
697
+ nativeType: spec.nativeType,
698
+ ...(length === null ? {} : { typeParams: { length } }),
699
+ };
700
+ }
701
+ case 'optionalPrecision': {
702
+ const precision = parseOptionalSingleIntegerArgument({
703
+ attribute: input.attribute,
704
+ diagnostics: input.diagnostics,
705
+ sourceId: input.sourceId,
706
+ entityLabel: input.entityLabel,
707
+ minimum: 0,
708
+ valueLabel: 'non-negative integer precision',
709
+ });
710
+ if (precision === undefined) {
711
+ return undefined;
712
+ }
713
+ return {
714
+ codecId: spec.codecId,
715
+ nativeType: spec.nativeType,
716
+ ...(precision === null ? {} : { typeParams: { precision } }),
717
+ };
718
+ }
719
+ case 'optionalNumeric': {
720
+ const numeric = parseOptionalNumericArguments({
721
+ attribute: input.attribute,
722
+ diagnostics: input.diagnostics,
723
+ sourceId: input.sourceId,
724
+ entityLabel: input.entityLabel,
725
+ });
726
+ if (numeric === undefined) {
727
+ return undefined;
728
+ }
729
+ return {
730
+ codecId: spec.codecId,
731
+ nativeType: spec.nativeType,
732
+ ...(numeric === null ? {} : { typeParams: numeric }),
733
+ };
734
+ }
735
+ }
736
+ }
737
+
266
738
  function resolveColumnDescriptor(
267
739
  field: PslField,
268
740
  enumTypeDescriptors: Map<string, ColumnDescriptor>,
269
741
  namedTypeDescriptors: Map<string, ColumnDescriptor>,
742
+ scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>,
270
743
  ): ColumnDescriptor | undefined {
271
744
  if (field.typeRef && namedTypeDescriptors.has(field.typeRef)) {
272
745
  return namedTypeDescriptors.get(field.typeRef);
@@ -277,7 +750,7 @@ function resolveColumnDescriptor(
277
750
  if (enumTypeDescriptors.has(field.typeName)) {
278
751
  return enumTypeDescriptors.get(field.typeName);
279
752
  }
280
- return SCALAR_COLUMN_MAP[field.typeName];
753
+ return scalarTypeDescriptors.get(field.typeName);
281
754
  }
282
755
 
283
756
  function collectResolvedFields(
@@ -288,16 +761,22 @@ function collectResolvedFields(
288
761
  namedTypeBaseTypes: Map<string, string>,
289
762
  modelNames: Set<string>,
290
763
  composedExtensions: Set<string>,
764
+ defaultFunctionRegistry: ControlMutationDefaultRegistry,
765
+ generatorDescriptorById: ReadonlyMap<string, MutationDefaultGeneratorDescriptor>,
291
766
  diagnostics: ContractSourceDiagnostic[],
292
767
  sourceId: string,
768
+ scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>,
293
769
  ): ResolvedField[] {
294
770
  const resolvedFields: ResolvedField[] = [];
295
771
 
296
772
  for (const field of model.fields) {
297
773
  if (field.list) {
774
+ if (modelNames.has(field.typeName)) {
775
+ continue;
776
+ }
298
777
  diagnostics.push({
299
778
  code: 'PSL_UNSUPPORTED_FIELD_LIST',
300
- message: `Field "${model.name}.${field.name}" uses list types, which are not supported in SQL PSL provider v1`,
779
+ 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.`,
301
780
  sourceId,
302
781
  span: field.span,
303
782
  });
@@ -337,7 +816,12 @@ function collectResolvedFields(
337
816
  continue;
338
817
  }
339
818
 
340
- let descriptor = resolveColumnDescriptor(field, enumTypeDescriptors, namedTypeDescriptors);
819
+ let descriptor = resolveColumnDescriptor(
820
+ field,
821
+ enumTypeDescriptors,
822
+ namedTypeDescriptors,
823
+ scalarTypeDescriptors,
824
+ );
341
825
  const pgvectorColumnAttribute = getAttribute(field.attributes, 'pgvector.column');
342
826
  if (pgvectorColumnAttribute) {
343
827
  if (!composedExtensions.has('pgvector')) {
@@ -368,7 +852,7 @@ function collectResolvedFields(
368
852
  if (length !== undefined) {
369
853
  descriptor = {
370
854
  codecId: 'pg/vector@1',
371
- nativeType: `vector(${length})`,
855
+ nativeType: 'vector',
372
856
  typeParams: { length },
373
857
  };
374
858
  }
@@ -387,22 +871,47 @@ function collectResolvedFields(
387
871
  }
388
872
 
389
873
  const defaultAttribute = getAttribute(field.attributes, 'default');
390
- const defaultValueRaw = defaultAttribute ? getPositionalArgument(defaultAttribute) : undefined;
391
- const defaultValue = defaultValueRaw ? parseDefaultValueExpression(defaultValueRaw) : undefined;
392
- if (defaultAttribute && defaultValueRaw && !defaultValue) {
874
+ const loweredDefault = defaultAttribute
875
+ ? lowerDefaultForField({
876
+ modelName: model.name,
877
+ fieldName: field.name,
878
+ defaultAttribute,
879
+ columnDescriptor: descriptor,
880
+ generatorDescriptorById,
881
+ sourceId,
882
+ defaultFunctionRegistry,
883
+ diagnostics,
884
+ })
885
+ : {};
886
+ if (field.optional && loweredDefault.executionDefault) {
887
+ const generatorDescription =
888
+ loweredDefault.executionDefault.kind === 'generator'
889
+ ? `"${loweredDefault.executionDefault.id}"`
890
+ : 'for this field';
393
891
  diagnostics.push({
394
- code: 'PSL_INVALID_DEFAULT_VALUE',
395
- message: `Unsupported default value "${defaultValueRaw}"`,
892
+ code: 'PSL_INVALID_DEFAULT_FUNCTION_ARGUMENT',
893
+ message: `Field "${model.name}.${field.name}" cannot be optional when using execution default ${generatorDescription}. Remove "?" or use a storage default.`,
396
894
  sourceId,
397
- span: defaultAttribute.span,
895
+ span: defaultAttribute?.span ?? field.span,
398
896
  });
897
+ continue;
898
+ }
899
+ if (loweredDefault.executionDefault) {
900
+ const generatorDescriptor = generatorDescriptorById.get(loweredDefault.executionDefault.id);
901
+ const generatedDescriptor = generatorDescriptor?.resolveGeneratedColumnDescriptor?.({
902
+ generated: loweredDefault.executionDefault,
903
+ });
904
+ if (generatedDescriptor) {
905
+ descriptor = generatedDescriptor;
906
+ }
399
907
  }
400
908
  const mappedColumnName = mapping.fieldColumns.get(field.name) ?? field.name;
401
909
  resolvedFields.push({
402
910
  field,
403
911
  columnName: mappedColumnName,
404
912
  descriptor,
405
- ...(defaultValue ? { defaultValue } : {}),
913
+ ...ifDefined('defaultValue', loweredDefault.defaultValue),
914
+ ...ifDefined('executionDefault', loweredDefault.executionDefault),
406
915
  isId: Boolean(getAttribute(field.attributes, 'id')),
407
916
  isUnique: Boolean(getAttribute(field.attributes, 'unique')),
408
917
  });
@@ -420,6 +929,155 @@ function hasSameSpan(a: PslSpan, b: ContractSourceDiagnosticSpan): boolean {
420
929
  );
421
930
  }
422
931
 
932
+ function compareStrings(left: string, right: string): -1 | 0 | 1 {
933
+ if (left < right) {
934
+ return -1;
935
+ }
936
+ if (left > right) {
937
+ return 1;
938
+ }
939
+ return 0;
940
+ }
941
+
942
+ function indexFkRelations(input: { readonly fkRelationMetadata: readonly FkRelationMetadata[] }): {
943
+ readonly modelRelations: Map<string, ModelRelationMetadata[]>;
944
+ readonly fkRelationsByPair: Map<string, FkRelationMetadata[]>;
945
+ } {
946
+ const modelRelations = new Map<string, ModelRelationMetadata[]>();
947
+ const fkRelationsByPair = new Map<string, FkRelationMetadata[]>();
948
+
949
+ for (const relation of input.fkRelationMetadata) {
950
+ const existing = modelRelations.get(relation.declaringModelName);
951
+ const current = existing ?? [];
952
+ if (!existing) {
953
+ modelRelations.set(relation.declaringModelName, current);
954
+ }
955
+ current.push({
956
+ fieldName: relation.declaringFieldName,
957
+ toModel: relation.targetModelName,
958
+ toTable: relation.targetTableName,
959
+ cardinality: 'N:1',
960
+ parentTable: relation.declaringTableName,
961
+ parentColumns: relation.localColumns,
962
+ childTable: relation.targetTableName,
963
+ childColumns: relation.referencedColumns,
964
+ });
965
+
966
+ const pairKey = fkRelationPairKey(relation.declaringModelName, relation.targetModelName);
967
+ const pairRelations = fkRelationsByPair.get(pairKey);
968
+ if (!pairRelations) {
969
+ fkRelationsByPair.set(pairKey, [relation]);
970
+ continue;
971
+ }
972
+ pairRelations.push(relation);
973
+ }
974
+
975
+ return { modelRelations, fkRelationsByPair };
976
+ }
977
+
978
+ function applyBackrelationCandidates(input: {
979
+ readonly backrelationCandidates: readonly ModelBackrelationCandidate[];
980
+ readonly fkRelationsByPair: Map<string, readonly FkRelationMetadata[]>;
981
+ readonly modelRelations: Map<string, ModelRelationMetadata[]>;
982
+ readonly diagnostics: ContractSourceDiagnostic[];
983
+ readonly sourceId: string;
984
+ }): void {
985
+ for (const candidate of input.backrelationCandidates) {
986
+ const pairKey = fkRelationPairKey(candidate.targetModelName, candidate.modelName);
987
+ const pairMatches = input.fkRelationsByPair.get(pairKey) ?? [];
988
+ const matches = candidate.relationName
989
+ ? pairMatches.filter((relation) => relation.relationName === candidate.relationName)
990
+ : [...pairMatches];
991
+
992
+ if (matches.length === 0) {
993
+ input.diagnostics.push({
994
+ code: 'PSL_ORPHANED_BACKRELATION_LIST',
995
+ 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.`,
996
+ sourceId: input.sourceId,
997
+ span: candidate.field.span,
998
+ });
999
+ continue;
1000
+ }
1001
+ if (matches.length > 1) {
1002
+ input.diagnostics.push({
1003
+ code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
1004
+ 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.`,
1005
+ sourceId: input.sourceId,
1006
+ span: candidate.field.span,
1007
+ });
1008
+ continue;
1009
+ }
1010
+
1011
+ invariant(matches.length === 1, 'Backrelation matching requires exactly one match');
1012
+ const matched = matches[0];
1013
+ assertDefined(matched, 'Backrelation matching requires a defined relation match');
1014
+
1015
+ const existing = input.modelRelations.get(candidate.modelName);
1016
+ const current = existing ?? [];
1017
+ if (!existing) {
1018
+ input.modelRelations.set(candidate.modelName, current);
1019
+ }
1020
+ current.push({
1021
+ fieldName: candidate.field.name,
1022
+ toModel: matched.declaringModelName,
1023
+ toTable: matched.declaringTableName,
1024
+ cardinality: '1:N',
1025
+ parentTable: candidate.tableName,
1026
+ parentColumns: matched.referencedColumns,
1027
+ childTable: matched.declaringTableName,
1028
+ childColumns: matched.localColumns,
1029
+ });
1030
+ }
1031
+ }
1032
+
1033
+ function emitModelsWithRelations(input: {
1034
+ readonly builder: DynamicContractBuilder;
1035
+ readonly resolvedModels: ResolvedModelEntry[];
1036
+ readonly modelRelations: Map<string, readonly ModelRelationMetadata[]>;
1037
+ }): DynamicContractBuilder {
1038
+ let nextBuilder = input.builder;
1039
+
1040
+ const sortedModels = input.resolvedModels.sort((left, right) => {
1041
+ const tableComparison = compareStrings(left.mapping.tableName, right.mapping.tableName);
1042
+ if (tableComparison === 0) {
1043
+ return compareStrings(left.model.name, right.model.name);
1044
+ }
1045
+ return tableComparison;
1046
+ });
1047
+
1048
+ for (const entry of sortedModels) {
1049
+ const relationEntries = [...(input.modelRelations.get(entry.model.name) ?? [])].sort(
1050
+ (left, right) => compareStrings(left.fieldName, right.fieldName),
1051
+ );
1052
+ nextBuilder = nextBuilder.model(
1053
+ entry.model.name,
1054
+ entry.mapping.tableName,
1055
+ (modelBuilder: DynamicModelBuilder) => {
1056
+ let next = modelBuilder;
1057
+ for (const resolvedField of entry.resolvedFields) {
1058
+ next = next.field(resolvedField.field.name, resolvedField.columnName);
1059
+ }
1060
+ for (const relation of relationEntries) {
1061
+ next = next.relation(relation.fieldName, {
1062
+ toModel: relation.toModel,
1063
+ toTable: relation.toTable,
1064
+ cardinality: relation.cardinality,
1065
+ on: {
1066
+ parentTable: relation.parentTable,
1067
+ parentColumns: relation.parentColumns,
1068
+ childTable: relation.childTable,
1069
+ childColumns: relation.childColumns,
1070
+ },
1071
+ });
1072
+ }
1073
+ return next;
1074
+ },
1075
+ );
1076
+ }
1077
+
1078
+ return nextBuilder;
1079
+ }
1080
+
423
1081
  function mapParserDiagnostics(document: ParsePslDocumentResult): ContractSourceDiagnostic[] {
424
1082
  return document.diagnostics.map((diagnostic) => ({
425
1083
  code: diagnostic.code,
@@ -547,33 +1205,82 @@ function buildModelMappings(
547
1205
  return result;
548
1206
  }
549
1207
 
1208
+ function validateNavigationListFieldAttributes(input: {
1209
+ readonly modelName: string;
1210
+ readonly field: PslField;
1211
+ readonly sourceId: string;
1212
+ readonly composedExtensions: Set<string>;
1213
+ readonly diagnostics: ContractSourceDiagnostic[];
1214
+ }): boolean {
1215
+ let valid = true;
1216
+ for (const attribute of input.field.attributes) {
1217
+ if (attribute.name === 'relation') {
1218
+ continue;
1219
+ }
1220
+ if (attribute.name.startsWith('pgvector.') && !input.composedExtensions.has('pgvector')) {
1221
+ input.diagnostics.push({
1222
+ code: 'PSL_EXTENSION_NAMESPACE_NOT_COMPOSED',
1223
+ message: `Attribute "@${attribute.name}" uses unrecognized namespace "pgvector". Add extension pack "pgvector" to extensionPacks in prisma-next.config.ts.`,
1224
+ sourceId: input.sourceId,
1225
+ span: attribute.span,
1226
+ });
1227
+ valid = false;
1228
+ continue;
1229
+ }
1230
+ input.diagnostics.push({
1231
+ code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE',
1232
+ message: `Field "${input.modelName}.${input.field.name}" uses unsupported attribute "@${attribute.name}"`,
1233
+ sourceId: input.sourceId,
1234
+ span: attribute.span,
1235
+ });
1236
+ valid = false;
1237
+ }
1238
+ return valid;
1239
+ }
1240
+
550
1241
  function parseRelationAttribute(input: {
551
1242
  readonly attribute: PslAttribute;
552
1243
  readonly modelName: string;
553
1244
  readonly fieldName: string;
554
1245
  readonly sourceId: string;
555
1246
  readonly diagnostics: ContractSourceDiagnostic[];
556
- }):
557
- | {
558
- readonly fields: readonly string[];
559
- readonly references: readonly string[];
560
- readonly onDelete?: string;
561
- readonly onUpdate?: string;
562
- }
563
- | undefined {
564
- for (const arg of input.attribute.args) {
565
- if (arg.kind === 'positional') {
1247
+ }): ParsedRelationAttribute | undefined {
1248
+ const positionalEntries = input.attribute.args.filter((arg) => arg.kind === 'positional');
1249
+ if (positionalEntries.length > 1) {
1250
+ input.diagnostics.push({
1251
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1252
+ message: `Relation field "${input.modelName}.${input.fieldName}" has too many positional arguments`,
1253
+ sourceId: input.sourceId,
1254
+ span: input.attribute.span,
1255
+ });
1256
+ return undefined;
1257
+ }
1258
+
1259
+ let relationNameFromPositional: string | undefined;
1260
+ const positionalNameEntry = getPositionalArgumentEntry(input.attribute);
1261
+ if (positionalNameEntry) {
1262
+ const parsedName = parseQuotedStringLiteral(positionalNameEntry.value);
1263
+ if (!parsedName) {
566
1264
  input.diagnostics.push({
567
1265
  code: 'PSL_INVALID_RELATION_ATTRIBUTE',
568
- message: `Relation field "${input.modelName}.${input.fieldName}" must use named arguments`,
1266
+ message: `Relation field "${input.modelName}.${input.fieldName}" positional relation name must be a quoted string literal`,
569
1267
  sourceId: input.sourceId,
570
- span: arg.span,
1268
+ span: positionalNameEntry.span,
571
1269
  });
572
1270
  return undefined;
573
1271
  }
1272
+ relationNameFromPositional = parsedName;
1273
+ }
1274
+
1275
+ for (const arg of input.attribute.args) {
1276
+ if (arg.kind === 'positional') {
1277
+ continue;
1278
+ }
574
1279
  if (
1280
+ arg.name !== 'name' &&
575
1281
  arg.name !== 'fields' &&
576
1282
  arg.name !== 'references' &&
1283
+ arg.name !== 'map' &&
577
1284
  arg.name !== 'onDelete' &&
578
1285
  arg.name !== 'onUpdate'
579
1286
  ) {
@@ -587,58 +1294,151 @@ function parseRelationAttribute(input: {
587
1294
  }
588
1295
  }
589
1296
 
590
- const fieldsRaw = getNamedArgument(input.attribute, 'fields');
591
- const referencesRaw = getNamedArgument(input.attribute, 'references');
592
- if (!fieldsRaw || !referencesRaw) {
1297
+ const namedRelationNameRaw = getNamedArgument(input.attribute, 'name');
1298
+ const namedRelationName = namedRelationNameRaw
1299
+ ? parseQuotedStringLiteral(namedRelationNameRaw)
1300
+ : undefined;
1301
+ if (namedRelationNameRaw && !namedRelationName) {
593
1302
  input.diagnostics.push({
594
1303
  code: 'PSL_INVALID_RELATION_ATTRIBUTE',
595
- message: `Relation field "${input.modelName}.${input.fieldName}" requires fields and references arguments`,
1304
+ message: `Relation field "${input.modelName}.${input.fieldName}" named relation name must be a quoted string literal`,
596
1305
  sourceId: input.sourceId,
597
1306
  span: input.attribute.span,
598
1307
  });
599
1308
  return undefined;
600
1309
  }
601
- const fields = parseFieldList(fieldsRaw);
602
- const references = parseFieldList(referencesRaw);
603
- if (!fields || !references || fields.length === 0 || references.length === 0) {
1310
+
1311
+ if (
1312
+ relationNameFromPositional &&
1313
+ namedRelationName &&
1314
+ relationNameFromPositional !== namedRelationName
1315
+ ) {
1316
+ input.diagnostics.push({
1317
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1318
+ message: `Relation field "${input.modelName}.${input.fieldName}" has conflicting positional and named relation names`,
1319
+ sourceId: input.sourceId,
1320
+ span: input.attribute.span,
1321
+ });
1322
+ return undefined;
1323
+ }
1324
+ const relationName = namedRelationName ?? relationNameFromPositional;
1325
+
1326
+ const constraintNameRaw = getNamedArgument(input.attribute, 'map');
1327
+ const constraintName = constraintNameRaw
1328
+ ? parseQuotedStringLiteral(constraintNameRaw)
1329
+ : undefined;
1330
+ if (constraintNameRaw && !constraintName) {
1331
+ input.diagnostics.push({
1332
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1333
+ message: `Relation field "${input.modelName}.${input.fieldName}" map argument must be a quoted string literal`,
1334
+ sourceId: input.sourceId,
1335
+ span: input.attribute.span,
1336
+ });
1337
+ return undefined;
1338
+ }
1339
+
1340
+ const fieldsRaw = getNamedArgument(input.attribute, 'fields');
1341
+ const referencesRaw = getNamedArgument(input.attribute, 'references');
1342
+ if ((fieldsRaw && !referencesRaw) || (!fieldsRaw && referencesRaw)) {
604
1343
  input.diagnostics.push({
605
1344
  code: 'PSL_INVALID_RELATION_ATTRIBUTE',
606
- message: `Relation field "${input.modelName}.${input.fieldName}" requires bracketed fields and references lists`,
1345
+ message: `Relation field "${input.modelName}.${input.fieldName}" requires fields and references arguments`,
607
1346
  sourceId: input.sourceId,
608
1347
  span: input.attribute.span,
609
1348
  });
610
1349
  return undefined;
611
1350
  }
612
1351
 
1352
+ let fields: readonly string[] | undefined;
1353
+ let references: readonly string[] | undefined;
1354
+ if (fieldsRaw && referencesRaw) {
1355
+ const parsedFields = parseFieldList(fieldsRaw);
1356
+ const parsedReferences = parseFieldList(referencesRaw);
1357
+ if (
1358
+ !parsedFields ||
1359
+ !parsedReferences ||
1360
+ parsedFields.length === 0 ||
1361
+ parsedReferences.length === 0
1362
+ ) {
1363
+ input.diagnostics.push({
1364
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1365
+ message: `Relation field "${input.modelName}.${input.fieldName}" requires bracketed fields and references lists`,
1366
+ sourceId: input.sourceId,
1367
+ span: input.attribute.span,
1368
+ });
1369
+ return undefined;
1370
+ }
1371
+ fields = parsedFields;
1372
+ references = parsedReferences;
1373
+ }
1374
+
1375
+ const onDeleteArgument = getNamedArgument(input.attribute, 'onDelete');
1376
+ const onUpdateArgument = getNamedArgument(input.attribute, 'onUpdate');
1377
+
613
1378
  return {
614
- fields,
615
- references,
616
- ...(getNamedArgument(input.attribute, 'onDelete')
617
- ? { onDelete: unquoteStringLiteral(getNamedArgument(input.attribute, 'onDelete') ?? '') }
618
- : {}),
619
- ...(getNamedArgument(input.attribute, 'onUpdate')
620
- ? { onUpdate: unquoteStringLiteral(getNamedArgument(input.attribute, 'onUpdate') ?? '') }
621
- : {}),
1379
+ ...ifDefined('relationName', relationName),
1380
+ ...ifDefined('fields', fields),
1381
+ ...ifDefined('references', references),
1382
+ ...ifDefined('constraintName', constraintName),
1383
+ ...ifDefined('onDelete', onDeleteArgument ? unquoteStringLiteral(onDeleteArgument) : undefined),
1384
+ ...ifDefined('onUpdate', onUpdateArgument ? unquoteStringLiteral(onUpdateArgument) : undefined),
622
1385
  };
623
1386
  }
624
1387
 
625
1388
  export function interpretPslDocumentToSqlContractIR(
626
1389
  input: InterpretPslDocumentToSqlContractIRInput,
627
1390
  ): Result<ContractIR, ContractSourceDiagnostics> {
1391
+ const sourceId = input.document.ast.sourceId;
1392
+ if (!input.target) {
1393
+ return notOk({
1394
+ summary: 'PSL to SQL Contract IR normalization failed',
1395
+ diagnostics: [
1396
+ {
1397
+ code: 'PSL_TARGET_CONTEXT_REQUIRED',
1398
+ message: 'PSL interpretation requires an explicit target context from composition.',
1399
+ sourceId,
1400
+ },
1401
+ ],
1402
+ });
1403
+ }
1404
+ if (!input.scalarTypeDescriptors) {
1405
+ return notOk({
1406
+ summary: 'PSL to SQL Contract IR normalization failed',
1407
+ diagnostics: [
1408
+ {
1409
+ code: 'PSL_SCALAR_TYPE_CONTEXT_REQUIRED',
1410
+ message: 'PSL interpretation requires composed scalar type descriptors.',
1411
+ sourceId,
1412
+ },
1413
+ ],
1414
+ });
1415
+ }
1416
+
628
1417
  const diagnostics: ContractSourceDiagnostic[] = mapParserDiagnostics(input.document);
629
1418
  const modelNames = new Set(input.document.ast.models.map((model) => model.name));
630
- const sourceId = input.document.ast.sourceId;
631
1419
  const composedExtensions = new Set(input.composedExtensionPacks ?? []);
1420
+ const defaultFunctionRegistry =
1421
+ input.controlMutationDefaults?.defaultFunctionRegistry ?? new Map<string, never>();
1422
+ const generatorDescriptors = input.controlMutationDefaults?.generatorDescriptors ?? [];
1423
+ const generatorDescriptorById = new Map<string, MutationDefaultGeneratorDescriptor>();
1424
+ for (const descriptor of generatorDescriptors) {
1425
+ generatorDescriptorById.set(descriptor.id, descriptor);
1426
+ }
632
1427
 
633
- let builder = defineContract().target(
634
- input.target ?? DEFAULT_POSTGRES_TARGET,
635
- ) as unknown as DynamicContractBuilder;
1428
+ let builder = defineContract().target(input.target) as DynamicContractBuilder;
636
1429
  const enumTypeDescriptors = new Map<string, ColumnDescriptor>();
637
1430
  const namedTypeDescriptors = new Map<string, ColumnDescriptor>();
638
1431
  const namedTypeBaseTypes = new Map<string, string>();
639
1432
 
640
1433
  for (const enumDeclaration of input.document.ast.enums) {
641
- const nativeType = enumDeclaration.name.toLowerCase();
1434
+ const nativeType = parseMapName({
1435
+ attribute: getAttribute(enumDeclaration.attributes, 'map'),
1436
+ defaultValue: enumDeclaration.name,
1437
+ sourceId,
1438
+ diagnostics,
1439
+ entityLabel: `Enum "${enumDeclaration.name}"`,
1440
+ span: enumDeclaration.span,
1441
+ });
642
1442
  const descriptor: ColumnDescriptor = {
643
1443
  codecId: 'pg/enum@1',
644
1444
  nativeType,
@@ -654,7 +1454,8 @@ export function interpretPslDocumentToSqlContractIR(
654
1454
 
655
1455
  for (const declaration of input.document.ast.types?.declarations ?? []) {
656
1456
  const baseDescriptor =
657
- enumTypeDescriptors.get(declaration.baseType) ?? SCALAR_COLUMN_MAP[declaration.baseType];
1457
+ enumTypeDescriptors.get(declaration.baseType) ??
1458
+ input.scalarTypeDescriptors.get(declaration.baseType);
658
1459
  if (!baseDescriptor) {
659
1460
  diagnostics.push({
660
1461
  code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE',
@@ -667,8 +1468,11 @@ export function interpretPslDocumentToSqlContractIR(
667
1468
  namedTypeBaseTypes.set(declaration.name, declaration.baseType);
668
1469
 
669
1470
  const pgvectorAttribute = getAttribute(declaration.attributes, 'pgvector.column');
1471
+ const dbNativeTypeAttribute = declaration.attributes.find((attribute) =>
1472
+ attribute.name.startsWith('db.'),
1473
+ );
670
1474
  const unsupportedNamedTypeAttribute = declaration.attributes.find(
671
- (attribute) => attribute.name !== 'pgvector.column',
1475
+ (attribute) => attribute.name !== 'pgvector.column' && !attribute.name.startsWith('db.'),
672
1476
  );
673
1477
  if (unsupportedNamedTypeAttribute) {
674
1478
  diagnostics.push({
@@ -680,6 +1484,16 @@ export function interpretPslDocumentToSqlContractIR(
680
1484
  continue;
681
1485
  }
682
1486
 
1487
+ if (pgvectorAttribute && dbNativeTypeAttribute) {
1488
+ diagnostics.push({
1489
+ code: 'PSL_UNSUPPORTED_NAMED_TYPE_ATTRIBUTE',
1490
+ message: `Named type "${declaration.name}" cannot combine @pgvector.column with @${dbNativeTypeAttribute.name}.`,
1491
+ sourceId,
1492
+ span: dbNativeTypeAttribute.span,
1493
+ });
1494
+ continue;
1495
+ }
1496
+
683
1497
  if (pgvectorAttribute) {
684
1498
  if (!composedExtensions.has('pgvector')) {
685
1499
  diagnostics.push({
@@ -710,17 +1524,41 @@ export function interpretPslDocumentToSqlContractIR(
710
1524
  }
711
1525
  namedTypeDescriptors.set(declaration.name, {
712
1526
  codecId: 'pg/vector@1',
713
- nativeType: `vector(${length})`,
714
- typeRef: declaration.name,
1527
+ nativeType: 'vector',
1528
+ typeParams: { length },
715
1529
  });
716
1530
  builder = builder.storageType(declaration.name, {
717
1531
  codecId: 'pg/vector@1',
718
- nativeType: `vector(${length})`,
1532
+ nativeType: 'vector',
719
1533
  typeParams: { length },
720
1534
  });
721
1535
  continue;
722
1536
  }
723
1537
 
1538
+ if (dbNativeTypeAttribute) {
1539
+ const descriptor = resolveDbNativeTypeAttribute({
1540
+ attribute: dbNativeTypeAttribute,
1541
+ baseType: declaration.baseType,
1542
+ baseDescriptor,
1543
+ diagnostics,
1544
+ sourceId,
1545
+ entityLabel: `Named type "${declaration.name}"`,
1546
+ });
1547
+ if (!descriptor) {
1548
+ continue;
1549
+ }
1550
+ namedTypeDescriptors.set(declaration.name, {
1551
+ ...descriptor,
1552
+ typeRef: declaration.name,
1553
+ });
1554
+ builder = builder.storageType(declaration.name, {
1555
+ codecId: descriptor.codecId,
1556
+ nativeType: descriptor.nativeType,
1557
+ typeParams: descriptor.typeParams ?? {},
1558
+ });
1559
+ continue;
1560
+ }
1561
+
724
1562
  const descriptor: ColumnDescriptor = {
725
1563
  codecId: baseDescriptor.codecId,
726
1564
  nativeType: baseDescriptor.nativeType,
@@ -735,6 +1573,13 @@ export function interpretPslDocumentToSqlContractIR(
735
1573
  }
736
1574
 
737
1575
  const modelMappings = buildModelMappings(input.document.ast.models, diagnostics, sourceId);
1576
+ const resolvedModels: Array<{
1577
+ model: PslModel;
1578
+ mapping: ModelNameMapping;
1579
+ resolvedFields: ResolvedField[];
1580
+ }> = [];
1581
+ const fkRelationMetadata: FkRelationMetadata[] = [];
1582
+ const backrelationCandidates: ModelBackrelationCandidate[] = [];
738
1583
 
739
1584
  for (const model of input.document.ast.models) {
740
1585
  const mapping = modelMappings.get(model.name);
@@ -750,9 +1595,13 @@ export function interpretPslDocumentToSqlContractIR(
750
1595
  namedTypeBaseTypes,
751
1596
  modelNames,
752
1597
  composedExtensions,
1598
+ defaultFunctionRegistry,
1599
+ generatorDescriptorById,
753
1600
  diagnostics,
754
1601
  sourceId,
1602
+ input.scalarTypeDescriptors,
755
1603
  );
1604
+ resolvedModels.push({ model, mapping, resolvedFields });
756
1605
 
757
1606
  const primaryKeyColumns = resolvedFields
758
1607
  .filter((field) => field.isId)
@@ -766,6 +1615,63 @@ export function interpretPslDocumentToSqlContractIR(
766
1615
  });
767
1616
  }
768
1617
 
1618
+ for (const field of model.fields) {
1619
+ if (!field.list || !modelNames.has(field.typeName)) {
1620
+ continue;
1621
+ }
1622
+ const attributesValid = validateNavigationListFieldAttributes({
1623
+ modelName: model.name,
1624
+ field,
1625
+ sourceId,
1626
+ composedExtensions,
1627
+ diagnostics,
1628
+ });
1629
+ const relationAttribute = getAttribute(field.attributes, 'relation');
1630
+ let relationName: string | undefined;
1631
+ if (relationAttribute) {
1632
+ const parsedRelation = parseRelationAttribute({
1633
+ attribute: relationAttribute,
1634
+ modelName: model.name,
1635
+ fieldName: field.name,
1636
+ sourceId,
1637
+ diagnostics,
1638
+ });
1639
+ if (!parsedRelation) {
1640
+ continue;
1641
+ }
1642
+ if (parsedRelation.fields || parsedRelation.references) {
1643
+ diagnostics.push({
1644
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1645
+ message: `Backrelation list field "${model.name}.${field.name}" cannot declare fields/references; define them on the FK-side relation field`,
1646
+ sourceId,
1647
+ span: relationAttribute.span,
1648
+ });
1649
+ continue;
1650
+ }
1651
+ if (parsedRelation.onDelete || parsedRelation.onUpdate) {
1652
+ diagnostics.push({
1653
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1654
+ message: `Backrelation list field "${model.name}.${field.name}" cannot declare onDelete/onUpdate; define referential actions on the FK-side relation field`,
1655
+ sourceId,
1656
+ span: relationAttribute.span,
1657
+ });
1658
+ continue;
1659
+ }
1660
+ relationName = parsedRelation.relationName;
1661
+ }
1662
+ if (!attributesValid) {
1663
+ continue;
1664
+ }
1665
+
1666
+ backrelationCandidates.push({
1667
+ modelName: model.name,
1668
+ tableName,
1669
+ field,
1670
+ targetModelName: field.typeName,
1671
+ ...ifDefined('relationName', relationName),
1672
+ });
1673
+ }
1674
+
769
1675
  const relationAttributes = model.fields
770
1676
  .map((field) => ({
771
1677
  field,
@@ -779,16 +1685,23 @@ export function interpretPslDocumentToSqlContractIR(
779
1685
  let table = tableBuilder;
780
1686
 
781
1687
  for (const resolvedField of resolvedFields) {
782
- const options: {
783
- type: ColumnDescriptor;
784
- nullable?: true;
785
- default?: ColumnDefault;
786
- } = {
787
- type: resolvedField.descriptor,
788
- ...(resolvedField.field.optional ? { nullable: true as const } : {}),
789
- ...(resolvedField.defaultValue ? { default: resolvedField.defaultValue } : {}),
790
- };
791
- table = table.column(resolvedField.columnName, options);
1688
+ if (resolvedField.executionDefault) {
1689
+ table = table.generated(resolvedField.columnName, {
1690
+ type: resolvedField.descriptor,
1691
+ generated: resolvedField.executionDefault,
1692
+ });
1693
+ } else {
1694
+ const options: {
1695
+ type: ColumnDescriptor;
1696
+ nullable?: true;
1697
+ default?: ColumnDefault;
1698
+ } = {
1699
+ type: resolvedField.descriptor,
1700
+ ...ifDefined('nullable', resolvedField.field.optional ? (true as const) : undefined),
1701
+ ...ifDefined('default', resolvedField.defaultValue),
1702
+ };
1703
+ table = table.column(resolvedField.columnName, options);
1704
+ }
792
1705
 
793
1706
  if (resolvedField.isUnique) {
794
1707
  table = table.unique([resolvedField.columnName]);
@@ -851,6 +1764,10 @@ export function interpretPslDocumentToSqlContractIR(
851
1764
  }
852
1765
 
853
1766
  for (const relationAttribute of relationAttributes) {
1767
+ if (relationAttribute.field.list) {
1768
+ continue;
1769
+ }
1770
+
854
1771
  if (!modelNames.has(relationAttribute.field.typeName)) {
855
1772
  diagnostics.push({
856
1773
  code: 'PSL_INVALID_RELATION_TARGET',
@@ -871,6 +1788,15 @@ export function interpretPslDocumentToSqlContractIR(
871
1788
  if (!parsedRelation) {
872
1789
  continue;
873
1790
  }
1791
+ if (!parsedRelation.fields || !parsedRelation.references) {
1792
+ diagnostics.push({
1793
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1794
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`,
1795
+ sourceId,
1796
+ span: relationAttribute.relation.span,
1797
+ });
1798
+ continue;
1799
+ }
874
1800
 
875
1801
  const targetMapping = modelMappings.get(relationAttribute.field.typeName);
876
1802
  if (!targetMapping) {
@@ -907,6 +1833,15 @@ export function interpretPslDocumentToSqlContractIR(
907
1833
  if (!referencedColumns) {
908
1834
  continue;
909
1835
  }
1836
+ if (localColumns.length !== referencedColumns.length) {
1837
+ diagnostics.push({
1838
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1839
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" must provide the same number of fields and references`,
1840
+ sourceId,
1841
+ span: relationAttribute.relation.span,
1842
+ });
1843
+ continue;
1844
+ }
910
1845
 
911
1846
  const onDelete = parsedRelation.onDelete
912
1847
  ? normalizeReferentialAction({
@@ -938,24 +1873,42 @@ export function interpretPslDocumentToSqlContractIR(
938
1873
  columns: referencedColumns,
939
1874
  },
940
1875
  {
941
- ...(onDelete ? { onDelete } : {}),
942
- ...(onUpdate ? { onUpdate } : {}),
1876
+ ...ifDefined('name', parsedRelation.constraintName),
1877
+ ...ifDefined('onDelete', onDelete),
1878
+ ...ifDefined('onUpdate', onUpdate),
943
1879
  },
944
1880
  );
1881
+
1882
+ fkRelationMetadata.push({
1883
+ declaringModelName: model.name,
1884
+ declaringFieldName: relationAttribute.field.name,
1885
+ declaringTableName: tableName,
1886
+ targetModelName: targetMapping.model.name,
1887
+ targetTableName: targetMapping.tableName,
1888
+ ...ifDefined('relationName', parsedRelation.relationName),
1889
+ localColumns,
1890
+ referencedColumns,
1891
+ });
945
1892
  }
946
1893
 
947
1894
  return table;
948
1895
  });
949
-
950
- builder = builder.model(model.name, tableName, (modelBuilder: DynamicModelBuilder) => {
951
- let next = modelBuilder;
952
- for (const resolvedField of resolvedFields) {
953
- next = next.field(resolvedField.field.name, resolvedField.columnName);
954
- }
955
- return next;
956
- });
957
1896
  }
958
1897
 
1898
+ const { modelRelations, fkRelationsByPair } = indexFkRelations({ fkRelationMetadata });
1899
+ applyBackrelationCandidates({
1900
+ backrelationCandidates,
1901
+ fkRelationsByPair,
1902
+ modelRelations,
1903
+ diagnostics,
1904
+ sourceId,
1905
+ });
1906
+ builder = emitModelsWithRelations({
1907
+ builder,
1908
+ resolvedModels,
1909
+ modelRelations,
1910
+ });
1911
+
959
1912
  if (diagnostics.length > 0) {
960
1913
  const dedupedDiagnostics = diagnostics.filter(
961
1914
  (diagnostic, index, allDiagnostics) =>