diff --git a/packages/1-framework/2-authoring/psl-parser/src/parse.ts b/packages/1-framework/2-authoring/psl-parser/src/parse.ts index 4b9ae1ee22..29f6b2f222 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/parse.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/parse.ts @@ -188,10 +188,26 @@ export function parseExpression(cursor: Cursor): GreenNode | undefined { parseObjectLiteralExpr(cursor) ?? parseFunctionCall(cursor) ?? parseBooleanLiteralExpr(cursor) ?? - parseIdentifierExpr(cursor) + parseMemberAccessExpr(cursor) ); } +/** + * Parses an identifier-leading value: a member-access `Foo.bar` (the dotted form + * `through: Junction.relationField` uses to pin a relation field) as a + * {@link parseQualifiedName} chain that preserves every segment, or a bare + * `Foo` as a single `Identifier`. Returns `undefined` when no identifier leads, + * leaving the `parseExpression` chain to fall through. + */ +export function parseMemberAccessExpr(cursor: Cursor): GreenNode | undefined { + if (cursor.peekKind() !== 'Ident') return undefined; + if (cursor.peekKind(1) !== 'Dot') return parseIdentifierExpr(cursor); + cursor.startNode('QualifiedName'); + parseIdentifier(cursor); + parseQualifiedSegments(cursor, 'Dot'); + return cursor.finishNode(); +} + export function parseStringLiteralExpr(cursor: Cursor): GreenNode | undefined { if (cursor.peekKind() !== 'StringLiteral') return undefined; const stringMark = cursor.mark(); diff --git a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/expressions.ts b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/expressions.ts index 68e33a7e51..27c25ed0a9 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/expressions.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/expressions.ts @@ -312,6 +312,7 @@ export type ExpressionAst = | NumberLiteralExprAst | BooleanLiteralExprAst | ObjectLiteralExprAst + | QualifiedNameAst | IdentifierAst; export function castExpression(node: SyntaxNode): ExpressionAst | undefined { @@ -322,6 +323,7 @@ export function castExpression(node: SyntaxNode): ExpressionAst | undefined { NumberLiteralExprAst.cast(node) ?? BooleanLiteralExprAst.cast(node) ?? ObjectLiteralExprAst.cast(node) ?? + QualifiedNameAst.cast(node) ?? IdentifierAst.cast(node) ); } diff --git a/packages/1-framework/2-authoring/psl-parser/test/member-access-value.test.ts b/packages/1-framework/2-authoring/psl-parser/test/member-access-value.test.ts new file mode 100644 index 0000000000..d2c09754cb --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/member-access-value.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; +import { Cursor, parse, parseAttributeArg, parseExpression } from '../src/parse'; +import { readResolvedAttribute } from '../src/resolve'; +import type { ModelDeclarationAst } from '../src/syntax/ast/declarations'; +import { ArrayLiteralAst, AttributeArgAst } from '../src/syntax/ast/expressions'; +import { IdentifierAst } from '../src/syntax/ast/identifier'; +import { QualifiedNameAst } from '../src/syntax/ast/qualified-name'; +import type { GreenElement, GreenNode } from '../src/syntax/green'; +import { createSyntaxTree } from '../src/syntax/red'; +import { printTree } from './support'; + +function greenText(element: GreenElement): string { + if (element.type === 'token') return element.text; + return element.children.map(greenText).join(''); +} + +function parseArg(source: string) { + const cursor = new Cursor(source); + cursor.startNode('AttributeArgList'); + parseAttributeArg(cursor); + const list = cursor.finishNode(); + const node = list.children.find( + (child): child is GreenNode => child.type === 'node' && child.kind === 'AttributeArg', + ); + if (node === undefined) { + throw new Error(`no AttributeArg parsed from ${JSON.stringify(source)}`); + } + return { node, diagnostics: cursor.diagnostics }; +} + +/** The resolved value string `getNamedArgument(attr, name)` returns downstream. */ +function resolvedNamedArg( + source: string, + attributeName: string, + argName: string, +): string | undefined { + const result = parse(source); + const model = Array.from(result.document.declarations())[0] as ModelDeclarationAst; + for (const field of model.fields()) { + for (const attribute of field.attributes()) { + const resolved = readResolvedAttribute(attribute, result.sourceFile); + if (resolved.name !== attributeName) continue; + const arg = resolved.args.find((a) => a.kind === 'named' && a.name === argName); + if (arg) return arg.value; + } + } + return undefined; +} + +describe('member-access argument value', () => { + it('parses a qualified Identifier.Identifier value into a QualifiedName node', () => { + const source = 'through: Foo.bar'; + const { node, diagnostics } = parseArg(source); + + expect(printTree(node)).toMatchInlineSnapshot(` + "AttributeArg + Identifier + Ident "through" + Colon ":" + Whitespace " " + QualifiedName + Identifier + Ident "Foo" + Dot "." + Identifier + Ident "bar"" + `); + expect(greenText(node)).toBe(source); + expect(diagnostics).toEqual([]); + }); + + it('exposes both segments of the qualified value through the AST', () => { + const { node } = parseArg('through: Foo.bar'); + const value = AttributeArgAst.cast(createSyntaxTree(node))!.value(); + expect(value).toBeInstanceOf(QualifiedNameAst); + if (value instanceof QualifiedNameAst) { + expect(value.path()).toEqual(['Foo', 'bar']); + } + }); + + it('surfaces the full dotted string to the resolver (what getNamedArgument returns)', () => { + const source = [ + 'model Follow {', + ' follower User @relation(through: Follow.follower)', + '}', + ].join('\n'); + expect(resolvedNamedArg(source, 'relation', 'through')).toBe('Follow.follower'); + }); +}); + +describe('member-access value — no regression on simpler forms', () => { + it('keeps a bare identifier value as an Identifier node', () => { + const source = 'through: Foo'; + const { node, diagnostics } = parseArg(source); + + expect(printTree(node)).toMatchInlineSnapshot(` + "AttributeArg + Identifier + Ident "through" + Colon ":" + Whitespace " " + Identifier + Ident "Foo"" + `); + expect(greenText(node)).toBe(source); + expect(diagnostics).toEqual([]); + + const value = AttributeArgAst.cast(createSyntaxTree(node))!.value(); + expect(value).toBeInstanceOf(IdentifierAst); + }); + + it('resolves a bare identifier value to its name', () => { + const source = ['model M {', ' rel Other @relation(through: Foo)', '}'].join('\n'); + expect(resolvedNamedArg(source, 'relation', 'through')).toBe('Foo'); + }); + + it('keeps a bracketed list value as an ArrayLiteral node', () => { + const source = 'from: [a, b]'; + const { node, diagnostics } = parseArg(source); + + expect(printTree(node)).toMatchInlineSnapshot(` + "AttributeArg + Identifier + Ident "from" + Colon ":" + Whitespace " " + ArrayLiteral + LBracket "[" + Identifier + Ident "a" + Comma "," + Whitespace " " + Identifier + Ident "b" + RBracket "]"" + `); + expect(greenText(node)).toBe(source); + expect(diagnostics).toEqual([]); + + const value = AttributeArgAst.cast(createSyntaxTree(node))!.value(); + expect(value).toBeInstanceOf(ArrayLiteralAst); + }); + + it('resolves a bracketed list value to its rendered source', () => { + const source = ['model M {', ' rel Other @relation(from: [a, b])', '}'].join('\n'); + expect(resolvedNamedArg(source, 'relation', 'from')).toBe('[a, b]'); + }); +}); + +describe('member-access value — expression entry point', () => { + it('parseExpression yields a QualifiedName for a dotted value', () => { + const cursor = new Cursor('Foo.bar'); + const node = parseExpression(cursor) as GreenNode; + expect(node.kind).toBe('QualifiedName'); + expect(greenText(node)).toBe('Foo.bar'); + expect(cursor.diagnostics).toEqual([]); + }); + + it('parseExpression yields a bare Identifier when no dot follows', () => { + const cursor = new Cursor('Foo'); + const node = parseExpression(cursor) as GreenNode; + expect(node.kind).toBe('Identifier'); + expect(greenText(node)).toBe('Foo'); + expect(cursor.diagnostics).toEqual([]); + }); +}); + +describe('member-access value — three-segment bound', () => { + // Two segments is the disambiguation form (`through: J.field`). A third + // segment round-trips but is over-qualified: the shared qualified-name + // mechanism flags it with PSL_INVALID_QUALIFIED_NAME, the same bound the + // type-annotation and call grammars carry. + it('round-trips a.b.c but flags the extra segment, still exposing all segments', () => { + const source = 'through: a.b.c'; + const { node, diagnostics } = parseArg(source); + + expect(greenText(node)).toBe(source); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]!.code).toBe('PSL_INVALID_QUALIFIED_NAME'); + + const value = AttributeArgAst.cast(createSyntaxTree(node))!.value(); + expect(value).toBeInstanceOf(QualifiedNameAst); + if (value instanceof QualifiedNameAst) { + expect(value.path()).toEqual(['a', 'b', 'c']); + } + }); +}); diff --git a/packages/1-framework/3-tooling/language-server/src/semantic-tokens.ts b/packages/1-framework/3-tooling/language-server/src/semantic-tokens.ts index 7f226edea0..04c7b0a99e 100644 --- a/packages/1-framework/3-tooling/language-server/src/semantic-tokens.ts +++ b/packages/1-framework/3-tooling/language-server/src/semantic-tokens.ts @@ -21,7 +21,7 @@ import { NamespaceDeclarationAst, NumberLiteralExprAst, ObjectLiteralExprAst, - type QualifiedNameAst, + QualifiedNameAst, type SourceFile, StringLiteralExprAst, type SyntaxToken, @@ -447,6 +447,13 @@ function collectExpression( return; } + if (expression instanceof QualifiedNameAst) { + for (const segment of filterChildren(expression.syntax, IdentifierAst.cast)) { + collectIdentifierExpression(segment, source, tokens, namespace, context); + } + return; + } + collectIdentifierExpression(expression, source, tokens, namespace, context); } diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts index 83c9b5dfdf..e75d69f062 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts @@ -121,7 +121,6 @@ interface FkRelation { readonly declaringModel: string; readonly fieldName: string; readonly targetModel: string; - readonly relationName?: string; readonly localFields: readonly string[]; readonly targetFields: readonly string[]; } @@ -1008,7 +1007,7 @@ export function interpretPslDocumentToMongoContract( readonly modelName: string; readonly fieldName: string; readonly targetModelName: string; - readonly relationName?: string; + readonly inverse?: string; readonly cardinality: '1:1' | '1:N'; readonly field: FieldSymbol; } @@ -1060,7 +1059,7 @@ export function interpretPslDocumentToMongoContract( modelName: pslModel.name, fieldName: field.name, targetModelName: field.typeName, - ...ifDefined('relationName', relation?.relationName), +...(relation?.inverse !== undefined ? { inverse: relation.inverse } : {}), cardinality: field.list ? '1:N' : '1:1', field, }); @@ -1087,7 +1086,6 @@ export function interpretPslDocumentToMongoContract( declaringModel: pslModel.name, fieldName: field.name, targetModel: field.typeName, - ...(relation.relationName !== undefined ? { relationName: relation.relationName } : {}), localFields: localMapped, targetFields: targetMapped, }); @@ -1197,30 +1195,48 @@ export function interpretPslDocumentToMongoContract( for (const candidate of backrelationCandidates) { const pairKey = fkRelationPairKey(candidate.targetModelName, candidate.modelName); const pairMatches = fkRelationsByPair.get(pairKey) ?? []; - const matches = candidate.relationName - ? pairMatches.filter((r) => r.relationName === candidate.relationName) - : [...pairMatches]; - if (matches.length === 0) { - diagnostics.push({ - code: 'PSL_ORPHANED_BACKRELATION', - 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.`, - sourceId, - span: candidate.field.span, - }); - continue; - } - if (matches.length > 1) { - diagnostics.push({ - code: 'PSL_AMBIGUOUS_BACKRELATION', - message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation("...") to both sides to disambiguate.`, - sourceId, - span: candidate.field.span, - }); - continue; + // `inverse:` pins a one-to-many back-relation to the FK-side relation whose + // declaring field it names, the directional disambiguator across multiple + // relations between the same pair of models. A relation field name is unique + // within its model, so at most one FK-side relation matches. When `inverse:` + // names a field that is not an FK-side relation back to the candidate, report + // it rather than letting recognition fall into the generic ambiguity path. + let fk: FkRelation | undefined; + if (candidate.inverse !== undefined) { + const inverseMatched = pairMatches.find((r) => r.fieldName === candidate.inverse); + if (!inverseMatched) { + diagnostics.push({ + code: 'PSL_INVERSE_FIELD_NOT_FK', + 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}".`, + sourceId, + span: candidate.field.span, + }); + continue; + } + fk = inverseMatched; + } else { + if (pairMatches.length === 0) { + diagnostics.push({ + code: 'PSL_ORPHANED_BACKRELATION', + 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.`, + sourceId, + span: candidate.field.span, + }); + continue; + } + if (pairMatches.length > 1) { + diagnostics.push({ + code: 'PSL_AMBIGUOUS_BACKRELATION', + message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add inverse: to the list field, naming the FK-side relation field it pairs with, to disambiguate.`, + sourceId, + span: candidate.field.span, + }); + continue; + } + fk = pairMatches[0]; } - const fk = matches[0]; if (!fk) continue; const modelEntry = models[candidate.modelName]; if (!modelEntry) continue; diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/psl-helpers.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/psl-helpers.ts index 3a466e80fa..5dcfafa34b 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/psl-helpers.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/psl-helpers.ts @@ -93,7 +93,6 @@ export function getMapName(attributes: readonly ResolvedAttribute[]): string | u } export interface ParsedRelationAttribute { - readonly relationName?: string; readonly fields?: readonly string[]; readonly references?: readonly string[]; /** @@ -103,6 +102,12 @@ export interface ParsedRelationAttribute { * never co-occur. */ readonly referencesInferred?: true; + /** + * The FK-side relation field named by `inverse:` on a one-to-many back-relation + * list field. A bare relation-field name pinning the owning foreign-key field, + * used to disambiguate when multiple relations link the same pair of models. + */ + readonly inverse?: string; } /** @@ -129,16 +134,21 @@ export function parseRelationAttribute(input: { const relationAttr = getAttribute(input.attributes, 'relation'); if (!relationAttr) return undefined; - let relationName: string | undefined; let fromRaw: string | undefined; let toRaw: string | undefined; + let inverse: string | undefined; for (const arg of relationAttr.args) { - if (arg.kind === 'positional') { - relationName = stripQuotes(arg.value); - } else if (arg.name === 'name') { - relationName = stripQuotes(arg.value); - } else if (arg.name === 'fields' || arg.name === 'references') { + if (arg.kind === 'positional' || arg.name === 'name') { + input.diagnostics.push({ + code: 'PSL_LEGACY_RELATION_NAME', + 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)`, + sourceId: input.sourceId, + span: arg.span, + }); + return undefined; + } + if (arg.name === 'fields' || arg.name === 'references') { input.diagnostics.push({ code: 'PSL_LEGACY_FIELDS_REFERENCES', message: `Relation field "${input.modelName}.${input.fieldName}" uses @relation(fields:/references:), which is no longer supported — use from:/to: instead`, @@ -146,10 +156,14 @@ export function parseRelationAttribute(input: { span: arg.span, }); return undefined; - } else if (arg.name === 'from') { + } + if (arg.name === 'from') { fromRaw = arg.value; } else if (arg.name === 'to') { toRaw = arg.value; + } else if (arg.name === 'inverse') { + const trimmed = arg.value.trim(); + inverse = trimmed.length > 0 ? trimmed : undefined; } } @@ -199,10 +213,10 @@ export function parseRelationAttribute(input: { } return { - ...ifDefined('relationName', relationName), ...ifDefined('fields', fields), ...ifDefined('references', references), ...ifDefined('referencesInferred', referencesInferred), + ...ifDefined('inverse', inverse), }; } diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts index 271b6af30d..ddc4c70b8e 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts @@ -435,12 +435,12 @@ describe('interpretPslDocumentToMongoContract', () => { expect(model(ir, 'User').fields).not.toHaveProperty('posts'); }); - it('disambiguates multiple FK relations to the same target using relation name', () => { + it('disambiguates multiple FK relations to the same target using inverse:', () => { const ir = interpretOk(` model User { id ObjectId @id @map("_id") - createdTasks Task[] @relation("created") - assignedTasks Task[] @relation("assigned") + createdTasks Task[] @relation(inverse: creator) + assignedTasks Task[] @relation(inverse: assignee) } model Task { @@ -448,8 +448,8 @@ describe('interpretPslDocumentToMongoContract', () => { title String creatorId ObjectId assigneeId ObjectId - creator User @relation("created", from: [creatorId], to: [id]) - assignee User @relation("assigned", from: [assigneeId], to: [id]) + creator User @relation(from: [creatorId], to: [id]) + assignee User @relation(from: [assigneeId], to: [id]) } `); @@ -467,7 +467,7 @@ describe('interpretPslDocumentToMongoContract', () => { }); }); - it('emits diagnostic for ambiguous backrelation with multiple FKs and no relation name', () => { + it('emits diagnostic for ambiguous backrelation with multiple FKs and no inverse: pin', () => { const result = interpret(` model User { id ObjectId @id @map("_id") @@ -478,8 +478,8 @@ describe('interpretPslDocumentToMongoContract', () => { id ObjectId @id @map("_id") creatorId ObjectId assigneeId ObjectId - creator User @relation("created", from: [creatorId], to: [id]) - assignee User @relation("assigned", from: [assigneeId], to: [id]) + creator User @relation(from: [creatorId], to: [id]) + assignee User @relation(from: [assigneeId], to: [id]) } `); @@ -494,6 +494,78 @@ describe('interpretPslDocumentToMongoContract', () => { ); }); + it('rejects @relation(name:) and the positional name form', () => { + const named = interpret(` + model User { + id ObjectId @id @map("_id") + createdTasks Task[] @relation(name: "created") + } + + model Task { + id ObjectId @id @map("_id") + creatorId ObjectId + creator User @relation(name: "created", from: [creatorId], to: [id]) + } + `); + + expect(named.ok).toBe(false); + if (named.ok) return; + expect(named.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_LEGACY_RELATION_NAME', + message: expect.stringContaining('inverse:'), + }), + ]), + ); + + const positional = interpret(` + model User { + id ObjectId @id @map("_id") + posts Post[] @relation("UserPosts") + } + + model Post { + id ObjectId @id @map("_id") + userId ObjectId + user User @relation("UserPosts", from: [userId], to: [id]) + } + `); + + expect(positional.ok).toBe(false); + if (positional.ok) return; + expect(positional.failure.diagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'PSL_LEGACY_RELATION_NAME' })]), + ); + }); + + it('emits a diagnostic when inverse: names a field that is not an FK-side relation', () => { + const result = interpret(` + model User { + id ObjectId @id @map("_id") + createdTasks Task[] @relation(inverse: notAField) + assignedTasks Task[] @relation(inverse: assignee) + } + + model Task { + id ObjectId @id @map("_id") + creatorId ObjectId + assigneeId ObjectId + creator User @relation(from: [creatorId], to: [id]) + assignee User @relation(from: [assigneeId], to: [id]) + } + `); + + expect(result.ok).toBe(false); + if (result.ok) return; + const diagnostic = result.failure.diagnostics.find( + (d) => d.code === 'PSL_INVERSE_FIELD_NOT_FK', + ); + expect(diagnostic).toBeDefined(); + expect(diagnostic?.message).toContain('User.createdTasks'); + expect(diagnostic?.message).toContain('notAField'); + }); + it('creates 1:1 inverse relation for singular non-FK relation field', () => { const ir = interpretOk(` model User { diff --git a/packages/2-sql/2-authoring/contract-psl/README.md b/packages/2-sql/2-authoring/contract-psl/README.md index fc92fe3a81..d70107937c 100644 --- a/packages/2-sql/2-authoring/contract-psl/README.md +++ b/packages/2-sql/2-authoring/contract-psl/README.md @@ -52,8 +52,8 @@ Unsupported PSL constructs in v1 (strict errors): - Enum lists and named-type lists - **Relation navigation lists are supported** when they can be matched to an FK-side relation: - Example: `User.posts Post[]` + `Post.user User @relation(from: [userId], to: [id])` - - Matching may use `@relation("Name")` or `@relation(name: "Name")` when multiple candidates exist - - Navigation list fields accept only `@relation` (name-only form); other field attributes are strict errors + - When multiple candidates exist, disambiguate by pointing: `@relation(inverse: )` 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`). + - Navigation list fields accept only `@relation` (with `inverse:`/`through:`); other field attributes are strict errors - **Implicit Prisma ORM many-to-many remains unsupported** (list navigation on both sides without explicit join model) - Represent many-to-many with an explicit join model (two foreign keys) diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index 7e1ed54676..4819d920dc 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -106,6 +106,7 @@ import { interpretRelationAttribute, type ModelBackrelationCandidate, normalizeReferentialAction, + type ParsedThrough, resolveTargetIdFieldNames, validateNavigationListFieldAttributes, } from './psl-relation-resolution'; @@ -563,8 +564,8 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult targetId: input.targetId, }); const relationAttribute = getAttribute(field.attributes, 'relation'); - let relationName: string | undefined; - let through: string | undefined; + let through: ParsedThrough | undefined; + let inverse: string | undefined; if (relationAttribute) { const parsedRelation = interpretRelationAttribute({ selfModel: model, @@ -595,8 +596,8 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult }); continue; } - relationName = parsedRelation.name; through = parsedRelation.through; + inverse = parsedRelation.inverse; } if (!attributesValid) { continue; @@ -607,8 +608,8 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult tableName, field, targetModelName: field.typeName, - ...ifDefined('relationName', relationName), ...ifDefined('through', through), + ...ifDefined('inverse', inverse), }); } @@ -1172,7 +1173,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult targetModelName: targetMapping.model.name, targetTableName: targetMapping.tableName, ...ifDefined('targetNamespaceId', targetNamespaceId), - ...ifDefined('relationName', parsedRelation.name), localColumns, referencedColumns, }); diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts index 87f71feb0f..955476da92 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts @@ -13,7 +13,6 @@ import type { } from '@prisma-next/psl-parser'; import { fieldAttribute, - fieldRef, identifier, interpretAttribute, list, @@ -22,12 +21,8 @@ import { optional, str, } from '@prisma-next/psl-parser'; -import type { - AttributeArgAst, - FieldAttributeAst, - SourceFile, -} from '@prisma-next/psl-parser/syntax'; -import { ArrayLiteralAst, IdentifierAst } from '@prisma-next/psl-parser/syntax'; +import type { ExpressionAst, FieldAttributeAst, SourceFile } from '@prisma-next/psl-parser/syntax'; +import { ArrayLiteralAst, IdentifierAst, QualifiedNameAst } from '@prisma-next/psl-parser/syntax'; import type { ReferentialAction } from '@prisma-next/sql-contract/types'; import type { RelationNode } from '@prisma-next/sql-contract-ts/contract-builder'; import { assertDefined, invariant } from '@prisma-next/utils/assertions'; @@ -66,7 +61,6 @@ export type FkRelationMetadata = { readonly targetTableName: string; /** Resolved namespace coordinate of the related model, when known. */ readonly targetNamespaceId?: string; - readonly relationName?: string; readonly localColumns: readonly string[]; readonly referencedColumns: readonly string[]; }; @@ -76,13 +70,21 @@ export type ModelBackrelationCandidate = { readonly tableName: string; readonly field: FieldSymbol; readonly targetModelName: string; - readonly relationName?: string; /** - * The junction model named by `through:` on the list field. When present, - * many-to-many recognition considers only this junction rather than scanning - * every junction-shaped model linking the two sides. + * The junction named by `through:` on the list field. When present, + * many-to-many recognition considers only `junction` rather than scanning + * every junction-shaped model linking the two sides; an optional `field` pins + * the parent-side junction FK by its relation field, disambiguating + * self-relations and multiple many-to-many between the same pair of models. + */ + readonly through?: ParsedThrough; + /** + * The FK-side relation field named by `inverse:` on a one-to-many back-relation + * list field. When present, FK-side matching pins the back-relation to the FK + * relation whose declaring field is `inverse`, disambiguating multiple + * relations linking the same pair of models. */ - readonly through?: string; + readonly inverse?: string; }; type ModelRelationMetadata = RelationNode; @@ -97,6 +99,72 @@ export function normalizeReferentialAction(actionToken: string): ReferentialActi return REFERENTIAL_ACTION_MAP[actionToken]; } +/** + * The junction named by `through:`. The junction is the head of the value, so + * a qualified `through: Follow.follower` splits into `junction: 'Follow'` and + * the optional pin `field: 'follower'`. + */ +export type ParsedThrough = { + readonly junction: string; + readonly field?: string; +}; + +/** + * Extracts the field name from a directional argument element: a bare field + * name or a `Model.field` member access, whose redundant model qualifier is + * stripped so the qualified spelling lowers identically to the bare one. + */ +function directionalFieldName(arg: ExpressionAst): string | undefined { + if (arg instanceof IdentifierAst) { + return arg.name(); + } + if (arg instanceof QualifiedNameAst) { + const path = arg.path(); + const tail = path[path.length - 1]; + return tail !== undefined && tail.length > 0 ? tail : undefined; + } + return undefined; +} + +/** + * Reads a directional field-argument element (`from:`/`to:` entries): a bare + * field name or a qualifier-stripped `Model.field`, existence-checked against + * the scope model like the kit's `fieldRef`. + */ +function directionalFieldRef(scope: FieldRefScope): ArgType { + return { + kind: 'directionalFieldRef', + label: 'field name', + parse: (arg, ctx): Result => { + const name = directionalFieldName(arg); + if (name === undefined) { + return notOk([ + { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Expected a field name', + sourceId: ctx.sourceId, + span: nodePslSpan(arg.syntax, ctx.sourceFile), + }, + ]); + } + const model = scope === 'self' ? ctx.selfModel : ctx.resolveReferencedModel(); + // A referenced model in another space can't be resolved here; skip the + // existence check — it runs where that model is known. + if (model !== undefined && !Object.hasOwn(model.fields, name)) { + return notOk([ + { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: `Field "${name}" does not exist on model "${model.name}"`, + sourceId: ctx.sourceId, + span: nodePslSpan(arg.syntax, ctx.sourceFile), + }, + ]); + } + return ok(name); + }, + }; +} + /** * Accepts a `@relation` directional argument value (`from:`/`to:`): a single * bare field (`from: userId`) or a bracketed list (`from: [a, b]`), normalised @@ -105,8 +173,8 @@ export function normalizeReferentialAction(actionToken: string): ReferentialActi * into a generic mismatch message. */ function fieldRefOrList(scope: FieldRefScope): ArgType { - const single = fieldRef(scope); - const bracketed = list(fieldRef(scope), { nonEmpty: true, unique: true }); + const single = directionalFieldRef(scope); + const bracketed = list(single, { nonEmpty: true, unique: true }); return { kind: 'fieldRefOrList', label: 'field name or field name[]', @@ -124,16 +192,14 @@ function fieldRefOrList(scope: FieldRefScope): ArgType { } /** - * Reads a bare model-name identifier argument value (`through: PostTag`). The - * expression grammar carries only the head identifier of a member-access - * value, so a qualified `through: PostTag.post` reaches this combinator as - * the bare model name `PostTag` — the qualified disambiguation form is a - * separate grammar change, and the bare name is all this slice recognises. + * Reads a bare identifier argument value as a plain name (e.g. `inverse: + * editor`, naming an FK-side relation field). Existence is validated + * downstream where the named model is known. */ -function modelName(): ArgType { +function bareName(label: string): ArgType { return { - kind: 'modelName', - label: 'model name', + kind: 'bareName', + label, parse: (arg, ctx): Result => { if (arg instanceof IdentifierAst) { const name = arg.name(); @@ -144,7 +210,45 @@ function modelName(): ArgType { return notOk([ { code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: 'Expected a model name', + message: `Expected a ${label}`, + sourceId: ctx.sourceId, + span: nodePslSpan(arg.syntax, ctx.sourceFile), + }, + ]); + }, + }; +} + +/** + * Reads the `through:` junction pointer: a bare junction model + * (`through: PostTag`) or a qualified junction relation field + * (`through: PostTag.post`), whose field segment pins the parent-side + * junction FK to disambiguate self-relations and multiple many-to-many + * between the same pair of models. + */ +function throughRef(): ArgType { + return { + kind: 'throughRef', + label: 'junction model or Junction.relationField', + parse: (arg, ctx): Result => { + if (arg instanceof IdentifierAst) { + const junction = arg.name(); + if (junction !== undefined) { + return ok({ junction }); + } + } + if (arg instanceof QualifiedNameAst) { + const path = arg.path(); + const junction = path[0]; + if (junction !== undefined && junction.length > 0) { + const field = path.slice(1).join('.'); + return ok({ junction, ...ifDefined('field', field.length > 0 ? field : undefined) }); + } + } + return notOk([ + { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Expected a junction model name', sourceId: ctx.sourceId, span: nodePslSpan(arg.syntax, ctx.sourceFile), }, @@ -184,12 +288,11 @@ function relationInvariants( // spellings are rejected up front with a guiding diagnostic (see // interpretRelationAttribute) rather than reported as unknown arguments. const sqlRelation = fieldAttribute('relation', { - positional: [{ key: 'name', type: optional(str()) }], named: { - name: optional(str()), from: optional(fieldRefOrList('self')), to: optional(fieldRefOrList('referenced')), - through: optional(modelName()), + through: optional(throughRef()), + inverse: optional(bareName('relation field name')), map: optional(str()), onDelete: optional( oneOf( @@ -221,7 +324,6 @@ export type SqlRelationOutput = InferAttr; * the resolution pipeline consumes. */ export type ParsedSqlRelation = { - readonly name?: string; readonly fields?: readonly string[]; readonly references?: readonly string[]; /** @@ -232,13 +334,22 @@ export type ParsedSqlRelation = { */ readonly referencesInferred?: true; /** - * The junction model named by `through:` on a navigable list field, used to - * recognise the many-to-many via that explicit junction. A bare model - * identifier (`through: PostTag`); the qualified relation-field form - * (`through: PostTag.post`) is a separate member-access grammar and does not - * reach the resolver as a dotted value — only its head identifier survives. + * The junction named by `through:` on a navigable list field, used to + * recognise the many-to-many via that explicit junction. `junction` is the + * head identifier (`through: PostTag`); `field` is the optional + * relation-field segment of the qualified form (`through: PostTag.post` ⇒ + * `field: 'post'`), which pins the parent-side junction FK to disambiguate + * self-relations and multiple many-to-many between the same pair of models. + */ + readonly through?: ParsedThrough; + /** + * The FK-side relation field named by `inverse:` on a one-to-many + * back-relation list field (`posts Post[] @relation(inverse: editor)` ⇒ + * `inverse: 'editor'`). A bare relation-field name pinning the owning + * foreign-key field, used to disambiguate when multiple relations link the + * same pair of models. */ - readonly through?: string; + readonly inverse?: string; readonly map?: string; readonly onDelete?: SqlRelationOutput['onDelete']; readonly onUpdate?: SqlRelationOutput['onUpdate']; @@ -296,18 +407,50 @@ function buildRelationInterpretCtx(input: { }; } +function legacyRelationNameDiagnostic( + input: { + readonly selfModel: ModelSymbol; + readonly field: FieldSymbol; + readonly sourceId: string; + }, + span: PslSpan, +): ContractSourceDiagnostic { + return { + code: 'PSL_LEGACY_RELATION_NAME', + message: `Relation field "${input.selfModel.name}.${input.field.name}" 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)`, + sourceId: input.sourceId, + span, + }; +} + /** - * Finds a legacy `fields:`/`references:` argument on the `@relation` attribute - * so it can be rejected with a guiding diagnostic instead of the generic - * unknown-argument message the spec would produce. + * Rejects retired `@relation` arguments with a guiding diagnostic instead of + * the generic unknown-argument message the spec would produce: the legacy + * `fields:`/`references:` directional spellings, and the `name:`/positional + * relation-name disambiguator that `inverse:`/`through:` replace. */ -function findLegacyDirectionalArgument( +function findLegacyArgumentDiagnostic( attributeNode: FieldAttributeAst, -): AttributeArgAst | undefined { + input: { + readonly selfModel: ModelSymbol; + readonly field: FieldSymbol; + readonly sourceFile: SourceFile; + readonly sourceId: string; + }, +): ContractSourceDiagnostic | undefined { for (const arg of attributeNode.argList()?.args() ?? []) { const name = arg.name()?.name(); + const span = nodePslSpan(arg.syntax, input.sourceFile); if (name === 'fields' || name === 'references') { - return arg; + return { + code: 'PSL_LEGACY_FIELDS_REFERENCES', + message: `Relation field "${input.selfModel.name}.${input.field.name}" uses @relation(fields:/references:), which is no longer supported — use from:/to: instead`, + sourceId: input.sourceId, + span, + }; + } + if (name === undefined || name === 'name') { + return legacyRelationNameDiagnostic(input, span); } } return undefined; @@ -325,14 +468,9 @@ export function interpretRelationAttribute(input: { if (attributeNode === undefined) { return undefined; } - const legacyArgument = findLegacyDirectionalArgument(attributeNode); - if (legacyArgument !== undefined) { - input.diagnostics.push({ - code: 'PSL_LEGACY_FIELDS_REFERENCES', - message: `Relation field "${input.selfModel.name}.${input.field.name}" uses @relation(fields:/references:), which is no longer supported — use from:/to: instead`, - sourceId: input.sourceId, - span: nodePslSpan(legacyArgument.syntax, input.sourceFile), - }); + const legacyDiagnostic = findLegacyArgumentDiagnostic(attributeNode, input); + if (legacyDiagnostic !== undefined) { + input.diagnostics.push(legacyDiagnostic); return undefined; } const ctx = buildRelationInterpretCtx(input); @@ -349,11 +487,11 @@ export function interpretRelationAttribute(input: { const referencesInferred: true | undefined = fields !== undefined && references === undefined ? true : undefined; return { - ...ifDefined('name', value.name), ...ifDefined('fields', fields), ...ifDefined('references', references), ...ifDefined('referencesInferred', referencesInferred), ...ifDefined('through', value.through), + ...ifDefined('inverse', value.inverse), ...ifDefined('map', value.map), ...ifDefined('onDelete', value.onDelete), ...ifDefined('onUpdate', value.onUpdate), @@ -506,10 +644,16 @@ function childColumnsInTargetIdOrder( * junction-specific diagnostic that is more actionable than the generic * orphaned-backrelation message. */ -type JunctionNearMiss = { - readonly junctionModelName: string; - readonly reason: 'id-not-fk-covering' | 'target-fk-not-id'; -}; +type JunctionNearMiss = + | { + readonly junctionModelName: string; + readonly reason: 'id-not-fk-covering' | 'target-fk-not-id'; + } + | { + readonly junctionModelName: string; + readonly reason: 'through-field-not-fk'; + readonly throughField: string; + }; /** * Finds explicit junction models that connect a bare backrelation list field @@ -518,12 +662,15 @@ type JunctionNearMiss = { * one relation to the candidate's target model (the child side). The child * FK must reference exactly the target model's id columns; its junction * columns are carried in target-id order on the pair. A relation name on the - * list field pins the parent-side FK relation, which is how self-referential - * many-to-many sides are disambiguated. + * list field, or a `through: Junction.relationField` pin, fixes the parent-side + * FK relation, which is how self-referential many-to-many sides and multiple + * many-to-many between the same pair of models are disambiguated. * * Alongside the recognised pairs, returns junction-shaped near-misses (models * that link both sides but were declined) so the caller can emit a * junction-specific diagnostic instead of the generic orphaned-list message. + * A `through:` pin naming a field that is not a parent-side junction FK back to + * the candidate is itself reported as a near-miss. */ function findJunctionFkPairs(input: { readonly candidate: ModelBackrelationCandidate; @@ -534,24 +681,44 @@ function findJunctionFkPairs(input: { if (!targetIdColumns || targetIdColumns.length === 0) { return { pairs: [], nearMisses: [] }; } + const through = input.candidate.through; const pairs: JunctionFkPair[] = []; const nearMisses: JunctionNearMiss[] = []; for (const [junctionModelName, junctionFks] of input.fkRelationsByDeclaringModel) { // An explicit `through:` names the junction directly: skip every other // junction-shaped model so recognition and near-miss reporting are scoped // to the authored junction. A bare list (no `through:`) scans all of them. - if (input.candidate.through !== undefined && junctionModelName !== input.candidate.through) { + if (through !== undefined && junctionModelName !== through.junction) { continue; } const idColumns = input.modelIdColumns.get(junctionModelName); + // A `through: Junction.relationField` pin names a parent-side junction FK by + // its relation field. If the named junction has no such FK back to the + // candidate, the pin cannot resolve: record it as an actionable near-miss + // rather than letting recognition fall into the generic ambiguity path. + if (through?.field !== undefined) { + const pinnedParentFkExists = junctionFks.some( + (fk) => + fk.targetModelName === input.candidate.modelName && + fk.declaringFieldName === through.field, + ); + if (!pinnedParentFkExists) { + nearMisses.push({ + junctionModelName, + reason: 'through-field-not-fk', + throughField: through.field, + }); + continue; + } + } for (const parentFk of junctionFks) { if (parentFk.targetModelName !== input.candidate.modelName) { continue; } - if ( - input.candidate.relationName !== undefined && - parentFk.relationName !== input.candidate.relationName - ) { + // `through: Junction.relationField` pins the parent-side FK to the + // junction relation field named, selecting one leg of a self-relation or + // of multiple many-to-many between the same pair of models. + if (through?.field !== undefined && parentFk.declaringFieldName !== through.field) { continue; } for (const childFk of junctionFks) { @@ -590,6 +757,15 @@ function junctionNearMissDiagnostic( junctionModel: nearMiss.junctionModelName, targetModel: candidate.targetModelName, }; + if (nearMiss.reason === 'through-field-not-fk') { + return { + code: 'PSL_JUNCTION_THROUGH_FIELD_NOT_FK', + message: `Backrelation list field "${listField}" pins junction "${nearMiss.junctionModelName}" relation field "${nearMiss.throughField}" via through: ${nearMiss.junctionModelName}.${nearMiss.throughField}, but "${nearMiss.junctionModelName}" has no relation field "${nearMiss.throughField}" with a foreign key back to "${candidate.modelName}". Name a junction relation field whose foreign key references "${candidate.modelName}".`, + sourceId, + span: candidate.field.span, + data: { ...data, throughField: nearMiss.throughField }, + }; + } if (nearMiss.reason === 'target-fk-not-id') { return { code: 'PSL_JUNCTION_TARGET_FK_NOT_ID', @@ -633,6 +809,25 @@ function manyToManyRelationNode( }; } +function oneToManyRelationNode( + candidate: ModelBackrelationCandidate, + matched: FkRelationMetadata, +): ModelRelationMetadata { + return { + fieldName: candidate.field.name, + toModel: matched.declaringModelName, + toTable: matched.declaringTableName, + ...ifDefined('toNamespaceId', matched.declaringNamespaceId), + cardinality: '1:N', + on: { + parentTable: candidate.tableName, + parentColumns: matched.referencedColumns, + childTable: matched.declaringTableName, + childColumns: matched.localColumns, + }, + }; +} + function relationsForModel( modelRelations: Map, modelName: string, @@ -658,9 +853,39 @@ export function applyBackrelationCandidates(input: { for (const candidate of input.backrelationCandidates) { const pairKey = fkRelationPairKey(candidate.targetModelName, candidate.modelName); const pairMatches = input.fkRelationsByPair.get(pairKey) ?? []; - const matches = candidate.relationName - ? pairMatches.filter((relation) => relation.relationName === candidate.relationName) - : [...pairMatches]; + + // `inverse:` pins a one-to-many back-relation to the FK-side relation whose + // declaring field it names, the directional disambiguator across multiple + // relations between the same pair of models. A relation field name is unique + // within its model, so at most one FK-side relation matches. When `inverse:` + // names a field that is not an FK-side relation back to the candidate, report + // it rather than letting recognition fall into the generic ambiguity or + // junction path. + if (candidate.inverse !== undefined) { + const inverseMatched = pairMatches.find( + (relation) => relation.declaringFieldName === candidate.inverse, + ); + if (!inverseMatched) { + input.diagnostics.push({ + code: 'PSL_INVERSE_FIELD_NOT_FK', + message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" 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}".`, + sourceId: input.sourceId, + span: candidate.field.span, + data: { + listField: `${candidate.modelName}.${candidate.field.name}`, + targetModel: candidate.targetModelName, + inverseField: candidate.inverse, + }, + }); + continue; + } + relationsForModel(input.modelRelations, candidate.modelName).push( + oneToManyRelationNode(candidate, inverseMatched), + ); + continue; + } + + const matches = [...pairMatches]; if (matches.length === 0) { const { pairs: junctionPairs, nearMisses } = findJunctionFkPairs({ @@ -678,7 +903,7 @@ export function applyBackrelationCandidates(input: { if (junctionPairs.length > 1) { input.diagnostics.push({ code: 'PSL_AMBIGUOUS_BACKRELATION_LIST', - 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.`, + message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple junction FK pairs for a many-to-many relation. Add through: Junction.relationField (the qualified junction pin) to the list field to disambiguate.`, sourceId: input.sourceId, span: candidate.field.span, }); @@ -700,7 +925,7 @@ export function applyBackrelationCandidates(input: { if (matches.length > 1) { input.diagnostics.push({ code: 'PSL_AMBIGUOUS_BACKRELATION_LIST', - 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.`, + message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add inverse: to the list field, naming the FK-side relation field it pairs with, to disambiguate.`, sourceId: input.sourceId, span: candidate.field.span, }); @@ -711,19 +936,9 @@ export function applyBackrelationCandidates(input: { const matched = matches[0]; assertDefined(matched, 'Backrelation matching requires a defined relation match'); - relationsForModel(input.modelRelations, candidate.modelName).push({ - fieldName: candidate.field.name, - toModel: matched.declaringModelName, - toTable: matched.declaringTableName, - ...ifDefined('toNamespaceId', matched.declaringNamespaceId), - cardinality: '1:N', - on: { - parentTable: candidate.tableName, - parentColumns: matched.referencedColumns, - childTable: matched.declaringTableName, - childColumns: matched.localColumns, - }, - }); + relationsForModel(input.modelRelations, candidate.modelName).push( + oneToManyRelationNode(candidate, matched), + ); } } diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.from-to.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.from-to.test.ts index 28915662fd..39555be164 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.from-to.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.from-to.test.ts @@ -98,6 +98,60 @@ model Post { }); }); + describe('legacy relation name rejection', () => { + it('rejects @relation(name: "...") with a guiding diagnostic pointing at inverse:/through:', () => { + const result = interpret(`model User { + id Int @id + authoredPosts Post[] @relation(name: "AuthoredPosts") + editedPosts Post[] @relation(name: "EditedPosts") +} + +model Post { + id Int @id + authorId Int + editorId Int + author User @relation(name: "AuthoredPosts", from: authorId) + editor User @relation(name: "EditedPosts", from: editorId) +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_LEGACY_RELATION_NAME', + message: expect.stringContaining('inverse:'), + }), + ]), + ); + const diagnostic = result.failure.diagnostics.find( + (d) => d.code === 'PSL_LEGACY_RELATION_NAME', + ); + expect(diagnostic?.message).toContain('through: Junction.field'); + }); + + it('rejects the positional @relation("...") name form', () => { + const result = interpret(`model User { + id Int @id + posts Post[] @relation("UserPosts") +} + +model Post { + id Int @id + userId Int + user User @relation("UserPosts", from: [userId], to: [id]) +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'PSL_LEGACY_RELATION_NAME' })]), + ); + }); + }); + describe('to inference (omit to: ⇒ target @id)', () => { it('infers the single-column target @id when to: is omitted', () => { const inferred = interpret(`model User { @@ -226,12 +280,11 @@ model Post { expect(bare.value).toEqual(bracketed.value); }); - // The PSL expression grammar does not carry a member-access argument value: - // `parseIdentifierExpr` consumes only the head identifier, so `to: User.id` - // reaches the attribute spec as `to: User`, which names no field on the - // target model and is rejected. This pins the present grammar boundary as a - // regression anchor for a future slice that carries the dotted value. - it('rejects a member-access to: value (qualifier dropped at the grammar layer)', () => { + // A redundant `Model.` qualifier on `to:` (e.g. `to: User.id`) is tolerated: + // the member-access grammar carries the dotted value to the resolver, which + // strips the qualifying `Model.` to the bare referenced column, so it lowers + // identically to the unqualified spelling. + it('tolerates a redundant Model. qualifier on to:, lowering it like the bare value', () => { const qualified = interpret(`model User { id Int @id posts Post[] @@ -243,16 +296,22 @@ model Post { user User @relation(from: userId, to: User.id) } `); + const bare = interpret(`model User { + id Int @id + posts Post[] +} - expect(qualified.ok).toBe(false); - if (qualified.ok) return; - expect(qualified.failure.diagnostics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: expect.stringContaining('Field "User" does not exist on model "User"'), - }), - ]), - ); +model Post { + id Int @id + userId Int + user User @relation(from: userId, to: id) +} +`); + + expect(qualified.ok).toBe(true); + expect(bare.ok).toBe(true); + if (!qualified.ok || !bare.ok) return; + expect(qualified.value).toEqual(bare.value); }); }); @@ -303,12 +362,12 @@ model Post { }); describe('self-referential from/to', () => { - it('resolves a named self-referential from/to relation, inferring to: from @id', () => { + it('resolves a self-referential from/to relation disambiguated by inverse:, inferring to: from @id', () => { const result = interpret(`model Employee { id Int @id managerId Int? - manager Employee? @relation("Manages", from: managerId) - reports Employee[] @relation("Manages") + manager Employee? @relation(from: managerId) + reports Employee[] @relation(inverse: manager) } `); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.inverse.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.inverse.test.ts new file mode 100644 index 0000000000..30f47d0b81 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.inverse.test.ts @@ -0,0 +1,157 @@ +import type { Contract } from '@prisma-next/contract/types'; +import { crossRef } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { validateSqlContractFully } from '@prisma-next/sql-contract/validators'; +import { describe, expect, it } from 'vitest'; +import { interpretPslDocumentToSqlContract } from '../src/interpreter'; +import { + createBuiltinLikeControlMutationDefaults, + createTestSqlNamespace, + modelsOf, + postgresScalarTypeDescriptors, + postgresTarget, + symbolTableInputFromParseArgs, +} from './fixtures'; + +const baseInput = { + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, + capabilities: { sql: { scalarList: true } }, +} as const; + +function interpret(schema: string) { + const document = symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }); + return interpretPslDocumentToSqlContract({ ...baseInput, ...document }); +} + +function relationsOf(contract: Contract) { + return modelsOf(contract) as Record }>; +} + +const twoRelationPostModel = `model Post { + id Int @id + authorId Int + editorId Int + author User @relation(from: authorId) + editor User @relation(from: editorId) +} +`; + +describe('interpretPslDocumentToSqlContract inverse: one-to-many disambiguation', () => { + it('pins each back-relation to the FK-side relation field it names', () => { + const result = interpret(`model User { + id Int @id + authoredPosts Post[] @relation(inverse: author) + editedPosts Post[] @relation(inverse: editor) +} + +${twoRelationPostModel}`); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const models = relationsOf(result.value); + expect(models['User']?.relations).toEqual({ + authoredPosts: { + to: crossRef('Post', 'public'), + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['authorId'] }, + }, + editedPosts: { + to: crossRef('Post', 'public'), + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['editorId'] }, + }, + }); + expect(models['Post']?.relations).toEqual({ + author: { + to: crossRef('User', 'public'), + cardinality: 'N:1', + on: { localFields: ['authorId'], targetFields: ['id'] }, + }, + editor: { + to: crossRef('User', 'public'), + cardinality: 'N:1', + on: { localFields: ['editorId'], targetFields: ['id'] }, + }, + }); + + const envelope = JSON.parse(JSON.stringify(result.value)) as unknown; + expect(() => validateSqlContractFully>(envelope)).not.toThrow(); + }); + + it('defers the same shape to the ambiguity diagnostic when inverse: is absent (control)', () => { + const result = interpret(`model User { + id Int @id + authoredPosts Post[] + editedPosts Post[] +} + +${twoRelationPostModel}`); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_AMBIGUOUS_BACKRELATION_LIST', + message: expect.stringContaining('User.authoredPosts'), + }), + ]), + ); + }); + + it('emits an actionable diagnostic when inverse: names a field that is not an FK-side relation', () => { + const result = interpret(`model User { + id Int @id + authoredPosts Post[] @relation(inverse: notAField) + editedPosts Post[] @relation(inverse: editor) +} + +${twoRelationPostModel}`); + + expect(result.ok).toBe(false); + if (result.ok) return; + + const diagnostic = result.failure.diagnostics.find( + (d) => d.code === 'PSL_INVERSE_FIELD_NOT_FK', + ); + expect(diagnostic).toBeDefined(); + expect(diagnostic?.message).toContain('User.authoredPosts'); + expect(diagnostic?.message).toContain('notAField'); + expect(diagnostic?.message).toContain('Post'); + }); + + it('rejects the legacy name:-authored version that inverse: replaces', () => { + const viaName = interpret(`model User { + id Int @id + authoredPosts Post[] @relation(name: "AuthoredPosts") + editedPosts Post[] @relation(name: "EditedPosts") +} + +model Post { + id Int @id + authorId Int + editorId Int + author User @relation(name: "AuthoredPosts", from: authorId) + editor User @relation(name: "EditedPosts", from: editorId) +} +`); + + expect(viaName.ok).toBe(false); + if (viaName.ok) return; + + expect(viaName.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_LEGACY_RELATION_NAME', + message: expect.stringContaining('inverse:'), + }), + ]), + ); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts index 918e166845..adeb6cb8bd 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts @@ -357,18 +357,18 @@ model PostTag { expect(diagnostic?.message).toContain('@id'); }); - it('resolves self-referential junction lists disambiguated by relation name', () => { + it('resolves self-referential junction lists disambiguated by through: Junction.field', () => { const result = interpretSchema(`model User { id Int @id - following User[] @relation("follower") - followers User[] @relation("followee") + following User[] @relation(through: Follow.follower) + followers User[] @relation(through: Follow.followee) } model Follow { followerId Int followeeId Int - follower User @relation("follower", from: [followerId], to: [id]) - followee User @relation("followee", from: [followeeId], to: [id]) + follower User @relation(from: [followerId], to: [id]) + followee User @relation(from: [followeeId], to: [id]) @@id([followerId, followeeId]) } @@ -406,7 +406,7 @@ model Follow { }); }); - it('returns diagnostics for self-referential junction lists without a relation name', () => { + it('returns diagnostics for self-referential junction lists without a through: pin', () => { const result = interpretSchema(`model User { id Int @id follows User[] @@ -415,8 +415,8 @@ model Follow { model Follow { followerId Int followeeId Int - follower User @relation("follower", from: [followerId], to: [id]) - followee User @relation("followee", from: [followeeId], to: [id]) + follower User @relation(from: [followerId], to: [id]) + followee User @relation(from: [followeeId], to: [id]) @@id([followerId, followeeId]) } @@ -435,24 +435,24 @@ model Follow { ); }); - it('lowers two distinct named many-to-many relations between the same pair through separate junctions', () => { + it('lowers two distinct many-to-many relations between the same pair through separate junctions', () => { const result = interpretSchema(`model User { id Int @id - ownedTags Tag[] @relation("owned") - watchedTags Tag[] @relation("watched") + ownedTags Tag[] @relation(through: TagOwnership) + watchedTags Tag[] @relation(through: TagWatch) } model Tag { id Int @id - owners User[] @relation("owned") - watchers User[] @relation("watched") + owners User[] @relation(through: TagOwnership) + watchers User[] @relation(through: TagWatch) } model TagOwnership { userId Int tagId Int - user User @relation("owned", from: [userId], to: [id]) - tag Tag @relation("owned", from: [tagId], to: [id]) + user User @relation(from: [userId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) @@id([userId, tagId]) } @@ -460,8 +460,8 @@ model TagOwnership { model TagWatch { userId Int tagId Int - user User @relation("watched", from: [userId], to: [id]) - tag Tag @relation("watched", from: [tagId], to: [id]) + user User @relation(from: [userId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) @@id([userId, tagId]) } diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts index 60f9fcaaeb..8a205aada5 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts @@ -75,20 +75,20 @@ model Post { }); }); - it('matches named backrelations using positional and named relation forms', () => { + it('matches backrelations disambiguated by inverse:', () => { const document = symbolTableInputFromParseArgs({ schema: `model User { id Int @id - authored Post[] @relation("AuthoredPosts") - reviewed Post[] @relation(name: "ReviewedPosts") + authored Post[] @relation(inverse: author) + reviewed Post[] @relation(inverse: reviewer) } model Post { id Int @id authorId Int reviewerId Int - author User @relation("AuthoredPosts", from: [authorId], to: [id]) - reviewer User @relation(name: "ReviewedPosts", from: [reviewerId], to: [id]) + author User @relation(from: [authorId], to: [id]) + reviewer User @relation(from: [reviewerId], to: [id]) } `, sourceId: 'schema.prisma', @@ -176,13 +176,13 @@ model Member { }); }); - it('matches self-referential backrelations when disambiguated by relation name', () => { + it('matches self-referential backrelations when disambiguated by inverse:', () => { const document = symbolTableInputFromParseArgs({ schema: `model Employee { id Int @id managerId Int? - manager Employee? @relation("Manages", from: [managerId], to: [id]) - reports Employee[] @relation("Manages") + manager Employee? @relation(from: [managerId], to: [id]) + reports Employee[] @relation(inverse: manager) } `, sourceId: 'schema.prisma', diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.through.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.through.test.ts index e4764a4a18..3bf99758f1 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.through.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.through.test.ts @@ -246,8 +246,8 @@ model PostTag { model Follow { followerId Int followeeId Int - follower User @relation("follower", from: [followerId], to: [id]) - followee User @relation("followee", from: [followeeId], to: [id]) + follower User @relation(from: [followerId], to: [id]) + followee User @relation(from: [followeeId], to: [id]) @@id([followerId, followeeId]) } @@ -266,3 +266,102 @@ model Follow { ); }); }); + +const selfRelationFollowJunction = `model Follow { + followerId Int + followeeId Int + follower User @relation(from: followerId) + followee User @relation(from: followeeId) + + @@id([followerId, followeeId]) +} +`; + +describe('interpretPslDocumentToSqlContract qualified through: disambiguation', () => { + it('pins each self-referential M:N leg to the junction relation field it names', () => { + const result = interpret(`model User { + id Int @id + following User[] @relation(through: Follow.follower) + followers User[] @relation(through: Follow.followee) +} + +${selfRelationFollowJunction}`); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const models = relationsOf(result.value); + expect(models['User']?.relations).toEqual({ + following: { + to: crossRef('User', 'public'), + cardinality: 'N:M', + on: { localFields: ['id'], targetFields: ['followerId'] }, + through: { + table: 'follow', + namespaceId: 'public', + parentColumns: ['followerId'], + childColumns: ['followeeId'], + targetColumns: ['id'], + }, + }, + followers: { + to: crossRef('User', 'public'), + cardinality: 'N:M', + on: { localFields: ['id'], targetFields: ['followeeId'] }, + through: { + table: 'follow', + namespaceId: 'public', + parentColumns: ['followeeId'], + childColumns: ['followerId'], + targetColumns: ['id'], + }, + }, + }); + + const envelope = JSON.parse(JSON.stringify(result.value)) as unknown; + expect(() => validateSqlContractFully>(envelope)).not.toThrow(); + }); + + it('defers the same self-relation to the ambiguity diagnostic when through: is unqualified (control)', () => { + const result = interpret(`model User { + id Int @id + following User[] @relation(through: Follow) + followers User[] @relation(through: Follow) +} + +${selfRelationFollowJunction}`); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_AMBIGUOUS_BACKRELATION_LIST', + message: expect.stringContaining('User.following'), + }), + ]), + ); + }); + + it('emits an actionable diagnostic when through: names a field that is not a junction FK back to the candidate', () => { + const result = interpret(`model User { + id Int @id + following User[] @relation(through: Follow.notAField) + followers User[] @relation(through: Follow.followee) +} + +${selfRelationFollowJunction}`); + + expect(result.ok).toBe(false); + if (result.ok) return; + + const diagnostic = result.failure.diagnostics.find( + (d) => d.code === 'PSL_JUNCTION_THROUGH_FIELD_NOT_FK', + ); + expect(diagnostic).toBeDefined(); + expect(diagnostic?.message).toContain('User.following'); + expect(diagnostic?.message).toContain('Follow'); + expect(diagnostic?.message).toContain('notAField'); + }); +}); diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts index 7fad774bd5..723f4003f2 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts @@ -53,6 +53,13 @@ export type RelationField = { readonly optional: boolean; readonly list: boolean; readonly relationName?: string | undefined; + /** + * On the back/list side of a disambiguated relation (multiple FKs between the + * same pair of models, or a self-relation), the field name of the FK-side + * relation field this end pairs with. The printer emits `inverse: ` to + * point at it. + */ + readonly inverseOf?: string | undefined; readonly fkName?: string | undefined; readonly fields?: readonly string[] | undefined; readonly references?: readonly string[] | undefined; diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/relation-inference.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/relation-inference.ts index 2b259a761e..8e22fbaa36 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/relation-inference.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/relation-inference.ts @@ -91,6 +91,7 @@ export function inferRelations( optional: isOneToOne, list: !isOneToOne, relationName, + ...(needsRelationName ? { inverseOf: childRelFieldName } : {}), }; addRelationField(relationsByTable, parentTableName, backRelField); diff --git a/packages/2-sql/9-family/test/psl-contract-infer/relation-inference.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/relation-inference.test.ts index a67b905e5d..42f3082e05 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/relation-inference.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/relation-inference.test.ts @@ -48,6 +48,8 @@ describe('inferRelations', () => { typeName: 'Post', list: true, }); + // An unambiguous 1:N back-relation carries no inverse pointer. + expect(userRelations![0]).not.toHaveProperty('inverseOf'); }); it('detects 1:1 when FK column has unique constraint', () => { @@ -217,6 +219,12 @@ describe('inferRelations', () => { relationName: 'fk_receiver', fkName: 'fk_receiver', }); + + // Each back-relation on the parent points at the FK-side field it pairs with. + const userRelations = relationsByTable.get('user'); + expect(userRelations).toHaveLength(2); + expect(userRelations![0]).toMatchObject({ inverseOf: 'sender' }); + expect(userRelations![1]).toMatchObject({ inverseOf: 'receiver' }); }); it('falls back to generated relation names for unnamed duplicate FKs', () => { @@ -287,12 +295,13 @@ describe('inferRelations', () => { optional: true, relationName: 'ParentCategories', }); - // Back-relation field + // Back-relation field points at the FK-side relation field it pairs with. const backRel = relations!.find((r) => !r.fields); expect(backRel).toMatchObject({ typeName: 'Category', list: true, relationName: 'ParentCategories', + inverseOf: 'parent', }); }); diff --git a/packages/3-extensions/sql-orm-client/package.json b/packages/3-extensions/sql-orm-client/package.json index 2a96c59dd8..ed7a04552b 100644 --- a/packages/3-extensions/sql-orm-client/package.json +++ b/packages/3-extensions/sql-orm-client/package.json @@ -7,7 +7,7 @@ "description": "ORM client for Prisma Next — fluent, type-safe model collections", "scripts": { "build": "tsdown", - "emit": "cd ../../../test/integration && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/polymorphism/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/junction-namespaces/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/execution-defaulted-tags/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl-through/prisma-next.config.ts && cp test/sql-orm-client/fixtures/generated/contract.json test/sql-orm-client/fixtures/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/generated/ && cp test/sql-orm-client/fixtures/junction-namespaces/generated/contract.json test/sql-orm-client/fixtures/junction-namespaces/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/junction-namespaces/generated/ && cd ../../packages/3-extensions/sql-orm-client && node scripts/strip-pgvector-fixture.mjs test/fixtures/generated/contract.d.ts", + "emit": "cd ../../../test/integration && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/polymorphism/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/junction-namespaces/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/execution-defaulted-tags/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl-through/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/disambiguated-1n-inverse/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/self-ref-mn-through/prisma-next.config.ts && cp test/sql-orm-client/fixtures/generated/contract.json test/sql-orm-client/fixtures/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/generated/ && cp test/sql-orm-client/fixtures/junction-namespaces/generated/contract.json test/sql-orm-client/fixtures/junction-namespaces/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/junction-namespaces/generated/ && cd ../../packages/3-extensions/sql-orm-client && node scripts/strip-pgvector-fixture.mjs test/fixtures/generated/contract.d.ts", "emit:check": "pnpm emit && git diff --exit-code test/fixtures/generated/ test/fixtures/junction-namespaces/generated/", "test": "vitest run", "test:coverage": "vitest run --coverage", diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts index e697551cac..0749ab58a0 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts @@ -648,9 +648,6 @@ function buildRelationField( const args: PslAttributeArgument[] = []; if (rel.fields && rel.references) { - if (rel.relationName) { - args.push(namedArg('name', `"${escapePslString(rel.relationName)}"`)); - } args.push( namedArg( 'from', @@ -678,8 +675,8 @@ function buildRelationField( if (rel.fkName) { args.push(namedArg('map', `"${escapePslString(rel.fkName)}"`)); } - } else if (rel.relationName) { - args.push(namedArg('name', `"${escapePslString(rel.relationName)}"`)); + } else if (rel.inverseOf) { + args.push(namedArg('inverse', rel.inverseOf)); } const attrs: PslFieldAttribute[] = diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts index 98c7f5bcbd..95b172648d 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts @@ -2,6 +2,44 @@ import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; import { printPslFromFlat as printPslFromSql } from '../fixtures'; +const MULTI_FK_BETWEEN_SAME_MODELS: SqlSchemaIR = { + tables: { + user: { + name: 'user', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + message: { + name: 'message', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + sender_id: { name: 'sender_id', nativeType: 'int4', nullable: false }, + recipient_id: { name: 'recipient_id', nativeType: 'int4', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [ + { + name: 'message_sender_fk', + columns: ['sender_id'], + referencedTable: 'user', + referencedColumns: ['id'], + }, + { + name: 'message_recipient_fk', + columns: ['recipient_id'], + referencedTable: 'user', + referencedColumns: ['id'], + }, + ], + uniques: [], + indexes: [], + }, + }, +}; + describe('printPsl', () => { it('schema with 1:N relation', () => { const schemaIR: SqlSchemaIR = { @@ -229,8 +267,8 @@ describe('printPsl', () => { id Int @id name String managerId Int? @map("manager_id") - manager Employee? @relation(name: "ManagerEmployees", from: [managerId], to: [id]) - employees Employee[] @relation(name: "ManagerEmployees") + manager Employee? @relation(from: [managerId], to: [id]) + employees Employee[] @relation(inverse: manager) @@map("employee") } @@ -293,8 +331,8 @@ describe('printPsl', () => { model User { id Int @id - messages Message[] @relation(name: "message_sender_fk") - messagesMessage Message[] @relation(name: "message_recipient_fk") + messages Message[] @relation(inverse: sender) + messagesMessage Message[] @relation(inverse: recipient) @@map("user") } @@ -303,8 +341,8 @@ describe('printPsl', () => { id Int @id senderId Int @map("sender_id") recipientId Int @map("recipient_id") - sender User @relation(name: "message_sender_fk", from: [senderId], to: [id], map: "message_sender_fk") - recipient User @relation(name: "message_recipient_fk", from: [recipientId], to: [id], map: "message_recipient_fk") + sender User @relation(from: [senderId], to: [id], map: "message_sender_fk") + recipient User @relation(from: [recipientId], to: [id], map: "message_recipient_fk") @@map("message") } @@ -551,3 +589,19 @@ describe('printPsl', () => { expect(betaIndex).toBeLessThan(alphaIndex); }); }); + +describe('printer retires @relation(name:)', () => { + it('emits no @relation(name:) and points the back side with inverse:', () => { + const result = printPslFromSql(MULTI_FK_BETWEEN_SAME_MODELS); + + expect(result).not.toContain('@relation(name:'); + expect(result).toContain('inverse:'); + }); + + it('points each back-relation at the FK-side field it pairs with', () => { + const result = printPslFromSql(MULTI_FK_BETWEEN_SAME_MODELS); + + expect(result).toContain('@relation(inverse: sender)'); + expect(result).toContain('@relation(inverse: recipient)'); + }); +}); diff --git a/projects/psl-relation-syntax/slices/03-pointer-disambiguation/plan.md b/projects/psl-relation-syntax/slices/03-pointer-disambiguation/plan.md new file mode 100644 index 0000000000..d0973eae78 --- /dev/null +++ b/projects/psl-relation-syntax/slices/03-pointer-disambiguation/plan.md @@ -0,0 +1,55 @@ +# Slice 3 — pointer disambiguation — Dispatch plan + +**Slice spec:** `projects/psl-relation-syntax/slices/03-pointer-disambiguation/spec.md` +**Linear:** [TML-2942](https://linear.app/prisma-company/issue/TML-2942) + +Five dispatches. M1 (grammar) gates M2. M3 (inverse:) is independent of M1. M4/M5 follow. + +## M1 — Member-access value grammar (psl-parser) + +- **Outcome:** an `@relation` argument value may be a qualified `Identifier.Identifier` (`through: Junction.field`), with both segments preserved through parse → resolve. +- **Builds on:** none (parser-internal). +- **Hands to:** the qualified-value grammar S3·M2 (and S5's arrow-path + the deferred `to:` qualifier) consume. +- **Focus:** `psl-parser/src/parse.ts` — extend the argument-value path (`parseArgValue` / `parseExpression` / `parseIdentifierExpr`) to parse a member-access value (reuse `Dot` handling from `parseQualifiedName`); expose both segments on the resolved arg. Keep bare identifiers and bracketed lists working unchanged. +- **Completed when:** `pnpm --filter @prisma-next/psl-parser test` green with tests parsing `Foo.bar` as a qualified value (and bare/bracketed still parse); `pnpm --filter @prisma-next/psl-parser typecheck` + `lint` clean. +- **Halt:** if extending the value grammar regresses existing arg parsing in a way that can't be cleanly scoped to member-access values → surface. + +## M2 — `through: Junction.relationField` M:N disambiguation (resolver) + +- **Outcome:** a self-referential / multiple-between-same-models M:N (today `PSL_AMBIGUOUS_BACKRELATION_LIST`) resolves when both ends pin their junction FK leg via `through: J.field`. +- **Builds on:** M1's qualified value + slice 2's named-junction recognition. +- **Hands to:** disambiguated M:N lowering. +- **Focus:** `contract-psl` — `ParsedRelationAttribute.through` carries the optional `field` segment; in `findJunctionFkPairs`, pin the parent-side FK to the junction relation field named; actionable diagnostic when `field` isn't a junction FK back to the candidate. +- **Completed when:** `pnpm --filter @prisma-next/sql-contract-psl test` green with a lowering test on a self-referential M:N (ambiguous without the qualifier) + the bad-`field` diagnostic; typecheck + lint clean. +- **Halt:** — + +## M3 — `inverse:` 1:N back-relation disambiguation (resolver) + +- **Outcome:** a 1:N back-relation with multiple candidates (today needs `name:`) resolves via `inverse: `. +- **Builds on:** slice 1's allow-list (add `inverse`); independent of M1 (bare field name). +- **Hands to:** the directional replacement for `name:` on the 1:N back side. +- **Focus:** `contract-psl` — add `inverse` to the allow-list; in the back-relation pairing, pin the owning FK field; actionable diagnostic when `inverse:` names a non-FK-side field. +- **Completed when:** `pnpm --filter @prisma-next/sql-contract-psl test` green with a lowering test on a two-relations-between-same-models 1:N + the bad-field diagnostic; typecheck + lint clean. +- **Halt:** — + +## M4 — Printer retires `name:` (output) + grep gate + +- **Outcome:** the `contract infer` printer / canonical output emits pointer forms, never `@relation(name:)`; legacy `name:` still parses + survives `format`. +- **Builds on:** M2 (`through: J.field`) + M3 (`inverse:`) — the pointer forms it emits. +- **Hands to:** single-dialect disambiguation output (the `name:`-retirement DoD). +- **Focus:** `sql-schema-ir-to-psl-ast.ts` `buildRelationField` — replace `namedArg('name', …)` emission with the pointer form (`inverse:` on a disambiguated 1:N back side; `through: J.field` where applicable); grep gate over printer output asserting no `name:`. Update inferred-PSL test expectations. Do **not** touch the formatter's legacy `name:` handling (deferred per decision #4). +- **Completed when:** `pnpm --filter @prisma-next/family-sql test` green with updated expectations + the no-`name:` grep gate; a test that a legacy `name:` schema still parses and `format` leaves it unchanged; `pnpm fixtures:check` clean (rebuild dist first). +- **Halt:** if a `contract.json` shape changes (only PSL spelling should) → surface (D1). + +## M5 — Integration parity + +- **Outcome:** a self-referential M:N (`through: J.field`) and a disambiguated 1:N (`inverse:`) drive the ORM. +- **Builds on:** M2 + M3 (the lowering) — the runtime is unchanged. +- **Hands to:** the slice's runtime DoD. +- **Focus:** a PSL fixture with a self-ref M:N (e.g. `Follow` junction) + a two-relations-1:N disambiguated by `inverse:`; emit; integration test (`include`, whole-row, ≥1 implicit; PGlite per the harness). `fixtures:check`. +- **Completed when:** the integration test passes; `pnpm fixtures:check` clean. +- **Halt:** if the disambiguated contract doesn't drive the runtime → surface (lowering wrong, not runtime). + +## Hand-off completeness + +M1→M2 (M:N pointer), M3 (1:N pointer), M4 (printer retires `name:`), M5 (runtime) compose to the slice-DoD: disambiguation by pointing for both cardinalities, `name:` gone from output, runtime parity. The formatter's legacy-`name:` conversion is the one explicit deferral (decision #4). diff --git a/projects/psl-relation-syntax/slices/03-pointer-disambiguation/spec.md b/projects/psl-relation-syntax/slices/03-pointer-disambiguation/spec.md new file mode 100644 index 0000000000..2d5954979e --- /dev/null +++ b/projects/psl-relation-syntax/slices/03-pointer-disambiguation/spec.md @@ -0,0 +1,49 @@ +# Slice 3: pointer disambiguation — retire `@relation(name:)` + +_Parent project: `projects/psl-relation-syntax/`. Linear: [TML-2942](https://linear.app/prisma-company/issue/TML-2942). Builds on slices 1–2. Design: `design-notes.md` decisions **D2**, **D4** (ambiguous case)._ + +## At a glance + +Disambiguate relations by **pointing at the relation field**, not by a free-floating string: + +- **Ambiguous M:N** (self-relation, or multiple M:N between the same pair of models): both ends declare `through: Junction.relationField` — the dotted form pins the junction FK leg. +- **1:N back-relation** with multiple candidates: `posts Post[] @relation(inverse: editor)` points at the owning FK field. +- **`@relation(name:)` is retired from canonical output** — the `contract infer` printer and canonical authoring emit the pointer forms; `name:` stays accepted as **legacy input** and is left untouched by `format` (per operator decision; auto-conversion is a deferred follow-up). + +This is the largest core slice; its breadth is unified by one theme — _disambiguation by pointing_. + +## Chosen design + +1. **Member-access value grammar (psl-parser).** The PSL expression grammar carries only the head identifier of a dotted value (`Foo.bar` → `Foo`). Extend the argument-value grammar to parse a qualified `Identifier.Identifier` (and preserve both segments) so `through: Junction.relationField` round-trips. This unblocks S5's arrow-path and the deferred `to:` qualifier too. Foundation dispatch. +2. **`through: Junction.relationField` (resolver).** `ParsedRelationAttribute.through` becomes a `{ junction: string; field?: string }` (or equivalent) — bare model name from slice 2, plus the optional relation-field segment. In `findJunctionFkPairs`, when the candidate carries `through.field`, pin the **parent-side FK** to the junction relation field named (the junction FK whose declaring field is `field`), resolving the self-relation / multiple-M:N ambiguity that slice 2 deferred (it currently falls into `PSL_AMBIGUOUS_BACKRELATION_LIST`). +3. **`inverse:` (resolver).** Add `inverse` to the `@relation` allow-list; a bare relation-field name (no grammar dependency). In the 1:N back-relation pairing (where multiple FK-side relations between the same pair of models force the `PSL_AMBIGUOUS_*` / name-based path today), `inverse: ` pins the owning FK field. This is the directional replacement for `name:` on the back side. +4. **Retire `name:` from canonical output (printer).** `sql-schema-ir-to-psl-ast.ts` `buildRelationField` emits `name:` today for `relationName` disambiguation. Change it to emit the pointer form — `inverse:` on the back side of a disambiguated 1:N (and `through: J.field` where it infers an M:N needing it, if applicable). Wire a grep gate: no `@relation(name:)` in printer/canonical output. `name:` remains a parsed legacy input; the formatter (slice 1) leaves it untouched. +5. **Integration parity.** A self-referential M:N (e.g. `User.following`/`User.followers` via a `Follow` junction) authored with `through: Follow.follower`/`through: Follow.followee`, and a 1:N with two relations between the same models disambiguated with `inverse:`, exercised through the ORM per the project integration standard. + +## Scope + +**In:** member-access value grammar; `through: J.field` M:N disambiguation; `inverse:` 1:N disambiguation; printer emits pointer forms (retire `name:` from output) + grep gate; round-trip `validateContract`; integration parity (self-rel M:N + disambiguated 1:N). + +**Out:** auto-converting a legacy `name:` schema to pointer form in `format` (deferred follow-up — operator decision #4); implicit synthesis (S4); arrow-path (S5); the `to: Model.col` qualifier (S5, now unblocked by this slice's grammar); the M:N runtime (sibling). + +## Pre-investigated edge cases + +| Edge case | Disposition | +|---|---| +| Self-referential M:N (two FKs from the junction to the same model) | the core `through: J.field` case — both ends pin their leg; this is what slice 2 deferred | +| `through: J.field` where `field` isn't a junction FK back to the candidate | actionable diagnostic (not silent) | +| `inverse:` naming a field that isn't an FK-side relation to the candidate | actionable diagnostic | +| legacy `@relation(name:)` schema | still parses; lowers as today; survives `format` unchanged (not auto-converted) | +| a relation needing no disambiguation that gratuitously specifies `inverse:`/`through: J.field` | accept (explicit-but-redundant), or a low-key diagnostic — decide at dispatch, lean accept | + +## Slice-specific done conditions + +- [ ] `through: Junction.relationField` parses (member-access grammar) and disambiguates a self-referential / multiple-between-same-models M:N — proven by a lowering test on a shape that is `PSL_AMBIGUOUS_BACKRELATION_LIST` without the qualifier. +- [ ] `inverse: ` disambiguates a 1:N back-relation with multiple candidates — lowering test on a shape that needs `name:` today. +- [ ] The `contract infer` printer / canonical output emits **no** `@relation(name:)` — grep gate; legacy `name:` still parses and survives `format`. +- [ ] Self-referential M:N (`through: J.field`) + disambiguated 1:N (`inverse:`) drive the ORM — integration (PGlite, project standard). + +## References + +- Project: `spec.md`, `design-notes.md` (D2, D4). Operator decisions in `wip/unattended-decisions.md` (#4 name-retirement, #5 keep-4/5). +- Surfaces: `psl-parser/src/parse.ts` (`parseArgValue`/`parseExpression`/`parseIdentifierExpr`; `parseQualifiedName` for Dot handling); `contract-psl/src/psl-relation-resolution.ts` (`findJunctionFkPairs`, the backrelation pairing + `PSL_AMBIGUOUS_*`), `interpreter.ts`; `sql-schema-ir-to-psl-ast.ts` (`buildRelationField` `name:` emission). diff --git a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md index c55c04b7d8..495abae719 100644 --- a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md +++ b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md @@ -89,6 +89,25 @@ changes: contains: - "@relation" anyMatch: true + - id: relation-name-pointer-disambiguation + summary: | + `@relation(name:)` and the positional relation name (`@relation("...")`) are + rejected at contract emission with PSL_LEGACY_RELATION_NAME. Replace name-based + relation pairing with pointer disambiguation: on a 1:N back-relation list field, + name the owning FK-side relation field with `inverse:` (e.g. `posts Post[] + @relation(inverse: author)`); on a many-to-many list field, name the junction + with `through:` (e.g. `tags Tag[] @relation(through: PostTag)`) and, for + self-relations or multiple many-to-many between the same pair of models, pin the + parent-side junction relation field with the qualified form + (`through: Follow.follower`). Relations that are unambiguous without a name + simply drop the argument. This applies to extension fixture schemas and example + PSL alike; `contract infer` no longer prints `@relation(name:)` — the back side + is printed with `inverse:` — so tests asserting inferred PSL move with it. + detection: + glob: "**/*.prisma" + contains: + - "@relation(" + anyMatch: true ---