TML-2943: implicit many-to-many via a synthesised model-less junction#875
TML-2943: implicit many-to-many via a synthesised model-less junction#875tensordreams wants to merge 3 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
@prisma-next/extension-author-tools
@prisma-next/mongo-runtime
@prisma-next/family-mongo
@prisma-next/sql-runtime
@prisma-next/family-sql
@prisma-next/extension-arktype-json
@prisma-next/middleware-cache
@prisma-next/mongo
@prisma-next/extension-paradedb
@prisma-next/extension-pgvector
@prisma-next/extension-postgis
@prisma-next/postgres
@prisma-next/sql-orm-client
@prisma-next/sqlite
@prisma-next/extension-supabase
@prisma-next/target-mongo
@prisma-next/adapter-mongo
@prisma-next/driver-mongo
@prisma-next/contract
@prisma-next/utils
@prisma-next/config
@prisma-next/errors
@prisma-next/framework-components
@prisma-next/operations
@prisma-next/ts-render
@prisma-next/contract-authoring
@prisma-next/ids
@prisma-next/psl-parser
@prisma-next/psl-printer
@prisma-next/cli
@prisma-next/cli-telemetry
@prisma-next/config-loader
@prisma-next/emitter
@prisma-next/language-server
@prisma-next/migration-tools
prisma-next
@prisma-next/vite-plugin-contract-emit
@prisma-next/mongo-codec
@prisma-next/mongo-contract
@prisma-next/mongo-value
@prisma-next/mongo-contract-psl
@prisma-next/mongo-contract-ts
@prisma-next/mongo-emitter
@prisma-next/mongo-schema-ir
@prisma-next/mongo-query-ast
@prisma-next/mongo-orm
@prisma-next/mongo-query-builder
@prisma-next/mongo-lowering
@prisma-next/mongo-wire
@prisma-next/sql-contract
@prisma-next/sql-errors
@prisma-next/sql-operations
@prisma-next/sql-schema-ir
@prisma-next/sql-contract-psl
@prisma-next/sql-contract-ts
@prisma-next/sql-contract-emitter
@prisma-next/sql-lane-query-builder
@prisma-next/sql-relational-core
@prisma-next/sql-builder
@prisma-next/target-postgres
@prisma-next/target-sqlite
@prisma-next/adapter-postgres
@prisma-next/adapter-sqlite
@prisma-next/driver-postgres
@prisma-next/driver-sqlite
commit: |
size-limit report 📦
|
4aeb245 to
f422aef
Compare
02a463c to
1c404f0
Compare
| // 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) { |
There was a problem hiding this comment.
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.
f422aef to
b253568
Compare
1c404f0 to
01db649
Compare
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>
b253568 to
b52910d
Compare
01db649 to
8affc21
Compare
tensordreams
left a comment
There was a problem hiding this comment.
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.
| 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) { |
There was a problem hiding this comment.
[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', () => { |
There was a problem hiding this comment.
[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). |
There was a problem hiding this comment.
[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).
Linked issue
Refs TML-2943. Fourth of the PSL: Directional Relation Syntax stack — stacked on
tml-2942-s3-pointer-disambiguation(TML-2942). Spec underprojects/psl-relation-syntax/slices/04-implicit-mn-synthesis/.At a glance
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>, columnsA/B, composite PK) and lowers the relation toN: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/Bconvention (terminal storage names, alphabetically ordered).How it fits together
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.contract-psl/src/interpreter.ts): inject a junctionModelNode—_<A>To<B>, columnsA/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 fromcontract.roots(the same mechanism STI variants use) so it isn't a queryable entity.includewalks the resultingthrough.Behavior changes & evidence
_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(toEqualon the emittedContract+ the D5-precedence control + diagnostics for no-@id/ ambiguous / name-collision)._PostToTagon 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; assertsCREATE TABLE _PostToTag+ composite PK + both FKs on both targets).includewalks 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
through.tableto be a declared model, so a raw table would trip that guard. It's filtered fromroots, so it's more hidden than an explicit junction;db.orm._PostToTagremains reachable to exactly the degree STI variants are (rootsis 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.@prisma-next/sqlitedev-dep, used solely to emit a real sqlite contract for the DDL assertion (layering-legal;lint:depsclean).check:upgrade-coverageapplies (touchespackages/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), andpnpm lint:deps(0 violations).Skill update
n/a for behaviour beyond the additive implicit-M:N support. Touches
packages/3-extensions/sql-orm-client, socheck:upgrade-coverageexpects achanges: []declaration in the in-flight upgrade cycle — flagged as a stack-wide follow-up.Checklist
TML-NNNN: …form.