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

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.
@@ -1,11 +1,11 @@
1
- import type { TargetPackRef } from '@prisma-next/contract/framework-components';
2
- import type { ContractIR } from '@prisma-next/contract/ir';
3
- import type { ColumnDefault } from '@prisma-next/contract/types';
4
1
  import type {
5
2
  ContractSourceDiagnostic,
6
3
  ContractSourceDiagnosticSpan,
7
4
  ContractSourceDiagnostics,
8
- } from '@prisma-next/core-control-plane/config-types';
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';
9
9
  import type {
10
10
  ParsePslDocumentResult,
11
11
  PslAttribute,
@@ -14,7 +14,15 @@ 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
+ createBuiltinDefaultFunctionRegistry,
22
+ type DefaultFunctionRegistry,
23
+ lowerDefaultFunctionWithRegistry,
24
+ parseDefaultFunctionCall,
25
+ } from './default-function-registry';
18
26
 
19
27
  type ColumnDescriptor = {
20
28
  readonly codecId: string;
@@ -27,6 +35,7 @@ export interface InterpretPslDocumentToSqlContractIRInput {
27
35
  readonly document: ParsePslDocumentResult;
28
36
  readonly target?: TargetPackRef<'sql', 'postgres'>;
29
37
  readonly composedExtensionPacks?: readonly string[];
38
+ readonly defaultFunctionRegistry?: DefaultFunctionRegistry;
30
39
  }
31
40
 
32
41
  const DEFAULT_POSTGRES_TARGET: TargetPackRef<'sql', 'postgres'> = {
@@ -50,6 +59,32 @@ const SCALAR_COLUMN_MAP: Record<string, ColumnDescriptor> = {
50
59
  Bytes: { codecId: 'pg/bytea@1', nativeType: 'bytea' },
51
60
  };
52
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
+
53
88
  const REFERENTIAL_ACTION_MAP = {
54
89
  NoAction: 'noAction',
55
90
  Restrict: 'restrict',
@@ -68,10 +103,60 @@ type ResolvedField = {
68
103
  readonly columnName: string;
69
104
  readonly descriptor: ColumnDescriptor;
70
105
  readonly defaultValue?: ColumnDefault;
106
+ readonly executionDefault?: ExecutionMutationDefaultValue;
71
107
  readonly isId: boolean;
72
108
  readonly isUnique: boolean;
73
109
  };
74
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
+
75
160
  type ModelNameMapping = {
76
161
  readonly model: PslModel;
77
162
  readonly tableName: string;
@@ -83,6 +168,10 @@ type DynamicTableBuilder = {
83
168
  name: string,
84
169
  options: { type: ColumnDescriptor; nullable?: true; default?: ColumnDefault },
85
170
  ): DynamicTableBuilder;
171
+ generated(
172
+ name: string,
173
+ options: { type: ColumnDescriptor; generated: ExecutionMutationDefaultValue },
174
+ ): DynamicTableBuilder;
86
175
  unique(columns: readonly string[]): DynamicTableBuilder;
87
176
  primaryKey(columns: readonly string[]): DynamicTableBuilder;
88
177
  index(columns: readonly string[]): DynamicTableBuilder;
@@ -95,6 +184,20 @@ type DynamicTableBuilder = {
95
184
 
96
185
  type DynamicModelBuilder = {
97
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;
98
201
  };
99
202
 
100
203
  type DynamicContractBuilder = {
@@ -145,6 +248,21 @@ function getPositionalArgument(attribute: PslAttribute, index = 0): string | und
145
248
  return entry.value;
146
249
  }
147
250
 
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
+ };
264
+ }
265
+
148
266
  function unquoteStringLiteral(value: string): string {
149
267
  const trimmed = value.trim();
150
268
  const match = trimmed.match(/^(['"])(.*)\1$/);
@@ -156,6 +274,8 @@ function unquoteStringLiteral(value: string): string {
156
274
 
157
275
  function parseQuotedStringLiteral(value: string): string | undefined {
158
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.
159
279
  const match = trimmed.match(/^(['"])(.*)\1$/);
160
280
  if (!match) {
161
281
  return undefined;
@@ -176,14 +296,8 @@ function parseFieldList(value: string): readonly string[] | undefined {
176
296
  return parts;
177
297
  }
178
298
 
179
- function parseDefaultValueExpression(expression: string): ColumnDefault | undefined {
299
+ function parseDefaultLiteralValue(expression: string): ColumnDefault | undefined {
180
300
  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
301
  if (trimmed === 'true' || trimmed === 'false') {
188
302
  return { kind: 'literal', value: trimmed === 'true' };
189
303
  }
@@ -197,6 +311,78 @@ function parseDefaultValueExpression(expression: string): ColumnDefault | undefi
197
311
  return undefined;
198
312
  }
199
313
 
314
+ function lowerDefaultForField(input: {
315
+ readonly modelName: string;
316
+ readonly fieldName: string;
317
+ readonly defaultAttribute: PslAttribute;
318
+ readonly sourceId: string;
319
+ readonly defaultFunctionRegistry: DefaultFunctionRegistry;
320
+ 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
+ }
348
+
349
+ const literalDefault = parseDefaultLiteralValue(expressionEntry.value);
350
+ if (literalDefault) {
351
+ return { defaultValue: literalDefault };
352
+ }
353
+
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}"`,
359
+ sourceId: input.sourceId,
360
+ span: input.defaultAttribute.span,
361
+ });
362
+ return {};
363
+ }
364
+
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
+ }
379
+
380
+ if (lowered.value.kind === 'storage') {
381
+ return { defaultValue: lowered.value.defaultValue };
382
+ }
383
+ return { executionDefault: lowered.value.generated };
384
+ }
385
+
200
386
  function parseMapName(input: {
201
387
  readonly attribute: PslAttribute | undefined;
202
388
  readonly defaultValue: string;
@@ -288,6 +474,7 @@ function collectResolvedFields(
288
474
  namedTypeBaseTypes: Map<string, string>,
289
475
  modelNames: Set<string>,
290
476
  composedExtensions: Set<string>,
477
+ defaultFunctionRegistry: DefaultFunctionRegistry,
291
478
  diagnostics: ContractSourceDiagnostic[],
292
479
  sourceId: string,
293
480
  ): ResolvedField[] {
@@ -295,9 +482,12 @@ function collectResolvedFields(
295
482
 
296
483
  for (const field of model.fields) {
297
484
  if (field.list) {
485
+ if (modelNames.has(field.typeName)) {
486
+ continue;
487
+ }
298
488
  diagnostics.push({
299
489
  code: 'PSL_UNSUPPORTED_FIELD_LIST',
300
- message: `Field "${model.name}.${field.name}" uses list types, which are not supported in SQL PSL provider v1`,
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.`,
301
491
  sourceId,
302
492
  span: field.span,
303
493
  });
@@ -387,22 +577,42 @@ function collectResolvedFields(
387
577
  }
388
578
 
389
579
  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) {
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';
393
595
  diagnostics.push({
394
- code: 'PSL_INVALID_DEFAULT_VALUE',
395
- message: `Unsupported default value "${defaultValueRaw}"`,
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.`,
396
598
  sourceId,
397
- span: defaultAttribute.span,
599
+ span: defaultAttribute?.span ?? field.span,
398
600
  });
601
+ continue;
602
+ }
603
+ if (loweredDefault.executionDefault) {
604
+ const generatedDescriptor = resolveGeneratedColumnDescriptor(loweredDefault.executionDefault);
605
+ if (generatedDescriptor) {
606
+ descriptor = generatedDescriptor;
607
+ }
399
608
  }
400
609
  const mappedColumnName = mapping.fieldColumns.get(field.name) ?? field.name;
401
610
  resolvedFields.push({
402
611
  field,
403
612
  columnName: mappedColumnName,
404
613
  descriptor,
405
- ...(defaultValue ? { defaultValue } : {}),
614
+ ...ifDefined('defaultValue', loweredDefault.defaultValue),
615
+ ...ifDefined('executionDefault', loweredDefault.executionDefault),
406
616
  isId: Boolean(getAttribute(field.attributes, 'id')),
407
617
  isUnique: Boolean(getAttribute(field.attributes, 'unique')),
408
618
  });
@@ -420,6 +630,155 @@ function hasSameSpan(a: PslSpan, b: ContractSourceDiagnosticSpan): boolean {
420
630
  );
421
631
  }
422
632
 
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;
641
+ }
642
+
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
+ });
666
+
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
+
423
782
  function mapParserDiagnostics(document: ParsePslDocumentResult): ContractSourceDiagnostic[] {
424
783
  return document.diagnostics.map((diagnostic) => ({
425
784
  code: diagnostic.code,
@@ -547,31 +906,79 @@ function buildModelMappings(
547
906
  return result;
548
907
  }
549
908
 
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') {
919
+ continue;
920
+ }
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;
929
+ continue;
930
+ }
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
+
550
942
  function parseRelationAttribute(input: {
551
943
  readonly attribute: PslAttribute;
552
944
  readonly modelName: string;
553
945
  readonly fieldName: string;
554
946
  readonly sourceId: string;
555
947
  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') {
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) {
566
965
  input.diagnostics.push({
567
966
  code: 'PSL_INVALID_RELATION_ATTRIBUTE',
568
- message: `Relation field "${input.modelName}.${input.fieldName}" must use named arguments`,
967
+ message: `Relation field "${input.modelName}.${input.fieldName}" positional relation name must be a quoted string literal`,
569
968
  sourceId: input.sourceId,
570
- span: arg.span,
969
+ span: positionalNameEntry.span,
571
970
  });
572
971
  return undefined;
573
972
  }
973
+ relationNameFromPositional = parsedName;
974
+ }
975
+
976
+ for (const arg of input.attribute.args) {
977
+ if (arg.kind === 'positional') {
978
+ continue;
979
+ }
574
980
  if (
981
+ arg.name !== 'name' &&
575
982
  arg.name !== 'fields' &&
576
983
  arg.name !== 'references' &&
577
984
  arg.name !== 'onDelete' &&
@@ -587,38 +994,79 @@ function parseRelationAttribute(input: {
587
994
  }
588
995
  }
589
996
 
590
- const fieldsRaw = getNamedArgument(input.attribute, 'fields');
591
- const referencesRaw = getNamedArgument(input.attribute, 'references');
592
- if (!fieldsRaw || !referencesRaw) {
997
+ const namedRelationNameRaw = getNamedArgument(input.attribute, 'name');
998
+ const namedRelationName = namedRelationNameRaw
999
+ ? parseQuotedStringLiteral(namedRelationNameRaw)
1000
+ : undefined;
1001
+ if (namedRelationNameRaw && !namedRelationName) {
593
1002
  input.diagnostics.push({
594
1003
  code: 'PSL_INVALID_RELATION_ATTRIBUTE',
595
- message: `Relation field "${input.modelName}.${input.fieldName}" requires fields and references arguments`,
1004
+ message: `Relation field "${input.modelName}.${input.fieldName}" named relation name must be a quoted string literal`,
596
1005
  sourceId: input.sourceId,
597
1006
  span: input.attribute.span,
598
1007
  });
599
1008
  return undefined;
600
1009
  }
601
- const fields = parseFieldList(fieldsRaw);
602
- const references = parseFieldList(referencesRaw);
603
- if (!fields || !references || fields.length === 0 || references.length === 0) {
1010
+
1011
+ if (
1012
+ relationNameFromPositional &&
1013
+ namedRelationName &&
1014
+ relationNameFromPositional !== namedRelationName
1015
+ ) {
604
1016
  input.diagnostics.push({
605
1017
  code: 'PSL_INVALID_RELATION_ATTRIBUTE',
606
- message: `Relation field "${input.modelName}.${input.fieldName}" requires bracketed fields and references lists`,
1018
+ message: `Relation field "${input.modelName}.${input.fieldName}" has conflicting positional and named relation names`,
607
1019
  sourceId: input.sourceId,
608
1020
  span: input.attribute.span,
609
1021
  });
610
1022
  return undefined;
611
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,
1034
+ });
1035
+ return undefined;
1036
+ }
1037
+
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;
1056
+ }
1057
+ fields = parsedFields;
1058
+ references = parsedReferences;
1059
+ }
1060
+
1061
+ const onDeleteArgument = getNamedArgument(input.attribute, 'onDelete');
1062
+ const onUpdateArgument = getNamedArgument(input.attribute, 'onUpdate');
612
1063
 
613
1064
  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
- : {}),
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),
622
1070
  };
623
1071
  }
624
1072
 
@@ -629,6 +1077,8 @@ export function interpretPslDocumentToSqlContractIR(
629
1077
  const modelNames = new Set(input.document.ast.models.map((model) => model.name));
630
1078
  const sourceId = input.document.ast.sourceId;
631
1079
  const composedExtensions = new Set(input.composedExtensionPacks ?? []);
1080
+ const defaultFunctionRegistry =
1081
+ input.defaultFunctionRegistry ?? createBuiltinDefaultFunctionRegistry();
632
1082
 
633
1083
  let builder = defineContract().target(
634
1084
  input.target ?? DEFAULT_POSTGRES_TARGET,
@@ -735,6 +1185,13 @@ export function interpretPslDocumentToSqlContractIR(
735
1185
  }
736
1186
 
737
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[] = [];
738
1195
 
739
1196
  for (const model of input.document.ast.models) {
740
1197
  const mapping = modelMappings.get(model.name);
@@ -750,9 +1207,11 @@ export function interpretPslDocumentToSqlContractIR(
750
1207
  namedTypeBaseTypes,
751
1208
  modelNames,
752
1209
  composedExtensions,
1210
+ defaultFunctionRegistry,
753
1211
  diagnostics,
754
1212
  sourceId,
755
1213
  );
1214
+ resolvedModels.push({ model, mapping, resolvedFields });
756
1215
 
757
1216
  const primaryKeyColumns = resolvedFields
758
1217
  .filter((field) => field.isId)
@@ -766,6 +1225,63 @@ export function interpretPslDocumentToSqlContractIR(
766
1225
  });
767
1226
  }
768
1227
 
1228
+ for (const field of model.fields) {
1229
+ if (!field.list || !modelNames.has(field.typeName)) {
1230
+ continue;
1231
+ }
1232
+ const attributesValid = validateNavigationListFieldAttributes({
1233
+ modelName: model.name,
1234
+ field,
1235
+ sourceId,
1236
+ composedExtensions,
1237
+ diagnostics,
1238
+ });
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) {
1253
+ 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`,
1256
+ sourceId,
1257
+ span: relationAttribute.span,
1258
+ });
1259
+ continue;
1260
+ }
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
+ continue;
1274
+ }
1275
+
1276
+ backrelationCandidates.push({
1277
+ modelName: model.name,
1278
+ tableName,
1279
+ field,
1280
+ targetModelName: field.typeName,
1281
+ ...ifDefined('relationName', relationName),
1282
+ });
1283
+ }
1284
+
769
1285
  const relationAttributes = model.fields
770
1286
  .map((field) => ({
771
1287
  field,
@@ -779,16 +1295,23 @@ export function interpretPslDocumentToSqlContractIR(
779
1295
  let table = tableBuilder;
780
1296
 
781
1297
  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);
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
+ }
792
1315
 
793
1316
  if (resolvedField.isUnique) {
794
1317
  table = table.unique([resolvedField.columnName]);
@@ -851,6 +1374,10 @@ export function interpretPslDocumentToSqlContractIR(
851
1374
  }
852
1375
 
853
1376
  for (const relationAttribute of relationAttributes) {
1377
+ if (relationAttribute.field.list) {
1378
+ continue;
1379
+ }
1380
+
854
1381
  if (!modelNames.has(relationAttribute.field.typeName)) {
855
1382
  diagnostics.push({
856
1383
  code: 'PSL_INVALID_RELATION_TARGET',
@@ -871,6 +1398,15 @@ export function interpretPslDocumentToSqlContractIR(
871
1398
  if (!parsedRelation) {
872
1399
  continue;
873
1400
  }
1401
+ if (!parsedRelation.fields || !parsedRelation.references) {
1402
+ diagnostics.push({
1403
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1404
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`,
1405
+ sourceId,
1406
+ span: relationAttribute.relation.span,
1407
+ });
1408
+ continue;
1409
+ }
874
1410
 
875
1411
  const targetMapping = modelMappings.get(relationAttribute.field.typeName);
876
1412
  if (!targetMapping) {
@@ -907,6 +1443,15 @@ export function interpretPslDocumentToSqlContractIR(
907
1443
  if (!referencedColumns) {
908
1444
  continue;
909
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
+ }
910
1455
 
911
1456
  const onDelete = parsedRelation.onDelete
912
1457
  ? normalizeReferentialAction({
@@ -938,24 +1483,41 @@ export function interpretPslDocumentToSqlContractIR(
938
1483
  columns: referencedColumns,
939
1484
  },
940
1485
  {
941
- ...(onDelete ? { onDelete } : {}),
942
- ...(onUpdate ? { onUpdate } : {}),
1486
+ ...ifDefined('onDelete', onDelete),
1487
+ ...ifDefined('onUpdate', onUpdate),
943
1488
  },
944
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
+ });
945
1501
  }
946
1502
 
947
1503
  return table;
948
1504
  });
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
1505
  }
958
1506
 
1507
+ const { modelRelations, fkRelationsByPair } = indexFkRelations({ fkRelationMetadata });
1508
+ applyBackrelationCandidates({
1509
+ backrelationCandidates,
1510
+ fkRelationsByPair,
1511
+ modelRelations,
1512
+ diagnostics,
1513
+ sourceId,
1514
+ });
1515
+ builder = emitModelsWithRelations({
1516
+ builder,
1517
+ resolvedModels,
1518
+ modelRelations,
1519
+ });
1520
+
959
1521
  if (diagnostics.length > 0) {
960
1522
  const dedupedDiagnostics = diagnostics.filter(
961
1523
  (diagnostic, index, allDiagnostics) =>