Skip to content

Commit b253568

Browse files
committed
feat(contract-psl,mongo-family)!: reject legacy @relation(name:) — disambiguate with inverse:/through: J.field
Complete the clean break begun in S1 (rejecting fields:/references:): the positional @relation("x") and the named @relation(name: "x") forms are now rejected as input in both the SQL and Mongo families, with a guiding PSL_LEGACY_RELATION_NAME diagnostic pointing at the directional replacements. Disambiguation is by pointing only: inverse: <fkField> for a 1:N back-relation with multiple candidates, through: Junction.field for an ambiguous many-to-many. The SQL family already had both pointer forms (S2/S3); the Mongo family gains inverse: parsing and matching here (it has no junction-based M:N recognition, so only the 1:N disambiguator applies). The relation-name string never reached the emitted contract, so the name:->inverse:/through: migration of the test schemas and the relation-backrelation-list parity fixture lowers byte-identically (fixtures:check clean). The legacy-name plumbing (relationName on ParsedRelationAttribute, FkRelationMetadata, and the backrelation candidates) is removed in both families. The two ambiguity diagnostics now recommend inverse:/through: instead of @relation(name:). Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent 4593c85 commit b253568

13 files changed

Lines changed: 264 additions & 176 deletions

File tree

packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ interface FkRelation {
101101
readonly declaringModel: string;
102102
readonly fieldName: string;
103103
readonly targetModel: string;
104-
readonly relationName?: string;
105104
readonly localFields: readonly string[];
106105
readonly targetFields: readonly string[];
107106
}
@@ -987,7 +986,7 @@ export function interpretPslDocumentToMongoContract(
987986
readonly modelName: string;
988987
readonly fieldName: string;
989988
readonly targetModelName: string;
990-
readonly relationName?: string;
989+
readonly inverse?: string;
991990
readonly cardinality: '1:1' | '1:N';
992991
readonly field: FieldSymbol;
993992
}
@@ -1039,7 +1038,7 @@ export function interpretPslDocumentToMongoContract(
10391038
modelName: pslModel.name,
10401039
fieldName: field.name,
10411040
targetModelName: field.typeName,
1042-
...ifDefined('relationName', relation?.relationName),
1041+
...(relation?.inverse !== undefined ? { inverse: relation.inverse } : {}),
10431042
cardinality: field.list ? '1:N' : '1:1',
10441043
field,
10451044
});
@@ -1066,7 +1065,6 @@ export function interpretPslDocumentToMongoContract(
10661065
declaringModel: pslModel.name,
10671066
fieldName: field.name,
10681067
targetModel: field.typeName,
1069-
...(relation.relationName !== undefined ? { relationName: relation.relationName } : {}),
10701068
localFields: localMapped,
10711069
targetFields: targetMapped,
10721070
});
@@ -1176,30 +1174,48 @@ export function interpretPslDocumentToMongoContract(
11761174
for (const candidate of backrelationCandidates) {
11771175
const pairKey = fkRelationPairKey(candidate.targetModelName, candidate.modelName);
11781176
const pairMatches = fkRelationsByPair.get(pairKey) ?? [];
1179-
const matches = candidate.relationName
1180-
? pairMatches.filter((r) => r.relationName === candidate.relationName)
1181-
: [...pairMatches];
11821177

1183-
if (matches.length === 0) {
1184-
diagnostics.push({
1185-
code: 'PSL_ORPHANED_BACKRELATION',
1186-
message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" 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.`,
1187-
sourceId,
1188-
span: candidate.field.span,
1189-
});
1190-
continue;
1191-
}
1192-
if (matches.length > 1) {
1193-
diagnostics.push({
1194-
code: 'PSL_AMBIGUOUS_BACKRELATION',
1195-
message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation("...") to both sides to disambiguate.`,
1196-
sourceId,
1197-
span: candidate.field.span,
1198-
});
1199-
continue;
1178+
// `inverse:` pins a one-to-many back-relation to the FK-side relation whose
1179+
// declaring field it names, the directional disambiguator across multiple
1180+
// relations between the same pair of models. A relation field name is unique
1181+
// within its model, so at most one FK-side relation matches. When `inverse:`
1182+
// names a field that is not an FK-side relation back to the candidate, report
1183+
// it rather than letting recognition fall into the generic ambiguity path.
1184+
let fk: FkRelation | undefined;
1185+
if (candidate.inverse !== undefined) {
1186+
const inverseMatched = pairMatches.find((r) => r.fieldName === candidate.inverse);
1187+
if (!inverseMatched) {
1188+
diagnostics.push({
1189+
code: 'PSL_INVERSE_FIELD_NOT_FK',
1190+
message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" pins FK-side relation field "${candidate.inverse}" via inverse: ${candidate.inverse}, but "${candidate.targetModelName}" has no relation field "${candidate.inverse}" with a foreign key back to "${candidate.modelName}". Name an FK-side relation field whose foreign key references "${candidate.modelName}".`,
1191+
sourceId,
1192+
span: candidate.field.span,
1193+
});
1194+
continue;
1195+
}
1196+
fk = inverseMatched;
1197+
} else {
1198+
if (pairMatches.length === 0) {
1199+
diagnostics.push({
1200+
code: 'PSL_ORPHANED_BACKRELATION',
1201+
message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" 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.`,
1202+
sourceId,
1203+
span: candidate.field.span,
1204+
});
1205+
continue;
1206+
}
1207+
if (pairMatches.length > 1) {
1208+
diagnostics.push({
1209+
code: 'PSL_AMBIGUOUS_BACKRELATION',
1210+
message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add inverse: <fkField> to the list field, naming the FK-side relation field it pairs with, to disambiguate.`,
1211+
sourceId,
1212+
span: candidate.field.span,
1213+
});
1214+
continue;
1215+
}
1216+
fk = pairMatches[0];
12001217
}
12011218

1202-
const fk = matches[0];
12031219
if (!fk) continue;
12041220
const modelEntry = models[candidate.modelName];
12051221
if (!modelEntry) continue;

packages/2-mongo-family/2-authoring/contract-psl/src/psl-helpers.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ export function getMapName(attributes: readonly ResolvedAttribute[]): string | u
9393
}
9494

9595
export interface ParsedRelationAttribute {
96-
readonly relationName?: string;
9796
readonly fields?: readonly string[];
9897
readonly references?: readonly string[];
9998
/**
@@ -103,6 +102,12 @@ export interface ParsedRelationAttribute {
103102
* never co-occur.
104103
*/
105104
readonly referencesInferred?: true;
105+
/**
106+
* The FK-side relation field named by `inverse:` on a one-to-many back-relation
107+
* list field. A bare relation-field name pinning the owning foreign-key field,
108+
* used to disambiguate when multiple relations link the same pair of models.
109+
*/
110+
readonly inverse?: string;
106111
}
107112

108113
/**
@@ -129,27 +134,36 @@ export function parseRelationAttribute(input: {
129134
const relationAttr = getAttribute(input.attributes, 'relation');
130135
if (!relationAttr) return undefined;
131136

132-
let relationName: string | undefined;
133137
let fromRaw: string | undefined;
134138
let toRaw: string | undefined;
139+
let inverse: string | undefined;
135140

136141
for (const arg of relationAttr.args) {
137-
if (arg.kind === 'positional') {
138-
relationName = stripQuotes(arg.value);
139-
} else if (arg.name === 'name') {
140-
relationName = stripQuotes(arg.value);
141-
} else if (arg.name === 'fields' || arg.name === 'references') {
142+
if (arg.kind === 'positional' || arg.name === 'name') {
143+
input.diagnostics.push({
144+
code: 'PSL_LEGACY_RELATION_NAME',
145+
message: `Relation field "${input.modelName}.${input.fieldName}" uses @relation(name:) (or a positional @relation("...")), which is no longer supported — disambiguate with inverse: (1:N back-relation) or through: Junction.field (M:N)`,
146+
sourceId: input.sourceId,
147+
span: arg.span,
148+
});
149+
return undefined;
150+
}
151+
if (arg.name === 'fields' || arg.name === 'references') {
142152
input.diagnostics.push({
143153
code: 'PSL_LEGACY_FIELDS_REFERENCES',
144154
message: `Relation field "${input.modelName}.${input.fieldName}" uses @relation(fields:/references:), which is no longer supported — use from:/to: instead`,
145155
sourceId: input.sourceId,
146156
span: arg.span,
147157
});
148158
return undefined;
149-
} else if (arg.name === 'from') {
159+
}
160+
if (arg.name === 'from') {
150161
fromRaw = arg.value;
151162
} else if (arg.name === 'to') {
152163
toRaw = arg.value;
164+
} else if (arg.name === 'inverse') {
165+
const trimmed = arg.value.trim();
166+
inverse = trimmed.length > 0 ? trimmed : undefined;
153167
}
154168
}
155169

@@ -199,10 +213,10 @@ export function parseRelationAttribute(input: {
199213
}
200214

201215
return {
202-
...ifDefined('relationName', relationName),
203216
...ifDefined('fields', fields),
204217
...ifDefined('references', references),
205218
...ifDefined('referencesInferred', referencesInferred),
219+
...ifDefined('inverse', inverse),
206220
};
207221
}
208222

packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -435,21 +435,21 @@ describe('interpretPslDocumentToMongoContract', () => {
435435
expect(model(ir, 'User').fields).not.toHaveProperty('posts');
436436
});
437437

438-
it('disambiguates multiple FK relations to the same target using relation name', () => {
438+
it('disambiguates multiple FK relations to the same target using inverse:', () => {
439439
const ir = interpretOk(`
440440
model User {
441441
id ObjectId @id @map("_id")
442-
createdTasks Task[] @relation("created")
443-
assignedTasks Task[] @relation("assigned")
442+
createdTasks Task[] @relation(inverse: creator)
443+
assignedTasks Task[] @relation(inverse: assignee)
444444
}
445445
446446
model Task {
447447
id ObjectId @id @map("_id")
448448
title String
449449
creatorId ObjectId
450450
assigneeId ObjectId
451-
creator User @relation("created", from: [creatorId], to: [id])
452-
assignee User @relation("assigned", from: [assigneeId], to: [id])
451+
creator User @relation(from: [creatorId], to: [id])
452+
assignee User @relation(from: [assigneeId], to: [id])
453453
}
454454
`);
455455

@@ -467,7 +467,7 @@ describe('interpretPslDocumentToMongoContract', () => {
467467
});
468468
});
469469

470-
it('emits diagnostic for ambiguous backrelation with multiple FKs and no relation name', () => {
470+
it('emits diagnostic for ambiguous backrelation with multiple FKs and no inverse: pin', () => {
471471
const result = interpret(`
472472
model User {
473473
id ObjectId @id @map("_id")
@@ -478,8 +478,8 @@ describe('interpretPslDocumentToMongoContract', () => {
478478
id ObjectId @id @map("_id")
479479
creatorId ObjectId
480480
assigneeId ObjectId
481-
creator User @relation("created", from: [creatorId], to: [id])
482-
assignee User @relation("assigned", from: [assigneeId], to: [id])
481+
creator User @relation(from: [creatorId], to: [id])
482+
assignee User @relation(from: [assigneeId], to: [id])
483483
}
484484
`);
485485

@@ -494,6 +494,78 @@ describe('interpretPslDocumentToMongoContract', () => {
494494
);
495495
});
496496

497+
it('rejects @relation(name:) and the positional name form', () => {
498+
const named = interpret(`
499+
model User {
500+
id ObjectId @id @map("_id")
501+
createdTasks Task[] @relation(name: "created")
502+
}
503+
504+
model Task {
505+
id ObjectId @id @map("_id")
506+
creatorId ObjectId
507+
creator User @relation(name: "created", from: [creatorId], to: [id])
508+
}
509+
`);
510+
511+
expect(named.ok).toBe(false);
512+
if (named.ok) return;
513+
expect(named.failure.diagnostics).toEqual(
514+
expect.arrayContaining([
515+
expect.objectContaining({
516+
code: 'PSL_LEGACY_RELATION_NAME',
517+
message: expect.stringContaining('inverse:'),
518+
}),
519+
]),
520+
);
521+
522+
const positional = interpret(`
523+
model User {
524+
id ObjectId @id @map("_id")
525+
posts Post[] @relation("UserPosts")
526+
}
527+
528+
model Post {
529+
id ObjectId @id @map("_id")
530+
userId ObjectId
531+
user User @relation("UserPosts", from: [userId], to: [id])
532+
}
533+
`);
534+
535+
expect(positional.ok).toBe(false);
536+
if (positional.ok) return;
537+
expect(positional.failure.diagnostics).toEqual(
538+
expect.arrayContaining([expect.objectContaining({ code: 'PSL_LEGACY_RELATION_NAME' })]),
539+
);
540+
});
541+
542+
it('emits a diagnostic when inverse: names a field that is not an FK-side relation', () => {
543+
const result = interpret(`
544+
model User {
545+
id ObjectId @id @map("_id")
546+
createdTasks Task[] @relation(inverse: notAField)
547+
assignedTasks Task[] @relation(inverse: assignee)
548+
}
549+
550+
model Task {
551+
id ObjectId @id @map("_id")
552+
creatorId ObjectId
553+
assigneeId ObjectId
554+
creator User @relation(from: [creatorId], to: [id])
555+
assignee User @relation(from: [assigneeId], to: [id])
556+
}
557+
`);
558+
559+
expect(result.ok).toBe(false);
560+
if (result.ok) return;
561+
const diagnostic = result.failure.diagnostics.find(
562+
(d) => d.code === 'PSL_INVERSE_FIELD_NOT_FK',
563+
);
564+
expect(diagnostic).toBeDefined();
565+
expect(diagnostic?.message).toContain('User.createdTasks');
566+
expect(diagnostic?.message).toContain('notAField');
567+
});
568+
497569
it('creates 1:1 inverse relation for singular non-FK relation field', () => {
498570
const ir = interpretOk(`
499571
model User {

packages/2-sql/2-authoring/contract-psl/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ Unsupported PSL constructs in v1 (strict errors):
5252
- Enum lists and named-type lists
5353
- **Relation navigation lists are supported** when they can be matched to an FK-side relation:
5454
- Example: `User.posts Post[]` + `Post.user User @relation(from: [userId], to: [id])`
55-
- Matching may use `@relation("Name")` or `@relation(name: "Name")` when multiple candidates exist
56-
- Navigation list fields accept only `@relation` (name-only form); other field attributes are strict errors
55+
- When multiple candidates exist, disambiguate by pointing: `@relation(inverse: <fkField>)` on a 1:N back-relation, or `@relation(through: Junction.field)` on a many-to-many list field. Legacy `@relation("Name")` / `@relation(name: "Name")` is rejected (`PSL_LEGACY_RELATION_NAME`).
56+
- Navigation list fields accept only `@relation` (with `inverse:`/`through:`); other field attributes are strict errors
5757
- **Implicit Prisma ORM many-to-many remains unsupported** (list navigation on both sides without explicit join model)
5858
- Represent many-to-many with an explicit join model (two foreign keys)
5959

packages/2-sql/2-authoring/contract-psl/src/interpreter.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
516516
targetId: input.targetId,
517517
});
518518
const relationAttribute = getAttribute(field.attributes, 'relation');
519-
let relationName: string | undefined;
520519
let through: ParsedThrough | undefined;
521520
let inverse: string | undefined;
522521
if (relationAttribute) {
@@ -548,7 +547,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
548547
});
549548
continue;
550549
}
551-
relationName = parsedRelation.relationName;
552550
through = parsedRelation.through;
553551
inverse = parsedRelation.inverse;
554552
}
@@ -561,7 +559,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
561559
tableName,
562560
field,
563561
targetModelName: field.typeName,
564-
...ifDefined('relationName', relationName),
565562
...ifDefined('through', through),
566563
...ifDefined('inverse', inverse),
567564
});
@@ -1157,7 +1154,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
11571154
targetModelName: targetMapping.model.name,
11581155
targetTableName: targetMapping.tableName,
11591156
...ifDefined('targetNamespaceId', targetNamespaceId),
1160-
...ifDefined('relationName', parsedRelation.relationName),
11611157
localColumns,
11621158
referencedColumns,
11631159
});

0 commit comments

Comments
 (0)