Skip to content

Commit f422aef

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 31a5fc9 commit f422aef

13 files changed

Lines changed: 264 additions & 178 deletions

File tree

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

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ interface FkRelation {
8686
readonly declaringModel: string;
8787
readonly fieldName: string;
8888
readonly targetModel: string;
89-
readonly relationName?: string;
9089
readonly localFields: readonly string[];
9190
readonly targetFields: readonly string[];
9291
}
@@ -872,7 +871,7 @@ export function interpretPslDocumentToMongoContract(
872871
readonly modelName: string;
873872
readonly fieldName: string;
874873
readonly targetModelName: string;
875-
readonly relationName?: string;
874+
readonly inverse?: string;
876875
readonly cardinality: '1:1' | '1:N';
877876
readonly field: FieldSymbol;
878877
}
@@ -924,9 +923,7 @@ export function interpretPslDocumentToMongoContract(
924923
modelName: pslModel.name,
925924
fieldName: field.name,
926925
targetModelName: field.typeName,
927-
...(relation?.relationName !== undefined
928-
? { relationName: relation.relationName }
929-
: {}),
926+
...(relation?.inverse !== undefined ? { inverse: relation.inverse } : {}),
930927
cardinality: field.list ? '1:N' : '1:1',
931928
field,
932929
});
@@ -953,7 +950,6 @@ export function interpretPslDocumentToMongoContract(
953950
declaringModel: pslModel.name,
954951
fieldName: field.name,
955952
targetModel: field.typeName,
956-
...(relation.relationName !== undefined ? { relationName: relation.relationName } : {}),
957953
localFields: localMapped,
958954
targetFields: targetMapped,
959955
});
@@ -1061,30 +1057,48 @@ export function interpretPslDocumentToMongoContract(
10611057
for (const candidate of backrelationCandidates) {
10621058
const pairKey = fkRelationPairKey(candidate.targetModelName, candidate.modelName);
10631059
const pairMatches = fkRelationsByPair.get(pairKey) ?? [];
1064-
const matches = candidate.relationName
1065-
? pairMatches.filter((r) => r.relationName === candidate.relationName)
1066-
: [...pairMatches];
10671060

1068-
if (matches.length === 0) {
1069-
diagnostics.push({
1070-
code: 'PSL_ORPHANED_BACKRELATION',
1071-
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.`,
1072-
sourceId,
1073-
span: candidate.field.span,
1074-
});
1075-
continue;
1076-
}
1077-
if (matches.length > 1) {
1078-
diagnostics.push({
1079-
code: 'PSL_AMBIGUOUS_BACKRELATION',
1080-
message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation("...") to both sides to disambiguate.`,
1081-
sourceId,
1082-
span: candidate.field.span,
1083-
});
1084-
continue;
1061+
// `inverse:` pins a one-to-many back-relation to the FK-side relation whose
1062+
// declaring field it names, the directional disambiguator across multiple
1063+
// relations between the same pair of models. A relation field name is unique
1064+
// within its model, so at most one FK-side relation matches. When `inverse:`
1065+
// names a field that is not an FK-side relation back to the candidate, report
1066+
// it rather than letting recognition fall into the generic ambiguity path.
1067+
let fk: FkRelation | undefined;
1068+
if (candidate.inverse !== undefined) {
1069+
const inverseMatched = pairMatches.find((r) => r.fieldName === candidate.inverse);
1070+
if (!inverseMatched) {
1071+
diagnostics.push({
1072+
code: 'PSL_INVERSE_FIELD_NOT_FK',
1073+
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}".`,
1074+
sourceId,
1075+
span: candidate.field.span,
1076+
});
1077+
continue;
1078+
}
1079+
fk = inverseMatched;
1080+
} else {
1081+
if (pairMatches.length === 0) {
1082+
diagnostics.push({
1083+
code: 'PSL_ORPHANED_BACKRELATION',
1084+
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.`,
1085+
sourceId,
1086+
span: candidate.field.span,
1087+
});
1088+
continue;
1089+
}
1090+
if (pairMatches.length > 1) {
1091+
diagnostics.push({
1092+
code: 'PSL_AMBIGUOUS_BACKRELATION',
1093+
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.`,
1094+
sourceId,
1095+
span: candidate.field.span,
1096+
});
1097+
continue;
1098+
}
1099+
fk = pairMatches[0];
10851100
}
10861101

1087-
const fk = matches[0];
10881102
if (!fk) continue;
10891103
const modelEntry = models[candidate.modelName];
10901104
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
@@ -522,7 +522,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
522522
targetId: input.targetId,
523523
});
524524
const relationAttribute = getAttribute(field.attributes, 'relation');
525-
let relationName: string | undefined;
526525
let through: ParsedThrough | undefined;
527526
let inverse: string | undefined;
528527
if (relationAttribute) {
@@ -554,7 +553,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
554553
});
555554
continue;
556555
}
557-
relationName = parsedRelation.relationName;
558556
through = parsedRelation.through;
559557
inverse = parsedRelation.inverse;
560558
}
@@ -567,7 +565,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
567565
tableName,
568566
field,
569567
targetModelName: field.typeName,
570-
...ifDefined('relationName', relationName),
571568
...ifDefined('through', through),
572569
...ifDefined('inverse', inverse),
573570
});
@@ -1163,7 +1160,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
11631160
targetModelName: targetMapping.model.name,
11641161
targetTableName: targetMapping.tableName,
11651162
...ifDefined('targetNamespaceId', targetNamespaceId),
1166-
...ifDefined('relationName', parsedRelation.relationName),
11671163
localColumns,
11681164
referencedColumns,
11691165
});

0 commit comments

Comments
 (0)