@prisma-next/sql-contract-psl 0.14.0-dev.1 → 0.14.0-dev.10
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.
- package/README.md +11 -7
- package/dist/index.d.mts +7 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{interpreter-B0BsCLKT.mjs → interpreter-CygvamTk.mjs} +435 -232
- package/dist/interpreter-CygvamTk.mjs.map +1 -0
- package/dist/provider.d.mts.map +1 -1
- package/dist/provider.mjs +22 -7
- package/dist/provider.mjs.map +1 -1
- package/package.json +12 -12
- package/src/interpreter.ts +151 -323
- package/src/provider.ts +38 -9
- package/src/psl-attribute-parsing.ts +18 -14
- package/src/psl-authoring-arguments.ts +2 -2
- package/src/psl-column-resolution.ts +17 -15
- package/src/psl-field-resolution.ts +28 -20
- package/src/psl-named-type-resolution.ts +250 -0
- package/src/psl-relation-resolution.ts +250 -11
- package/dist/interpreter-B0BsCLKT.mjs.map +0 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types';
|
|
2
|
+
import type { AuthoringContributions } from '@prisma-next/framework-components/authoring';
|
|
3
|
+
import type { ResolvedAttribute, ScalarSymbol, TypeAliasSymbol } from '@prisma-next/psl-parser';
|
|
4
|
+
import type { StorageTypeInstance } from '@prisma-next/sql-contract/types';
|
|
5
|
+
import {
|
|
6
|
+
type ColumnDescriptor,
|
|
7
|
+
checkUncomposedNamespace,
|
|
8
|
+
instantiatePslTypeConstructor,
|
|
9
|
+
reportUncomposedNamespace,
|
|
10
|
+
resolveDbNativeTypeAttribute,
|
|
11
|
+
resolvePslTypeConstructorDescriptor,
|
|
12
|
+
toNamedTypeFieldDescriptor,
|
|
13
|
+
} from './psl-column-resolution';
|
|
14
|
+
|
|
15
|
+
type NamedTypeSymbol = ScalarSymbol | TypeAliasSymbol;
|
|
16
|
+
|
|
17
|
+
export interface ResolveNamedTypeDeclarationsInput {
|
|
18
|
+
readonly declarations: readonly NamedTypeSymbol[];
|
|
19
|
+
readonly sourceId: string;
|
|
20
|
+
readonly enumTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
|
|
21
|
+
readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
|
|
22
|
+
readonly composedExtensions: ReadonlySet<string>;
|
|
23
|
+
readonly familyId: string;
|
|
24
|
+
readonly targetId: string;
|
|
25
|
+
readonly authoringContributions: AuthoringContributions | undefined;
|
|
26
|
+
readonly diagnostics: ContractSourceDiagnostic[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateNamedTypeAttributes(input: {
|
|
30
|
+
readonly declaration: NamedTypeSymbol;
|
|
31
|
+
readonly sourceId: string;
|
|
32
|
+
readonly diagnostics: ContractSourceDiagnostic[];
|
|
33
|
+
readonly composedExtensions: ReadonlySet<string>;
|
|
34
|
+
readonly authoringContributions: AuthoringContributions | undefined;
|
|
35
|
+
readonly allowDbNativeType: boolean;
|
|
36
|
+
readonly familyId: string;
|
|
37
|
+
readonly targetId: string;
|
|
38
|
+
}): {
|
|
39
|
+
readonly dbNativeTypeAttribute: ResolvedAttribute | undefined;
|
|
40
|
+
readonly hasUnsupportedNamedTypeAttribute: boolean;
|
|
41
|
+
} {
|
|
42
|
+
const dbNativeTypeAttributes = input.allowDbNativeType
|
|
43
|
+
? input.declaration.attributes.filter((attribute) => attribute.name.startsWith('db.'))
|
|
44
|
+
: [];
|
|
45
|
+
const [dbNativeTypeAttribute, ...extraDbNativeTypeAttributes] = dbNativeTypeAttributes;
|
|
46
|
+
let hasUnsupportedNamedTypeAttribute = false;
|
|
47
|
+
|
|
48
|
+
for (const extra of extraDbNativeTypeAttributes) {
|
|
49
|
+
input.diagnostics.push({
|
|
50
|
+
code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
|
|
51
|
+
message: `Named type "${input.declaration.name}" can declare at most one @db.* attribute`,
|
|
52
|
+
sourceId: input.sourceId,
|
|
53
|
+
span: extra.span,
|
|
54
|
+
});
|
|
55
|
+
hasUnsupportedNamedTypeAttribute = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const attribute of input.declaration.attributes) {
|
|
59
|
+
if (input.allowDbNativeType && attribute.name.startsWith('db.')) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const uncomposedNamespace = checkUncomposedNamespace(attribute.name, input.composedExtensions, {
|
|
64
|
+
familyId: input.familyId,
|
|
65
|
+
targetId: input.targetId,
|
|
66
|
+
authoringContributions: input.authoringContributions,
|
|
67
|
+
});
|
|
68
|
+
if (uncomposedNamespace) {
|
|
69
|
+
reportUncomposedNamespace({
|
|
70
|
+
subjectLabel: `Attribute "@${attribute.name}"`,
|
|
71
|
+
namespace: uncomposedNamespace,
|
|
72
|
+
sourceId: input.sourceId,
|
|
73
|
+
span: attribute.span,
|
|
74
|
+
diagnostics: input.diagnostics,
|
|
75
|
+
});
|
|
76
|
+
hasUnsupportedNamedTypeAttribute = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
input.diagnostics.push({
|
|
81
|
+
code: 'PSL_UNSUPPORTED_NAMED_TYPE_ATTRIBUTE',
|
|
82
|
+
message: `Named type "${input.declaration.name}" uses unsupported attribute "${attribute.name}"`,
|
|
83
|
+
sourceId: input.sourceId,
|
|
84
|
+
span: attribute.span,
|
|
85
|
+
});
|
|
86
|
+
hasUnsupportedNamedTypeAttribute = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { dbNativeTypeAttribute, hasUnsupportedNamedTypeAttribute };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveNamedTypeDeclarations(input: ResolveNamedTypeDeclarationsInput): {
|
|
93
|
+
readonly storageTypes: Record<string, StorageTypeInstance>;
|
|
94
|
+
readonly namedTypeDescriptors: Map<string, ColumnDescriptor>;
|
|
95
|
+
} {
|
|
96
|
+
const storageTypeEntries: [string, StorageTypeInstance][] = [];
|
|
97
|
+
const namedTypeDescriptors = new Map<string, ColumnDescriptor>();
|
|
98
|
+
|
|
99
|
+
for (const declaration of input.declarations) {
|
|
100
|
+
if (declaration.isConstructor) {
|
|
101
|
+
const typeConstructor = declaration.typeConstructor;
|
|
102
|
+
if (typeConstructor === undefined) {
|
|
103
|
+
input.diagnostics.push({
|
|
104
|
+
code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE',
|
|
105
|
+
message: `Named type "${declaration.name}" must declare a base type or constructor`,
|
|
106
|
+
sourceId: input.sourceId,
|
|
107
|
+
span: declaration.span,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { hasUnsupportedNamedTypeAttribute } = validateNamedTypeAttributes({
|
|
113
|
+
declaration,
|
|
114
|
+
sourceId: input.sourceId,
|
|
115
|
+
diagnostics: input.diagnostics,
|
|
116
|
+
composedExtensions: input.composedExtensions,
|
|
117
|
+
authoringContributions: input.authoringContributions,
|
|
118
|
+
allowDbNativeType: false,
|
|
119
|
+
familyId: input.familyId,
|
|
120
|
+
targetId: input.targetId,
|
|
121
|
+
});
|
|
122
|
+
if (hasUnsupportedNamedTypeAttribute) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const helperPath = typeConstructor.path.join('.');
|
|
127
|
+
const descriptor = resolvePslTypeConstructorDescriptor({
|
|
128
|
+
call: typeConstructor,
|
|
129
|
+
authoringContributions: input.authoringContributions,
|
|
130
|
+
composedExtensions: input.composedExtensions,
|
|
131
|
+
familyId: input.familyId,
|
|
132
|
+
targetId: input.targetId,
|
|
133
|
+
diagnostics: input.diagnostics,
|
|
134
|
+
sourceId: input.sourceId,
|
|
135
|
+
unsupportedCode: 'PSL_UNSUPPORTED_NAMED_TYPE_CONSTRUCTOR',
|
|
136
|
+
unsupportedMessage: `Named type "${declaration.name}" references unsupported constructor "${helperPath}"`,
|
|
137
|
+
});
|
|
138
|
+
if (!descriptor) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const storageType = instantiatePslTypeConstructor({
|
|
143
|
+
call: typeConstructor,
|
|
144
|
+
descriptor,
|
|
145
|
+
diagnostics: input.diagnostics,
|
|
146
|
+
sourceId: input.sourceId,
|
|
147
|
+
entityLabel: `Named type "${declaration.name}"`,
|
|
148
|
+
});
|
|
149
|
+
if (!storageType) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
namedTypeDescriptors.set(
|
|
154
|
+
declaration.name,
|
|
155
|
+
toNamedTypeFieldDescriptor(declaration.name, storageType),
|
|
156
|
+
);
|
|
157
|
+
storageTypeEntries.push([
|
|
158
|
+
declaration.name,
|
|
159
|
+
{
|
|
160
|
+
kind: 'codec-instance',
|
|
161
|
+
codecId: storageType.codecId,
|
|
162
|
+
nativeType: storageType.nativeType,
|
|
163
|
+
typeParams: storageType.typeParams ?? {},
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const baseType = declaration.baseType;
|
|
170
|
+
if (baseType === undefined) {
|
|
171
|
+
input.diagnostics.push({
|
|
172
|
+
code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE',
|
|
173
|
+
message: `Named type "${declaration.name}" must declare a base type or constructor`,
|
|
174
|
+
sourceId: input.sourceId,
|
|
175
|
+
span: declaration.span,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const baseDescriptor =
|
|
181
|
+
input.enumTypeDescriptors.get(baseType) ?? input.scalarTypeDescriptors.get(baseType);
|
|
182
|
+
if (!baseDescriptor) {
|
|
183
|
+
input.diagnostics.push({
|
|
184
|
+
code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE',
|
|
185
|
+
message: `Named type "${declaration.name}" references unsupported base type "${baseType}"`,
|
|
186
|
+
sourceId: input.sourceId,
|
|
187
|
+
span: declaration.span,
|
|
188
|
+
});
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { dbNativeTypeAttribute, hasUnsupportedNamedTypeAttribute } = validateNamedTypeAttributes(
|
|
193
|
+
{
|
|
194
|
+
declaration,
|
|
195
|
+
sourceId: input.sourceId,
|
|
196
|
+
diagnostics: input.diagnostics,
|
|
197
|
+
composedExtensions: input.composedExtensions,
|
|
198
|
+
authoringContributions: input.authoringContributions,
|
|
199
|
+
allowDbNativeType: true,
|
|
200
|
+
familyId: input.familyId,
|
|
201
|
+
targetId: input.targetId,
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
if (hasUnsupportedNamedTypeAttribute) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (dbNativeTypeAttribute) {
|
|
209
|
+
const descriptor = resolveDbNativeTypeAttribute({
|
|
210
|
+
attribute: dbNativeTypeAttribute,
|
|
211
|
+
baseType,
|
|
212
|
+
baseDescriptor,
|
|
213
|
+
diagnostics: input.diagnostics,
|
|
214
|
+
sourceId: input.sourceId,
|
|
215
|
+
entityLabel: `Named type "${declaration.name}"`,
|
|
216
|
+
});
|
|
217
|
+
if (!descriptor) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
namedTypeDescriptors.set(
|
|
221
|
+
declaration.name,
|
|
222
|
+
toNamedTypeFieldDescriptor(declaration.name, descriptor),
|
|
223
|
+
);
|
|
224
|
+
storageTypeEntries.push([
|
|
225
|
+
declaration.name,
|
|
226
|
+
{
|
|
227
|
+
kind: 'codec-instance',
|
|
228
|
+
codecId: descriptor.codecId,
|
|
229
|
+
nativeType: descriptor.nativeType,
|
|
230
|
+
typeParams: descriptor.typeParams ?? {},
|
|
231
|
+
},
|
|
232
|
+
]);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const descriptor = toNamedTypeFieldDescriptor(declaration.name, baseDescriptor);
|
|
237
|
+
namedTypeDescriptors.set(declaration.name, descriptor);
|
|
238
|
+
storageTypeEntries.push([
|
|
239
|
+
declaration.name,
|
|
240
|
+
{
|
|
241
|
+
kind: 'codec-instance',
|
|
242
|
+
codecId: baseDescriptor.codecId,
|
|
243
|
+
nativeType: baseDescriptor.nativeType,
|
|
244
|
+
typeParams: {},
|
|
245
|
+
},
|
|
246
|
+
]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { storageTypes: Object.fromEntries(storageTypeEntries), namedTypeDescriptors };
|
|
250
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types';
|
|
2
2
|
import type { AuthoringContributions } from '@prisma-next/framework-components/authoring';
|
|
3
|
-
import type {
|
|
3
|
+
import type { FieldSymbol, PslSpan, ResolvedAttribute } from '@prisma-next/psl-parser';
|
|
4
4
|
import type { ReferentialAction } from '@prisma-next/sql-contract/types';
|
|
5
5
|
import type { RelationNode } from '@prisma-next/sql-contract-ts/contract-builder';
|
|
6
6
|
import { assertDefined, invariant } from '@prisma-next/utils/assertions';
|
|
7
7
|
import { ifDefined } from '@prisma-next/utils/defined';
|
|
8
|
+
|
|
8
9
|
import {
|
|
9
10
|
getNamedArgument,
|
|
10
11
|
getPositionalArgumentEntry,
|
|
@@ -40,6 +41,8 @@ export type FkRelationMetadata = {
|
|
|
40
41
|
readonly declaringModelName: string;
|
|
41
42
|
readonly declaringFieldName: string;
|
|
42
43
|
readonly declaringTableName: string;
|
|
44
|
+
/** Resolved namespace coordinate of the declaring model, when known. */
|
|
45
|
+
readonly declaringNamespaceId?: string;
|
|
43
46
|
readonly targetModelName: string;
|
|
44
47
|
readonly targetTableName: string;
|
|
45
48
|
/** Resolved namespace coordinate of the related model, when known. */
|
|
@@ -52,7 +55,7 @@ export type FkRelationMetadata = {
|
|
|
52
55
|
export type ModelBackrelationCandidate = {
|
|
53
56
|
readonly modelName: string;
|
|
54
57
|
readonly tableName: string;
|
|
55
|
-
readonly field:
|
|
58
|
+
readonly field: FieldSymbol;
|
|
56
59
|
readonly targetModelName: string;
|
|
57
60
|
readonly relationName?: string;
|
|
58
61
|
};
|
|
@@ -88,7 +91,7 @@ export function normalizeReferentialAction(input: {
|
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
export function parseRelationAttribute(input: {
|
|
91
|
-
readonly attribute:
|
|
94
|
+
readonly attribute: ResolvedAttribute;
|
|
92
95
|
readonly modelName: string;
|
|
93
96
|
readonly fieldName: string;
|
|
94
97
|
readonly sourceId: string;
|
|
@@ -239,11 +242,20 @@ export function indexFkRelations(input: {
|
|
|
239
242
|
}): {
|
|
240
243
|
readonly modelRelations: Map<string, ModelRelationMetadata[]>;
|
|
241
244
|
readonly fkRelationsByPair: Map<string, FkRelationMetadata[]>;
|
|
245
|
+
readonly fkRelationsByDeclaringModel: Map<string, FkRelationMetadata[]>;
|
|
242
246
|
} {
|
|
243
247
|
const modelRelations = new Map<string, ModelRelationMetadata[]>();
|
|
244
248
|
const fkRelationsByPair = new Map<string, FkRelationMetadata[]>();
|
|
249
|
+
const fkRelationsByDeclaringModel = new Map<string, FkRelationMetadata[]>();
|
|
245
250
|
|
|
246
251
|
for (const relation of input.fkRelationMetadata) {
|
|
252
|
+
const declaringFkRelations = fkRelationsByDeclaringModel.get(relation.declaringModelName);
|
|
253
|
+
if (declaringFkRelations) {
|
|
254
|
+
declaringFkRelations.push(relation);
|
|
255
|
+
} else {
|
|
256
|
+
fkRelationsByDeclaringModel.set(relation.declaringModelName, [relation]);
|
|
257
|
+
}
|
|
258
|
+
|
|
247
259
|
const existing = modelRelations.get(relation.declaringModelName);
|
|
248
260
|
const current = existing ?? [];
|
|
249
261
|
if (!existing) {
|
|
@@ -272,12 +284,217 @@ export function indexFkRelations(input: {
|
|
|
272
284
|
pairRelations.push(relation);
|
|
273
285
|
}
|
|
274
286
|
|
|
275
|
-
return { modelRelations, fkRelationsByPair };
|
|
287
|
+
return { modelRelations, fkRelationsByPair, fkRelationsByDeclaringModel };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
type JunctionFkPair = {
|
|
291
|
+
readonly parentFk: FkRelationMetadata;
|
|
292
|
+
readonly childFk: FkRelationMetadata;
|
|
293
|
+
/**
|
|
294
|
+
* The child FK's junction columns reordered to the target model's
|
|
295
|
+
* id-column order, so positional pairing against the target id stays
|
|
296
|
+
* faithful to the authored references regardless of declaration order.
|
|
297
|
+
*/
|
|
298
|
+
readonly childColumnsInTargetIdOrder: readonly string[];
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
function idColumnsAreExactlyFkPair(
|
|
302
|
+
idColumns: readonly string[],
|
|
303
|
+
parentColumns: readonly string[],
|
|
304
|
+
childColumns: readonly string[],
|
|
305
|
+
): boolean {
|
|
306
|
+
if (idColumns.length !== parentColumns.length + childColumns.length) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
const fkColumns = new Set([...parentColumns, ...childColumns]);
|
|
310
|
+
if (fkColumns.size !== parentColumns.length + childColumns.length) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
return idColumns.every((column) => fkColumns.has(column));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Reorders the child FK's junction columns into the target model's id-column
|
|
318
|
+
* order. Returns undefined unless the FK references exactly the target's full
|
|
319
|
+
* id, because downstream consumers pair `through.childColumns` positionally
|
|
320
|
+
* against the target id columns — an FK referencing anything else (a non-id
|
|
321
|
+
* unique, a partial id) would produce a silently wrong join.
|
|
322
|
+
*/
|
|
323
|
+
function childColumnsInTargetIdOrder(
|
|
324
|
+
childFk: FkRelationMetadata,
|
|
325
|
+
targetIdColumns: readonly string[],
|
|
326
|
+
): readonly string[] | undefined {
|
|
327
|
+
if (childFk.referencedColumns.length !== targetIdColumns.length) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
const localByReferenced = new Map<string, string>();
|
|
331
|
+
for (const [index, referencedColumn] of childFk.referencedColumns.entries()) {
|
|
332
|
+
const localColumn = childFk.localColumns[index];
|
|
333
|
+
if (localColumn === undefined) {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
localByReferenced.set(referencedColumn, localColumn);
|
|
337
|
+
}
|
|
338
|
+
if (localByReferenced.size !== targetIdColumns.length) {
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
const ordered: string[] = [];
|
|
342
|
+
for (const idColumn of targetIdColumns) {
|
|
343
|
+
const localColumn = localByReferenced.get(idColumn);
|
|
344
|
+
if (localColumn === undefined) {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
ordered.push(localColumn);
|
|
348
|
+
}
|
|
349
|
+
return ordered;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A model that carries an FK back to the candidate's model and an FK to the
|
|
354
|
+
* candidate's target model — i.e. it is junction-shaped for this candidate —
|
|
355
|
+
* but was declined as a many-to-many junction. The reason drives a
|
|
356
|
+
* junction-specific diagnostic that is more actionable than the generic
|
|
357
|
+
* orphaned-backrelation message.
|
|
358
|
+
*/
|
|
359
|
+
type JunctionNearMiss = {
|
|
360
|
+
readonly junctionModelName: string;
|
|
361
|
+
readonly reason: 'id-not-fk-covering' | 'target-fk-not-id';
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Finds explicit junction models that connect a bare backrelation list field
|
|
366
|
+
* to its target model: a model whose composite id columns are exactly the FK
|
|
367
|
+
* columns of one relation back to the candidate's model (the parent side) and
|
|
368
|
+
* one relation to the candidate's target model (the child side). The child
|
|
369
|
+
* FK must reference exactly the target model's id columns; its junction
|
|
370
|
+
* columns are carried in target-id order on the pair. A relation name on the
|
|
371
|
+
* list field pins the parent-side FK relation, which is how self-referential
|
|
372
|
+
* many-to-many sides are disambiguated.
|
|
373
|
+
*
|
|
374
|
+
* Alongside the recognised pairs, returns junction-shaped near-misses (models
|
|
375
|
+
* that link both sides but were declined) so the caller can emit a
|
|
376
|
+
* junction-specific diagnostic instead of the generic orphaned-list message.
|
|
377
|
+
*/
|
|
378
|
+
function findJunctionFkPairs(input: {
|
|
379
|
+
readonly candidate: ModelBackrelationCandidate;
|
|
380
|
+
readonly fkRelationsByDeclaringModel: ReadonlyMap<string, readonly FkRelationMetadata[]>;
|
|
381
|
+
readonly modelIdColumns: ReadonlyMap<string, readonly string[]>;
|
|
382
|
+
}): { readonly pairs: JunctionFkPair[]; readonly nearMisses: JunctionNearMiss[] } {
|
|
383
|
+
const targetIdColumns = input.modelIdColumns.get(input.candidate.targetModelName);
|
|
384
|
+
if (!targetIdColumns || targetIdColumns.length === 0) {
|
|
385
|
+
return { pairs: [], nearMisses: [] };
|
|
386
|
+
}
|
|
387
|
+
const pairs: JunctionFkPair[] = [];
|
|
388
|
+
const nearMisses: JunctionNearMiss[] = [];
|
|
389
|
+
for (const [junctionModelName, junctionFks] of input.fkRelationsByDeclaringModel) {
|
|
390
|
+
const idColumns = input.modelIdColumns.get(junctionModelName);
|
|
391
|
+
for (const parentFk of junctionFks) {
|
|
392
|
+
if (parentFk.targetModelName !== input.candidate.modelName) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (
|
|
396
|
+
input.candidate.relationName !== undefined &&
|
|
397
|
+
parentFk.relationName !== input.candidate.relationName
|
|
398
|
+
) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
for (const childFk of junctionFks) {
|
|
402
|
+
if (childFk === parentFk || childFk.targetModelName !== input.candidate.targetModelName) {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
// The model links both sides, so it is junction-shaped for this
|
|
406
|
+
// candidate: record why it is declined rather than silently skipping.
|
|
407
|
+
if (
|
|
408
|
+
!idColumns ||
|
|
409
|
+
!idColumnsAreExactlyFkPair(idColumns, parentFk.localColumns, childFk.localColumns)
|
|
410
|
+
) {
|
|
411
|
+
nearMisses.push({ junctionModelName, reason: 'id-not-fk-covering' });
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const orderedChildColumns = childColumnsInTargetIdOrder(childFk, targetIdColumns);
|
|
415
|
+
if (!orderedChildColumns) {
|
|
416
|
+
nearMisses.push({ junctionModelName, reason: 'target-fk-not-id' });
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
pairs.push({ parentFk, childFk, childColumnsInTargetIdOrder: orderedChildColumns });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { pairs, nearMisses };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function junctionNearMissDiagnostic(
|
|
427
|
+
candidate: ModelBackrelationCandidate,
|
|
428
|
+
nearMiss: JunctionNearMiss,
|
|
429
|
+
sourceId: string,
|
|
430
|
+
): ContractSourceDiagnostic {
|
|
431
|
+
const listField = `${candidate.modelName}.${candidate.field.name}`;
|
|
432
|
+
const data = {
|
|
433
|
+
listField,
|
|
434
|
+
junctionModel: nearMiss.junctionModelName,
|
|
435
|
+
targetModel: candidate.targetModelName,
|
|
436
|
+
};
|
|
437
|
+
if (nearMiss.reason === 'target-fk-not-id') {
|
|
438
|
+
return {
|
|
439
|
+
code: 'PSL_JUNCTION_TARGET_FK_NOT_ID',
|
|
440
|
+
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.`,
|
|
441
|
+
sourceId,
|
|
442
|
+
span: candidate.field.span,
|
|
443
|
+
data,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
code: 'PSL_JUNCTION_ID_NOT_FK_COVERING',
|
|
448
|
+
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.`,
|
|
449
|
+
sourceId,
|
|
450
|
+
span: candidate.field.span,
|
|
451
|
+
data,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function manyToManyRelationNode(
|
|
456
|
+
candidate: ModelBackrelationCandidate,
|
|
457
|
+
pair: JunctionFkPair,
|
|
458
|
+
): ModelRelationMetadata {
|
|
459
|
+
return {
|
|
460
|
+
fieldName: candidate.field.name,
|
|
461
|
+
toModel: pair.childFk.targetModelName,
|
|
462
|
+
toTable: pair.childFk.targetTableName,
|
|
463
|
+
...ifDefined('toNamespaceId', pair.childFk.targetNamespaceId),
|
|
464
|
+
cardinality: 'N:M',
|
|
465
|
+
on: {
|
|
466
|
+
parentTable: candidate.tableName,
|
|
467
|
+
parentColumns: pair.parentFk.referencedColumns,
|
|
468
|
+
childTable: pair.parentFk.declaringTableName,
|
|
469
|
+
childColumns: pair.parentFk.localColumns,
|
|
470
|
+
},
|
|
471
|
+
through: {
|
|
472
|
+
table: pair.parentFk.declaringTableName,
|
|
473
|
+
...ifDefined('namespaceId', pair.parentFk.declaringNamespaceId),
|
|
474
|
+
parentColumns: pair.parentFk.localColumns,
|
|
475
|
+
childColumns: pair.childColumnsInTargetIdOrder,
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function relationsForModel(
|
|
481
|
+
modelRelations: Map<string, ModelRelationMetadata[]>,
|
|
482
|
+
modelName: string,
|
|
483
|
+
): ModelRelationMetadata[] {
|
|
484
|
+
const existing = modelRelations.get(modelName);
|
|
485
|
+
if (existing) {
|
|
486
|
+
return existing;
|
|
487
|
+
}
|
|
488
|
+
const created: ModelRelationMetadata[] = [];
|
|
489
|
+
modelRelations.set(modelName, created);
|
|
490
|
+
return created;
|
|
276
491
|
}
|
|
277
492
|
|
|
278
493
|
export function applyBackrelationCandidates(input: {
|
|
279
494
|
readonly backrelationCandidates: readonly ModelBackrelationCandidate[];
|
|
280
495
|
readonly fkRelationsByPair: Map<string, readonly FkRelationMetadata[]>;
|
|
496
|
+
readonly fkRelationsByDeclaringModel: ReadonlyMap<string, readonly FkRelationMetadata[]>;
|
|
497
|
+
readonly modelIdColumns: ReadonlyMap<string, readonly string[]>;
|
|
281
498
|
readonly modelRelations: Map<string, ModelRelationMetadata[]>;
|
|
282
499
|
readonly diagnostics: ContractSourceDiagnostic[];
|
|
283
500
|
readonly sourceId: string;
|
|
@@ -290,6 +507,32 @@ export function applyBackrelationCandidates(input: {
|
|
|
290
507
|
: [...pairMatches];
|
|
291
508
|
|
|
292
509
|
if (matches.length === 0) {
|
|
510
|
+
const { pairs: junctionPairs, nearMisses } = findJunctionFkPairs({
|
|
511
|
+
candidate,
|
|
512
|
+
fkRelationsByDeclaringModel: input.fkRelationsByDeclaringModel,
|
|
513
|
+
modelIdColumns: input.modelIdColumns,
|
|
514
|
+
});
|
|
515
|
+
const junctionPair = junctionPairs[0];
|
|
516
|
+
if (junctionPairs.length === 1 && junctionPair) {
|
|
517
|
+
relationsForModel(input.modelRelations, candidate.modelName).push(
|
|
518
|
+
manyToManyRelationNode(candidate, junctionPair),
|
|
519
|
+
);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (junctionPairs.length > 1) {
|
|
523
|
+
input.diagnostics.push({
|
|
524
|
+
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
|
|
525
|
+
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.`,
|
|
526
|
+
sourceId: input.sourceId,
|
|
527
|
+
span: candidate.field.span,
|
|
528
|
+
});
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const nearMiss = nearMisses[0];
|
|
532
|
+
if (nearMiss) {
|
|
533
|
+
input.diagnostics.push(junctionNearMissDiagnostic(candidate, nearMiss, input.sourceId));
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
293
536
|
input.diagnostics.push({
|
|
294
537
|
code: 'PSL_ORPHANED_BACKRELATION_LIST',
|
|
295
538
|
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.`,
|
|
@@ -312,15 +555,11 @@ export function applyBackrelationCandidates(input: {
|
|
|
312
555
|
const matched = matches[0];
|
|
313
556
|
assertDefined(matched, 'Backrelation matching requires a defined relation match');
|
|
314
557
|
|
|
315
|
-
|
|
316
|
-
const current = existing ?? [];
|
|
317
|
-
if (!existing) {
|
|
318
|
-
input.modelRelations.set(candidate.modelName, current);
|
|
319
|
-
}
|
|
320
|
-
current.push({
|
|
558
|
+
relationsForModel(input.modelRelations, candidate.modelName).push({
|
|
321
559
|
fieldName: candidate.field.name,
|
|
322
560
|
toModel: matched.declaringModelName,
|
|
323
561
|
toTable: matched.declaringTableName,
|
|
562
|
+
...ifDefined('toNamespaceId', matched.declaringNamespaceId),
|
|
324
563
|
cardinality: '1:N',
|
|
325
564
|
on: {
|
|
326
565
|
parentTable: candidate.tableName,
|
|
@@ -334,7 +573,7 @@ export function applyBackrelationCandidates(input: {
|
|
|
334
573
|
|
|
335
574
|
export function validateNavigationListFieldAttributes(input: {
|
|
336
575
|
readonly modelName: string;
|
|
337
|
-
readonly field:
|
|
576
|
+
readonly field: FieldSymbol;
|
|
338
577
|
readonly sourceId: string;
|
|
339
578
|
readonly composedExtensions: Set<string>;
|
|
340
579
|
readonly authoringContributions: AuthoringContributions | undefined;
|