@prisma-next/sql-contract-psl 0.3.0-dev.128 → 0.3.0-dev.147

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