Skip to content

Commit c793885

Browse files
authored
TML-2787: M:N slice 3 — nested writes through the junction (#683)
Slice 3 (final) of the **SQL ORM: Many-to-Many End to End** project ([Linear project](https://linear.app/prisma-company/project/sql-orm-many-to-many-end-to-end-c178df40ca3a)). Nested `connect`/`disconnect`/`create` through the junction + the required-payload safety rail. > **Stacked PR.** Base `tml-2786` (#680) → `tml-2785` (#679) → `tml-2784` (#678) → `tml-2597` (#673) → `tml-2729` (#667) → `main`. Review/merge bottom-up. ## Overview `db.orm.User.update/create({ tags: (t) => t.connect/disconnect/create(...) })` now routes to the `UserTag` junction (INSERT / DELETE / target-insert+link), under both `create()` and `update()`. The `N:M not supported yet` guard is gone. Junctions with a **required non-FK payload column** cant be written through the sugar, so `create` **and** `connect` on them are disabled at both the type level and runtime with a clear error (disconnect stays). ## Changes - **Runtime junction write path:** `partitionByOwnership` gains a `junctionOwned` bucket (keyed on `through` presence); connect→INSERT, disconnect→DELETE, create→target-insert+link, both flows, composite-key AND-ed; the rejection unit test flipped positive. (`getRelationDefinitions` now carries `through`.) - **Required-payload fixture:** `User ↔ Role` via `UserRole(user_id, role_id, level NOT NULL)` (canonical CLI emit). - **Runtime guard:** nested `create` **and** `connect` on a required-payload junction throw (connect also INSERTs a junction row it cant complete → DB NOT-NULL violation). Disconnect stays allowed. - **Type-level gate:** `HasRequiredJunctionPayload` + `HasJunctionThrough` compose `LinkWritesDisabled` and `BareDisconnectDisabled` on `RelationMutator`, disabling `create`/`connect` and bare `disconnect()` at the type level for required-payload junctions. The `.d.ts` emitter now carries `through` (namespace-scoped), so the gate derives required-payload columns from the junction model's field types without `any`. Covered by type tests (`junction-link-write-disable.test-d.ts`). - **Error handling:** `isUniqueConstraintViolation` uses driver-normalized `SqlQueryError.sqlState`; duplicate-connect wraps with a domain error naming the junction; shared-column conflicts detected on both INSERT and DELETE sides; metadata-length assertions fail fast; preflight detects duplicate resolved targets. - **Namespace scoping:** `MutationDefaultsOptions` carries `namespace`; `ModelNameForTable` and `JunctionPayloadFieldNames` are namespace-aware; execution-default and storage-default columns are excluded from `requiredPayloadColumns`. - **Transaction safety:** ORM mutations run inside a transaction via `withMutationScope`. ## Integration tests (per the project standard) `mn-nested-write.test.ts` — connect/create/disconnect on the pure `User.tags` junction and the required-payload `User.roles` junction, both `create()` and `update()` flows; duplicate-connect rejection; missing-target rejection; parent-insert-failure-after-connect-preflight (no partial writes); execution-defaulted junction payload column; bare `disconnect()` type error on junctions; whole-row `toEqual`, explicit `.select` in most, ≥1 implicit/default selection. ## AC status - ✅ **AC-1** — connect/disconnect/create route to junction DML, both flows, guard removed, rejection test flipped (unit + integration). - ✅ **AC-2** — Runtime disable of `create`+`connect` on required-payload junctions + type-level disable (`create`/`connect` → `never`, bare `disconnect()` → type error on junctions). Both implemented and tested. - ✅ **AC-3** — write integration tests per the standard. ## Notes `connect`-disabled-on-required-payload was a mid-flight spec correction (the original spec wrongly assumed connect was FK-pair-only safe). Duplicate `connect` errors deliberately rather than silently succeeding (intentional divergence from Prisma-classic implicit-M:N `connect`, which is idempotent via `ON CONFLICT DO NOTHING`). The reverse `Tag.users`/`Role.users` directions are deferred (one-directional fixture, decision #3). Refs: TML-2787. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enabled many-to-many nested mutations via junction tables when junction metadata is available, including `create`, `connect`, and `disconnect`. * Added compile-time and runtime protections to prevent creating/connecting junction links when the junction has required payload fields. * **Bug Fixes** * Improved unique-constraint error detection for more consistent handling (based on SQLSTATE). * **Tests** * Expanded unit and integration coverage for junction-based M:N nested writes, junction payload validation, and correct junction DML behavior. * **Chores** * Updated generated contract and example artifacts to use namespace-qualified execution-default references. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent 69b6e74 commit c793885

121 files changed

Lines changed: 7304 additions & 342 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/rules/no-contract-data-patching-in-tests.mdc

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,20 @@ alwaysApply: false
88

99
## Rule
1010

11-
**Never, under any circumstances, patch raw contract data structures in tests.** Use real emitted fixtures. If a test really has to generate a contract at runtime, it may only generate it from a high-level representation made with a user-facing authoring surface (contract builder DSL, PSL) — never by writing, or worse, patching, the raw contract data structure.
11+
**Never, under any circumstances, patch or hand-author raw contract data structures in tests.** This is a HARD rule — hard as obsidian — not a guideline, suggestion, or default you may trade away under time pressure. Use real emitted fixtures. If a test really has to generate a contract at runtime, it may only generate it from a high-level representation made with a user-facing authoring surface (contract builder DSL, PSL) — never by writing, or worse, patching, the raw contract data structure.
12+
13+
A hand-built object literal that "happens" to match the contract shape is **not** a contract — it is a bag of random data that incidentally coincides with a real contract today and will diverge tomorrow given the velocity of this repo. Tests built on it are vacuous.
14+
15+
### No cast-smuggling
16+
17+
Constructing a contract value at runtime and forcing it through the type system with `as unknown as FrameworkContract<...>`, `as unknown as Contract`, `blindCast`/`castAs`, or any equivalent escape is **strictly forbidden** — that cast is the tell that you are hand-authoring raw contract data. Do not refactor unrelated `as` casts to `blindCast`/`castAs` to slip such a value past review. There is no acceptable variant of this workaround.
18+
19+
### If you cannot satisfy this rule
20+
21+
Stop. A workaround is never acceptable — it invalidates the rest of the work and is treated as a total failure of the task, not a partial success.
22+
23+
- **Working unattended:** stop and fix whatever prevents you from satisfying the rule (e.g. add the missing emitted fixture, or extend the authoring surface) before continuing.
24+
- **Working interactively:** stop and flag it to the operator.
1225

1326
## Why
1427

examples/bundle-size/src/postgres/generated/contract.d.ts

Lines changed: 6 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/bundle-size/src/postgres/generated/contract.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts

Lines changed: 16 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/prisma-next-cloudflare-worker/src/prisma/contract.json

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts

Lines changed: 11 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/prisma-next-demo-sqlite/src/prisma/contract.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.d.ts

Lines changed: 16 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.json

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.d.ts

Lines changed: 16 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)