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

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.
@@ -0,0 +1,335 @@
1
+ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types';
2
+ import type { ColumnDefault, ExecutionMutationDefaultValue } from '@prisma-next/contract/types';
3
+ import type { AuthoringContributions } from '@prisma-next/framework-components/authoring';
4
+ import type { PslAttribute, PslField, PslModel } from '@prisma-next/psl-parser';
5
+ import { ifDefined } from '@prisma-next/utils/defined';
6
+ import type {
7
+ ControlMutationDefaultRegistry,
8
+ MutationDefaultGeneratorDescriptor,
9
+ } from './default-function-registry';
10
+ import {
11
+ getAttribute,
12
+ lowerFirst,
13
+ parseConstraintMapArgument,
14
+ parseMapName,
15
+ } from './psl-attribute-parsing';
16
+ import type { ColumnDescriptor } from './psl-column-resolution';
17
+ import {
18
+ checkUncomposedNamespace,
19
+ lowerDefaultForField,
20
+ reportUncomposedNamespace,
21
+ resolveFieldTypeDescriptor,
22
+ } from './psl-column-resolution';
23
+
24
+ export type ResolvedField = {
25
+ readonly field: PslField;
26
+ readonly columnName: string;
27
+ readonly descriptor: ColumnDescriptor;
28
+ readonly defaultValue?: ColumnDefault;
29
+ readonly executionDefault?: ExecutionMutationDefaultValue;
30
+ readonly isId: boolean;
31
+ readonly isUnique: boolean;
32
+ readonly idName?: string;
33
+ readonly uniqueName?: string;
34
+ readonly many?: true;
35
+ readonly valueObjectTypeName?: string;
36
+ readonly scalarCodecId?: string;
37
+ };
38
+
39
+ export type ModelNameMapping = {
40
+ readonly model: PslModel;
41
+ readonly tableName: string;
42
+ readonly fieldColumns: Map<string, string>;
43
+ };
44
+
45
+ export interface CollectResolvedFieldsInput {
46
+ readonly model: PslModel;
47
+ readonly mapping: ModelNameMapping;
48
+ readonly enumTypeDescriptors: Map<string, ColumnDescriptor>;
49
+ readonly namedTypeDescriptors: Map<string, ColumnDescriptor>;
50
+ readonly modelNames: Set<string>;
51
+ readonly compositeTypeNames: ReadonlySet<string>;
52
+ readonly composedExtensions: Set<string>;
53
+ readonly authoringContributions: AuthoringContributions | undefined;
54
+ readonly familyId: string;
55
+ readonly targetId: string;
56
+ readonly defaultFunctionRegistry: ControlMutationDefaultRegistry;
57
+ readonly generatorDescriptorById: ReadonlyMap<string, MutationDefaultGeneratorDescriptor>;
58
+ readonly diagnostics: ContractSourceDiagnostic[];
59
+ readonly sourceId: string;
60
+ readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
61
+ }
62
+
63
+ const BUILTIN_FIELD_ATTRIBUTE_NAMES: ReadonlySet<string> = new Set([
64
+ 'id',
65
+ 'unique',
66
+ 'default',
67
+ 'relation',
68
+ 'map',
69
+ ]);
70
+
71
+ function validateFieldAttributes(input: {
72
+ readonly model: PslModel;
73
+ readonly field: PslField;
74
+ readonly composedExtensions: ReadonlySet<string>;
75
+ readonly diagnostics: ContractSourceDiagnostic[];
76
+ readonly sourceId: string;
77
+ readonly familyId: string;
78
+ readonly targetId: string;
79
+ }): void {
80
+ for (const attribute of input.field.attributes) {
81
+ if (BUILTIN_FIELD_ATTRIBUTE_NAMES.has(attribute.name)) {
82
+ continue;
83
+ }
84
+
85
+ const uncomposedNamespace = checkUncomposedNamespace(attribute.name, input.composedExtensions, {
86
+ familyId: input.familyId,
87
+ targetId: input.targetId,
88
+ });
89
+ if (uncomposedNamespace) {
90
+ reportUncomposedNamespace({
91
+ subjectLabel: `Attribute "@${attribute.name}"`,
92
+ namespace: uncomposedNamespace,
93
+ sourceId: input.sourceId,
94
+ span: attribute.span,
95
+ diagnostics: input.diagnostics,
96
+ });
97
+ continue;
98
+ }
99
+
100
+ input.diagnostics.push({
101
+ code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE',
102
+ message: `Field "${input.model.name}.${input.field.name}" uses unsupported attribute "@${attribute.name}"`,
103
+ sourceId: input.sourceId,
104
+ span: attribute.span,
105
+ });
106
+ }
107
+ }
108
+
109
+ function extractFieldConstraintNames(input: {
110
+ readonly model: PslModel;
111
+ readonly field: PslField;
112
+ readonly sourceId: string;
113
+ readonly diagnostics: ContractSourceDiagnostic[];
114
+ }): {
115
+ readonly idAttribute: PslAttribute | undefined;
116
+ readonly uniqueAttribute: PslAttribute | undefined;
117
+ readonly idName: string | undefined;
118
+ readonly uniqueName: string | undefined;
119
+ } {
120
+ const idAttribute = getAttribute(input.field.attributes, 'id');
121
+ const uniqueAttribute = getAttribute(input.field.attributes, 'unique');
122
+ const idName = parseConstraintMapArgument({
123
+ attribute: idAttribute,
124
+ sourceId: input.sourceId,
125
+ diagnostics: input.diagnostics,
126
+ entityLabel: `Field "${input.model.name}.${input.field.name}" @id`,
127
+ span: input.field.span,
128
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
129
+ });
130
+ const uniqueName = parseConstraintMapArgument({
131
+ attribute: uniqueAttribute,
132
+ sourceId: input.sourceId,
133
+ diagnostics: input.diagnostics,
134
+ entityLabel: `Field "${input.model.name}.${input.field.name}" @unique`,
135
+ span: input.field.span,
136
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
137
+ });
138
+ return { idAttribute, uniqueAttribute, idName, uniqueName };
139
+ }
140
+
141
+ export function collectResolvedFields(input: CollectResolvedFieldsInput): ResolvedField[] {
142
+ const {
143
+ model,
144
+ mapping,
145
+ enumTypeDescriptors,
146
+ namedTypeDescriptors,
147
+ modelNames,
148
+ compositeTypeNames,
149
+ composedExtensions,
150
+ authoringContributions,
151
+ familyId,
152
+ targetId,
153
+ defaultFunctionRegistry,
154
+ generatorDescriptorById,
155
+ diagnostics,
156
+ sourceId,
157
+ scalarTypeDescriptors,
158
+ } = input;
159
+ const resolvedFields: ResolvedField[] = [];
160
+
161
+ for (const field of model.fields) {
162
+ if (field.list && modelNames.has(field.typeName)) {
163
+ continue;
164
+ }
165
+
166
+ validateFieldAttributes({
167
+ model,
168
+ field,
169
+ composedExtensions,
170
+ diagnostics,
171
+ sourceId,
172
+ familyId,
173
+ targetId,
174
+ });
175
+
176
+ const relationAttribute = getAttribute(field.attributes, 'relation');
177
+ if (relationAttribute && modelNames.has(field.typeName)) {
178
+ continue;
179
+ }
180
+
181
+ const isValueObjectField = compositeTypeNames.has(field.typeName);
182
+ const isListField = field.list;
183
+
184
+ let descriptor: ColumnDescriptor | undefined;
185
+ let scalarCodecId: string | undefined;
186
+ const resolveInput = {
187
+ field,
188
+ enumTypeDescriptors,
189
+ namedTypeDescriptors,
190
+ scalarTypeDescriptors,
191
+ authoringContributions,
192
+ composedExtensions,
193
+ familyId,
194
+ targetId,
195
+ diagnostics,
196
+ sourceId,
197
+ entityLabel: `Field "${model.name}.${field.name}"`,
198
+ };
199
+
200
+ if (isValueObjectField) {
201
+ descriptor = scalarTypeDescriptors.get('Json');
202
+ } else if (isListField) {
203
+ const resolved = resolveFieldTypeDescriptor(resolveInput);
204
+ if (!resolved.ok) {
205
+ if (!resolved.alreadyReported) {
206
+ diagnostics.push({
207
+ code: 'PSL_UNSUPPORTED_FIELD_TYPE',
208
+ message: `Field "${model.name}.${field.name}" type "${field.typeName}" is not supported in SQL PSL provider v1`,
209
+ sourceId,
210
+ span: field.span,
211
+ });
212
+ }
213
+ continue;
214
+ }
215
+ scalarCodecId = resolved.descriptor.codecId;
216
+ descriptor = scalarTypeDescriptors.get('Json');
217
+ } else {
218
+ const resolved = resolveFieldTypeDescriptor(resolveInput);
219
+ if (!resolved.ok) {
220
+ if (!resolved.alreadyReported) {
221
+ diagnostics.push({
222
+ code: 'PSL_UNSUPPORTED_FIELD_TYPE',
223
+ message: `Field "${model.name}.${field.name}" type "${field.typeName}" is not supported in SQL PSL provider v1`,
224
+ sourceId,
225
+ span: field.span,
226
+ });
227
+ }
228
+ continue;
229
+ }
230
+ descriptor = resolved.descriptor;
231
+ }
232
+
233
+ if (!descriptor) {
234
+ continue;
235
+ }
236
+
237
+ const defaultAttribute = getAttribute(field.attributes, 'default');
238
+ const loweredDefault = defaultAttribute
239
+ ? lowerDefaultForField({
240
+ modelName: model.name,
241
+ fieldName: field.name,
242
+ defaultAttribute,
243
+ columnDescriptor: descriptor,
244
+ generatorDescriptorById,
245
+ sourceId,
246
+ defaultFunctionRegistry,
247
+ diagnostics,
248
+ })
249
+ : {};
250
+ if (field.optional && loweredDefault.executionDefault) {
251
+ const generatorDescription =
252
+ loweredDefault.executionDefault.kind === 'generator'
253
+ ? `"${loweredDefault.executionDefault.id}"`
254
+ : 'for this field';
255
+ diagnostics.push({
256
+ code: 'PSL_INVALID_DEFAULT_FUNCTION_ARGUMENT',
257
+ message: `Field "${model.name}.${field.name}" cannot be optional when using execution default ${generatorDescription}. Remove "?" or use a storage default.`,
258
+ sourceId,
259
+ span: defaultAttribute?.span ?? field.span,
260
+ });
261
+ continue;
262
+ }
263
+ if (loweredDefault.executionDefault) {
264
+ const generatorDescriptor = generatorDescriptorById.get(loweredDefault.executionDefault.id);
265
+ const generatedDescriptor = generatorDescriptor?.resolveGeneratedColumnDescriptor?.({
266
+ generated: loweredDefault.executionDefault,
267
+ });
268
+ if (generatedDescriptor) {
269
+ descriptor = generatedDescriptor;
270
+ }
271
+ }
272
+ const mappedColumnName = mapping.fieldColumns.get(field.name) ?? field.name;
273
+ const { idAttribute, uniqueAttribute, idName, uniqueName } = extractFieldConstraintNames({
274
+ model,
275
+ field,
276
+ sourceId,
277
+ diagnostics,
278
+ });
279
+
280
+ resolvedFields.push({
281
+ field,
282
+ columnName: mappedColumnName,
283
+ descriptor,
284
+ ...ifDefined('defaultValue', loweredDefault.defaultValue),
285
+ ...ifDefined('executionDefault', loweredDefault.executionDefault),
286
+ isId: Boolean(idAttribute),
287
+ isUnique: Boolean(uniqueAttribute),
288
+ ...ifDefined('idName', idName),
289
+ ...ifDefined('uniqueName', uniqueName),
290
+ ...ifDefined('many', isListField ? (true as const) : undefined),
291
+ ...ifDefined('valueObjectTypeName', isValueObjectField ? field.typeName : undefined),
292
+ ...ifDefined('scalarCodecId', scalarCodecId),
293
+ });
294
+ }
295
+
296
+ return resolvedFields;
297
+ }
298
+
299
+ export function buildModelMappings(
300
+ models: readonly PslModel[],
301
+ diagnostics: ContractSourceDiagnostic[],
302
+ sourceId: string,
303
+ ): Map<string, ModelNameMapping> {
304
+ const result = new Map<string, ModelNameMapping>();
305
+ for (const model of models) {
306
+ const mapAttribute = getAttribute(model.attributes, 'map');
307
+ const tableName = parseMapName({
308
+ attribute: mapAttribute,
309
+ defaultValue: lowerFirst(model.name),
310
+ sourceId,
311
+ diagnostics,
312
+ entityLabel: `Model "${model.name}"`,
313
+ span: model.span,
314
+ });
315
+ const fieldColumns = new Map<string, string>();
316
+ for (const field of model.fields) {
317
+ const fieldMapAttribute = getAttribute(field.attributes, 'map');
318
+ const columnName = parseMapName({
319
+ attribute: fieldMapAttribute,
320
+ defaultValue: field.name,
321
+ sourceId,
322
+ diagnostics,
323
+ entityLabel: `Field "${model.name}.${field.name}"`,
324
+ span: field.span,
325
+ });
326
+ fieldColumns.set(field.name, columnName);
327
+ }
328
+ result.set(model.name, {
329
+ model,
330
+ tableName,
331
+ fieldColumns,
332
+ });
333
+ }
334
+ return result;
335
+ }
@@ -0,0 +1,371 @@
1
+ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types';
2
+ import type { PslAttribute, PslField, PslSpan } from '@prisma-next/psl-parser';
3
+ import type { ReferentialAction } from '@prisma-next/sql-contract/types';
4
+ import type { RelationNode } from '@prisma-next/sql-contract-ts/contract-builder';
5
+ import { assertDefined, invariant } from '@prisma-next/utils/assertions';
6
+ import { ifDefined } from '@prisma-next/utils/defined';
7
+ import {
8
+ getNamedArgument,
9
+ getPositionalArgumentEntry,
10
+ parseFieldList,
11
+ parseQuotedStringLiteral,
12
+ unquoteStringLiteral,
13
+ } from './psl-attribute-parsing';
14
+ import { checkUncomposedNamespace, reportUncomposedNamespace } from './psl-column-resolution';
15
+
16
+ export const REFERENTIAL_ACTION_MAP = {
17
+ NoAction: 'noAction',
18
+ Restrict: 'restrict',
19
+ Cascade: 'cascade',
20
+ SetNull: 'setNull',
21
+ SetDefault: 'setDefault',
22
+ noAction: 'noAction',
23
+ restrict: 'restrict',
24
+ cascade: 'cascade',
25
+ setNull: 'setNull',
26
+ setDefault: 'setDefault',
27
+ } as const;
28
+
29
+ export type ParsedRelationAttribute = {
30
+ readonly relationName?: string;
31
+ readonly fields?: readonly string[];
32
+ readonly references?: readonly string[];
33
+ readonly constraintName?: string;
34
+ readonly onDelete?: string;
35
+ readonly onUpdate?: string;
36
+ };
37
+
38
+ export type FkRelationMetadata = {
39
+ readonly declaringModelName: string;
40
+ readonly declaringFieldName: string;
41
+ readonly declaringTableName: string;
42
+ readonly targetModelName: string;
43
+ readonly targetTableName: string;
44
+ readonly relationName?: string;
45
+ readonly localColumns: readonly string[];
46
+ readonly referencedColumns: readonly string[];
47
+ };
48
+
49
+ export type ModelBackrelationCandidate = {
50
+ readonly modelName: string;
51
+ readonly tableName: string;
52
+ readonly field: PslField;
53
+ readonly targetModelName: string;
54
+ readonly relationName?: string;
55
+ };
56
+
57
+ type ModelRelationMetadata = RelationNode;
58
+
59
+ export function fkRelationPairKey(declaringModelName: string, targetModelName: string): string {
60
+ // NOTE: We assume PSL model identifiers do not contain the `::` separator.
61
+ return `${declaringModelName}::${targetModelName}`;
62
+ }
63
+
64
+ export function normalizeReferentialAction(input: {
65
+ readonly modelName: string;
66
+ readonly fieldName: string;
67
+ readonly actionName: 'onDelete' | 'onUpdate';
68
+ readonly actionToken: string;
69
+ readonly sourceId: string;
70
+ readonly span: PslSpan;
71
+ readonly diagnostics: ContractSourceDiagnostic[];
72
+ }): ReferentialAction | undefined {
73
+ const normalized =
74
+ REFERENTIAL_ACTION_MAP[input.actionToken as keyof typeof REFERENTIAL_ACTION_MAP];
75
+ if (normalized) {
76
+ return normalized;
77
+ }
78
+
79
+ input.diagnostics.push({
80
+ code: 'PSL_UNSUPPORTED_REFERENTIAL_ACTION',
81
+ message: `Relation field "${input.modelName}.${input.fieldName}" has unsupported ${input.actionName} action "${input.actionToken}"`,
82
+ sourceId: input.sourceId,
83
+ span: input.span,
84
+ });
85
+ return undefined;
86
+ }
87
+
88
+ export function parseRelationAttribute(input: {
89
+ readonly attribute: PslAttribute;
90
+ readonly modelName: string;
91
+ readonly fieldName: string;
92
+ readonly sourceId: string;
93
+ readonly diagnostics: ContractSourceDiagnostic[];
94
+ }): ParsedRelationAttribute | undefined {
95
+ const positionalEntries = input.attribute.args.filter((arg) => arg.kind === 'positional');
96
+ if (positionalEntries.length > 1) {
97
+ input.diagnostics.push({
98
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
99
+ message: `Relation field "${input.modelName}.${input.fieldName}" has too many positional arguments`,
100
+ sourceId: input.sourceId,
101
+ span: input.attribute.span,
102
+ });
103
+ return undefined;
104
+ }
105
+
106
+ let relationNameFromPositional: string | undefined;
107
+ const positionalNameEntry = getPositionalArgumentEntry(input.attribute);
108
+ if (positionalNameEntry) {
109
+ const parsedName = parseQuotedStringLiteral(positionalNameEntry.value);
110
+ if (!parsedName) {
111
+ input.diagnostics.push({
112
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
113
+ message: `Relation field "${input.modelName}.${input.fieldName}" positional relation name must be a quoted string literal`,
114
+ sourceId: input.sourceId,
115
+ span: positionalNameEntry.span,
116
+ });
117
+ return undefined;
118
+ }
119
+ relationNameFromPositional = parsedName;
120
+ }
121
+
122
+ for (const arg of input.attribute.args) {
123
+ if (arg.kind === 'positional') {
124
+ continue;
125
+ }
126
+ if (
127
+ arg.name !== 'name' &&
128
+ arg.name !== 'fields' &&
129
+ arg.name !== 'references' &&
130
+ arg.name !== 'map' &&
131
+ arg.name !== 'onDelete' &&
132
+ arg.name !== 'onUpdate'
133
+ ) {
134
+ input.diagnostics.push({
135
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
136
+ message: `Relation field "${input.modelName}.${input.fieldName}" has unsupported argument "${arg.name}"`,
137
+ sourceId: input.sourceId,
138
+ span: arg.span,
139
+ });
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ const namedRelationNameRaw = getNamedArgument(input.attribute, 'name');
145
+ const namedRelationName = namedRelationNameRaw
146
+ ? parseQuotedStringLiteral(namedRelationNameRaw)
147
+ : undefined;
148
+ if (namedRelationNameRaw && !namedRelationName) {
149
+ input.diagnostics.push({
150
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
151
+ message: `Relation field "${input.modelName}.${input.fieldName}" named relation name must be a quoted string literal`,
152
+ sourceId: input.sourceId,
153
+ span: input.attribute.span,
154
+ });
155
+ return undefined;
156
+ }
157
+
158
+ if (
159
+ relationNameFromPositional &&
160
+ namedRelationName &&
161
+ relationNameFromPositional !== namedRelationName
162
+ ) {
163
+ input.diagnostics.push({
164
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
165
+ message: `Relation field "${input.modelName}.${input.fieldName}" has conflicting positional and named relation names`,
166
+ sourceId: input.sourceId,
167
+ span: input.attribute.span,
168
+ });
169
+ return undefined;
170
+ }
171
+ const relationName = namedRelationName ?? relationNameFromPositional;
172
+
173
+ const constraintNameRaw = getNamedArgument(input.attribute, 'map');
174
+ const constraintName = constraintNameRaw
175
+ ? parseQuotedStringLiteral(constraintNameRaw)
176
+ : undefined;
177
+ if (constraintNameRaw && !constraintName) {
178
+ input.diagnostics.push({
179
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
180
+ message: `Relation field "${input.modelName}.${input.fieldName}" map argument must be a quoted string literal`,
181
+ sourceId: input.sourceId,
182
+ span: input.attribute.span,
183
+ });
184
+ return undefined;
185
+ }
186
+
187
+ const fieldsRaw = getNamedArgument(input.attribute, 'fields');
188
+ const referencesRaw = getNamedArgument(input.attribute, 'references');
189
+ if ((fieldsRaw && !referencesRaw) || (!fieldsRaw && referencesRaw)) {
190
+ input.diagnostics.push({
191
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
192
+ message: `Relation field "${input.modelName}.${input.fieldName}" requires fields and references arguments`,
193
+ sourceId: input.sourceId,
194
+ span: input.attribute.span,
195
+ });
196
+ return undefined;
197
+ }
198
+
199
+ let fields: readonly string[] | undefined;
200
+ let references: readonly string[] | undefined;
201
+ if (fieldsRaw && referencesRaw) {
202
+ const parsedFields = parseFieldList(fieldsRaw);
203
+ const parsedReferences = parseFieldList(referencesRaw);
204
+ if (
205
+ !parsedFields ||
206
+ !parsedReferences ||
207
+ parsedFields.length === 0 ||
208
+ parsedReferences.length === 0
209
+ ) {
210
+ input.diagnostics.push({
211
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
212
+ message: `Relation field "${input.modelName}.${input.fieldName}" requires bracketed fields and references lists`,
213
+ sourceId: input.sourceId,
214
+ span: input.attribute.span,
215
+ });
216
+ return undefined;
217
+ }
218
+ fields = parsedFields;
219
+ references = parsedReferences;
220
+ }
221
+
222
+ const onDeleteArgument = getNamedArgument(input.attribute, 'onDelete');
223
+ const onUpdateArgument = getNamedArgument(input.attribute, 'onUpdate');
224
+
225
+ return {
226
+ ...ifDefined('relationName', relationName),
227
+ ...ifDefined('fields', fields),
228
+ ...ifDefined('references', references),
229
+ ...ifDefined('constraintName', constraintName),
230
+ ...ifDefined('onDelete', onDeleteArgument ? unquoteStringLiteral(onDeleteArgument) : undefined),
231
+ ...ifDefined('onUpdate', onUpdateArgument ? unquoteStringLiteral(onUpdateArgument) : undefined),
232
+ };
233
+ }
234
+
235
+ export function indexFkRelations(input: {
236
+ readonly fkRelationMetadata: readonly FkRelationMetadata[];
237
+ }): {
238
+ readonly modelRelations: Map<string, ModelRelationMetadata[]>;
239
+ readonly fkRelationsByPair: Map<string, FkRelationMetadata[]>;
240
+ } {
241
+ const modelRelations = new Map<string, ModelRelationMetadata[]>();
242
+ const fkRelationsByPair = new Map<string, FkRelationMetadata[]>();
243
+
244
+ for (const relation of input.fkRelationMetadata) {
245
+ const existing = modelRelations.get(relation.declaringModelName);
246
+ const current = existing ?? [];
247
+ if (!existing) {
248
+ modelRelations.set(relation.declaringModelName, current);
249
+ }
250
+ current.push({
251
+ fieldName: relation.declaringFieldName,
252
+ toModel: relation.targetModelName,
253
+ toTable: relation.targetTableName,
254
+ cardinality: 'N:1',
255
+ on: {
256
+ parentTable: relation.declaringTableName,
257
+ parentColumns: relation.localColumns,
258
+ childTable: relation.targetTableName,
259
+ childColumns: relation.referencedColumns,
260
+ },
261
+ });
262
+
263
+ const pairKey = fkRelationPairKey(relation.declaringModelName, relation.targetModelName);
264
+ const pairRelations = fkRelationsByPair.get(pairKey);
265
+ if (!pairRelations) {
266
+ fkRelationsByPair.set(pairKey, [relation]);
267
+ continue;
268
+ }
269
+ pairRelations.push(relation);
270
+ }
271
+
272
+ return { modelRelations, fkRelationsByPair };
273
+ }
274
+
275
+ export function applyBackrelationCandidates(input: {
276
+ readonly backrelationCandidates: readonly ModelBackrelationCandidate[];
277
+ readonly fkRelationsByPair: Map<string, readonly FkRelationMetadata[]>;
278
+ readonly modelRelations: Map<string, ModelRelationMetadata[]>;
279
+ readonly diagnostics: ContractSourceDiagnostic[];
280
+ readonly sourceId: string;
281
+ }): void {
282
+ for (const candidate of input.backrelationCandidates) {
283
+ const pairKey = fkRelationPairKey(candidate.targetModelName, candidate.modelName);
284
+ const pairMatches = input.fkRelationsByPair.get(pairKey) ?? [];
285
+ const matches = candidate.relationName
286
+ ? pairMatches.filter((relation) => relation.relationName === candidate.relationName)
287
+ : [...pairMatches];
288
+
289
+ if (matches.length === 0) {
290
+ input.diagnostics.push({
291
+ code: 'PSL_ORPHANED_BACKRELATION_LIST',
292
+ 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.`,
293
+ sourceId: input.sourceId,
294
+ span: candidate.field.span,
295
+ });
296
+ continue;
297
+ }
298
+ if (matches.length > 1) {
299
+ input.diagnostics.push({
300
+ code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
301
+ 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.`,
302
+ sourceId: input.sourceId,
303
+ span: candidate.field.span,
304
+ });
305
+ continue;
306
+ }
307
+
308
+ invariant(matches.length === 1, 'Backrelation matching requires exactly one match');
309
+ const matched = matches[0];
310
+ assertDefined(matched, 'Backrelation matching requires a defined relation match');
311
+
312
+ const existing = input.modelRelations.get(candidate.modelName);
313
+ const current = existing ?? [];
314
+ if (!existing) {
315
+ input.modelRelations.set(candidate.modelName, current);
316
+ }
317
+ current.push({
318
+ fieldName: candidate.field.name,
319
+ toModel: matched.declaringModelName,
320
+ toTable: matched.declaringTableName,
321
+ cardinality: '1:N',
322
+ on: {
323
+ parentTable: candidate.tableName,
324
+ parentColumns: matched.referencedColumns,
325
+ childTable: matched.declaringTableName,
326
+ childColumns: matched.localColumns,
327
+ },
328
+ });
329
+ }
330
+ }
331
+
332
+ export function validateNavigationListFieldAttributes(input: {
333
+ readonly modelName: string;
334
+ readonly field: PslField;
335
+ readonly sourceId: string;
336
+ readonly composedExtensions: Set<string>;
337
+ readonly diagnostics: ContractSourceDiagnostic[];
338
+ readonly familyId: string;
339
+ readonly targetId: string;
340
+ }): boolean {
341
+ let valid = true;
342
+ for (const attribute of input.field.attributes) {
343
+ if (attribute.name === 'relation') {
344
+ continue;
345
+ }
346
+
347
+ const uncomposedNamespace = checkUncomposedNamespace(attribute.name, input.composedExtensions, {
348
+ familyId: input.familyId,
349
+ targetId: input.targetId,
350
+ });
351
+ if (uncomposedNamespace) {
352
+ reportUncomposedNamespace({
353
+ subjectLabel: `Attribute "@${attribute.name}"`,
354
+ namespace: uncomposedNamespace,
355
+ sourceId: input.sourceId,
356
+ span: attribute.span,
357
+ diagnostics: input.diagnostics,
358
+ });
359
+ valid = false;
360
+ continue;
361
+ }
362
+ input.diagnostics.push({
363
+ code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE',
364
+ message: `Field "${input.modelName}.${input.field.name}" uses unsupported attribute "@${attribute.name}"`,
365
+ sourceId: input.sourceId,
366
+ span: attribute.span,
367
+ });
368
+ valid = false;
369
+ }
370
+ return valid;
371
+ }