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. 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 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: 3eb72d0d-69e8-42f4-9efc-0c0cf8b01b09

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: 8affc21

@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-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
// A lone end with no mirror is genuinely orphaned: there is no second
// navigable side to make a many-to-many. A self-referential list is its own
// mirror and synthesises from a single end.
if (!isSelfRelation && ends.length < 2) {

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.

For non-self pairs this only checks that the unordered bucket has two ends, not that the two ends are mirrors. A schema with two one-sided lists on Post and none on Tag (tags Tag[], pinnedTags Tag[]) lands here with ends.length === 2, so it synthesizes _PostToTag even though there is no Tag -> Post end. Please partition by declaring model and require one end from each side for non-self implicit M:N; otherwise keep the one-sided fields orphaned/ambiguous.

@tensordreams tensordreams force-pushed the tml-2942-s3-pointer-disambiguation branch from f422aef to b253568 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>
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 b253568 to b52910d Compare July 1, 2026 14:40
@tensordreams tensordreams force-pushed the tml-2943-s4-implicit-mn-synthesis branch from 01db649 to 8affc21 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 implicit-M:N synthesis is cleanly structured: it reuses slice-2's through-descriptor shape (omitting targetColumns for the assembler to fill), the synthesised _<A>To<B> name + A/B column assignment is deterministic across declaration order and both targets, the generated postgres/sqlite fixtures check out (A→posts.id int4, B→tags.id text, composite PK, FKs, _PostToTag filtered from roots), and the D5 precedence / no-@id / collision / self-ref cases are all handled with diagnostics. One correctness gap: the ambiguity check counts ends per unordered model-pair but doesn't verify a non-self pair is a genuine mirror (one end from each side), so two bare lists on the same model with no inverse silently collapse onto a single shared junction. Minor spec wording nit on "storage names" vs the model-name naming actually implemented.

Comment on lines +946 to +955
if (!isSelfRelation && ends.length < 2) {
input.diagnostics.push(orphanedBackrelationDiagnostic(first, input.sourceId));
continue;
}
// More than one implicit many-to-many between the same pair of models: the
// synthesised `_<A>To<B>` name would collide, and there is no junction model
// to disambiguate. A self-relation tolerates a single end (its own mirror);
// anything beyond the expected end count is ambiguous.
const expectedEndCount = isSelfRelation ? 1 : 2;
if (ends.length > expectedEndCount) {

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] The ambiguity guard counts ends per unordered model-pair but never verifies a non-self pair is a genuine mirror — i.e. that the two ends come from different models. Consider model Post { id Int @id; tags Tag[]; pinnedTags Tag[] } model Tag { id Int @id }: both tags and pinnedTags fall into the orphaned-ends path and group under pair Post::Tag with ends.length === 2 === expectedEndCount, so no diagnostic fires. The code synthesises a single _PostToTag and emits two N:M relations on Post (tags, pinnedTags) whose on/through descriptors are byte-identical (parentColumns: ['A'], childColumns: ['B'], targetColumns: ['id']) — the two fields become indistinguishable at query time and return the same rows. A non-self implicit M:N is only well-formed when one end is declared on each side; if both ends share a declaring model the pair should be diagnosed PSL_IMPLICIT_MN_AMBIGUOUS (or each end reported as orphaned) rather than synthesised. The symmetric 4-end case the test covers happens to trip the > expectedEndCount branch, but the 2-ends-same-side case slips through.

);
});

it('diagnoses two implicit many-to-many relations between the same pair of models', () => {

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 ambiguity test only exercises the symmetric 4-end shape (two lists on each side). Add the asymmetric case from the bug above — two bare lists on one model (Post.tags, Post.pinnedTags) with no inverse list on Tag — and assert PSL_IMPLICIT_MN_AMBIGUOUS (or PSL_ORPHANED_BACKRELATION_LIST) fires rather than a single _PostToTag being synthesised for both. Currently that input produces a "valid" contract with two colliding relations and no diagnostic, which the suite doesn't catch.

## Chosen design

- **Detection (resolver).** In the bare-list M:N path (`contract-psl` `findJunctionFkPairs` / the backrelation resolution), when both ends are bare and no junction model is found for the pair, branch to **synthesis** instead of the orphaned-backrelation diagnostic. The two terminal models must each have a single-column (or composite) `@id` to reference.
- **Synthesis (lowering).** Inject a storage table into the contract IR: name `_<A>To<B>` (terminal storage names, alphabetically ordered — decision #7), columns `A` (FK → first model's id) and `B` (FK → second model's id), composite identity `(A, B)`, the FK types matched to the referenced id columns. Emit the `N:M` + `through { table, parentColumns, childColumns, targetColumns, namespaceId }` descriptor over it on **both** navigable ends. The contract is **additive** (a table with no PSL 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.

[doc] The spec says the synthesised name uses "terminal storage names, alphabetically ordered," but the implementation keys off terminal model names (synthesizedJunctionName(modelA, modelB) with modelA/modelB from candidate.modelName), and the mn-psl-implicit fixture confirms it: @@map("posts")/@@map("tags") yet the junction is _PostToTag, not _postsTotags. That matches Prisma's convention, so the impl is right — update the spec wording from "storage names" to "model names" so the doc matches the shipped behaviour (doc-maintenance rule).

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