Skip to content

TML-2941: through: explicit many-to-many junction authoring in PSL#873

Open
tensordreams wants to merge 4 commits into
tml-2940-s1-from-to-fk-foundationfrom
tml-2941-s2-through-explicit-mn
Open

TML-2941: through: explicit many-to-many junction authoring in PSL#873
tensordreams wants to merge 4 commits into
tml-2940-s1-from-to-fk-foundationfrom
tml-2941-s2-through-explicit-mn

Conversation

@tensordreams

@tensordreams tensordreams commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Linked issue

Refs TML-2941. Second of the PSL: Directional Relation Syntax stack — stacked on tml-2940-s1-from-to-fk-foundation (TML-2940); review/merge that first. Spec under projects/psl-relation-syntax/slices/02-through-explicit-mn/.

At a glance

model User { tags Tag[] @relation(through: UserTag) }   // declared on one end
model Tag  { users User[] }                              // inverse inferred
model UserTag { userId Uuid; tagId Uuid; @@id([userId, tagId])
  user User @relation(from: userId); tag Tag @relation(from: tagId) }

through: UserTag recognises the many-to-many via the named junction and lowers it to the same N:M + through contract the runtime already consumes — byte-identical to the bare-list form.

Summary

A navigable list field may name its junction explicitly with @relation(through: Junction), rather than relying on convention-based discovery. Declared on one end; the opposite bare list resolves as the inferred inverse. This is the explicit-authoring path that the disambiguation work (PR 3) extends.

Decision

Recognise an explicit through: Junction on a navigable list and lower it to the existing N:M + through descriptor. The runtime is unchanged — this PR only changes how that descriptor is authored. The bare-list convention recognition (a junction model present, discovered by shape) is preserved, so a one-end-declared M:N resolves on both ends.

How it fits together

  1. Parse: through joins the @relation argument allow-list (next to from/to); the resolver reads a bare model name into ParsedRelationAttribute.through.
  2. Recognise: in findJunctionFkPairs (packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts), a single continue filter narrows the junction search to the named model when through: is set, reusing every existing junction-shaped validation and the manyToManyRelationNode machinery — so the emitted descriptor is byte-identical to the convention path.
  3. Prove: a PSL fixture authored with through: is emitted and exercised through the ORM include.

Behavior changes & evidence

  • through: Junction lowers byte-identically to the bare-list form; a non-junction model gets an actionable diagnostic. packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts — evidence: packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.through.test.ts (toEqual on the emitted Contract).
  • A PSL-through:-authored M:N drives db.orm.<Model>.include(...). evidence: test/integration/test/sql-orm-client/fixtures/mn-psl-through/contract.prisma + test/integration/test/sql-orm-client/mn-psl-through-parity.test.ts (PGlite, whole-row toEqual, explicit + implicit select).

Notes for the reviewer

  • The recognition is intentionally a minimal narrowing of the existing junction-pair search — not a parallel code path. The byte-identical equivalence (vs the convention form) is the proof it reuses the same lowering.
  • One disposition recorded as out-of-scope: a through: on a field that also has a direct FK match would be silently ignored — but a navigable list field is structurally excluded from FK-side matches, so this is unreachable by any sensible schema (it's the "never silently ignore an authored junction" guard, deferred). Disambiguation of self-referential / multiple-between-same-models M:N is PR 3.
  • check:upgrade-coverage: this PR touches packages/3-extensions/sql-orm-client (the integration fixture's emit wiring), so the gate will want a changes: [] declaration under the in-flight upgrade cycle — see Skill update.

Testing performed

  • pnpm --filter @prisma-next/sql-contract-psl test; the M:N integration parity suite on PGlite. Validated in the full-stack pass (build, typecheck:packages, fixtures:check, lint:deps, cast delta 0).

Skill update

n/a for behaviour beyond the additive through: authoring surface, runtime unchanged. This PR touches packages/3-extensions/sql-orm-client, so check:upgrade-coverage expects a changes: [] declaration in the in-flight …/upgrades/<cycle>/instructions.md. Flagged as a stack-wide follow-up (the four touching PRs share it); happy to add it on request.

Checklist

  • All commits signed off (DCO).
  • Read CONTRIBUTING.md; scoped to one logical concern (explicit through: recognition).
  • Tests updated.
  • Title in TML-NNNN: … form.
  • Skill update section filled in.

@tensordreams tensordreams requested a review from a team as a code owner June 26, 2026 14:48
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 3a904edf-c4a5-4ce6-9295-3659856cfb11

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tml-2941-s2-through-explicit-mn

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jun 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

@prisma-next/extension-author-tools

npm i https://pkg.pr.new/@prisma-next/extension-author-tools@873

@prisma-next/mongo-runtime

npm i https://pkg.pr.new/@prisma-next/mongo-runtime@873

@prisma-next/family-mongo

npm i https://pkg.pr.new/@prisma-next/family-mongo@873

@prisma-next/sql-runtime

npm i https://pkg.pr.new/@prisma-next/sql-runtime@873

@prisma-next/family-sql

npm i https://pkg.pr.new/@prisma-next/family-sql@873

@prisma-next/extension-arktype-json

npm i https://pkg.pr.new/@prisma-next/extension-arktype-json@873

@prisma-next/middleware-cache

npm i https://pkg.pr.new/@prisma-next/middleware-cache@873

@prisma-next/mongo

npm i https://pkg.pr.new/@prisma-next/mongo@873

@prisma-next/extension-paradedb

npm i https://pkg.pr.new/@prisma-next/extension-paradedb@873

@prisma-next/extension-pgvector

npm i https://pkg.pr.new/@prisma-next/extension-pgvector@873

@prisma-next/extension-postgis

npm i https://pkg.pr.new/@prisma-next/extension-postgis@873

@prisma-next/postgres

npm i https://pkg.pr.new/@prisma-next/postgres@873

@prisma-next/sql-orm-client

npm i https://pkg.pr.new/@prisma-next/sql-orm-client@873

@prisma-next/sqlite

npm i https://pkg.pr.new/@prisma-next/sqlite@873

@prisma-next/extension-supabase

npm i https://pkg.pr.new/@prisma-next/extension-supabase@873

@prisma-next/target-mongo

npm i https://pkg.pr.new/@prisma-next/target-mongo@873

@prisma-next/adapter-mongo

npm i https://pkg.pr.new/@prisma-next/adapter-mongo@873

@prisma-next/driver-mongo

npm i https://pkg.pr.new/@prisma-next/driver-mongo@873

@prisma-next/contract

npm i https://pkg.pr.new/@prisma-next/contract@873

@prisma-next/utils

npm i https://pkg.pr.new/@prisma-next/utils@873

@prisma-next/config

npm i https://pkg.pr.new/@prisma-next/config@873

@prisma-next/errors

npm i https://pkg.pr.new/@prisma-next/errors@873

@prisma-next/framework-components

npm i https://pkg.pr.new/@prisma-next/framework-components@873

@prisma-next/operations

npm i https://pkg.pr.new/@prisma-next/operations@873

@prisma-next/ts-render

npm i https://pkg.pr.new/@prisma-next/ts-render@873

@prisma-next/contract-authoring

npm i https://pkg.pr.new/@prisma-next/contract-authoring@873

@prisma-next/ids

npm i https://pkg.pr.new/@prisma-next/ids@873

@prisma-next/psl-parser

npm i https://pkg.pr.new/@prisma-next/psl-parser@873

@prisma-next/psl-printer

npm i https://pkg.pr.new/@prisma-next/psl-printer@873

@prisma-next/cli

npm i https://pkg.pr.new/@prisma-next/cli@873

@prisma-next/cli-telemetry

npm i https://pkg.pr.new/@prisma-next/cli-telemetry@873

@prisma-next/config-loader

npm i https://pkg.pr.new/@prisma-next/config-loader@873

@prisma-next/emitter

npm i https://pkg.pr.new/@prisma-next/emitter@873

@prisma-next/language-server

npm i https://pkg.pr.new/@prisma-next/language-server@873

@prisma-next/migration-tools

npm i https://pkg.pr.new/@prisma-next/migration-tools@873

prisma-next

npm i https://pkg.pr.new/prisma-next@873

@prisma-next/vite-plugin-contract-emit

npm i https://pkg.pr.new/@prisma-next/vite-plugin-contract-emit@873

@prisma-next/mongo-codec

npm i https://pkg.pr.new/@prisma-next/mongo-codec@873

@prisma-next/mongo-contract

npm i https://pkg.pr.new/@prisma-next/mongo-contract@873

@prisma-next/mongo-value

npm i https://pkg.pr.new/@prisma-next/mongo-value@873

@prisma-next/mongo-contract-psl

npm i https://pkg.pr.new/@prisma-next/mongo-contract-psl@873

@prisma-next/mongo-contract-ts

npm i https://pkg.pr.new/@prisma-next/mongo-contract-ts@873

@prisma-next/mongo-emitter

npm i https://pkg.pr.new/@prisma-next/mongo-emitter@873

@prisma-next/mongo-schema-ir

npm i https://pkg.pr.new/@prisma-next/mongo-schema-ir@873

@prisma-next/mongo-query-ast

npm i https://pkg.pr.new/@prisma-next/mongo-query-ast@873

@prisma-next/mongo-orm

npm i https://pkg.pr.new/@prisma-next/mongo-orm@873

@prisma-next/mongo-query-builder

npm i https://pkg.pr.new/@prisma-next/mongo-query-builder@873

@prisma-next/mongo-lowering

npm i https://pkg.pr.new/@prisma-next/mongo-lowering@873

@prisma-next/mongo-wire

npm i https://pkg.pr.new/@prisma-next/mongo-wire@873

@prisma-next/sql-contract

npm i https://pkg.pr.new/@prisma-next/sql-contract@873

@prisma-next/sql-errors

npm i https://pkg.pr.new/@prisma-next/sql-errors@873

@prisma-next/sql-operations

npm i https://pkg.pr.new/@prisma-next/sql-operations@873

@prisma-next/sql-schema-ir

npm i https://pkg.pr.new/@prisma-next/sql-schema-ir@873

@prisma-next/sql-contract-psl

npm i https://pkg.pr.new/@prisma-next/sql-contract-psl@873

@prisma-next/sql-contract-ts

npm i https://pkg.pr.new/@prisma-next/sql-contract-ts@873

@prisma-next/sql-contract-emitter

npm i https://pkg.pr.new/@prisma-next/sql-contract-emitter@873

@prisma-next/sql-lane-query-builder

npm i https://pkg.pr.new/@prisma-next/sql-lane-query-builder@873

@prisma-next/sql-relational-core

npm i https://pkg.pr.new/@prisma-next/sql-relational-core@873

@prisma-next/sql-builder

npm i https://pkg.pr.new/@prisma-next/sql-builder@873

@prisma-next/target-postgres

npm i https://pkg.pr.new/@prisma-next/target-postgres@873

@prisma-next/target-sqlite

npm i https://pkg.pr.new/@prisma-next/target-sqlite@873

@prisma-next/adapter-postgres

npm i https://pkg.pr.new/@prisma-next/adapter-postgres@873

@prisma-next/adapter-sqlite

npm i https://pkg.pr.new/@prisma-next/adapter-sqlite@873

@prisma-next/driver-postgres

npm i https://pkg.pr.new/@prisma-next/driver-postgres@873

@prisma-next/driver-sqlite

npm i https://pkg.pr.new/@prisma-next/driver-sqlite@873

commit: 2fad31e

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
postgres / no-emit 160.35 KB (0%)
postgres / emit 147.55 KB (0%)
mongo / no-emit 79.86 KB (0%)
mongo / emit 72.68 KB (0%)
cf-worker / no-emit 188.11 KB (0%)
cf-worker / emit 173.57 KB (0%)

@tensordreams tensordreams force-pushed the tml-2941-s2-through-explicit-mn branch from acd5bd6 to a3fea80 Compare July 1, 2026 10:56
// 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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

through: only affects the junction scan here, but findJunctionFkPairs is reached only after pairMatches is empty in applyBackrelationCandidates. That means an explicit list field like tags Tag[] @relation(through: PostTag) is silently lowered as a direct 1:N if Tag also has any FK back to Post, ignoring the authored junction. through: should either short-circuit into the junction path before direct FK matching, or report a conflict when both a direct FK and the named junction are present.

@tensordreams tensordreams force-pushed the tml-2940-s1-from-to-fk-foundation branch from c8e0273 to 94f373d Compare July 1, 2026 14:35
@tensordreams tensordreams force-pushed the tml-2941-s2-through-explicit-mn branch from a3fea80 to f2764a7 Compare July 1, 2026 14:35
Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…e M:N

Parse a bare `through: Junction` on a navigable list field into
ParsedRelationAttribute.through and thread it onto the backrelation
candidate. When present, many-to-many recognition is scoped to the named
junction model: findJunctionFkPairs skips every other junction-shaped
model, reusing idColumnsAreExactlyFkPair and childColumnsInTargetIdOrder
to validate the junction and lowering to the existing N:M + through
descriptor the bare-list convention path emits.

A one-end-declared M:N (through: on Post.tags, bare Tag.posts) resolves
on both ends: the explicit end via the named junction, the bare inverse
via the convention scan. A through: naming a non-junction-shaped model
yields the junction near-miss diagnostic; a self-relation through:
(multiple FK pairs from one junction) defers to the existing ambiguity
diagnostic rather than silently picking.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Add the `mn-psl-through` PSL fixture — a User <-> Tag many-to-many spelled
with the explicit `tags Tag[] @relation(through: UserTag)` keyword on the
User end and a bare `users User[]` inverse on Tag (D4 unambiguous case).
The interpreter recognises the M:N via the named junction and lowers both
ends to `cardinality: 'N:M'` + `through`, producing a contract structurally
identical to the bare-list `mn-psl` fixture.

Wire the fixture into the sql-orm-client emit pipeline and add an
integration parity test (`mn-psl-through-parity.test.ts`) that drives
`db.orm.User.include('tags')` over the emitted contract on the PGlite
harness — explicit-select and implicit-select cases, whole-row toEqual.
This proves the explicit `through:` source drives the ORM include
end-to-end through emit -> runtime, matching how the sibling's M:N tests
are parameterised (PGlite only; this suite has no SQLite path).

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…ct (operator)

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
@tensordreams tensordreams force-pushed the tml-2941-s2-through-explicit-mn branch from f2764a7 to 2fad31e Compare July 1, 2026 14:40
@tensordreams tensordreams force-pushed the tml-2940-s1-from-to-fk-foundation branch from 94f373d to 5bfb3ea Compare July 1, 2026 14:40

@tensordreams tensordreams left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The through: parsing, allow-list extension, and named-junction scoping are clean and the lowering correctly reuses the existing junction-recognition primitives — the equivalence test, both-ends test, two-junction selection, non-junction near-miss, and self-referential ambiguity test are all solid, and the runtime parity fixture is well-structured. However, the spec's edge-case table explicitly lists "through: names a model with no FK to one side" as requiring an actionable diagnostic ("do not silently fall through"), but the implementation only records a near-miss when the named model links both sides; a through: naming a one-sided or non-existent model falls through to the generic PSL_ORPHANED_BACKRELATION_LIST, whose message never mentions through: or the named junction. Separately, a through: on a backrelation list that also matches a 1:N FK pair is silently dropped (no diagnostic). Neither one-sided/non-existent case is covered by tests. Typesafety and the no-bare-casts / no-any rules are respected (the only as casts are in test files, which are exempt).

Comment on lines +505 to +508
// 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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 parentFk/childFk double loop). When through: names a model that has an FK to only one side, or names a model that doesn't exist / declares no FKs, no near-miss is recorded and the candidate falls through to the generic PSL_ORPHANED_BACKRELATION_LIST — whose message ("has no matching FK-side relation ... use an explicit join model") never mentions through: or the named junction. I verified this: through: NoSuchJunction and a one-sided through: PostTag (FK to Post only) both emit PSL_ORPHANED_BACKRELATION_LIST.

The slice spec's edge-case table (projects/psl-relation-syntax/slices/02-through-explicit-mn/spec.md) explicitly lists "through: names a model that isn't junction-shaped (no FK to one side / @@id ≠ the two FKs)" as requiring an actionable diagnostic that reuses the near-miss reasons — "do not silently fall through." The @@id ≠ two FKs sub-case is handled (the existing test covers it); the "no FK to one side" sub-case is not. Consider recording a dedicated near-miss / PSL_THROUGH_JUNCTION_NOT_FOUND diagnostic when candidate.through is set and the named model isn't recognised as a junction for this candidate, so the message can name the through: value and point the user at the missing FK side.

field,
targetModelName: field.typeName,
...ifDefined('relationName', relationName),
...ifDefined('through', through),

Copy link
Copy Markdown
Contributor Author

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 (applyBackrelationCandidates takes the matches.length === 1 path at psl-relation-resolution.ts:675), through is carried on the candidate but never consulted — the relation lowers as cardinality: '1:N' and the through: declaration is silently dropped. I verified: comments Comment[] @relation(through: NoSuchJunction) on a Post whose Comment has 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 explicit through: masks it. Worth at least a diagnostic when through is set on a candidate that resolves via the 1:N FK-pair path, or documenting that through: is ignored once a 1:N match exists.

});
});

it('emits the junction near-miss diagnostic when through: names a non-junction model', () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test] The non-junction near-miss test covers the @@id ≠ two FKs sub-case (model links both sides). Two related shapes are untested and currently regress to the generic PSL_ORPHANED_BACKRELATION_LIST:

  • through: naming a model that links only one side (FK to the candidate's model but not the target, or vice-versa) — the spec lists this as requiring an actionable diagnostic.
  • through: naming a non-existent model.

Adding these would lock in whichever behaviour is decided for the [bug] above (a dedicated diagnostic vs. the generic orphaned message) and prevent a silent regression if the near-miss surface is later broadened.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant