-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathpsl-relation-resolution.ts
More file actions
771 lines (731 loc) · 27.8 KB
/
Copy pathpsl-relation-resolution.ts
File metadata and controls
771 lines (731 loc) · 27.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types';
import type { AuthoringContributions } from '@prisma-next/framework-components/authoring';
import type {
ArgType,
FieldRefScope,
FieldSymbol,
InferAttr,
InterpretCtx,
ModelSymbol,
PslDiagnostic,
PslSpan,
SymbolTable,
} from '@prisma-next/psl-parser';
import {
fieldAttribute,
fieldRef,
identifier,
interpretAttribute,
list,
nodePslSpan,
oneOf,
optional,
str,
} from '@prisma-next/psl-parser';
import type {
AttributeArgAst,
FieldAttributeAst,
SourceFile,
} from '@prisma-next/psl-parser/syntax';
import { ArrayLiteralAst, IdentifierAst } from '@prisma-next/psl-parser/syntax';
import type { ReferentialAction } from '@prisma-next/sql-contract/types';
import type { RelationNode } from '@prisma-next/sql-contract-ts/contract-builder';
import { assertDefined, invariant } from '@prisma-next/utils/assertions';
import { ifDefined } from '@prisma-next/utils/defined';
import type { Result } from '@prisma-next/utils/result';
import { notOk, ok } from '@prisma-next/utils/result';
import {
getAttribute,
getNamedArgument,
getPositionalArgument,
parseFieldList,
} from './psl-attribute-parsing';
import { checkUncomposedNamespace, reportUncomposedNamespace } from './psl-column-resolution';
export const REFERENTIAL_ACTION_MAP: Record<string, ReferentialAction | undefined> = {
NoAction: 'noAction',
Restrict: 'restrict',
Cascade: 'cascade',
SetNull: 'setNull',
SetDefault: 'setDefault',
noAction: 'noAction',
restrict: 'restrict',
cascade: 'cascade',
setNull: 'setNull',
setDefault: 'setDefault',
};
export type FkRelationMetadata = {
readonly declaringModelName: string;
readonly declaringFieldName: string;
readonly declaringTableName: string;
/** Resolved namespace coordinate of the declaring model, when known. */
readonly declaringNamespaceId?: string;
readonly targetModelName: string;
readonly targetTableName: string;
/** Resolved namespace coordinate of the related model, when known. */
readonly targetNamespaceId?: string;
readonly relationName?: string;
readonly localColumns: readonly string[];
readonly referencedColumns: readonly string[];
};
export type ModelBackrelationCandidate = {
readonly modelName: string;
readonly tableName: string;
readonly field: FieldSymbol;
readonly targetModelName: string;
readonly relationName?: string;
/**
* The junction model named by `through:` on the list field. When present,
* many-to-many recognition considers only this junction rather than scanning
* every junction-shaped model linking the two sides.
*/
readonly through?: string;
};
type ModelRelationMetadata = RelationNode;
export function fkRelationPairKey(declaringModelName: string, targetModelName: string): string {
// NOTE: We assume PSL model identifiers do not contain the `::` separator.
return `${declaringModelName}::${targetModelName}`;
}
export function normalizeReferentialAction(actionToken: string): ReferentialAction | undefined {
// the token is already validated by the `@relation` spec's `oneOf(identifier(...))`, so this is just a lookup — no second validation path here.
return REFERENTIAL_ACTION_MAP[actionToken];
}
/**
* Accepts a `@relation` directional argument value (`from:`/`to:`): a single
* bare field (`from: userId`) or a bracketed list (`from: [a, b]`), normalised
* to a field-name array. Delegating each shape to its own combinator keeps the
* specific diagnostics (e.g. a nonexistent field) that `oneOf` would collapse
* into a generic mismatch message.
*/
function fieldRefOrList(scope: FieldRefScope): ArgType<readonly string[]> {
const single = fieldRef(scope);
const bracketed = list(fieldRef(scope), { nonEmpty: true, unique: true });
return {
kind: 'fieldRefOrList',
label: 'field name or field name[]',
parse: (arg, ctx): Result<readonly string[], readonly PslDiagnostic[]> => {
if (arg instanceof ArrayLiteralAst) {
return bracketed.parse(arg, ctx);
}
const result = single.parse(arg, ctx);
if (!result.ok) {
return result;
}
return ok([result.value]);
},
};
}
/**
* Reads a bare model-name identifier argument value (`through: PostTag`). The
* expression grammar carries only the head identifier of a member-access
* value, so a qualified `through: PostTag.post` reaches this combinator as
* the bare model name `PostTag` — the qualified disambiguation form is a
* separate grammar change, and the bare name is all this slice recognises.
*/
function modelName(): ArgType<string> {
return {
kind: 'modelName',
label: 'model name',
parse: (arg, ctx): Result<string, readonly PslDiagnostic[]> => {
if (arg instanceof IdentifierAst) {
const name = arg.name();
if (name !== undefined) {
return ok(name);
}
}
return notOk([
{
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
message: 'Expected a model name',
sourceId: ctx.sourceId,
span: nodePslSpan(arg.syntax, ctx.sourceFile),
},
]);
},
};
}
function relationInvariants(
parsed: {
readonly from?: readonly string[];
readonly to?: readonly string[];
},
ctx: InterpretCtx,
): readonly PslDiagnostic[] {
const hasFrom = parsed.from !== undefined;
const hasTo = parsed.to !== undefined;
// `to:` may stand alone only alongside `from:` — a referenced key without
// local FK fields is unresolvable, a cross-argument rule that per-argument
// parsing can't enforce. `from:` alone is fine (references are inferred from
// the target's `@id`).
if (hasTo && !hasFrom) {
return [
{
code: 'PSL_INVALID_RELATION_ATTRIBUTE',
message: `Relation field "${ctx.selfModel.name}.${ctx.field?.name ?? ''}" requires a from argument naming the local foreign-key field(s)`,
sourceId: ctx.sourceId,
span: relationAttributeSpan(ctx),
},
];
}
return [];
}
// `from:`/`to:` are the only local-fields/referenced-key arguments; both
// accept a bare field or a bracketed list. The legacy `fields:`/`references:`
// spellings are rejected up front with a guiding diagnostic (see
// interpretRelationAttribute) rather than reported as unknown arguments.
const sqlRelation = fieldAttribute('relation', {
positional: [{ key: 'name', type: optional(str()) }],
named: {
name: optional(str()),
from: optional(fieldRefOrList('self')),
to: optional(fieldRefOrList('referenced')),
through: optional(modelName()),
map: optional(str()),
onDelete: optional(
oneOf(
identifier('NoAction'),
identifier('Restrict'),
identifier('Cascade'),
identifier('SetNull'),
identifier('SetDefault'),
),
),
onUpdate: optional(
oneOf(
identifier('NoAction'),
identifier('Restrict'),
identifier('Cascade'),
identifier('SetNull'),
identifier('SetDefault'),
),
),
},
refine: relationInvariants,
});
export type SqlRelationOutput = InferAttr<typeof sqlRelation>;
/**
* The interpreted `@relation` attribute with the directional arguments
* normalised: `from:` lands in `fields` and `to:` in `references`, the names
* the resolution pipeline consumes.
*/
export type ParsedSqlRelation = {
readonly name?: string;
readonly fields?: readonly string[];
readonly references?: readonly string[];
/**
* Set when local FK fields are declared (`from:`) but the referenced key is
* omitted (`to:` absent). The caller resolves the referenced columns from
* the target model's `@id`. `references` stays undefined in this case; the
* two never co-occur.
*/
readonly referencesInferred?: true;
/**
* The junction model named by `through:` on a navigable list field, used to
* recognise the many-to-many via that explicit junction. A bare model
* identifier (`through: PostTag`); the qualified relation-field form
* (`through: PostTag.post`) is a separate member-access grammar and does not
* reach the resolver as a dotted value — only its head identifier survives.
*/
readonly through?: string;
readonly map?: string;
readonly onDelete?: SqlRelationOutput['onDelete'];
readonly onUpdate?: SqlRelationOutput['onUpdate'];
};
function findRelationAttributeNode(field: FieldSymbol): FieldAttributeAst | undefined {
for (const attribute of field.node.attributes()) {
if (attribute.name()?.path().join('.') === 'relation') {
return attribute;
}
}
return undefined;
}
function relationAttributeSpan(ctx: InterpretCtx): PslSpan {
const field = ctx.field;
if (field !== undefined) {
const node = findRelationAttributeNode(field);
if (node !== undefined) {
return nodePslSpan(node.syntax, ctx.sourceFile);
}
return field.span;
}
return ctx.selfModel.span;
}
function resolveReferencedModel(symbols: SymbolTable, field: FieldSymbol): ModelSymbol | undefined {
const topLevel = symbols.topLevel.models[field.typeName];
if (topLevel !== undefined) {
return topLevel;
}
for (const namespace of Object.values(symbols.topLevel.namespaces)) {
const model = namespace.models[field.typeName];
if (model !== undefined) {
return model;
}
}
return undefined;
}
function buildRelationInterpretCtx(input: {
readonly selfModel: ModelSymbol;
readonly field: FieldSymbol;
readonly symbols: SymbolTable;
readonly sourceFile: SourceFile;
readonly sourceId: string;
}): InterpretCtx {
return {
level: 'field',
sourceId: input.sourceId,
sourceFile: input.sourceFile,
selfModel: input.selfModel,
field: input.field,
resolveReferencedModel: () => resolveReferencedModel(input.symbols, input.field),
};
}
/**
* Finds a legacy `fields:`/`references:` argument on the `@relation` attribute
* so it can be rejected with a guiding diagnostic instead of the generic
* unknown-argument message the spec would produce.
*/
function findLegacyDirectionalArgument(
attributeNode: FieldAttributeAst,
): AttributeArgAst | undefined {
for (const arg of attributeNode.argList()?.args() ?? []) {
const name = arg.name()?.name();
if (name === 'fields' || name === 'references') {
return arg;
}
}
return undefined;
}
export function interpretRelationAttribute(input: {
readonly selfModel: ModelSymbol;
readonly field: FieldSymbol;
readonly symbols: SymbolTable;
readonly sourceFile: SourceFile;
readonly sourceId: string;
readonly diagnostics: ContractSourceDiagnostic[];
}): ParsedSqlRelation | undefined {
const attributeNode = findRelationAttributeNode(input.field);
if (attributeNode === undefined) {
return undefined;
}
const legacyArgument = findLegacyDirectionalArgument(attributeNode);
if (legacyArgument !== undefined) {
input.diagnostics.push({
code: 'PSL_LEGACY_FIELDS_REFERENCES',
message: `Relation field "${input.selfModel.name}.${input.field.name}" uses @relation(fields:/references:), which is no longer supported — use from:/to: instead`,
sourceId: input.sourceId,
span: nodePslSpan(legacyArgument.syntax, input.sourceFile),
});
return undefined;
}
const ctx = buildRelationInterpretCtx(input);
const result = interpretAttribute(attributeNode, sqlRelation, ctx);
if (!result.ok) {
for (const failure of result.failure) {
input.diagnostics.push(failure);
}
return undefined;
}
const value = result.value;
const fields = value.from;
const references = value.to;
const referencesInferred: true | undefined =
fields !== undefined && references === undefined ? true : undefined;
return {
...ifDefined('name', value.name),
...ifDefined('fields', fields),
...ifDefined('references', references),
...ifDefined('referencesInferred', referencesInferred),
...ifDefined('through', value.through),
...ifDefined('map', value.map),
...ifDefined('onDelete', value.onDelete),
...ifDefined('onUpdate', value.onUpdate),
};
}
/**
* Resolves a model's `@id` field names in declaration order — an inline `@id`
* on a single field, or a model-level `@@id([...])` list. Returns undefined
* when the model declares no identity, which is what makes an omitted `to:`
* un-inferable for a relation targeting it.
*/
export function resolveTargetIdFieldNames(model: ModelSymbol): readonly string[] | undefined {
const blockId = getAttribute(model.attributes, 'id');
if (blockId) {
const raw = getNamedArgument(blockId, 'fields') ?? getPositionalArgument(blockId);
const fields = raw ? parseFieldList(raw) : undefined;
if (fields && fields.length > 0) {
return fields;
}
return undefined;
}
const inlineIdFields = Object.values(model.fields).filter((field) =>
field.attributes.some((attribute) => attribute.name === 'id'),
);
if (inlineIdFields.length === 1) {
const idField = inlineIdFields[0];
return idField ? [idField.name] : undefined;
}
return undefined;
}
export function indexFkRelations(input: {
readonly fkRelationMetadata: readonly FkRelationMetadata[];
}): {
readonly modelRelations: Map<string, ModelRelationMetadata[]>;
readonly fkRelationsByPair: Map<string, FkRelationMetadata[]>;
readonly fkRelationsByDeclaringModel: Map<string, FkRelationMetadata[]>;
} {
const modelRelations = new Map<string, ModelRelationMetadata[]>();
const fkRelationsByPair = new Map<string, FkRelationMetadata[]>();
const fkRelationsByDeclaringModel = new Map<string, FkRelationMetadata[]>();
for (const relation of input.fkRelationMetadata) {
const declaringFkRelations = fkRelationsByDeclaringModel.get(relation.declaringModelName);
if (declaringFkRelations) {
declaringFkRelations.push(relation);
} else {
fkRelationsByDeclaringModel.set(relation.declaringModelName, [relation]);
}
const existing = modelRelations.get(relation.declaringModelName);
const current = existing ?? [];
if (!existing) {
modelRelations.set(relation.declaringModelName, current);
}
current.push({
fieldName: relation.declaringFieldName,
toModel: relation.targetModelName,
toTable: relation.targetTableName,
...ifDefined('toNamespaceId', relation.targetNamespaceId),
cardinality: 'N:1',
on: {
parentTable: relation.declaringTableName,
parentColumns: relation.localColumns,
childTable: relation.targetTableName,
childColumns: relation.referencedColumns,
},
});
const pairKey = fkRelationPairKey(relation.declaringModelName, relation.targetModelName);
const pairRelations = fkRelationsByPair.get(pairKey);
if (!pairRelations) {
fkRelationsByPair.set(pairKey, [relation]);
continue;
}
pairRelations.push(relation);
}
return { modelRelations, fkRelationsByPair, fkRelationsByDeclaringModel };
}
type JunctionFkPair = {
readonly parentFk: FkRelationMetadata;
readonly childFk: FkRelationMetadata;
/**
* The child FK's junction columns reordered to the target model's
* id-column order, so positional pairing against the target id stays
* faithful to the authored references regardless of declaration order.
*/
readonly childColumnsInTargetIdOrder: readonly string[];
};
function idColumnsAreExactlyFkPair(
idColumns: readonly string[],
parentColumns: readonly string[],
childColumns: readonly string[],
): boolean {
if (idColumns.length !== parentColumns.length + childColumns.length) {
return false;
}
const fkColumns = new Set([...parentColumns, ...childColumns]);
if (fkColumns.size !== parentColumns.length + childColumns.length) {
return false;
}
return idColumns.every((column) => fkColumns.has(column));
}
/**
* Reorders the child FK's junction columns into the target model's id-column
* order. Returns undefined unless the FK references exactly the target's full
* id, because downstream consumers pair `through.childColumns` positionally
* against the target id columns — an FK referencing anything else (a non-id
* unique, a partial id) would produce a silently wrong join.
*/
function childColumnsInTargetIdOrder(
childFk: FkRelationMetadata,
targetIdColumns: readonly string[],
): readonly string[] | undefined {
if (childFk.referencedColumns.length !== targetIdColumns.length) {
return undefined;
}
const localByReferenced = new Map<string, string>();
for (const [index, referencedColumn] of childFk.referencedColumns.entries()) {
const localColumn = childFk.localColumns[index];
if (localColumn === undefined) {
return undefined;
}
localByReferenced.set(referencedColumn, localColumn);
}
if (localByReferenced.size !== targetIdColumns.length) {
return undefined;
}
const ordered: string[] = [];
for (const idColumn of targetIdColumns) {
const localColumn = localByReferenced.get(idColumn);
if (localColumn === undefined) {
return undefined;
}
ordered.push(localColumn);
}
return ordered;
}
/**
* A model that carries an FK back to the candidate's model and an FK to the
* candidate's target model — i.e. it is junction-shaped for this candidate —
* but was declined as a many-to-many junction. The reason drives a
* junction-specific diagnostic that is more actionable than the generic
* orphaned-backrelation message.
*/
type JunctionNearMiss = {
readonly junctionModelName: string;
readonly reason: 'id-not-fk-covering' | 'target-fk-not-id';
};
/**
* Finds explicit junction models that connect a bare backrelation list field
* to its target model: a model whose composite id columns are exactly the FK
* columns of one relation back to the candidate's model (the parent side) and
* one relation to the candidate's target model (the child side). The child
* FK must reference exactly the target model's id columns; its junction
* columns are carried in target-id order on the pair. A relation name on the
* list field pins the parent-side FK relation, which is how self-referential
* many-to-many sides are disambiguated.
*
* Alongside the recognised pairs, returns junction-shaped near-misses (models
* that link both sides but were declined) so the caller can emit a
* junction-specific diagnostic instead of the generic orphaned-list message.
*/
function findJunctionFkPairs(input: {
readonly candidate: ModelBackrelationCandidate;
readonly fkRelationsByDeclaringModel: ReadonlyMap<string, readonly FkRelationMetadata[]>;
readonly modelIdColumns: ReadonlyMap<string, readonly string[]>;
}): { readonly pairs: JunctionFkPair[]; readonly nearMisses: JunctionNearMiss[] } {
const targetIdColumns = input.modelIdColumns.get(input.candidate.targetModelName);
if (!targetIdColumns || targetIdColumns.length === 0) {
return { pairs: [], nearMisses: [] };
}
const pairs: JunctionFkPair[] = [];
const nearMisses: JunctionNearMiss[] = [];
for (const [junctionModelName, junctionFks] of input.fkRelationsByDeclaringModel) {
// An explicit `through:` names the junction directly: skip every other
// junction-shaped model so recognition and near-miss reporting are scoped
// to the authored junction. A bare list (no `through:`) scans all of them.
if (input.candidate.through !== undefined && junctionModelName !== input.candidate.through) {
continue;
}
const idColumns = input.modelIdColumns.get(junctionModelName);
for (const parentFk of junctionFks) {
if (parentFk.targetModelName !== input.candidate.modelName) {
continue;
}
if (
input.candidate.relationName !== undefined &&
parentFk.relationName !== input.candidate.relationName
) {
continue;
}
for (const childFk of junctionFks) {
if (childFk === parentFk || childFk.targetModelName !== input.candidate.targetModelName) {
continue;
}
// The model links both sides, so it is junction-shaped for this
// candidate: record why it is declined rather than silently skipping.
if (
!idColumns ||
!idColumnsAreExactlyFkPair(idColumns, parentFk.localColumns, childFk.localColumns)
) {
nearMisses.push({ junctionModelName, reason: 'id-not-fk-covering' });
continue;
}
const orderedChildColumns = childColumnsInTargetIdOrder(childFk, targetIdColumns);
if (!orderedChildColumns) {
nearMisses.push({ junctionModelName, reason: 'target-fk-not-id' });
continue;
}
pairs.push({ parentFk, childFk, childColumnsInTargetIdOrder: orderedChildColumns });
}
}
}
return { pairs, nearMisses };
}
function junctionNearMissDiagnostic(
candidate: ModelBackrelationCandidate,
nearMiss: JunctionNearMiss,
sourceId: string,
): ContractSourceDiagnostic {
const listField = `${candidate.modelName}.${candidate.field.name}`;
const data = {
listField,
junctionModel: nearMiss.junctionModelName,
targetModel: candidate.targetModelName,
};
if (nearMiss.reason === 'target-fk-not-id') {
return {
code: 'PSL_JUNCTION_TARGET_FK_NOT_ID',
message: `Backrelation list field "${listField}" found junction model "${nearMiss.junctionModelName}", but its foreign key to "${candidate.targetModelName}" does not reference "${candidate.targetModelName}"'s @id. The junction's target-side foreign key must reference "${candidate.targetModelName}"'s full @id columns for many-to-many recognition.`,
sourceId,
span: candidate.field.span,
data,
};
}
return {
code: 'PSL_JUNCTION_ID_NOT_FK_COVERING',
message: `Backrelation list field "${listField}" found junction-shaped model "${nearMiss.junctionModelName}" linking "${candidate.modelName}" and "${candidate.targetModelName}", but its id does not cover exactly its foreign-key columns. Declare @@id([...]) on "${nearMiss.junctionModelName}" listing exactly the two foreign-key columns for many-to-many recognition.`,
sourceId,
span: candidate.field.span,
data,
};
}
function manyToManyRelationNode(
candidate: ModelBackrelationCandidate,
pair: JunctionFkPair,
): ModelRelationMetadata {
return {
fieldName: candidate.field.name,
toModel: pair.childFk.targetModelName,
toTable: pair.childFk.targetTableName,
...ifDefined('toNamespaceId', pair.childFk.targetNamespaceId),
cardinality: 'N:M',
on: {
parentTable: candidate.tableName,
parentColumns: pair.parentFk.referencedColumns,
childTable: pair.parentFk.declaringTableName,
childColumns: pair.parentFk.localColumns,
},
through: {
table: pair.parentFk.declaringTableName,
...ifDefined('namespaceId', pair.parentFk.declaringNamespaceId),
parentColumns: pair.parentFk.localColumns,
childColumns: pair.childColumnsInTargetIdOrder,
},
};
}
function relationsForModel(
modelRelations: Map<string, ModelRelationMetadata[]>,
modelName: string,
): ModelRelationMetadata[] {
const existing = modelRelations.get(modelName);
if (existing) {
return existing;
}
const created: ModelRelationMetadata[] = [];
modelRelations.set(modelName, created);
return created;
}
export function applyBackrelationCandidates(input: {
readonly backrelationCandidates: readonly ModelBackrelationCandidate[];
readonly fkRelationsByPair: Map<string, readonly FkRelationMetadata[]>;
readonly fkRelationsByDeclaringModel: ReadonlyMap<string, readonly FkRelationMetadata[]>;
readonly modelIdColumns: ReadonlyMap<string, readonly string[]>;
readonly modelRelations: Map<string, ModelRelationMetadata[]>;
readonly diagnostics: ContractSourceDiagnostic[];
readonly sourceId: string;
}): void {
for (const candidate of input.backrelationCandidates) {
const pairKey = fkRelationPairKey(candidate.targetModelName, candidate.modelName);
const pairMatches = input.fkRelationsByPair.get(pairKey) ?? [];
const matches = candidate.relationName
? pairMatches.filter((relation) => relation.relationName === candidate.relationName)
: [...pairMatches];
if (matches.length === 0) {
const { pairs: junctionPairs, nearMisses } = findJunctionFkPairs({
candidate,
fkRelationsByDeclaringModel: input.fkRelationsByDeclaringModel,
modelIdColumns: input.modelIdColumns,
});
const junctionPair = junctionPairs[0];
if (junctionPairs.length === 1 && junctionPair) {
relationsForModel(input.modelRelations, candidate.modelName).push(
manyToManyRelationNode(candidate, junctionPair),
);
continue;
}
if (junctionPairs.length > 1) {
input.diagnostics.push({
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple junction FK pairs for a many-to-many relation. Add @relation(name: "...") (or @relation("...")) to the list field and the junction FK-side relation pointing back at "${candidate.modelName}" to disambiguate.`,
sourceId: input.sourceId,
span: candidate.field.span,
});
continue;
}
const nearMiss = nearMisses[0];
if (nearMiss) {
input.diagnostics.push(junctionNearMissDiagnostic(candidate, nearMiss, input.sourceId));
continue;
}
input.diagnostics.push({
code: 'PSL_ORPHANED_BACKRELATION_LIST',
message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(from: [...], to: [...]) on the FK-side relation or use an explicit join model for many-to-many.`,
sourceId: input.sourceId,
span: candidate.field.span,
});
continue;
}
if (matches.length > 1) {
input.diagnostics.push({
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
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.`,
sourceId: input.sourceId,
span: candidate.field.span,
});
continue;
}
invariant(matches.length === 1, 'Backrelation matching requires exactly one match');
const matched = matches[0];
assertDefined(matched, 'Backrelation matching requires a defined relation match');
relationsForModel(input.modelRelations, candidate.modelName).push({
fieldName: candidate.field.name,
toModel: matched.declaringModelName,
toTable: matched.declaringTableName,
...ifDefined('toNamespaceId', matched.declaringNamespaceId),
cardinality: '1:N',
on: {
parentTable: candidate.tableName,
parentColumns: matched.referencedColumns,
childTable: matched.declaringTableName,
childColumns: matched.localColumns,
},
});
}
}
export function validateNavigationListFieldAttributes(input: {
readonly modelName: string;
readonly field: FieldSymbol;
readonly sourceId: string;
readonly composedExtensions: Set<string>;
readonly authoringContributions: AuthoringContributions | undefined;
readonly diagnostics: ContractSourceDiagnostic[];
readonly familyId: string;
readonly targetId: string;
}): boolean {
let valid = true;
for (const attribute of input.field.attributes) {
if (attribute.name === 'relation') {
continue;
}
const uncomposedNamespace = checkUncomposedNamespace(attribute.name, input.composedExtensions, {
familyId: input.familyId,
targetId: input.targetId,
authoringContributions: input.authoringContributions,
});
if (uncomposedNamespace) {
reportUncomposedNamespace({
subjectLabel: `Attribute "@${attribute.name}"`,
namespace: uncomposedNamespace,
sourceId: input.sourceId,
span: attribute.span,
diagnostics: input.diagnostics,
});
valid = false;
continue;
}
input.diagnostics.push({
code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE',
message: `Field "${input.modelName}.${input.field.name}" uses unsupported attribute "@${attribute.name}"`,
sourceId: input.sourceId,
span: attribute.span,
});
valid = false;
}
return valid;
}