-
Notifications
You must be signed in to change notification settings - Fork 12
TML-2941: through: explicit many-to-many junction authoring in PSL #873
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: tml-2940-s1-from-to-fk-foundation
Are you sure you want to change the base?
Changes from all commits
2cd34d8
14181cb
395d336
80cf2ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,13 +27,13 @@ import type { | |
| FieldAttributeAst, | ||
| SourceFile, | ||
| } from '@prisma-next/psl-parser/syntax'; | ||
| import { ArrayLiteralAst } from '@prisma-next/psl-parser/syntax'; | ||
| import { ArrayLiteralAst, IdentifierAst } 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'; | ||
| import { ifDefined } from '@prisma-next/utils/defined'; | ||
| import type { Result } from '@prisma-next/utils/result'; | ||
| import { ok } from '@prisma-next/utils/result'; | ||
| import { notOk, ok } from '@prisma-next/utils/result'; | ||
|
|
||
| import { | ||
| getAttribute, | ||
|
|
@@ -77,6 +77,12 @@ export type ModelBackrelationCandidate = { | |
| 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. | ||
| */ | ||
| readonly through?: string; | ||
| }; | ||
|
|
||
| type ModelRelationMetadata = RelationNode; | ||
|
|
@@ -117,6 +123,36 @@ function fieldRefOrList(scope: FieldRefScope): ArgType<readonly string[]> { | |
| }; | ||
| } | ||
|
|
||
| /** | ||
| * 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. | ||
| */ | ||
| function modelName(): ArgType<string> { | ||
| return { | ||
| kind: 'modelName', | ||
| label: 'model name', | ||
| parse: (arg, ctx): Result<string, readonly PslDiagnostic[]> => { | ||
| if (arg instanceof IdentifierAst) { | ||
| const name = arg.name(); | ||
| if (name !== undefined) { | ||
| return ok(name); | ||
| } | ||
| } | ||
| return notOk([ | ||
| { | ||
| code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', | ||
| message: 'Expected a model name', | ||
| sourceId: ctx.sourceId, | ||
| span: nodePslSpan(arg.syntax, ctx.sourceFile), | ||
| }, | ||
| ]); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function relationInvariants( | ||
| parsed: { | ||
| readonly from?: readonly string[]; | ||
|
|
@@ -153,6 +189,7 @@ const sqlRelation = fieldAttribute('relation', { | |
| name: optional(str()), | ||
| from: optional(fieldRefOrList('self')), | ||
| to: optional(fieldRefOrList('referenced')), | ||
| through: optional(modelName()), | ||
| map: optional(str()), | ||
| onDelete: optional( | ||
| oneOf( | ||
|
|
@@ -194,6 +231,14 @@ export type ParsedSqlRelation = { | |
| * two never co-occur. | ||
| */ | ||
| 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. | ||
| */ | ||
| readonly through?: string; | ||
| readonly map?: string; | ||
| readonly onDelete?: SqlRelationOutput['onDelete']; | ||
| readonly onUpdate?: SqlRelationOutput['onUpdate']; | ||
|
|
@@ -308,6 +353,7 @@ export function interpretRelationAttribute(input: { | |
| ...ifDefined('fields', fields), | ||
| ...ifDefined('references', references), | ||
| ...ifDefined('referencesInferred', referencesInferred), | ||
| ...ifDefined('through', value.through), | ||
| ...ifDefined('map', value.map), | ||
| ...ifDefined('onDelete', value.onDelete), | ||
| ...ifDefined('onUpdate', value.onUpdate), | ||
|
|
@@ -491,6 +537,12 @@ function findJunctionFkPairs(input: { | |
| 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) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Comment on lines
+540
to
+543
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [bug] Scoping recognition to the named junction is correct, but the near-miss is only recorded when the named model links both sides (the recording lives inside the The slice spec's edge-case table ( |
||
| continue; | ||
| } | ||
| const idColumns = input.modelIdColumns.get(junctionModelName); | ||
| for (const parentFk of junctionFks) { | ||
| if (parentFk.targetModelName !== input.candidate.modelName) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[question] When this candidate also matches a 1:N FK pair (
applyBackrelationCandidatestakes thematches.length === 1path atpsl-relation-resolution.ts:675),throughis carried on the candidate but never consulted — the relation lowers ascardinality: '1:N'and thethrough:declaration is silently dropped. I verified:comments Comment[] @relation(through: NoSuchJunction)on aPostwhoseCommenthas an FK back resolves to a 1:N with no diagnostic.through:is M:N-only per the spec, so a co-occurrence with a 1:N FK match is a user mistake (likely a typo'd junction name); silently honouring the 1:N over the explicitthrough:masks it. Worth at least a diagnostic whenthroughis set on a candidate that resolves via the 1:N FK-pair path, or documenting thatthrough:is ignored once a 1:N match exists.