Skip to content

TML-2943: implicit many-to-many via a synthesised model-less junction#875

Open
tensordreams wants to merge 3 commits into
tml-2942-s3-pointer-disambiguationfrom
tml-2943-s4-implicit-mn-synthesis
Open

TML-2943: implicit many-to-many via a synthesised model-less junction#875
tensordreams wants to merge 3 commits into
tml-2942-s3-pointer-disambiguationfrom
tml-2943-s4-implicit-mn-synthesis

Conversation

@tensordreams

@tensordreams tensordreams commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Linked issue

Refs TML-2943. Fourth of the PSL: Directional Relation Syntax stack — stacked on tml-2942-s3-pointer-disambiguation (TML-2942). Spec under projects/psl-relation-syntax/slices/04-implicit-mn-synthesis/.

At a glance

model Post { id Uuid @id @default(uuid()); tags Tag[]  }
model Tag  { id Uuid @id @default(uuid()); posts Post[] }
// no junction model → the framework synthesises `_PostToTag(A, B)` and an N:M `through` over it

Both ends bare and no junction model → an implicit many-to-many. The synthesised junction is created by migration (postgres + sqlite) and walked by the ORM, exactly like an authored one.

Summary

Prisma's implicit many-to-many: when both navigable ends are bare lists and no junction model links them, the framework synthesises a model-less junction table (_<A>To<B>, columns A/B, composite PK) and lowers the relation to N:M + through. Migration creates the table; the ORM walks it.

Decision

Detect the both-bare-no-junction case and synthesise the junction, preserving the precedence from earlier in the project: a through: uses the named junction; both-bare with an authored junction model recognises it (PR 2's path); only both-bare without one synthesises. The synthesised junction mirrors Prisma's _AToB/A/B convention (terminal storage names, alphabetically ordered).

How it fits together

  1. Detect (packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts): bare lists with no FK match, no authored junction, and no near-miss are grouped by model pair; a mirrored pair (or a self-ref list) synthesises.
  2. Synthesise (contract-psl/src/interpreter.ts): inject a junction ModelNode_<A>To<B>, columns A/B (FKs to the two model @ids, descriptors copied so types match), composite (A, B) PK — and feed it through the existing contract-builder; filter it from contract.roots (the same mechanism STI variants use) so it isn't a queryable entity.
  3. Migrate + walk: the synthesised junction is a normal contract table, so the migration system creates it with no special-casing, and the ORM include walks the resulting through.

Behavior changes & evidence

  • An implicit M:N synthesises _PostToTag + N:M/through; precedence preserved. contract-psl/src/interpreter.ts, …/psl-relation-resolution.ts — evidence: packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.implicit-mn.test.ts (toEqual on the emitted Contract + the D5-precedence control + diagnostics for no-@id / ambiguous / name-collision).
  • Migration creates _PostToTag on postgres and sqlite. evidence: test/integration/test/sql-orm-client/mn-psl-implicit-migration.test.ts (drives the real planners over the real emitted contract; asserts CREATE TABLE _PostToTag + composite PK + both FKs on both targets).
  • The ORM include walks the synthesised junction. evidence: test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/contract.prisma + …/mn-psl-implicit-parity.test.ts (PGlite, whole-row).

Notes for the reviewer

  • The junction is a hidden synthesised model, not literally model-less — the contract-builder requires through.table to be a declared model, so a raw table would trip that guard. It's filtered from roots, so it's more hidden than an explicit junction; db.orm._PostToTag remains reachable to exactly the degree STI variants are (roots is metadata, not the query gate in the SQL family). That is a pre-existing, project-wide property — not a new leak. A truly-db.orm-unreachable junction is a separate ORM-surface change that would also tighten STI variants; flagged as an optional follow-up.
  • The integration package gains a one-line @prisma-next/sqlite dev-dep, used solely to emit a real sqlite contract for the DDL assertion (layering-legal; lint:deps clean).
  • 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 migration DDL test + implicit-M:N parity on PGlite. Full-stack pass green (build, typecheck:packages, fixtures:check, lint:deps, cast delta 0).

Skill update

n/a for behaviour beyond the additive implicit-M:N support. 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 (implicit-M:N synthesis).
  • 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: c8efd67b-68c8-4631-b9b7-11507f5a8cf2

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-2943-s4-implicit-mn-synthesis

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

@prisma-next/mongo-runtime

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

@prisma-next/family-mongo

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

@prisma-next/sql-runtime

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

@prisma-next/family-sql

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

@prisma-next/extension-arktype-json

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

@prisma-next/middleware-cache

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

@prisma-next/mongo

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

@prisma-next/extension-paradedb

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

@prisma-next/extension-pgvector

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

@prisma-next/extension-postgis

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

@prisma-next/postgres

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

@prisma-next/sql-orm-client

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

@prisma-next/sqlite

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

@prisma-next/extension-supabase

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

@prisma-next/target-mongo

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

@prisma-next/adapter-mongo

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

@prisma-next/driver-mongo

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

@prisma-next/contract

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

@prisma-next/utils

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

@prisma-next/config

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

@prisma-next/errors

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

@prisma-next/framework-components

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

@prisma-next/operations

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

@prisma-next/ts-render

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

@prisma-next/contract-authoring

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

@prisma-next/ids

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

@prisma-next/psl-parser

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

@prisma-next/psl-printer

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

@prisma-next/cli

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

@prisma-next/cli-telemetry

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

@prisma-next/config-loader

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

@prisma-next/emitter

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

@prisma-next/language-server

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

@prisma-next/migration-tools

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

prisma-next

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

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

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

@prisma-next/mongo-codec

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

@prisma-next/mongo-contract

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

@prisma-next/mongo-value

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

@prisma-next/mongo-contract-psl

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

@prisma-next/mongo-contract-ts

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

@prisma-next/mongo-emitter

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

@prisma-next/mongo-schema-ir

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

@prisma-next/mongo-query-ast

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

@prisma-next/mongo-orm

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

@prisma-next/mongo-query-builder

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

@prisma-next/mongo-lowering

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

@prisma-next/mongo-wire

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

@prisma-next/sql-contract

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

@prisma-next/sql-errors

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

@prisma-next/sql-operations

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

@prisma-next/sql-schema-ir

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

@prisma-next/sql-contract-psl

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

@prisma-next/sql-contract-ts

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

@prisma-next/sql-contract-emitter

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

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

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

@prisma-next/sql-relational-core

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

@prisma-next/sql-builder

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

@prisma-next/target-postgres

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

@prisma-next/target-sqlite

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

@prisma-next/adapter-postgres

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

@prisma-next/adapter-sqlite

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

@prisma-next/driver-postgres

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

@prisma-next/driver-sqlite

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

commit: 1c404f0

@github-actions

Copy link
Copy Markdown

size-limit report 📦

Path Size
postgres / no-emit 158.3 KB (0%)
postgres / emit 145.84 KB (0%)
mongo / no-emit 78 KB (0%)
mongo / emit 72.09 KB (0%)
cf-worker / no-emit 185.6 KB (0%)
cf-worker / emit 171.65 KB (0%)

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
When both navigable list ends are bare (no `through:`) and no junction
model links the pair, the PSL interpreter now synthesises a model-less
junction table (Prisma's implicit many-to-many) instead of emitting the
orphaned-backrelation diagnostic.

Detection lives in `applyBackrelationCandidates`: a bare list with no
FK-side match, no authored junction, and no junction near-miss is deferred
to `resolveImplicitManyToMany`, which pairs it with its mirror end (or
resolves a self-referential list on its own) and emits the `N:M`/`through`
descriptor on both ends. D5 precedence is preserved — a both-bare pair
with an authored junction model is still recognised, never synthesised.

Synthesis injects a junction `ModelNode` named `_<A>To<B>` (terminal model
names ordered alphabetically) with foreign-key columns `A` and `B` (A
references the first model's id, B the second), a composite `(A, B)`
identity, and the two foreign keys; the contract assembler turns it into a
storage table and fills the through descriptors' `targetColumns` from the
terminal ids. The junction is a physical table only — filtered out of
`roots` like an STI variant.

Diagnostics: a terminal without a single-column `@id`
(`PSL_IMPLICIT_MN_TARGET_NO_ID`), more than one implicit many-to-many
between the same pair (`PSL_IMPLICIT_MN_AMBIGUOUS`), and a real table
already named like the synthesised junction
(`PSL_IMPLICIT_MN_NAME_COLLISION`).

Migration DDL and runtime `include` integration are S4·M2.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
S4·M1 synthesises a model-less junction for an implicit many-to-many
(both navigable list ends bare, no junction model). This proves that
junction downstream: the migration system creates it, and the ORM
`include` walks it.

The `mn-psl-implicit` fixture authors `Post.tags Tag[]` / `Tag.posts
Post[]` with no junction model, emitted via the real pipeline for both
postgres and sqlite. The interpreter synthesises `_PostToTag` (composite
PK `(A, B)`, FK `A` → `posts.id`, FK `B` → `tags.id`) and lowers both
ends to `cardinality: 'N:M'` + `through` over it.

Migration DDL: the synthesised junction is a normal contract storage
table, so the migration planner creates it with no special-casing. A
migration test drives the real planner over each emitted contract against
an empty schema and asserts the `_PostToTag` `CREATE TABLE` (composite
primary key + the two foreign keys) is planned for postgres and sqlite.

Integration: an `include('tags')` test over the implicit M:N returns the
related rows (whole-row assertions, explicit + implicit select, PGlite),
walking the synthesised junction through a real emitted PSL contract with
no authored junction model.

Wires both emissions into the sql-orm-client emit script (so
`fixtures:check` regenerates them) and adds `@prisma-next/sqlite` to the
integration test package to emit the sqlite contract.

Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
@tensordreams tensordreams force-pushed the tml-2942-s3-pointer-disambiguation branch from 4aeb245 to f422aef Compare July 1, 2026 10:56
@tensordreams tensordreams force-pushed the tml-2943-s4-implicit-mn-synthesis branch from 02a463c to 1c404f0 Compare July 1, 2026 10:56
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