@prisma-next/sql-contract-psl 0.14.0-dev.7 → 0.14.0-dev.9

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/dist/provider.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as interpretPslDocumentToSqlContract } from "./interpreter-kDkm5opL.mjs";
1
+ import { t as interpretPslDocumentToSqlContract } from "./interpreter-CygvamTk.mjs";
2
2
  import { buildSymbolTable, rangeToPslSpan } from "@prisma-next/psl-parser";
3
3
  import { ifDefined } from "@prisma-next/utils/defined";
4
4
  import { notOk, ok } from "@prisma-next/utils/result";
package/package.json CHANGED
@@ -1,25 +1,25 @@
1
1
  {
2
2
  "name": "@prisma-next/sql-contract-psl",
3
- "version": "0.14.0-dev.7",
3
+ "version": "0.14.0-dev.9",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "description": "PSL-to-SQL ContractIR interpreter for Prisma Next",
8
8
  "dependencies": {
9
- "@prisma-next/config": "0.14.0-dev.7",
10
- "@prisma-next/contract": "0.14.0-dev.7",
11
- "@prisma-next/framework-components": "0.14.0-dev.7",
12
- "@prisma-next/psl-parser": "0.14.0-dev.7",
13
- "@prisma-next/sql-contract": "0.14.0-dev.7",
14
- "@prisma-next/sql-contract-ts": "0.14.0-dev.7",
15
- "@prisma-next/utils": "0.14.0-dev.7",
9
+ "@prisma-next/config": "0.14.0-dev.9",
10
+ "@prisma-next/contract": "0.14.0-dev.9",
11
+ "@prisma-next/framework-components": "0.14.0-dev.9",
12
+ "@prisma-next/psl-parser": "0.14.0-dev.9",
13
+ "@prisma-next/sql-contract": "0.14.0-dev.9",
14
+ "@prisma-next/sql-contract-ts": "0.14.0-dev.9",
15
+ "@prisma-next/utils": "0.14.0-dev.9",
16
16
  "pathe": "^2.0.3"
17
17
  },
18
18
  "devDependencies": {
19
- "@prisma-next/contract-authoring": "0.14.0-dev.7",
20
- "@prisma-next/test-utils": "0.14.0-dev.7",
21
- "@prisma-next/tsconfig": "0.14.0-dev.7",
22
- "@prisma-next/tsdown": "0.14.0-dev.7",
19
+ "@prisma-next/contract-authoring": "0.14.0-dev.9",
20
+ "@prisma-next/test-utils": "0.14.0-dev.9",
21
+ "@prisma-next/tsconfig": "0.14.0-dev.9",
22
+ "@prisma-next/tsdown": "0.14.0-dev.9",
23
23
  "arktype": "^2.2.0",
24
24
  "tsdown": "0.22.1",
25
25
  "typescript": "5.9.3",
@@ -1061,6 +1061,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
1061
1061
  declaringModelName: model.name,
1062
1062
  declaringFieldName: relationAttribute.field.name,
1063
1063
  declaringTableName: tableName,
1064
+ ...ifDefined('declaringNamespaceId', input.modelNamespaceIds.get(model.name)),
1064
1065
  targetModelName: targetMapping.model.name,
1065
1066
  targetTableName: targetMapping.tableName,
1066
1067
  ...ifDefined('targetNamespaceId', targetNamespaceId),
@@ -1860,10 +1861,20 @@ export function interpretPslDocumentToSqlContract(
1860
1861
  }
1861
1862
  }
1862
1863
 
1863
- const { modelRelations, fkRelationsByPair } = indexFkRelations({ fkRelationMetadata });
1864
+ const { modelRelations, fkRelationsByPair, fkRelationsByDeclaringModel } = indexFkRelations({
1865
+ fkRelationMetadata,
1866
+ });
1867
+ const modelIdColumns = new Map<string, readonly string[]>();
1868
+ for (const modelNode of modelNodes) {
1869
+ if (modelNode.id) {
1870
+ modelIdColumns.set(modelNode.modelName, modelNode.id.columns);
1871
+ }
1872
+ }
1864
1873
  applyBackrelationCandidates({
1865
1874
  backrelationCandidates,
1866
1875
  fkRelationsByPair,
1876
+ fkRelationsByDeclaringModel,
1877
+ modelIdColumns,
1867
1878
  modelRelations,
1868
1879
  diagnostics,
1869
1880
  sourceId,
@@ -41,6 +41,8 @@ export type FkRelationMetadata = {
41
41
  readonly declaringModelName: string;
42
42
  readonly declaringFieldName: string;
43
43
  readonly declaringTableName: string;
44
+ /** Resolved namespace coordinate of the declaring model, when known. */
45
+ readonly declaringNamespaceId?: string;
44
46
  readonly targetModelName: string;
45
47
  readonly targetTableName: string;
46
48
  /** Resolved namespace coordinate of the related model, when known. */
@@ -240,11 +242,20 @@ export function indexFkRelations(input: {
240
242
  }): {
241
243
  readonly modelRelations: Map<string, ModelRelationMetadata[]>;
242
244
  readonly fkRelationsByPair: Map<string, FkRelationMetadata[]>;
245
+ readonly fkRelationsByDeclaringModel: Map<string, FkRelationMetadata[]>;
243
246
  } {
244
247
  const modelRelations = new Map<string, ModelRelationMetadata[]>();
245
248
  const fkRelationsByPair = new Map<string, FkRelationMetadata[]>();
249
+ const fkRelationsByDeclaringModel = new Map<string, FkRelationMetadata[]>();
246
250
 
247
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
+
248
259
  const existing = modelRelations.get(relation.declaringModelName);
249
260
  const current = existing ?? [];
250
261
  if (!existing) {
@@ -273,12 +284,217 @@ export function indexFkRelations(input: {
273
284
  pairRelations.push(relation);
274
285
  }
275
286
 
276
- 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;
277
491
  }
278
492
 
279
493
  export function applyBackrelationCandidates(input: {
280
494
  readonly backrelationCandidates: readonly ModelBackrelationCandidate[];
281
495
  readonly fkRelationsByPair: Map<string, readonly FkRelationMetadata[]>;
496
+ readonly fkRelationsByDeclaringModel: ReadonlyMap<string, readonly FkRelationMetadata[]>;
497
+ readonly modelIdColumns: ReadonlyMap<string, readonly string[]>;
282
498
  readonly modelRelations: Map<string, ModelRelationMetadata[]>;
283
499
  readonly diagnostics: ContractSourceDiagnostic[];
284
500
  readonly sourceId: string;
@@ -291,6 +507,32 @@ export function applyBackrelationCandidates(input: {
291
507
  : [...pairMatches];
292
508
 
293
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
+ }
294
536
  input.diagnostics.push({
295
537
  code: 'PSL_ORPHANED_BACKRELATION_LIST',
296
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.`,
@@ -313,15 +555,11 @@ export function applyBackrelationCandidates(input: {
313
555
  const matched = matches[0];
314
556
  assertDefined(matched, 'Backrelation matching requires a defined relation match');
315
557
 
316
- const existing = input.modelRelations.get(candidate.modelName);
317
- const current = existing ?? [];
318
- if (!existing) {
319
- input.modelRelations.set(candidate.modelName, current);
320
- }
321
- current.push({
558
+ relationsForModel(input.modelRelations, candidate.modelName).push({
322
559
  fieldName: candidate.field.name,
323
560
  toModel: matched.declaringModelName,
324
561
  toTable: matched.declaringTableName,
562
+ ...ifDefined('toNamespaceId', matched.declaringNamespaceId),
325
563
  cardinality: '1:N',
326
564
  on: {
327
565
  parentTable: candidate.tableName,