Skip to content

TML-2944: arrow-path through: for relation-field-less junctions#876

Open
tensordreams wants to merge 3 commits into
tml-2943-s4-implicit-mn-synthesisfrom
tml-2944-s5-arrow-path-through
Open

TML-2944: arrow-path through: for relation-field-less junctions#876
tensordreams wants to merge 3 commits into
tml-2943-s4-implicit-mn-synthesisfrom
tml-2944-s5-arrow-path-through

Conversation

@tensordreams

@tensordreams tensordreams commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Linked issue

Refs TML-2944. Fifth and final of the PSL: Directional Relation Syntax stack — stacked on tml-2943-s4-implicit-mn-synthesis (TML-2943). Spec under projects/psl-relation-syntax/slices/05-arrow-path-through/.

At a glance

model Post { id Uuid @id; tags Tag[] @relation(through: "id -> PostTag.postId -> PostTag.tagId -> Tag.id") }
model Tag  { id Uuid @id; posts Post[] @relation(through: "id -> PostTag.tagId -> PostTag.postId -> Post.id") }
model PostTag { postId Uuid; tagId Uuid; @@id([postId, tagId]) }   // scalar columns only — no relation fields

The arrow-path declares the many-to-many on the terminal models, naming the join columns directly — so the junction needs no relation fields of its own.

Summary

An escape hatch for declaring a many-to-many over a junction that carries scalar columns + @@id but no relation fields: the through: arrow-path names the column hops (localKey -> Junction.nearCol -> Junction.farCol -> targetKey). It lowers to the same N:M + through descriptor as the relation-field form — by construction.

Decision

Recognise an arrow-path through: value and build the through descriptor straight from the named columns, bypassing the relation-field-based junction recognition (which can't fire when the junction has no relation fields). The arrow-path is a quoted string value — see Reviewer notes for why the unquoted operator form was not taken here.

How it fits together

  1. Parse: a quoted through: value containing -> is detected and split into an ArrowPath ({ localKey, near/far junction model+column, target model+key }). No grammar change — the parser already emits a string literal.
  2. Recognise (packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts): a dedicated branch validates the columns against the resolved models and builds the N:M node directly — parent walks localKey → near col, child walks far col → targetKey — reusing the same node machinery as the relation-field through:. The junction must be a declared model (so the contract-builder's through.table requirement holds).
  3. Prove: an arrow-path fixture over a relation-field-less junction is emitted and exercised through the ORM.

Behavior changes & evidence

  • An arrow-path over a relation-field-less junction lowers to N:M + through — identically to the relation-field form. contract-psl/src/psl-relation-resolution.ts — evidence: packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.arrow-path.test.ts (a test asserts toEqual on the Contract against the relation-field through: form — the equivalence holds because both paths return the same RelationNode shape into the same assembler).
  • Four actionable diagnostics (PSL_ARROW_PATH_MALFORMED / _COLUMN_NOT_FOUND / _JUNCTION_MISMATCH / _JUNCTION_NOT_MODEL), each tested.
  • The arrow-path M:N drives the ORM include. evidence: test/integration/test/sql-orm-client/fixtures/mn-psl-arrow-path/contract.prisma + …/mn-psl-arrow-path-parity.test.ts (PGlite, @map'd storage columns exercised end-to-end).

Notes for the reviewer

  • Value form is a quoted string, not the straw-man's unquoted a -> J.b. The unquoted form is a real grammar change (a new tokenizer token, a chained-arrow expression node, AST/union/rendering, formatter tests) — disproportionate for the lowest-priority escape-hatch slice. The quoted form is zero-grammar and functionally identical. The unquoted-operator polish is an open ratification — happy to file it as its own grammar PR if preferred (recorded in the project's decisions log).
  • The column-based branch deliberately bypasses the relation-field junction search; the equivalence test is the proof it lands the same contract.
  • check:upgrade-coverage applies (touches packages/3-extensions/sql-orm-client) — see Skill update.

Testing performed

  • pnpm --filter @prisma-next/sql-contract-psl test; the arrow-path parity test on PGlite. Validated after the rebase: pnpm typecheck:packages (129/129), pnpm --filter -next/sql-contract-psl test (23 files, 314 tests), pnpm fixtures:check (zero drift), and pnpm lint:deps (0 violations).

Skill update

n/a for behaviour beyond the additive arrow-path syntax. Touches packages/3-extensions/sql-orm-client, so check:upgrade-coverage expects a changes: [] declaration in the in-flight upgrade cycle — flagged as a stack-wide follow-up.

Checklist

  • All commits signed off (DCO).
  • Read CONTRIBUTING.md; scoped to one logical concern (arrow-path 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: 26f1f398-6627-45a5-b2b4-e88d248765ec

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-2944-s5-arrow-path-through

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@876

@prisma-next/mongo-runtime

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

@prisma-next/family-mongo

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

@prisma-next/sql-runtime

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

@prisma-next/family-sql

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

@prisma-next/extension-arktype-json

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

@prisma-next/middleware-cache

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

@prisma-next/mongo

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

@prisma-next/extension-paradedb

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

@prisma-next/extension-pgvector

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

@prisma-next/extension-postgis

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

@prisma-next/postgres

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

@prisma-next/sql-orm-client

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

@prisma-next/sqlite

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

@prisma-next/extension-supabase

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

@prisma-next/target-mongo

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

@prisma-next/adapter-mongo

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

@prisma-next/driver-mongo

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

@prisma-next/contract

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

@prisma-next/utils

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

@prisma-next/config

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

@prisma-next/errors

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

@prisma-next/framework-components

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

@prisma-next/operations

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

@prisma-next/ts-render

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

@prisma-next/contract-authoring

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

@prisma-next/ids

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

@prisma-next/psl-parser

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

@prisma-next/psl-printer

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

@prisma-next/cli

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

@prisma-next/cli-telemetry

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

@prisma-next/config-loader

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

@prisma-next/emitter

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

@prisma-next/language-server

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

@prisma-next/migration-tools

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

prisma-next

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

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

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

@prisma-next/mongo-codec

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

@prisma-next/mongo-contract

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

@prisma-next/mongo-value

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

@prisma-next/mongo-contract-psl

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

@prisma-next/mongo-contract-ts

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

@prisma-next/mongo-emitter

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

@prisma-next/mongo-schema-ir

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

@prisma-next/mongo-query-ast

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

@prisma-next/mongo-orm

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

@prisma-next/mongo-query-builder

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

@prisma-next/mongo-lowering

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

@prisma-next/mongo-wire

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

@prisma-next/sql-contract

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

@prisma-next/sql-errors

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

@prisma-next/sql-operations

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

@prisma-next/sql-schema-ir

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

@prisma-next/sql-contract-psl

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

@prisma-next/sql-contract-ts

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

@prisma-next/sql-contract-emitter

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

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

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

@prisma-next/sql-relational-core

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

@prisma-next/sql-builder

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

@prisma-next/target-postgres

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

@prisma-next/target-sqlite

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

@prisma-next/adapter-postgres

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

@prisma-next/adapter-sqlite

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

@prisma-next/driver-postgres

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

@prisma-next/driver-sqlite

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

commit: 1f38e8f

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
postgres / no-emit 160.36 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.58 KB (0%)

@tensordreams tensordreams force-pushed the tml-2943-s4-implicit-mn-synthesis branch from 02a463c to 1c404f0 Compare July 1, 2026 10:56
@tensordreams tensordreams force-pushed the tml-2944-s5-arrow-path-through branch from 454be09 to aae6a61 Compare July 1, 2026 10:56
diagnostics: input.diagnostics,
sourceId: input.sourceId,
});
const targetColumn = resolveArrowColumn({

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.

targetColumn is only checked for existence; the relation node below never uses it, and the assembler fills through.targetColumns from the target model @id. So through: "id -> PostTag.postId -> PostTag.tagSlug -> Tag.slug" succeeds as long as Tag.slug exists, but the generated relation still joins the far column to Tag.id. Please either require arrowPath.targetKey to be the target @id column(s), or thread the resolved target column into the through descriptor so the authored path is honored.

@tensordreams tensordreams force-pushed the tml-2944-s5-arrow-path-through branch from aae6a61 to 471261d Compare July 1, 2026 14:35
@tensordreams tensordreams force-pushed the tml-2943-s4-implicit-mn-synthesis branch from 1c404f0 to 01db649 Compare July 1, 2026 14:35
Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Recognise the arrow-path many-to-many form
`through: "localKey -> Junction.nearCol -> Junction.farCol -> Target.targetKey"`
on terminal models over a junction that carries scalar columns + @@id but no
relation fields. The relation-field-based junction recognition cannot fire when
the junction declares no relation fields, so the resolver builds the `through`
descriptor straight from the path-named columns.

Value form: a quoted string (no grammar change). The parser already produces a
StringLiteralExpr; the resolver detects the `->` separator, strips quotes, and
splits the four segments. An unquoted arrow grammar would require a new
tokenizer token plus an expression node, AST class, union/cast extension,
printSyntax rendering, and formatter spacing rules — out of scope for this
escape-hatch slice.

Each end carries its own mirror-imaged path; both ends lower to N:M + through.
Diagnostics: malformed path (not four segments / bad arrows), a named column
absent on its model, the two junction columns on different models, and a
junction that is not a declared model.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Add a real emitted PSL fixture (mn-psl-arrow-path) authoring a Post <-> Tag
many-to-many over a relation-field-less PostTag junction via the quoted
arrow-path through: form, e.g.

  tags Tag[] @relation(through: "id -> PostTag.postId -> PostTag.tagId -> Tag.id")

with the mirror-imaged path on Tag.posts. The resolver builds the through
descriptor straight from the named columns and lowers both ends to
cardinality: 'N:M' + through over post_tags -- the same runtime-consumable
shape the authored-junction (mn-psl-through) and implicit (mn-psl-implicit)
forms emit.

Wire the fixture into the sql-orm-client emit chain so fixtures:check regen
covers it, and add a PGlite integration parity test that drives
db.orm.Post.include('tags') over the arrow-path M:N -- whole-row toEqual,
explicit .select() plus one implicit-selection readback -- proving the
column-based arrow-path contract drives the ORM include end-to-end.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
@tensordreams tensordreams force-pushed the tml-2943-s4-implicit-mn-synthesis branch from 01db649 to 8affc21 Compare July 1, 2026 14:40
@tensordreams tensordreams force-pushed the tml-2944-s5-arrow-path-through branch from 471261d to 1f38e8f 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.

Slice 5 lands the arrow-path through: cleanly: the four-segment quoted value parses and lowers to an N:M through descriptor that is structurally consistent with slice-2's relation-field junction form (the lower-to-same-contract parity test plus the PGlite include integration test prove the ORM drives it end-to-end), and it early-exits the backrelation loop so it complements rather than collides with slice-4's implicit synthesis. Two substantive correctness gaps remain: the path's targetKey (4th segment) is resolved for existence but never threaded into the relation node — through.targetColumns is always the target model's PK downstream (build-contract.ts:200) — so naming a non-PK target column silently lowers to the PK regardless of what the author declared; and localKey/targetKey are existence-checked but not validated to be key/unique columns as the spec ("segment 1 = a local @id/key column", "segment 4 = a key column") requires, so a non-key local side would silently build a non-unique parent join where slice-2's FK-referenced columns inherently guarantee one. The parseArrowPath jsdoc also mis-describes the last segment as a bare key, and two diagnostic branches (PSL_ARROW_PATH_TARGET_MISMATCH and the same-column PSL_ARROW_PATH_JUNCTION_MISMATCH) ship without regression tests.

return undefined;
}

return arrowPathManyToManyRelationNode({

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] targetColumn (the path's 4th segment) is resolved via resolveArrowColumn for existence but is never threaded into arrowPathManyToManyRelationNode — its arg list carries localColumns/nearColumn/farColumn only. through.targetColumns is then filled downstream by buildThroughDescriptortargetColumnsForJunction(targetModel, …) (build-contract.ts:200), which always returns the target model's PK. So the 4th path segment is existence-validated but otherwise discarded: through: "... -> PostTag.tagId -> Tag.label" would silently lower to through.targetColumns: ['id'] (the PK), joining tag_id against Tag.id rather than the named label. The path reads as though the author controls the target key; they don't. Either thread targetColumn into the node so the contract honours the named target key, or explicitly require targetKey to be the target's PK and validate it (arrowPath.targetKeytargetModel.id.columns) with an actionable diagnostic — otherwise a typo on the 4th segment is silent.

return undefined;
}

const localColumn = resolveArrowColumn({

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] localKey (on.parentColumns via localColumns: [localColumn]) is resolved as any existing column on the declaring model, not a key/unique column. The spec calls for "segment 1 = a local @id/key column on the declaring model". Slice-2's relation-field path inherently uses pair.parentFk.referencedColumns (FK-referenced ⇒ guaranteed unique/PK), so the M:N parent side is always a key; the arrow-path has no such guarantee, so a non-unique localKey would silently build a non-unique parent join, letting the runtime include fan out/duplicate parent rows. The same gap applies to targetKey (already partly covered above). Consider validating localKey/targetKey are a declared @id/@@unique column (or, for the target, the PK the assembler will use), matching what slice-2 produces structurally.

Comment on lines +161 to +163
* its named parts. The middle two segments are `Model.column`; the first and
* last are bare keys (a leading `Model.` qualifier on them is tolerated and
* dropped, like `from:`/`to:`). Returns undefined on the wrong segment count or

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.

[doc] The jsdoc says "the first and last are bare keys (a leading Model. qualifier on them is tolerated and dropped, like from:/to:)", but the last segment is parsed by splitQualifiedColumn(targetSegment) (line 184), which requires Model.column and rejects a bare key — and its model is then validated against candidate.targetModelName at line 992. So the last segment is neither bare nor merely tolerated-and-dropped. Worth correcting to match the implementation (and the test form Tag.id): middle two and last are Model.column; only the first is a bare key with an optional tolerated qualifier. As a related asymmetry, the local segment's qualifier is silently dropped without checking it names the declaring model, whereas the target qualifier is checked — worth noting or aligning.

expect(diagnostic?.message).toContain('Other');
});

it('diagnoses an arrow path whose junction is not a declared 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 resolver has six diagnostic branches but only four are exercised here. Two are untested:

  • PSL_ARROW_PATH_TARGET_MISMATCH (psl-relation-resolution.ts:992) — the path's target model ≠ the list field's element type, e.g. tags Tag[] @relation(through: "id -> PostTag.postId -> PostTag.tagId -> Wrong.id") with a Tag-typed list field. This is a valuable typo-catcher and worth a regression test.
  • The same-column PSL_ARROW_PATH_JUNCTION_MISMATCH (psl-relation-resolution.ts:1054) — nearColumn === farColumn, e.g. "... -> PostTag.postId -> PostTag.postId -> ...".

Add a sibling it(...) for each so the diagnostic text/codes stay pinned.

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