TML-2787: M:N slice 3 — nested writes through the junction#683
TML-2787: M:N slice 3 — nested writes through the junction#683tensordreams wants to merge 23 commits into
Conversation
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 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 |
size-limit report 📦
|
@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/emitter
@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: |
92d1741 to
25a3c7e
Compare
7cbb4a0 to
de78282
Compare
25a3c7e to
06fc270
Compare
de78282 to
3e1c908
Compare
|
Updated: the type-level |
06fc270 to
bc2d4b9
Compare
308b48d to
bb3e246
Compare
3982e13 to
b1dadcf
Compare
bb3e246 to
47cf59e
Compare
b1dadcf to
df900cd
Compare
47cf59e to
b04a59c
Compare
df900cd to
ee44053
Compare
b04a59c to
04522c0
Compare
ee44053 to
65d4fe3
Compare
04522c0 to
4d8dad2
Compare
65d4fe3 to
bd47c00
Compare
4d8dad2 to
16dc690
Compare
16dc690 to
f3a7a5c
Compare
bd47c00 to
40b8cbe
Compare
f3a7a5c to
b772b54
Compare
|
On it 👍 — re: the review-body finding "Duplicate-connect error message can be misleading": softening the connect unique-violation wrap so it no longer asserts the junction link is already present (a non-PK unique constraint on the junction could be the real cause); it will say a unique constraint on the junction was violated and that the link may already be present. Unit + integration message assertions updated accordingly. |
|
Done in c274f1e95 — re: the review-body finding "Duplicate-connect error message can be misleading": the connect unique-violation wrap now says the mutation 'violated a unique constraint on junction " "; the junction link may already be present' instead of asserting the link already exists. Unit assertions (duplicate-key wrap + SQLITE_CONSTRAINT_PRIMARYKEY wrap) and the integration already-linked-tag test updated to the new wording. |
|
On it 👍 — re: the decision-log note: recording duplicate-connect-errors as an intentional divergence from Prisma-classic implicit-M:N connect (which is idempotent via ON CONFLICT DO NOTHING) in the slice 03 spec's decision surface. |
|
Done in 07379d4f8 — re: the decision-log note: the slice 03 spec (projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md) now records duplicate-connect-errors as an intentional divergence from Prisma-classic implicit-M:N connect (idempotent ON CONFLICT DO NOTHING): a note on the connect design bullet plus an edge-case row with the rationale (portable write path, surfaces caller bugs) and the revisit condition. The wording reflects the softened error message from A25c (c274f1e95). |
Write slice: connect/disconnect/create through the junction + required-payload .create disable (types+runtime). 4 dispatches (write path / required-payload fixture / type+runtime disable / integration). Type-disable feasibility risk pre-named with a halt. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Replace the partitionByOwnership N:M rejection guard with a junctionOwned bucket. connect/disconnect/create over a through relation now resolve to junction INSERT/DELETE (create = target-insert then link) in both the create() and update() graph flows, after the parent PK is known. Composite keys are AND-ed across all parent/child column pairs; disconnect stays update()-only. Flip the rejection unit test to a positive junction-DML assertion and add connect/disconnect/create coverage for both flows. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…umns exist Throw a clear runtime error when a nested `.create()` targets an M:N relation whose junction table has non-FK NOT-NULL columns with no default (i.e. `requiredPayloadColumns` is non-empty). The error names the relation, the offending column(s), and points to the junction model / SQL builder as the supported alternative. `connect` and `disconnect` are unaffected — they only touch the FK pair. Pure junctions (no required payload) pass through the create path as before. Adds `requiredPayloadColumns` to the local `JunctionThrough` interface and copies it from the already-resolved `relation.through` in `getRelationDefinitions`. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…finish write integration tests Both `connect` and `create` write a bare junction row the M:N sugar can't complete when the junction has required non-FK columns (e.g. `user_roles.level NOT NULL`). Extend the runtime guard in `applyJunctionOwnedMutation` to cover `connect` in addition to `create`; `disconnect` (DELETE path) is unaffected. Guard message is unified: "Cannot `<op>` on relation `<rel>`: its junction `<table>` has required column(s) `<col>` the relation API can't populate. Use the `<Model>` model directly or the SQL builder." Unit test that previously asserted connect-on-User.roles succeeds is flipped to assert rejection. Integration tests are fully enabled (no it.skip); the new test asserts connect on User.roles throws the guard, while pure-junction (User.tags) connect/create/disconnect paths continue to work end-to-end. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…sted-write test; re-emit fixtures with through blocks Plain `string` constants failed the integration typecheck (tsc) because Tag.id / Role.id are `Char<36>`. Brand TAG_RUST, TAG_TS, ROLE_ADMIN, ROLE_EDITOR at declaration (matching the pattern from create.test.ts). Runtime values are unchanged. Also commits the re-emitted contract.d.ts fixtures carrying the new `through` blocks on the N:M relations (User.tags / User.roles). Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…elations at the type level Nested `.create` on an N:M relation whose junction carries a required non-FK payload column now resolves its argument to `never`, surfacing a compile-time error instead of only the runtime guard in `mutation-executor.ts`. `connect`/`disconnect` typing is unchanged. The derivation resolves the relation `through` to its junction model, treats any non-join column as a payload column, and disables create when any payload column is required (not nullable, no default). `User.roles` (junction `user_roles` with required `level`) disables create; `User.tags` (pure junction) keeps it. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Renames the `CreateDisabled` flag on `RelationMutator` to `LinkWritesDisabled` and applies the same `never`-arg guard to both `create` and `connect` overloads. `disconnect` is unaffected (it issues a DELETE, not an INSERT, so no payload column is needed). `HasRequiredJunctionPayload` is already threaded as the flag via `RelationMutationCallback` — no change required there. The runtime guard in `mutation-executor.ts` already blocks both operations; the type system now matches. Test file renamed to `junction-link-write-disable.test-d.ts`; the former "connect remains available" positive test is replaced with a `@ts-expect-error` assertion, and a new pure-positive disconnect test preserves coverage of the allowed path. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…ard tests The type-level .create/.connect disable (now in dist after the main rebase) makes those calls compile errors; cast the args to `never` to still exercise the runtime guard on a real DB (defense-in-depth). Surfaced by rebuilding against origin/main. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…fter TML-2841 rebase The write path gained namespace coordinates: insertSingleRow, findRowByCriterion, toFieldName, compileInsertCount, compileDeleteCount all take a leading namespaceId. Add namespaceId to the local JunctionThrough, thread parentNamespaceId into applyJunctionOwnedMutation / readJunctionParentValues, and use relation.relatedNamespaceId / through.namespaceId for the related-row and junction-DML calls. Add the namespaceId arg to the M:N write tests. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
updateFirstGraph now mirrors createGraph and runs preflightJunctionOwnedCreateMutation over the junction-owned bucket before the scalar-update block. Without it, an update()-flow junction rejection (required-payload guard, metadata shape, unresolved connect target) landed after the scalar UPDATE had already persisted; ordering is the only partial-write protection on runtimes without transaction(). Pinned by a unit test asserting no UPDATE executes when the junction guard fires, and an update()-flow integration test asserting the scalar update does not survive a junction rejection. Addresses PR 683 review thread r3404036584. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…raph The junction-owned create bucket was the only one that did not recurse: parent-owned and child-owned nested creates route their payloads through createGraph, but the junction bucket called insertSingleRow directly, so a junction-created target carrying its own relation mutation (type-legal per RelationMutationCreate.data) passed through mapModelDataToStorageRow untouched and died in normalizeInsertRows with an unknown-column error. Swapping insertSingleRow for createGraph is drop-in (same returned row shape readJunctionTargetValues needs) and buys bucket parity. Pinned by a unit test where a junction-created Child carries an N:1 owner connect that now resolves into the child INSERT. Addresses PR 683 review thread r3404036572. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…flight Duplicate criteria in a single connect([...]) (or two criteria resolving to the same row) defeated the preflight: each criterion resolved independently and passed, the parent INSERT landed, the first link INSERTed, and the second threw the duplicate-link error — leaving the parent plus the first link behind, the exact partial-write shape the preflight exists to prevent. The preflight now keys each resolved target tuple and fails fast on a duplicate before any write, consistent with duplicate connect being a domain error in this slice. Pinned by a unit test asserting the rejection lands with zero INSERTs executed. Addresses PR 683 review thread r3404036592. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Adds the create()-flow ordering edge case from review: a connect preflight that resolves its target successfully, followed by a parent INSERT failure (duplicate users pkey), must leave no junction rows and no parent mutation behind. Ordering is the only partial-write protection on the integration runtimes, so this pins the guarantee the rest of the suite leans on. Addresses the ordering finding in PR 683 review 4486308660. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…nctionLink
Both the type gate and the runtime guard treat execution-defaulted junction
payload columns as satisfied, but the junction INSERT built the row from the
FK pairs only — the exempted case passed both gates and then hit a NOT NULL
violation on the database. Mirror insertSingleRow: merge
context.applyMutationDefaults({ op: create, table: through.table }) into the
junction row before compileInsertCount.
The reworked unit test builds its ExecutionContext from the patched contract
(the defaults applier is a closure over the contract the context was created
from, so spreading a patched contract over an existing context never sees the
new default) and asserts the junction row includes the defaulted column. A
new integration test drops the DB-side default on user_tags.created_at and
covers an execution-defaulted junction payload column end-to-end.
Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…ons only The constraint failed message arm matched every SQLite constraint class — NOT NULL, FOREIGN KEY, and CHECK failures during a junction connect INSERT were rewritten into the duplicate-link error, pointing users at a duplicate that is not the problem. The arm was also redundant for genuine unique violations: SQLite says UNIQUE constraint failed, which the unique constraint arm already matches. Drop the over-broad arm and recognize SQLITE_CONSTRAINT_PRIMARYKEY next to SQLITE_CONSTRAINT_UNIQUE (better-sqlite3 reports junction-PK duplicates — the common M:N shape — with the PRIMARYKEY code). Unit tests pin both boundaries: a NOT NULL constraint failure passes through unwrapped, and the PRIMARYKEY code is recognized on its own with a message no string arm matches. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…onnect The shared-column conflict guard existed on the INSERT side only: insertJunctionLink merges parent/target values through writeJunctionColumn and throws on mismatch, while deleteJunctionLink emitted one predicate per side — a mismatched shared column produced contradictory predicates (tenant_id = 7 AND tenant_id = 8), so the DELETE matched nothing and disconnect silently no-oped where connect throws. deleteJunctionLink now builds the merged row via writeJunctionColumn and derives the WHERE from it: the mismatched case throws the same conflict error as connect (before any DELETE executes), and the values-equal case drops the redundant duplicate predicate. Both pinned by unit tests. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
The connect unique-violation wrap asserted "the junction link is already present", but when the junction table carries unique constraints beyond its PK the underlying violation may not be about the link being connected at all — the message attributed every unique violation to a duplicate link. Reword to state what is actually established: a unique constraint on the junction was violated, and the link may already be present. Unit and integration message assertions updated to the new wording. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…e gate DisconnectMutator reshaped disconnect for every relation, but nothing pinned that the bare disconnect() overload stays accepted for plain 1:N relations — if HasJunctionThrough ever mis-resolved toward true for non-junction relations, 1:N disconnect-all would silently drop out of the typed surface. A positive update-input test on User.posts pins it. HasRequiredJunctionPayload was only exercised on the required-to-disabled side. Three new positive tests pin the optional-payload arms of IsOptionalCreateField in isolation: a junction whose only payload column is nullable (user_tags.note), one whose only payload column carries a storage default (user_tags.created_at), and one whose required payload column is satisfied solely by an execution onCreate default (user_roles.level — the type-level counterpart of the insertJunctionLink defaults fix). Verified non-vacuous by temporarily removing the storage-default arm from IsOptionalCreateField, which turns the storage-default pin red. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
…ntentional divergence Duplicate M:N connect deliberately errors with a wrapped unique-violation domain error where Prisma-classic implicit-M:N connect is idempotent (ON CONFLICT DO NOTHING). Per round-3 review, record the divergence in the slice 03 decision surface: a note on the connect design bullet plus an edge-case row carrying the rationale (portable write path, surface caller bugs) and the revisit condition. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
3440e5d to
1f24648
Compare
Slice 3 (final) of the SQL ORM: Many-to-Many End to End project (Linear project). Nested
connect/disconnect/createthrough the junction + the required-payload safety rail.Overview
db.orm.User.update/create({ tags: (t) => t.connect/disconnect/create(...) })now routes to theUserTagjunction (INSERT / DELETE / target-insert+link), under bothcreate()andupdate(). TheN:M not supported yetguard is gone. Junctions with a required non-FK payload column cant be written through the sugar, socreateandconnecton them are disabled with a clear error (disconnect stays).Changes (5 commits)
74a778816— runtime junction write path:partitionByOwnershipgains ajunctionOwnedbucket (keyed onthroughpresence); connect→INSERT, disconnect→DELETE, create→target-insert+link, both flows, composite-key AND-ed; the rejection unit test flipped positive. (getRelationDefinitionsnow carriesthrough.)926bdc849— required-payload fixture:User ↔ RoleviaUserRole(user_id, role_id, level NOT NULL)(canonical CLI emit).3bccd80b3— runtime guard: nestedcreateon a required-payload junction throws.e6c641811— design correction (decision Remove SQL -> Runtime dependency #9): extended the guard toconnecttoo (connect also INSERTs a junction row it cant complete → DB NOT-NULL violation), flipped the unit test, finished the 10 write integration tests.Integration tests (per the project standard)
mn-nested-write.test.ts— 10 tests, no skips: connect/create on the pureUser.tagsjunction with whole-row readback viainclude(tags), bothcreate()+update()flows;disconnect; connect AND create onUser.rolesthrow the guard;disconnectonUser.rolesworks. Whole-rowtoEqual, explicit.selectin most, ≥1 implicit/default selection.AC status
create+connecton required-payload junctions: ✅ done + tested. Type-level disable: deferred — see below.⚠ Open decision (blocks marking this slice done)
The type-level
.createdisable cant be built as specified: the generatedcontract.d.tsrelation type carries onlyto/cardinality/on, notthrough— so a conditional type cant see which model is the junction or that it has a required column.requiredPayloadColumnsexists only at runtime. Honouring the type-level disable needs the contract.d.tstype emitter to carrythrough(a contract-surface change reaching into slice-0 territory). Options (full detail inwip/unattended-decisions.md#8):.d.tsemitter to emitthrough, then a follow-up adds the conditional-type disable + negative type test;requiredPayloadColumns/hasRequiredPayloadmarker into the typed relation;I shipped the runtime guard and left this for you. Once you pick (a)/(b), a small follow-up dispatch completes the type-level disable.
Notes
connect-disabled-on-required-payload was a mid-flight spec correction (the original spec wrongly assumed connect was FK-pair-only safe). The reverseTag.users/Role.usersdirections are deferred (one-directional fixture, decision #3). Refs: TML-2787.Summary by CodeRabbit
Release Notes
New Features
Improvements