From e0ce95053cf3ed6c24213bb00e7f5d7d7a0537c3 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 15 Oct 2026 14:11:35 +0000 Subject: [PATCH 01/14] docs(nested-mutations): table state as graph-created nodes; graph-nested slice spec and plan Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- projects/nested-mutations/mutation-graph.md | 22 +++-- .../slices/graph-nested/plan.md | 30 +++++++ .../slices/graph-nested/spec.md | 84 +++++++++++++++++++ 3 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 projects/nested-mutations/slices/graph-nested/plan.md create mode 100644 projects/nested-mutations/slices/graph-nested/spec.md diff --git a/projects/nested-mutations/mutation-graph.md b/projects/nested-mutations/mutation-graph.md index ac27b120fa55..d0a5e61fe3a6 100644 --- a/projects/nested-mutations/mutation-graph.md +++ b/projects/nested-mutations/mutation-graph.md @@ -106,21 +106,16 @@ A data edge can only point backwards: a node reads only from nodes already in th - **Why.** Every data dependency already forces its source to be added first, and every other order requirement in this project is "as the caller wrote it", which the translation satisfies by walking the caller's input in order and appending. An `After` edge was built first and nothing in the runner read it: position order was already dependency order. The fixed phases of the old executor are a consequence of data edges here, and the defect of the first build, lookups hoisted in front of a write they should have seen, cannot be produced by appending in order. - **Assumes.** Nothing moves a node after it is added. Order edges become necessary, and are the way to state the constraints, as soon as one of these exists: a step that reorders nodes or hoists lookups; running independent nodes concurrently or batching them into one round trip; a rule that inserts a node in front of existing ones. Until then an order requirement that is not a data dependency is visible only as the order of nodes in a test's printed graph. -### D4. Table state is nodes in the graph +### D4. Table state is nodes and edges the graph creates -> To be reconsidered when slice `graph-nested` is specified. "The latest state of its table" means the latest at the time a node is added, so these edges restate the add order (D3) and never contradict it. They are worth building only if something will reorder nodes. D5 depends on this one. +Every version of a table is a `State` node. The graph creates them; the builder never does. When a node is added, the graph: +- adds an edge from the current `State` of every table its statement reads (every table in its AST) to the node; +- if the node writes, adds a new `State` for its target and for every table a foreign-key action can change from there (`changedBy`, derived once from the contract: tables whose foreign keys point at a changed table with `CASCADE`, `SET NULL` or `SET DEFAULT`, followed transitively over the contract's tables), each with an edge from the write. -Per table, `State(table, n)`. A read depends on the latest state of its table. A write depends on the latest state and produces the next. A write also depends on every read of the state it replaces. +State edges are ordinary edges in an input slot of the node, resolved by the runner like any other; a `State` has no statement and produces no rows, and a node that has no use for its state inputs ignores them. `sameState(a, b, table)` compares the `State` sources of the two nodes' state edges for that table: a lookup on each side and one comparison, no traversal. -- **Why.** This orders a lookup after earlier writes to the same table, which fixes the defect where a lookup read rows as they were before an earlier write in the same call, without anyone having to remember to add an edge. State as nodes, not as bookkeeping in the builder, because peephole rules need to ask "do these two nodes see the same version of this table" and that must be readable from the graph. -- **Assumes.** Two nodes that touch different tables and share no data edge need no order between them, except as D5 says. - -### D5. A write to the target of a `through` relation also advances the junction's state - -Reads and junction writes use only their own table's state. An update or delete on the target table of a many-to-many relation depends on and advances the state of that relation's junction table. - -- **Why.** `[t.connect(x), t.deleteAll()]` inserts into the junction and deletes from the target: two tables, no shared state, no order. The order matters, because deleting the target changes junction rows through the foreign key. The rule models that one hop and nothing more. -- **Assumes.** The only cross-table ordering that matters within one relation's array is target-versus-junction. A schema that also declares a direct relation from the parent to the junction model can still produce two writes with no defined order; that case is accepted as unordered. +- **Why.** An optimisation such as merging two identical lookups needs to know, without walking the nodes between them, that no write to the looked-up tables happened in between. Edges are the one way the graph states a dependency; a version number per table, kept in the graph or stamped on nodes, would be a second one. Making state edges ordinary inputs instead of a special kind of edge keeps one resolution path in the runner. The first version of this decision had the builder create state nodes and covered only the junction table (D5); `changedBy` covers every table a write can change through foreign keys. +- **Assumes.** State edges always point from an earlier position to a later one, so nodes still run in the order they were added (D3). Triggers are invisible. A write does not yet depend on the reads of the state it replaces: no rule reorders nodes; that edge is added with the first one that does. ### D6. An empty source skips its consumers; `Assert` is the only way empty becomes an error @@ -242,6 +237,9 @@ Stage 1 builds the graph for what main does today. The bar is that every integra | An order-only edge between consecutive operations of one array | Table state already orders most pairs; D5 covers the remaining many-to-many pairs without a per-array rule | | A write advancing the state of every table that references its table | Spreads through the schema, needs cycle handling, and depends on foreign keys the contract does not always have | | Table state as bookkeeping in the builder | Peephole rules could not ask whether two nodes see the same version of a table | +| Per-table version numbers in the graph, or stamped on nodes | A second way of stating a dependency beside edges | +| State edges as a special kind of edge that nodes never receive | An exception in the runner's resolution for no gain; a node that does not use them ignores them | +| D5: only a write to a many-to-many target advances the junction's state | Replaced by `changedBy`, which covers every table a foreign-key action can change | | A junction-aware edge lowered to `EXISTS` | A special edge where a peephole belongs; and `EXISTS` is slow on SQLite | | Optimisation passes run in a chosen order | Order between passes has to be found by trial; peepholes at construction have none | | A worklist run after the graph is built | Not needed for the rules we have; added only when a rule requires it | diff --git a/projects/nested-mutations/slices/graph-nested/plan.md b/projects/nested-mutations/slices/graph-nested/plan.md new file mode 100644 index 000000000000..c1c0e1d82bb0 --- /dev/null +++ b/projects/nested-mutations/slices/graph-nested/plan.md @@ -0,0 +1,30 @@ +## Dispatch plan + +Slice spec: `projects/nested-mutations/slices/graph-nested/spec.md`. Four dispatches, sequential. Tests first in each. Tests assert effects and printed graphs, never SQL. + +### Dispatch 1: table state, `Assert`, and the table collector + +- **Outcome:** The AST table collector exists in `relational-core`; `changedBy` is derived from the contract; the graph creates `State` nodes and state edges on `add` and offers `sameState`; `Assert` exists. Unit tests for each, including a transitive cascade and a node that reads several tables. Existing graphs print as before by default, with state shown in the tests that are about it. +- **Builds on:** Slice `graph-creates`. +- **Hands to:** The pieces the translation uses. +- **Focus:** State edges are ordinary input edges resolved by the runner; no special case in the runner. + +### Dispatch 2: nested operations in `create()` + +- **Outcome:** `create()` with relation callbacks builds its graph through the translation for every operation and layout and runs it; the executor's create path is unused. Integration tests on both targets pass. +- **Builds on:** Dispatch 1. +- **Hands to:** The translation table, which dispatch 3 extends to `update()`. +- **Focus:** Phase order, recursion, the junction `connect` conflict-clause rule, rejections while building. + +### Dispatch 3: nested operations in `update()`, and the executor removed + +- **Outcome:** `update()` with relation callbacks runs through the graph; `mutation-executor.ts` and the branch on relation callbacks are removed; the contract validator rejects empty link-column lists; the decided behaviour changes hold with tests. +- **Builds on:** Dispatch 2. +- **Hands to:** Every write method on the graph. +- **Focus:** `disconnect`, `null` on no match, rejection timing, the non-callback rejection, the integration tests on main that assert the old behaviours (listed and changed only where a decided change applies). + +### Dispatch 4: clean-up and the slice gates + +- **Outcome:** The `compile…` functions without callers and `aliasTarget` are removed and the file renamed; the unread-`Find` step built or reported as not needed; pending upgrade entries as the coverage gate requires (report whether the contract-validation change needs anything other than `changes: []`, with the reason); every gate passes. +- **Builds on:** Dispatch 3. +- **Hands to:** The slice ready for its PR. diff --git a/projects/nested-mutations/slices/graph-nested/spec.md b/projects/nested-mutations/slices/graph-nested/spec.md new file mode 100644 index 000000000000..a813025f2df0 --- /dev/null +++ b/projects/nested-mutations/slices/graph-nested/spec.md @@ -0,0 +1,84 @@ +# Slice: graph-nested + +Parent project `projects/nested-mutations/`. Last slice of stage 1, stacked on `graph-creates` (branch `mutation-graph-nested` on top of `mutation-graph-creates`). It moves `create()` and `update()` with relation callbacks onto the mutation graph and removes the nested-write executor. After it, every write method of the collection runs through the graph. + +## At a glance + +```ts +db.orm.Post.where({ id: 1 }).update({ + title: 'New', + author: (a) => a.connect({ email: 'ada@x' }), + comments: (c) => c.create({ body: 'hi' }), + tags: (t) => t.connect({ name: 'sql' }), +}) +``` + +``` +n1 Find post where id = 1 limit 1 +n2 Find user where email = 'ada@x' +n3 Assert <- Rows n2 +n4 Update post set title = 'New' <- FilterData n1 (id->id), PayloadData n2 (id->author_id) +n5 Insert comment { body: 'hi' } <- PayloadData n4 (id->post_id) +n6 Find tag where name = 'sql' +n7 Assert <- Rows n6 +n8 Insert post_tag <- PayloadData n4 (id->post_id), PayloadData n6 (id->tag_id) +result: n4 +``` + +(State nodes and edges left out of the printed form above; see below.) + +## Chosen design + +The design is in [`../../mutation-graph.md`](../../mutation-graph.md). This slice adds: + +| Part | Added | +| --- | --- | +| `Assert` | a node with no statement and one `Rows` input; throws its named error when the input has no rows, otherwise returns nothing. | +| `State` | a node with no statement, one per version of a table, created by the graph (design record D4). | +| State edges | ordinary edges in an input slot of statement nodes, from the current `State` of each table the statement reads; and from a write to each new `State` it produces. Resolved by the runner like any edge; nodes ignore them. | +| `changedBy` | a function derived once from the contract: the tables a write to a table can change through `CASCADE`, `SET NULL`, `SET DEFAULT` foreign keys, transitively. | +| `sameState(a, b, table)` | on the graph; compares the `State` sources of two nodes' state edges. Nothing in this slice calls it. | +| AST table collector | in `relational-core`: every table source in a statement (from, joins, subqueries, include joins, a write's target). | +| Translation | one entry per operation and relation layout, for `create`, `connect`, `disconnect` in `create()` and `update()`, appended in main's phase order: relations the parent holds the key for, the parent's write, relations the child holds the key for, many-to-many relations. Nested creates inside nested creates recurse. | + +Rules for the translation, reproducing main except where a decided change says otherwise: + +- **Parent holds the key.** `create`: the related row is inserted first and its key copied into the parent's values by `PayloadData`. `connect`: a `Find` by the criterion, an `Assert` raising `ORM.RELATION_ROW_MISSING`, the key copied by `PayloadData`. `disconnect` (in `update()` only): the parent's key columns are set to null in its own values. +- **Child holds the key.** `create`: one `Insert` of the child rows with the parent's key from `PayloadData`. `connect`: an `Update` of the children matching the criterion, setting their key from the parent by `PayloadData`; no error when nothing matches, as on main. `disconnect`: an `Update` setting the children's key to null, limited to children of the parent by `FilterData`, and to the criterion when one is given. +- **Many-to-many.** `create`: the target row is inserted, then the junction row with both keys by `PayloadData`. `connect`: a `Find` and an `Assert` per criterion, then the junction insert. `disconnect`: a `Find` per criterion, then a `Delete` of the junction row. Junction `connect` uses a conflict clause where the junction has a key over exactly its link columns and a plain insert otherwise, with no duplicate check and no wrapped error; `ORM.RELATION_LINK_DUPLICATE` is removed. +- **The parent's write.** In `update()`, `Find` of the first matching row, then `Update` (or the `Find` itself when nothing is set, design record D7a). In `create()`, `Insert`. +- **Result.** The parent's write node, with the caller's selection. With includes, a `Find` with the includes reading the parent's key is added last, after every child operation. +- **Rejections while the graph is built.** Invalid nested input is rejected before any statement, including when the filter matches no row. A relation field that is not a callback is rejected with `ORM.RELATION_MUTATION_INVALID`. `disconnect` in `create()` stays rejected with `ORM.RELATION_MUTATION_UNSUPPORTED`. +- **Contract validation.** A relation whose link-column lists are empty is rejected by the contract validator (minimum length 1 on `on.localFields`, `on.targetFields` and the three `through` lists). The executor's guards for empty lists are not carried over. + +Clean-up that this slice owns: + +- `mutation-executor.ts` and the branch on relation callbacks in `create()` and `update()` are removed; `withMutationScope` moves where the runner can import it. +- Every `compile…` function in `query-plan-mutations.ts` that loses its last caller is removed, `aliasTarget` with them; the file keeps the AST-building functions and is renamed to say what it holds. +- The step that removes a `Find` nobody reads (design record D7a) is built if a graph in this slice can contain one; otherwise it stays for later and the slice says so. + +## Scope + +**In:** the above; unit tests for each new class, the collector, `changedBy` (including a transitive cascade), each translation entry by printed graph; integration tests on Postgres and SQLite for every operation and layout. + +**Out:** arrays of operations, `updateAll`/`deleteAll` inside relation callbacks, the inlining peephole (slice `filtered-many-writes`); any rule that calls `sameState`. + +## Pre-investigated edge cases + +| Case | Behaviour that must hold | +| --- | --- | +| `update()` matching no row with valid nested input | Resolves `null`; no write runs (the parent's `Find` is empty, so its readers return no rows). | +| A nested create whose data has its own relation callbacks | The inner operations translate in the same phase order, recursively. | +| A variant collection as parent or child | As main; a variant stored in its own table as a nested create target uses the chain from slice `graph-creates`. Report what main does where it differs from that. | +| Annotations | On every statement of the call, including the nested ones (main discarded them for nested calls; design record D12). | +| Includes on a nested `create()`/`update()` | Loaded by the final `Find`, after all child operations. | + +## Slice-specific done conditions + +- [ ] Every integration test on main passes on Postgres and SQLite, unedited except for the decided behaviour changes listed above. +- [ ] No write method of the collection executes a statement outside a node; `mutation-executor.ts` does not exist. +- [ ] Tests assert effects (returned rows, table contents, errors) and printed graphs; never generated SQL. + +## Open questions + +None. From 5e8d59336320130a792cd6bd22b091cccac272b5 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 15 Oct 2026 14:58:05 +0000 Subject: [PATCH 02/14] feat(sql-orm-client): table state, Assert and per-kind edge methods in the mutation graph relational-core gets collectTables, which gives every table a statement names, by namespace and name, from FROM, joins, derived tables, function source arguments, subqueries at any depth and a write's target. The graph takes the contract. When a statement node is added, the graph gives it a state edge from the current State of every table its statement reads, and when the node writes, a new State for its target and for every table a CASCADE, SET NULL or SET DEFAULT foreign key changes from there, derived from the contract and cached per contract. sameState compares the State two nodes read for a table. Assert throws its error when its input has no row. Edges no longer carry columns: FilterData, PayloadData and Rows share a DataEdge base with the column pairs. Data edges are added with addFilterDataEdge, addPayloadDataEdge and addRowsEdge, which widen the source and record the columns read from it; the result step asks the graph for those columns. The id add returns carries the node's input type, so an edge method only accepts a target with the slot the edge goes into. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- .../4-lanes/relational-core/src/ast/types.ts | 102 ++++++ .../test/ast/collect-tables.test.ts | 157 +++++++++ .../sql-orm-client/src/collection.ts | 2 +- .../src/mutation-graph/assert.ts | 23 ++ .../src/mutation-graph/changed-by.ts | 80 +++++ .../src/mutation-graph/collection-graphs.ts | 79 ++--- .../src/mutation-graph/cross-join.ts | 2 + .../src/mutation-graph/delete.ts | 9 +- .../sql-orm-client/src/mutation-graph/edge.ts | 19 +- .../src/mutation-graph/filter-data.ts | 4 +- .../src/mutation-graph/filtered-statement.ts | 5 +- .../sql-orm-client/src/mutation-graph/find.ts | 9 +- .../src/mutation-graph/graph.ts | 188 +++++++++- .../src/mutation-graph/insert-chunked.ts | 14 +- .../src/mutation-graph/insert.ts | 10 +- .../sql-orm-client/src/mutation-graph/node.ts | 10 + .../src/mutation-graph/payload-data.ts | 4 +- .../sql-orm-client/src/mutation-graph/rows.ts | 4 +- .../src/mutation-graph/run-graph.ts | 22 +- .../src/mutation-graph/state.ts | 37 ++ .../src/mutation-graph/statement-node.ts | 9 +- .../src/mutation-graph/union-all.ts | 2 + .../src/mutation-graph/update.ts | 9 +- .../test/mutation-graph/assert.test.ts | 22 ++ .../test/mutation-graph/cascade-contract.ts | 60 ++++ .../test/mutation-graph/changed-by.test.ts | 36 ++ .../mutation-graph/filtered-statement.test.ts | 14 +- .../test/mutation-graph/graph-edges.test-d.ts | 31 ++ .../test/mutation-graph/graph-state.test.ts | 200 +++++++++++ .../test/mutation-graph/graph.test.ts | 329 +++++++++++------- .../mutation-graph/insert-chunked.test.ts | 4 +- .../test/mutation-graph/insert.test.ts | 8 +- .../test/mutation-graph/print-graph.test.ts | 85 ++--- .../test/mutation-graph/print-graph.ts | 25 +- .../test/mutation-graph/run-graph.test.ts | 144 ++++---- .../test/mutation-graph/statements.ts | 35 +- 36 files changed, 1429 insertions(+), 364 deletions(-) create mode 100644 packages/2-sql-family/4-lanes/relational-core/test/ast/collect-tables.test.ts create mode 100644 packages/3-extensions/sql-orm-client/src/mutation-graph/assert.ts create mode 100644 packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts create mode 100644 packages/3-extensions/sql-orm-client/src/mutation-graph/state.ts create mode 100644 packages/3-extensions/sql-orm-client/test/mutation-graph/assert.test.ts create mode 100644 packages/3-extensions/sql-orm-client/test/mutation-graph/cascade-contract.ts create mode 100644 packages/3-extensions/sql-orm-client/test/mutation-graph/changed-by.test.ts create mode 100644 packages/3-extensions/sql-orm-client/test/mutation-graph/graph-edges.test-d.ts create mode 100644 packages/3-extensions/sql-orm-client/test/mutation-graph/graph-state.test.ts diff --git a/packages/2-sql-family/4-lanes/relational-core/src/ast/types.ts b/packages/2-sql-family/4-lanes/relational-core/src/ast/types.ts index 69b33a4908ba..c27e7a0ec614 100644 --- a/packages/2-sql-family/4-lanes/relational-core/src/ast/types.ts +++ b/packages/2-sql-family/4-lanes/relational-core/src/ast/types.ts @@ -239,6 +239,47 @@ function collectParamRefsWith(node: TNode): AnyParamRe }); } +function collectTableSourcesWith(node: TNode): TableSource[] { + return node.fold({ + empty: [], + combine: (a, b) => [...a, ...b], + select: (ast) => ast.collectTableSources(), + }); +} + +function tableSourcesOfFrom(source: AnyFromSource): TableSource[] { + switch (source.kind) { + case 'table-source': + return [source]; + case 'derived-table-source': + return source.query.collectTableSources(); + case 'function-source': + return source.args.flatMap((arg) => arg.collectTableSources()); + } +} + +function tableSourcesOfReturning( + returning: ReadonlyArray | undefined, +): TableSource[] { + return (returning ?? []).flatMap((item) => item.expr.collectTableSources()); +} + +export interface TableCoordinate { + readonly namespaceId: string | undefined; + readonly name: string; +} + +export function collectTables(ast: AnyQueryAst): readonly TableCoordinate[] { + const tables = new Map(); + for (const { namespaceId, name } of ast.collectTableSources()) { + const key = JSON.stringify([namespaceId, name]); + if (!tables.has(key)) { + tables.set(key, Object.freeze({ namespaceId, name })); + } + } + return [...tables.values()]; +} + function rewriteTableSource(table: TableSource, rewriter: AstRewriter): TableSource { return rewriter.tableSource ? rewriter.tableSource(table) : table; } @@ -341,6 +382,7 @@ abstract class AstNode { abstract class QueryAst extends AstNode { abstract collectParamRefs(): AnyParamRef[]; + abstract collectTableSources(): TableSource[]; abstract toQueryAst(): AnyQueryAst; } @@ -362,6 +404,10 @@ abstract class Expression extends AstNode implements ExpressionSource { return collectParamRefsWith(this); } + collectTableSources(): TableSource[] { + return collectTableSourcesWith(this); + } + baseColumnRef(): ColumnRef { throw new InternalError(`${this.constructor.name} does not expose a base column reference`); } @@ -1895,6 +1941,24 @@ export class SelectAst extends QueryAst { return refs; } + collectTableSources(): TableSource[] { + return [ + ...(this.from === undefined ? [] : tableSourcesOfFrom(this.from)), + ...this.projection.flatMap((projection) => projection.expr.collectTableSources()), + ...(this.where?.collectTableSources() ?? []), + ...(this.having?.collectTableSources() ?? []), + ...(this.orderBy ?? []).flatMap((orderItem) => orderItem.expr.collectTableSources()), + ...(this.distinctOn ?? []).flatMap((expr) => expr.collectTableSources()), + ...(this.groupBy ?? []).flatMap((expr) => expr.collectTableSources()), + ...(this.joins ?? []).flatMap((join) => [ + ...tableSourcesOfFrom(join.source), + ...(join.on.kind === 'eq-col-join-on' ? [] : join.on.collectTableSources()), + ]), + ...(typeof this.limit === 'object' ? this.limit.collectTableSources() : []), + ...(typeof this.offset === 'object' ? this.offset.collectTableSources() : []), + ]; + } + collectParamRefs(): AnyParamRef[] { const refs: AnyParamRef[] = []; const pushRefs = (params: ReadonlyArray) => { @@ -2084,6 +2148,21 @@ export class InsertAst extends QueryAst { ); } + override collectTableSources(): TableSource[] { + const conflictSet = + this.onConflict?.action.kind === 'do-update-set' ? this.onConflict.action.set : {}; + return [ + this.table, + ...this.rows.flatMap((row) => + Object.values(row).flatMap((value) => + value.kind === 'raw-expr' ? value.collectTableSources() : [], + ), + ), + ...Object.values(conflictSet).flatMap((value) => value.collectTableSources()), + ...tableSourcesOfReturning(this.returning), + ]; + } + override collectParamRefs(): AnyParamRef[] { const refs: AnyParamRef[] = []; for (const row of this.rows) { @@ -2159,6 +2238,15 @@ export class UpdateAst extends QueryAst { ); } + override collectTableSources(): TableSource[] { + return [ + this.table, + ...Object.values(this.set).flatMap((value) => value.collectTableSources()), + ...(this.where?.collectTableSources() ?? []), + ...tableSourcesOfReturning(this.returning), + ]; + } + override collectParamRefs(): AnyParamRef[] { const refs: AnyParamRef[] = []; for (const value of Object.values(this.set)) { @@ -2218,6 +2306,14 @@ export class DeleteAst extends QueryAst { ); } + override collectTableSources(): TableSource[] { + return [ + this.table, + ...(this.where?.collectTableSources() ?? []), + ...tableSourcesOfReturning(this.returning), + ]; + } + override collectParamRefs(): AnyParamRef[] { const refs: AnyParamRef[] = []; if (this.where) { @@ -2329,6 +2425,12 @@ export class RawQueryAst extends QueryAst { }); } + override collectTableSources(): TableSource[] { + return this.parts.flatMap((part) => + typeof part === 'string' ? [] : part.collectTableSources(), + ); + } + override collectParamRefs(): AnyParamRef[] { const refs: AnyParamRef[] = []; for (const part of this.parts) { diff --git a/packages/2-sql-family/4-lanes/relational-core/test/ast/collect-tables.test.ts b/packages/2-sql-family/4-lanes/relational-core/test/ast/collect-tables.test.ts new file mode 100644 index 000000000000..37a552791608 --- /dev/null +++ b/packages/2-sql-family/4-lanes/relational-core/test/ast/collect-tables.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { + BinaryExpr, + collectTables, + DeleteAst, + DerivedTableSource, + EqColJoinOn, + ExistsExpr, + FunctionSource, + InsertAst, + InsertOnConflict, + JoinAst, + ProjectionItem, + RawQueryAst, + SelectAst, + SubqueryExpr, + TableSource, + UpdateAst, +} from '../../src/ast/types'; +import { col, param } from './test-helpers'; + +function inNamespace(name: string, namespaceId: string, alias?: string): TableSource { + return TableSource.named(name, alias, namespaceId); +} + +function selectFrom(source: TableSource): SelectAst { + return SelectAst.from(source).withProjection([ + ProjectionItem.of('id', col(source.alias ?? source.name, 'id')), + ]); +} + +describe('collectTables', () => { + it('gives the table of a FROM by its storage coordinate, not its alias', () => { + expect(collectTables(selectFrom(inNamespace('user', 'public', 'u')))).toEqual([ + { namespaceId: 'public', name: 'user' }, + ]); + }); + + it('gives each table once, in the order it is first named', () => { + const ast = selectFrom(inNamespace('user', 'public')).withJoins([ + JoinAst.inner( + inNamespace('post', 'public'), + EqColJoinOn.of(col('user', 'id'), col('post', 'user_id')), + ), + JoinAst.left( + inNamespace('user', 'public', 'author'), + EqColJoinOn.of(col('post', 'user_id'), col('author', 'id')), + ), + ]); + + expect(collectTables(ast)).toEqual([ + { namespaceId: 'public', name: 'user' }, + { namespaceId: 'public', name: 'post' }, + ]); + }); + + it('tells apart tables of the same name in different namespaces', () => { + const ast = selectFrom(inNamespace('tasks', 'a')).withJoins([ + JoinAst.inner( + inNamespace('tasks', 'b', 'tasks_2'), + EqColJoinOn.of(col('tasks', 'id'), col('tasks_2', 'id')), + ), + ]); + + expect(collectTables(ast)).toEqual([ + { namespaceId: 'a', name: 'tasks' }, + { namespaceId: 'b', name: 'tasks' }, + ]); + }); + + it('reaches tables in derived tables, lateral joins and function source arguments', () => { + const lateral = DerivedTableSource.as('posts', selectFrom(inNamespace('post', 'public'))); + const ast = SelectAst.from( + DerivedTableSource.as('inner', selectFrom(inNamespace('user', 'public'))), + ) + .withProjection([ProjectionItem.of('id', col('inner', 'id'))]) + .withJoins([ + JoinAst.left(lateral, BinaryExpr.eq(col('inner', 'id'), col('posts', 'id')), true), + JoinAst.inner( + FunctionSource.of('unnest', [SubqueryExpr.of(selectFrom(inNamespace('tag', 'public')))]), + BinaryExpr.eq(col('inner', 'id'), col('inner', 'id')), + ), + ]); + + expect(collectTables(ast)).toEqual([ + { namespaceId: 'public', name: 'user' }, + { namespaceId: 'public', name: 'post' }, + { namespaceId: 'public', name: 'tag' }, + ]); + }); + + it('reaches tables in subqueries of the projection and the condition, at any depth', () => { + const deepest = selectFrom(inNamespace('tag', 'public')); + const nested = selectFrom(inNamespace('post', 'public')).withWhere(ExistsExpr.exists(deepest)); + const ast = selectFrom(inNamespace('user', 'public')) + .withProjection([ProjectionItem.of('posts', SubqueryExpr.of(nested))]) + .withWhere(ExistsExpr.exists(selectFrom(inNamespace('comment', 'public')))); + + expect(collectTables(ast)).toEqual([ + { namespaceId: 'public', name: 'user' }, + { namespaceId: 'public', name: 'post' }, + { namespaceId: 'public', name: 'tag' }, + { namespaceId: 'public', name: 'comment' }, + ]); + }); + + it('gives the target of an insert and the tables its conflict clause and returning read', () => { + const ast = InsertAst.into(inNamespace('user', 'public')) + .withRows([{ email: param('a@example.com') }]) + .withOnConflict( + InsertOnConflict.on([col('user', 'email')]).doUpdateSet({ + name: SubqueryExpr.of(selectFrom(inNamespace('profile', 'public'))), + }), + ) + .withReturning([ + ProjectionItem.of('posts', SubqueryExpr.of(selectFrom(inNamespace('post', 'public')))), + ]); + + expect(collectTables(ast)).toEqual([ + { namespaceId: 'public', name: 'user' }, + { namespaceId: 'public', name: 'profile' }, + { namespaceId: 'public', name: 'post' }, + ]); + }); + + it('gives the target of an update and the tables its values and condition read', () => { + const ast = UpdateAst.table(inNamespace('user', 'public', 'u')) + .withSet({ name: SubqueryExpr.of(selectFrom(inNamespace('profile', 'public'))) }) + .withWhere(ExistsExpr.exists(selectFrom(inNamespace('post', 'public')))); + + expect(collectTables(ast)).toEqual([ + { namespaceId: 'public', name: 'user' }, + { namespaceId: 'public', name: 'profile' }, + { namespaceId: 'public', name: 'post' }, + ]); + }); + + it('gives the target of a delete and the tables its condition reads', () => { + const ast = DeleteAst.from(inNamespace('user', 'public')).withWhere( + ExistsExpr.exists(selectFrom(inNamespace('post', 'public'))), + ); + + expect(collectTables(ast)).toEqual([ + { namespaceId: 'public', name: 'user' }, + { namespaceId: 'public', name: 'post' }, + ]); + }); + + it('gives the tables of the expressions in a raw query', () => { + const ast = RawQueryAst.affectedCount([ + 'select ', + SubqueryExpr.of(selectFrom(inNamespace('user', 'public'))), + ]); + + expect(collectTables(ast)).toEqual([{ namespaceId: 'public', name: 'user' }]); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index 9c6fab804904..e102ef851c41 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -2773,7 +2773,7 @@ export class CollectionBase< const values = this.#updateValues(data); const graph = Object.keys(values).length === 0 - ? new Graph() + ? new Graph(this.contract) : updateAllGraph(this.#descriptionWithoutIncludes(), values); const annotations = this.#collectAnnotationsFromMeta(configure, 'write', 'updateAndCount'); return this.#countCall(this.#writeCall('updateAndCount', annotations, ormResults.count), graph); diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/assert.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/assert.ts new file mode 100644 index 000000000000..135619f307b2 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/assert.ts @@ -0,0 +1,23 @@ +import { type Executed, Node, type OutputsOf, type Run } from './node'; +import type { Rows } from './rows'; +import { rowsResult } from './run-statement'; + +type Inputs = { rows: Rows }; + +export class Assert extends Node { + readonly slots = { rows: 'one' } as const; + readonly error: Error; + + constructor(error: Error) { + super(); + this.error = error; + Object.freeze(this); + } + + override execute(inputs: OutputsOf, _run: Run): Executed { + if (inputs.rows.length === 0) { + throw this.error; + } + return rowsResult([]); + } +} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts new file mode 100644 index 000000000000..9e7687c363f9 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts @@ -0,0 +1,80 @@ +import type { Contract } from '@internal/contract/types'; +import type { ReferentialAction, SqlStorage } from '@internal/sql-contract/types'; +import type { TableCoordinate } from '@internal/sql-relational-core/ast'; + +export type WriteKind = 'insert' | 'update' | 'delete'; + +interface Referencing { + readonly table: TableCoordinate; + readonly onDelete: ReferentialAction | undefined; + readonly onUpdate: ReferentialAction | undefined; +} + +const changingActions: ReadonlySet = new Set([ + 'cascade', + 'setNull', + 'setDefault', +]); + +const referencingByContract = new WeakMap< + Contract, + ReadonlyMap +>(); + +export function tableKey(table: TableCoordinate): string { + return JSON.stringify([table.namespaceId, table.name]); +} + +function referencingOf( + contract: Contract, +): ReadonlyMap { + const known = referencingByContract.get(contract); + if (known !== undefined) { + return known; + } + const referencing = new Map(); + for (const [namespaceId, namespace] of Object.entries(contract.storage.namespaces)) { + for (const [name, table] of Object.entries(namespace.entries.table ?? {})) { + for (const foreignKey of table.foreignKeys) { + const key = tableKey({ + namespaceId: foreignKey.target.namespaceId, + name: foreignKey.target.tableName, + }); + referencing.set(key, [ + ...(referencing.get(key) ?? []), + { + table: { namespaceId, name }, + onDelete: foreignKey.onDelete, + onUpdate: foreignKey.onUpdate, + }, + ]); + } + } + } + referencingByContract.set(contract, referencing); + return referencing; +} + +export function changedBy( + contract: Contract, + write: WriteKind, + table: TableCoordinate, +): readonly TableCoordinate[] { + if (write === 'insert') { + return [table]; + } + const referencing = referencingOf(contract); + const changed = new Map([[tableKey(table), table]]); + const pending = [table]; + for (let next = pending.shift(); next !== undefined; next = pending.shift()) { + for (const reference of referencing.get(tableKey(next)) ?? []) { + const action = write === 'delete' ? reference.onDelete : reference.onUpdate; + const key = tableKey(reference.table); + if (changingActions.has(action) && !changed.has(key)) { + changed.set(key, reference.table); + pending.push(reference.table); + } + } + } + return [...changed.values()]; +} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts index 474c9138f69b..2df47111012c 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts @@ -30,13 +30,10 @@ import { type CollectionState, emptyState } from '../types'; import { CrossJoin } from './cross-join'; import { Delete } from './delete'; import type { ColumnPair, NodeId } from './edge'; -import { FilterData } from './filter-data'; import { Find } from './find'; import { Graph } from './graph'; import { Insert } from './insert'; import { InsertChunked } from './insert-chunked'; -import { PayloadData } from './payload-data'; -import { Rows } from './rows'; import { UnionAll } from './union-all'; import { Update } from './update'; @@ -45,30 +42,30 @@ export function updateAllGraph( set: Readonly>, ): Graph { const { context, state } = collection; - const graph = new Graph(); + const graph = new Graph(context.contract); if (Object.keys(set).length === 0) { - graph.setResult(graph.add(new Find(selectRowsAst(collection, state)), { filter: [] })); + graph.setResult(graph.add(new Find(selectRowsAst(collection, state)))); return graph; } requireIdentityColumnsForIncludes(collection); const update = updateAst(context.contract, state.tables, set, whereOf(collection)); - const updated = graph.add(new Update(update), { filter: [] }); + const updated = graph.add(new Update(update)); graph.setResult(addWithIncludes(graph, collection, updated)); return graph; } export function deleteAllGraph(collection: DescribeCollectionRowsOptions): Graph { const { context, state } = collection; - const graph = new Graph(); + const graph = new Graph(context.contract); const del = new Delete(deleteAst(context.contract, state.tables, whereOf(collection))); if (state.includes.length === 0) { - graph.setResult(graph.add(del, { filter: [] })); + graph.setResult(graph.add(del)); return graph; } - graph.setResult(graph.add(new Find(selectRowsAst(collection, state)), { filter: [] })); - graph.add(del, { filter: [] }); + graph.setResult(graph.add(new Find(selectRowsAst(collection, state)))); + graph.add(del); return graph; } @@ -77,7 +74,7 @@ export function updateFirstGraph( set: Readonly>, ): Graph { const { context, state } = collection; - const graph = new Graph(); + const graph = new Graph(context.contract); const nothingToSet = Object.keys(set).length === 0; if (!nothingToSet) { requireIdentityColumns(collection); @@ -97,7 +94,8 @@ export function updateFirstGraph( } const update = updateAst(context.contract, state.tables, set, undefined); - const updated = graph.add(new Update(update), { filter: [sameRow(find, collection)] }); + const updated = graph.add(new Update(update)); + graph.addFilterDataEdge(find, updated, sameRowColumns(collection)); graph.setResult(addWithIncludes(graph, collection, updated)); return graph; } @@ -105,7 +103,7 @@ export function updateFirstGraph( export function deleteFirstGraph(collection: DescribeCollectionRowsOptions): Graph { const { context, state } = collection; const withIncludes = state.includes.length > 0; - const graph = new Graph(); + const graph = new Graph(context.contract); requireIdentityColumns(collection); const find = addFindOfFirstRow( graph, @@ -117,7 +115,8 @@ export function deleteFirstGraph(collection: DescribeCollectionRowsOptions): Gra } const del = deleteAst(context.contract, state.tables, undefined); - const deleted = graph.add(new Delete(del), { filter: [sameRow(find, collection)] }); + const deleted = graph.add(new Delete(del)); + graph.addFilterDataEdge(find, deleted, sameRowColumns(collection)); graph.setResult(withIncludes ? find : deleted); return graph; } @@ -128,7 +127,7 @@ export function createAllGraph( conflict: InsertConflictSkip | undefined, ): Graph { const { context, state } = collection; - const graph = new Graph(); + const graph = new Graph(context.contract); if (rows.length === 0) { return graph; } @@ -136,14 +135,13 @@ export function createAllGraph( const { contract } = context; const { root } = state.tables; const inserted = supportsDefaultInInsert(contract) - ? graph.add(new Insert(insertAst(contract, root, rows, conflict)), { payload: [] }) + ? graph.add(new Insert(insertAst(contract, root, rows, conflict))) : graph.add( new InsertChunked( groupRowsByColumnSignature(rows).map((group) => insertAst(contract, root, group, conflict), ), ), - {}, ); graph.setResult(addWithIncludes(graph, collection, inserted)); return graph; @@ -167,7 +165,7 @@ export function createAllVariantGraph( const { context, modelName, state } = collection; const { contract } = context; const { root } = state.tables; - const graph = new Graph(); + const graph = new Graph(contract); const keyColumns = resolvePrimaryKeyColumns( contract, root.storage.namespaceId, @@ -184,17 +182,16 @@ export function createAllVariantGraph( const joins = rows.map((row) => { const baseInsert = insertAst(contract, root, [row.base], undefined).withReturning(baseColumns); - const base = graph.add(new Insert(baseInsert), { payload: [] }); + const base = graph.add(new Insert(baseInsert)); const variantInsert = insertAst(contract, variantTable, [row.variant], undefined).withReturning( variantColumns, ); - const variant = graph.add(new Insert(variantInsert), { - payload: [new PayloadData(base, keyPairs)], - }); - return graph.add(new CrossJoin(), { - left: new Rows(base, sameColumns(baseColumns)), - right: new Rows(variant, sameColumns(variantColumns)), - }); + const variant = graph.add(new Insert(variantInsert)); + graph.addPayloadDataEdge(base, variant, keyPairs); + const join = graph.add(new CrossJoin()); + graph.addRowsEdge(base, join, 'left', sameColumns(baseColumns)); + graph.addRowsEdge(variant, join, 'right', sameColumns(variantColumns)); + return join; }); const [only, ...others] = joins; @@ -202,9 +199,11 @@ export function createAllVariantGraph( graph.setResult(only); } else if (only !== undefined) { const joined = sameColumns([...baseColumns, ...variantColumns]); - graph.setResult( - graph.add(new UnionAll(), { rows: joins.map((join) => new Rows(join, joined)) }), - ); + const union = graph.add(new UnionAll()); + for (const join of joins) { + graph.addRowsEdge(join, union, 'rows', joined); + } + graph.setResult(union); } return graph; } @@ -221,7 +220,7 @@ export function upsertGraph( ): Graph { const { context, state } = collection; const { root } = state.tables; - const graph = new Graph(); + const graph = new Graph(context.contract); requireIdentityColumnsForIncludes(collection); const updateSet = Object.keys(update).length > 0 @@ -230,7 +229,7 @@ export function upsertGraph( conflictColumns.slice(0, 1).map((column) => [column, ColumnRef.of('excluded', column)]), ); const upsert = upsertAst(context.contract, root, create, conflictColumns, updateSet); - const upserted = graph.add(new Insert(upsert), { payload: [] }); + const upserted = graph.add(new Insert(upsert)); graph.setResult(addWithIncludes(graph, collection, upserted)); return graph; } @@ -249,7 +248,9 @@ function addWithIncludes( selectedFields: state.selectedFields, includes: state.includes, }); - return graph.add(new Find(select), { filter: [sameRow(written, collection)] }); + const find = graph.add(new Find(select)); + graph.addFilterDataEdge(written, find, sameRowColumns(collection)); + return find; } function requireIdentityColumnsForIncludes(collection: DescribeCollectionRowsOptions): void { @@ -274,7 +275,7 @@ function addFindOfFirstRow( if (state.limit === 0) { return undefined; } - return graph.add(new Find(select(collection, { ...state, limit: 1 })), { filter: [] }); + return graph.add(new Find(select(collection, { ...state, limit: 1 }))); } function requireIdentityColumns(collection: DescribeCollectionRowsOptions): void { @@ -321,16 +322,10 @@ function identityColumnsOf(collection: DescribeCollectionRowsOptions): readonly return resolveRowIdentityColumns(collection.context.contract, namespaceId, tableName); } -function sameRow(find: NodeId, collection: DescribeCollectionRowsOptions): FilterData { +function sameRowColumns(collection: DescribeCollectionRowsOptions): ColumnPair[] { const { context, state } = collection; - const columns = projectTableColumns( - context.contract, - state.tables.root, - identityColumnsOf(collection), - ); - return new FilterData( - find, - columns.map((column) => [column, column]), + return sameColumns( + projectTableColumns(context.contract, state.tables.root, identityColumnsOf(collection)), ); } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/cross-join.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/cross-join.ts index 9aef6db860ea..1c7314a9798b 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/cross-join.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/cross-join.ts @@ -5,6 +5,8 @@ import { rowsResult } from './run-statement'; type Inputs = { left: Rows; right: Rows }; export class CrossJoin extends Node { + readonly slots = { left: 'one', right: 'one' } as const; + constructor() { super(); Object.freeze(this); diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts index bd3efec71fd8..4461fb45ad2a 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts @@ -1,9 +1,10 @@ import type { DeleteAst, ProjectionItem } from '@internal/sql-relational-core/ast'; -import { executeFiltered, type Filtered, withColumns } from './filtered-statement'; -import type { Executed, OutputsOf, Run } from './node'; +import { executeFiltered, type Filtered, filteredSlots, withColumns } from './filtered-statement'; +import type { Executed, OutputsOf, Run, StatementAst } from './node'; import { StatementNode } from './statement-node'; export class Delete extends StatementNode { + readonly slots = filteredSlots; readonly ast: DeleteAst; constructor(ast: DeleteAst) { @@ -12,6 +13,10 @@ export class Delete extends StatementNode { Object.freeze(this); } + get statements(): readonly StatementAst[] { + return [this.ast]; + } + get returns(): readonly ProjectionItem[] { return this.ast.returning ?? []; } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/edge.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/edge.ts index 671fa4855aa2..5670237aaa80 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/edge.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/edge.ts @@ -1,7 +1,11 @@ import type { Brand } from '@internal/contract/types'; import type { ProjectionItem } from '@internal/sql-relational-core/ast'; +import type { Slots } from './node'; -export type NodeId = number & Brand<'MutationGraphNodeId'>; +declare const inputsOfNode: unique symbol; + +export type NodeId = number & + Brand<'MutationGraphNodeId'> & { readonly [inputsOfNode]?: Inputs }; export type StorageRow = Record; @@ -9,12 +13,19 @@ export type ColumnPair = readonly [source: ProjectionItem, target: ProjectionIte export abstract class Edge { readonly from: NodeId; - readonly columns: readonly ColumnPair[]; - constructor(from: NodeId, columns: readonly ColumnPair[]) { + constructor(from: NodeId) { this.from = from; - this.columns = Object.freeze([...columns]); } abstract output(sourceRow: StorageRow): Output; } + +export abstract class DataEdge extends Edge { + readonly columns: readonly ColumnPair[]; + + constructor(from: NodeId, columns: readonly ColumnPair[]) { + super(from); + this.columns = Object.freeze([...columns]); + } +} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/filter-data.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/filter-data.ts index 6e75a7352e51..e73371a50172 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/filter-data.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/filter-data.ts @@ -5,9 +5,9 @@ import { ParamRef, } from '@internal/sql-relational-core/ast'; import { ifDefined } from '@internal/utils/defined'; -import { type ColumnPair, Edge, type NodeId, type StorageRow } from './edge'; +import { type ColumnPair, DataEdge, type NodeId, type StorageRow } from './edge'; -export class FilterData extends Edge { +export class FilterData extends DataEdge { constructor(from: NodeId, columns: readonly ColumnPair[]) { super(from, columns); Object.freeze(this); diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/filtered-statement.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/filtered-statement.ts index 5f2fca5d6f1c..a69ebca14d53 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/filtered-statement.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/filtered-statement.ts @@ -10,8 +10,11 @@ import { combineWhereExprs } from '../where-utils'; import type { FilterData } from './filter-data'; import type { Executed, OutputsOf, Run } from './node'; import { executeStatement, rowsResult } from './run-statement'; +import type { StateEdge } from './state'; -export type Filtered = { filter: FilterData[] }; +export type Filtered = { filter: FilterData[]; state: StateEdge[] }; + +export const filteredSlots = { filter: 'list', state: 'list' } as const; export function executeFiltered( ast: SelectAst | UpdateAst | DeleteAst, diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/find.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/find.ts index 0c296eff1b29..c0ff48a43523 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/find.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/find.ts @@ -1,9 +1,10 @@ import type { ProjectionItem, SelectAst } from '@internal/sql-relational-core/ast'; -import { executeFiltered, type Filtered, withColumns } from './filtered-statement'; -import type { Executed, OutputsOf, Run } from './node'; +import { executeFiltered, type Filtered, filteredSlots, withColumns } from './filtered-statement'; +import type { Executed, OutputsOf, Run, StatementAst } from './node'; import { StatementNode } from './statement-node'; export class Find extends StatementNode { + readonly slots = filteredSlots; readonly ast: SelectAst; constructor(ast: SelectAst) { @@ -12,6 +13,10 @@ export class Find extends StatementNode { Object.freeze(this); } + get statements(): readonly StatementAst[] { + return [this.ast]; + } + get returns(): readonly ProjectionItem[] { return this.ast.projection; } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/graph.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/graph.ts index f801314926a5..b11f3f717ca9 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/graph.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/graph.ts @@ -1,6 +1,19 @@ +import type { Contract } from '@internal/contract/types'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { + collectTables, + type ProjectionItem, + type TableCoordinate, + type TableSource, +} from '@internal/sql-relational-core/ast'; import { blindCast } from '@internal/utils/casts'; -import type { Edge, NodeId } from './edge'; -import type { Node, Slots } from './node'; +import { changedBy, tableKey, type WriteKind } from './changed-by'; +import type { ColumnPair, Edge, NodeId } from './edge'; +import { FilterData } from './filter-data'; +import type { Node, Slot, Slots, StatementAst } from './node'; +import { PayloadData } from './payload-data'; +import { Rows } from './rows'; +import { State, StateEdge } from './state'; import { StatementNode } from './statement-node'; export interface Reader { @@ -9,12 +22,19 @@ export interface Reader { } export class Graph { + readonly #contract: Contract; #ids: NodeId[] = []; #nodes: (Node | undefined)[] = []; - #inputs: Slots[] = []; + #inputs: Record[] = []; #readers: Reader[][] = []; + #columnsRead: ProjectionItem[][] = []; + #states = new Map(); #result: NodeId | undefined = undefined; + constructor(contract: Contract) { + this.#contract = contract; + } + get result(): NodeId | undefined { return this.#result; } @@ -23,28 +43,68 @@ export class Graph { this.#result = node; } - add(node: Node, inputs: Inputs): NodeId { - const id = blindCast(this.#nodes.length); - this.#ids.push(id); - this.#nodes.push(node); - this.#inputs.push(inputs); - this.#readers.push([]); - for (const edge of Object.values(inputs).flat()) { - this.#readers[edge.from]?.push({ reader: id, edge }); - const source = this.#nodes[edge.from]; - if (source instanceof StatementNode) { - this.#nodes[edge.from] = source.withReturning(edge.columns.map(([column]) => column)); - } + add(node: Node): NodeId { + if (!(node instanceof StatementNode)) { + return this.#append(node); + } + const stateEdges = tablesReadBy(node.statements).map( + (table) => new StateEdge(this.#stateOf(table), table), + ); + const id = this.#append(node); + for (const edge of stateEdges) { + this.#connect(id, 'state', edge); + } + for (const table of this.#tablesWrittenBy(node.statements)) { + const state = this.#append(new State(table)); + this.#connect(state, 'write', new StateEdge(id, table)); + this.#states.set(tableKey(table), state); } return id; } + addFilterDataEdge( + from: NodeId, + to: NodeId<{ filter: FilterData[] }>, + columns: readonly ColumnPair[], + ): void { + this.#read(from, columns); + this.#connect(to, 'filter', new FilterData(from, columns)); + } + + addPayloadDataEdge( + from: NodeId, + to: NodeId<{ payload: PayloadData[] }>, + columns: readonly ColumnPair[], + ): void { + this.#read(from, columns); + this.#connect(to, 'payload', new PayloadData(from, columns)); + } + + addRowsEdge( + from: NodeId, + to: NodeId<{ [Slot in Name]: Rows | Rows[] }>, + slot: Name, + columns: readonly ColumnPair[], + ): void { + this.#read(from, columns); + this.#connect(to, slot, new Rows(from, columns)); + } + + sameState(a: NodeId, b: NodeId, table: TableCoordinate): boolean { + const stateA = this.#stateEdgeOf(a, table); + return stateA !== undefined && stateA.from === this.#stateEdgeOf(b, table)?.from; + } + + columnsReadFrom(id: NodeId): readonly ProjectionItem[] { + return this.#columnsRead[id] ?? []; + } + replace(id: NodeId, next: Node): void { this.#nodes[id] = next; } remove(id: NodeId): void { - for (const edge of Object.values(this.inputsOf(id)).flat()) { + for (const edge of edgesIn(this.inputsOf(id))) { this.#readers[edge.from] = this.readersOf(edge.from).filter((entry) => entry.edge !== edge); } for (const { reader, edge } of this.readersOf(id)) { @@ -74,4 +134,100 @@ export class Graph { readersOf(id: NodeId): readonly Reader[] { return this.#readers[id] ?? []; } + + #append(node: Node): NodeId { + const id = blindCast, 'the position a node is added at is its id'>( + this.#nodes.length, + ); + this.#ids.push(id); + this.#nodes.push(node); + this.#inputs.push( + Object.fromEntries( + Object.entries(node.slots) + .filter(([, kind]) => kind === 'list') + .map(([name]) => [name, []]), + ), + ); + this.#readers.push([]); + this.#columnsRead.push([]); + return id; + } + + #connect(to: NodeId, slot: string, edge: Edge): void { + const current = this.inputsOf(to)[slot]; + this.#inputs[to] = { + ...this.inputsOf(to), + [slot]: Array.isArray(current) ? [...current, edge] : edge, + }; + this.#readers[edge.from]?.push({ reader: to, edge }); + } + + #read(from: NodeId, columns: readonly ColumnPair[]): void { + const read = columns.map(([source]) => source); + this.#columnsRead[from]?.push(...read); + const source = this.#nodes[from]; + if (source instanceof StatementNode) { + this.#nodes[from] = source.withReturning(read); + } + } + + #stateOf(table: TableCoordinate): NodeId { + const current = this.#states.get(tableKey(table)); + if (current !== undefined) { + return current; + } + const initial = this.#append(new State(table)); + this.#states.set(tableKey(table), initial); + return initial; + } + + #stateEdgeOf(id: NodeId, table: TableCoordinate): StateEdge | undefined { + const key = tableKey(table); + return edgesIn(this.inputsOf(id)).find( + (edge): edge is StateEdge => edge instanceof StateEdge && tableKey(edge.table) === key, + ); + } + + #tablesWrittenBy(statements: readonly StatementAst[]): readonly TableCoordinate[] { + return uniqueTables( + statements.flatMap((ast) => { + const write = writeOf(ast); + return write === undefined + ? [] + : changedBy(this.#contract, write.kind, { + namespaceId: write.target.namespaceId, + name: write.target.name, + }); + }), + ); + } +} + +function edgesIn(slots: Slots): readonly Edge[] { + return Object.values(slots).flat(); +} + +function tablesReadBy(statements: readonly StatementAst[]): readonly TableCoordinate[] { + return uniqueTables(statements.flatMap((ast) => collectTables(ast))); +} + +function uniqueTables(tables: readonly TableCoordinate[]): readonly TableCoordinate[] { + return [...new Map(tables.map((table) => [tableKey(table), table])).values()]; +} + +function writeOf( + ast: StatementAst, +): { readonly kind: WriteKind; readonly target: TableSource } | undefined { + switch (ast.kind) { + case 'insert': + return { + kind: ast.onConflict?.action.kind === 'do-update-set' ? 'update' : 'insert', + target: ast.table, + }; + case 'update': + case 'delete': + return { kind: ast.kind, target: ast.table }; + case 'select': + return undefined; + } } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert-chunked.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert-chunked.ts index 31a5c0cf12d2..549f3bd9ee05 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert-chunked.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert-chunked.ts @@ -2,12 +2,14 @@ import { AsyncIterableResult } from '@internal/framework-components/runtime'; import type { InsertAst, ProjectionItem } from '@internal/sql-relational-core/ast'; import type { StorageRow } from './edge'; import { withColumns } from './filtered-statement'; -import type { Executed, OutputsOf, Run } from './node'; +import type { Executed, OutputsOf, Run, StatementAst } from './node'; +import type { StateEdge } from './state'; import { StatementNode } from './statement-node'; -type NoInputs = Record; +type StateOnly = { state: StateEdge[] }; -export class InsertChunked extends StatementNode { +export class InsertChunked extends StatementNode { + readonly slots = { state: 'list' } as const; readonly asts: readonly InsertAst[]; constructor(asts: readonly InsertAst[]) { @@ -16,11 +18,15 @@ export class InsertChunked extends StatementNode { Object.freeze(this); } + get statements(): readonly StatementAst[] { + return this.asts; + } + get returns(): readonly ProjectionItem[] { return this.asts[0]?.returning ?? []; } - override execute(_inputs: OutputsOf, run: Run): Executed { + override execute(_inputs: OutputsOf, run: Run): Executed { if (this.returns.length === 0) { return this.#sumOfCounts(run); } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts index 01c053f94e3a..81cfe9c87b4c 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts @@ -1,13 +1,15 @@ import type { InsertAst, ProjectionItem } from '@internal/sql-relational-core/ast'; import { withColumns } from './filtered-statement'; -import type { Executed, OutputsOf, Run } from './node'; +import type { Executed, OutputsOf, Run, StatementAst } from './node'; import type { PayloadData } from './payload-data'; import { executeStatement, rowsResult } from './run-statement'; +import type { StateEdge } from './state'; import { StatementNode } from './statement-node'; -type Payloads = { payload: PayloadData[] }; +type Payloads = { payload: PayloadData[]; state: StateEdge[] }; export class Insert extends StatementNode { + readonly slots = { payload: 'list', state: 'list' } as const; readonly ast: InsertAst; constructor(ast: InsertAst) { @@ -16,6 +18,10 @@ export class Insert extends StatementNode { Object.freeze(this); } + get statements(): readonly StatementAst[] { + return [this.ast]; + } + get returns(): readonly ProjectionItem[] { return this.ast.returning ?? []; } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/node.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/node.ts index 7b184771a858..473c7aecf078 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/node.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/node.ts @@ -25,6 +25,16 @@ export interface Run { execute(ast: StatementAst): Promise; } +export type SlotLayout = { + readonly [Name in keyof Inputs]: Inputs[Name] extends Edge[] + ? 'list' + : Inputs[Name] extends Edge + ? 'one' + : 'list' | 'one'; +}; + export abstract class Node { + abstract readonly slots: SlotLayout; + abstract execute(inputs: OutputsOf, run: Run): Executed; } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/payload-data.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/payload-data.ts index 73a9022e3238..2560e898279c 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/payload-data.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/payload-data.ts @@ -1,10 +1,10 @@ import { ParamRef } from '@internal/sql-relational-core/ast'; import { ifDefined } from '@internal/utils/defined'; -import { type ColumnPair, Edge, type NodeId, type StorageRow } from './edge'; +import { type ColumnPair, DataEdge, type NodeId, type StorageRow } from './edge'; export type Payload = Readonly>; -export class PayloadData extends Edge { +export class PayloadData extends DataEdge { constructor(from: NodeId, columns: readonly ColumnPair[]) { super(from, columns); Object.freeze(this); diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/rows.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/rows.ts index 5f97a7390740..2494d6fcab38 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/rows.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/rows.ts @@ -1,6 +1,6 @@ -import { type ColumnPair, Edge, type NodeId, type StorageRow } from './edge'; +import { type ColumnPair, DataEdge, type NodeId, type StorageRow } from './edge'; -export class Rows extends Edge { +export class Rows extends DataEdge { constructor(from: NodeId, columns: readonly ColumnPair[]) { super(from, columns); Object.freeze(this); diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts index a2e83ad9f165..08c50abc2b0a 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts @@ -14,7 +14,7 @@ import { queryPlanRows } from '../query-plan-rows'; import type { CollectionContext } from '../types'; import type { Edge, NodeId, StorageRow } from './edge'; import type { Graph } from './graph'; -import type { Executed, OutputsOf, Run, Slots, StatementAst } from './node'; +import type { Executed, Node, OutputsOf, Run, Slots, StatementAst } from './node'; import { StatementNode } from './statement-node'; type Context = CollectionContext>; @@ -31,11 +31,11 @@ export function queryGraph( callerRows: DescribeCollectionRowsOptions, ): AsyncIterableResult { returnCallerColumns(graph, callerRows); - const [only, ...others] = graph.nodes(); - // A graph with one node is one statement. It runs without a transaction and its rows are - // handed to the caller as they arrive. A graph with several nodes runs inside the mutation - // scope, whose transaction ends when its callback returns, so its result is collected there - // and yielded afterwards. + const [only, ...others] = statementNodes(graph); + // A graph with one statement node is one statement. It runs without a transaction and its rows + // are handed to the caller as they arrive. A graph with several statement nodes runs inside the + // mutation scope, whose transaction ends when its callback returns, so its result is collected + // there and yielded afterwards. if (only !== undefined && others.length === 0 && only[0] === graph.result) { const [id, node] = only; const run = runOn(ctx, ctx.runtime); @@ -74,7 +74,7 @@ function runAllNodes( ctx: Context, callerRows: DescribeCollectionRowsOptions | undefined, ): Promise> { - if (graph.nodes().length > 1) { + if (statementNodes(graph).length > 1) { return withMutationScope(ctx.runtime, (scope) => runNodesInOrder(graph, ctx, scope, callerRows), ); @@ -145,8 +145,8 @@ function shapeRows( const selected = state.selectedFields ?? getColumnsReadOnTable(contract, namespaceId, modelName, tableName); const hiddenColumns = graph - .readersOf(id) - .flatMap(({ edge }) => edge.columns.map(([source]) => source.alias)) + .columnsReadFrom(id) + .map((column) => column.alias) .filter((column) => !selected.includes(column)); return mapResultRows(consumeCollectionRows(callerRows, storageRows), (row) => { @@ -155,6 +155,10 @@ function shapeRows( }); } +function statementNodes(graph: Graph): readonly (readonly [NodeId, Node])[] { + return graph.nodes().filter(([, node]) => node instanceof StatementNode); +} + function rowsOf(executed: readonly StorageRow[] | number | undefined): readonly StorageRow[] { return typeof executed === 'object' ? executed : []; } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/state.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/state.ts new file mode 100644 index 000000000000..e2130a01dca8 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/state.ts @@ -0,0 +1,37 @@ +import type { TableCoordinate } from '@internal/sql-relational-core/ast'; +import { Edge, type NodeId } from './edge'; +import { type Executed, Node, type OutputsOf, type Run } from './node'; +import { rowsResult } from './run-statement'; + +export class StateEdge extends Edge { + readonly table: TableCoordinate; + + constructor(from: NodeId, table: TableCoordinate) { + super(from); + this.table = table; + Object.freeze(this); + } + + override output(): undefined { + return undefined; + } +} + +export type StateSlot = { state: StateEdge[] }; + +type Inputs = { write: StateEdge[] }; + +export class State extends Node { + readonly slots = { write: 'list' } as const; + readonly table: TableCoordinate; + + constructor(table: TableCoordinate) { + super(); + this.table = table; + Object.freeze(this); + } + + override execute(_inputs: OutputsOf, _run: Run): Executed { + return rowsResult([]); + } +} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/statement-node.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/statement-node.ts index 07e67842f8be..5394fe54cafe 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/statement-node.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/statement-node.ts @@ -1,8 +1,13 @@ import type { ProjectionItem } from '@internal/sql-relational-core/ast'; -import { Node, type Slots } from './node'; +import { Node, type Slots, type StatementAst } from './node'; +import type { StateSlot } from './state'; -export abstract class StatementNode extends Node { +export abstract class StatementNode< + Inputs extends Slots & StateSlot = Slots & StateSlot, +> extends Node { abstract readonly returns: readonly ProjectionItem[]; + abstract readonly statements: readonly StatementAst[]; + abstract withReturning(columns: readonly ProjectionItem[]): StatementNode; } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/union-all.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/union-all.ts index eba6932e992c..42a8c00327b4 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/union-all.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/union-all.ts @@ -5,6 +5,8 @@ import { rowsResult } from './run-statement'; type Inputs = { rows: Rows[] }; export class UnionAll extends Node { + readonly slots = { rows: 'list' } as const; + constructor() { super(); Object.freeze(this); diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts index 0e0d2c111d60..358c8e0bdbfe 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts @@ -1,9 +1,10 @@ import type { ProjectionItem, UpdateAst } from '@internal/sql-relational-core/ast'; -import { executeFiltered, type Filtered, withColumns } from './filtered-statement'; -import type { Executed, OutputsOf, Run } from './node'; +import { executeFiltered, type Filtered, filteredSlots, withColumns } from './filtered-statement'; +import type { Executed, OutputsOf, Run, StatementAst } from './node'; import { StatementNode } from './statement-node'; export class Update extends StatementNode { + readonly slots = filteredSlots; readonly ast: UpdateAst; constructor(ast: UpdateAst) { @@ -12,6 +13,10 @@ export class Update extends StatementNode { Object.freeze(this); } + get statements(): readonly StatementAst[] { + return [this.ast]; + } + get returns(): readonly ProjectionItem[] { return this.ast.returning ?? []; } diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/assert.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/assert.test.ts new file mode 100644 index 000000000000..3c29d614b73e --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/assert.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { Assert } from '../../src/mutation-graph/assert'; +import { recordingRun } from './statements'; + +const missing = new Error('the related row does not exist'); + +describe('Assert', () => { + it('is frozen', () => { + expect(Object.isFrozen(new Assert(missing))).toBe(true); + }); + + it('throws the error it was made with when its input has no row', () => { + expect(() => new Assert(missing).execute({ rows: [] }, recordingRun())).toThrow(missing); + }); + + it('gives no row and runs no statement when its input has a row', async () => { + const run = recordingRun(); + + expect(await new Assert(missing).execute({ rows: [{ id: 7 }] }, run)).toEqual([]); + expect(run).toMatchObject({ queried: [], executed: [] }); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/cascade-contract.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/cascade-contract.ts new file mode 100644 index 000000000000..558d75d13ff7 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/cascade-contract.ts @@ -0,0 +1,60 @@ +import { int4Column } from '@internal/adapter-postgres/column-types'; +import { defineContract, field, model, rel } from '../contract-builder'; + +const Org = model('Org', { fields: { id: field.column(int4Column).id() } }); + +const Team = model('Team', { + fields: { id: field.column(int4Column).id(), orgId: field.column(int4Column) }, + relations: { + org: rel + .belongsTo(Org, { from: 'orgId', to: 'id' }) + .sql({ fk: { onDelete: 'cascade', onUpdate: 'cascade' } }), + }, +}); + +const Member = model('Member', { + fields: { id: field.column(int4Column).id(), teamId: field.column(int4Column) }, + relations: { + team: rel + .belongsTo(Team, { from: 'teamId', to: 'id' }) + .sql({ fk: { onDelete: 'cascade', onUpdate: 'restrict' } }), + }, +}); + +const Badge = model('Badge', { + fields: { id: field.column(int4Column).id(), teamId: field.column(int4Column).optional() }, + relations: { + team: rel + .belongsTo(Team, { from: 'teamId', to: 'id' }) + .sql({ fk: { onDelete: 'setNull', onUpdate: 'setDefault' } }), + }, +}); + +const Note = model('Note', { + fields: { id: field.column(int4Column).id(), memberId: field.column(int4Column) }, + relations: { + member: rel + .belongsTo(Member, { from: 'memberId', to: 'id' }) + .sql({ fk: { onDelete: 'restrict', onUpdate: 'noAction' } }), + }, +}); + +const Left = model('Left', { + fields: { id: field.column(int4Column).id(), rightId: field.column(int4Column) }, + relations: { + right: rel + .belongsTo('Right', { from: 'rightId', to: 'id' }) + .sql({ fk: { onDelete: 'cascade' } }), + }, +}); + +const Right = model('Right', { + fields: { id: field.column(int4Column).id(), leftId: field.column(int4Column) }, + relations: { + left: rel.belongsTo(Left, { from: 'leftId', to: 'id' }).sql({ fk: { onDelete: 'cascade' } }), + }, +}); + +export const cascadeContract = defineContract({ + models: { Org, Team, Member, Badge, Note, Left, Right }, +}); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/changed-by.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/changed-by.test.ts new file mode 100644 index 000000000000..08589ad1923e --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/changed-by.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { changedBy } from '../../src/mutation-graph/changed-by'; +import { cascadeContract as contract } from './cascade-contract'; + +function names(write: 'insert' | 'update' | 'delete', table: string): string[] { + return changedBy(contract, write, { namespaceId: 'public', name: table }).map( + (changed) => `${changed.namespaceId}.${changed.name}`, + ); +} + +describe('changedBy', () => { + it('gives only the table itself for an insert', () => { + expect(names('insert', 'Org')).toEqual(['public.Org']); + }); + + it('follows onDelete cascade, set null and set default over two hops, and stops at restrict and no action', () => { + expect(names('delete', 'Org')).toEqual([ + 'public.Org', + 'public.Team', + 'public.Member', + 'public.Badge', + ]); + }); + + it('follows onUpdate actions, not onDelete ones', () => { + expect(names('update', 'Org')).toEqual(['public.Org', 'public.Team', 'public.Badge']); + }); + + it('gives each table of a cycle once', () => { + expect(names('delete', 'Left')).toEqual(['public.Left', 'public.Right']); + }); + + it('gives the table itself when no foreign key points at it', () => { + expect(names('delete', 'Note')).toEqual(['public.Note']); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts index cc3b3712fcc5..2bb17adcd466 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts @@ -36,7 +36,7 @@ describe.each([ const node = make(); const run = recordingRun([{ id: 1 }]); - const rows = await node.execute({ filter: null }, run); + const rows = await node.execute({ filter: null, state: null }, run); expect(rows).toEqual([{ id: 1 }]); expect(run.queried).toEqual([node.ast]); @@ -45,7 +45,7 @@ describe.each([ it('adds the conditions of one edge to its where, or between the rows of the edge', async () => { const run = recordingRun(); - await make().execute({ filter: [[idIs(7), idIs(8)]] }, run); + await make().execute({ filter: [[idIs(7), idIs(8)]], state: null }, run); expect(run.queried.map(whereOf)).toEqual(["(name = 'Ada' and (id = 7 or id = 8))"]); }); @@ -53,7 +53,7 @@ describe.each([ it('puts and between the edges', async () => { const run = recordingRun(); - await make().execute({ filter: [[idIs(7)], [idIs(8), idIs(9)]] }, run); + await make().execute({ filter: [[idIs(7)], [idIs(8), idIs(9)]], state: null }, run); expect(run.queried.map(whereOf)).toEqual(["(name = 'Ada' and id = 7 and (id = 8 or id = 9))"]); }); @@ -61,7 +61,7 @@ describe.each([ it('returns no rows and runs nothing when an edge has no source row', async () => { const run = recordingRun([{ id: 1 }]); - const rows = await make().execute({ filter: [[idIs(7)], []] }, run); + const rows = await make().execute({ filter: [[idIs(7)], []], state: null }, run); expect(rows).toEqual([]); expect(run.queried).toEqual([]); @@ -87,7 +87,7 @@ describe.each([ const node = make(); const run = recordingRun([], 3); - expect(await node.execute({ filter: null }, run)).toBe(3); + expect(await node.execute({ filter: null, state: null }, run)).toBe(3); expect(run.executed).toEqual([node.ast]); expect(run.queried).toEqual([]); }); @@ -95,7 +95,9 @@ describe.each([ it('returns columns once it is asked to', async () => { const run = recordingRun([{ email: 'a@b.c' }]); - const rows = await make().withReturning(emailColumn).execute({ filter: null }, run); + const rows = await make() + .withReturning(emailColumn) + .execute({ filter: null, state: null }, run); expect(rows).toEqual([{ email: 'a@b.c' }]); expect(run.executed).toEqual([]); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/graph-edges.test-d.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/graph-edges.test-d.ts new file mode 100644 index 000000000000..843cb6bde32b --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/graph-edges.test-d.ts @@ -0,0 +1,31 @@ +import { describe, it } from 'vitest'; +import { CrossJoin } from '../../src/mutation-graph/cross-join'; +import { Insert } from '../../src/mutation-graph/insert'; +import { UnionAll } from '../../src/mutation-graph/union-all'; +import { columnPairs, findUsers, insertInto, newGraph } from './statements'; + +const sameId = columnPairs('users', 'users', [['id', 'id']]); + +describe('graph edge methods', () => { + it('take only a target that has the slot the edge goes into', () => { + const graph = newGraph(); + const find = graph.add(findUsers()); + const insert = graph.add(new Insert(insertInto('users', [{ name: 'Ada' }]))); + const join = graph.add(new CrossJoin()); + const union = graph.add(new UnionAll()); + + graph.addFilterDataEdge(find, find, sameId); + graph.addPayloadDataEdge(find, insert, sameId); + graph.addRowsEdge(find, join, 'left', sameId); + graph.addRowsEdge(find, union, 'rows', sameId); + + // @ts-expect-error an Insert has no filter slot + graph.addFilterDataEdge(find, insert, sameId); + // @ts-expect-error a Find has no payload slot + graph.addPayloadDataEdge(find, find, sameId); + // @ts-expect-error a CrossJoin has no rows slot + graph.addRowsEdge(find, join, 'rows', sameId); + // @ts-expect-error a Find has no slot for Rows edges + graph.addRowsEdge(find, find, 'filter', sameId); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/graph-state.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/graph-state.test.ts new file mode 100644 index 000000000000..255d9e04749b --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/graph-state.test.ts @@ -0,0 +1,200 @@ +import { + ColumnRef, + DeleteAst, + ProjectionItem, + SelectAst, + TableSource, +} from '@internal/sql-relational-core/ast'; +import { describe, expect, it } from 'vitest'; +import { Delete } from '../../src/mutation-graph/delete'; +import { Find } from '../../src/mutation-graph/find'; +import { Graph } from '../../src/mutation-graph/graph'; +import { Insert } from '../../src/mutation-graph/insert'; +import { State, StateEdge } from '../../src/mutation-graph/state'; +import { UnionAll } from '../../src/mutation-graph/union-all'; +import { createCollectionFor } from '../collection-fixtures'; +import { cascadeContract } from './cascade-contract'; +import { printGraph } from './print-graph'; +import { + addFiltered, + columnPairs, + findUsers, + findUsersWith, + insertInto, + newGraph, + recordingRun, + updateUsers, +} from './statements'; + +const users = { namespaceId: 'public', name: 'users' }; +const posts = { namespaceId: 'public', name: 'posts' }; +const sameId = columnPairs('users', 'users', [['id', 'id']]); +const withState = { state: true }; + +function cascadeTable(name: string): TableSource { + return TableSource.named(name, undefined, 'public'); +} + +function findIn(name: string): Find { + return new Find( + SelectAst.from(cascadeTable(name)).withProjection([ + ProjectionItem.of('id', ColumnRef.of(name, 'id')), + ]), + ); +} + +describe('table state in the graph', () => { + it('adds the first State of a table in front of the first node that reads it', () => { + const graph = newGraph(); + graph.setResult(graph.add(findUsers())); + + expect(printGraph(graph, withState)).toBe( + ['n1 State users', 'n2 Find users <- state n1', 'result: n2'].join('\n'), + ); + }); + + it('gives a node one state edge for each table its statement reads', () => { + const { includes, tables } = createCollectionFor('User').collection.include('posts').state; + const graph = newGraph(); + graph.add(findUsersWith({ includes, tables })); + + expect(printGraph(graph, withState)).toBe( + [ + 'n1 State users', + 'n2 State posts', + 'n3 Find users <- state n1, state n2', + 'result: none', + ].join('\n'), + ); + }); + + it('adds a new State of the table a write changes, which later readers read', () => { + const graph = newGraph(); + const find = graph.add(findUsers()); + addFiltered(graph, updateUsers({ name: 'Ada' }), [find, sameId]); + graph.add(findUsers()); + + expect(printGraph(graph, withState)).toBe( + [ + 'n1 State users', + 'n2 Find users <- state n1', + "n3 Update users set name = 'Ada' <- FilterData n2 (id->id), state n1", + 'n4 State users <- state n3', + 'n5 Find users <- state n4', + 'result: none', + ].join('\n'), + ); + }); + + it('adds a State only for the target of an insert', () => { + const graph = newGraph(); + graph.add(new Insert(insertInto('posts', [{ title: 'Hello' }]))); + + expect(printGraph(graph, withState)).toBe( + [ + 'n1 State posts', + 'n2 Insert posts <- state n1', + 'n3 State posts <- state n2', + 'result: none', + ].join('\n'), + ); + }); + + it('adds a State for every table a delete changes through its foreign keys', () => { + const graph = new Graph(cascadeContract); + const find = graph.add(findIn('Org')); + const del = graph.add(new Delete(DeleteAst.from(cascadeTable('Org')))); + const orgId = ProjectionItem.of('id', ColumnRef.of('Org', 'id')); + graph.addFilterDataEdge(find, del, [[orgId, orgId]]); + graph.setResult(del); + graph.add(findIn('Member')); + + expect(printGraph(graph, withState)).toBe( + [ + 'n1 State Org', + 'n2 Find Org <- state n1', + 'n3 Delete Org <- FilterData n2 (id->id), state n1', + 'n4 State Org <- state n3', + 'n5 State Team <- state n3', + 'n6 State Member <- state n3', + 'n7 State Badge <- state n3', + 'n8 Find Member <- state n6', + 'result: n3', + ].join('\n'), + ); + }); + + it('adds no state to a node that runs no statement', () => { + const graph = newGraph(); + const union = graph.add(new UnionAll()); + + expect(graph.inputsOf(union)).toEqual({ rows: [] }); + expect(graph.nodes()).toHaveLength(1); + }); + + it('prints the graph without state by default', () => { + const graph = newGraph(); + const find = graph.add(findUsers()); + graph.setResult(addFiltered(graph, updateUsers({ name: 'Ada' }), [find, sameId])); + + expect(printGraph(graph)).toBe( + [ + 'n1 Find users', + "n2 Update users set name = 'Ada' <- FilterData n1 (id->id)", + 'result: n2', + ].join('\n'), + ); + }); +}); + +describe('sameState', () => { + it('holds for two reads of a table that nothing wrote in between', () => { + const graph = newGraph(); + const first = graph.add(findUsers()); + const second = graph.add(findUsers()); + + expect(graph.sameState(first, second, users)).toBe(true); + }); + + it('does not hold across a write to the table', () => { + const graph = newGraph(); + const first = graph.add(findUsers()); + graph.add(updateUsers({ name: 'Ada' })); + const second = graph.add(findUsers()); + + expect(graph.sameState(first, second, users)).toBe(false); + }); + + it('holds for a table the write in between did not change', () => { + const graph = newGraph(); + const first = graph.add(findUsers()); + graph.add(new Insert(insertInto('posts', [{ title: 'b' }]))); + const second = graph.add(findUsers()); + + expect(graph.sameState(first, second, users)).toBe(true); + }); + + it('does not hold when a node does not read the table', () => { + const graph = newGraph(); + const read = graph.add(findUsers()); + const insert = graph.add(new Insert(insertInto('posts', [{ title: 'a' }]))); + + expect(graph.sameState(read, insert, posts)).toBe(false); + expect(graph.sameState(read, insert, users)).toBe(false); + }); +}); + +describe('State', () => { + it('runs no statement and gives no row', async () => { + const run = recordingRun(); + + expect(await new State(users).execute({ write: null }, run)).toEqual([]); + expect(await new State(users).execute({ write: [[undefined]] }, run)).toEqual([]); + expect(run).toMatchObject({ queried: [], executed: [] }); + }); + + it('is frozen, and so is its edge', () => { + expect(Object.isFrozen(new State(users))).toBe(true); + expect(Object.isFrozen(new StateEdge(newGraph().add(new UnionAll()), users))).toBe(true); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/graph.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/graph.test.ts index afceec1087cf..353a332b81fe 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/graph.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/graph.test.ts @@ -2,19 +2,28 @@ import { describe, expect, it } from 'vitest'; import { CrossJoin } from '../../src/mutation-graph/cross-join'; import type { NodeId } from '../../src/mutation-graph/edge'; import { FilterData } from '../../src/mutation-graph/filter-data'; -import { Graph } from '../../src/mutation-graph/graph'; +import type { Graph } from '../../src/mutation-graph/graph'; +import { Insert } from '../../src/mutation-graph/insert'; +import { PayloadData } from '../../src/mutation-graph/payload-data'; import { Rows } from '../../src/mutation-graph/rows'; +import { StateEdge } from '../../src/mutation-graph/state'; import { StatementNode } from '../../src/mutation-graph/statement-node'; import { UnionAll } from '../../src/mutation-graph/union-all'; -import { columnPairs, deletePosts, deleteUsers, findUsers, updateUsers } from './statements'; +import { + columnPairs, + deletePosts, + deleteUsers, + findUsers, + insertInto, + newGraph, + updateUsers, + visibleNodes, +} from './statements'; const sameId = columnPairs('users', 'users', [['id', 'id']]); const idToUserId = columnPairs('users', 'posts', [['id', 'user_id']]); const emailToTitle = columnPairs('users', 'posts', [['email', 'title']]); - -function firstFilterOf(graph: Graph, id: NodeId): unknown { - return [graph.inputsOf(id)['filter'] ?? []].flat()[0]; -} +const posts = { namespaceId: 'public', name: 'posts' }; function returned(graph: Graph, id: NodeId): string[] { return graph @@ -24,118 +33,188 @@ function returned(graph: Graph, id: NodeId): string[] { .map((column) => column.alias); } +function filterOf(graph: Graph, id: NodeId): unknown { + return graph.inputsOf(id)['filter']; +} + describe('Graph', () => { describe('add', () => { it('returns the position of the node, counting from zero', () => { - const graph = new Graph(); - const find = findUsers(); - const del = deleteUsers(); + const graph = newGraph(); + const union = new UnionAll(); + const join = new CrossJoin(); - const findId = graph.add(find, { filter: [] }); - const delId = graph.add(del, { filter: [] }); + const unionId = graph.add(union); + const joinId = graph.add(join); - expect([findId, delId]).toEqual([0, 1]); + expect([unionId, joinId]).toEqual([0, 1]); expect(graph.nodes()).toEqual([ - [findId, find], - [delId, del], + [unionId, union], + [joinId, join], ]); }); - it('keeps the inputs of the node in their slot', () => { - const graph = new Graph(); - const find = graph.add(findUsers(), { filter: [] }); - const inputs = { - filter: [new FilterData(find, idToUserId), new FilterData(find, emailToTitle)], - }; - - const del = graph.add(deletePosts(), inputs); + it('gives each list slot of the node no edge, and leaves a slot for one edge unset', () => { + const graph = newGraph(); - expect(graph.inputsOf(del)).toBe(inputs); + expect(graph.inputsOf(graph.add(new UnionAll()))).toEqual({ rows: [] }); + expect(graph.inputsOf(graph.add(new CrossJoin()))).toEqual({}); }); + }); - it('keeps a slot with no edge', () => { - const graph = new Graph(); + describe('addFilterDataEdge', () => { + it('puts a FilterData edge into the filter slot of the target, in the order they are added', () => { + const graph = newGraph(); + const find = graph.add(findUsers([], ['id', 'email'])); + const del = graph.add(deletePosts()); - expect(graph.inputsOf(graph.add(findUsers(), { filter: [] }))).toEqual({ filter: [] }); + graph.addFilterDataEdge(find, del, idToUserId); + graph.addFilterDataEdge(find, del, emailToTitle); + + expect(filterOf(graph, del)).toEqual([ + new FilterData(find, idToUserId), + new FilterData(find, emailToTitle), + ]); }); - it('makes the source of a data edge also return the columns the edge reads', () => { - const graph = new Graph(); - const update = graph.add(updateUsers({ name: 'Ada' }), { filter: [] }); + it('makes the source return the columns the edge reads', () => { + const graph = newGraph(); + const update = graph.add(updateUsers({ name: 'Ada' })); expect(returned(graph, update)).toEqual([]); - graph.add(deletePosts(), { filter: [new FilterData(update, idToUserId)] }); - graph.add(deletePosts(), { - filter: [new FilterData(update, emailToTitle), new FilterData(update, idToUserId)], - }); + graph.addFilterDataEdge(update, graph.add(deletePosts()), idToUserId); + const del = graph.add(deletePosts()); + graph.addFilterDataEdge(update, del, emailToTitle); + graph.addFilterDataEdge(update, del, idToUserId); expect(returned(graph, update)).toEqual(['id', 'email']); }); - it('leaves the inputs of a node whose statement it widens as they were', () => { - const graph = new Graph(); - const find = graph.add(findUsers(), { filter: [] }); - const inputs = { filter: [new FilterData(find, sameId)] }; - const update = graph.add(updateUsers({ name: 'Ada' }), inputs); + it('leaves the inputs of the source as they were when it widens it', () => { + const graph = newGraph(); + const find = graph.add(findUsers()); + const update = graph.add(updateUsers({ name: 'Ada' })); + graph.addFilterDataEdge(find, update, sameId); - graph.add(deletePosts(), { filter: [new FilterData(update, idToUserId)] }); + graph.addFilterDataEdge(update, graph.add(deletePosts()), idToUserId); - expect(graph.inputsOf(update)).toBe(inputs); + expect(filterOf(graph, update)).toEqual([new FilterData(find, sameId)]); }); it('does not change what a node returns when a node with no edge is added after it', () => { - const graph = new Graph(); - const update = graph.add(updateUsers({ name: 'Ada' }), { filter: [] }); + const graph = newGraph(); + const update = graph.add(updateUsers({ name: 'Ada' })); - graph.add(deleteUsers(), { filter: [] }); + graph.add(deleteUsers()); expect(returned(graph, update)).toEqual([]); }); + }); + + describe('addPayloadDataEdge', () => { + it('puts a PayloadData edge into the payload slot of the target and widens the source', () => { + const graph = newGraph(); + const user = graph.add(new Insert(insertInto('users', [{ name: 'Ada' }]))); + const post = graph.add(new Insert(insertInto('posts', [{ title: 'Hello' }]))); + + graph.addPayloadDataEdge(user, post, idToUserId); + + expect(graph.inputsOf(post)['payload']).toEqual([new PayloadData(user, idToUserId)]); + expect(returned(graph, user)).toEqual(['id']); + }); + }); + + describe('addRowsEdge', () => { + it('puts a Rows edge into a list slot, in the order they are added', () => { + const graph = newGraph(); + const first = graph.add(findUsers()); + const second = graph.add(findUsers()); + const union = graph.add(new UnionAll()); + + graph.addRowsEdge(first, union, 'rows', sameId); + graph.addRowsEdge(second, union, 'rows', sameId); + + expect(graph.inputsOf(union)).toEqual({ + rows: [new Rows(first, sameId), new Rows(second, sameId)], + }); + }); + + it('puts a Rows edge into a slot that holds one edge', () => { + const graph = newGraph(); + const left = graph.add(findUsers()); + const right = graph.add(findUsers()); + const join = graph.add(new CrossJoin()); + + graph.addRowsEdge(left, join, 'left', sameId); + graph.addRowsEdge(right, join, 'right', sameId); + + expect(graph.inputsOf(join)).toEqual({ + left: new Rows(left, sameId), + right: new Rows(right, sameId), + }); + }); - it('leaves a node that runs no statement as it was when an edge reads from it', () => { - const graph = new Graph(); - const find = graph.add(findUsers([], ['email']), { filter: [] }); + it('widens a source that runs a statement and leaves one that runs none as it was', () => { + const graph = newGraph(); + const find = graph.add(findUsers([], ['email'])); const union = new UnionAll(); - const unionId = graph.add(union, { rows: [new Rows(find, sameId)] }); + const unionId = graph.add(union); - graph.add(updateUsers({ name: 'Ada' }), { filter: [new FilterData(unionId, sameId)] }); + graph.addRowsEdge(find, unionId, 'rows', sameId); + graph.addFilterDataEdge(unionId, graph.add(updateUsers({ name: 'Ada' })), sameId); expect(graph.nodes().find(([id]) => id === unionId)?.[1]).toBe(union); expect(returned(graph, find)).toEqual(['email', 'id']); }); }); + describe('columnsReadFrom', () => { + it('gives the columns every edge from the node reads, in the order the edges were added', () => { + const graph = newGraph(); + const find = graph.add(findUsers()); + const union = graph.add(new UnionAll()); + + graph.addFilterDataEdge(find, graph.add(deletePosts()), emailToTitle); + graph.addRowsEdge(find, union, 'rows', sameId); + + expect(graph.columnsReadFrom(find).map((column) => column.alias)).toEqual(['email', 'id']); + expect(graph.columnsReadFrom(union)).toEqual([]); + }); + }); + describe('readersOf', () => { it('gives, for a position, each edge that reads from it with the position of its reader', () => { - const graph = new Graph(); - const find = graph.add(findUsers(), { filter: [] }); - const toUpdate = new FilterData(find, sameId); - const toDelete = new FilterData(find, idToUserId); - const update = graph.add(updateUsers({ name: 'Ada' }), { filter: [toUpdate] }); - const del = graph.add(deletePosts(), { filter: [toDelete] }); + const graph = newGraph(); + const find = graph.add(findUsers()); + const update = graph.add(updateUsers({ name: 'Ada' })); + const del = graph.add(deletePosts()); + graph.addFilterDataEdge(find, update, sameId); + graph.addFilterDataEdge(find, del, idToUserId); expect(graph.readersOf(find)).toEqual([ - { reader: update, edge: toUpdate }, - { reader: del, edge: toDelete }, + { reader: update, edge: new FilterData(find, sameId) }, + { reader: del, edge: new FilterData(find, idToUserId) }, + ]); + expect(graph.readersOf(find)[0]?.edge).toBe([filterOf(graph, update)].flat()[0]); + expect(graph.readersOf(del)).toEqual([ + { reader: expect.any(Number), edge: new StateEdge(del, posts) }, ]); - expect(graph.readersOf(find)[0]?.edge).toBe(firstFilterOf(graph, update)); - expect(graph.readersOf(del)).toEqual([]); }); }); describe('replace', () => { it('puts the new node at the position and leaves the others where they are', () => { - const graph = new Graph(); + const graph = newGraph(); const find = findUsers(); const del = deletePosts(); const otherUpdate = updateUsers({ name: 'Grace' }); - const findId = graph.add(find, { filter: [] }); - const update = graph.add(updateUsers({ name: 'Ada' }), { filter: [] }); - const delId = graph.add(del, { filter: [] }); + const findId = graph.add(find); + const update = graph.add(updateUsers({ name: 'Ada' })); + const delId = graph.add(del); graph.replace(update, otherUpdate); - expect(graph.nodes()).toEqual([ + expect(visibleNodes(graph)).toEqual([ [findId, find], [update, otherUpdate], [delId, del], @@ -143,112 +222,98 @@ describe('Graph', () => { }); it('touches no edge in either direction', () => { - const graph = new Graph(); - const find = graph.add(findUsers(), { filter: [] }); - const into = new FilterData(find, sameId); - const update = graph.add(updateUsers({ name: 'Ada' }, undefined, ['id']), { - filter: [into], - }); - const outOf = new FilterData(update, idToUserId); - const del = graph.add(deletePosts(), { filter: [outOf] }); + const graph = newGraph(); + const find = graph.add(findUsers()); + const update = graph.add(updateUsers({ name: 'Ada' }, undefined, ['id'])); + graph.addFilterDataEdge(find, update, sameId); + const del = graph.add(deletePosts()); + graph.addFilterDataEdge(update, del, idToUserId); + const into = graph.inputsOf(update); + const outOf = graph.readersOf(update); graph.replace(update, updateUsers({ name: 'Grace' })); - expect(firstFilterOf(graph, update)).toBe(into); - expect(graph.readersOf(find)).toEqual([{ reader: update, edge: into }]); - expect(graph.readersOf(find)[0]?.edge).toBe(into); - expect(graph.readersOf(update)).toEqual([{ reader: del, edge: outOf }]); - expect(firstFilterOf(graph, del)).toBe(outOf); + expect(graph.inputsOf(update)).toBe(into); + expect(graph.readersOf(update)).toBe(outOf); }); }); describe('remove', () => { it('empties the position, and nodes skips it and keeps the other positions', () => { - const graph = new Graph(); - const find = findUsers(); - const del = deletePosts(); - const findId = graph.add(find, { filter: [] }); - const update = graph.add(updateUsers({ name: 'Ada' }), { filter: [] }); - const delId = graph.add(del, { filter: [] }); + const graph = newGraph(); + const first = new UnionAll(); + const third = new UnionAll(); + const firstId = graph.add(first); + const second = graph.add(new UnionAll()); + const thirdId = graph.add(third); - graph.remove(update); + graph.remove(second); expect(graph.nodes()).toEqual([ - [findId, find], - [delId, del], + [firstId, first], + [thirdId, third], ]); - expect([findId, delId]).toEqual([0, 2]); + expect([firstId, thirdId]).toEqual([0, 2]); }); it('takes the edges of a node that is both a source and a reader out of both sides', () => { - const graph = new Graph(); - const find = graph.add(findUsers(), { filter: [] }); - const update = graph.add(updateUsers({ name: 'Ada' }), { - filter: [new FilterData(find, sameId)], - }); - const kept = new FilterData(find, idToUserId); - const del = graph.add(deletePosts(), { - filter: [kept, new FilterData(update, idToUserId)], - }); - - graph.remove(update); - - expect(graph.inputsOf(update)).toEqual({}); - expect(graph.readersOf(update)).toEqual([]); - expect(graph.readersOf(find)).toEqual([{ reader: del, edge: kept }]); - expect(graph.inputsOf(del)).toEqual({ filter: [kept] }); - }); - - it('leaves the inputs and the readers of the other positions intact', () => { - const graph = new Graph(); - const find = graph.add(findUsers(), { filter: [] }); - const removed = graph.add(updateUsers({ name: 'Ada' }), { filter: [] }); - const kept = new FilterData(find, idToUserId); - const del = graph.add(deletePosts(), { filter: [kept] }); - - graph.remove(removed); - - expect(graph.readersOf(find)).toEqual([{ reader: del, edge: kept }]); - expect(firstFilterOf(graph, del)).toBe(kept); + const graph = newGraph(); + const find = graph.add(findUsers()); + const union = graph.add(new UnionAll()); + graph.addRowsEdge(find, union, 'rows', sameId); + const other = graph.add(new UnionAll()); + graph.addRowsEdge(find, other, 'rows', idToUserId); + graph.addRowsEdge(union, other, 'rows', sameId); + + graph.remove(union); + + expect(graph.inputsOf(union)).toEqual({}); + expect(graph.readersOf(union)).toEqual([]); + expect(graph.readersOf(find)).toEqual([{ reader: other, edge: new Rows(find, idToUserId) }]); + expect(graph.inputsOf(other)).toEqual({ rows: [new Rows(find, idToUserId)] }); }); it('keeps a slot that holds one edge when the source of that edge is removed', () => { - const graph = new Graph(); - const left = graph.add(findUsers(), { filter: [] }); - const right = graph.add(findUsers(), { filter: [] }); - const inputs = { left: new Rows(left, sameId), right: new Rows(right, sameId) }; - const join = graph.add(new CrossJoin(), inputs); + const graph = newGraph(); + const left = graph.add(new UnionAll()); + const right = graph.add(new UnionAll()); + const join = graph.add(new CrossJoin()); + graph.addRowsEdge(left, join, 'left', sameId); + graph.addRowsEdge(right, join, 'right', sameId); graph.remove(right); - expect(graph.inputsOf(join)).toEqual(inputs); - expect(graph.readersOf(left)).toEqual([{ reader: join, edge: inputs.left }]); + expect(graph.inputsOf(join)).toEqual({ + left: new Rows(left, sameId), + right: new Rows(right, sameId), + }); + expect(graph.readersOf(left)).toEqual([{ reader: join, edge: new Rows(left, sameId) }]); }); it('gives the next node the next position, not the emptied one', () => { - const graph = new Graph(); - const find = graph.add(findUsers(), { filter: [] }); - graph.remove(find); + const graph = newGraph(); + const first = graph.add(new UnionAll()); + graph.remove(first); - const next = graph.add(deleteUsers(), { filter: [] }); + const next = graph.add(new UnionAll()); - expect(next).not.toBe(find); + expect(next).not.toBe(first); expect(graph.nodes().map(([id]) => id)).toEqual([next]); }); }); describe('result', () => { it('names no node on a new graph', () => { - expect(new Graph().result).toBeUndefined(); + expect(newGraph().result).toBeUndefined(); }); it('names the position it is given', () => { - const graph = new Graph(); - const find = graph.add(findUsers(), { filter: [] }); + const graph = newGraph(); + const union = graph.add(new UnionAll()); - graph.setResult(find); + graph.setResult(union); - expect(graph.result).toBe(find); + expect(graph.result).toBe(union); }); }); }); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/insert-chunked.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/insert-chunked.test.ts index cc4d03e1d2d5..fbb94ae97d94 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/insert-chunked.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/insert-chunked.test.ts @@ -17,7 +17,7 @@ describe('InsertChunked', () => { it('runs its statements in order and gives their rows one statement after another', async () => { const run = recordingRun([{ id: 1 }]); - const rows = await new InsertChunked([first, second]).execute({}, run); + const rows = await new InsertChunked([first, second]).execute({ state: null }, run); expect(rows).toEqual([{ id: 1 }, { id: 1 }]); expect(run.queried).toEqual([first, second]); @@ -30,7 +30,7 @@ describe('InsertChunked', () => { insertInto('users', [{ name: 'Grace', email: 'g@h.io' }]), ]); - expect(await counted.execute({}, run)).toBe(4); + expect(await counted.execute({ state: null }, run)).toBe(4); expect(run.executed).toEqual(counted.asts); expect(run.queried).toEqual([]); }); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts index 7bcf6b240a13..22c233d70aea 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts @@ -18,7 +18,7 @@ describe('Insert', () => { const insert = new Insert(insertInto('posts', [{ title: 'Hello' }], ['id'])); const run = recordingRun([{ id: 1 }]); - expect(await insert.execute({ payload: null }, run)).toEqual([{ id: 1 }]); + expect(await insert.execute({ payload: null, state: null }, run)).toEqual([{ id: 1 }]); expect(run.queried).toEqual([insert.ast]); }); @@ -27,7 +27,7 @@ describe('Insert', () => { const run = recordingRun(); const titleOfEdge = { views: ParamRef.of(3, { name: 'views' }) }; - await insert.execute({ payload: [[userIdOf7], [titleOfEdge]] }, run); + await insert.execute({ payload: [[userIdOf7], [titleOfEdge]], state: null }, run); expect(run.queried).toHaveLength(1); expect(run.queried[0]).toMatchObject({ @@ -46,7 +46,7 @@ describe('Insert', () => { const insert = new Insert(insertInto('posts', [{ title: 'Hello' }], ['id'])); const run = recordingRun([{ id: 1 }]); - expect(await insert.execute({ payload: [[userIdOf7], []] }, run)).toEqual([]); + expect(await insert.execute({ payload: [[userIdOf7], []], state: null }, run)).toEqual([]); expect(run.queried).toEqual([]); expect(run.executed).toEqual([]); }); @@ -55,7 +55,7 @@ describe('Insert', () => { const insert = new Insert(insertInto('posts', [{ title: 'a' }, { title: 'b' }])); const run = recordingRun([], 2); - expect(await insert.execute({ payload: null }, run)).toBe(2); + expect(await insert.execute({ payload: null, state: null }, run)).toBe(2); expect(run.executed).toEqual([insert.ast]); }); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.test.ts index e9b4427d566d..c868ce8e0d81 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.test.ts @@ -1,15 +1,12 @@ import { InsertOnConflict } from '@internal/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import { CrossJoin } from '../../src/mutation-graph/cross-join'; -import { FilterData } from '../../src/mutation-graph/filter-data'; -import { Graph } from '../../src/mutation-graph/graph'; import { Insert } from '../../src/mutation-graph/insert'; import { InsertChunked } from '../../src/mutation-graph/insert-chunked'; -import { PayloadData } from '../../src/mutation-graph/payload-data'; -import { Rows } from '../../src/mutation-graph/rows'; import { UnionAll } from '../../src/mutation-graph/union-all'; import { printGraph } from './print-graph'; import { + addFiltered, aliasedTable, columnPairs, deletePosts, @@ -17,15 +14,14 @@ import { findUsers, insertInto, nameIsAda, + newGraph, updateUsers, } from './statements'; describe('printGraph', () => { it('prints one node and the result that names it', () => { - const graph = new Graph(); - graph.setResult( - graph.add(updateUsers({ email: 'ada@example.com' }, nameIsAda), { filter: [] }), - ); + const graph = newGraph(); + graph.setResult(graph.add(updateUsers({ email: 'ada@example.com' }, nameIsAda))); expect(printGraph(graph)).toBe( ["n1 Update users set email = 'ada@example.com' where name = 'Ada'", 'result: n1'].join('\n'), @@ -33,10 +29,10 @@ describe('printGraph', () => { }); it('prints a FilterData edge with its column pair', () => { - const graph = new Graph(); - const find = graph.add(findUsers([nameIsAda]), { filter: [] }); + const graph = newGraph(); + const find = graph.add(findUsers([nameIsAda])); const sameId = columnPairs('users', 'users', [['id', 'id']]); - graph.setResult(graph.add(deleteUsers(), { filter: [new FilterData(find, sameId)] })); + graph.setResult(addFiltered(graph, deleteUsers(), [find, sameId])); expect(printGraph(graph)).toBe( [ @@ -48,13 +44,13 @@ describe('printGraph', () => { }); it('prints every column pair of a FilterData edge', () => { - const graph = new Graph(); - const find = graph.add(findUsers(), { filter: [] }); + const graph = newGraph(); + const find = graph.add(findUsers()); const pairs = columnPairs('users', 'posts', [ ['id', 'user_id'], ['name', 'title'], ]); - graph.setResult(graph.add(deletePosts(), { filter: [new FilterData(find, pairs)] })); + graph.setResult(addFiltered(graph, deletePosts(), [find, pairs])); expect(printGraph(graph)).toBe( [ @@ -66,13 +62,11 @@ describe('printGraph', () => { }); it('prints every input of a node', () => { - const graph = new Graph(); - const find = graph.add(findUsers(), { filter: [] }); - const other = graph.add(findUsers(), { filter: [] }); + const graph = newGraph(); + const find = graph.add(findUsers()); + const other = graph.add(findUsers()); const sameId = columnPairs('users', 'users', [['id', 'id']]); - graph.add(deleteUsers(), { - filter: [new FilterData(find, sameId), new FilterData(other, sameId)], - }); + addFiltered(graph, deleteUsers(), [find, sameId], [other, sameId]); graph.setResult(find); expect(printGraph(graph)).toBe( @@ -86,21 +80,19 @@ describe('printGraph', () => { }); it('prints none for a result with no node', () => { - const graph = new Graph(); - graph.add(findUsers(), { filter: [] }); + const graph = newGraph(); + graph.add(findUsers()); - expect(printGraph(new Graph())).toBe('result: none'); + expect(printGraph(newGraph())).toBe('result: none'); expect(printGraph(graph)).toBe(['n1 Find users', 'result: none'].join('\n')); }); describe('inserts', () => { it('prints an Insert of one row and of several rows', () => { - const graph = new Graph(); - graph.add(new Insert(insertInto('users', [{ name: 'Ada' }])), { payload: [] }); + const graph = newGraph(); + graph.add(new Insert(insertInto('users', [{ name: 'Ada' }]))); graph.setResult( - graph.add(new Insert(insertInto('users', [{ name: 'a' }, { name: 'b' }, { name: 'c' }])), { - payload: [], - }), + graph.add(new Insert(insertInto('users', [{ name: 'a' }, { name: 'b' }, { name: 'c' }]))), ); expect(printGraph(graph)).toBe( @@ -111,7 +103,7 @@ describe('printGraph', () => { it('prints the conflict clause of an Insert', () => { const users = aliasedTable('users'); const ast = insertInto('users', [{ email: 'a@b.c' }]); - const graph = new Graph(); + const graph = newGraph(); graph.add( new Insert( ast.withOnConflict( @@ -120,15 +112,11 @@ describe('printGraph', () => { }), ), ), - { payload: [] }, ); graph.add( new Insert(ast.withOnConflict(InsertOnConflict.on([users.column('email')]).doNothing())), - { - payload: [], - }, ); - graph.add(new Insert(ast.withOnConflict(InsertOnConflict.doNothing())), { payload: [] }); + graph.add(new Insert(ast.withOnConflict(InsertOnConflict.doNothing()))); expect(printGraph(graph)).toBe( [ @@ -141,12 +129,12 @@ describe('printGraph', () => { }); it('prints an InsertChunked with the number of its statements', () => { - const graph = new Graph(); + const graph = newGraph(); const chunked = new InsertChunked([ insertInto('users', [{ name: 'Ada' }]), insertInto('users', [{ name: 'Grace', email: 'g@h.io' }]), ]); - graph.setResult(graph.add(chunked, {})); + graph.setResult(graph.add(chunked)); expect(printGraph(graph)).toBe( ['n1 InsertChunked users (2 statements)', 'result: n1'].join('\n'), @@ -154,23 +142,24 @@ describe('printGraph', () => { }); it('prints the chain of a variant create: Insert, Insert with PayloadData, CrossJoin, UnionAll', () => { - const graph = new Graph(); + const graph = newGraph(); const idToUserId = columnPairs('users', 'posts', [['id', 'user_id']]); const sameUserId = columnPairs('users', 'users', [['id', 'id']]); const samePostId = columnPairs('posts', 'posts', [['id', 'id']]); const joins = [1, 2].map(() => { - const base = graph.add(new Insert(insertInto('users', [{ name: 'Ada' }])), { payload: [] }); - const variant = graph.add(new Insert(insertInto('posts', [{ title: 'Hello' }])), { - payload: [new PayloadData(base, idToUserId)], - }); - return graph.add(new CrossJoin(), { - left: new Rows(base, sameUserId), - right: new Rows(variant, samePostId), - }); + const base = graph.add(new Insert(insertInto('users', [{ name: 'Ada' }]))); + const variant = graph.add(new Insert(insertInto('posts', [{ title: 'Hello' }]))); + graph.addPayloadDataEdge(base, variant, idToUserId); + const join = graph.add(new CrossJoin()); + graph.addRowsEdge(base, join, 'left', sameUserId); + graph.addRowsEdge(variant, join, 'right', samePostId); + return join; }); - graph.setResult( - graph.add(new UnionAll(), { rows: joins.map((join) => new Rows(join, sameUserId)) }), - ); + const union = graph.add(new UnionAll()); + for (const join of joins) { + graph.addRowsEdge(join, union, 'rows', sameUserId); + } + graph.setResult(union); expect(printGraph(graph)).toBe( [ diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.ts index 275e1b4caf95..68de5979fa34 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.ts @@ -7,13 +7,14 @@ import type { UpdateAst, } from '@internal/sql-relational-core/ast'; import { Delete } from '../../src/mutation-graph/delete'; -import type { Edge, NodeId } from '../../src/mutation-graph/edge'; +import { type ColumnPair, DataEdge, type Edge, type NodeId } from '../../src/mutation-graph/edge'; import { Find } from '../../src/mutation-graph/find'; import type { Graph } from '../../src/mutation-graph/graph'; import { Insert } from '../../src/mutation-graph/insert'; import { InsertChunked } from '../../src/mutation-graph/insert-chunked'; import type { Node } from '../../src/mutation-graph/node'; import { Rows } from '../../src/mutation-graph/rows'; +import { State, StateEdge } from '../../src/mutation-graph/state'; import { Update } from '../../src/mutation-graph/update'; import { printExpression } from './print-expression'; @@ -100,6 +101,9 @@ function printNode(node: Node): string { const count = node.asts.length; return `InsertChunked ${node.asts[0]?.table.name} (${count} ${count === 1 ? 'statement' : 'statements'})`; } + if (node instanceof State) { + return `State ${node.table.name}`; + } return node instanceof Delete ? printDelete(node.ast) : node.constructor.name; } @@ -107,7 +111,12 @@ function printEdge(names: NodeNames, edge: Edge): string { if (edge instanceof Rows) { return `Rows ${names.get(edge.from)}`; } - const pairs = edge.columns.map(([source, target]) => `${source.alias}->${target.alias}`); + if (!(edge instanceof DataEdge)) { + return `state ${names.get(edge.from)}`; + } + const pairs = edge.columns.map( + ([source, target]: ColumnPair) => `${source.alias}->${target.alias}`, + ); return `${edge.constructor.name} ${names.get(edge.from)} (${pairs.join(', ')})`; } @@ -123,11 +132,17 @@ function printResult(names: NodeNames, graph: Graph): string { return `result: ${name ?? 'none'}`; } -export function printGraph(graph: Graph): string { - const nodes = graph.nodes(); +export interface PrintOptions { + readonly state: boolean; +} + +export function printGraph(graph: Graph, options: PrintOptions = { state: false }): string { + const nodes = graph.nodes().filter(([, node]) => options.state || !(node instanceof State)); const names: NodeNames = new Map(nodes.map(([id], index) => [id, `n${index + 1}`])); const lines = nodes.map(([id, node]) => { - const inputs = Object.values(graph.inputsOf(id)).flat(); + const inputs = Object.values(graph.inputsOf(id)) + .flat() + .filter((edge) => options.state || !(edge instanceof StateEdge)); return `${names.get(id)} ${printNode(node)}${printInputs(names, inputs)}`; }); lines.push(printResult(names, graph)); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/run-graph.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/run-graph.test.ts index 33185cfbe589..3040a7e67eca 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/run-graph.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/run-graph.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { FilterData } from '../../src/mutation-graph/filter-data'; -import { Graph } from '../../src/mutation-graph/graph'; +import type { Graph } from '../../src/mutation-graph/graph'; import { executeGraph, queryGraph } from '../../src/mutation-graph/run-graph'; import { createCollectionFor } from '../collection-fixtures'; import { createMockRuntime, type MockExecution, type MockRuntime } from '../helpers'; import { printExpression } from './print-expression'; import { + addFiltered, columnPairs, contextOf, deletePosts, @@ -13,6 +13,7 @@ import { findUsers, findUsersWith, nameIsAda, + newGraph, rowsOfPosts, rowsOfUsers, updatePosts, @@ -84,17 +85,17 @@ const idAndName = columnPairs('users', 'posts', [ ]); function findThenUpdatePost(columns = idToUserId): Graph { - const graph = new Graph(); - const find = graph.add(findUsers([nameIsAda]), { filter: [] }); + const graph = newGraph(); + const find = graph.add(findUsers([nameIsAda])); const update = updatePosts({ title: 'New' }, undefined, ['id', 'title']); - graph.setResult(graph.add(update, { filter: [new FilterData(find, columns)] })); + graph.setResult(addFiltered(graph, update, [find, columns])); return graph; } function findThenDeleteUsers(): Graph { - const graph = new Graph(); - const find = graph.add(findUsers([nameIsAda], ['id', 'name']), { filter: [] }); - graph.add(deleteUsers(nameIsAda), { filter: [] }); + const graph = newGraph(); + const find = graph.add(findUsers([nameIsAda], ['id', 'name'])); + graph.add(deleteUsers(nameIsAda)); graph.setResult(find); return graph; } @@ -104,9 +105,9 @@ describe('running a graph', () => { it('returns the rows of an Update, mapped to fields', async () => { const runtime = createMockRuntime(); runtime.setNextResults([[{ id: 1, name: 'Ada', invited_by_id: 2 }]]); - const graph = new Graph(); + const graph = newGraph(); const update = updateUsers({ name: 'Ada' }, nameIsAda, allUserColumns); - graph.setResult(graph.add(update, { filter: [] })); + graph.setResult(graph.add(update)); const rows = await queryGraph(graph, contextOf(runtime), rowsOfUsers()); @@ -116,8 +117,8 @@ describe('running a graph', () => { it('makes a write that is the result return the selection of the caller', async () => { const runtime = createMockRuntime(); - const graph = new Graph(); - graph.setResult(graph.add(deleteUsers(nameIsAda), { filter: [] })); + const graph = newGraph(); + graph.setResult(graph.add(deleteUsers(nameIsAda))); await queryGraph(graph, contextOf(runtime), rowsOfUsers({ selectedFields: ['id', 'email'] })); @@ -127,8 +128,8 @@ describe('running a graph', () => { it('makes it return every column of the model when the caller selected nothing', async () => { const runtime = createMockRuntime(); - const graph = new Graph(); - graph.setResult(graph.add(deleteUsers(nameIsAda), { filter: [] })); + const graph = newGraph(); + graph.setResult(graph.add(deleteUsers(nameIsAda))); await queryGraph(graph, contextOf(runtime), rowsOfUsers()); @@ -137,8 +138,8 @@ describe('running a graph', () => { it('makes a Find that is the result also return what the caller selected', async () => { const runtime = createMockRuntime(); - const graph = new Graph(); - graph.setResult(graph.add(findUsers([nameIsAda], ['id']), { filter: [] })); + const graph = newGraph(); + graph.setResult(graph.add(findUsers([nameIsAda], ['id']))); await queryGraph(graph, contextOf(runtime), rowsOfUsers({ selectedFields: ['id', 'email'] })); @@ -148,8 +149,8 @@ describe('running a graph', () => { it('returns the affected row count of a count result without returning rows', async () => { const runtime = createMockRuntime(); runtime.setNextStats([{ affectedRows: 3 }]); - const graph = new Graph(); - graph.setResult(graph.add(deleteUsers(nameIsAda), { filter: [] })); + const graph = newGraph(); + graph.setResult(graph.add(deleteUsers(nameIsAda))); expect(await executeGraph(graph, contextOf(runtime))).toEqual({ affectedRows: 3 }); expect(statements(runtime)).toEqual(['execute delete']); @@ -159,8 +160,8 @@ describe('running a graph', () => { it('returns the first row of a first row result', async () => { const runtime = createMockRuntime(); runtime.setNextResults([[{ id: 1 }, { id: 2 }]]); - const graph = new Graph(); - graph.setResult(graph.add(findUsers(), { filter: [] })); + const graph = newGraph(); + graph.setResult(graph.add(findUsers())); expect( await firstRowOf(graph, contextOf(runtime), rowsOfUsers({ selectedFields: ['id'] })), @@ -169,8 +170,8 @@ describe('running a graph', () => { it('does not open a transaction', async () => { const transactional = withTransaction(createMockRuntime()); - const graph = new Graph(); - graph.setResult(graph.add(deleteUsers(undefined, allUserColumns), { filter: [] })); + const graph = newGraph(); + graph.setResult(graph.add(deleteUsers(undefined, allUserColumns))); await queryGraph(graph, contextOf(transactional.runtime), rowsOfUsers()); @@ -182,22 +183,41 @@ describe('running a graph', () => { it('gives no rows, null and zero, and executes nothing when the graph has no node', async () => { const runtime = createMockRuntime(); - expect(await queryGraph(new Graph(), contextOf(runtime), rowsOfUsers())).toEqual([]); - expect(await firstRowOf(new Graph(), contextOf(runtime), rowsOfUsers())).toBeNull(); - expect(await affectedBy(new Graph(), contextOf(runtime))).toBe(0); + expect(await queryGraph(newGraph(), contextOf(runtime), rowsOfUsers())).toEqual([]); + expect(await firstRowOf(newGraph(), contextOf(runtime), rowsOfUsers())).toBeNull(); + expect(await affectedBy(newGraph(), contextOf(runtime))).toBe(0); expect(runtime.executions).toEqual([]); }); it('still executes the nodes of the graph', async () => { const runtime = createMockRuntime(); - const graph = new Graph(); - graph.add(findUsers([nameIsAda]), { filter: [] }); + const graph = newGraph(); + graph.add(findUsers([nameIsAda])); expect(await firstRowOf(graph, contextOf(runtime), rowsOfUsers())).toBeNull(); expect(statements(runtime)).toEqual(['query select']); }); }); + describe('with a Find after an Update of the same table', () => { + it('runs the Find, whose only input is the state the Update left', async () => { + const runtime = createMockRuntime(); + runtime.setNextResults([[{ id: 1, name: 'Ada' }]]); + const graph = newGraph(); + graph.add(updateUsers({ name: 'Ada' }, nameIsAda)); + graph.setResult(graph.add(findUsers([nameIsAda], ['id', 'name']))); + + const rows = await queryGraph( + graph, + contextOf(runtime), + rowsOfUsers({ selectedFields: ['id', 'name'] }), + ); + + expect(rows).toEqual([{ id: 1, name: 'Ada' }]); + expect(statements(runtime)).toEqual(['execute update', 'query select']); + }); + }); + describe('with a Find and a Delete after it', () => { it('reads the rows, then deletes with the count form, and returns the rows read', async () => { const runtime = createMockRuntime(); @@ -291,9 +311,9 @@ describe('running a graph', () => { it('gives zero for a count result whose source has no row', async () => { const runtime = createMockRuntime(); runtime.setNextResults([[]]); - const graph = new Graph(); - const find = graph.add(findUsers([nameIsAda]), { filter: [] }); - graph.setResult(graph.add(deletePosts(), { filter: [new FilterData(find, idToUserId)] })); + const graph = newGraph(); + const find = graph.add(findUsers([nameIsAda])); + graph.setResult(addFiltered(graph, deletePosts(), [find, idToUserId])); expect(await affectedBy(graph, contextOf(runtime))).toBe(0); expect(statements(runtime)).toEqual(['query select']); @@ -302,13 +322,11 @@ describe('running a graph', () => { it('does not run a node whose source did not run', async () => { const runtime = createMockRuntime(); runtime.setNextResults([[]]); - const graph = new Graph(); - const find = graph.add(findUsers([nameIsAda]), { filter: [] }); - const update = graph.add(updatePosts({ title: 'New' }), { - filter: [new FilterData(find, idToUserId)], - }); + const graph = newGraph(); + const find = graph.add(findUsers([nameIsAda])); + const update = addFiltered(graph, updatePosts({ title: 'New' }), [find, idToUserId]); const sameId = columnPairs('posts', 'posts', [['id', 'id']]); - graph.setResult(graph.add(deletePosts(), { filter: [new FilterData(update, sameId)] })); + graph.setResult(addFiltered(graph, deletePosts(), [update, sameId])); expect(await affectedBy(graph, contextOf(runtime))).toBe(0); expect(statements(runtime)).toEqual(['query select']); @@ -317,11 +335,9 @@ describe('running a graph', () => { it('reads the rows of a write that returns columns for an edge', async () => { const runtime = createMockRuntime(); runtime.setNextResults([[{ id: 7 }], []]); - const graph = new Graph(); - const update = graph.add(updateUsers({ name: 'Ada' }, nameIsAda), { filter: [] }); - graph.setResult( - graph.add(deletePosts(undefined, ['id']), { filter: [new FilterData(update, idToUserId)] }), - ); + const graph = newGraph(); + const update = graph.add(updateUsers({ name: 'Ada' }, nameIsAda)); + graph.setResult(addFiltered(graph, deletePosts(undefined, ['id']), [update, idToUserId])); await queryGraph(graph, contextOf(runtime), rowsOfPosts()); @@ -335,10 +351,10 @@ describe('running a graph', () => { it('uses the rows of a Find result for the edge and returns them mapped', async () => { const runtime = createMockRuntime(); runtime.setNextResults([[{ id: 7, invited_by_id: 2 }]]); - const graph = new Graph(); - const find = graph.add(findUsers([nameIsAda], ['id', 'invited_by_id']), { filter: [] }); + const graph = newGraph(); + const find = graph.add(findUsers([nameIsAda], ['id', 'invited_by_id'])); const sameId = columnPairs('users', 'users', [['id', 'id']]); - graph.add(deleteUsers(), { filter: [new FilterData(find, sameId)] }); + addFiltered(graph, deleteUsers(), [find, sameId]); graph.setResult(find); const row = await firstRowOf( @@ -355,9 +371,9 @@ describe('running a graph', () => { it('leaves a column that was returned only for the edge out of the rows', async () => { const runtime = createMockRuntime(); runtime.setNextResults([[{ name: 'Ada', id: 7 }]]); - const graph = new Graph(); - const update = graph.add(updateUsers({ name: 'Ada' }, nameIsAda, ['name']), { filter: [] }); - graph.add(deletePosts(), { filter: [new FilterData(update, idToUserId)] }); + const graph = newGraph(); + const update = graph.add(updateUsers({ name: 'Ada' }, nameIsAda, ['name'])); + addFiltered(graph, deletePosts(), [update, idToUserId]); graph.setResult(update); const rows = await queryGraph( @@ -374,9 +390,9 @@ describe('running a graph', () => { it('counts the rows of a count result that returns rows for an edge', async () => { const runtime = createMockRuntime(); runtime.setNextResults([[{ id: 7 }, { id: 8 }]]); - const graph = new Graph(); - const update = graph.add(updateUsers({ name: 'Ada' }, nameIsAda), { filter: [] }); - graph.add(deletePosts(), { filter: [new FilterData(update, idToUserId)] }); + const graph = newGraph(); + const update = graph.add(updateUsers({ name: 'Ada' }, nameIsAda)); + addFiltered(graph, deletePosts(), [update, idToUserId]); graph.setResult(update); expect(await affectedBy(graph, contextOf(runtime))).toBe(2); @@ -390,11 +406,9 @@ describe('running a graph', () => { runtime.setNextResults([[{ id: 1 }], [{ id: 1, name: 'Ada', posts: [] }]]); const state = { ...usersWithPosts(), selectedFields: ['id', 'name'] }; const sameId = columnPairs('users', 'users', [['id', 'id']]); - const graph = new Graph(); - const update = graph.add(updateUsers({ name: 'Ada' }, nameIsAda), { filter: [] }); - graph.setResult( - graph.add(findUsersWith(state), { filter: [new FilterData(update, sameId)] }), - ); + const graph = newGraph(); + const update = graph.add(updateUsers({ name: 'Ada' }, nameIsAda)); + graph.setResult(addFiltered(graph, findUsersWith(state), [update, sameId])); const rows = await queryGraph(graph, contextOf(runtime), rowsOfUsers(state)); @@ -409,11 +423,9 @@ describe('running a graph', () => { runtime.setNextResults([[]]); const state = { ...usersWithPosts(), selectedFields: ['id', 'name'] }; const sameId = columnPairs('users', 'users', [['id', 'id']]); - const graph = new Graph(); - const update = graph.add(updateUsers({ name: 'Ada' }, nameIsAda), { filter: [] }); - graph.setResult( - graph.add(findUsersWith(state), { filter: [new FilterData(update, sameId)] }), - ); + const graph = newGraph(); + const update = graph.add(updateUsers({ name: 'Ada' }, nameIsAda)); + graph.setResult(addFiltered(graph, findUsersWith(state), [update, sameId])); expect(await queryGraph(graph, contextOf(runtime), rowsOfUsers(state))).toEqual([]); expect(statements(runtime)).toEqual(['query update']); @@ -423,9 +435,9 @@ describe('running a graph', () => { const runtime = createMockRuntime(); runtime.setNextResults([[{ id: 1, name: 'Ada', posts: [] }]]); const state = { ...usersWithPosts(), selectedFields: ['id', 'name'] }; - const graph = new Graph(); - const find = graph.add(findUsersWith({ ...state, filters: [nameIsAda] }), { filter: [] }); - graph.add(deleteUsers(nameIsAda), { filter: [] }); + const graph = newGraph(); + const find = graph.add(findUsersWith({ ...state, filters: [nameIsAda] })); + graph.add(deleteUsers(nameIsAda)); graph.setResult(find); const rows = await queryGraph(graph, contextOf(runtime), rowsOfUsers(state)); @@ -439,10 +451,10 @@ describe('running a graph', () => { const runtime = createMockRuntime(); runtime.setNextResults([[{ name: 'Ada', posts: [], id: 1 }]]); const state = { ...usersWithPosts(), selectedFields: ['name'] }; - const graph = new Graph(); - const find = graph.add(findUsersWith({ ...state, filters: [nameIsAda] }), { filter: [] }); + const graph = newGraph(); + const find = graph.add(findUsersWith({ ...state, filters: [nameIsAda] })); const sameId = columnPairs('users', 'users', [['id', 'id']]); - graph.add(deleteUsers(), { filter: [new FilterData(find, sameId)] }); + addFiltered(graph, deleteUsers(), [find, sameId]); graph.setResult(find); const row = await firstRowOf(graph, contextOf(runtime), rowsOfUsers(state)); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/statements.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/statements.ts index eabaec6234f0..39065882be6f 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/statements.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/statements.ts @@ -13,10 +13,13 @@ import type { DescribeCollectionRowsOptions } from '../../src/collection-dispatc import { createCollectionTables } from '../../src/collection-tables'; import { Delete } from '../../src/mutation-graph/delete'; import type { ColumnPair, NodeId, StorageRow } from '../../src/mutation-graph/edge'; +import type { FilterData } from '../../src/mutation-graph/filter-data'; import { Find } from '../../src/mutation-graph/find'; import { Graph } from '../../src/mutation-graph/graph'; -import type { Run, StatementAst } from '../../src/mutation-graph/node'; +import type { Node, Run, Slots, StatementAst } from '../../src/mutation-graph/node'; +import { State } from '../../src/mutation-graph/state'; import { StatementNode } from '../../src/mutation-graph/statement-node'; +import { UnionAll } from '../../src/mutation-graph/union-all'; import { Update } from '../../src/mutation-graph/update'; import { deleteAst, projectTableColumns, updateAst } from '../../src/query-plan-mutations'; import { collectionSelectAst, collectionSelectWithIncludesAst } from '../../src/query-plan-select'; @@ -174,16 +177,32 @@ export function recordingRun(rows: readonly StorageRow[] = [], affectedRows = 0) }; } +export function addFiltered( + graph: Graph, + node: Node, + ...sources: readonly (readonly [NodeId, readonly ColumnPair[]])[] +): NodeId { + const id = graph.add(node); + for (const [from, columns] of sources) { + graph.addFilterDataEdge(from, id, columns); + } + return id; +} + +export function newGraph(): Graph { + return new Graph(contract); +} + export function positions(): [NodeId, NodeId, NodeId] { - const graph = new Graph(); - return [ - graph.add(findUsers(), { filter: [] }), - graph.add(findUsers(), { filter: [] }), - graph.add(findUsers(), { filter: [] }), - ]; + const graph = newGraph(); + return [graph.add(new UnionAll()), graph.add(new UnionAll()), graph.add(new UnionAll())]; +} + +export function visibleNodes(graph: Graph): readonly (readonly [NodeId, Node])[] { + return graph.nodes().filter(([, node]) => !(node instanceof State)); } export function returnedAt(graph: Graph, index: number): string[] { - const [, node] = graph.nodes()[index] ?? []; + const [, node] = visibleNodes(graph)[index] ?? []; return (node instanceof StatementNode ? node.returns : []).map((column) => column.alias); } From dced5251579feb5b06e55d69f17882ca880de682 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 15 Oct 2026 15:29:23 +0000 Subject: [PATCH 03/14] feat(sql-orm-client): create() with relation callbacks runs through the mutation graph create() with relation callbacks builds its graph with createGraph and runs it with queryGraph; the nested create executor is no longer called. The translation adds, in main's phase order, the relations the parent holds the key for (the related Insert, or a Find and an Assert raising ORM.RELATION_ROW_MISSING, with the key copied by PayloadData), the parent's Insert, the relations the child holds the key for (one Insert of the children, or an Update per connect criterion setting the parent's key by PayloadData), and many-to-many relations (target Insert or Find and Assert, then a junction Insert that skips an existing link when the junction has a key over exactly its link columns). Nested creates with their own callbacks recurse. Invalid input is rejected while the graph is built. With includes a final Find reads the parent. Update takes PayloadData records into what it sets, and a junction Insert refuses two records that give one column different values with main's error. The relation definitions and input parsing move to relation-mutations.ts, shared with the executor until update() moves. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- .../sql-orm-client/src/collection-contract.ts | 4 + .../sql-orm-client/src/collection.ts | 38 +- .../sql-orm-client/src/mutation-executor.ts | 310 +------------ .../src/mutation-graph/collection-graphs.ts | 27 +- .../src/mutation-graph/insert.ts | 39 +- .../src/mutation-graph/nested-create.ts | 437 ++++++++++++++++++ .../src/mutation-graph/update.ts | 21 +- .../sql-orm-client/src/relation-mutations.ts | 306 ++++++++++++ .../test/collection.state.test.ts | 8 +- .../test/mutation-executor.test.ts | 8 +- .../test/mutation-graph/create-graph.test.ts | 271 +++++++++++ .../mutation-graph/filtered-statement.test.ts | 15 +- .../test/mutation-graph/insert.test.ts | 29 ++ .../test/mutation-graph/update.test.ts | 31 ++ .../test/orm-call-terminals.test.ts | 5 +- .../nested-create-graph-sqlite.test.ts | 221 +++++++++ .../nested-create-graph.test.ts | 212 +++++++++ 17 files changed, 1629 insertions(+), 353 deletions(-) create mode 100644 packages/3-extensions/sql-orm-client/src/mutation-graph/nested-create.ts create mode 100644 packages/3-extensions/sql-orm-client/src/relation-mutations.ts create mode 100644 packages/3-extensions/sql-orm-client/test/mutation-graph/create-graph.test.ts create mode 100644 packages/3-extensions/sql-orm-client/test/mutation-graph/update.test.ts create mode 100644 test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts create mode 100644 test/integration/test/sql-orm-client/nested-create-graph.test.ts diff --git a/packages/3-extensions/sql-orm-client/src/collection-contract.ts b/packages/3-extensions/sql-orm-client/src/collection-contract.ts index b8705c704102..3b62daf5bcf3 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-contract.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-contract.ts @@ -1004,6 +1004,10 @@ export function assertLockCapability( }); } +export function supportsDefaultInInsert(contract: Contract): boolean { + return contract.capabilities['sql']?.['defaultInInsert'] === true; +} + export function hasContractCapability(contract: Contract, capability: string): boolean { const capabilities = contract.capabilities; const value = capabilities[capability]; diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index e102ef851c41..3b7fc7e007ce 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -135,15 +135,11 @@ import { } from './include-descriptors'; import { assertLockCompatible } from './lock-guards'; import { createModelAccessor, createModelCallbackTools } from './model-accessor'; -import { - buildRowIdentityFilterFromRow, - executeNestedCreateMutation, - executeNestedUpdateMutation, - hasNestedMutationCallbacks, -} from './mutation-executor'; +import { buildRowIdentityFilterFromRow, executeNestedUpdateMutation } from './mutation-executor'; import { createAllGraph, createAllVariantGraph, + createGraph, deleteAllGraph, deleteFirstGraph, updateAllGraph, @@ -168,6 +164,7 @@ import { import { ormError } from './orm-errors'; import type { PreparedCollection } from './prepared-collection'; import { compileAggregate, type InsertConflictSkip } from './query-plan'; +import { hasNestedMutationCallbacks } from './relation-mutations'; import type { AliasedTable } from './table-scope'; import { type AggregateBuilder, @@ -191,7 +188,6 @@ import { type IncludeScalar, type InferRootRow, type ModelCallbackTools, - type MutationCreateInput, type MutationCreateInputWithRelations, type MutationUpdateInput, type RelatedModelName, @@ -2017,32 +2013,26 @@ export class CollectionBase< >(data), ) ) { - const createdRow = await executeNestedCreateMutation({ - context: this.ctx.context, - runtime: queryable, - namespaceId: this.namespaceId, - modelName: this.modelName, - data: blindCast< - MutationCreateInput, string>, + const graph = createGraph( + this.#descriptionOptions(), + blindCast< + Record, 'nested callback detection selects the relation-mutation create input' >(data), - }); - - const identityCriterion = buildRowIdentityFilterFromRow( - this.contract, - this.namespaceId, - this.modelName, - createdRow, ); - const reloaded = await this.#reloadMutationRowByIdentity(queryable, identityCriterion); - if (!reloaded) { + const [created] = await queryGraph( + graph, + this.#on(queryable), + this.#descriptionOptions(), + ).toArray(); + if (created === undefined) { throw ormError( 'ORM.MUTATION_ROW_MISSING', `create() for model "${this.modelName}" did not return a row`, { meta: { operation: 'create', model: this.modelName } }, ); } - return reloaded; + return created; } const rows = await this.#createAllRows([ diff --git a/packages/3-extensions/sql-orm-client/src/mutation-executor.ts b/packages/3-extensions/sql-orm-client/src/mutation-executor.ts index c7be7c727efd..eaf670b3a30a 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-executor.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-executor.ts @@ -7,15 +7,7 @@ import type { RuntimeScope } from '@internal/sql-relational-core/types'; import { settlementOf } from '@internal/sql-runtime/internal/prepared-query'; import { castAs } from '@internal/utils/casts'; import { InternalError } from '@internal/utils/internal-error'; -import { - columnOfContractField, - fieldOfColumn, - getModelColumnFields, - resolveModelRelations, - resolveModelTableName, - resolveRelationTargetColumns, - resolveRowIdentityColumns, -} from './collection-contract'; +import { resolveModelTableName, resolveRowIdentityColumns } from './collection-contract'; import { mapModelDataToStorageRow, mapStorageRowToModelFields } from './collection-runtime'; import { type CollectionTables, createStatementTables } from './collection-tables'; import { and, shorthandToWhereExpr } from './filters'; @@ -30,16 +22,23 @@ import { } from './query-plan'; import { queryPlanRows } from './query-plan-rows'; import { - createRelationMutator, - isRelationMutationCallback, - isRelationMutationDescriptor, -} from './relation-mutator'; + assertJunctionMetadataShape, + assertJunctionParentMetadataLength, + assertJunctionPayloadWritable, + assertJunctionTargetMetadataLength, + type JunctionParsedRelationMutation, + type JunctionRelationDefinition, + type JunctionThrough, + parseMutationInput, + partitionByOwnership, + type RelationDefinition, + toFieldName, +} from './relation-mutations'; import type { AliasedTable } from './table-scope'; import type { CollectionState, MutationCreateInput, MutationUpdateInput, - RelationCardinalityTag, RelationMutation, RuntimeQueryable, RuntimeTransaction, @@ -47,69 +46,6 @@ import type { import { emptyState } from './types'; import { paramRefForStorageColumn } from './where-binding'; -interface JunctionThrough { - readonly table: string; - readonly namespaceId: string; - readonly parentColumns: readonly string[]; - readonly childColumns: readonly string[]; - readonly targetColumns: readonly string[]; - readonly requiredPayloadColumns: readonly string[]; -} - -interface RelationDefinition { - readonly relationName: string; - readonly relatedModelName: string; - readonly relatedNamespaceId: string; - readonly relatedTableName: string; - readonly cardinality: RelationCardinalityTag | undefined; - readonly localColumns: readonly string[]; - readonly targetColumns: readonly string[]; - readonly through: JunctionThrough | undefined; -} - -export interface JunctionRelationDefinition extends RelationDefinition { - readonly through: JunctionThrough; -} - -function hasThrough(relation: RelationDefinition): relation is JunctionRelationDefinition { - return relation.through !== undefined; -} - -interface ParsedRelationMutation { - readonly relation: RelationDefinition; - readonly mutation: RelationMutation, string>; -} - -interface ParsedMutationInput { - readonly scalarData: Record; - readonly relationMutations: readonly ParsedRelationMutation[]; -} - -export function hasNestedMutationCallbacks( - contract: Contract, - namespaceId: string, - modelName: string, - data: Record, -): boolean { - // Only the base model's relation names are needed to detect nested-mutation - // callbacks; resolving relation targets here would eagerly resolve - // cross-namespace targets (and throw on a non-existent target namespace), - // so enumerate names directly without target resolution. - const relationNames = new Set( - Object.keys(resolveModelRelations(contract, namespaceId, modelName)), - ); - for (const [fieldName, value] of Object.entries(data)) { - if (!relationNames.has(fieldName)) { - continue; - } - if (isRelationMutationCallback(value)) { - return true; - } - } - - return false; -} - export async function executeNestedCreateMutation(options: { context: ExecutionContext; runtime: RuntimeQueryable; @@ -427,98 +363,6 @@ async function updateFirstGraph( return parentRow; } -function parseMutationInput( - contract: Contract, - namespaceId: string, - modelName: string, - input: - | MutationCreateInput, string> - | MutationUpdateInput, string>, -): ParsedMutationInput { - const scalarData: Record = {}; - const relationDefinitions = new Map( - getRelationDefinitions(contract, namespaceId, modelName).map((relation) => [ - relation.relationName, - relation, - ]), - ); - - const relationMutations: ParsedRelationMutation[] = []; - - for (const [fieldName, value] of Object.entries(input)) { - const relation = relationDefinitions.get(fieldName); - if (!relation) { - scalarData[fieldName] = value; - continue; - } - - if (!isRelationMutationCallback(value)) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `Relation field "${fieldName}" on model "${modelName}" expects a mutator callback`, - { meta: { relation: fieldName, model: modelName, problem: 'missing-callback' } }, - ); - } - - const mutator = createRelationMutator, string>(); - const mutation = value(mutator); - if (!isRelationMutationDescriptor(mutation)) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `Relation field "${fieldName}" on model "${modelName}" returned an invalid mutation descriptor`, - { meta: { relation: fieldName, model: modelName, problem: 'invalid-descriptor' } }, - ); - } - - relationMutations.push({ - relation, - mutation, - }); - } - - return { - scalarData, - relationMutations, - }; -} - -interface JunctionParsedRelationMutation extends ParsedRelationMutation { - readonly relation: JunctionRelationDefinition; -} - -function partitionByOwnership(relationMutations: readonly ParsedRelationMutation[]): { - parentOwned: ParsedRelationMutation[]; - childOwned: ParsedRelationMutation[]; - junctionOwned: JunctionParsedRelationMutation[]; -} { - const parentOwned: ParsedRelationMutation[] = []; - const childOwned: ParsedRelationMutation[] = []; - const junctionOwned: JunctionParsedRelationMutation[] = []; - - for (const relationMutation of relationMutations) { - if (hasThrough(relationMutation.relation)) { - junctionOwned.push({ - relation: relationMutation.relation, - mutation: relationMutation.mutation, - }); - continue; - } - - if (relationMutation.relation.cardinality === 'N:1') { - parentOwned.push(relationMutation); - continue; - } - - childOwned.push(relationMutation); - } - - return { - parentOwned, - childOwned, - junctionOwned, - }; -} - async function applyParentOwnedMutation( scope: RuntimeScope, context: ExecutionContext, @@ -848,33 +692,6 @@ async function preflightJunctionOwnedCreateMutation( } } -function assertJunctionPayloadWritable( - relation: JunctionRelationDefinition, - mutationKind: RelationMutation, string>['kind'], -): void { - const through = relation.through; - if ( - (mutationKind !== 'create' && mutationKind !== 'connect') || - through.requiredPayloadColumns.length === 0 - ) { - return; - } - - const cols = through.requiredPayloadColumns.map((c) => `\`${c}\``).join(', '); - throw ormError( - 'ORM.RELATION_MUTATION_UNSUPPORTED', - `Cannot \`${mutationKind}\` on relation \`${relation.relationName}\`: its junction \`${through.table}\` has required column(s) ${cols} the relation API can't populate. Write the \`${through.table}\` junction directly or use the SQL builder.`, - { - meta: { - kind: mutationKind, - relation: relation.relationName, - reason: 'junction-required-columns', - junction: through.table, - }, - }, - ); -} - async function resolveJunctionTargetValues( scope: RuntimeScope, context: ExecutionContext, @@ -968,47 +785,6 @@ function readJunctionTargetValues( return values; } -function assertJunctionMetadataLength( - relation: JunctionRelationDefinition, - throughColumnName: string, - throughColumns: readonly string[], - pairedColumnName: string, - pairedColumns: readonly string[], -): void { - if (throughColumns.length === pairedColumns.length) { - return; - } - - throw new InternalError( - `Relation "${relation.relationName}" has invalid junction metadata: ${throughColumnName} has ${throughColumns.length} column(s), but ${pairedColumnName} has ${pairedColumns.length}`, - ); -} - -export function assertJunctionParentMetadataLength(relation: JunctionRelationDefinition): void { - assertJunctionMetadataLength( - relation, - 'parentColumns', - relation.through.parentColumns, - 'localColumns', - relation.localColumns, - ); -} - -export function assertJunctionTargetMetadataLength(relation: JunctionRelationDefinition): void { - assertJunctionMetadataLength( - relation, - 'childColumns', - relation.through.childColumns, - 'targetColumns', - relation.through.targetColumns, - ); -} - -function assertJunctionMetadataShape(relation: JunctionRelationDefinition): void { - assertJunctionParentMetadataLength(relation); - assertJunctionTargetMetadataLength(relation); -} - function writeJunctionColumn( junctionRow: Record, through: JunctionThrough, @@ -1300,63 +1076,3 @@ async function executeUpdateCount( const compiled = compileUpdateCount(contract, tables, setValues, filters); await scope.execute(compiled); } - -const relationDefsCache = new WeakMap>(); - -function getRelationDefinitions( - contract: Contract, - namespaceId: string, - modelName: string, -): RelationDefinition[] { - let perContract = relationDefsCache.get(contract); - if (!perContract) { - perContract = new Map(); - relationDefsCache.set(contract, perContract); - } - const cacheKey = JSON.stringify([namespaceId, modelName]); - const cached = perContract.get(cacheKey); - if (cached) return cached; - - // The base model's relations resolve within its namespace; relation - // targets resolve within the target model's namespace (`relation.toNamespace`, - // carried by the cross-reference) so a cross-namespace relation does not - // fall back to the default/first-match path. - const relations = resolveModelRelations(contract, namespaceId, modelName); - const definitions = Object.entries(relations).map(([relationName, relation]) => ({ - relationName, - relatedModelName: relation.to, - relatedNamespaceId: relation.toNamespace, - relatedTableName: resolveModelTableName(contract, relation.toNamespace, relation.to), - cardinality: relation.cardinality, - localColumns: relation.on.localFields.map((f) => - columnOfContractField(contract, namespaceId, modelName, f), - ), - targetColumns: resolveRelationTargetColumns(contract, relation), - through: relation.through - ? { - table: relation.through.table, - namespaceId: relation.through.namespaceId, - parentColumns: relation.through.parentColumns, - childColumns: relation.through.childColumns, - targetColumns: relation.through.targetColumns, - requiredPayloadColumns: relation.through.requiredPayloadColumns, - } - : undefined, - })); - - perContract.set(cacheKey, definitions); - return definitions; -} - -function toFieldName( - contract: Contract, - namespaceId: string, - modelName: string, - columnName: string, -): string { - return fieldOfColumn( - getModelColumnFields(contract, namespaceId, modelName), - modelName, - columnName, - ); -} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts index 2df47111012c..e8bd53221655 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts @@ -1,5 +1,3 @@ -import type { Contract } from '@internal/contract/types'; -import type { SqlStorage } from '@internal/sql-contract/types'; import { type AnyExpression, ColumnRef, @@ -7,7 +5,11 @@ import { type ProjectionItem, type SelectAst, } from '@internal/sql-relational-core/ast'; -import { resolvePrimaryKeyColumns, resolveRowIdentityColumns } from '../collection-contract'; +import { + resolvePrimaryKeyColumns, + resolveRowIdentityColumns, + supportsDefaultInInsert, +} from '../collection-contract'; import type { DescribeCollectionRowsOptions } from '../collection-dispatch'; import { ormError } from '../orm-errors'; import { @@ -34,6 +36,7 @@ import { Find } from './find'; import { Graph } from './graph'; import { Insert } from './insert'; import { InsertChunked } from './insert-chunked'; +import { addCreateWithRelations } from './nested-create'; import { UnionAll } from './union-all'; import { Update } from './update'; @@ -147,8 +150,22 @@ export function createAllGraph( return graph; } -function supportsDefaultInInsert(contract: Contract): boolean { - return contract.capabilities['sql']?.['defaultInInsert'] === true; +export function createGraph( + collection: DescribeCollectionRowsOptions, + data: Readonly>, +): Graph { + const { context, modelName, namespaceId, state } = collection; + const graph = new Graph(context.contract); + requireIdentityColumnsForIncludes(collection); + const created = addCreateWithRelations( + graph, + context, + { namespaceId, modelName, tables: state.tables }, + data, + [], + ); + graph.setResult(addWithIncludes(graph, collection, created)); + return graph; } export interface VariantCreateRow { diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts index 81cfe9c87b4c..1385a47448ff 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts @@ -1,20 +1,28 @@ import type { InsertAst, ProjectionItem } from '@internal/sql-relational-core/ast'; +import { ormError } from '../orm-errors'; import { withColumns } from './filtered-statement'; import type { Executed, OutputsOf, Run, StatementAst } from './node'; -import type { PayloadData } from './payload-data'; +import type { Payload, PayloadData } from './payload-data'; import { executeStatement, rowsResult } from './run-statement'; import type { StateEdge } from './state'; import { StatementNode } from './statement-node'; type Payloads = { payload: PayloadData[]; state: StateEdge[] }; +export interface InsertJunction { + readonly relation: string; + readonly junction: string; +} + export class Insert extends StatementNode { readonly slots = { payload: 'list', state: 'list' } as const; readonly ast: InsertAst; + readonly junction: InsertJunction | undefined; - constructor(ast: InsertAst) { + constructor(ast: InsertAst, junction?: InsertJunction) { super(); this.ast = ast; + this.junction = junction; Object.freeze(this); } @@ -35,12 +43,35 @@ export class Insert extends StatementNode { if (inputs.payload.some((records) => records.length === 0)) { return rowsResult([]); } - const payload = Object.assign({}, ...inputs.payload.map(([record]) => record)); + const records = inputs.payload.map(([record]) => record); + if (this.junction !== undefined) { + assertNoConflictingColumn(records, this.junction); + } + const payload = Object.assign({}, ...records); const rows = this.ast.rows.map((row) => ({ ...row, ...payload })); return executeStatement(this.ast.withRows(rows), run); } override withReturning(columns: readonly ProjectionItem[]): Insert { - return new Insert(this.ast.withReturning(withColumns(this.returns, columns))); + return new Insert(this.ast.withReturning(withColumns(this.returns, columns)), this.junction); + } +} + +function assertNoConflictingColumn( + records: readonly (Payload | undefined)[], + { relation, junction }: InsertJunction, +): void { + const seen = new Map(); + for (const record of records) { + for (const [column, param] of Object.entries(record ?? {})) { + if (seen.has(column) && !Object.is(seen.get(column), param.value)) { + throw ormError( + 'ORM.RELATION_MUTATION_INVALID', + `Cannot write junction "${junction}": conflicting values for junction column "${column}"`, + { meta: { relation, junction, column } }, + ); + } + seen.set(column, param.value); + } } } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/nested-create.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/nested-create.ts new file mode 100644 index 000000000000..caef9494b3c4 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/nested-create.ts @@ -0,0 +1,437 @@ +import type { Contract } from '@internal/contract/types'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import type { ExecutionContext } from '@internal/sql-relational-core/query-lane-context'; +import { castAs } from '@internal/utils/casts'; +import { supportsDefaultInInsert } from '../collection-contract'; +import { mapModelDataToStorageRow } from '../collection-runtime'; +import { type CollectionTables, createStatementTables } from '../collection-tables'; +import { shorthandToWhereExpr } from '../filters'; +import { ormError } from '../orm-errors'; +import { + groupRowsByColumnSignature, + type InsertConflictSkip, + insertAst, + projectTableColumn, + updateAst, +} from '../query-plan-mutations'; +import { collectionSelectAst } from '../query-plan-select'; +import { + assertJunctionMetadataShape, + assertJunctionPayloadWritable, + hasNestedMutationCallbacks, + type JunctionRelationDefinition, + parseMutationInput, + partitionByOwnership, + type RelationDefinition, +} from '../relation-mutations'; +import { storageTableForContract } from '../storage-resolution'; +import type { AliasedTable } from '../table-scope'; +import type { MutationCreateInput, MutationUpdateInput, RelationMutation } from '../types'; +import { emptyState } from '../types'; +import { Assert } from './assert'; +import type { ColumnPair, NodeId } from './edge'; +import { Find } from './find'; +import type { Graph } from './graph'; +import { Insert } from './insert'; +import { Update } from './update'; + +type Mutation = RelationMutation, string>; +type CreateOrConnect = Exclude; + +interface KeySource { + readonly from: NodeId; + readonly columns: readonly ColumnPair[]; +} + +interface Model { + readonly namespaceId: string; + readonly modelName: string; + readonly tables: CollectionTables; +} + +export function addCreateWithRelations( + graph: Graph, + context: ExecutionContext, + model: Model, + input: Readonly>, + keysFromParent: readonly KeySource[], +): NodeId { + const { contract } = context; + const parsed = parseMutationInput( + contract, + model.namespaceId, + model.modelName, + castAs, string>>(input), + ); + const { parentOwned, childOwned, junctionOwned } = partitionByOwnership(parsed.relationMutations); + + const keys = [...keysFromParent]; + for (const { relation, mutation } of parentOwned) { + rejectDisconnect(relation, mutation); + keys.push(addParentOwned(graph, context, model, relation, mutation)); + } + for (const { relation, mutation } of junctionOwned) { + rejectDisconnect(relation, mutation); + assertJunctionMetadataShape(relation); + assertJunctionPayloadWritable(relation, mutation.kind); + } + + const parent = addParentInsert(graph, context, model, parsed.scalarData, keys); + + for (const { relation, mutation } of childOwned) { + rejectDisconnect(relation, mutation); + addChildOwned(graph, context, model, parent, relation, mutation); + } + for (const { relation, mutation } of junctionOwned) { + addJunctionOwned(graph, context, model, parent, relation, mutation); + } + return parent; +} + +function rejectDisconnect( + relation: RelationDefinition, + mutation: Mutation, +): asserts mutation is CreateOrConnect { + if (mutation.kind === 'disconnect') { + throw ormError( + 'ORM.RELATION_MUTATION_UNSUPPORTED', + 'disconnect() is only supported in update() nested mutations', + { meta: { kind: 'disconnect', relation: relation.relationName } }, + ); + } +} + +function addParentOwned( + graph: Graph, + context: ExecutionContext, + model: Model, + relation: RelationDefinition, + mutation: CreateOrConnect, +): KeySource { + const related = relatedModel(relation); + const columns = pairs( + context.contract, + related.tables.root, + relation.targetColumns, + model.tables.root, + relation.localColumns, + ); + if (mutation.kind === 'create') { + const [row] = mutation.data; + if (row === undefined) { + throw ormError( + 'ORM.RELATION_MUTATION_INVALID', + `create() nested mutation for relation "${relation.relationName}" requires data`, + { meta: { kind: 'create', relation: relation.relationName, problem: 'missing-data' } }, + ); + } + return { from: addCreateWithRelations(graph, context, related, row, []), columns }; + } + const [criterion] = mutation.criteria; + if (criterion === undefined) { + throw ormError( + 'ORM.RELATION_MUTATION_INVALID', + `connect() nested mutation for relation "${relation.relationName}" requires criterion`, + { meta: { kind: 'connect', relation: relation.relationName, problem: 'missing-criterion' } }, + ); + } + const found = addFindAndAssert(graph, context, related, relation, criterion, columns); + return { from: found, columns }; +} + +function addChildOwned( + graph: Graph, + context: ExecutionContext, + model: Model, + parent: NodeId, + relation: RelationDefinition, + mutation: Mutation, +): void { + const related = relatedModel(relation); + const keys = [ + { + from: parent, + columns: pairs( + context.contract, + model.tables.root, + relation.localColumns, + related.tables.root, + relation.targetColumns, + ), + }, + ]; + if (mutation.kind === 'create') { + const rows = mutation.data.map((row) => castAs>>(row)); + if (rows.some((row) => hasRelationCallbacks(context, related, row))) { + for (const row of rows) { + addCreateWithRelations(graph, context, related, row, keys); + } + return; + } + addChildInserts(graph, context, related, rows, keys); + return; + } + if (mutation.kind === 'connect') { + for (const criterion of mutation.criteria) { + const where = criterionWhere(context, related, criterion); + if (where === undefined) { + throw ormError( + 'ORM.RELATION_MUTATION_INVALID', + `connect() nested mutation for relation "${relation.relationName}" requires non-empty criterion`, + { + meta: { kind: 'connect', relation: relation.relationName, problem: 'empty-criterion' }, + }, + ); + } + const update = graph.add(new Update(updateAst(context.contract, related.tables, {}, where))); + for (const { from, columns } of keys) { + graph.addPayloadDataEdge(from, update, columns); + } + } + } +} + +function addJunctionOwned( + graph: Graph, + context: ExecutionContext, + model: Model, + parent: NodeId, + relation: JunctionRelationDefinition, + mutation: Mutation, +): void { + const { contract } = context; + const { through } = relation; + const related = relatedModel(relation); + const junction = createStatementTables({ + namespaceId: through.namespaceId, + tableName: through.table, + }); + const fromParent = pairs( + contract, + model.tables.root, + relation.localColumns, + junction.root, + through.parentColumns, + ); + const fromTarget = pairs( + contract, + related.tables.root, + through.targetColumns, + junction.root, + through.childColumns, + ); + const addLink = (target: NodeId, conflict: InsertConflictSkip | undefined) => { + const values = withCreateDefaults(context, through.namespaceId, through.table, {}); + const link = graph.add( + new Insert(insertAst(contract, junction.root, [values], conflict), { + relation: relation.relationName, + junction: through.table, + }), + ); + graph.addPayloadDataEdge(parent, link, fromParent); + graph.addPayloadDataEdge(target, link, fromTarget); + }; + + if (mutation.kind === 'create') { + for (const row of mutation.data) { + addLink(addCreateWithRelations(graph, context, related, row, []), undefined); + } + return; + } + if (mutation.kind === 'connect') { + const conflict = linkKeyConflict(contract, relation); + for (const criterion of mutation.criteria) { + addLink(addFindAndAssert(graph, context, related, relation, criterion, fromTarget), conflict); + } + } +} + +function addParentInsert( + graph: Graph, + context: ExecutionContext, + model: Model, + row: Readonly>, + keys: readonly KeySource[], +): NodeId { + return addInsertOf(graph, context, model, [storageRowOf(context, model, row)], keys); +} + +function addChildInserts( + graph: Graph, + context: ExecutionContext, + model: Model, + rows: readonly Readonly>[], + keys: readonly KeySource[], +): void { + const values = rows.map((row) => storageRowOf(context, model, row)); + const groups = supportsDefaultInInsert(context.contract) + ? [values] + : groupRowsByColumnSignature(values); + for (const group of groups) { + addInsertOf(graph, context, model, group, keys); + } +} + +function addInsertOf( + graph: Graph, + context: ExecutionContext, + model: Model, + values: readonly Record[], + keys: readonly KeySource[], +): NodeId { + const insert = graph.add( + new Insert(insertAst(context.contract, model.tables.root, values, undefined)), + ); + for (const { from, columns } of keys) { + graph.addPayloadDataEdge(from, insert, columns); + } + return insert; +} + +function storageRowOf( + context: ExecutionContext, + model: Model, + row: Readonly>, +): Record { + const { namespaceId, tableName } = model.tables.root.storage; + return withCreateDefaults( + context, + namespaceId, + tableName, + mapModelDataToStorageRow(context.contract, namespaceId, model.modelName, row), + ); +} + +function addFindAndAssert( + graph: Graph, + context: ExecutionContext, + related: Model, + relation: RelationDefinition, + criterion: Readonly>, + columns: readonly ColumnPair[], +): NodeId { + const where = criterionWhere(context, related, criterion); + if (where === undefined) { + throw ormError( + 'ORM.RELATION_MUTATION_INVALID', + `Nested connect for model "${related.modelName}" requires non-empty criterion`, + { meta: { kind: 'connect', model: related.modelName, problem: 'empty-criterion' } }, + ); + } + const find = graph.add( + new Find( + collectionSelectAst(context.contract, related.modelName, { + ...emptyState(related.tables), + filters: [where], + limit: 1, + }), + ), + ); + const assert = graph.add( + new Assert( + ormError( + 'ORM.RELATION_ROW_MISSING', + `connect() nested mutation for relation "${relation.relationName}" did not find a matching row`, + { meta: { kind: 'connect', relation: relation.relationName } }, + ), + ), + ); + graph.addRowsEdge( + find, + assert, + 'rows', + columns.map(([source]) => [source, source]), + ); + return find; +} + +function criterionWhere( + context: ExecutionContext, + model: Model, + criterion: Readonly>, +) { + return shorthandToWhereExpr( + context, + model.namespaceId, + model.modelName, + castAs, string>>(criterion), + model.tables.root, + ); +} + +function withCreateDefaults( + context: ExecutionContext, + namespaceId: string, + tableName: string, + values: Record, +): Record { + const withDefaults = { ...values }; + for (const applied of context.applyMutationDefaults({ + op: 'create', + entry: tableName, + namespace: namespaceId, + values, + })) { + withDefaults[applied.field] = applied.value; + } + return withDefaults; +} + +function hasRelationCallbacks( + context: ExecutionContext, + model: Model, + row: Readonly>, +): boolean { + return hasNestedMutationCallbacks(context.contract, model.namespaceId, model.modelName, { + ...row, + }); +} + +function linkKeyConflict( + contract: Contract, + relation: JunctionRelationDefinition, +): InsertConflictSkip | undefined { + const { through } = relation; + const linkColumns = new Set([...through.parentColumns, ...through.childColumns]); + const table = storageTableForContract(contract, through.namespaceId, through.table); + const keys = [ + ...(table.primaryKey === undefined ? [] : [table.primaryKey.columns]), + ...table.uniques.map((unique) => unique.columns), + ]; + const key = keys.find( + (columns) => + columns.length === linkColumns.size && columns.every((column) => linkColumns.has(column)), + ); + return key === undefined ? undefined : { columns: key }; +} + +function relatedModel(relation: RelationDefinition): Model { + return { + namespaceId: relation.relatedNamespaceId, + modelName: relation.relatedModelName, + tables: createStatementTables({ + namespaceId: relation.relatedNamespaceId, + tableName: relation.relatedTableName, + }), + }; +} + +function pairs( + contract: Contract, + source: AliasedTable, + sourceColumns: readonly string[], + target: AliasedTable, + targetColumns: readonly string[], +): ColumnPair[] { + return sourceColumns.flatMap((column, index) => { + const targetColumn = targetColumns[index]; + return targetColumn === undefined + ? [] + : [ + [ + projectTableColumn(contract, source, column), + projectTableColumn(contract, target, targetColumn), + ] as const, + ]; + }); +} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts index 358c8e0bdbfe..2b8234172712 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts @@ -1,10 +1,14 @@ import type { ProjectionItem, UpdateAst } from '@internal/sql-relational-core/ast'; -import { executeFiltered, type Filtered, filteredSlots, withColumns } from './filtered-statement'; +import { executeFiltered, type Filtered, withColumns } from './filtered-statement'; import type { Executed, OutputsOf, Run, StatementAst } from './node'; +import type { PayloadData } from './payload-data'; +import { rowsResult } from './run-statement'; import { StatementNode } from './statement-node'; -export class Update extends StatementNode { - readonly slots = filteredSlots; +type Inputs = Filtered & { payload: PayloadData[] }; + +export class Update extends StatementNode { + readonly slots = { filter: 'list', payload: 'list', state: 'list' } as const; readonly ast: UpdateAst; constructor(ast: UpdateAst) { @@ -21,8 +25,15 @@ export class Update extends StatementNode { return this.ast.returning ?? []; } - override execute(inputs: OutputsOf, run: Run): Executed { - return executeFiltered(this.ast, inputs.filter, run); + override execute(inputs: OutputsOf, run: Run): Executed { + if (inputs.payload === null) { + return executeFiltered(this.ast, inputs.filter, run); + } + if (inputs.payload.some((records) => records.length === 0)) { + return rowsResult([]); + } + const payload = Object.assign({}, ...inputs.payload.map(([record]) => record)); + return executeFiltered(this.ast.withSet({ ...this.ast.set, ...payload }), inputs.filter, run); } override withReturning(columns: readonly ProjectionItem[]): Update { diff --git a/packages/3-extensions/sql-orm-client/src/relation-mutations.ts b/packages/3-extensions/sql-orm-client/src/relation-mutations.ts new file mode 100644 index 000000000000..45eff7c4ecd1 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/src/relation-mutations.ts @@ -0,0 +1,306 @@ +import type { Contract } from '@internal/contract/types'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { InternalError } from '@internal/utils/internal-error'; +import { + columnOfContractField, + fieldOfColumn, + getModelColumnFields, + resolveModelRelations, + resolveModelTableName, + resolveRelationTargetColumns, +} from './collection-contract'; +import { ormError } from './orm-errors'; +import { + createRelationMutator, + isRelationMutationCallback, + isRelationMutationDescriptor, +} from './relation-mutator'; +import type { + MutationCreateInput, + MutationUpdateInput, + RelationCardinalityTag, + RelationMutation, +} from './types'; + +export interface JunctionThrough { + readonly table: string; + readonly namespaceId: string; + readonly parentColumns: readonly string[]; + readonly childColumns: readonly string[]; + readonly targetColumns: readonly string[]; + readonly requiredPayloadColumns: readonly string[]; +} + +export interface RelationDefinition { + readonly relationName: string; + readonly relatedModelName: string; + readonly relatedNamespaceId: string; + readonly relatedTableName: string; + readonly cardinality: RelationCardinalityTag | undefined; + readonly localColumns: readonly string[]; + readonly targetColumns: readonly string[]; + readonly through: JunctionThrough | undefined; +} + +export interface JunctionRelationDefinition extends RelationDefinition { + readonly through: JunctionThrough; +} + +export function hasThrough(relation: RelationDefinition): relation is JunctionRelationDefinition { + return relation.through !== undefined; +} + +export interface ParsedRelationMutation { + readonly relation: RelationDefinition; + readonly mutation: RelationMutation, string>; +} + +export interface ParsedMutationInput { + readonly scalarData: Record; + readonly relationMutations: readonly ParsedRelationMutation[]; +} + +export function parseMutationInput( + contract: Contract, + namespaceId: string, + modelName: string, + input: + | MutationCreateInput, string> + | MutationUpdateInput, string>, +): ParsedMutationInput { + const scalarData: Record = {}; + const relationDefinitions = new Map( + getRelationDefinitions(contract, namespaceId, modelName).map((relation) => [ + relation.relationName, + relation, + ]), + ); + + const relationMutations: ParsedRelationMutation[] = []; + + for (const [fieldName, value] of Object.entries(input)) { + const relation = relationDefinitions.get(fieldName); + if (!relation) { + scalarData[fieldName] = value; + continue; + } + + if (!isRelationMutationCallback(value)) { + throw ormError( + 'ORM.RELATION_MUTATION_INVALID', + `Relation field "${fieldName}" on model "${modelName}" expects a mutator callback`, + { meta: { relation: fieldName, model: modelName, problem: 'missing-callback' } }, + ); + } + + const mutator = createRelationMutator, string>(); + const mutation = value(mutator); + if (!isRelationMutationDescriptor(mutation)) { + throw ormError( + 'ORM.RELATION_MUTATION_INVALID', + `Relation field "${fieldName}" on model "${modelName}" returned an invalid mutation descriptor`, + { meta: { relation: fieldName, model: modelName, problem: 'invalid-descriptor' } }, + ); + } + + relationMutations.push({ + relation, + mutation, + }); + } + + return { + scalarData, + relationMutations, + }; +} + +export interface JunctionParsedRelationMutation extends ParsedRelationMutation { + readonly relation: JunctionRelationDefinition; +} + +export function partitionByOwnership(relationMutations: readonly ParsedRelationMutation[]): { + parentOwned: ParsedRelationMutation[]; + childOwned: ParsedRelationMutation[]; + junctionOwned: JunctionParsedRelationMutation[]; +} { + const parentOwned: ParsedRelationMutation[] = []; + const childOwned: ParsedRelationMutation[] = []; + const junctionOwned: JunctionParsedRelationMutation[] = []; + + for (const relationMutation of relationMutations) { + if (hasThrough(relationMutation.relation)) { + junctionOwned.push({ + relation: relationMutation.relation, + mutation: relationMutation.mutation, + }); + continue; + } + + if (relationMutation.relation.cardinality === 'N:1') { + parentOwned.push(relationMutation); + continue; + } + + childOwned.push(relationMutation); + } + + return { + parentOwned, + childOwned, + junctionOwned, + }; +} + +export function assertJunctionPayloadWritable( + relation: JunctionRelationDefinition, + mutationKind: RelationMutation, string>['kind'], +): void { + const through = relation.through; + if ( + (mutationKind !== 'create' && mutationKind !== 'connect') || + through.requiredPayloadColumns.length === 0 + ) { + return; + } + + const cols = through.requiredPayloadColumns.map((c) => `\`${c}\``).join(', '); + throw ormError( + 'ORM.RELATION_MUTATION_UNSUPPORTED', + `Cannot \`${mutationKind}\` on relation \`${relation.relationName}\`: its junction \`${through.table}\` has required column(s) ${cols} the relation API can't populate. Write the \`${through.table}\` junction directly or use the SQL builder.`, + { + meta: { + kind: mutationKind, + relation: relation.relationName, + reason: 'junction-required-columns', + junction: through.table, + }, + }, + ); +} + +function assertJunctionMetadataLength( + relation: JunctionRelationDefinition, + throughColumnName: string, + throughColumns: readonly string[], + pairedColumnName: string, + pairedColumns: readonly string[], +): void { + if (throughColumns.length === pairedColumns.length) { + return; + } + + throw new InternalError( + `Relation "${relation.relationName}" has invalid junction metadata: ${throughColumnName} has ${throughColumns.length} column(s), but ${pairedColumnName} has ${pairedColumns.length}`, + ); +} + +export function assertJunctionParentMetadataLength(relation: JunctionRelationDefinition): void { + assertJunctionMetadataLength( + relation, + 'parentColumns', + relation.through.parentColumns, + 'localColumns', + relation.localColumns, + ); +} + +export function assertJunctionTargetMetadataLength(relation: JunctionRelationDefinition): void { + assertJunctionMetadataLength( + relation, + 'childColumns', + relation.through.childColumns, + 'targetColumns', + relation.through.targetColumns, + ); +} + +export function assertJunctionMetadataShape(relation: JunctionRelationDefinition): void { + assertJunctionParentMetadataLength(relation); + assertJunctionTargetMetadataLength(relation); +} + +const relationDefsCache = new WeakMap>(); + +export function getRelationDefinitions( + contract: Contract, + namespaceId: string, + modelName: string, +): RelationDefinition[] { + let perContract = relationDefsCache.get(contract); + if (!perContract) { + perContract = new Map(); + relationDefsCache.set(contract, perContract); + } + const cacheKey = JSON.stringify([namespaceId, modelName]); + const cached = perContract.get(cacheKey); + if (cached) return cached; + + // The base model's relations resolve within its namespace; relation + // targets resolve within the target model's namespace (`relation.toNamespace`, + // carried by the cross-reference) so a cross-namespace relation does not + // fall back to the default/first-match path. + const relations = resolveModelRelations(contract, namespaceId, modelName); + const definitions = Object.entries(relations).map(([relationName, relation]) => ({ + relationName, + relatedModelName: relation.to, + relatedNamespaceId: relation.toNamespace, + relatedTableName: resolveModelTableName(contract, relation.toNamespace, relation.to), + cardinality: relation.cardinality, + localColumns: relation.on.localFields.map((f) => + columnOfContractField(contract, namespaceId, modelName, f), + ), + targetColumns: resolveRelationTargetColumns(contract, relation), + through: relation.through + ? { + table: relation.through.table, + namespaceId: relation.through.namespaceId, + parentColumns: relation.through.parentColumns, + childColumns: relation.through.childColumns, + targetColumns: relation.through.targetColumns, + requiredPayloadColumns: relation.through.requiredPayloadColumns, + } + : undefined, + })); + + perContract.set(cacheKey, definitions); + return definitions; +} + +export function toFieldName( + contract: Contract, + namespaceId: string, + modelName: string, + columnName: string, +): string { + return fieldOfColumn( + getModelColumnFields(contract, namespaceId, modelName), + modelName, + columnName, + ); +} + +export function hasNestedMutationCallbacks( + contract: Contract, + namespaceId: string, + modelName: string, + data: Record, +): boolean { + // Only the base model's relation names are needed to detect nested-mutation + // callbacks; resolving relation targets here would eagerly resolve + // cross-namespace targets (and throw on a non-existent target namespace), + // so enumerate names directly without target resolution. + const relationNames = new Set( + Object.keys(resolveModelRelations(contract, namespaceId, modelName)), + ); + for (const [fieldName, value] of Object.entries(data)) { + if (!relationNames.has(fieldName)) { + continue; + } + if (isRelationMutationCallback(value)) { + return true; + } + } + + return false; +} diff --git a/packages/3-extensions/sql-orm-client/test/collection.state.test.ts b/packages/3-extensions/sql-orm-client/test/collection.state.test.ts index 682b057991fe..4e0f4d71dbdd 100644 --- a/packages/3-extensions/sql-orm-client/test/collection.state.test.ts +++ b/packages/3-extensions/sql-orm-client/test/collection.state.test.ts @@ -329,13 +329,9 @@ describe('Collection', () => { await expect(collection.createAndCount([])).resolves.toBe(0); }); - it('create() nested mutation throws when reload by primary key returns no row', async () => { + it('create() nested mutation throws when the insert of the parent returns no row', async () => { const { collection, runtime } = createReturningCollectionFor('User'); - runtime.setNextResults([ - [{ id: 1, name: 'Alice', email: 'alice@example.com' }], - [{ id: 10, title: 'Post', user_id: 1, views: 1 }], - [], - ]); + runtime.setNextResults([[]]); await expect( collection.create({ diff --git a/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts index dc095122f35d..534a092454bb 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts @@ -10,14 +10,16 @@ import { import { describe, expect, it, vi } from 'vitest'; import { createCollectionTables } from '../src/collection-tables'; import { - assertJunctionParentMetadataLength, - assertJunctionTargetMetadataLength, buildRowIdentityFilterFromRow, executeNestedCreateMutation, executeNestedUpdateMutation, +} from '../src/mutation-executor'; +import { + assertJunctionParentMetadataLength, + assertJunctionTargetMetadataLength, hasNestedMutationCallbacks, type JunctionRelationDefinition, -} from '../src/mutation-executor'; +} from '../src/relation-mutations'; import { defineContract, field, model } from './contract-builder'; import type { MockRuntime } from './helpers'; import { diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/create-graph.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/create-graph.test.ts new file mode 100644 index 000000000000..fbbe7cb83436 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/create-graph.test.ts @@ -0,0 +1,271 @@ +import type { Contract } from '@internal/contract/types'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/mutation-graph/collection-graphs'; +import type { RelationMutator } from '../../src/types'; +import { createCollectionFor } from '../collection-fixtures'; +import { getTestContext } from '../helpers'; +import { printGraph } from './print-graph'; + +type Mutator = RelationMutator, string>; +type Callback = (mutator: Mutator) => unknown; + +function targetOf(modelName: 'User' | 'Post', withPosts = false) { + const state = withPosts + ? createCollectionFor('User').collection.include('posts').state + : createCollectionFor(modelName).collection.state; + return { context: getTestContext(), state, modelName, namespaceId: 'public' }; +} + +function printCreate(modelName: 'User' | 'Post', data: Record): string { + return printGraph(createGraph(targetOf(modelName), data)); +} + +const ada = { id: 1, name: 'Ada', email: 'ada@example.com' }; +const hello = { id: 10, title: 'Hello', views: 0 }; +const createPost: Callback = (posts) => posts.create([hello]); + +describe('createGraph', () => { + describe('a relation the parent holds the key for', () => { + it('inserts the related row first and copies its key into the parent', () => { + expect( + printCreate('Post', { ...hello, author: (author: Mutator) => author.create(ada) }), + ).toBe( + ['n1 Insert users', 'n2 Insert posts <- PayloadData n1 (id->user_id)', 'result: n2'].join( + '\n', + ), + ); + }); + + it('finds the row to connect, asserts it exists and copies its key into the parent', () => { + expect( + printCreate('Post', { + ...hello, + author: (author: Mutator) => author.connect({ email: 'ada@example.com' }), + }), + ).toBe( + [ + "n1 Find users where email = 'ada@example.com' limit 1", + 'n2 Assert <- Rows n1', + 'n3 Insert posts <- PayloadData n1 (id->user_id)', + 'result: n3', + ].join('\n'), + ); + }); + + it('rejects disconnect while the graph is built', () => { + expect(() => + createGraph(targetOf('Post'), { + ...hello, + author: (author: Mutator) => author.disconnect(), + }), + ).toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_UNSUPPORTED', + message: 'disconnect() is only supported in update() nested mutations', + meta: { kind: 'disconnect', relation: 'author' }, + }), + ); + }); + }); + + describe('a relation the child holds the key for', () => { + it('inserts the children in one Insert with the key of the parent', () => { + expect( + printCreate('User', { + ...ada, + posts: (posts: Mutator) => posts.create([hello, { id: 11, title: 'Again', views: 1 }]), + }), + ).toBe( + [ + 'n1 Insert users', + 'n2 Insert posts 2 rows <- PayloadData n1 (id->user_id)', + 'result: n1', + ].join('\n'), + ); + }); + + it('sets the key of the parent on the children each criterion matches', () => { + expect( + printCreate('User', { + ...ada, + posts: (posts: Mutator) => posts.connect([{ id: 10 }, { id: 11 }]), + }), + ).toBe( + [ + 'n1 Insert users', + 'n2 Update posts set nothing where id = 10 <- PayloadData n1 (id->user_id)', + 'n3 Update posts set nothing where id = 11 <- PayloadData n1 (id->user_id)', + 'result: n1', + ].join('\n'), + ); + }); + + it('rejects disconnect while the graph is built', () => { + expect(() => + createGraph(targetOf('User'), { + ...ada, + posts: (posts: Mutator) => posts.disconnect(), + }), + ).toThrow(expect.objectContaining({ code: 'ORM.RELATION_MUTATION_UNSUPPORTED' })); + }); + }); + + describe('a many-to-many relation', () => { + it('inserts the target row, then the junction row with both keys', () => { + expect( + printCreate('User', { + ...ada, + tags: (tags: Mutator) => tags.create([{ id: 't1', name: 'SQL' }]), + }), + ).toBe( + [ + 'n1 Insert users', + 'n2 Insert tags', + 'n3 Insert user_tags <- PayloadData n1 (id->user_id), PayloadData n2 (id->tag_id)', + 'result: n1', + ].join('\n'), + ); + }); + + it('finds and asserts each target, then inserts a junction row that skips an existing link', () => { + expect( + printCreate('User', { + ...ada, + tags: (tags: Mutator) => tags.connect([{ name: 'SQL' }, { name: 'Rust' }]), + }), + ).toBe( + [ + 'n1 Insert users', + "n2 Find tags where name = 'SQL' limit 1", + 'n3 Assert <- Rows n2', + 'n4 Insert user_tags on conflict (user_id, tag_id) do nothing <- PayloadData n1 (id->user_id), PayloadData n2 (id->tag_id)', + "n5 Find tags where name = 'Rust' limit 1", + 'n6 Assert <- Rows n5', + 'n7 Insert user_tags on conflict (user_id, tag_id) do nothing <- PayloadData n1 (id->user_id), PayloadData n5 (id->tag_id)', + 'result: n1', + ].join('\n'), + ); + }); + + it('rejects a junction with a required column the relation cannot fill', () => { + expect(() => + createGraph(targetOf('User'), { + ...ada, + roles: (roles: Mutator) => roles.connect({ id: 1 }), + }), + ).toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_UNSUPPORTED', + meta: { + kind: 'connect', + relation: 'roles', + reason: 'junction-required-columns', + junction: 'user_roles', + }, + }), + ); + }); + }); + + describe('phase order and recursion', () => { + it('adds parent-held relations, the parent, child-held relations, then many-to-many', () => { + expect( + printCreate('User', { + ...ada, + tags: (tags: Mutator) => tags.create([{ id: 't1', name: 'SQL' }]), + posts: createPost, + invitedBy: (inviter: Mutator) => inviter.connect({ id: 2 }), + }), + ).toBe( + [ + 'n1 Find users where id = 2 limit 1', + 'n2 Assert <- Rows n1', + 'n3 Insert users <- PayloadData n1 (id->invited_by_id)', + 'n4 Insert posts <- PayloadData n3 (id->user_id)', + 'n5 Insert tags', + 'n6 Insert user_tags <- PayloadData n3 (id->user_id), PayloadData n5 (id->tag_id)', + 'result: n3', + ].join('\n'), + ); + }); + + it('translates the relations of a nested create in the same order, one child row at a time', () => { + expect( + printCreate('User', { + ...ada, + posts: (posts: Mutator) => + posts.create([ + { + ...hello, + comments: (comments: Mutator) => comments.create([{ id: 100, body: 'Hi' }]), + }, + { id: 11, title: 'Again', views: 1 }, + ]), + }), + ).toBe( + [ + 'n1 Insert users', + 'n2 Insert posts <- PayloadData n1 (id->user_id)', + 'n3 Insert comments <- PayloadData n2 (id->post_id)', + 'n4 Insert posts <- PayloadData n1 (id->user_id)', + 'result: n1', + ].join('\n'), + ); + }); + + it('reads the parent with its includes after every child operation', () => { + const graph = createGraph(targetOf('User', true), { ...ada, posts: createPost }); + + expect(printGraph(graph)).toBe( + [ + 'n1 Insert users', + 'n2 Insert posts <- PayloadData n1 (id->user_id)', + 'n3 Find users <- FilterData n1 (id->id)', + 'result: n3', + ].join('\n'), + ); + }); + }); + + describe('rejections while the graph is built', () => { + it('rejects a relation field that is not a callback', () => { + expect(() => + createGraph(targetOf('User'), { ...ada, posts: [hello], tags: createPost }), + ).toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_INVALID', + message: 'Relation field "posts" on model "User" expects a mutator callback', + meta: { relation: 'posts', model: 'User', problem: 'missing-callback' }, + }), + ); + }); + + it('rejects a connect without a criterion on a relation the parent holds the key for', () => { + expect(() => + createGraph(targetOf('Post'), { + ...hello, + author: (author: Mutator) => author.connect([]), + }), + ).toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_INVALID', + message: 'connect() nested mutation for relation "author" requires criterion', + meta: { kind: 'connect', relation: 'author', problem: 'missing-criterion' }, + }), + ); + }); + + it('rejects an empty criterion on a relation the child holds the key for', () => { + expect(() => + createGraph(targetOf('User'), { ...ada, posts: (posts: Mutator) => posts.connect({}) }), + ).toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_INVALID', + message: 'connect() nested mutation for relation "posts" requires non-empty criterion', + meta: { kind: 'connect', relation: 'posts', problem: 'empty-criterion' }, + }), + ); + }); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts index 2bb17adcd466..9669ffc1ba90 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts @@ -36,7 +36,7 @@ describe.each([ const node = make(); const run = recordingRun([{ id: 1 }]); - const rows = await node.execute({ filter: null, state: null }, run); + const rows = await node.execute({ filter: null, payload: null, state: null }, run); expect(rows).toEqual([{ id: 1 }]); expect(run.queried).toEqual([node.ast]); @@ -45,7 +45,7 @@ describe.each([ it('adds the conditions of one edge to its where, or between the rows of the edge', async () => { const run = recordingRun(); - await make().execute({ filter: [[idIs(7), idIs(8)]], state: null }, run); + await make().execute({ filter: [[idIs(7), idIs(8)]], payload: null, state: null }, run); expect(run.queried.map(whereOf)).toEqual(["(name = 'Ada' and (id = 7 or id = 8))"]); }); @@ -53,7 +53,10 @@ describe.each([ it('puts and between the edges', async () => { const run = recordingRun(); - await make().execute({ filter: [[idIs(7)], [idIs(8), idIs(9)]], state: null }, run); + await make().execute( + { filter: [[idIs(7)], [idIs(8), idIs(9)]], payload: null, state: null }, + run, + ); expect(run.queried.map(whereOf)).toEqual(["(name = 'Ada' and id = 7 and (id = 8 or id = 9))"]); }); @@ -61,7 +64,7 @@ describe.each([ it('returns no rows and runs nothing when an edge has no source row', async () => { const run = recordingRun([{ id: 1 }]); - const rows = await make().execute({ filter: [[idIs(7)], []], state: null }, run); + const rows = await make().execute({ filter: [[idIs(7)], []], payload: null, state: null }, run); expect(rows).toEqual([]); expect(run.queried).toEqual([]); @@ -87,7 +90,7 @@ describe.each([ const node = make(); const run = recordingRun([], 3); - expect(await node.execute({ filter: null, state: null }, run)).toBe(3); + expect(await node.execute({ filter: null, payload: null, state: null }, run)).toBe(3); expect(run.executed).toEqual([node.ast]); expect(run.queried).toEqual([]); }); @@ -97,7 +100,7 @@ describe.each([ const rows = await make() .withReturning(emailColumn) - .execute({ filter: null, state: null }, run); + .execute({ filter: null, payload: null, state: null }, run); expect(rows).toEqual([{ email: 'a@b.c' }]); expect(run.executed).toEqual([]); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts index 22c233d70aea..338885dcb151 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts @@ -51,6 +51,35 @@ describe('Insert', () => { expect(run.executed).toEqual([]); }); + it('refuses two edges that give one column of a junction different values, before running', () => { + const insert = new Insert(insertInto('posts', [{ title: 'Hello' }]), { + relation: 'tags', + junction: 'post_tags', + }); + const run = recordingRun(); + const userIdOf8 = { user_id: ParamRef.of(8, { name: 'user_id' }) }; + + expect(() => insert.execute({ payload: [[userIdOf7], [userIdOf8]], state: null }, run)).toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_INVALID', + message: + 'Cannot write junction "post_tags": conflicting values for junction column "user_id"', + meta: { relation: 'tags', junction: 'post_tags', column: 'user_id' }, + }), + ); + expect(run).toMatchObject({ queried: [], executed: [] }); + }); + + it('accepts two edges that give one column of a junction the same value', async () => { + const insert = new Insert(insertInto('posts', [{ title: 'Hello' }]), { + relation: 'tags', + junction: 'post_tags', + }); + const run = recordingRun([], 1); + + expect(await insert.execute({ payload: [[userIdOf7], [userIdOf7]], state: null }, run)).toBe(1); + }); + it('gives the affected row count when its statement returns no column', async () => { const insert = new Insert(insertInto('posts', [{ title: 'a' }, { title: 'b' }])); const run = recordingRun([], 2); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/update.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/update.test.ts new file mode 100644 index 000000000000..bb63f0a3f09d --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/update.test.ts @@ -0,0 +1,31 @@ +import { ParamRef } from '@internal/sql-relational-core/ast'; +import { describe, expect, it } from 'vitest'; +import { recordingRun, updatePosts } from './statements'; + +const userIdOf7 = { user_id: ParamRef.of(7, { name: 'user_id' }) }; + +describe('Update with a payload', () => { + it('adds the record of each edge to what it sets', async () => { + const update = updatePosts({ title: 'New' }); + const run = recordingRun([], 2); + const views = { views: ParamRef.of(3, { name: 'views' }) }; + + expect( + await update.execute({ filter: null, payload: [[userIdOf7], [views]], state: null }, run), + ).toBe(2); + expect(run.executed[0]).toMatchObject({ + kind: 'update', + set: { title: { value: 'New' }, user_id: { value: 7 }, views: { value: 3 } }, + }); + }); + + it('returns no rows and runs nothing when a payload edge has no source row', async () => { + const update = updatePosts({ title: 'New' }, undefined, ['id']); + const run = recordingRun([{ id: 1 }]); + + expect( + await update.execute({ filter: null, payload: [[userIdOf7], []], state: null }, run), + ).toEqual([]); + expect(run).toMatchObject({ queried: [], executed: [] }); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/orm-call-terminals.test.ts b/packages/3-extensions/sql-orm-client/test/orm-call-terminals.test.ts index f3032a9470cc..3cafa751d329 100644 --- a/packages/3-extensions/sql-orm-client/test/orm-call-terminals.test.ts +++ b/packages/3-extensions/sql-orm-client/test/orm-call-terminals.test.ts @@ -182,12 +182,11 @@ describe('write terminals run as one orm-call', () => { ); }); - it('create() with a relation callback runs its statements and its read-back in a transaction of the call', async () => { + it('create() with a relation callback runs its statements in a transaction of the call', async () => { const recorded = createActivityCollectionFor('User', { returning: true }); recorded.runtime.setNextResults([ [aliceRow], [{ id: 10, title: 'Post', user_id: 1, views: 1 }], - [aliceRow], ]); const created = await recorded.collection.create( @@ -200,7 +199,7 @@ describe('write terminals run as one orm-call', () => { ); expect(summary(recorded)).toEqual( - oneCall(['audit'], 3, { outcome: 'succeeded', result: created }, ['committed']), + oneCall(['audit'], 2, { outcome: 'succeeded', result: created }, ['committed']), ); }); diff --git a/test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts b/test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts new file mode 100644 index 000000000000..a6671d9ac66e --- /dev/null +++ b/test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts @@ -0,0 +1,221 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { DatabaseSync } from 'node:sqlite'; +import { integerColumn, textColumn } from '@internal/adapter-sqlite/column-types'; +import sqliteAdapter from '@internal/adapter-sqlite/runtime'; +import { soleDomainNamespaceId } from '@internal/contract/types'; +import sqliteDriver from '@internal/driver-sqlite/runtime'; +import { instantiateExecutionStack } from '@internal/framework-components/execution'; +import { Collection } from '@internal/sql-orm-client'; +import { createExecutionContext, createSqlExecutionStack } from '@internal/sql-runtime'; +import { defineContract, field, model, rel } from '@internal/sqlite/contract-builder'; +import { SqliteRuntimeImpl } from '@internal/sqlite/runtime'; +import sqliteTarget from '@internal/target-sqlite/runtime'; +import { InternalError } from '@internal/utils/internal-error'; +import { timeouts } from '@repo/test-utils'; +import { join } from 'pathe'; +import { describe, expect, it } from 'vitest'; + +const UserTag = model('UserTag', { + fields: { + userId: field.column(integerColumn).column('user_id'), + tagId: field.column(integerColumn).column('tag_id'), + }, +}) + .attributes(({ fields, constraints }) => ({ + id: constraints.id([fields.userId, fields.tagId]), + })) + .sql({ table: 'user_tags' }); +const Tag = model('Tag', { + fields: { id: field.column(integerColumn).id(), name: field.column(textColumn) }, +}).sql({ table: 'tags' }); +const UserBase = model('User', { + fields: { id: field.column(integerColumn).id(), name: field.column(textColumn) }, +}).sql({ table: 'users' }); +const Comment = model('Comment', { + fields: { + id: field.column(integerColumn).id(), + postId: field.column(integerColumn).column('post_id'), + body: field.column(textColumn), + }, +}).sql({ table: 'comments' }); +const PostBase = model('Post', { + fields: { + id: field.column(integerColumn).id(), + userId: field.column(integerColumn).column('user_id').optional(), + title: field.column(textColumn), + }, + relations: { author: rel.belongsTo(UserBase, { from: 'userId', to: 'id' }).sql({ fk: {} }) }, +}).sql({ table: 'posts' }); +const contract = defineContract({ + models: { + User: UserBase.relations({ + posts: rel.hasMany(() => PostBase, { by: 'userId' }), + tags: rel.manyToMany(() => Tag, { through: () => UserTag, from: 'userId', to: 'tagId' }), + }).sql({ table: 'users' }), + Post: PostBase.relations({ + comments: rel.hasMany(() => Comment, { by: 'postId' }), + }).sql({ table: 'posts' }), + Comment: Comment.relations({ + post: rel.belongsTo(PostBase, { from: 'postId', to: 'id' }).sql({ fk: {} }), + }).sql({ table: 'comments' }), + Tag, + UserTag, + }, +}); + +type Collections = { + readonly users: Collection; + readonly posts: Collection; + readonly rows: (sql: string) => unknown[]; +}; + +async function withDatabase(fn: (collections: Collections) => Promise): Promise { + const directory = mkdtempSync(join(tmpdir(), 'orm-nested-create-')); + const path = join(directory, 'test.db'); + const database = new DatabaseSync(path); + database.exec(` + create table users (id integer primary key, name text not null); + create table posts (id integer primary key, user_id integer, title text not null); + create table comments (id integer primary key, post_id integer not null, body text not null); + create table tags (id integer primary key, name text not null unique); + create table user_tags (user_id integer not null, tag_id integer not null, primary key (user_id, tag_id)); + insert into tags values (1, 'SQL'); + `); + database.close(); + + const stack = createSqlExecutionStack({ + target: sqliteTarget, + adapter: sqliteAdapter, + driver: sqliteDriver, + }); + const context = createExecutionContext({ contract, stack }); + const instance = instantiateExecutionStack(stack); + const { adapter, driver } = instance; + if (adapter === undefined || driver === undefined) { + throw new InternalError('SQLite execution stack is missing its adapter or driver'); + } + await driver.connect({ kind: 'path', path }); + const runtime = new SqliteRuntimeImpl({ context, adapter, driver, closeRefusal: undefined }); + const namespaceId = soleDomainNamespaceId(contract.domain); + const rows = (sql: string) => { + const reader = new DatabaseSync(path); + try { + return reader + .prepare(sql) + .all() + .map((row) => ({ ...row })); + } finally { + reader.close(); + } + }; + try { + await fn({ + users: new Collection({ runtime, context }, 'User', { namespaceId }), + posts: new Collection({ runtime, context }, 'Post', { namespaceId }), + rows, + }); + } finally { + await runtime.close(); + rmSync(directory, { recursive: true, force: true }); + } +} + +describe('integration/nested create() through the mutation graph on SQLite', () => { + it( + 'creates children, created and connected tags, and returns the selected parent', + async () => { + await withDatabase(async ({ users, rows }) => { + const created = await users.select('id', 'name').create({ + id: 1, + name: 'Ada', + posts: (posts) => + posts.create([ + { id: 10, title: 'First' }, + { id: 11, title: 'Second' }, + ]), + tags: (tags) => tags.create([{ id: 2, name: 'Rust' }]), + }); + + expect(created).toEqual({ id: 1, name: 'Ada' }); + expect(rows('select id, user_id from posts order by id')).toEqual([ + { id: 10, user_id: 1 }, + { id: 11, user_id: 1 }, + ]); + expect(rows('select id, name from tags order by id')).toEqual([ + { id: 1, name: 'SQL' }, + { id: 2, name: 'Rust' }, + ]); + expect(rows('select user_id, tag_id from user_tags')).toEqual([{ user_id: 1, tag_id: 2 }]); + }); + }, + timeouts.databaseOperation, + ); + + it( + 'creates the author of a post before the post and loads includes after every write', + async () => { + await withDatabase(async ({ posts, rows }) => { + const created = await posts + .select('id', 'title') + .include('author', (author) => author.select('id', 'name')) + .create({ + id: 10, + title: 'Hello', + author: (author) => author.create({ id: 5, name: 'Ada' }), + comments: (comments) => comments.create([{ id: 100, body: 'Hi' }]), + }); + + expect(created).toEqual({ id: 10, title: 'Hello', author: { id: 5, name: 'Ada' } }); + expect(rows('select id, user_id from posts')).toEqual([{ id: 10, user_id: 5 }]); + expect(rows('select id, post_id from comments')).toEqual([{ id: 100, post_id: 10 }]); + }); + }, + timeouts.databaseOperation, + ); + + it( + 'links a tag once when two connect criteria find the same tag', + async () => { + await withDatabase(async ({ users, rows }) => { + const created = await users + .select('id') + .include('tags', (tags) => tags.select('id', 'name')) + .create({ + id: 1, + name: 'Ada', + tags: (tags) => tags.connect([{ id: 1 }, { name: 'SQL' }]), + }); + + expect(created).toEqual({ id: 1, tags: [{ id: 1, name: 'SQL' }] }); + expect(rows('select user_id, tag_id from user_tags')).toEqual([{ user_id: 1, tag_id: 1 }]); + }); + }, + timeouts.databaseOperation, + ); + + it( + 'writes nothing when a connect finds no row', + async () => { + await withDatabase(async ({ users, rows }) => { + await expect( + users.create({ + id: 1, + name: 'Ada', + posts: (posts) => posts.create([{ id: 10, title: 'First' }]), + tags: (tags) => tags.connect({ name: 'Missing' }), + }), + ).rejects.toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_ROW_MISSING', + meta: { kind: 'connect', relation: 'tags' }, + }), + ); + expect(rows('select id from users')).toEqual([]); + expect(rows('select id from posts')).toEqual([]); + expect(rows('select user_id from user_tags')).toEqual([]); + }); + }, + timeouts.databaseOperation, + ); +}); diff --git a/test/integration/test/sql-orm-client/nested-create-graph.test.ts b/test/integration/test/sql-orm-client/nested-create-graph.test.ts new file mode 100644 index 000000000000..ed7a6879f971 --- /dev/null +++ b/test/integration/test/sql-orm-client/nested-create-graph.test.ts @@ -0,0 +1,212 @@ +import type { Char } from '@internal/target-postgres/codec-types'; +import { describe, expect, it } from 'vitest'; +import { + createReturningPostsCollection, + createReturningUsersCollection, + timeouts, + withCollectionRuntime, +} from './integration-helpers'; +import { seedPosts, seedTags, seedUsers } from './runtime-helpers'; + +const TAG_SQL = 'tag-sql' as Char<36>; + +describe('integration/nested create() through the mutation graph', () => { + it( + 'creates the related row of a relation the parent holds the key for, before the parent', + async () => { + await withCollectionRuntime(async (runtime) => { + const posts = createReturningPostsCollection(runtime); + + const created = await posts.select('id', 'title', 'userId').create({ + id: 20, + title: 'Hello', + views: 1, + author: (author) => author.create({ id: 5, name: 'Ada', email: 'ada@example.com' }), + }); + + expect(created).toEqual({ id: 20, title: 'Hello', userId: 5 }); + expect(await runtime.query('select id, name from users')).toEqual([{ id: 5, name: 'Ada' }]); + expect(await runtime.query('select id, user_id from posts')).toEqual([ + { id: 20, user_id: 5 }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'rejects a connect that finds no row and writes nothing', + async () => { + await withCollectionRuntime(async (runtime) => { + const posts = createReturningPostsCollection(runtime); + + await expect( + posts.create({ + id: 20, + title: 'Hello', + views: 1, + author: (author) => author.connect({ id: 5 }), + }), + ).rejects.toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_ROW_MISSING', + message: 'connect() nested mutation for relation "author" did not find a matching row', + meta: { kind: 'connect', relation: 'author' }, + }), + ); + expect(await runtime.query('select id from posts')).toEqual([]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'sets the key of the new parent on the children a connect matches, and on nothing else', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + await seedPosts(runtime, [ + { id: 10, title: 'First', userId: null, views: 1 }, + { id: 11, title: 'Second', userId: null, views: 2 }, + { id: 12, title: 'Third', userId: null, views: 3 }, + ]); + + const created = await users + .select('id', 'name') + .include('posts', (posts) => posts.select('id').orderBy((post) => post.id.asc())) + .create({ + id: 1, + name: 'Ada', + email: 'ada@example.com', + posts: (posts) => posts.connect([{ id: 10 }, { id: 12 }, { id: 99 }]), + }); + + expect(created).toEqual({ id: 1, name: 'Ada', posts: [{ id: 10 }, { id: 12 }] }); + expect(await runtime.query('select id, user_id from posts order by id')).toEqual([ + { id: 10, user_id: 1 }, + { id: 11, user_id: null }, + { id: 12, user_id: 1 }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'creates nested children of nested children, each with the key of its parent', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + const created = await users.select('id', 'name').create({ + id: 1, + name: 'Ada', + email: 'ada@example.com', + posts: (posts) => + posts.create([ + { + id: 10, + title: 'First', + views: 1, + comments: (comments) => + comments.create([ + { id: 100, body: 'One' }, + { id: 101, body: 'Two' }, + ]), + }, + { id: 11, title: 'Second', views: 2 }, + ]), + }); + + expect(created).toEqual({ id: 1, name: 'Ada' }); + expect(await runtime.query('select id, user_id from posts order by id')).toEqual([ + { id: 10, user_id: 1 }, + { id: 11, user_id: 1 }, + ]); + expect(await runtime.query('select id, post_id from comments order by id')).toEqual([ + { id: 100, post_id: 10 }, + { id: 101, post_id: 10 }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'links a tag once when two connect criteria find the same tag', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + await seedTags(runtime, [{ id: TAG_SQL, name: 'SQL' }]); + + const created = await users + .select('id') + .include('tags', (tags) => tags.select('id', 'name')) + .create({ + id: 1, + name: 'Ada', + email: 'ada@example.com', + tags: (tags) => tags.connect([{ id: TAG_SQL }, { name: 'SQL' }]), + }); + + expect(created).toEqual({ id: 1, tags: [{ id: TAG_SQL, name: 'SQL' }] }); + expect(await runtime.query('select user_id, tag_id from user_tags')).toEqual([ + { user_id: 1, tag_id: TAG_SQL }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'rejects a relation field that is not a callback and writes nothing', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + await seedUsers(runtime, [{ id: 2, name: 'Grace', email: 'grace@example.com' }]); + + await expect( + users.create({ + id: 1, + name: 'Ada', + email: 'ada@example.com', + invitedBy: (inviter) => inviter.connect({ id: 2 }), + posts: [] as never, + }), + ).rejects.toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_INVALID', + meta: { relation: 'posts', model: 'User', problem: 'missing-callback' }, + }), + ); + expect(await runtime.query('select id from users order by id')).toEqual([{ id: 2 }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'rejects disconnect in create() and writes nothing', + async () => { + await withCollectionRuntime(async (runtime) => { + const posts = createReturningPostsCollection(runtime); + + await expect( + posts.create({ + id: 20, + title: 'Hello', + views: 1, + author: ((author: { disconnect: () => unknown }) => author.disconnect()) as never, + }), + ).rejects.toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_UNSUPPORTED', + meta: { kind: 'disconnect', relation: 'author' }, + }), + ); + expect(await runtime.query('select id from posts')).toEqual([]); + }); + }, + timeouts.spinUpPpgDev, + ); +}); From 06965f99efb2c81232cf36d54add1b440f35abbd Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 15 Oct 2026 15:45:36 +0000 Subject: [PATCH 04/14] docs(nested-mutations): gate nested update operations on the parent with a Requires edge Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- projects/nested-mutations/slices/graph-nested/spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/nested-mutations/slices/graph-nested/spec.md b/projects/nested-mutations/slices/graph-nested/spec.md index a813025f2df0..4fd4f99fa70f 100644 --- a/projects/nested-mutations/slices/graph-nested/spec.md +++ b/projects/nested-mutations/slices/graph-nested/spec.md @@ -47,6 +47,7 @@ Rules for the translation, reproducing main except where a decided change says o - **Child holds the key.** `create`: one `Insert` of the child rows with the parent's key from `PayloadData`. `connect`: an `Update` of the children matching the criterion, setting their key from the parent by `PayloadData`; no error when nothing matches, as on main. `disconnect`: an `Update` setting the children's key to null, limited to children of the parent by `FilterData`, and to the criterion when one is given. - **Many-to-many.** `create`: the target row is inserted, then the junction row with both keys by `PayloadData`. `connect`: a `Find` and an `Assert` per criterion, then the junction insert. `disconnect`: a `Find` per criterion, then a `Delete` of the junction row. Junction `connect` uses a conflict clause where the junction has a key over exactly its link columns and a plain insert otherwise, with no duplicate check and no wrapped error; `ORM.RELATION_LINK_DUPLICATE` is removed. - **The parent's write.** In `update()`, `Find` of the first matching row, then `Update` (or the `Find` itself when nothing is set, design record D7a). In `create()`, `Insert`. +- **Gate on the parent in `update()`.** Every node that does not read from the parent or from a gated node (the related `Insert` of a parent-held `create`, the target `Insert` of a many-to-many `create`, the lookups and `Assert`s of `connect`/`disconnect`) gets a `Requires` edge from the parent's `Find`: an edge with no data meaning "run only if this source produced a row". When no row matches, those nodes return no rows without running, so `update()` resolves `null` and writes nothing, as on main. Decided 2026-10-15 over a data edge with no column pairs and over no gate. - **Result.** The parent's write node, with the caller's selection. With includes, a `Find` with the includes reading the parent's key is added last, after every child operation. - **Rejections while the graph is built.** Invalid nested input is rejected before any statement, including when the filter matches no row. A relation field that is not a callback is rejected with `ORM.RELATION_MUTATION_INVALID`. `disconnect` in `create()` stays rejected with `ORM.RELATION_MUTATION_UNSUPPORTED`. - **Contract validation.** A relation whose link-column lists are empty is rejected by the contract validator (minimum length 1 on `on.localFields`, `on.targetFields` and the three `through` lists). The executor's guards for empty lists are not carried over. From 9c51557ea1f590c7e4d91c6a9744290d56d4fc89 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 15 Oct 2026 16:17:48 +0000 Subject: [PATCH 05/14] feat(sql-contract): a relation's link column lists are not empty The contract validator requires at least one entry in on.localFields, on.targetFields and the parentColumns, childColumns and targetColumns of a many-to-many relation's through. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- .../1-core/contract/src/validators.ts | 10 +++---- .../test/relation-join-validation.test.ts | 28 ++++++++++++++++++- .../1-core/contract/test/validators.test.ts | 6 ++-- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/2-sql-family/1-core/contract/src/validators.ts b/packages/2-sql-family/1-core/contract/src/validators.ts index 087f7187268a..bb67be1a4db2 100644 --- a/packages/2-sql-family/1-core/contract/src/validators.ts +++ b/packages/2-sql-family/1-core/contract/src/validators.ts @@ -479,15 +479,15 @@ const ContractRelationThroughSchema = type({ '+': 'reject', table: 'string', namespaceId: 'string', - parentColumns: type.string.array().readonly(), - childColumns: type.string.array().readonly(), - targetColumns: type.string.array().readonly(), + parentColumns: type.string.array().atLeastLength(1).readonly(), + childColumns: type.string.array().atLeastLength(1).readonly(), + targetColumns: type.string.array().atLeastLength(1).readonly(), }); const ContractRelationOnSchema = type({ '+': 'reject', - localFields: type.string.array().readonly(), - targetFields: type.string.array().readonly(), + localFields: type.string.array().atLeastLength(1).readonly(), + targetFields: type.string.array().atLeastLength(1).readonly(), }); const ContractManyToManyRelationSchema = type({ diff --git a/packages/2-sql-family/1-core/contract/test/relation-join-validation.test.ts b/packages/2-sql-family/1-core/contract/test/relation-join-validation.test.ts index 56f9d4bff6ef..41cd3f07028a 100644 --- a/packages/2-sql-family/1-core/contract/test/relation-join-validation.test.ts +++ b/packages/2-sql-family/1-core/contract/test/relation-join-validation.test.ts @@ -99,7 +99,32 @@ describe('a relation join field', () => { }); }); -function contractWithTags(targetFields: readonly string[]) { +describe('the link column lists of a relation', () => { + it('refuse an empty on.localFields or on.targetFields', () => { + expect(() => validateSqlContractFully(contractWith(postsOn([], ['userId'])))).toThrow( + /relations\.posts\.on\.localFields must be non-empty/, + ); + expect(() => validateSqlContractFully(contractWith(postsOn(['id'], [])))).toThrow( + /relations\.posts\.on\.targetFields must be non-empty/, + ); + }); + + it.each(['parentColumns', 'childColumns', 'targetColumns'] as const)( + 'refuse an empty through.%s', + (list) => { + expect(() => validateSqlContractFully(contractWithTags(['user_id'], { [list]: [] }))).toThrow( + new RegExp(`relations\\.tags\\.through\\.${list} must be non-empty`), + ); + }, + ); +}); + +function contractWithTags( + targetFields: readonly string[], + throughLists: Partial< + Record<'parentColumns' | 'childColumns' | 'targetColumns', readonly string[]> + > = {}, +) { return createContract({ storage, models: { @@ -117,6 +142,7 @@ function contractWithTags(targetFields: readonly string[]) { parentColumns: ['user_id'], childColumns: ['tag_id'], targetColumns: ['id'], + ...throughLists, }, }, }, diff --git a/packages/2-sql-family/1-core/contract/test/validators.test.ts b/packages/2-sql-family/1-core/contract/test/validators.test.ts index c92f5a31d756..0736be7c950b 100644 --- a/packages/2-sql-family/1-core/contract/test/validators.test.ts +++ b/packages/2-sql-family/1-core/contract/test/validators.test.ts @@ -847,11 +847,11 @@ describe('SQL contract validators', () => { it('rejects an N:M relation whose through.parentColumns and on.localFields differ in length', () => { const c = manyToManyContract({ - on: { localFields: [], targetFields: ['user_id'] }, - through: consistentThrough, + on: { localFields: ['id'], targetFields: ['user_id'] }, + through: { ...consistentThrough, parentColumns: ['user_id', 'user_id'] }, }); expect(() => validateSqlContractFully(c)).toThrow( - /through\.parentColumns \(1\) with on\.localFields \(0\) of differing length/, + /through\.parentColumns \(2\) with on\.localFields \(1\) of differing length/, ); }); From 16f6b6654acf5c5a2c903ff2fc701467f99e3634 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 15 Oct 2026 16:17:54 +0000 Subject: [PATCH 06/14] feat(sql-orm-client): update() with relation callbacks runs through the mutation graph; the nested executor is removed update() with relation callbacks builds its graph with updateFirstGraph: a Find of the first matching row, the operations on relations the parent holds the key for, an Update by key (or the Find alone when nothing is set), then the operations on relations the child holds the key for and on many-to-many relations, and a final Find with includes. disconnect sets the parent's key to null, sets the children's key to null for the children of the parent (all of them, or those a criterion matches), or deletes the junction row found by both keys. In update(), the parent's Find gates every node that reads nothing from the parent through a Requires edge: a node whose required source produced no row returns no rows without running, so an update() that matches no row resolves null and writes nothing. Statement nodes and Assert declare a requires slot. A relation field that is not a callback is rejected in create() and update(); a junction connect skips an existing link with a conflict clause, so ORM.RELATION_LINK_DUPLICATE is removed. mutation-executor.ts is removed; withMutationScope moves next to the runner. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- .../4. Runtime & Middleware Framework.md | 2 +- docs/reference/error-reference.md | 10 +- .../3-extensions/sql-orm-client/README.md | 2 +- .../sql-orm-client/src/collection.ts | 131 +- .../sql-orm-client/src/mutation-executor.ts | 1078 ------------ .../src/mutation-graph/assert.ts | 8 +- .../src/mutation-graph/collection-graphs.ts | 79 +- .../src/mutation-graph/delete.ts | 2 +- .../src/mutation-graph/filtered-statement.ts | 11 +- .../sql-orm-client/src/mutation-graph/find.ts | 2 +- .../src/mutation-graph/graph.ts | 5 + .../src/mutation-graph/insert-chunked.ts | 11 +- .../src/mutation-graph/insert.ts | 8 +- .../src/mutation-graph/mutation-scope.ts | 57 + .../{nested-create.ts => nested-mutations.ts} | 244 ++- .../src/mutation-graph/requires.ts | 18 + .../src/mutation-graph/run-graph.ts | 2 +- .../src/mutation-graph/statement-node.ts | 3 +- .../src/mutation-graph/update.ts | 12 +- .../sql-orm-client/src/orm-errors.ts | 1 - .../sql-orm-client/src/relation-mutations.ts | 30 +- .../3-extensions/sql-orm-client/src/types.ts | 2 +- .../test/mutation-executor.test.ts | 1496 ----------------- .../test/mutation-graph/assert.test.ts | 20 +- .../test/mutation-graph/create-graph.test.ts | 37 +- .../mutation-graph/filtered-statement.test.ts | 32 +- .../test/mutation-graph/graph-edges.test-d.ts | 3 + .../test/mutation-graph/graph.test.ts | 15 + .../mutation-graph/insert-chunked.test.ts | 7 +- .../test/mutation-graph/insert.test.ts | 34 +- .../mutation-graph/mutation-scope.test.ts | 75 + .../test/mutation-graph/print-graph.ts | 9 +- .../test/mutation-graph/update-graph.test.ts | 192 +++ .../test/mutation-graph/update.test.ts | 10 +- .../test/relation-mutations.test.ts | 33 + .../sql-orm-client/mn-nested-write.test.ts | 20 +- .../nested-create-graph-sqlite.test.ts | 70 + .../nested-update-graph.test.ts | 142 ++ 38 files changed, 1063 insertions(+), 2850 deletions(-) delete mode 100644 packages/3-extensions/sql-orm-client/src/mutation-executor.ts create mode 100644 packages/3-extensions/sql-orm-client/src/mutation-graph/mutation-scope.ts rename packages/3-extensions/sql-orm-client/src/mutation-graph/{nested-create.ts => nested-mutations.ts} (65%) create mode 100644 packages/3-extensions/sql-orm-client/src/mutation-graph/requires.ts delete mode 100644 packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts create mode 100644 packages/3-extensions/sql-orm-client/test/mutation-graph/mutation-scope.test.ts create mode 100644 packages/3-extensions/sql-orm-client/test/mutation-graph/update-graph.test.ts create mode 100644 packages/3-extensions/sql-orm-client/test/relation-mutations.test.ts create mode 100644 test/integration/test/sql-orm-client/nested-update-graph.test.ts diff --git a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md index a23bcb3ac614..c4853b7a884d 100644 --- a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md +++ b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md @@ -701,7 +701,7 @@ This holds for `postgres()`, every serverless Postgres connection, `sqlite()` an The runtime acquires a connection (auto-connecting lazily, like `db.runtime()`), starts a transaction activity, issues `BEGIN`, runs the callback, and either commits and resolves with the callback's return value or rolls back and re-throws the original error. The transaction and its statements are finalized between the commit or rollback and the release, and `db.transaction(fn)` resolves or rejects only after their `activityFinalized` hooks have run; see [When an activity is finalized](#when-an-activity-is-finalized). The connection is released once the callback settles and the commit or rollback cleanup has run, on both the success and the failure branch. If the transaction cannot start, the start error is rethrown. When a transaction `activityStarted` hook threw, no `BEGIN` was sent, the runtime records the connection as clean, and it is released. After any other start failure, such as a failed `BEGIN` or a connection the runtime did not make, the connection's state is unknown and it is destroyed rather than returned to the pool. If `COMMIT` itself fails, the promise rejects with `RUNTIME.TRANSACTION_COMMIT_FAILED`. If `COMMIT` resolves and a transaction `activityEnded` hook then throws, the transaction has committed: it is finalized `committed`, no `ROLLBACK` is sent, and the promise rejects with the hook's error unchanged. The transaction itself records that its `COMMIT` or `ROLLBACK` resolved, under an internal symbol-keyed member that `withTransaction` and the ORM read and that `tx` and the client facades do not expose, so a `rollback()` called after that, by `withTransaction`, by the ORM's own transactions or by user code, does nothing and resolves. If the `ROLLBACK` after a callback throw resolves and a transaction `activityEnded` hook then throws, the promise rejects with the callback's original error, the hook's error is passed to the transaction's `ctx.log.error` as `middleware.activityEnded.error`, and the connection is released. The ORM's own transactions do the same. If `ROLLBACK` itself fails after a callback throw, the rollback error wraps the original. -ORM nested mutations (`withMutationScope` in the SQL ORM mutation executor) detect that they are running inside a transaction context and reuse the transaction's `RuntimeQueryable` instead of acquiring a new connection. This is why `RuntimeConnection` and `RuntimeTransaction` extend the same `RuntimeScope` slice that `RuntimeQueryable` does — both compose against one canonical surface (see [ORM Client Integration](#orm-client-integration) above). +ORM writes of more than one statement (`withMutationScope` in the SQL ORM mutation graph runner) detect that they are running inside a transaction context and reuse the transaction's `RuntimeQueryable` instead of acquiring a new connection. This is why `RuntimeConnection` and `RuntimeTransaction` extend the same `RuntimeScope` slice that `RuntimeQueryable` does — both compose against one canonical surface (see [ORM Client Integration](#orm-client-integration) above). ### `AsyncIterableResult` and transaction scope diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 10c9316018ce..477cf00d0b12 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1121,19 +1121,15 @@ The Mongo ORM client was asked to operate on a model name that is not in the con ### ORM.MUTATION_ROW_MISSING -A mutation that expected the database to return a row got none: `create()`/`upsert()` read-back, MTI base or variant INSERT, or a nested create. The Prisma-classic analogue of P2025. Payload: `operation`, `model`, `tableName`, `phase`. +A mutation that expected the database to return a row got none: `create()`/`upsert()`, including `create()` with nested relation mutations, or an MTI base or variant INSERT. The Prisma-classic analogue of P2025. Payload: `operation`, `model`, `tableName`, `phase`. ### ORM.OPERATION_UNSUPPORTED A valid ORM method was called in a configuration that does not support it: mutating an MTI variant collection with a method that requires `createAll()`, passing `onConflict: 'skip'` to `createAll()` on an MTI variant collection, Mongo `upsert()` with dot-path field operations, a Mongo `upsert()` whose `create` sets a field that has an update default and whose update pulls by a match document (that upsert runs as one update pipeline, which can pull only a single value), a Mongo mutation carrying windowing (`orderBy`/`offset`/`limit`) or includes, or `variant()` called on a collection that already has a variant selected (SQL and Mongo ORMs; call it on the base collection instead; `reason: 'variant-already-selected'`, with `variant` and `selectedValue` naming the selected variant model and its discriminator value). Payload: `method`, `model`, `reason`, `field`. -### ORM.RELATION_LINK_DUPLICATE - -A `connect()` nested mutation violated a unique constraint on the junction table: the junction link is likely already present. The original driver error is preserved as `cause`. Payload: `relation`, `junction`. - ### ORM.RELATION_MUTATION_INVALID -A nested relation mutation's input is malformed: a relation field without a mutator callback or returning an invalid descriptor, `create` without data, `connect`/`disconnect` with a missing or empty criterion, duplicate connect criteria resolving to the same junction link, or conflicting values for a junction column. Payload: `kind`, `relation`, `model`, `problem`, `junction`, `column`. +A nested relation mutation's input is malformed: a relation field without a mutator callback or returning an invalid descriptor, `create` without data, `connect`/`disconnect` with a missing or empty criterion, or conflicting values for a junction column. Payload: `kind`, `relation`, `model`, `problem`, `junction`, `column`. ### ORM.RELATION_MUTATION_UNSUPPORTED @@ -1149,7 +1145,7 @@ A referenced relation name does not exist on the model, in `include()` (SQL and ### ORM.ROW_IDENTITY_MISSING -The operation needs a primary key or unique constraint the table does not have: `update()`/`delete()` targeting a single row, a `create()` or `update()` with nested relation mutations (which updates and reloads the row by that key), or keying the include read-back after a mutation. Payload: `model`, `table`. +The operation needs a primary key or unique constraint the table does not have: `update()`/`delete()` targeting a single row, an `update()` with nested relation mutations (which updates the row by that key), or keying the include read-back after a mutation. Payload: `model`, `table`. ### ORM.TABLE_UNKNOWN diff --git a/packages/3-extensions/sql-orm-client/README.md b/packages/3-extensions/sql-orm-client/README.md index 0fe24a2b839a..2ebf0efd1dd0 100644 --- a/packages/3-extensions/sql-orm-client/README.md +++ b/packages/3-extensions/sql-orm-client/README.md @@ -67,7 +67,7 @@ const audit: SqlMiddleware = { await db.orm.public.User.first({ id: 1 }, (meta) => meta.annotate(cacheAnnotation({ key: 'user-1' }))); ``` -- **Statements go through the call.** Reads, the statements of a mutation graph, nested-relation mutations and the read-back after a nested create or update are the call's children. The client's own transaction is `handle.transaction()`; inside `db.transaction(fn)` the call is a child of that transaction and opens none of its own. +- **Statements go through the call.** Reads and the statements of a mutation graph, including those of nested-relation mutations, are the call's children. The client's own transaction is `handle.transaction()`; inside `db.transaction(fn)` the call is a child of that transaction and opens none of its own. - **Annotations live on the call.** The annotations the `configure` callback records are the call's, including for writes with relation callbacks. The statements the call sends carry only the reserved keys the client sets itself, so a middleware that reads annotations from `plan.meta.annotations` sees none from an ORM call. - **The call ends with the value the method returns:** the model rows, the row or `null`, the aggregate object or the count. A failed call ends `failed`. A streaming terminal such as `all()` starts its call when the caller first reads it and ends it when the caller reads to the end (`succeeded`), stops early (`stopped`) or the stream fails (`failed`); it keeps the rows for the call's result only when the call carries annotations. Read a streaming terminal with `for await` or `await`, or call `return()` on its iterator when you stop early: an iterator dropped after `next()` leaves its call open, and `close()` then waits for it for ever. - **Middleware can answer the call.** When an `intercept.onActivity` handler supplies a result, the client returns it without sending any statement. A value of the wrong shape for the method rejects with `ORM.INTERCEPTED_RESULT_INVALID`. diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index 3b7fc7e007ce..c2ac075db495 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -28,7 +28,6 @@ import { type TraitExpression, toExpr } from '@internal/sql-relational-core/expr import type { Preparable, SqlQueryPlan } from '@internal/sql-relational-core/plan'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; -import { InternalError } from '@internal/utils/internal-error'; import type { SimplifyDeep } from '@internal/utils/simplify-deep'; import type { Simplify } from '@internal/utils/types'; import { createAggregateBuilder, isAggregateSelector } from './aggregate-builder'; @@ -66,7 +65,6 @@ import { describeCollectionFirstOrThrow, describeCollectionQuery, describeCollectionRows, - dispatchCollectionRows, runQuery, } from './collection-dispatch'; import type { @@ -135,7 +133,6 @@ import { } from './include-descriptors'; import { assertLockCompatible } from './lock-guards'; import { createModelAccessor, createModelCallbackTools } from './model-accessor'; -import { buildRowIdentityFilterFromRow, executeNestedUpdateMutation } from './mutation-executor'; import { createAllGraph, createAllVariantGraph, @@ -164,7 +161,7 @@ import { import { ormError } from './orm-errors'; import type { PreparedCollection } from './prepared-collection'; import { compileAggregate, type InsertConflictSkip } from './query-plan'; -import { hasNestedMutationCallbacks } from './relation-mutations'; +import { hasRelationFields, parseMutationInput } from './relation-mutations'; import type { AliasedTable } from './table-scope'; import { type AggregateBuilder, @@ -2002,24 +1999,12 @@ export class CollectionBase< | ResolvedCreateInput | MutationCreateInputWithRelations, ): Promise { - if ( - hasNestedMutationCallbacks( - this.contract, - this.namespaceId, - this.modelName, - blindCast< - Record, - 'create overload inputs are model-field records inspected for relation callbacks' - >(data), - ) - ) { - const graph = createGraph( - this.#descriptionOptions(), - blindCast< - Record, - 'nested callback detection selects the relation-mutation create input' - >(data), - ); + const input = blindCast< + Record, + 'create overload inputs are model-field records inspected for relation callbacks' + >(data); + if (hasRelationFields(this.contract, this.namespaceId, this.modelName, input)) { + const graph = createGraph(this.#descriptionOptions(), input); const [created] = await queryGraph( graph, this.#on(queryable), @@ -2623,56 +2608,24 @@ export class CollectionBase< queryable: RuntimeQueryable, data: MutationUpdateInput, ): Promise { - if ( - hasNestedMutationCallbacks( - this.contract, - this.namespaceId, - this.modelName, - blindCast< - Record, - 'update input is a model-field record inspected for relation callbacks' - >(data), - ) - ) { + const input = blindCast< + MutationUpdateInput, string>, + 'update input is a model-field record whose relation fields are callbacks' + >(data); + const parsed = hasRelationFields(this.contract, this.namespaceId, this.modelName, input) + ? parseMutationInput(this.contract, this.namespaceId, this.modelName, input) + : { scalarData: input, relationMutations: [] }; + if (parsed.relationMutations.length > 0) { assertRelationUpdateIgnoresNothing(this.state, this.modelName); - const updatedRow = await executeNestedUpdateMutation({ - context: this.ctx.context, - runtime: queryable, - namespaceId: this.namespaceId, - modelName: this.modelName, - tables: this.state.tables, - filters: this.state.filters, - data: blindCast< - MutationUpdateInput, string>, - 'nested callback detection selects the relation-mutation update input' - >(data), - }); - if (!updatedRow) { - return null; - } - - const identityCriterion = buildRowIdentityFilterFromRow( - this.contract, - this.namespaceId, - this.modelName, - updatedRow, - ); - return this.#reloadMutationRowByIdentity(queryable, identityCriterion); } - - assertModelFieldNames( - this.contract, - this.namespaceId, - this.modelName, - blindCast, 'scalar update input is a model-field record'>(data), - ); + assertModelFieldNames(this.contract, this.namespaceId, this.modelName, parsed.scalarData); const values = this.#updateValues( blindCast< ScalarUpdateInput, - 'absence of nested callbacks selects the scalar update input' - >(data), + 'the fields that are not relations are the scalar update input' + >(parsed.scalarData), ); - const graph = updateFirstGraph(this.#descriptionOptions(), values); + const graph = updateFirstGraph(this.#descriptionOptions(), values, parsed.relationMutations); return consumeFirstRow(queryGraph(graph, this.#on(queryable), this.#descriptionOptions())); } @@ -2869,52 +2822,6 @@ export class CollectionBase< return values; } - async #reloadMutationRowByIdentity( - queryable: RuntimeQueryable, - criterion: Record, - ): Promise { - return this.#reloadMutationRowByCriterion(queryable, criterion, 'row identity'); - } - - async #reloadMutationRowByCriterion( - queryable: RuntimeQueryable, - criterion: Record, - criterionLabel: string, - ): Promise { - const whereExpr = shorthandToWhereExpr( - this.ctx.context, - this.namespaceId, - this.modelName, - blindCast< - ShorthandWhereFilter, - 'mutation reload criterion contains resolved fields for this model' - >(criterion), - this.state.tables.root, - ); - if (!whereExpr) { - throw new InternalError( - `Failed to build ${criterionLabel} filter for mutation result on model "${this.modelName}"`, - ); - } - - const resultState: CollectionState = { - ...emptyState(this.state.tables), - filters: [whereExpr], - includes: this.state.includes, - selectedFields: this.state.selectedFields, - limit: 1, - }; - - const rows = await dispatchCollectionRows({ - context: this.ctx.context, - runtime: queryable, - state: resultState, - modelName: this.modelName, - namespaceId: this.namespaceId, - }); - return rows[0] ?? null; - } - #assertIncludeRefinementMode(action: string): void { if (this.includeRefinementMode) { return; diff --git a/packages/3-extensions/sql-orm-client/src/mutation-executor.ts b/packages/3-extensions/sql-orm-client/src/mutation-executor.ts deleted file mode 100644 index eaf670b3a30a..000000000000 --- a/packages/3-extensions/sql-orm-client/src/mutation-executor.ts +++ /dev/null @@ -1,1078 +0,0 @@ -import type { Contract } from '@internal/contract/types'; -import type { SqlStorage } from '@internal/sql-contract/types'; -import { isUniqueConstraintViolation } from '@internal/sql-errors'; -import { type AnyExpression, BinaryExpr } from '@internal/sql-relational-core/ast'; -import type { ExecutionContext } from '@internal/sql-relational-core/query-lane-context'; -import type { RuntimeScope } from '@internal/sql-relational-core/types'; -import { settlementOf } from '@internal/sql-runtime/internal/prepared-query'; -import { castAs } from '@internal/utils/casts'; -import { InternalError } from '@internal/utils/internal-error'; -import { resolveModelTableName, resolveRowIdentityColumns } from './collection-contract'; -import { mapModelDataToStorageRow, mapStorageRowToModelFields } from './collection-runtime'; -import { type CollectionTables, createStatementTables } from './collection-tables'; -import { and, shorthandToWhereExpr } from './filters'; -import { ormError } from './orm-errors'; -import { - compileDeleteCount, - compileInsertCount, - compileInsertReturning, - compileSelect, - compileUpdateCount, - compileUpdateReturning, -} from './query-plan'; -import { queryPlanRows } from './query-plan-rows'; -import { - assertJunctionMetadataShape, - assertJunctionParentMetadataLength, - assertJunctionPayloadWritable, - assertJunctionTargetMetadataLength, - type JunctionParsedRelationMutation, - type JunctionRelationDefinition, - type JunctionThrough, - parseMutationInput, - partitionByOwnership, - type RelationDefinition, - toFieldName, -} from './relation-mutations'; -import type { AliasedTable } from './table-scope'; -import type { - CollectionState, - MutationCreateInput, - MutationUpdateInput, - RelationMutation, - RuntimeQueryable, - RuntimeTransaction, -} from './types'; -import { emptyState } from './types'; -import { paramRefForStorageColumn } from './where-binding'; - -export async function executeNestedCreateMutation(options: { - context: ExecutionContext; - runtime: RuntimeQueryable; - namespaceId: string; - modelName: string; - data: MutationCreateInput, string>; -}): Promise> { - return withMutationScope(options.runtime, async (scope) => - createGraph(scope, options.context, options.namespaceId, options.modelName, options.data), - ); -} - -export async function executeNestedUpdateMutation(options: { - context: ExecutionContext; - runtime: RuntimeQueryable; - namespaceId: string; - modelName: string; - tables: CollectionTables; - filters: readonly AnyExpression[]; - data: MutationUpdateInput, string>; -}): Promise | null> { - return withMutationScope(options.runtime, async (scope) => - updateFirstGraph( - scope, - options.context, - options.namespaceId, - options.modelName, - options.tables, - options.filters, - options.data, - ), - ); -} - -export function buildRowIdentityFilterFromRow( - contract: Contract, - namespaceId: string, - modelName: string, - row: Record, -): Record { - const tableName = resolveModelTableName(contract, namespaceId, modelName); - const identityColumns = resolveRowIdentityColumns(contract, namespaceId, tableName); - if (identityColumns.length === 0) { - throw ormError( - 'ORM.ROW_IDENTITY_MISSING', - `Nested mutations on model "${modelName}" require table "${tableName}" to have a primary key or unique constraint`, - { meta: { model: modelName, table: tableName } }, - ); - } - - const filter: Record = {}; - for (const column of identityColumns) { - const fieldName = toFieldName(contract, namespaceId, modelName, column); - const value = row[fieldName]; - if (value === undefined) { - throw new InternalError( - `Missing identity field "${fieldName}" while reloading model "${modelName}"`, - ); - } - filter[fieldName] = value; - } - return filter; -} - -export async function withMutationScope( - runtime: RuntimeQueryable, - run: (scope: RuntimeScope) => Promise, -): Promise { - // A top-level transaction wins when the runtime exposes one directly. - if (typeof runtime.transaction === 'function') { - return runInTransaction(await runtime.transaction(), run); - } - - // Otherwise open a connection and run the whole mutation graph inside its - // transaction. The top-level `Runtime` exposes `transaction()` only on a - // connection (`connection().transaction()`), so without this a multi-statement - // graph that fails after the first write would leave a partial write behind. - if (typeof runtime.connection === 'function') { - const connection = await runtime.connection(); - try { - if (typeof connection.transaction === 'function') { - return await runInTransaction(await connection.transaction(), run); - } - return await run(connection); - } finally { - await connection.release?.(); - } - } - - // Bare runtimes (e.g. unit-test stubs) expose neither: run directly. - return run(runtime); -} - -async function runInTransaction( - transaction: RuntimeTransaction, - run: (scope: RuntimeScope) => Promise, -): Promise { - try { - const result = await run(transaction); - if (typeof transaction.commit === 'function') { - await transaction.commit(); - } - return result; - } catch (error) { - if (typeof transaction.rollback === 'function') { - try { - await transaction.rollback(); - } catch (rollbackError) { - // A hook that threw after the ROLLBACK landed must not replace the error that caused it. - const settlement = settlementOf(transaction); - if (settlement?.rolledBack !== true) throw rollbackError; - settlement.logDroppedError(rollbackError); - } - } - throw error; - } -} - -async function createGraph( - scope: RuntimeScope, - context: ExecutionContext, - namespaceId: string, - modelName: string, - input: MutationCreateInput, string>, -): Promise> { - const contract = context.contract; - const parsed = parseMutationInput(contract, namespaceId, modelName, input); - const { parentOwned, childOwned, junctionOwned } = partitionByOwnership(parsed.relationMutations); - - const scalarData = { ...parsed.scalarData }; - - for (const relationMutation of parentOwned) { - if (relationMutation.mutation.kind === 'disconnect') { - throw ormError( - 'ORM.RELATION_MUTATION_UNSUPPORTED', - 'disconnect() is only supported in update() nested mutations', - { meta: { kind: 'disconnect', relation: relationMutation.relation.relationName } }, - ); - } - - await applyParentOwnedMutation( - scope, - context, - namespaceId, - modelName, - scalarData, - relationMutation.relation, - relationMutation.mutation, - ); - } - - for (const relationMutation of junctionOwned) { - if (relationMutation.mutation.kind === 'disconnect') { - throw ormError( - 'ORM.RELATION_MUTATION_UNSUPPORTED', - 'disconnect() is only supported in update() nested mutations', - { meta: { kind: 'disconnect', relation: relationMutation.relation.relationName } }, - ); - } - - await preflightJunctionOwnedCreateMutation(scope, context, relationMutation); - } - - const parentRow = await insertSingleRow(scope, context, namespaceId, modelName, scalarData); - - for (const relationMutation of childOwned) { - if (relationMutation.mutation.kind === 'disconnect') { - throw ormError( - 'ORM.RELATION_MUTATION_UNSUPPORTED', - 'disconnect() is only supported in update() nested mutations', - { meta: { kind: 'disconnect', relation: relationMutation.relation.relationName } }, - ); - } - - await applyChildOwnedMutation( - scope, - context, - namespaceId, - modelName, - parentRow, - relationMutation.relation, - relationMutation.mutation, - ); - } - - for (const relationMutation of junctionOwned) { - await applyJunctionOwnedMutation( - scope, - context, - namespaceId, - modelName, - parentRow, - relationMutation.relation, - relationMutation.mutation, - ); - } - - return parentRow; -} - -async function updateFirstGraph( - scope: RuntimeScope, - context: ExecutionContext, - namespaceId: string, - modelName: string, - tables: CollectionTables, - filters: readonly AnyExpression[], - input: MutationUpdateInput, string>, -): Promise | null> { - const contract = context.contract; - const existingRow = await findFirstByFilters( - scope, - contract, - namespaceId, - modelName, - tables, - filters, - ); - if (!existingRow) { - return null; - } - - const parsed = parseMutationInput(contract, namespaceId, modelName, input); - const { parentOwned, childOwned, junctionOwned } = partitionByOwnership(parsed.relationMutations); - - const scalarData = { ...parsed.scalarData }; - - for (const relationMutation of parentOwned) { - await applyParentOwnedMutation( - scope, - context, - namespaceId, - modelName, - scalarData, - relationMutation.relation, - relationMutation.mutation, - ); - } - - for (const relationMutation of junctionOwned) { - await preflightJunctionOwnedCreateMutation(scope, context, relationMutation); - } - - let parentRow = existingRow; - - const mappedUpdateData = mapModelDataToStorageRow(contract, namespaceId, modelName, scalarData); - if (Object.keys(mappedUpdateData).length > 0) { - const tableName = resolveModelTableName(contract, namespaceId, modelName); - const appliedUpdateDefaults = context.applyMutationDefaults({ - op: 'update', - entry: tableName, - namespace: namespaceId, - values: mappedUpdateData, - }); - for (const def of appliedUpdateDefaults) { - mappedUpdateData[def.field] = def.value; - } - const identityFilter = buildRowIdentityFilterFromRow( - contract, - namespaceId, - modelName, - existingRow, - ); - const identityWhere = shorthandToWhereExpr( - context, - namespaceId, - modelName, - castAs, string>>(identityFilter), - tables.root, - ); - if (!identityWhere) { - throw new InternalError(`Failed to build row identity filter for model "${modelName}"`); - } - - const compiled = compileUpdateReturning( - contract, - modelName, - tables, - mappedUpdateData, - [identityWhere], - undefined, - ); - const updatedRowsRaw = await queryPlanRows>(scope, compiled).toArray(); - - const updatedRaw = updatedRowsRaw[0]; - if (updatedRaw) { - parentRow = mapStorageRowToModelFields(contract, namespaceId, modelName, updatedRaw); - } - } - - for (const relationMutation of childOwned) { - await applyChildOwnedMutation( - scope, - context, - namespaceId, - modelName, - parentRow, - relationMutation.relation, - relationMutation.mutation, - ); - } - - for (const relationMutation of junctionOwned) { - await applyJunctionOwnedMutation( - scope, - context, - namespaceId, - modelName, - parentRow, - relationMutation.relation, - relationMutation.mutation, - ); - } - - return parentRow; -} - -async function applyParentOwnedMutation( - scope: RuntimeScope, - context: ExecutionContext, - parentNamespaceId: string, - parentModelName: string, - scalarData: Record, - relation: RelationDefinition, - mutation: RelationMutation, string>, -): Promise { - const contract = context.contract; - if (mutation.kind === 'disconnect') { - for (const localColumn of relation.localColumns) { - const parentFieldName = toFieldName( - contract, - parentNamespaceId, - parentModelName, - localColumn, - ); - scalarData[parentFieldName] = null; - } - return; - } - - if (mutation.kind === 'create') { - const row = mutation.data[0]; - if (!row) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `create() nested mutation for relation "${relation.relationName}" requires data`, - { meta: { kind: 'create', relation: relation.relationName, problem: 'missing-data' } }, - ); - } - - const relatedRow = await createGraph( - scope, - context, - relation.relatedNamespaceId, - relation.relatedModelName, - castAs, string>>(row), - ); - copyRelatedValuesToParent( - contract, - parentNamespaceId, - parentModelName, - relation, - scalarData, - relatedRow, - ); - return; - } - - const criterion = mutation.criteria[0]; - if (!criterion) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `connect() nested mutation for relation "${relation.relationName}" requires criterion`, - { meta: { kind: 'connect', relation: relation.relationName, problem: 'missing-criterion' } }, - ); - } - - const relatedRow = await findRowByCriterion( - scope, - context, - relation.relatedNamespaceId, - relation.relatedModelName, - castAs>(criterion), - ); - if (!relatedRow) { - throw ormError( - 'ORM.RELATION_ROW_MISSING', - `connect() nested mutation for relation "${relation.relationName}" did not find a matching row`, - { meta: { kind: 'connect', relation: relation.relationName } }, - ); - } - - copyRelatedValuesToParent( - contract, - parentNamespaceId, - parentModelName, - relation, - scalarData, - relatedRow, - ); -} - -function copyRelatedValuesToParent( - contract: Contract, - parentNamespaceId: string, - parentModelName: string, - relation: RelationDefinition, - scalarData: Record, - relatedRow: Record, -): void { - for (let i = 0; i < relation.localColumns.length; i++) { - const localColumn = relation.localColumns[i]; - const targetColumn = relation.targetColumns[i]; - if (!localColumn || !targetColumn) { - continue; - } - - const parentFieldName = toFieldName(contract, parentNamespaceId, parentModelName, localColumn); - const childFieldName = toFieldName( - contract, - relation.relatedNamespaceId, - relation.relatedModelName, - targetColumn, - ); - scalarData[parentFieldName] = relatedRow[childFieldName]; - } -} - -async function applyChildOwnedMutation( - scope: RuntimeScope, - context: ExecutionContext, - parentNamespaceId: string, - parentModelName: string, - parentRow: Record, - relation: RelationDefinition, - mutation: RelationMutation, string>, -): Promise { - const contract = context.contract; - const parentValues = readParentColumnValues( - contract, - parentNamespaceId, - parentModelName, - relation, - parentRow, - ); - - if (mutation.kind === 'create') { - for (const childInput of mutation.data) { - const payload: Record = { ...castAs>(childInput) }; - - for (const [childColumn, parentValue] of parentValues.entries()) { - const childFieldName = toFieldName( - contract, - relation.relatedNamespaceId, - relation.relatedModelName, - childColumn, - ); - payload[childFieldName] = parentValue; - } - - await createGraph( - scope, - context, - relation.relatedNamespaceId, - relation.relatedModelName, - castAs, string>>(payload), - ); - } - return; - } - - if (mutation.kind === 'connect') { - for (const criterion of mutation.criteria) { - const related = createRelatedTables(relation); - const criterionWhere = shorthandToWhereExpr( - context, - relation.relatedNamespaceId, - relation.relatedModelName, - castAs, string>>(criterion), - related.root, - ); - if (!criterionWhere) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `connect() nested mutation for relation "${relation.relationName}" requires non-empty criterion`, - { - meta: { kind: 'connect', relation: relation.relationName, problem: 'empty-criterion' }, - }, - ); - } - - const setValues: Record = {}; - for (const [childColumn, parentValue] of parentValues.entries()) { - setValues[childColumn] = parentValue; - } - - await executeUpdateCount(scope, contract, related, setValues, [criterionWhere]); - } - return; - } - - const setValues: Record = {}; - for (const childColumn of parentValues.keys()) { - setValues[childColumn] = null; - } - - if (!mutation.criteria || mutation.criteria.length === 0) { - const related = createRelatedTables(relation); - await executeUpdateCount(scope, contract, related, setValues, [ - buildChildJoinWhere(contract, related.root, parentValues), - ]); - return; - } - - for (const criterion of mutation.criteria) { - const related = createRelatedTables(relation); - const criterionWhere = shorthandToWhereExpr( - context, - relation.relatedNamespaceId, - relation.relatedModelName, - castAs, string>>(criterion), - related.root, - ); - if (!criterionWhere) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `disconnect() nested mutation for relation "${relation.relationName}" requires non-empty criterion`, - { - meta: { kind: 'disconnect', relation: relation.relationName, problem: 'empty-criterion' }, - }, - ); - } - - await executeUpdateCount(scope, contract, related, setValues, [ - and(buildChildJoinWhere(contract, related.root, parentValues), criterionWhere), - ]); - } -} - -async function applyJunctionOwnedMutation( - scope: RuntimeScope, - context: ExecutionContext, - parentNamespaceId: string, - parentModelName: string, - parentRow: Record, - relation: JunctionRelationDefinition, - mutation: RelationMutation, string>, -): Promise { - const contract = context.contract; - const parentPkValues = readJunctionParentValues( - contract, - parentNamespaceId, - parentModelName, - relation, - parentRow, - ); - - assertJunctionPayloadWritable(relation, mutation.kind); - - if (mutation.kind === 'create') { - for (const childInput of mutation.data) { - const relatedRow = await createGraph( - scope, - context, - relation.relatedNamespaceId, - relation.relatedModelName, - castAs, string>>(childInput), - ); - const targetPkValues = readJunctionTargetValues(contract, relation, relatedRow); - await insertJunctionLink(scope, context, relation, parentPkValues, targetPkValues, 'create'); - } - return; - } - - if (mutation.kind === 'connect') { - for (const criterion of mutation.criteria) { - const targetPkValues = await resolveJunctionTargetValues( - scope, - context, - relation, - 'connect', - criterion, - ); - await insertJunctionLink(scope, context, relation, parentPkValues, targetPkValues, 'connect'); - } - return; - } - - if (!mutation.criteria || mutation.criteria.length === 0) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `disconnect() nested mutation for relation "${relation.relationName}" requires criterion`, - { - meta: { kind: 'disconnect', relation: relation.relationName, problem: 'missing-criterion' }, - }, - ); - } - - for (const criterion of mutation.criteria) { - const targetPkValues = await resolveJunctionTargetValues( - scope, - context, - relation, - 'disconnect', - criterion, - ); - await deleteJunctionLink(scope, context, relation, parentPkValues, targetPkValues); - } -} - -async function preflightJunctionOwnedCreateMutation( - scope: RuntimeScope, - context: ExecutionContext, - relationMutation: JunctionParsedRelationMutation, -): Promise { - const { relation, mutation } = relationMutation; - assertJunctionMetadataShape(relation); - assertJunctionPayloadWritable(relation, mutation.kind); - - if (mutation.kind !== 'connect') { - return; - } - - const seenTargetKeys = new Set(); - for (const criterion of mutation.criteria) { - const targetValues = await resolveJunctionTargetValues( - scope, - context, - relation, - 'connect', - criterion, - ); - const targetKey = JSON.stringify([...targetValues.entries()]); - if (seenTargetKeys.has(targetKey)) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `connect() nested mutation for relation "${relation.relationName}" resolved duplicate junction link targets; remove the duplicate criteria`, - { - meta: { kind: 'connect', relation: relation.relationName, problem: 'duplicate-criteria' }, - }, - ); - } - seenTargetKeys.add(targetKey); - } -} - -async function resolveJunctionTargetValues( - scope: RuntimeScope, - context: ExecutionContext, - relation: JunctionRelationDefinition, - kind: 'connect' | 'disconnect', - criterion: Record, -): Promise> { - const relatedRow = await findRowByCriterion( - scope, - context, - relation.relatedNamespaceId, - relation.relatedModelName, - criterion, - ); - if (!relatedRow) { - throw ormError( - 'ORM.RELATION_ROW_MISSING', - `${kind}() nested mutation for relation "${relation.relationName}" did not find a matching row`, - { meta: { kind, relation: relation.relationName } }, - ); - } - return readJunctionTargetValues(context.contract, relation, relatedRow); -} - -function readJunctionParentValues( - contract: Contract, - parentNamespaceId: string, - parentModelName: string, - relation: JunctionRelationDefinition, - parentRow: Record, -): Map { - const values = new Map(); - assertJunctionParentMetadataLength(relation); - - for (let i = 0; i < relation.through.parentColumns.length; i++) { - const junctionColumn = relation.through.parentColumns[i]; - const parentColumn = relation.localColumns[i]; - if (junctionColumn === undefined || parentColumn === undefined) { - throw new InternalError( - `Relation "${relation.relationName}" has incomplete junction metadata for parent columns`, - ); - } - - const parentFieldName = toFieldName(contract, parentNamespaceId, parentModelName, parentColumn); - const parentValue = parentRow[parentFieldName]; - if (parentValue === undefined) { - throw new InternalError( - `Nested mutation requires parent field "${parentFieldName}" to be present in returned row`, - ); - } - - values.set(junctionColumn, parentValue); - } - - return values; -} - -function readJunctionTargetValues( - contract: Contract, - relation: JunctionRelationDefinition, - relatedRow: Record, -): Map { - const values = new Map(); - assertJunctionTargetMetadataLength(relation); - - for (let i = 0; i < relation.through.childColumns.length; i++) { - const junctionColumn = relation.through.childColumns[i]; - const targetColumn = relation.through.targetColumns[i]; - if (junctionColumn === undefined || targetColumn === undefined) { - throw new InternalError( - `Relation "${relation.relationName}" has incomplete junction metadata for target columns`, - ); - } - - const targetFieldName = toFieldName( - contract, - relation.relatedNamespaceId, - relation.relatedModelName, - targetColumn, - ); - const targetValue = relatedRow[targetFieldName]; - if (targetValue === undefined) { - throw new InternalError( - `Nested mutation requires target field "${targetFieldName}" to be present in returned row`, - ); - } - - values.set(junctionColumn, targetValue); - } - - return values; -} - -function writeJunctionColumn( - junctionRow: Record, - through: JunctionThrough, - column: string, - value: unknown, - relationName: string, -): void { - if (Object.hasOwn(junctionRow, column) && !Object.is(junctionRow[column], value)) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `Cannot write junction "${through.table}": conflicting values for junction column "${column}"`, - { meta: { relation: relationName, junction: through.table, column } }, - ); - } - - junctionRow[column] = value; -} - -async function insertJunctionLink( - scope: RuntimeScope, - context: ExecutionContext, - relation: JunctionRelationDefinition, - parentPkValues: Map, - targetPkValues: Map, - mutationKind: 'create' | 'connect', -): Promise { - const through = relation.through; - const junctionRow: Record = {}; - for (const [column, value] of parentPkValues.entries()) { - writeJunctionColumn(junctionRow, through, column, value, relation.relationName); - } - for (const [column, value] of targetPkValues.entries()) { - writeJunctionColumn(junctionRow, through, column, value, relation.relationName); - } - - // Mirror insertSingleRow: payload columns whose only source is an - // execution-time onCreate default pass both the type gate and the runtime - // guard, so the INSERT must populate them here or hit NOT NULL on the - // database. - const applied = context.applyMutationDefaults({ - op: 'create', - entry: through.table, - namespace: through.namespaceId, - values: junctionRow, - }); - for (const def of applied) { - junctionRow[def.field] = def.value; - } - - const compiled = compileInsertCount(context.contract, through.namespaceId, through.table, [ - junctionRow, - ]); - try { - await scope.execute(compiled); - } catch (error) { - // The junction PK is the common unique constraint here, but the table may - // carry others — say a unique constraint was violated rather than - // asserting the link itself already exists. - if (mutationKind === 'connect' && isUniqueConstraintViolation(error)) { - throw ormError( - 'ORM.RELATION_LINK_DUPLICATE', - `connect() nested mutation for relation "${relation.relationName}" violated a unique constraint on junction "${through.table}"; the junction link may already be present`, - { meta: { relation: relation.relationName, junction: through.table }, cause: error }, - ); - } - throw error; - } -} - -async function deleteJunctionLink( - scope: RuntimeScope, - context: ExecutionContext, - relation: JunctionRelationDefinition, - parentPkValues: Map, - targetPkValues: Map, -): Promise { - // Merge through writeJunctionColumn like the INSERT side: a shared junction - // column with mismatched parent/target values surfaces the same conflict - // error as connect instead of emitting contradictory predicates that make - // the DELETE silently match nothing. - const through = relation.through; - const junctionRow: Record = {}; - for (const [column, value] of parentPkValues.entries()) { - writeJunctionColumn(junctionRow, through, column, value, relation.relationName); - } - for (const [column, value] of targetPkValues.entries()) { - writeJunctionColumn(junctionRow, through, column, value, relation.relationName); - } - - const junction = createStatementTables({ - namespaceId: through.namespaceId, - tableName: through.table, - }); - const exprs: AnyExpression[] = []; - for (const [column, value] of Object.entries(junctionRow)) { - exprs.push( - BinaryExpr.eq( - junction.root.column(column), - paramRefForStorageColumn(context.contract, junction.root.storage, column, value), - ), - ); - } - - const first = exprs[0]; - const where = exprs.length === 1 && first !== undefined ? first : and(...exprs); - const compiled = compileDeleteCount(context.contract, junction, [where]); - await scope.execute(compiled); -} - -function readParentColumnValues( - contract: Contract, - parentNamespaceId: string, - parentModelName: string, - relation: RelationDefinition, - parentRow: Record, -): Map { - const values = new Map(); - - for (let i = 0; i < relation.localColumns.length; i++) { - const localColumn = relation.localColumns[i]; - const targetColumn = relation.targetColumns[i]; - if (!localColumn || !targetColumn) { - continue; - } - - const parentFieldName = toFieldName(contract, parentNamespaceId, parentModelName, localColumn); - const parentValue = parentRow[parentFieldName]; - if (parentValue === undefined) { - throw new InternalError( - `Nested mutation requires parent field "${parentFieldName}" to be present in returned row`, - ); - } - - values.set(targetColumn, parentValue); - } - - return values; -} - -function createRelatedTables(relation: RelationDefinition): CollectionTables { - return createStatementTables({ - namespaceId: relation.relatedNamespaceId, - tableName: relation.relatedTableName, - }); -} - -function buildChildJoinWhere( - contract: Contract, - related: AliasedTable, - childValues: Map, -): AnyExpression { - const exprs: AnyExpression[] = []; - - for (const [childColumn, parentValue] of childValues.entries()) { - exprs.push( - BinaryExpr.eq( - related.column(childColumn), - paramRefForStorageColumn(contract, related.storage, childColumn, parentValue), - ), - ); - } - - const first = exprs[0]; - if (exprs.length === 1 && first !== undefined) { - return first; - } - - return and(...exprs); -} - -async function insertSingleRow( - scope: RuntimeScope, - context: ExecutionContext, - namespaceId: string, - modelName: string, - data: Record, -): Promise> { - const contract = context.contract; - const tableName = resolveModelTableName(contract, namespaceId, modelName); - - const mappedData = mapModelDataToStorageRow(contract, namespaceId, modelName, data); - const applied = context.applyMutationDefaults({ - op: 'create', - entry: tableName, - namespace: namespaceId, - values: mappedData, - }); - - for (const def of applied) { - mappedData[def.field] = def.value; - } - - const compiled = compileInsertReturning( - contract, - namespaceId, - modelName, - tableName, - [mappedData], - undefined, - ); - const rows = await queryPlanRows>(scope, compiled).toArray(); - - const firstRow = rows[0]; - if (!firstRow) { - throw ormError( - 'ORM.MUTATION_ROW_MISSING', - `Nested create for model "${modelName}" did not return a row`, - { meta: { operation: 'create', model: modelName, phase: 'nested' } }, - ); - } - - return mapStorageRowToModelFields(contract, namespaceId, modelName, firstRow); -} - -async function findRowByCriterion( - scope: RuntimeScope, - context: ExecutionContext, - namespaceId: string, - modelName: string, - criterion: Record, -): Promise | null> { - const contract = context.contract; - const tables = createStatementTables({ - namespaceId, - tableName: resolveModelTableName(contract, namespaceId, modelName), - }); - const whereExpr = shorthandToWhereExpr( - context, - namespaceId, - modelName, - castAs, string>>(criterion), - tables.root, - ); - if (!whereExpr) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `Nested connect for model "${modelName}" requires non-empty criterion`, - { meta: { kind: 'connect', model: modelName, problem: 'empty-criterion' } }, - ); - } - - const state: CollectionState = { - ...emptyState(tables), - filters: [whereExpr], - limit: 1, - }; - const compiled = compileSelect(contract, modelName, state); - const rows = await queryPlanRows>(scope, compiled).toArray(); - - const firstRow = rows[0]; - if (!firstRow) { - return null; - } - - return mapStorageRowToModelFields(contract, namespaceId, modelName, firstRow); -} - -async function findFirstByFilters( - scope: RuntimeScope, - contract: Contract, - namespaceId: string, - modelName: string, - tables: CollectionTables, - filters: readonly AnyExpression[], -): Promise | null> { - const state: CollectionState = { - ...emptyState(tables), - filters, - limit: 1, - }; - const compiled = compileSelect(contract, modelName, state); - const rows = await queryPlanRows>(scope, compiled).toArray(); - - const firstRow = rows[0]; - if (!firstRow) { - return null; - } - - return mapStorageRowToModelFields(contract, namespaceId, modelName, firstRow); -} - -async function executeUpdateCount( - scope: RuntimeScope, - contract: Contract, - tables: CollectionTables, - setValues: Record, - filters: readonly AnyExpression[], -): Promise { - const compiled = compileUpdateCount(contract, tables, setValues, filters); - await scope.execute(compiled); -} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/assert.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/assert.ts index 135619f307b2..e63b4b46e4e0 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/assert.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/assert.ts @@ -1,11 +1,12 @@ import { type Executed, Node, type OutputsOf, type Run } from './node'; +import { type Requires, requirementUnmet } from './requires'; import type { Rows } from './rows'; import { rowsResult } from './run-statement'; -type Inputs = { rows: Rows }; +type Inputs = { rows: Rows; requires: Requires[] }; export class Assert extends Node { - readonly slots = { rows: 'one' } as const; + readonly slots = { rows: 'one', requires: 'list' } as const; readonly error: Error; constructor(error: Error) { @@ -15,6 +16,9 @@ export class Assert extends Node { } override execute(inputs: OutputsOf, _run: Run): Executed { + if (requirementUnmet(inputs.requires)) { + return rowsResult([]); + } if (inputs.rows.length === 0) { throw this.error; } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts index e8bd53221655..ad81d520f434 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts @@ -27,6 +27,7 @@ import { } from '../query-plan-mutations'; import { collectionSelectAst, collectionSelectWithIncludesAst } from '../query-plan-select'; import { variantReturning } from '../query-plan-source'; +import type { ParsedRelationMutation } from '../relation-mutations'; import type { AliasedTable } from '../table-scope'; import { type CollectionState, emptyState } from '../types'; import { CrossJoin } from './cross-join'; @@ -36,7 +37,13 @@ import { Find } from './find'; import { Graph } from './graph'; import { Insert } from './insert'; import { InsertChunked } from './insert-chunked'; -import { addCreateWithRelations } from './nested-create'; +import { + addAfterWrite, + addBeforeWrite, + addCreateWithRelations, + type BeforeWrite, + type KeySource, +} from './nested-mutations'; import { UnionAll } from './union-all'; import { Update } from './update'; @@ -75,7 +82,11 @@ export function deleteAllGraph(collection: DescribeCollectionRowsOptions): Graph export function updateFirstGraph( collection: DescribeCollectionRowsOptions, set: Readonly>, + relationMutations: readonly ParsedRelationMutation[] = [], ): Graph { + if (relationMutations.length > 0) { + return updateFirstWithRelationsGraph(collection, set, relationMutations); + } const { context, state } = collection; const graph = new Graph(context.contract); const nothingToSet = Object.keys(set).length === 0; @@ -103,6 +114,69 @@ export function updateFirstGraph( return graph; } +function updateFirstWithRelationsGraph( + collection: DescribeCollectionRowsOptions, + set: Readonly>, + relationMutations: readonly ParsedRelationMutation[], +): Graph { + const { context, modelName, namespaceId, state } = collection; + const graph = new Graph(context.contract); + requireIdentityColumns(collection); + requireIdentityColumnsForIncludes(collection); + const model = { namespaceId, modelName, tables: state.tables }; + const find = graph.add(new Find(selectIdentityAst(collection, { ...state, limit: 1 }))); + const t = { graph, context, gate: find }; + const before = addBeforeWrite(t, model, relationMutations); + const values = withUpdateDefaults(collection, set, before); + const parent = + Object.keys(values).length === 0 && before.keys.length === 0 + ? find + : addParentUpdate(graph, collection, find, values, before.keys); + addAfterWrite(t, model, parent, relationMutations); + graph.setResult(addWithIncludes(graph, collection, parent)); + return graph; +} + +function addParentUpdate( + graph: Graph, + collection: DescribeCollectionRowsOptions, + find: NodeId, + values: Readonly>, + keys: readonly KeySource[], +): NodeId { + const { context, state } = collection; + const updated = graph.add( + new Update(updateAst(context.contract, state.tables, values, undefined)), + ); + graph.addFilterDataEdge(find, updated, sameRowColumns(collection)); + for (const { from, columns } of keys) { + graph.addPayloadDataEdge(from, updated, columns); + } + return updated; +} + +function withUpdateDefaults( + collection: DescribeCollectionRowsOptions, + set: Readonly>, + before: BeforeWrite, +): Readonly> { + const values: Record = { ...set, ...before.nullColumns }; + const setNothingBefore = Object.keys(set).length === 0; + const changesNow = Object.keys(values).length > 0 || before.keys.length > 0; + if (setNothingBefore && changesNow) { + const { namespaceId, tableName } = collection.state.tables.root.storage; + for (const applied of collection.context.applyMutationDefaults({ + op: 'update', + entry: tableName, + namespace: namespaceId, + values, + })) { + values[applied.field] = applied.value; + } + } + return values; +} + export function deleteFirstGraph(collection: DescribeCollectionRowsOptions): Graph { const { context, state } = collection; const withIncludes = state.includes.length > 0; @@ -158,8 +232,7 @@ export function createGraph( const graph = new Graph(context.contract); requireIdentityColumnsForIncludes(collection); const created = addCreateWithRelations( - graph, - context, + { graph, context, gate: undefined }, { namespaceId, modelName, tables: state.tables }, data, [], diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts index 4461fb45ad2a..b2a32c702995 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts @@ -22,7 +22,7 @@ export class Delete extends StatementNode { } override execute(inputs: OutputsOf, run: Run): Executed { - return executeFiltered(this.ast, inputs.filter, run); + return executeFiltered(this.ast, inputs, run); } override withReturning(columns: readonly ProjectionItem[]): Delete { diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/filtered-statement.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/filtered-statement.ts index a69ebca14d53..082bc7ea2c4f 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/filtered-statement.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/filtered-statement.ts @@ -9,18 +9,23 @@ import { import { combineWhereExprs } from '../where-utils'; import type { FilterData } from './filter-data'; import type { Executed, OutputsOf, Run } from './node'; +import { type Requires, requirementUnmet } from './requires'; import { executeStatement, rowsResult } from './run-statement'; import type { StateEdge } from './state'; -export type Filtered = { filter: FilterData[]; state: StateEdge[] }; +export type Filtered = { filter: FilterData[]; state: StateEdge[]; requires: Requires[] }; -export const filteredSlots = { filter: 'list', state: 'list' } as const; +export const filteredSlots = { filter: 'list', state: 'list', requires: 'list' } as const; export function executeFiltered( ast: SelectAst | UpdateAst | DeleteAst, - filter: OutputsOf['filter'], + inputs: Pick, 'filter' | 'requires'>, run: Run, ): Executed { + const { filter } = inputs; + if (requirementUnmet(inputs.requires)) { + return rowsResult([]); + } if (filter === null) { return executeStatement(ast, run); } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/find.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/find.ts index c0ff48a43523..1154b483fbcc 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/find.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/find.ts @@ -22,7 +22,7 @@ export class Find extends StatementNode { } override execute(inputs: OutputsOf, run: Run): Executed { - return executeFiltered(this.ast, inputs.filter, run); + return executeFiltered(this.ast, inputs, run); } override withReturning(columns: readonly ProjectionItem[]): Find { diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/graph.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/graph.ts index b11f3f717ca9..3ef920f3030c 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/graph.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/graph.ts @@ -12,6 +12,7 @@ import type { ColumnPair, Edge, NodeId } from './edge'; import { FilterData } from './filter-data'; import type { Node, Slot, Slots, StatementAst } from './node'; import { PayloadData } from './payload-data'; +import { Requires } from './requires'; import { Rows } from './rows'; import { State, StateEdge } from './state'; import { StatementNode } from './statement-node'; @@ -90,6 +91,10 @@ export class Graph { this.#connect(to, slot, new Rows(from, columns)); } + addRequiresEdge(from: NodeId, to: NodeId<{ requires: Requires[] }>): void { + this.#connect(to, 'requires', new Requires(from)); + } + sameState(a: NodeId, b: NodeId, table: TableCoordinate): boolean { const stateA = this.#stateEdgeOf(a, table); return stateA !== undefined && stateA.from === this.#stateEdgeOf(b, table)?.from; diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert-chunked.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert-chunked.ts index 549f3bd9ee05..8f3d28d8571e 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert-chunked.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert-chunked.ts @@ -3,13 +3,15 @@ import type { InsertAst, ProjectionItem } from '@internal/sql-relational-core/as import type { StorageRow } from './edge'; import { withColumns } from './filtered-statement'; import type { Executed, OutputsOf, Run, StatementAst } from './node'; +import { type Requires, requirementUnmet } from './requires'; +import { rowsResult } from './run-statement'; import type { StateEdge } from './state'; import { StatementNode } from './statement-node'; -type StateOnly = { state: StateEdge[] }; +type StateOnly = { state: StateEdge[]; requires: Requires[] }; export class InsertChunked extends StatementNode { - readonly slots = { state: 'list' } as const; + readonly slots = { state: 'list', requires: 'list' } as const; readonly asts: readonly InsertAst[]; constructor(asts: readonly InsertAst[]) { @@ -26,7 +28,10 @@ export class InsertChunked extends StatementNode { return this.asts[0]?.returning ?? []; } - override execute(_inputs: OutputsOf, run: Run): Executed { + override execute(inputs: OutputsOf, run: Run): Executed { + if (requirementUnmet(inputs.requires)) { + return rowsResult([]); + } if (this.returns.length === 0) { return this.#sumOfCounts(run); } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts index 1385a47448ff..cbd08579f658 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts @@ -3,11 +3,12 @@ import { ormError } from '../orm-errors'; import { withColumns } from './filtered-statement'; import type { Executed, OutputsOf, Run, StatementAst } from './node'; import type { Payload, PayloadData } from './payload-data'; +import { type Requires, requirementUnmet } from './requires'; import { executeStatement, rowsResult } from './run-statement'; import type { StateEdge } from './state'; import { StatementNode } from './statement-node'; -type Payloads = { payload: PayloadData[]; state: StateEdge[] }; +type Payloads = { payload: PayloadData[]; state: StateEdge[]; requires: Requires[] }; export interface InsertJunction { readonly relation: string; @@ -15,7 +16,7 @@ export interface InsertJunction { } export class Insert extends StatementNode { - readonly slots = { payload: 'list', state: 'list' } as const; + readonly slots = { payload: 'list', state: 'list', requires: 'list' } as const; readonly ast: InsertAst; readonly junction: InsertJunction | undefined; @@ -35,6 +36,9 @@ export class Insert extends StatementNode { } override execute(inputs: OutputsOf, run: Run): Executed { + if (requirementUnmet(inputs.requires)) { + return rowsResult([]); + } // No PayloadData edge reaches this insert, so it runs with the values it was built with. if (inputs.payload === null) { return executeStatement(this.ast, run); diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/mutation-scope.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/mutation-scope.ts new file mode 100644 index 000000000000..1f2e68c5495c --- /dev/null +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/mutation-scope.ts @@ -0,0 +1,57 @@ +import type { RuntimeScope } from '@internal/sql-relational-core/types'; +import { settlementOf } from '@internal/sql-runtime/internal/prepared-query'; +import type { RuntimeQueryable, RuntimeTransaction } from '../types'; + +export async function withMutationScope( + runtime: RuntimeQueryable, + run: (scope: RuntimeScope) => Promise, +): Promise { + // A top-level transaction wins when the runtime exposes one directly. + if (typeof runtime.transaction === 'function') { + return runInTransaction(await runtime.transaction(), run); + } + + // Otherwise open a connection and run the whole mutation graph inside its + // transaction. The top-level `Runtime` exposes `transaction()` only on a + // connection (`connection().transaction()`), so without this a multi-statement + // graph that fails after the first write would leave a partial write behind. + if (typeof runtime.connection === 'function') { + const connection = await runtime.connection(); + try { + if (typeof connection.transaction === 'function') { + return await runInTransaction(await connection.transaction(), run); + } + return await run(connection); + } finally { + await connection.release?.(); + } + } + + // Bare runtimes (e.g. unit-test stubs) expose neither: run directly. + return run(runtime); +} + +async function runInTransaction( + transaction: RuntimeTransaction, + run: (scope: RuntimeScope) => Promise, +): Promise { + try { + const result = await run(transaction); + if (typeof transaction.commit === 'function') { + await transaction.commit(); + } + return result; + } catch (error) { + if (typeof transaction.rollback === 'function') { + try { + await transaction.rollback(); + } catch (rollbackError) { + // A hook that threw after the ROLLBACK landed must not replace the error that caused it. + const settlement = settlementOf(transaction); + if (settlement?.rolledBack !== true) throw rollbackError; + settlement.logDroppedError(rollbackError); + } + } + throw error; + } +} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/nested-create.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/nested-mutations.ts similarity index 65% rename from packages/3-extensions/sql-orm-client/src/mutation-graph/nested-create.ts rename to packages/3-extensions/sql-orm-client/src/mutation-graph/nested-mutations.ts index caef9494b3c4..dfbe97dd1e0c 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/nested-create.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/nested-mutations.ts @@ -8,6 +8,7 @@ import { type CollectionTables, createStatementTables } from '../collection-tabl import { shorthandToWhereExpr } from '../filters'; import { ormError } from '../orm-errors'; import { + deleteAst, groupRowsByColumnSignature, type InsertConflictSkip, insertAst, @@ -20,6 +21,7 @@ import { assertJunctionPayloadWritable, hasNestedMutationCallbacks, type JunctionRelationDefinition, + type ParsedRelationMutation, parseMutationInput, partitionByOwnership, type RelationDefinition, @@ -29,69 +31,109 @@ import type { AliasedTable } from '../table-scope'; import type { MutationCreateInput, MutationUpdateInput, RelationMutation } from '../types'; import { emptyState } from '../types'; import { Assert } from './assert'; +import { Delete } from './delete'; import type { ColumnPair, NodeId } from './edge'; import { Find } from './find'; import type { Graph } from './graph'; import { Insert } from './insert'; +import type { Requires } from './requires'; import { Update } from './update'; type Mutation = RelationMutation, string>; type CreateOrConnect = Exclude; -interface KeySource { +export interface Translation { + readonly graph: Graph; + readonly context: ExecutionContext; + readonly gate: NodeId | undefined; +} + +export interface KeySource { readonly from: NodeId; readonly columns: readonly ColumnPair[]; } -interface Model { +export interface Model { readonly namespaceId: string; readonly modelName: string; readonly tables: CollectionTables; } +export interface BeforeWrite { + readonly keys: readonly KeySource[]; + readonly nullColumns: Readonly>; +} + export function addCreateWithRelations( - graph: Graph, - context: ExecutionContext, + t: Translation, model: Model, input: Readonly>, keysFromParent: readonly KeySource[], + gated = false, ): NodeId { - const { contract } = context; const parsed = parseMutationInput( - contract, + t.context.contract, model.namespaceId, model.modelName, castAs, string>>(input), ); - const { parentOwned, childOwned, junctionOwned } = partitionByOwnership(parsed.relationMutations); - - const keys = [...keysFromParent]; - for (const { relation, mutation } of parentOwned) { + for (const { relation, mutation } of parsed.relationMutations) { rejectDisconnect(relation, mutation); - keys.push(addParentOwned(graph, context, model, relation, mutation)); } + const before = addBeforeWrite(t, model, parsed.relationMutations); + const parent = addInsertOf( + t, + model, + [storageRowOf(t.context, model, parsed.scalarData)], + [...keysFromParent, ...before.keys], + ); + if (gated) { + requireGate(t, parent); + } + addAfterWrite(t, model, parent, parsed.relationMutations); + return parent; +} + +export function addBeforeWrite( + t: Translation, + model: Model, + relationMutations: readonly ParsedRelationMutation[], +): BeforeWrite { + const { parentOwned, junctionOwned } = partitionByOwnership(relationMutations); for (const { relation, mutation } of junctionOwned) { - rejectDisconnect(relation, mutation); assertJunctionMetadataShape(relation); assertJunctionPayloadWritable(relation, mutation.kind); } + const keys: KeySource[] = []; + const nullColumns: Record = {}; + for (const { relation, mutation } of parentOwned) { + if (mutation.kind === 'disconnect') { + for (const column of relation.localColumns) { + nullColumns[column] = null; + } + } else { + keys.push(addParentOwned(t, model, relation, mutation)); + } + } + return { keys, nullColumns }; +} - const parent = addParentInsert(graph, context, model, parsed.scalarData, keys); - +export function addAfterWrite( + t: Translation, + model: Model, + parent: NodeId, + relationMutations: readonly ParsedRelationMutation[], +): void { + const { childOwned, junctionOwned } = partitionByOwnership(relationMutations); for (const { relation, mutation } of childOwned) { - rejectDisconnect(relation, mutation); - addChildOwned(graph, context, model, parent, relation, mutation); + addChildOwned(t, model, parent, relation, mutation); } for (const { relation, mutation } of junctionOwned) { - addJunctionOwned(graph, context, model, parent, relation, mutation); + addJunctionOwned(t, model, parent, relation, mutation); } - return parent; } -function rejectDisconnect( - relation: RelationDefinition, - mutation: Mutation, -): asserts mutation is CreateOrConnect { +function rejectDisconnect(relation: RelationDefinition, mutation: Mutation): void { if (mutation.kind === 'disconnect') { throw ormError( 'ORM.RELATION_MUTATION_UNSUPPORTED', @@ -102,15 +144,14 @@ function rejectDisconnect( } function addParentOwned( - graph: Graph, - context: ExecutionContext, + t: Translation, model: Model, relation: RelationDefinition, mutation: CreateOrConnect, ): KeySource { const related = relatedModel(relation); const columns = pairs( - context.contract, + t.context.contract, related.tables.root, relation.targetColumns, model.tables.root, @@ -125,7 +166,7 @@ function addParentOwned( { meta: { kind: 'create', relation: relation.relationName, problem: 'missing-data' } }, ); } - return { from: addCreateWithRelations(graph, context, related, row, []), columns }; + return { from: addCreateWithRelations(t, related, row, [], true), columns }; } const [criterion] = mutation.criteria; if (criterion === undefined) { @@ -135,70 +176,69 @@ function addParentOwned( { meta: { kind: 'connect', relation: relation.relationName, problem: 'missing-criterion' } }, ); } - const found = addFindAndAssert(graph, context, related, relation, criterion, columns); - return { from: found, columns }; + return { from: addFindAndAssert(t, related, relation, 'connect', criterion, columns), columns }; } function addChildOwned( - graph: Graph, - context: ExecutionContext, + t: Translation, model: Model, parent: NodeId, relation: RelationDefinition, mutation: Mutation, ): void { + const { graph, context } = t; const related = relatedModel(relation); - const keys = [ - { - from: parent, - columns: pairs( - context.contract, - model.tables.root, - relation.localColumns, - related.tables.root, - relation.targetColumns, - ), - }, - ]; + const fromParent = pairs( + context.contract, + model.tables.root, + relation.localColumns, + related.tables.root, + relation.targetColumns, + ); if (mutation.kind === 'create') { + const keys = [{ from: parent, columns: fromParent }]; const rows = mutation.data.map((row) => castAs>>(row)); if (rows.some((row) => hasRelationCallbacks(context, related, row))) { for (const row of rows) { - addCreateWithRelations(graph, context, related, row, keys); + addCreateWithRelations(t, related, row, keys); } return; } - addChildInserts(graph, context, related, rows, keys); + addChildInserts(t, related, rows, keys); return; } if (mutation.kind === 'connect') { for (const criterion of mutation.criteria) { - const where = criterionWhere(context, related, criterion); - if (where === undefined) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `connect() nested mutation for relation "${relation.relationName}" requires non-empty criterion`, - { - meta: { kind: 'connect', relation: relation.relationName, problem: 'empty-criterion' }, - }, - ); - } + const where = requiredCriterion(context, related, relation, 'connect', criterion); const update = graph.add(new Update(updateAst(context.contract, related.tables, {}, where))); - for (const { from, columns } of keys) { - graph.addPayloadDataEdge(from, update, columns); - } + graph.addPayloadDataEdge(parent, update, fromParent); } + return; + } + const unlink = Object.fromEntries(relation.targetColumns.map((column) => [column, null])); + const criteria = mutation.criteria ?? []; + const wheres = + criteria.length === 0 + ? [undefined] + : criteria.map((criterion) => + requiredCriterion(context, related, relation, 'disconnect', criterion), + ); + for (const where of wheres) { + const update = graph.add( + new Update(updateAst(context.contract, related.tables, unlink, where)), + ); + graph.addFilterDataEdge(parent, update, fromParent); } } function addJunctionOwned( - graph: Graph, - context: ExecutionContext, + t: Translation, model: Model, parent: NodeId, relation: JunctionRelationDefinition, mutation: Mutation, ): void { + const { graph, context } = t; const { contract } = context; const { through } = relation; const related = relatedModel(relation); @@ -234,56 +274,61 @@ function addJunctionOwned( if (mutation.kind === 'create') { for (const row of mutation.data) { - addLink(addCreateWithRelations(graph, context, related, row, []), undefined); + addLink(addCreateWithRelations(t, related, row, [], true), undefined); } return; } if (mutation.kind === 'connect') { const conflict = linkKeyConflict(contract, relation); for (const criterion of mutation.criteria) { - addLink(addFindAndAssert(graph, context, related, relation, criterion, fromTarget), conflict); + addLink(addFindAndAssert(t, related, relation, 'connect', criterion, fromTarget), conflict); } + return; + } + const criteria = mutation.criteria ?? []; + if (criteria.length === 0) { + throw ormError( + 'ORM.RELATION_MUTATION_INVALID', + `disconnect() nested mutation for relation "${relation.relationName}" requires criterion`, + { + meta: { kind: 'disconnect', relation: relation.relationName, problem: 'missing-criterion' }, + }, + ); + } + for (const criterion of criteria) { + const target = addFindAndAssert(t, related, relation, 'disconnect', criterion, fromTarget); + const unlink = graph.add(new Delete(deleteAst(contract, junction, undefined))); + graph.addFilterDataEdge(parent, unlink, fromParent); + graph.addFilterDataEdge(target, unlink, fromTarget); } -} - -function addParentInsert( - graph: Graph, - context: ExecutionContext, - model: Model, - row: Readonly>, - keys: readonly KeySource[], -): NodeId { - return addInsertOf(graph, context, model, [storageRowOf(context, model, row)], keys); } function addChildInserts( - graph: Graph, - context: ExecutionContext, + t: Translation, model: Model, rows: readonly Readonly>[], keys: readonly KeySource[], ): void { - const values = rows.map((row) => storageRowOf(context, model, row)); - const groups = supportsDefaultInInsert(context.contract) + const values = rows.map((row) => storageRowOf(t.context, model, row)); + const groups = supportsDefaultInInsert(t.context.contract) ? [values] : groupRowsByColumnSignature(values); for (const group of groups) { - addInsertOf(graph, context, model, group, keys); + addInsertOf(t, model, group, keys); } } function addInsertOf( - graph: Graph, - context: ExecutionContext, + t: Translation, model: Model, values: readonly Record[], keys: readonly KeySource[], -): NodeId { - const insert = graph.add( - new Insert(insertAst(context.contract, model.tables.root, values, undefined)), +) { + const insert = t.graph.add( + new Insert(insertAst(t.context.contract, model.tables.root, values, undefined)), ); for (const { from, columns } of keys) { - graph.addPayloadDataEdge(from, insert, columns); + t.graph.addPayloadDataEdge(from, insert, columns); } return insert; } @@ -303,13 +348,14 @@ function storageRowOf( } function addFindAndAssert( - graph: Graph, - context: ExecutionContext, + t: Translation, related: Model, relation: RelationDefinition, + kind: 'connect' | 'disconnect', criterion: Readonly>, columns: readonly ColumnPair[], ): NodeId { + const { graph, context } = t; const where = criterionWhere(context, related, criterion); if (where === undefined) { throw ormError( @@ -327,12 +373,13 @@ function addFindAndAssert( }), ), ); + requireGate(t, find); const assert = graph.add( new Assert( ormError( 'ORM.RELATION_ROW_MISSING', - `connect() nested mutation for relation "${relation.relationName}" did not find a matching row`, - { meta: { kind: 'connect', relation: relation.relationName } }, + `${kind}() nested mutation for relation "${relation.relationName}" did not find a matching row`, + { meta: { kind, relation: relation.relationName } }, ), ), ); @@ -342,9 +389,34 @@ function addFindAndAssert( 'rows', columns.map(([source]) => [source, source]), ); + requireGate(t, assert); return find; } +function requiredCriterion( + context: ExecutionContext, + model: Model, + relation: RelationDefinition, + kind: 'connect' | 'disconnect', + criterion: Readonly>, +) { + const where = criterionWhere(context, model, criterion); + if (where === undefined) { + throw ormError( + 'ORM.RELATION_MUTATION_INVALID', + `${kind}() nested mutation for relation "${relation.relationName}" requires non-empty criterion`, + { meta: { kind, relation: relation.relationName, problem: 'empty-criterion' } }, + ); + } + return where; +} + +function requireGate(t: Translation, node: NodeId<{ requires: Requires[] }>): void { + if (t.gate !== undefined) { + t.graph.addRequiresEdge(t.gate, node); + } +} + function criterionWhere( context: ExecutionContext, model: Model, diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/requires.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/requires.ts new file mode 100644 index 000000000000..1afc60f97612 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/requires.ts @@ -0,0 +1,18 @@ +import { Edge, type NodeId } from './edge'; + +export class Requires extends Edge { + constructor(from: NodeId) { + super(from); + Object.freeze(this); + } + + override output(): undefined { + return undefined; + } +} + +export type RequiresSlot = { requires: Requires[] }; + +export function requirementUnmet(requires: readonly (readonly undefined[])[] | null): boolean { + return requires?.some((rows) => rows.length === 0) === true; +} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts index 08c50abc2b0a..644200ec9219 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts @@ -7,13 +7,13 @@ import { blindCast } from '@internal/utils/casts'; import { getColumnsReadOnTable } from '../collection-contract'; import { consumeCollectionRows, type DescribeCollectionRowsOptions } from '../collection-dispatch'; import { mapResultRows, stripHiddenMappedFields } from '../collection-runtime'; -import { withMutationScope } from '../mutation-executor'; import { buildOrmQueryPlan, deriveParamsFromAst } from '../query-plan-meta'; import { returningProjection } from '../query-plan-mutations'; import { queryPlanRows } from '../query-plan-rows'; import type { CollectionContext } from '../types'; import type { Edge, NodeId, StorageRow } from './edge'; import type { Graph } from './graph'; +import { withMutationScope } from './mutation-scope'; import type { Executed, Node, OutputsOf, Run, Slots, StatementAst } from './node'; import { StatementNode } from './statement-node'; diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/statement-node.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/statement-node.ts index 5394fe54cafe..008b5fb0f691 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/statement-node.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/statement-node.ts @@ -1,9 +1,10 @@ import type { ProjectionItem } from '@internal/sql-relational-core/ast'; import { Node, type Slots, type StatementAst } from './node'; +import type { RequiresSlot } from './requires'; import type { StateSlot } from './state'; export abstract class StatementNode< - Inputs extends Slots & StateSlot = Slots & StateSlot, + Inputs extends Slots & StateSlot & RequiresSlot = Slots & StateSlot & RequiresSlot, > extends Node { abstract readonly returns: readonly ProjectionItem[]; diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts index 2b8234172712..c3b6a2e9f7e9 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/update.ts @@ -2,13 +2,14 @@ import type { ProjectionItem, UpdateAst } from '@internal/sql-relational-core/as import { executeFiltered, type Filtered, withColumns } from './filtered-statement'; import type { Executed, OutputsOf, Run, StatementAst } from './node'; import type { PayloadData } from './payload-data'; +import { requirementUnmet } from './requires'; import { rowsResult } from './run-statement'; import { StatementNode } from './statement-node'; type Inputs = Filtered & { payload: PayloadData[] }; export class Update extends StatementNode { - readonly slots = { filter: 'list', payload: 'list', state: 'list' } as const; + readonly slots = { filter: 'list', payload: 'list', state: 'list', requires: 'list' } as const; readonly ast: UpdateAst; constructor(ast: UpdateAst) { @@ -27,13 +28,16 @@ export class Update extends StatementNode { override execute(inputs: OutputsOf, run: Run): Executed { if (inputs.payload === null) { - return executeFiltered(this.ast, inputs.filter, run); + return executeFiltered(this.ast, inputs, run); } - if (inputs.payload.some((records) => records.length === 0)) { + if ( + requirementUnmet(inputs.requires) || + inputs.payload.some((records) => records.length === 0) + ) { return rowsResult([]); } const payload = Object.assign({}, ...inputs.payload.map(([record]) => record)); - return executeFiltered(this.ast.withSet({ ...this.ast.set, ...payload }), inputs.filter, run); + return executeFiltered(this.ast.withSet({ ...this.ast.set, ...payload }), inputs, run); } override withReturning(columns: readonly ProjectionItem[]): Update { diff --git a/packages/3-extensions/sql-orm-client/src/orm-errors.ts b/packages/3-extensions/sql-orm-client/src/orm-errors.ts index aef9067f8ecb..63efb89ad8c2 100644 --- a/packages/3-extensions/sql-orm-client/src/orm-errors.ts +++ b/packages/3-extensions/sql-orm-client/src/orm-errors.ts @@ -15,7 +15,6 @@ type OrmSubcode = | 'RELATION_MUTATION_UNSUPPORTED' | 'RELATION_MUTATION_INVALID' | 'RELATION_ROW_MISSING' - | 'RELATION_LINK_DUPLICATE' | 'INCLUDE_INVALID' | 'INCLUDE_UNSUPPORTED' | 'INTERCEPTED_RESULT_INVALID' diff --git a/packages/3-extensions/sql-orm-client/src/relation-mutations.ts b/packages/3-extensions/sql-orm-client/src/relation-mutations.ts index 45eff7c4ecd1..72cf30a070bb 100644 --- a/packages/3-extensions/sql-orm-client/src/relation-mutations.ts +++ b/packages/3-extensions/sql-orm-client/src/relation-mutations.ts @@ -84,6 +84,9 @@ export function parseMutationInput( scalarData[fieldName] = value; continue; } + if (value === undefined) { + continue; + } if (!isRelationMutationCallback(value)) { throw ormError( @@ -280,27 +283,26 @@ export function toFieldName( ); } +export function hasRelationFields( + contract: Contract, + namespaceId: string, + modelName: string, + data: Readonly>, +): boolean { + const relationNames = Object.keys(resolveModelRelations(contract, namespaceId, modelName)); + return relationNames.some((name) => data[name] !== undefined); +} + export function hasNestedMutationCallbacks( contract: Contract, namespaceId: string, modelName: string, data: Record, ): boolean { - // Only the base model's relation names are needed to detect nested-mutation - // callbacks; resolving relation targets here would eagerly resolve - // cross-namespace targets (and throw on a non-existent target namespace), - // so enumerate names directly without target resolution. const relationNames = new Set( Object.keys(resolveModelRelations(contract, namespaceId, modelName)), ); - for (const [fieldName, value] of Object.entries(data)) { - if (!relationNames.has(fieldName)) { - continue; - } - if (isRelationMutationCallback(value)) { - return true; - } - } - - return false; + return Object.entries(data).some( + ([fieldName, value]) => relationNames.has(fieldName) && isRelationMutationCallback(value), + ); } diff --git a/packages/3-extensions/sql-orm-client/src/types.ts b/packages/3-extensions/sql-orm-client/src/types.ts index c1f253689298..915f71308d87 100644 --- a/packages/3-extensions/sql-orm-client/src/types.ts +++ b/packages/3-extensions/sql-orm-client/src/types.ts @@ -1983,7 +1983,7 @@ type JunctionPayloadFieldNames< * column — a non-join column that is not nullable and has no default. Such a * relation can't be populated through nested `create`, so its create input is * disabled at the type level (mirroring the runtime guard in - * `mutation-executor.ts`). + * `relation-mutations.ts`). */ type HasRequiredJunctionPayload< TContract extends Contract, diff --git a/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts deleted file mode 100644 index 534a092454bb..000000000000 --- a/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts +++ /dev/null @@ -1,1496 +0,0 @@ -import { textColumn } from '@internal/adapter-postgres/column-types'; -import { SqlQueryError, UNIQUE_VIOLATION_SQLSTATE } from '@internal/sql-errors'; -import { - type AnyExpression, - BinaryExpr, - ColumnRef, - LiteralExpr, - ParamRef, -} from '@internal/sql-relational-core/ast'; -import { describe, expect, it, vi } from 'vitest'; -import { createCollectionTables } from '../src/collection-tables'; -import { - buildRowIdentityFilterFromRow, - executeNestedCreateMutation, - executeNestedUpdateMutation, -} from '../src/mutation-executor'; -import { - assertJunctionParentMetadataLength, - assertJunctionTargetMetadataLength, - hasNestedMutationCallbacks, - type JunctionRelationDefinition, -} from '../src/relation-mutations'; -import { defineContract, field, model } from './contract-builder'; -import type { MockRuntime } from './helpers'; -import { - buildCompositeForeignKeyContract, - buildCustomPrimaryKeyContract, - buildExecutionDefaultJunctionContract, - buildManyToManyContract, - buildManyToManyContractWithTargetRelation, - buildTestContextFromContract, - createMockRuntime, - getTestContext, - getTestContract, - withPatchedDomainModels, -} from './helpers'; - -function withTransaction(runtime: MockRuntime) { - const commit = vi.fn(async () => undefined); - const rollback = vi.fn(async () => undefined); - const transaction = { - query: runtime.query.bind(runtime), - execute: runtime.execute.bind(runtime), - commit, - rollback, - }; - - const runtimeWithTransaction = Object.assign(runtime, { - async transaction() { - return transaction; - }, - }); - - return { - runtime: runtimeWithTransaction, - commit, - rollback, - }; -} - -function withConnection(runtime: MockRuntime, onRelease: () => void) { - return Object.assign(runtime, { - async connection() { - return { - query: runtime.query.bind(runtime), - execute: runtime.execute.bind(runtime), - async release() { - onRelease(); - }, - }; - }, - }); -} - -const postIdFilter: AnyExpression = BinaryExpr.eq(ColumnRef.of('posts', 'id'), LiteralExpr.of(1)); - -const userIdFilter: AnyExpression = BinaryExpr.eq(ColumnRef.of('users', 'id'), LiteralExpr.of(1)); - -describe('mutation-executor', () => { - it('hasNestedMutationCallbacks() detects callbacks only on relation fields', () => { - const contract = getTestContract(); - - expect( - hasNestedMutationCallbacks(contract, 'public', 'User', { - posts: (posts: { connect: (criterion: Record) => unknown }) => - posts.connect({ id: 1 }), - }), - ).toBe(true); - - expect( - hasNestedMutationCallbacks(contract, 'public', 'User', { - posts: { kind: 'connect', criteria: [{ id: 1 }] }, - }), - ).toBe(false); - - expect( - hasNestedMutationCallbacks(contract, 'public', 'User', { - name: () => ({ kind: 'connect' }), - }), - ).toBe(false); - }); - - it('hasNestedMutationCallbacks() tolerates malformed relation metadata and unknown models', () => { - const contract = getTestContract(); - const malformed = withPatchedDomainModels(contract, (models) => { - const user = models['User'] as { - relations: Record; - }; - return { - ...models, - User: { - ...user, - relations: { - ...user.relations, - notObject: 1, - missingTo: { - cardinality: '1:N', - on: { - parentCols: ['id'], - childCols: ['user_id'], - }, - }, - badCols: { - to: { model: 'Post', namespace: '__unbound__' }, - cardinality: '1:N', - on: { - parentCols: 'id', - childCols: ['user_id'], - }, - }, - posts: { - to: { model: 'Post', namespace: '__unbound__' }, - cardinality: 'INVALID', - on: { - localFields: ['id'], - targetFields: ['userId'], - }, - }, - }, - }, - }; - }); - - expect( - hasNestedMutationCallbacks(malformed, 'public', 'User', { - posts: (posts: { connect: (criterion: Record) => unknown }) => - posts.connect({ id: 1 }), - }), - ).toBe(true); - - expect( - hasNestedMutationCallbacks(contract, 'public', 'UnknownModel', { - anything: () => ({ kind: 'connect' }), - }), - ).toBe(false); - }); - - it('buildRowIdentityFilterFromRow() resolves mapped keys and throws when missing', () => { - const contract = getTestContract(); - - expect(buildRowIdentityFilterFromRow(contract, 'public', 'User', { id: 7 })).toEqual({ id: 7 }); - - expect(() => buildRowIdentityFilterFromRow(contract, 'public', 'User', {})).toThrow( - /Missing identity field "id"/, - ); - }); - - it('buildRowIdentityFilterFromRow() resolves custom primary key columns', () => { - const withCustomPk = buildCustomPrimaryKeyContract(); - - expect(buildRowIdentityFilterFromRow(withCustomPk, 'public', 'User', { pk_id: 99 })).toEqual({ - pk_id: 99, - }); - }); - - it('buildRowIdentityFilterFromRow() uses every column of a composite primary key', () => { - const contract = buildCompositeForeignKeyContract(); - - expect( - buildRowIdentityFilterFromRow(contract, 'public', 'Customer', { - tenantId: 1, - id: 2, - name: 'Grace', - }), - ).toEqual({ tenantId: 1, id: 2 }); - }); - - it('buildRowIdentityFilterFromRow() throws ORM.ROW_IDENTITY_MISSING for a table without a key', () => { - const contract = defineContract({ - models: { Log: model('Log', { fields: { message: field.column(textColumn) } }) }, - }); - - expect(() => - buildRowIdentityFilterFromRow(contract, 'public', 'Log', { message: 'hello' }), - ).toThrow(expect.objectContaining({ code: 'ORM.ROW_IDENTITY_MISSING' })); - }); - - it('executeNestedCreateMutation() commits transactions on success', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]); - const transactional = withTransaction(runtime); - - const created = await executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime: transactional.runtime, - namespaceId: 'public', - modelName: 'User', - data: { id: 1, name: 'Alice', email: 'alice@example.com' } as never, - }); - - expect(created).toEqual({ id: 1, name: 'Alice', email: 'alice@example.com' }); - expect(transactional.commit).toHaveBeenCalledTimes(1); - expect(transactional.rollback).not.toHaveBeenCalled(); - }); - - it('executeNestedCreateMutation() supports transaction scopes without commit/rollback hooks', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]); - - const runtimeWithBareTransaction = Object.assign(runtime, { - async transaction() { - return { - query: runtime.query.bind(runtime), - execute: runtime.execute.bind(runtime), - }; - }, - }); - - const created = await executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime: runtimeWithBareTransaction, - namespaceId: 'public', - modelName: 'User', - data: { id: 1, name: 'Alice', email: 'alice@example.com' } as never, - }); - - expect(created).toEqual({ id: 1, name: 'Alice', email: 'alice@example.com' }); - }); - - it('executeNestedCreateMutation() rolls back transactions on failures', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([[]]); - const transactional = withTransaction(runtime); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime: transactional.runtime, - namespaceId: 'public', - modelName: 'User', - data: { id: 1, name: 'Alice', email: 'alice@example.com' } as never, - }), - ).rejects.toThrow(/did not return a row/); - - expect(transactional.commit).not.toHaveBeenCalled(); - expect(transactional.rollback).toHaveBeenCalledTimes(1); - }); - - it('executeNestedCreateMutation() releases scoped connections when no transaction is available', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]); - - let released = false; - const scopedRuntime = withConnection(runtime, () => { - released = true; - }); - - await executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime: scopedRuntime, - namespaceId: 'public', - modelName: 'User', - data: { id: 1, name: 'Alice', email: 'alice@example.com' } as never, - }); - - expect(released).toBe(true); - }); - - it('executeNestedCreateMutation() validates relation mutator input shapes', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - data: { - id: 1, - name: 'Alice', - email: 'alice@example.com', - posts: { kind: 'connect' }, - } as never, - }), - ).rejects.toThrow(/expects a mutator callback/); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - data: { - id: 1, - name: 'Alice', - email: 'alice@example.com', - posts: () => ({ invalid: true }), - } as never, - }), - ).rejects.toThrow(/invalid mutation descriptor/); - }); - - it('executeNestedCreateMutation() rejects unsupported disconnect() in create graphs', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Post', - data: { - id: 1, - title: 'Post', - views: 1, - author: (author: { disconnect: () => unknown }) => author.disconnect(), - } as never, - }), - ).rejects.toThrow(/disconnect\(\) is only supported in update\(\) nested mutations/); - - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]); - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - data: { - id: 1, - name: 'Alice', - email: 'alice@example.com', - posts: (posts: { disconnect: () => unknown }) => posts.disconnect(), - } as never, - }), - ).rejects.toThrow(/disconnect\(\) is only supported in update\(\) nested mutations/); - }); - - it('executeNestedCreateMutation() validates connect/create payloads for parent-owned relations', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Post', - data: { - id: 1, - title: 'Post', - views: 1, - author: (author: { - connect: (criteria: readonly Record[]) => unknown; - }) => author.connect([]), - } as never, - }), - ).rejects.toThrow(/requires criterion/); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Post', - data: { - id: 1, - title: 'Post', - views: 1, - author: (author: { connect: (criterion: Record) => unknown }) => - author.connect({}), - } as never, - }), - ).rejects.toThrow(/requires non-empty criterion/); - - runtime.setNextResults([[]]); - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Post', - data: { - id: 1, - title: 'Post', - views: 1, - author: (author: { connect: (criterion: Record) => unknown }) => - author.connect({ id: 5 }), - } as never, - }), - ).rejects.toThrow(/did not find a matching row/); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Post', - data: { - id: 1, - title: 'Post', - views: 1, - author: (author: { create: (data: readonly Record[]) => unknown }) => - author.create([]), - } as never, - }), - ).rejects.toThrow(/requires data/); - }); - - function findJunctionDml( - runtime: MockRuntime, - kind: 'insert' | 'delete', - table: string, - ): { kind: string; table: { name: string }; rows?: unknown; where?: unknown } { - for (const execution of runtime.executions) { - const ast = (execution.plan as { ast?: { kind: string; table?: { name: string } } }).ast; - if (ast && ast.kind === kind && ast.table?.name === table) { - return ast as { kind: string; table: { name: string }; rows?: unknown; where?: unknown }; - } - } - throw new Error(`no ${kind} on "${table}" found in executions`); - } - - function collectParams(node: unknown): ParamRef[] { - if (!node || typeof node !== 'object') { - return []; - } - if (node instanceof ParamRef) { - return [node]; - } - const expr = node as { - left?: unknown; - right?: unknown; - exprs?: readonly unknown[]; - }; - return [ - ...collectParams(expr.left), - ...collectParams(expr.right), - ...(expr.exprs ?? []).flatMap(collectParams), - ]; - } - - function collectParamValues(node: unknown): unknown[] { - return collectParams(node).map((param) => param.value); - } - - it('executeNestedCreateMutation() routes M:N connect through a junction INSERT', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['parent_id'], - childColumns: ['child_id'], - targetColumns: ['id'], - }); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 10 }], [{ id: 1 }], [{ id: 10 }], []]); - - const created = await executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - data: { - id: 1, - children: (children: { connect: (criterion: Record) => unknown }) => - children.connect({ id: 10 }), - } as never, - }); - - expect(created).toEqual({ id: 1 }); - const insert = findJunctionDml(runtime, 'insert', 'parent_child'); - const junctionRow = (insert.rows as ReadonlyArray>)[0]!; - expect(Object.keys(junctionRow).sort()).toEqual(['child_id', 'parent_id']); - expect((runtime.executions.at(-1)!.plan as { params: readonly unknown[] }).params).toEqual([ - 1, 10, - ]); - }); - - it('executeNestedCreateMutation() routes M:N create through target INSERT then junction INSERT', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['parent_id'], - childColumns: ['child_id'], - targetColumns: ['id'], - }); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1 }], [{ id: 20 }], []]); - - const created = await executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - data: { - id: 1, - children: (children: { create: (rows: readonly Record[]) => unknown }) => - children.create([{ id: 20 }]), - } as never, - }); - - expect(created).toEqual({ id: 1 }); - const targetInsert = findJunctionDml(runtime, 'insert', 'children'); - expect(targetInsert.kind).toBe('insert'); - const link = ( - findJunctionDml(runtime, 'insert', 'parent_child').rows as ReadonlyArray< - Record - > - )[0]!; - expect(Object.keys(link).sort()).toEqual(['child_id', 'parent_id']); - expect((runtime.executions.at(-1)!.plan as { params: readonly unknown[] }).params).toEqual([ - 1, 20, - ]); - }); - - it('executeNestedCreateMutation() recurses junction-created targets through the nested-create graph', async () => { - const contract = buildManyToManyContractWithTargetRelation(); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1 }], [{ id: 99 }], [{ id: 20, owner_id: 99 }], []]); - - const created = await executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - data: { - id: 1, - children: (children: { create: (rows: readonly Record[]) => unknown }) => - children.create([ - { - id: 20, - owner: (owner: { connect: (criterion: Record) => unknown }) => - owner.connect({ id: 99 }), - }, - ]), - } as never, - }); - - expect(created).toEqual({ id: 1 }); - const unwrapRow = (row: Record) => - Object.fromEntries( - Object.entries(row).map(([column, param]) => [column, (param as { value: unknown }).value]), - ); - const childInsert = findJunctionDml(runtime, 'insert', 'children'); - expect(unwrapRow((childInsert.rows as ReadonlyArray>)[0]!)).toEqual({ - id: 20, - owner_id: 99, - }); - const link = ( - findJunctionDml(runtime, 'insert', 'parent_child').rows as ReadonlyArray< - Record - > - )[0]!; - expect(unwrapRow(link)).toEqual({ parent_id: 1, child_id: 20 }); - }); - - it('executeNestedCreateMutation() AND-s composite keys in the junction INSERT', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['tenant_id', 'parent_id'], - childColumns: ['tenant_id', 'child_id'], - targetColumns: ['tenant_id', 'id'], - localFields: ['tenant_id', 'id'], - }); - const runtime = createMockRuntime(); - runtime.setNextResults([ - [{ tenant_id: 7, id: 10 }], - [{ tenant_id: 7, id: 1 }], - [{ tenant_id: 7, id: 10 }], - [], - ]); - - await executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - data: { - tenant_id: 7, - id: 1, - children: (children: { connect: (criterion: Record) => unknown }) => - children.connect({ id: 10 }), - } as never, - }); - - const link = ( - findJunctionDml(runtime, 'insert', 'parent_child').rows as ReadonlyArray< - Record - > - )[0]!; - expect(Object.keys(link).sort()).toEqual(['child_id', 'parent_id', 'tenant_id']); - }); - - // Mismatched junction column counts can't be authored — the contract-builder - // rejects an M:N relation whose junction FK pairing is uneven before a - // contract ever exists. These two cases exercise the defensive length guards - // directly with a typed JunctionRelationDefinition (a guard input, not a - // contract). - function junctionRelationDefinition( - through: Pick & { - readonly targetColumns: readonly string[]; - }, - columns: { - readonly localColumns: readonly string[]; - readonly targetColumns: readonly string[]; - }, - ): JunctionRelationDefinition { - return { - relationName: 'children', - relatedModelName: 'Child', - relatedNamespaceId: 'public', - relatedTableName: 'children', - cardinality: 'N:M', - localColumns: columns.localColumns, - targetColumns: columns.targetColumns, - through: { - table: 'parent_child', - namespaceId: 'public', - parentColumns: through.parentColumns, - childColumns: through.childColumns, - targetColumns: through.targetColumns, - requiredPayloadColumns: [], - }, - }; - } - - it('assertJunctionParentMetadataLength() rejects mismatched junction parent-column metadata', () => { - const relation = junctionRelationDefinition( - { - parentColumns: ['parent_id', 'tenant_id'], - childColumns: ['child_id'], - targetColumns: ['id'], - }, - { localColumns: ['id'], targetColumns: ['id'] }, - ); - - expect(() => assertJunctionParentMetadataLength(relation)).toThrow( - /parentColumns.*localColumns/, - ); - }); - - it('assertJunctionTargetMetadataLength() rejects mismatched junction target-column metadata', () => { - const relation = junctionRelationDefinition( - { - parentColumns: ['parent_id'], - childColumns: ['child_id', 'tenant_id'], - targetColumns: ['id'], - }, - { localColumns: ['id'], targetColumns: ['id'] }, - ); - - expect(() => assertJunctionTargetMetadataLength(relation)).toThrow( - /childColumns.*targetColumns/, - ); - }); - - it('executeNestedCreateMutation() rejects duplicate resolved connect targets before any write', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['parent_id'], - childColumns: ['child_id'], - targetColumns: ['id'], - }); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 10 }], [{ id: 10 }]]); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - data: { - id: 1, - children: (children: { - connect: (criteria: readonly Record[]) => unknown; - }) => children.connect([{ id: 10 }, { id: 10 }]), - } as never, - }), - ).rejects.toThrow( - /connect\(\) nested mutation for relation "children" resolved duplicate junction link targets/, - ); - - const inserts = runtime.executions.filter( - (execution) => (execution.plan as { ast?: { kind?: string } }).ast?.kind === 'insert', - ); - expect(inserts).toEqual([]); - }); - - it('executeNestedCreateMutation() rejects conflicting values for shared junction columns', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['tenant_id'], - childColumns: ['tenant_id'], - targetColumns: ['tenant_id'], - localFields: ['tenant_id'], - }); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ tenant_id: 8 }], [{ tenant_id: 7 }], [{ tenant_id: 8 }]]); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - data: { - tenant_id: 7, - children: (children: { connect: (criterion: Record) => unknown }) => - children.connect({ tenant_id: 8 }), - } as never, - }), - ).rejects.toThrow(/conflicting values for junction column "tenant_id"/); - }); - - it('executeNestedUpdateMutation() routes M:N connect through a junction INSERT', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['parent_id'], - childColumns: ['child_id'], - targetColumns: ['id'], - }); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1 }], [{ id: 10 }], [{ id: 10 }], []]); - - await executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - tables: createCollectionTables(contract, 'public', 'Parent'), - filters: [BinaryExpr.eq(ColumnRef.of('parents', 'id'), LiteralExpr.of(1))], - data: { - children: (children: { connect: (criterion: Record) => unknown }) => - children.connect({ id: 10 }), - } as never, - }); - - const insert = findJunctionDml(runtime, 'insert', 'parent_child'); - expect(insert.kind).toBe('insert'); - expect((runtime.executions.at(-1)!.plan as { params: readonly unknown[] }).params).toEqual([ - 1, 10, - ]); - }); - - it('executeNestedUpdateMutation() wraps duplicate M:N connect errors', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['parent_id'], - childColumns: ['child_id'], - targetColumns: ['id'], - }); - const runtime = createMockRuntime(); - const execute = runtime.execute.bind(runtime); - vi.spyOn(runtime, 'execute').mockImplementation((plan) => { - const ast = (plan as { ast?: { kind: string; table?: { name: string } } }).ast; - if (ast?.kind === 'insert' && ast.table?.name === 'parent_child') { - throw new SqlQueryError( - 'duplicate key value violates unique constraint "parent_child_pkey"', - { - sqlState: UNIQUE_VIOLATION_SQLSTATE, - }, - ); - } - return execute(plan); - }); - runtime.setNextResults([[{ id: 1 }], [{ id: 10 }], [{ id: 10 }]]); - - await expect( - executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - tables: createCollectionTables(contract, 'public', 'Parent'), - filters: [BinaryExpr.eq(ColumnRef.of('parents', 'id'), LiteralExpr.of(1))], - data: { - children: (children: { connect: (criterion: Record) => unknown }) => - children.connect({ id: 10 }), - } as never, - }), - ).rejects.toThrow( - /relation "children" violated a unique constraint on junction "parent_child"/, - ); - }); - - it('executeNestedUpdateMutation() passes a NOT NULL junction constraint failure through unwrapped', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['parent_id'], - childColumns: ['child_id'], - targetColumns: ['id'], - }); - const runtime = createMockRuntime(); - const execute = runtime.execute.bind(runtime); - vi.spyOn(runtime, 'execute').mockImplementation((plan) => { - const ast = (plan as { ast?: { kind: string; table?: { name: string } } }).ast; - if (ast?.kind === 'insert' && ast.table?.name === 'parent_child') { - // Drivers normalize a NOT NULL violation to sqlState 23502, not the - // unique-violation 23505, so the connect wrap must leave it alone. - throw new SqlQueryError('NOT NULL constraint failed: parent_child.level', { - sqlState: '23502', - }); - } - return execute(plan); - }); - runtime.setNextResults([[{ id: 1 }], [{ id: 10 }], [{ id: 10 }]]); - - await expect( - executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - tables: createCollectionTables(contract, 'public', 'Parent'), - filters: [BinaryExpr.eq(ColumnRef.of('parents', 'id'), LiteralExpr.of(1))], - data: { - children: (children: { connect: (criterion: Record) => unknown }) => - children.connect({ id: 10 }), - } as never, - }), - ).rejects.toThrow(/NOT NULL constraint failed: parent_child\.level/); - }); - - it('executeNestedUpdateMutation() wraps a normalized unique violation regardless of message', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['parent_id'], - childColumns: ['child_id'], - targetColumns: ['id'], - }); - const runtime = createMockRuntime(); - const execute = runtime.execute.bind(runtime); - vi.spyOn(runtime, 'execute').mockImplementation((plan) => { - const ast = (plan as { ast?: { kind: string; table?: { name: string } } }).ast; - if (ast?.kind === 'insert' && ast.table?.name === 'parent_child') { - // Opaque message: recognition rides on the normalized sqlState alone, - // so a primary-key violation (which drivers map to 23505) still wraps. - throw new SqlQueryError('constraint violation', { sqlState: UNIQUE_VIOLATION_SQLSTATE }); - } - return execute(plan); - }); - runtime.setNextResults([[{ id: 1 }], [{ id: 10 }], [{ id: 10 }]]); - - await expect( - executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - tables: createCollectionTables(contract, 'public', 'Parent'), - filters: [BinaryExpr.eq(ColumnRef.of('parents', 'id'), LiteralExpr.of(1))], - data: { - children: (children: { connect: (criterion: Record) => unknown }) => - children.connect({ id: 10 }), - } as never, - }), - ).rejects.toThrow( - /relation "children" violated a unique constraint on junction "parent_child"/, - ); - }); - - it('executeNestedUpdateMutation() routes M:N disconnect through a junction DELETE', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['parent_id'], - childColumns: ['child_id'], - targetColumns: ['id'], - }); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1 }], [{ id: 10 }], []]); - - await executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - tables: createCollectionTables(contract, 'public', 'Parent'), - filters: [BinaryExpr.eq(ColumnRef.of('parents', 'id'), LiteralExpr.of(1))], - data: { - children: (children: { - disconnect: (criteria: readonly Record[]) => unknown; - }) => children.disconnect([{ id: 10 }]), - } as never, - }); - - const del = findJunctionDml(runtime, 'delete', 'parent_child'); - expect(del.kind).toBe('delete'); - expect(collectParamValues(del.where).sort()).toEqual([1, 10]); - expect(collectParams(del.where).map((param) => param.codec)).toEqual([ - { codecId: 'pg/int4@1' }, - { codecId: 'pg/int4@1' }, - ]); - }); - - it('executeNestedUpdateMutation() rejects conflicting values for shared junction columns on disconnect', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['tenant_id'], - childColumns: ['tenant_id'], - targetColumns: ['tenant_id'], - localFields: ['tenant_id'], - }); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ tenant_id: 7 }], [{ tenant_id: 8 }]]); - - await expect( - executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - tables: createCollectionTables(contract, 'public', 'Parent'), - filters: [BinaryExpr.eq(ColumnRef.of('parents', 'tenant_id'), LiteralExpr.of(7))], - data: { - children: (children: { - disconnect: (criteria: readonly Record[]) => unknown; - }) => children.disconnect([{ tenant_id: 8 }]), - } as never, - }), - ).rejects.toThrow(/conflicting values for junction column "tenant_id"/); - - const deletes = runtime.executions.filter( - (execution) => (execution.plan as { ast?: { kind?: string } }).ast?.kind === 'delete', - ); - expect(deletes).toEqual([]); - }); - - it('executeNestedUpdateMutation() emits a single predicate for shared junction columns with equal values', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['tenant_id'], - childColumns: ['tenant_id'], - targetColumns: ['tenant_id'], - localFields: ['tenant_id'], - }); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ tenant_id: 7 }], [{ tenant_id: 7 }], []]); - - await executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - tables: createCollectionTables(contract, 'public', 'Parent'), - filters: [BinaryExpr.eq(ColumnRef.of('parents', 'tenant_id'), LiteralExpr.of(7))], - data: { - children: (children: { - disconnect: (criteria: readonly Record[]) => unknown; - }) => children.disconnect([{ tenant_id: 7 }]), - } as never, - }); - - const del = findJunctionDml(runtime, 'delete', 'parent_child'); - expect(collectParamValues(del.where)).toEqual([7]); - }); - - it('executeNestedCreateMutation() rejects M:N disconnect (update-only)', async () => { - const contract = buildManyToManyContract({ - junctionTable: 'parent_child', - parentColumns: ['parent_id'], - childColumns: ['child_id'], - targetColumns: ['id'], - }); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1 }]]); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Parent', - data: { - id: 1, - children: (children: { - disconnect: (criteria: readonly Record[]) => unknown; - }) => children.disconnect([{ id: 10 }]), - } as never, - }), - ).rejects.toThrow(/disconnect\(\) is only supported in update\(\) nested mutations/); - }); - - it('executeNestedCreateMutation() rejects M:N create when junction has required payload columns', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - data: { - id: 1, - name: 'Alice', - email: 'alice@example.com', - roles: (roles: { create: (rows: readonly Record[]) => unknown }) => - roles.create([{ id: 'admin' }]), - } as never, - }), - ).rejects.toThrow( - /Cannot `create` on relation `roles`: its junction `user_roles` has required column\(s\) `level`.*Write the `user_roles` junction directly or use the SQL builder\./, - ); - }); - - it('executeNestedCreateMutation() rejects M:N connect when junction has required payload columns', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - data: { - id: 1, - name: 'Alice', - email: 'alice@example.com', - roles: (roles: { connect: (criterion: Record) => unknown }) => - roles.connect({ id: 'admin' }), - } as never, - }), - ).rejects.toThrow( - /Cannot `connect` on relation `roles`: its junction `user_roles` has required column\(s\) `level`.*Write the `user_roles` junction directly or use the SQL builder\./, - ); - }); - - it('executeNestedCreateMutation() M:N connect applies the execution default to the junction row', async () => { - // `level` is a NOT NULL junction payload column whose only default is an - // execution-time onCreate generator (no storage default), authored through - // the DSL via `.onCreate(gen.testLevel())`. The connect path must populate it before - // the INSERT, mirroring insertJunctionLink. - const contract = buildExecutionDefaultJunctionContract(); - // The defaults applier closes over the contract the context was created - // from, so the context must be built from this contract — spreading - // `{ ...getTestContext(), contract }` would never see the new default. - const context = buildTestContextFromContract(contract, { - mutationDefaultGenerators: [{ id: 'test-level', generate: () => 5, stability: 'field' }], - }); - const runtime = createMockRuntime(); - runtime.setNextResults([ - [{ id: 'admin' }], - [{ id: 1, name: 'Alice', email: 'alice@example.com' }], - [{ id: 'admin' }], - [], - ]); - - await executeNestedCreateMutation({ - context, - runtime, - namespaceId: 'public', - modelName: 'User', - data: { - id: 1, - name: 'Alice', - email: 'alice@example.com', - roles: (roles: { connect: (criterion: Record) => unknown }) => - roles.connect({ id: 'admin' }), - } as never, - }); - - const insert = findJunctionDml(runtime, 'insert', 'user_roles'); - const junctionRow = (insert.rows as ReadonlyArray>)[0]!; - expect(Object.keys(junctionRow).sort()).toEqual(['level', 'role_id', 'user_id']); - expect((junctionRow['level'] as { value: unknown }).value).toBe(5); - }); - - it('executeNestedUpdateMutation() preflights junction guards before the scalar update', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]); - - await expect( - executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - tables: createCollectionTables(contract, 'public', 'User'), - filters: [userIdFilter], - data: { - name: 'Alice Updated', - roles: (roles: { connect: (criterion: Record) => unknown }) => - roles.connect({ id: 'admin' }), - } as never, - }), - ).rejects.toThrow( - /Cannot `connect` on relation `roles`: its junction `user_roles` has required column\(s\) `level`/, - ); - - const updates = runtime.executions.filter( - (execution) => (execution.plan as { ast?: { kind?: string } }).ast?.kind === 'update', - ); - expect(updates).toEqual([]); - }); - - it('executeNestedUpdateMutation() allows disconnect on junction with required payload columns', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([ - [{ id: 1, name: 'Alice', email: 'alice@example.com' }], - [{ id: 'admin' }], - [], - ]); - - await executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - tables: createCollectionTables(contract, 'public', 'User'), - filters: [userIdFilter], - data: { - roles: (roles: { disconnect: (criteria: readonly Record[]) => unknown }) => - roles.disconnect([{ id: 'admin' }]), - } as never, - }); - - const del = findJunctionDml(runtime, 'delete', 'user_roles'); - expect(del.kind).toBe('delete'); - expect(collectParams(del.where)).toEqual([ - ParamRef.of(1, { codec: { codecId: 'pg/int4@1' } }), - ParamRef.of('admin', { codec: { codecId: 'sql/char@1', typeParams: { length: 36 } } }), - ]); - }); - - it('executeNestedCreateMutation() allows M:N create on pure junction (no required payload)', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([ - [{ id: 1, name: 'Alice', email: 'alice@example.com' }], - [{ id: 'ts' }], - [], - ]); - - const created = await executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - data: { - id: 1, - name: 'Alice', - email: 'alice@example.com', - tags: (tags: { create: (rows: readonly Record[]) => unknown }) => - tags.create([{ id: 'ts' }]), - } as never, - }); - - expect(created).toEqual({ id: 1, name: 'Alice', email: 'alice@example.com' }); - const insert = findJunctionDml(runtime, 'insert', 'user_tags'); - expect(insert.kind).toBe('insert'); - }); - - it('executeNestedCreateMutation() supports parent-owned nested create() payloads', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([ - [{ id: 5, name: 'Author', email: 'author@example.com' }], - [{ id: 1, title: 'Post', user_id: 5, views: 1 }], - ]); - - const created = await executeNestedCreateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Post', - data: { - id: 1, - title: 'Post', - views: 1, - author: (author: { create: (rows: readonly Record[]) => unknown }) => - author.create([ - { - id: 5, - name: 'Author', - email: 'author@example.com', - }, - ]), - } as never, - }); - - expect(created).toEqual({ id: 1, title: 'Post', userId: 5, views: 1 }); - }); - - it('executeNestedCreateMutation() refuses relation metadata with a missing join field', async () => { - const contract = getTestContract(); - const sparseAuthorRelation = withPatchedDomainModels(contract, (models) => { - const post = models['Post'] as { relations: { author: Record } }; - return { - ...models, - Post: { - ...post, - relations: { - ...post.relations, - author: { - ...post.relations.author, - on: { - localFields: [undefined, 'userId'] as unknown as readonly string[], - targetFields: ['id', 'id'], - }, - }, - }, - }, - }; - }); - const runtime = createMockRuntime(); - runtime.setNextResults([ - [{ id: 5, name: 'Author', email: 'author@example.com' }], - [{ id: 1, title: 'Post', user_id: 5, views: 1 }], - ]); - - await expect( - executeNestedCreateMutation({ - context: { ...getTestContext(), contract: sparseAuthorRelation }, - runtime, - namespaceId: 'public', - modelName: 'Post', - data: { - id: 1, - title: 'Post', - views: 1, - author: (author: { connect: (criterion: Record) => unknown }) => - author.connect({ id: 5 }), - } as never, - }), - ).rejects.toThrow('has no field "undefined" the contract names'); - }); - - it('executeNestedUpdateMutation() returns null when no row matches filters', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([[]]); - - const updated = await executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - tables: createCollectionTables(contract, 'public', 'User'), - filters: [userIdFilter], - data: { name: 'Alice Updated' } as never, - }); - - expect(updated).toBeNull(); - }); - - it('executeNestedUpdateMutation() applies parent-owned disconnect updates', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([ - [{ id: 1, title: 'Post', user_id: 5, views: 10 }], - [{ id: 1, title: 'Post', user_id: null, views: 10 }], - ]); - - const updated = await executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'Post', - tables: createCollectionTables(contract, 'public', 'Post'), - filters: [postIdFilter], - data: { - author: (author: { disconnect: () => unknown }) => author.disconnect(), - } as never, - }); - - expect(updated).toEqual({ id: 1, title: 'Post', userId: null, views: 10 }); - }); - - it('executeNestedUpdateMutation() keeps existing rows when update-returning returns no row', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }], []]); - - const updated = await executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - tables: createCollectionTables(contract, 'public', 'User'), - filters: [userIdFilter], - data: { name: 'Updated' } as never, - }); - - expect(updated).toEqual({ id: 1, name: 'Alice', email: 'alice@example.com' }); - }); - - it('executeNestedUpdateMutation() sends child-owned connect and disconnect criteria as parameters with the column codec', async () => { - const contract = getTestContract(); - const updatePlans = async ( - data: Record, - ): Promise> => { - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }], []]); - await executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - tables: createCollectionTables(contract, 'public', 'User'), - filters: [userIdFilter], - data: data as never, - }); - return runtime.executions - .map((execution) => execution.plan as { ast: { kind?: string; where?: unknown } }) - .filter((plan) => plan.ast.kind === 'update') as never; - }; - - const [connect] = await updatePlans({ - posts: (posts: { connect: (criterion: Record) => unknown }) => - posts.connect({ title: 'Hello' }), - }); - expect(connect?.ast.where).toEqual( - BinaryExpr.eq( - ColumnRef.of('posts', 'title'), - ParamRef.of('Hello', { codec: { codecId: 'pg/text@1' } }), - ), - ); - expect(connect?.params).toEqual([1, 'Hello']); - - const [disconnect] = await updatePlans({ - posts: (posts: { disconnect: (criteria: readonly Record[]) => unknown }) => - posts.disconnect([{ title: 'Hello' }]), - }); - expect(collectParams(disconnect?.ast.where)).toEqual([ - ParamRef.of(1, { codec: { codecId: 'pg/int4@1' } }), - ParamRef.of('Hello', { codec: { codecId: 'pg/text@1' } }), - ]); - expect(disconnect?.params).toEqual([null, 1, 'Hello']); - }); - - it('executeNestedUpdateMutation() validates child-owned connect and disconnect criteria', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]); - await expect( - executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - tables: createCollectionTables(contract, 'public', 'User'), - filters: [userIdFilter], - data: { - posts: (posts: { connect: (criteria: readonly Record[]) => unknown }) => - posts.connect([{}]), - } as never, - }), - ).rejects.toThrow(/requires non-empty criterion/); - - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }], []]); - const connected = await executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - tables: createCollectionTables(contract, 'public', 'User'), - filters: [userIdFilter], - data: { - posts: (posts: { connect: (criterion: Record) => unknown }) => - posts.connect({ id: 11 }), - } as never, - }); - - expect(connected).toEqual({ id: 1, name: 'Alice', email: 'alice@example.com' }); - - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }], []]); - const disconnected = await executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - tables: createCollectionTables(contract, 'public', 'User'), - filters: [userIdFilter], - data: { - posts: (posts: { disconnect: () => unknown }) => posts.disconnect(), - } as never, - }); - - expect(disconnected).toEqual({ id: 1, name: 'Alice', email: 'alice@example.com' }); - - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]); - await expect( - executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - tables: createCollectionTables(contract, 'public', 'User'), - filters: [userIdFilter], - data: { - posts: (posts: { - disconnect: (criteria: readonly Record[]) => unknown; - }) => posts.disconnect([{}]), - } as never, - }), - ).rejects.toThrow(/requires non-empty criterion/); - }); - - it('executeNestedUpdateMutation() supports composite child joins', async () => { - const contract = getTestContract(); - const compositeRelationContract = withPatchedDomainModels(contract, (models) => { - const user = models['User'] as { relations: { posts: Record } }; - return { - ...models, - User: { - ...user, - relations: { - ...user.relations, - posts: { - ...user.relations.posts, - on: { - localFields: ['id', 'email'], - targetFields: ['userId', 'title'], - }, - }, - }, - }, - }; - }); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }], []]); - - const updated = await executeNestedUpdateMutation({ - context: { ...getTestContext(), contract: compositeRelationContract }, - runtime, - namespaceId: 'public', - modelName: 'User', - tables: createCollectionTables(compositeRelationContract, 'public', 'User'), - filters: [userIdFilter], - data: { - posts: (posts: { disconnect: () => unknown }) => posts.disconnect(), - } as never, - }); - - expect(updated).toEqual({ id: 1, name: 'Alice', email: 'alice@example.com' }); - }); - - it('executeNestedUpdateMutation() validates parent row shape for child-owned mutations', async () => { - const contract = getTestContract(); - const runtime = createMockRuntime(); - runtime.setNextResults([[{ name: 'Alice', email: 'alice@example.com' }]]); - - await expect( - executeNestedUpdateMutation({ - context: { ...getTestContext(), contract }, - runtime, - namespaceId: 'public', - modelName: 'User', - tables: createCollectionTables(contract, 'public', 'User'), - filters: [userIdFilter], - data: { - posts: (posts: { connect: (criterion: Record) => unknown }) => - posts.connect({ id: 10 }), - } as never, - }), - ).rejects.toThrow(/requires parent field "id"/); - }); - - it('executeNestedCreateMutation() reuses scope directly when runtime lacks transaction and connection', async () => { - const runtime = createMockRuntime(); - runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@test.com' }]]); - - const querySpy = vi.spyOn(runtime, 'query'); - - const created = await executeNestedCreateMutation({ - context: getTestContext(), - runtime, - namespaceId: 'public', - modelName: 'User', - data: { id: 1, name: 'Alice', email: 'alice@test.com' } as never, - }); - - expect(created).toEqual({ id: 1, name: 'Alice', email: 'alice@test.com' }); - expect(querySpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/assert.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/assert.test.ts index 3c29d614b73e..2a05640ad09d 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/assert.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/assert.test.ts @@ -10,13 +10,29 @@ describe('Assert', () => { }); it('throws the error it was made with when its input has no row', () => { - expect(() => new Assert(missing).execute({ rows: [] }, recordingRun())).toThrow(missing); + expect(() => new Assert(missing).execute({ rows: [], requires: null }, recordingRun())).toThrow( + missing, + ); }); it('gives no row and runs no statement when its input has a row', async () => { const run = recordingRun(); - expect(await new Assert(missing).execute({ rows: [{ id: 7 }] }, run)).toEqual([]); + expect(await new Assert(missing).execute({ rows: [{ id: 7 }], requires: null }, run)).toEqual( + [], + ); expect(run).toMatchObject({ queried: [], executed: [] }); }); + + it('gives no row and does not throw when a node it requires produced no row', async () => { + const run = recordingRun(); + + expect(await new Assert(missing).execute({ rows: [], requires: [[]] }, run)).toEqual([]); + }); + + it('throws when every node it requires produced a row and its input has none', () => { + expect(() => + new Assert(missing).execute({ rows: [], requires: [[undefined]] }, recordingRun()), + ).toThrow(missing); + }); }); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/create-graph.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/create-graph.test.ts index fbbe7cb83436..e75437a7beeb 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/create-graph.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/create-graph.test.ts @@ -10,14 +10,17 @@ import { printGraph } from './print-graph'; type Mutator = RelationMutator, string>; type Callback = (mutator: Mutator) => unknown; -function targetOf(modelName: 'User' | 'Post', withPosts = false) { +function targetOf(modelName: 'User' | 'Post' | 'Project', withPosts = false) { const state = withPosts ? createCollectionFor('User').collection.include('posts').state : createCollectionFor(modelName).collection.state; return { context: getTestContext(), state, modelName, namespaceId: 'public' }; } -function printCreate(modelName: 'User' | 'Post', data: Record): string { +function printCreate( + modelName: 'User' | 'Post' | 'Project', + data: Record, +): string { return printGraph(createGraph(targetOf(modelName), data)); } @@ -148,6 +151,25 @@ describe('createGraph', () => { ); }); + it('copies every column of a composite key into the junction row', () => { + expect( + printCreate('Project', { + id: 1, + tenantId: 7, + name: 'Core', + related: (related: Mutator) => related.connect({ tenantId: 7, id: 2 }), + }), + ).toBe( + [ + 'n1 Insert projects', + 'n2 Find projects where tenant_id = 7 and id = 2 limit 1', + 'n3 Assert <- Rows n2', + 'n4 Insert project_links on conflict (src_tenant_id, src_id, dst_tenant_id, dst_id) do nothing <- PayloadData n1 (tenant_id->src_tenant_id, id->src_id), PayloadData n2 (tenant_id->dst_tenant_id, id->dst_id)', + 'result: n1', + ].join('\n'), + ); + }); + it('rejects a junction with a required column the relation cannot fill', () => { expect(() => createGraph(targetOf('User'), { @@ -241,6 +263,17 @@ describe('createGraph', () => { ); }); + it('rejects a callback that returns no relation operation', () => { + expect(() => + createGraph(targetOf('User'), { ...ada, posts: () => ({ invalid: true }) }), + ).toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_INVALID', + meta: { relation: 'posts', model: 'User', problem: 'invalid-descriptor' }, + }), + ); + }); + it('rejects a connect without a criterion on a relation the parent holds the key for', () => { expect(() => createGraph(targetOf('Post'), { diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts index 9669ffc1ba90..18a8a5182d63 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/filtered-statement.test.ts @@ -36,7 +36,10 @@ describe.each([ const node = make(); const run = recordingRun([{ id: 1 }]); - const rows = await node.execute({ filter: null, payload: null, state: null }, run); + const rows = await node.execute( + { filter: null, payload: null, state: null, requires: null }, + run, + ); expect(rows).toEqual([{ id: 1 }]); expect(run.queried).toEqual([node.ast]); @@ -45,7 +48,10 @@ describe.each([ it('adds the conditions of one edge to its where, or between the rows of the edge', async () => { const run = recordingRun(); - await make().execute({ filter: [[idIs(7), idIs(8)]], payload: null, state: null }, run); + await make().execute( + { filter: [[idIs(7), idIs(8)]], payload: null, state: null, requires: null }, + run, + ); expect(run.queried.map(whereOf)).toEqual(["(name = 'Ada' and (id = 7 or id = 8))"]); }); @@ -54,7 +60,7 @@ describe.each([ const run = recordingRun(); await make().execute( - { filter: [[idIs(7)], [idIs(8), idIs(9)]], payload: null, state: null }, + { filter: [[idIs(7)], [idIs(8), idIs(9)]], payload: null, state: null, requires: null }, run, ); @@ -64,13 +70,25 @@ describe.each([ it('returns no rows and runs nothing when an edge has no source row', async () => { const run = recordingRun([{ id: 1 }]); - const rows = await make().execute({ filter: [[idIs(7)], []], payload: null, state: null }, run); + const rows = await make().execute( + { filter: [[idIs(7)], []], payload: null, state: null, requires: null }, + run, + ); expect(rows).toEqual([]); expect(run.queried).toEqual([]); expect(run.executed).toEqual([]); }); + it('returns no rows and runs nothing when a node it requires produced no row', async () => { + const run = recordingRun([{ id: 1 }]); + + expect( + await make().execute({ filter: null, payload: null, state: null, requires: [[]] }, run), + ).toEqual([]); + expect(run).toMatchObject({ queried: [], executed: [] }); + }); + it('gives a node that also returns the columns it is asked for, once each', () => { const node = make(); @@ -90,7 +108,9 @@ describe.each([ const node = make(); const run = recordingRun([], 3); - expect(await node.execute({ filter: null, payload: null, state: null }, run)).toBe(3); + expect( + await node.execute({ filter: null, payload: null, state: null, requires: null }, run), + ).toBe(3); expect(run.executed).toEqual([node.ast]); expect(run.queried).toEqual([]); }); @@ -100,7 +120,7 @@ describe.each([ const rows = await make() .withReturning(emailColumn) - .execute({ filter: null, payload: null, state: null }, run); + .execute({ filter: null, payload: null, state: null, requires: null }, run); expect(rows).toEqual([{ email: 'a@b.c' }]); expect(run.executed).toEqual([]); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/graph-edges.test-d.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/graph-edges.test-d.ts index 843cb6bde32b..714f5e3ddf8b 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/graph-edges.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/graph-edges.test-d.ts @@ -27,5 +27,8 @@ describe('graph edge methods', () => { graph.addRowsEdge(find, join, 'rows', sameId); // @ts-expect-error a Find has no slot for Rows edges graph.addRowsEdge(find, find, 'filter', sameId); + graph.addRequiresEdge(find, insert); + // @ts-expect-error a CrossJoin has no requires slot + graph.addRequiresEdge(find, join); }); }); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/graph.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/graph.test.ts index 353a332b81fe..05414a99ca16 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/graph.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/graph.test.ts @@ -5,6 +5,7 @@ import { FilterData } from '../../src/mutation-graph/filter-data'; import type { Graph } from '../../src/mutation-graph/graph'; import { Insert } from '../../src/mutation-graph/insert'; import { PayloadData } from '../../src/mutation-graph/payload-data'; +import { Requires } from '../../src/mutation-graph/requires'; import { Rows } from '../../src/mutation-graph/rows'; import { StateEdge } from '../../src/mutation-graph/state'; import { StatementNode } from '../../src/mutation-graph/statement-node'; @@ -168,6 +169,20 @@ describe('Graph', () => { }); }); + describe('addRequiresEdge', () => { + it('puts a Requires edge into the requires slot and leaves what the source returns as it was', () => { + const graph = newGraph(); + const find = graph.add(findUsers([], ['email'])); + const update = graph.add(updateUsers({ name: 'Ada' })); + + graph.addRequiresEdge(find, update); + + expect(graph.inputsOf(update)['requires']).toEqual([new Requires(find)]); + expect(returned(graph, find)).toEqual(['email']); + expect(graph.columnsReadFrom(find)).toEqual([]); + }); + }); + describe('columnsReadFrom', () => { it('gives the columns every edge from the node reads, in the order the edges were added', () => { const graph = newGraph(); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/insert-chunked.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/insert-chunked.test.ts index fbb94ae97d94..f797eff48cf3 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/insert-chunked.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/insert-chunked.test.ts @@ -17,7 +17,10 @@ describe('InsertChunked', () => { it('runs its statements in order and gives their rows one statement after another', async () => { const run = recordingRun([{ id: 1 }]); - const rows = await new InsertChunked([first, second]).execute({ state: null }, run); + const rows = await new InsertChunked([first, second]).execute( + { state: null, requires: null }, + run, + ); expect(rows).toEqual([{ id: 1 }, { id: 1 }]); expect(run.queried).toEqual([first, second]); @@ -30,7 +33,7 @@ describe('InsertChunked', () => { insertInto('users', [{ name: 'Grace', email: 'g@h.io' }]), ]); - expect(await counted.execute({ state: null }, run)).toBe(4); + expect(await counted.execute({ state: null, requires: null }, run)).toBe(4); expect(run.executed).toEqual(counted.asts); expect(run.queried).toEqual([]); }); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts index 338885dcb151..5b9f3e5f7994 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/insert.test.ts @@ -18,7 +18,9 @@ describe('Insert', () => { const insert = new Insert(insertInto('posts', [{ title: 'Hello' }], ['id'])); const run = recordingRun([{ id: 1 }]); - expect(await insert.execute({ payload: null, state: null }, run)).toEqual([{ id: 1 }]); + expect(await insert.execute({ payload: null, state: null, requires: null }, run)).toEqual([ + { id: 1 }, + ]); expect(run.queried).toEqual([insert.ast]); }); @@ -27,7 +29,10 @@ describe('Insert', () => { const run = recordingRun(); const titleOfEdge = { views: ParamRef.of(3, { name: 'views' }) }; - await insert.execute({ payload: [[userIdOf7], [titleOfEdge]], state: null }, run); + await insert.execute( + { payload: [[userIdOf7], [titleOfEdge]], state: null, requires: null }, + run, + ); expect(run.queried).toHaveLength(1); expect(run.queried[0]).toMatchObject({ @@ -46,7 +51,9 @@ describe('Insert', () => { const insert = new Insert(insertInto('posts', [{ title: 'Hello' }], ['id'])); const run = recordingRun([{ id: 1 }]); - expect(await insert.execute({ payload: [[userIdOf7], []], state: null }, run)).toEqual([]); + expect( + await insert.execute({ payload: [[userIdOf7], []], state: null, requires: null }, run), + ).toEqual([]); expect(run.queried).toEqual([]); expect(run.executed).toEqual([]); }); @@ -59,7 +66,9 @@ describe('Insert', () => { const run = recordingRun(); const userIdOf8 = { user_id: ParamRef.of(8, { name: 'user_id' }) }; - expect(() => insert.execute({ payload: [[userIdOf7], [userIdOf8]], state: null }, run)).toThrow( + expect(() => + insert.execute({ payload: [[userIdOf7], [userIdOf8]], state: null, requires: null }, run), + ).toThrow( expect.objectContaining({ code: 'ORM.RELATION_MUTATION_INVALID', message: @@ -77,14 +86,27 @@ describe('Insert', () => { }); const run = recordingRun([], 1); - expect(await insert.execute({ payload: [[userIdOf7], [userIdOf7]], state: null }, run)).toBe(1); + expect( + await insert.execute( + { payload: [[userIdOf7], [userIdOf7]], state: null, requires: null }, + run, + ), + ).toBe(1); + }); + + it('returns no rows and runs nothing when a node it requires produced no row', async () => { + const insert = new Insert(insertInto('posts', [{ title: 'Hello' }], ['id'])); + const run = recordingRun([{ id: 1 }]); + + expect(await insert.execute({ payload: null, state: null, requires: [[]] }, run)).toEqual([]); + expect(run).toMatchObject({ queried: [], executed: [] }); }); it('gives the affected row count when its statement returns no column', async () => { const insert = new Insert(insertInto('posts', [{ title: 'a' }, { title: 'b' }])); const run = recordingRun([], 2); - expect(await insert.execute({ payload: null, state: null }, run)).toBe(2); + expect(await insert.execute({ payload: null, state: null, requires: null }, run)).toBe(2); expect(run.executed).toEqual([insert.ast]); }); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/mutation-scope.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/mutation-scope.test.ts new file mode 100644 index 000000000000..f1e5b1008c82 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/mutation-scope.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; +import { withMutationScope } from '../../src/mutation-graph/mutation-scope'; +import { createMockRuntime } from '../helpers'; + +describe('withMutationScope', () => { + it('commits the transaction of the runtime when the work succeeds', async () => { + const runtime = createMockRuntime(); + const commit = vi.fn(async () => undefined); + const rollback = vi.fn(async () => undefined); + const transaction = { + query: runtime.query.bind(runtime), + execute: runtime.execute.bind(runtime), + commit, + rollback, + }; + const scoped = Object.assign(runtime, { transaction: async () => transaction }); + + expect(await withMutationScope(scoped, async (scope) => scope === transaction)).toBe(true); + expect(commit).toHaveBeenCalledTimes(1); + expect(rollback).not.toHaveBeenCalled(); + }); + + it('rolls the transaction back and rethrows when the work fails', async () => { + const runtime = createMockRuntime(); + const commit = vi.fn(async () => undefined); + const rollback = vi.fn(async () => undefined); + const transaction = { + query: runtime.query.bind(runtime), + execute: runtime.execute.bind(runtime), + commit, + rollback, + }; + const scoped = Object.assign(runtime, { transaction: async () => transaction }); + const failure = new Error('statement failed'); + + await expect( + withMutationScope(scoped, async () => { + throw failure; + }), + ).rejects.toBe(failure); + expect(commit).not.toHaveBeenCalled(); + expect(rollback).toHaveBeenCalledTimes(1); + }); + + it('runs in a transaction that has no commit or rollback hook', async () => { + const runtime = createMockRuntime(); + const transaction = { + query: runtime.query.bind(runtime), + execute: runtime.execute.bind(runtime), + }; + const scoped = Object.assign(runtime, { transaction: async () => transaction }); + + expect(await withMutationScope(scoped, async (scope) => scope === transaction)).toBe(true); + }); + + it('releases a connection it opened when the runtime has no transaction of its own', async () => { + const runtime = createMockRuntime(); + const release = vi.fn(async () => undefined); + const connection = { + query: runtime.query.bind(runtime), + execute: runtime.execute.bind(runtime), + release, + }; + const scoped = Object.assign(runtime, { connection: async () => connection }); + + expect(await withMutationScope(scoped, async (scope) => scope === connection)).toBe(true); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('runs on the runtime itself when it has neither a transaction nor a connection', async () => { + const runtime = createMockRuntime(); + + expect(await withMutationScope(runtime, async (scope) => scope === runtime)).toBe(true); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.ts index 68de5979fa34..66a7bf57c2bd 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/print-graph.ts @@ -13,6 +13,7 @@ import type { Graph } from '../../src/mutation-graph/graph'; import { Insert } from '../../src/mutation-graph/insert'; import { InsertChunked } from '../../src/mutation-graph/insert-chunked'; import type { Node } from '../../src/mutation-graph/node'; +import { Requires } from '../../src/mutation-graph/requires'; import { Rows } from '../../src/mutation-graph/rows'; import { State, StateEdge } from '../../src/mutation-graph/state'; import { Update } from '../../src/mutation-graph/update'; @@ -111,6 +112,9 @@ function printEdge(names: NodeNames, edge: Edge): string { if (edge instanceof Rows) { return `Rows ${names.get(edge.from)}`; } + if (edge instanceof Requires) { + return `Requires ${names.get(edge.from)}`; + } if (!(edge instanceof DataEdge)) { return `state ${names.get(edge.from)}`; } @@ -140,8 +144,9 @@ export function printGraph(graph: Graph, options: PrintOptions = { state: false const nodes = graph.nodes().filter(([, node]) => options.state || !(node instanceof State)); const names: NodeNames = new Map(nodes.map(([id], index) => [id, `n${index + 1}`])); const lines = nodes.map(([id, node]) => { - const inputs = Object.values(graph.inputsOf(id)) - .flat() + const slots = graph.inputsOf(id); + const inputs = Object.keys(node.slots) + .flatMap((name) => [slots[name] ?? []].flat()) .filter((edge) => options.state || !(edge instanceof StateEdge)); return `${names.get(id)} ${printNode(node)}${printInputs(names, inputs)}`; }); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/update-graph.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/update-graph.test.ts new file mode 100644 index 000000000000..d2320ec639ff --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/update-graph.test.ts @@ -0,0 +1,192 @@ +import type { Contract } from '@internal/contract/types'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import { updateFirstGraph } from '../../src/mutation-graph/collection-graphs'; +import { parseMutationInput } from '../../src/relation-mutations'; +import type { RelationMutator } from '../../src/types'; +import { createCollectionFor } from '../collection-fixtures'; +import { getTestContext } from '../helpers'; +import { printGraph } from './print-graph'; + +type Mutator = RelationMutator, string>; + +function printUpdate( + input: Record, + set: Record = {}, + withPosts = false, +): string { + const users = createCollectionFor('User').collection.where({ id: 1 }); + const state = withPosts ? users.include('posts').state : users.state; + const context = getTestContext(); + const parsed = parseMutationInput(context.contract, 'public', 'User', input as never); + return printGraph( + updateFirstGraph( + { context, state, modelName: 'User', namespaceId: 'public' }, + set, + parsed.relationMutations, + ), + ); +} + +describe('updateFirstGraph with relation operations', () => { + describe('a relation the parent holds the key for', () => { + it('finds the row to connect and copies its key into the values of the Update', () => { + expect( + printUpdate( + { invitedBy: (inviter: Mutator) => inviter.connect({ id: 2 }) }, + { name: 'Ada' }, + ), + ).toBe( + [ + 'n1 Find users where id = 1 limit 1', + 'n2 Find users where id = 2 limit 1 <- Requires n1', + 'n3 Assert <- Rows n2, Requires n1', + "n4 Update users set name = 'Ada' <- FilterData n1 (id->id), PayloadData n2 (id->invited_by_id)", + 'result: n4', + ].join('\n'), + ); + }); + + it('creates the related row and writes its key with an Update even when nothing else is set', () => { + expect( + printUpdate({ + invitedBy: (inviter: Mutator) => + inviter.create({ id: 2, name: 'Grace', email: 'g@h.io' }), + }), + ).toBe( + [ + 'n1 Find users where id = 1 limit 1', + 'n2 Insert users <- Requires n1', + 'n3 Update users set nothing <- FilterData n1 (id->id), PayloadData n2 (id->invited_by_id)', + 'result: n3', + ].join('\n'), + ); + }); + + it('sets the key of the parent to null on disconnect', () => { + expect(printUpdate({ invitedBy: (inviter: Mutator) => inviter.disconnect() })).toBe( + [ + 'n1 Find users where id = 1 limit 1', + 'n2 Update users set invited_by_id = null <- FilterData n1 (id->id)', + 'result: n2', + ].join('\n'), + ); + }); + }); + + describe('a relation the child holds the key for', () => { + it('inserts the children with the key read from the Find when nothing is set', () => { + expect( + printUpdate({ + posts: (posts: Mutator) => posts.create([{ id: 10, title: 'a', views: 1 }]), + }), + ).toBe( + [ + 'n1 Find users where id = 1 limit 1', + 'n2 Insert posts <- PayloadData n1 (id->user_id)', + 'result: n1', + ].join('\n'), + ); + }); + + it('sets the key of the parent on the children a connect matches', () => { + expect(printUpdate({ posts: (posts: Mutator) => posts.connect({ id: 10 }) })).toBe( + [ + 'n1 Find users where id = 1 limit 1', + 'n2 Update posts set nothing where id = 10 <- PayloadData n1 (id->user_id)', + 'result: n1', + ].join('\n'), + ); + }); + + it('sets the key to null on every child of the parent, or on those the criterion matches', () => { + expect(printUpdate({ posts: (posts: Mutator) => posts.disconnect() })).toBe( + [ + 'n1 Find users where id = 1 limit 1', + 'n2 Update posts set user_id = null <- FilterData n1 (id->user_id)', + 'result: n1', + ].join('\n'), + ); + expect(printUpdate({ posts: (posts: Mutator) => posts.disconnect([{ id: 10 }]) })).toBe( + [ + 'n1 Find users where id = 1 limit 1', + 'n2 Update posts set user_id = null where id = 10 <- FilterData n1 (id->user_id)', + 'result: n1', + ].join('\n'), + ); + }); + }); + + describe('a many-to-many relation', () => { + it('inserts the target row, then the junction row with both keys', () => { + expect( + printUpdate({ tags: (tags: Mutator) => tags.create([{ id: 't1', name: 'SQL' }]) }), + ).toBe( + [ + 'n1 Find users where id = 1 limit 1', + 'n2 Insert tags <- Requires n1', + 'n3 Insert user_tags <- PayloadData n1 (id->user_id), PayloadData n2 (id->tag_id)', + 'result: n1', + ].join('\n'), + ); + }); + + it('finds and asserts the target, then inserts a junction row that skips an existing link', () => { + expect(printUpdate({ tags: (tags: Mutator) => tags.connect({ name: 'SQL' }) })).toBe( + [ + 'n1 Find users where id = 1 limit 1', + "n2 Find tags where name = 'SQL' limit 1 <- Requires n1", + 'n3 Assert <- Rows n2, Requires n1', + 'n4 Insert user_tags on conflict (user_id, tag_id) do nothing <- PayloadData n1 (id->user_id), PayloadData n2 (id->tag_id)', + 'result: n1', + ].join('\n'), + ); + }); + + it('finds and asserts the target, then deletes the junction row by both keys', () => { + expect(printUpdate({ tags: (tags: Mutator) => tags.disconnect([{ name: 'SQL' }]) })).toBe( + [ + 'n1 Find users where id = 1 limit 1', + "n2 Find tags where name = 'SQL' limit 1 <- Requires n1", + 'n3 Assert <- Rows n2, Requires n1', + 'n4 Delete user_tags <- FilterData n1 (id->user_id), FilterData n2 (id->tag_id)', + 'result: n1', + ].join('\n'), + ); + }); + + it('rejects a disconnect without criteria while the graph is built', () => { + expect(() => printUpdate({ tags: (tags: Mutator) => tags.disconnect() })).toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_INVALID', + message: 'disconnect() nested mutation for relation "tags" requires criterion', + meta: { kind: 'disconnect', relation: 'tags', problem: 'missing-criterion' }, + }), + ); + }); + }); + + it('writes the parent, then the child operations, then reads the parent with its includes', () => { + expect( + printUpdate({ posts: (posts: Mutator) => posts.connect({ id: 10 }) }, { name: 'Ada' }, true), + ).toBe( + [ + 'n1 Find users where id = 1 limit 1', + "n2 Update users set name = 'Ada' <- FilterData n1 (id->id)", + 'n3 Update posts set nothing where id = 10 <- PayloadData n2 (id->user_id)', + 'n4 Find users <- FilterData n2 (id->id)', + 'result: n4', + ].join('\n'), + ); + }); + + it('rejects an empty disconnect criterion on a relation the child holds the key for', () => { + expect(() => printUpdate({ posts: (posts: Mutator) => posts.disconnect([{}]) })).toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_INVALID', + message: 'disconnect() nested mutation for relation "posts" requires non-empty criterion', + meta: { kind: 'disconnect', relation: 'posts', problem: 'empty-criterion' }, + }), + ); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/update.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/update.test.ts index bb63f0a3f09d..d73932c94751 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/update.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/update.test.ts @@ -11,7 +11,10 @@ describe('Update with a payload', () => { const views = { views: ParamRef.of(3, { name: 'views' }) }; expect( - await update.execute({ filter: null, payload: [[userIdOf7], [views]], state: null }, run), + await update.execute( + { filter: null, payload: [[userIdOf7], [views]], state: null, requires: null }, + run, + ), ).toBe(2); expect(run.executed[0]).toMatchObject({ kind: 'update', @@ -24,7 +27,10 @@ describe('Update with a payload', () => { const run = recordingRun([{ id: 1 }]); expect( - await update.execute({ filter: null, payload: [[userIdOf7], []], state: null }, run), + await update.execute( + { filter: null, payload: [[userIdOf7], []], state: null, requires: null }, + run, + ), ).toEqual([]); expect(run).toMatchObject({ queried: [], executed: [] }); }); diff --git a/packages/3-extensions/sql-orm-client/test/relation-mutations.test.ts b/packages/3-extensions/sql-orm-client/test/relation-mutations.test.ts new file mode 100644 index 000000000000..fc255050178a --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/relation-mutations.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { hasNestedMutationCallbacks, hasRelationFields } from '../src/relation-mutations'; +import { getTestContract } from './helpers'; + +const contract = getTestContract(); +const connectPost = (posts: { connect: (criterion: Record) => unknown }) => + posts.connect({ id: 1 }); + +describe('hasNestedMutationCallbacks', () => { + it('detects callbacks only on relation fields', () => { + expect(hasNestedMutationCallbacks(contract, 'public', 'User', { posts: connectPost })).toBe( + true, + ); + expect( + hasNestedMutationCallbacks(contract, 'public', 'User', { + posts: { kind: 'connect', criteria: [{ id: 1 }] }, + }), + ).toBe(false); + expect( + hasNestedMutationCallbacks(contract, 'public', 'User', { name: () => ({ kind: 'connect' }) }), + ).toBe(false); + }); +}); + +describe('hasRelationFields', () => { + it('detects a relation field with any value that is not undefined', () => { + expect(hasRelationFields(contract, 'public', 'User', { posts: connectPost })).toBe(true); + expect(hasRelationFields(contract, 'public', 'User', { posts: [] })).toBe(true); + expect(hasRelationFields(contract, 'public', 'User', { posts: undefined, name: 'Ada' })).toBe( + false, + ); + }); +}); diff --git a/test/integration/test/sql-orm-client/mn-nested-write.test.ts b/test/integration/test/sql-orm-client/mn-nested-write.test.ts index cb76474b7697..85575eb995dc 100644 --- a/test/integration/test/sql-orm-client/mn-nested-write.test.ts +++ b/test/integration/test/sql-orm-client/mn-nested-write.test.ts @@ -144,7 +144,7 @@ describe('integration/mn-nested-write', () => { ); it( - 'update(): connect to an already-linked tag rejects and preserves the junction link', + 'update(): connect to an already-linked tag keeps the one junction link', async () => { await withCollectionRuntime(async (runtime) => { const users = createReturningUsersCollection(runtime); @@ -153,15 +153,15 @@ describe('integration/mn-nested-write', () => { await seedTags(runtime, [{ id: TAG_RUST, name: 'Rust' }]); await seedUserTags(runtime, [{ userId: 1, tagId: TAG_RUST }]); - await expect( - users - .where({ id: 1 }) - .select('id', 'name') - .include('tags', (tags) => tags.select('id', 'name')) - .update({ - tags: (t) => t.connect({ id: TAG_RUST }), - }), - ).rejects.toThrow(/violated a unique constraint on junction "user_tags"/); + const updated = await users + .where({ id: 1 }) + .select('id', 'name') + .include('tags', (tags) => tags.select('id', 'name')) + .update({ + tags: (t) => t.connect({ id: TAG_RUST }), + }); + + expect(updated).toEqual({ id: 1, name: 'Alice', tags: [{ id: TAG_RUST, name: 'Rust' }] }); const junctionRows = await runtime.query<{ user_id: number; tag_id: string }>( 'select user_id, tag_id from user_tags', diff --git a/test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts b/test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts index a6671d9ac66e..81fcce4442cb 100644 --- a/test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts +++ b/test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts @@ -219,3 +219,73 @@ describe('integration/nested create() through the mutation graph on SQLite', () timeouts.databaseOperation, ); }); + +describe('integration/nested update() through the mutation graph on SQLite', () => { + it( + 'connects an author, links and unlinks tags, and disconnects a child', + async () => { + await withDatabase(async ({ users, posts, rows }) => { + await users.create({ + id: 1, + name: 'Ada', + posts: (children) => children.create([{ id: 10, title: 'First' }]), + tags: (tags) => tags.connect({ id: 1 }), + }); + await users.create({ + id: 2, + name: 'Grace', + tags: (tags) => tags.create([{ id: 2, name: 'Rust' }]), + }); + + const post = await posts + .where({ id: 10 }) + .select('id', 'userId') + .update({ author: (author) => author.connect({ id: 2 }) }); + const user = await users + .where({ id: 1 }) + .select('id', 'name') + .include('tags', (tags) => tags.select('id').orderBy((tag) => tag['id']!.asc())) + .update({ + name: 'Ada L.', + tags: (tags) => tags.connect([{ id: 2 }, { id: 1 }]), + }); + + expect(post).toEqual({ id: 10, userId: 2 }); + expect(user).toEqual({ id: 1, name: 'Ada L.', tags: [{ id: 1 }, { id: 2 }] }); + + const unlinked = await users + .where({ id: 1 }) + .select('id') + .update({ tags: (tags) => tags.disconnect([{ name: 'SQL' }]) }); + const orphaned = await users + .where({ id: 2 }) + .select('id') + .update({ posts: (children) => children.disconnect() }); + + expect([unlinked, orphaned]).toEqual([{ id: 1 }, { id: 2 }]); + expect(rows('select user_id, tag_id from user_tags order by user_id, tag_id')).toEqual([ + { user_id: 1, tag_id: 2 }, + { user_id: 2, tag_id: 2 }, + ]); + expect(rows('select id, user_id from posts')).toEqual([{ id: 10, user_id: null }]); + }); + }, + timeouts.databaseOperation, + ); + + it( + 'resolves null and writes nothing when no row matches', + async () => { + await withDatabase(async ({ users, rows }) => { + const updated = await users + .where({ id: 9 }) + .update({ tags: (tags) => tags.create([{ id: 3, name: 'Go' }]) }); + + expect(updated).toBeNull(); + expect(rows('select id from tags order by id')).toEqual([{ id: 1 }]); + expect(rows('select user_id from user_tags')).toEqual([]); + }); + }, + timeouts.databaseOperation, + ); +}); diff --git a/test/integration/test/sql-orm-client/nested-update-graph.test.ts b/test/integration/test/sql-orm-client/nested-update-graph.test.ts new file mode 100644 index 000000000000..5f1bb1c1a5c1 --- /dev/null +++ b/test/integration/test/sql-orm-client/nested-update-graph.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { + createReturningPostsCollection, + createReturningUsersCollection, + timeouts, + withCollectionRuntime, +} from './integration-helpers'; +import { seedPosts, seedUsers } from './runtime-helpers'; + +describe('integration/nested update() through the mutation graph', () => { + it( + 'connects the row the parent holds the key for and sets the other values in one update', + async () => { + await withCollectionRuntime(async (runtime) => { + const posts = createReturningPostsCollection(runtime); + await seedUsers(runtime, [{ id: 5, name: 'Ada', email: 'ada@example.com' }]); + await seedPosts(runtime, [{ id: 20, title: 'Old', userId: null, views: 1 }]); + + const updated = await posts + .where({ id: 20 }) + .select('id', 'title', 'userId') + .update({ + title: 'New', + author: (author) => author.connect({ email: 'ada@example.com' }), + }); + + expect(updated).toEqual({ id: 20, title: 'New', userId: 5 }); + expect(await runtime.query('select id, title, user_id from posts')).toEqual([ + { id: 20, title: 'New', user_id: 5 }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'creates the row the parent holds the key for and points the parent at it', + async () => { + await withCollectionRuntime(async (runtime) => { + const posts = createReturningPostsCollection(runtime); + await seedPosts(runtime, [{ id: 20, title: 'Old', userId: null, views: 1 }]); + + const updated = await posts + .where({ id: 20 }) + .select('id', 'userId') + .update({ + author: (author) => author.create({ id: 5, name: 'Ada', email: 'ada@example.com' }), + }); + + expect(updated).toEqual({ id: 20, userId: 5 }); + expect(await runtime.query('select id, name from users')).toEqual([{ id: 5, name: 'Ada' }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'sets the key of the parent on the children a connect matches, with nothing else set', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + await seedUsers(runtime, [{ id: 1, name: 'Ada', email: 'ada@example.com' }]); + await seedPosts(runtime, [ + { id: 10, title: 'First', userId: null, views: 1 }, + { id: 11, title: 'Second', userId: null, views: 2 }, + ]); + + const updated = await users + .where({ id: 1 }) + .select('id', 'name') + .include('posts', (posts) => posts.select('id')) + .update({ posts: (posts) => posts.connect({ id: 11 }) }); + + expect(updated).toEqual({ id: 1, name: 'Ada', posts: [{ id: 11 }] }); + expect(await runtime.query('select id, user_id from posts order by id')).toEqual([ + { id: 10, user_id: null }, + { id: 11, user_id: 1 }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'resolves null and writes nothing when no row matches', + async () => { + await withCollectionRuntime(async (runtime) => { + const posts = createReturningPostsCollection(runtime); + + const updated = await posts.where({ id: 20 }).update({ + title: 'New', + author: (author) => author.create({ id: 5, name: 'Ada', email: 'ada@example.com' }), + }); + + expect(updated).toBeNull(); + expect(await runtime.query('select id from users')).toEqual([]); + expect(await runtime.query('select id from posts')).toEqual([]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'rejects invalid nested input before any statement, even when no row matches', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + await expect( + users.where({ id: 99 }).update({ posts: (posts) => posts.connect({} as never) }), + ).rejects.toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_INVALID', + meta: { kind: 'connect', relation: 'posts', problem: 'empty-criterion' }, + }), + ); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'rejects a relation field that is not a callback and writes nothing', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + await seedUsers(runtime, [{ id: 1, name: 'Ada', email: 'ada@example.com' }]); + + await expect( + users.where({ id: 1 }).update({ name: 'Grace', posts: [] as never }), + ).rejects.toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_INVALID', + meta: { relation: 'posts', model: 'User', problem: 'missing-callback' }, + }), + ); + expect(await runtime.query('select name from users')).toEqual([{ name: 'Ada' }]); + }); + }, + timeouts.spinUpPpgDev, + ); +}); From 6befd42eabdbf7c38d0752cd1852f3d107b968ee Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 15 Oct 2026 16:24:07 +0000 Subject: [PATCH 07/14] refactor(sql-orm-client): clean up after the nested executor The junction Delete refuses two FilterData conditions that give one junction column different values, with main's ORM.RELATION_MUTATION_INVALID error, as the junction Insert does for its payloads; the check is shared in junction-write.ts. query-plan-mutations.ts loses its compile functions and aliasTarget, which nothing calls any more, and is renamed mutation-statements.ts for the statement builders it keeps. toFieldName, which nothing calls, is removed, and the relation-mutation helpers only the translation uses are no longer exported. Docs no longer describe a reload after an ORM write. A pending extension upgrade entry declares no consumer change. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- docs/Serverless Deployment Guide.md | 2 +- .../4. Runtime & Middleware Framework.md | 2 +- docs/reference/error-reference.md | 2 +- .../src/mutation-graph/collection-graphs.ts | 4 +- .../src/mutation-graph/delete.ts | 24 +- .../src/mutation-graph/insert.ts | 39 +-- .../src/mutation-graph/junction-write.ts | 23 ++ .../src/mutation-graph/nested-mutations.ts | 11 +- .../src/mutation-graph/payload-data.ts | 2 +- .../src/mutation-graph/run-graph.ts | 2 +- ...an-mutations.ts => mutation-statements.ts} | 77 +---- .../sql-orm-client/src/query-plan.ts | 9 +- .../sql-orm-client/src/relation-mutations.ts | 27 +- .../sql-orm-client/test/model-columns.test.ts | 25 +- .../test/mutation-graph/delete.test.ts | 39 +++ .../test/mutation-graph/statements.ts | 2 +- .../test/mutation-statements.test.ts | 227 +++++++++++++ .../test/namespace-qualification.test.ts | 46 ++- .../test/query-plan-mutations.test.ts | 304 ------------------ .../extension/instructions.md | 3 + 20 files changed, 376 insertions(+), 494 deletions(-) create mode 100644 packages/3-extensions/sql-orm-client/src/mutation-graph/junction-write.ts rename packages/3-extensions/sql-orm-client/src/{query-plan-mutations.ts => mutation-statements.ts} (77%) create mode 100644 packages/3-extensions/sql-orm-client/test/mutation-graph/delete.test.ts create mode 100644 packages/3-extensions/sql-orm-client/test/mutation-statements.test.ts delete mode 100644 packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts create mode 100644 upgrade-instructions/pending/mutation-graph-nested/extension/instructions.md diff --git a/docs/Serverless Deployment Guide.md b/docs/Serverless Deployment Guide.md index 0a8d8c21a69c..e0c51f081c47 100644 --- a/docs/Serverless Deployment Guide.md +++ b/docs/Serverless Deployment Guide.md @@ -33,7 +33,7 @@ export default { }; ``` -Inside a request, `db` does everything the `db` from `postgres()` does, so any documented `db.orm...`, `db.sql...`, `db.raw...`, `db.transaction(...)`, `db.prepare(...)` or `db.query(...)` snippet works unchanged. Await every query before the `await using` scope ends. The connection's `close()` waits until the runtime has been idle for one turn of the event loop (a `setTimeout(0)`), then refuses new work. A lazy ORM read such as `db.orm.public.User.all()`, or `db.query(plan)`, returned from the scope without `await` starts only when the caller awaits it, after the scope has closed, so it rejects with `DRIVER.NOT_CONNECTED` ("Runtime is closed"), whose `fix` names the missing `await`. Work that keeps the runtime busy from the close onward completes: a `first()`, `execute()` or `transaction()`, an ORM write and its reload, including `include(...).create()` and a nested `create()`, and an async helper whose steps go through `db.orm`, `db.transaction()` or `db.prepare()`; a transaction commits. Such work is never refused because of the close; if it fails for its own reason, it rejects like any unawaited promise. A helper that calls `db.runtime()` after the scope has ended throws "Postgres connection is closed" at that call. Work that starts after the runtime was idle for a turn is refused. Whether a query sent after waiting on something other than the database counts as idle depends on when that callback runs relative to the timer, so do not rely on it. An unawaited promise that fails then, or fails for its own reason, rejects like any unawaited promise, and Node reports it as unhandled. Write `return await db.orm...`, not `return db.orm...`. `connect` connects to the database before it returns; when the database refuses the connection, rejects the credentials, or does not answer within 20 seconds, it rejects with `DRIVER.CONNECTION_FAILED`, so a handler that answers with a 503 catches it there. Answer a request that needs no query, such as an unknown route or a missing parameter, before `connect`: `connect` opens a database connection whether or not a query follows. Never call `connect` at module scope. +Inside a request, `db` does everything the `db` from `postgres()` does, so any documented `db.orm...`, `db.sql...`, `db.raw...`, `db.transaction(...)`, `db.prepare(...)` or `db.query(...)` snippet works unchanged. Await every query before the `await using` scope ends. The connection's `close()` waits until the runtime has been idle for one turn of the event loop (a `setTimeout(0)`), then refuses new work. A lazy ORM read such as `db.orm.public.User.all()`, or `db.query(plan)`, returned from the scope without `await` starts only when the caller awaits it, after the scope has closed, so it rejects with `DRIVER.NOT_CONNECTED` ("Runtime is closed"), whose `fix` names the missing `await`. Work that keeps the runtime busy from the close onward completes: a `first()`, `execute()` or `transaction()`, an ORM write of several statements, including `include(...).create()` and a nested `create()`, and an async helper whose steps go through `db.orm`, `db.transaction()` or `db.prepare()`; a transaction commits. Such work is never refused because of the close; if it fails for its own reason, it rejects like any unawaited promise. A helper that calls `db.runtime()` after the scope has ended throws "Postgres connection is closed" at that call. Work that starts after the runtime was idle for a turn is refused. Whether a query sent after waiting on something other than the database counts as idle depends on when that callback runs relative to the timer, so do not rely on it. An unawaited promise that fails then, or fails for its own reason, rejects like any unawaited promise, and Node reports it as unhandled. Write `return await db.orm...`, not `return db.orm...`. `connect` connects to the database before it returns; when the database refuses the connection, rejects the credentials, or does not answer within 20 seconds, it rejects with `DRIVER.CONNECTION_FAILED`, so a handler that answers with a 503 catches it there. Answer a request that needs no query, such as an unknown route or a missing parameter, before `connect`: `connect` opens a database connection whether or not a query follows. Never call `connect` at module scope. ## Cloudflare Workers + Hyperdrive (worked example) diff --git a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md index c4853b7a884d..e4af422b6252 100644 --- a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md +++ b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md @@ -662,7 +662,7 @@ The runtime does not own pooling or sockets; it delegates to a target driver. Th - **What starts.** Runtime-scope work is work on the runtime's driver, decided by the queryable, not by the `scope` option a caller passes. It starts when it enters the runtime: `execute()`, a prepared execute, `connection()` or `startActivityOn(runtime, …)` is called, or the generator behind `query()` or a prepared query begins. An activity handle opened on the runtime is work in flight until its `end()`. A lazy result starts on its first `next()`, which `toArray()`, `then()`, `first()` and `for await` trigger, so a result built before `close()` and awaited after it starts after `close()`. - **Two close policies.** The owner passes `closeRefusal` in the runtime's options. `'when-idle'`, the default, waits for an idle turn as described below before refusing; the serverless Postgres connection uses it, because all its work belongs to one request. `'at-once'` refuses from the call of `close()` and waits only for the work already in flight, as `pool.end()` does; an activity already open when `close()` is called, such as an ORM call that started before it, counts as work in flight and finishes its work: its statements, its transaction and the activities started beneath it are admitted until it ends, while a new activity is refused; the `postgres()`, `sqlite()` and `supabase()` clients use it, because many callers share them, so a runtime captured with `runtime()` before `close()` is refused at once and steady traffic cannot keep the close open. -- **When refusal begins under `'when-idle'`.** `close()` loops on a `setTimeout(0)`, taken when the module loads so that a test which installs fake timers afterwards still closes. When the timer fires with database work in flight, or with work started since the timer was set, it waits for that work and sets another timer. Once a timer fires with the runtime idle, it starts refusing and closes the driver. Database work here includes statements on held connections and transactions and their begin, commit, rollback and release. So a chain of dependent queries that keeps the runtime busy from the close onward, such as an ORM write, its transaction and its reload, or an async helper's queries, is admitted to its end. Work that starts after the runtime was idle for a turn, such as a lazy result awaited after an `await using` scope has been disposed, is refused. The grace period is one `setTimeout(0)` measured from the last database work, so whether a query sent after waiting on something other than the database is admitted depends on when that callback runs relative to the timer; nothing should rely on it. +- **When refusal begins under `'when-idle'`.** `close()` loops on a `setTimeout(0)`, taken when the module loads so that a test which installs fake timers afterwards still closes. When the timer fires with database work in flight, or with work started since the timer was set, it waits for that work and sets another timer. Once a timer fires with the runtime idle, it starts refusing and closes the driver. Database work here includes statements on held connections and transactions and their begin, commit, rollback and release. So a chain of dependent queries that keeps the runtime busy from the close onward, such as an ORM write of several statements and its transaction, or an async helper's queries, is admitted to its end. Work that starts after the runtime was idle for a turn, such as a lazy result awaited after an `await using` scope has been disposed, is refused. The grace period is one `setTimeout(0)` measured from the last database work, so whether a query sent after waiting on something other than the database is admitted depends on when that callback runs relative to the timer; nothing should rely on it. - **What `close()` waits for.** `execute()`, a prepared execute and `connection()` until they settle, whether they resolve or reject. A query until its first row or its completion arrives. A statement sent directly on a raw connection the same way: an `execute` until it settles, a query stream from its first read until the driver answers it. Once it refuses, a transaction from its start until its commit or rollback settles, its commit fails, or its connection is released or destroyed. A connection held between statements is not waited for. Then it calls `driver.close()`, unless the runtime was built with `ownsDriver: false`. The runtime waits for work until the driver answers; from then on the driver's close waits, which `SqlDriver.close()` requires: it waits for the connections and streams it has handed out. - **What is refused.** Runtime-scope work that starts once refusal has begun is refused at its entry, before middleware and parameter encoding, with `DRIVER.NOT_CONNECTED` "Runtime is closed". The rejection is immediate and never waits for the close. - **Held connections are exempt.** Work on a held connection or transaction is never refused; it is recorded only so that it keeps the runtime busy. It holds a database connection, and the driver's close waits for that connection's release: a pool waits for its checked-out clients, and the direct Postgres driver waits for its connection lease and for the client query lock. The contract-marker read of a connection or transaction that the runtime handed out runs on that connection, so it works while the close waits. The marker check is runtime-wide: it runs on the queryable of the first query, which can be a held connection or a transaction, and concurrent first queries share its result. A queryable that a subclass passes in, such as a Supabase role session, reads the marker through the driver. On a connection with cursors on, a cursor iterator started by hand and never finished holds the client query lock, so the direct driver's `close()` waits for it for ever, as it does for a leaked `connection()`; `destroy()` ends the socket at once. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 477cf00d0b12..404d85664e4e 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1475,7 +1475,7 @@ A control-plane driver could not establish a database connection (`driver.create ### DRIVER.NOT_CONNECTED -Using a driver, a client, or the CLI control client before `connect(...)` has been called, or after it was closed. It surfaces from runtime `query` / `execute`, a prepared statement's `query(target, params, options?)`, `acquireConnection`, or `explain`, including lazily when iterating a query result. A closed Postgres client raises it as "Postgres client is closed" from every member, synchronously; it also closes its runtime, so a runtime captured with `runtime()` before `close()` refuses new work at once with "Runtime is closed" (a caller's pool or `pg.Client` stays open); and a closed serverless connection raises it as "Postgres connection is closed" from `runtime()`; the connection's ORM queries, `transaction(...)` and prepared statements follow the runtime's rule below. The SQL runtime raises it with the message "Runtime is closed" for a query, execute, prepared statement or `connection()` that starts once the runtime refuses new work; its `fix` names the missing `await`, and the rejection is immediate. When refusal begins depends on the runtime's `closeRefusal` policy. With `'at-once'`, which a Postgres client built from `{ url }` uses, it begins when `close()` is called, and work already in flight completes. With `'when-idle'`, which a serverless connection uses, it begins once `close()` has been called and the runtime has then been idle for one turn of the event loop (a `setTimeout(0)`), so a lazy result returned without `await` from an `await using` scope and awaited after the scope ended is refused, while work that keeps the runtime busy from the close onward, including an ORM write's reload and a helper's chain of queries through the ORM, completes, because `close()` waits for it, and is never refused because of the close; if it fails for its own reason, it rejects like any unawaited promise. Work that starts after the runtime was idle for a turn is refused; whether a query sent after waiting on something other than the database counts as idle depends on timing, so do not rely on it; an unawaited promise that fails then, or fails for its own reason, rejects like any unawaited promise, and Node reports it as unhandled. The Postgres driver's own messages are "Postgres driver is closed. Call connect(binding) to reconnect." after `close()`, "Postgres connection lost or closed. Create a new client to reconnect." from a query on a `pg.Client` whose connection was lost, whoever created it, since a pg client cannot reconnect, and "Postgres connection lost or closed. Call connect(binding) to reconnect." from the runtime on a later use, once that connection was destroyed. Binding a client the driver already gave up on fails with "This pg client lost its connection or was given up by an earlier driver. Create a new client to reconnect." The MySQL drivers raise it only for use before `connect` or after `close`. A connection lost under a statement is a `SqlConnectionError` with `transient: true`, as on Postgres, and the runtime driver destroys that connection. +Using a driver, a client, or the CLI control client before `connect(...)` has been called, or after it was closed. It surfaces from runtime `query` / `execute`, a prepared statement's `query(target, params, options?)`, `acquireConnection`, or `explain`, including lazily when iterating a query result. A closed Postgres client raises it as "Postgres client is closed" from every member, synchronously; it also closes its runtime, so a runtime captured with `runtime()` before `close()` refuses new work at once with "Runtime is closed" (a caller's pool or `pg.Client` stays open); and a closed serverless connection raises it as "Postgres connection is closed" from `runtime()`; the connection's ORM queries, `transaction(...)` and prepared statements follow the runtime's rule below. The SQL runtime raises it with the message "Runtime is closed" for a query, execute, prepared statement or `connection()` that starts once the runtime refuses new work; its `fix` names the missing `await`, and the rejection is immediate. When refusal begins depends on the runtime's `closeRefusal` policy. With `'at-once'`, which a Postgres client built from `{ url }` uses, it begins when `close()` is called, and work already in flight completes. With `'when-idle'`, which a serverless connection uses, it begins once `close()` has been called and the runtime has then been idle for one turn of the event loop (a `setTimeout(0)`), so a lazy result returned without `await` from an `await using` scope and awaited after the scope ended is refused, while work that keeps the runtime busy from the close onward, including an ORM write of several statements and a helper's chain of queries through the ORM, completes, because `close()` waits for it, and is never refused because of the close; if it fails for its own reason, it rejects like any unawaited promise. Work that starts after the runtime was idle for a turn is refused; whether a query sent after waiting on something other than the database counts as idle depends on timing, so do not rely on it; an unawaited promise that fails then, or fails for its own reason, rejects like any unawaited promise, and Node reports it as unhandled. The Postgres driver's own messages are "Postgres driver is closed. Call connect(binding) to reconnect." after `close()`, "Postgres connection lost or closed. Create a new client to reconnect." from a query on a `pg.Client` whose connection was lost, whoever created it, since a pg client cannot reconnect, and "Postgres connection lost or closed. Call connect(binding) to reconnect." from the runtime on a later use, once that connection was destroyed. Binding a client the driver already gave up on fails with "This pg client lost its connection or was given up by an earlier driver. Create a new client to reconnect." The MySQL drivers raise it only for use before `connect` or after `close`. A connection lost under a statement is a `SqlConnectionError` with `transient: true`, as on Postgres, and the runtime driver destroys that connection. ### DRIVER.PREPARE_FAILED diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts index ad81d520f434..2f93d0927ce4 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/collection-graphs.ts @@ -11,7 +11,6 @@ import { supportsDefaultInInsert, } from '../collection-contract'; import type { DescribeCollectionRowsOptions } from '../collection-dispatch'; -import { ormError } from '../orm-errors'; import { countMutationWhere, deleteAst, @@ -24,7 +23,8 @@ import { updateAssignments, updateAst, upsertAst, -} from '../query-plan-mutations'; +} from '../mutation-statements'; +import { ormError } from '../orm-errors'; import { collectionSelectAst, collectionSelectWithIncludesAst } from '../query-plan-select'; import { variantReturning } from '../query-plan-source'; import type { ParsedRelationMutation } from '../relation-mutations'; diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts index b2a32c702995..42c20e5df5c8 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/delete.ts @@ -1,15 +1,18 @@ -import type { DeleteAst, ProjectionItem } from '@internal/sql-relational-core/ast'; +import type { AnyExpression, DeleteAst, ProjectionItem } from '@internal/sql-relational-core/ast'; import { executeFiltered, type Filtered, filteredSlots, withColumns } from './filtered-statement'; +import { assertOneValuePerColumn, type JunctionWrite } from './junction-write'; import type { Executed, OutputsOf, Run, StatementAst } from './node'; import { StatementNode } from './statement-node'; export class Delete extends StatementNode { readonly slots = filteredSlots; readonly ast: DeleteAst; + readonly junction: JunctionWrite | undefined; - constructor(ast: DeleteAst) { + constructor(ast: DeleteAst, junction?: JunctionWrite) { super(); this.ast = ast; + this.junction = junction; Object.freeze(this); } @@ -22,10 +25,25 @@ export class Delete extends StatementNode { } override execute(inputs: OutputsOf, run: Run): Executed { + if (this.junction !== undefined) { + assertOneValuePerColumn((inputs.filter ?? []).flat().flatMap(columnValues), this.junction); + } return executeFiltered(this.ast, inputs, run); } override withReturning(columns: readonly ProjectionItem[]): Delete { - return new Delete(this.ast.withReturning(withColumns(this.returns, columns))); + return new Delete(this.ast.withReturning(withColumns(this.returns, columns)), this.junction); } } + +function columnValues(condition: AnyExpression): (readonly [string, unknown])[] { + if (condition.kind === 'and') { + return condition.exprs.flatMap(columnValues); + } + return condition.kind === 'binary' && + condition.op === 'eq' && + condition.left.kind === 'column-ref' && + condition.right.kind === 'param-ref' + ? [[condition.left.column, condition.right.value]] + : []; +} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts index cbd08579f658..78766e05fac8 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/insert.ts @@ -1,8 +1,8 @@ import type { InsertAst, ProjectionItem } from '@internal/sql-relational-core/ast'; -import { ormError } from '../orm-errors'; import { withColumns } from './filtered-statement'; +import { assertOneValuePerColumn, type JunctionWrite } from './junction-write'; import type { Executed, OutputsOf, Run, StatementAst } from './node'; -import type { Payload, PayloadData } from './payload-data'; +import type { PayloadData } from './payload-data'; import { type Requires, requirementUnmet } from './requires'; import { executeStatement, rowsResult } from './run-statement'; import type { StateEdge } from './state'; @@ -10,17 +10,12 @@ import { StatementNode } from './statement-node'; type Payloads = { payload: PayloadData[]; state: StateEdge[]; requires: Requires[] }; -export interface InsertJunction { - readonly relation: string; - readonly junction: string; -} - export class Insert extends StatementNode { readonly slots = { payload: 'list', state: 'list', requires: 'list' } as const; readonly ast: InsertAst; - readonly junction: InsertJunction | undefined; + readonly junction: JunctionWrite | undefined; - constructor(ast: InsertAst, junction?: InsertJunction) { + constructor(ast: InsertAst, junction?: JunctionWrite) { super(); this.ast = ast; this.junction = junction; @@ -49,7 +44,12 @@ export class Insert extends StatementNode { } const records = inputs.payload.map(([record]) => record); if (this.junction !== undefined) { - assertNoConflictingColumn(records, this.junction); + assertOneValuePerColumn( + records.flatMap((record) => + Object.entries(record ?? {}).map(([column, param]) => [column, param.value] as const), + ), + this.junction, + ); } const payload = Object.assign({}, ...records); const rows = this.ast.rows.map((row) => ({ ...row, ...payload })); @@ -60,22 +60,3 @@ export class Insert extends StatementNode { return new Insert(this.ast.withReturning(withColumns(this.returns, columns)), this.junction); } } - -function assertNoConflictingColumn( - records: readonly (Payload | undefined)[], - { relation, junction }: InsertJunction, -): void { - const seen = new Map(); - for (const record of records) { - for (const [column, param] of Object.entries(record ?? {})) { - if (seen.has(column) && !Object.is(seen.get(column), param.value)) { - throw ormError( - 'ORM.RELATION_MUTATION_INVALID', - `Cannot write junction "${junction}": conflicting values for junction column "${column}"`, - { meta: { relation, junction, column } }, - ); - } - seen.set(column, param.value); - } - } -} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/junction-write.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/junction-write.ts new file mode 100644 index 000000000000..c29e105c1e3d --- /dev/null +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/junction-write.ts @@ -0,0 +1,23 @@ +import { ormError } from '../orm-errors'; + +export interface JunctionWrite { + readonly relation: string; + readonly junction: string; +} + +export function assertOneValuePerColumn( + values: Iterable, + { relation, junction }: JunctionWrite, +): void { + const seen = new Map(); + for (const [column, value] of values) { + if (seen.has(column) && !Object.is(seen.get(column), value)) { + throw ormError( + 'ORM.RELATION_MUTATION_INVALID', + `Cannot write junction "${junction}": conflicting values for junction column "${column}"`, + { meta: { relation, junction, column } }, + ); + } + seen.set(column, value); + } +} diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/nested-mutations.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/nested-mutations.ts index dfbe97dd1e0c..b24c1214389a 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/nested-mutations.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/nested-mutations.ts @@ -6,7 +6,6 @@ import { supportsDefaultInInsert } from '../collection-contract'; import { mapModelDataToStorageRow } from '../collection-runtime'; import { type CollectionTables, createStatementTables } from '../collection-tables'; import { shorthandToWhereExpr } from '../filters'; -import { ormError } from '../orm-errors'; import { deleteAst, groupRowsByColumnSignature, @@ -14,7 +13,8 @@ import { insertAst, projectTableColumn, updateAst, -} from '../query-plan-mutations'; +} from '../mutation-statements'; +import { ormError } from '../orm-errors'; import { collectionSelectAst } from '../query-plan-select'; import { assertJunctionMetadataShape, @@ -297,7 +297,12 @@ function addJunctionOwned( } for (const criterion of criteria) { const target = addFindAndAssert(t, related, relation, 'disconnect', criterion, fromTarget); - const unlink = graph.add(new Delete(deleteAst(contract, junction, undefined))); + const unlink = graph.add( + new Delete(deleteAst(contract, junction, undefined), { + relation: relation.relationName, + junction: through.table, + }), + ); graph.addFilterDataEdge(parent, unlink, fromParent); graph.addFilterDataEdge(target, unlink, fromTarget); } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/payload-data.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/payload-data.ts index 2560e898279c..42e949a7f3fc 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/payload-data.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/payload-data.ts @@ -2,7 +2,7 @@ import { ParamRef } from '@internal/sql-relational-core/ast'; import { ifDefined } from '@internal/utils/defined'; import { type ColumnPair, DataEdge, type NodeId, type StorageRow } from './edge'; -export type Payload = Readonly>; +type Payload = Readonly>; export class PayloadData extends DataEdge { constructor(from: NodeId, columns: readonly ColumnPair[]) { diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts index 644200ec9219..958fc4bb79a0 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/run-graph.ts @@ -7,8 +7,8 @@ import { blindCast } from '@internal/utils/casts'; import { getColumnsReadOnTable } from '../collection-contract'; import { consumeCollectionRows, type DescribeCollectionRowsOptions } from '../collection-dispatch'; import { mapResultRows, stripHiddenMappedFields } from '../collection-runtime'; +import { returningProjection } from '../mutation-statements'; import { buildOrmQueryPlan, deriveParamsFromAst } from '../query-plan-meta'; -import { returningProjection } from '../query-plan-mutations'; import { queryPlanRows } from '../query-plan-rows'; import type { CollectionContext } from '../types'; import type { Edge, NodeId, StorageRow } from './edge'; diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts b/packages/3-extensions/sql-orm-client/src/mutation-statements.ts similarity index 77% rename from packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts rename to packages/3-extensions/sql-orm-client/src/mutation-statements.ts index 9403f68101ab..a92d43e05c77 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-statements.ts @@ -15,22 +15,16 @@ import { UpdateAst, } from '@internal/sql-relational-core/ast'; import { codecRefForStorageColumn } from '@internal/sql-relational-core/codec-descriptor-registry'; -import type { SqlQueryPlan } from '@internal/sql-relational-core/plan'; import { ifDefined } from '@internal/utils/defined'; import { InternalError } from '@internal/utils/internal-error'; import { getColumnsReadOnTable, resolvePrimaryKeyColumns } from './collection-contract'; import type { CollectionTables } from './collection-tables'; import { ormError } from './orm-errors'; -import { buildOrmQueryPlan, deriveParamsFromAst } from './query-plan-meta'; import { buildPrimaryKeyJoinOn } from './query-plan-source'; import { storageTableForContract } from './storage-resolution'; -import { type AliasedTable, createTableScope } from './table-scope'; +import type { AliasedTable } from './table-scope'; import { combineWhereExprs } from './where-utils'; -function aliasTarget(namespaceId: string, tableName: string): AliasedTable { - return createTableScope().aliasTable({ namespaceId, tableName }); -} - export function projectTableColumn( contract: Contract, target: AliasedTable, @@ -184,35 +178,6 @@ export function insertAst( .withOnConflict(conflictSkipClause(target, conflictSkip)); } -export function compileInsertReturning( - contract: Contract, - namespaceId: string, - modelName: string, - tableName: string, - rows: readonly Record[], - returningColumns: readonly string[] | undefined, - conflictSkip?: InsertConflictSkip, -): SqlQueryPlan> { - const target = aliasTarget(namespaceId, tableName); - const ast = insertAst(contract, target, rows, conflictSkip).withReturning( - returningProjection(contract, modelName, target, returningColumns), - ); - const { params } = deriveParamsFromAst(ast); - return buildOrmQueryPlan(contract, ast, params); -} - -export function compileInsertCount( - contract: Contract, - namespaceId: string, - tableName: string, - rows: readonly Record[], - conflictSkip?: InsertConflictSkip, -): SqlQueryPlan> { - const ast = insertAst(contract, aliasTarget(namespaceId, tableName), rows, conflictSkip); - const { params } = deriveParamsFromAst(ast); - return buildOrmQueryPlan(contract, ast, params); -} - function stripUndefinedValues(row: Record): Record { const result: Record = {}; for (const [key, value] of Object.entries(row)) { @@ -336,43 +301,3 @@ export function deleteAst( ): DeleteAst { return DeleteAst.from(tables.root.tableSource(contract)).withWhere(where); } - -export function compileUpdateReturning( - contract: Contract, - modelName: string, - tables: CollectionTables, - setValues: Record, - filters: readonly AnyExpression[], - returningColumns: readonly string[] | undefined, -): SqlQueryPlan> { - const ast = updateAst(contract, tables, setValues, combineWhereExprs(filters)).withReturning( - returningProjection(contract, modelName, tables.root, returningColumns), - ); - const { params } = deriveParamsFromAst(ast); - return buildOrmQueryPlan(contract, ast, params); -} - -export function compileUpdateCount( - contract: Contract, - tables: CollectionTables, - setValues: Record, - filters: readonly AnyExpression[], - variantValue?: string | undefined, -): SqlQueryPlan> { - const where = countMutationWhere(contract, tables, filters, variantValue); - const ast = updateAst(contract, tables, setValues, where); - const { params } = deriveParamsFromAst(ast); - return buildOrmQueryPlan(contract, ast, params); -} - -export function compileDeleteCount( - contract: Contract, - tables: CollectionTables, - filters: readonly AnyExpression[], - variantValue?: string | undefined, -): SqlQueryPlan> { - const where = countMutationWhere(contract, tables, filters, variantValue); - const ast = deleteAst(contract, tables, where); - const { params } = deriveParamsFromAst(ast); - return buildOrmQueryPlan(contract, ast, params); -} diff --git a/packages/3-extensions/sql-orm-client/src/query-plan.ts b/packages/3-extensions/sql-orm-client/src/query-plan.ts index 74650de061e6..8dafb1cad06c 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan.ts @@ -1,13 +1,6 @@ +export type { InsertConflictSkip } from './mutation-statements'; export { compileAggregate, compileGroupedAggregate, } from './query-plan-aggregate'; -export type { InsertConflictSkip } from './query-plan-mutations'; -export { - compileDeleteCount, - compileInsertCount, - compileInsertReturning, - compileUpdateCount, - compileUpdateReturning, -} from './query-plan-mutations'; export { compileSelect, compileSelectWithIncludes } from './query-plan-select'; diff --git a/packages/3-extensions/sql-orm-client/src/relation-mutations.ts b/packages/3-extensions/sql-orm-client/src/relation-mutations.ts index 72cf30a070bb..4036bec49e98 100644 --- a/packages/3-extensions/sql-orm-client/src/relation-mutations.ts +++ b/packages/3-extensions/sql-orm-client/src/relation-mutations.ts @@ -3,8 +3,6 @@ import type { SqlStorage } from '@internal/sql-contract/types'; import { InternalError } from '@internal/utils/internal-error'; import { columnOfContractField, - fieldOfColumn, - getModelColumnFields, resolveModelRelations, resolveModelTableName, resolveRelationTargetColumns, @@ -22,7 +20,7 @@ import type { RelationMutation, } from './types'; -export interface JunctionThrough { +interface JunctionThrough { readonly table: string; readonly namespaceId: string; readonly parentColumns: readonly string[]; @@ -55,7 +53,7 @@ export interface ParsedRelationMutation { readonly mutation: RelationMutation, string>; } -export interface ParsedMutationInput { +interface ParsedMutationInput { readonly scalarData: Record; readonly relationMutations: readonly ParsedRelationMutation[]; } @@ -118,7 +116,7 @@ export function parseMutationInput( }; } -export interface JunctionParsedRelationMutation extends ParsedRelationMutation { +interface JunctionParsedRelationMutation extends ParsedRelationMutation { readonly relation: JunctionRelationDefinition; } @@ -198,7 +196,7 @@ function assertJunctionMetadataLength( ); } -export function assertJunctionParentMetadataLength(relation: JunctionRelationDefinition): void { +function assertJunctionParentMetadataLength(relation: JunctionRelationDefinition): void { assertJunctionMetadataLength( relation, 'parentColumns', @@ -208,7 +206,7 @@ export function assertJunctionParentMetadataLength(relation: JunctionRelationDef ); } -export function assertJunctionTargetMetadataLength(relation: JunctionRelationDefinition): void { +function assertJunctionTargetMetadataLength(relation: JunctionRelationDefinition): void { assertJunctionMetadataLength( relation, 'childColumns', @@ -225,7 +223,7 @@ export function assertJunctionMetadataShape(relation: JunctionRelationDefinition const relationDefsCache = new WeakMap>(); -export function getRelationDefinitions( +function getRelationDefinitions( contract: Contract, namespaceId: string, modelName: string, @@ -270,19 +268,6 @@ export function getRelationDefinitions( return definitions; } -export function toFieldName( - contract: Contract, - namespaceId: string, - modelName: string, - columnName: string, -): string { - return fieldOfColumn( - getModelColumnFields(contract, namespaceId, modelName), - modelName, - columnName, - ); -} - export function hasRelationFields( contract: Contract, namespaceId: string, diff --git a/packages/3-extensions/sql-orm-client/test/model-columns.test.ts b/packages/3-extensions/sql-orm-client/test/model-columns.test.ts index c2d2f257df77..e97516d5d006 100644 --- a/packages/3-extensions/sql-orm-client/test/model-columns.test.ts +++ b/packages/3-extensions/sql-orm-client/test/model-columns.test.ts @@ -15,7 +15,7 @@ import { getColumnsReadOnTable, getModelFieldColumns, } from '../src/collection-contract'; -import { compileInsertReturning, compileUpdateReturning } from '../src/query-plan-mutations'; +import { insertAst, returningProjection, updateAst } from '../src/mutation-statements'; import { compileSelect, compileSelectWithIncludes } from '../src/query-plan-select'; import type { CollectionState } from '../src/types'; import { field, model, rel } from './contract-builder'; @@ -208,24 +208,17 @@ describe('a query with no select reads only the model columns', () => { }); it('in RETURNING', () => { - const insert = compileInsertReturning( + const posts = tablesForTable(contract, 'posts'); + const returning = returningProjection(contract, 'Post', posts.root, undefined); + const insert = insertAst( contract, - 'public', - 'Post', - 'posts', + posts.root, [{ id: 1, title: 'A', user_id: 1 }], undefined, - ); - const update = compileUpdateReturning( - contract, - 'Post', - tablesForTable(contract, 'posts'), - { title: 'B' }, - [], - undefined, - ); - for (const plan of [insert, update]) { - expect(returningOf(plan.ast)).toEqual(['id', 'title', 'user_id']); + ).withReturning(returning); + const update = updateAst(contract, posts, { title: 'B' }, undefined).withReturning(returning); + for (const ast of [insert, update]) { + expect(returningOf(ast)).toEqual(['id', 'title', 'user_id']); } }); }); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/delete.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/delete.test.ts new file mode 100644 index 000000000000..0c31938af04b --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/delete.test.ts @@ -0,0 +1,39 @@ +import { BinaryExpr, ColumnRef, ParamRef } from '@internal/sql-relational-core/ast'; +import { describe, expect, it } from 'vitest'; +import { Delete } from '../../src/mutation-graph/delete'; +import { deletePosts, recordingRun } from './statements'; + +const userIdIs = (value: number) => + BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ParamRef.of(value, { name: 'user_id' })); +const linkOf = (where: Delete) => new Delete(where.ast, { relation: 'posts', junction: 'posts' }); + +describe('Delete of a junction row', () => { + it('refuses two edges that give one junction column different values, before running', () => { + const run = recordingRun([], 1); + + expect(() => + linkOf(deletePosts()).execute( + { filter: [[userIdIs(7)], [userIdIs(8)]], state: null, requires: null }, + run, + ), + ).toThrow( + expect.objectContaining({ + code: 'ORM.RELATION_MUTATION_INVALID', + message: 'Cannot write junction "posts": conflicting values for junction column "user_id"', + meta: { relation: 'posts', junction: 'posts', column: 'user_id' }, + }), + ); + expect(run).toMatchObject({ queried: [], executed: [] }); + }); + + it('runs when the edges give one junction column the same value', async () => { + const run = recordingRun([], 1); + + expect( + await linkOf(deletePosts()).execute( + { filter: [[userIdIs(7)], [userIdIs(7)]], state: null, requires: null }, + run, + ), + ).toBe(1); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/statements.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/statements.ts index 39065882be6f..f099c29ec3fa 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/statements.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/statements.ts @@ -21,7 +21,7 @@ import { State } from '../../src/mutation-graph/state'; import { StatementNode } from '../../src/mutation-graph/statement-node'; import { UnionAll } from '../../src/mutation-graph/union-all'; import { Update } from '../../src/mutation-graph/update'; -import { deleteAst, projectTableColumns, updateAst } from '../../src/query-plan-mutations'; +import { deleteAst, projectTableColumns, updateAst } from '../../src/mutation-statements'; import { collectionSelectAst, collectionSelectWithIncludesAst } from '../../src/query-plan-select'; import type { AliasedTable } from '../../src/table-scope'; import { diff --git a/packages/3-extensions/sql-orm-client/test/mutation-statements.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-statements.test.ts new file mode 100644 index 000000000000..6b6052c954a6 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-statements.test.ts @@ -0,0 +1,227 @@ +import { BinaryExpr, ColumnRef, type InsertAst, ParamRef } from '@internal/sql-relational-core/ast'; +import { describe, expect, it } from 'vitest'; +import { createStatementTables } from '../src/collection-tables'; +import { + countMutationWhere, + deleteAst, + groupRowsByColumnSignature, + type InsertConflictSkip, + insertAst, + returningProjection, + updateAst, +} from '../src/mutation-statements'; +import { combineWhereExprs } from '../src/where-utils'; +import { withReturningCapability } from './collection-fixtures'; +import { getTestContract, tablesForTable } from './helpers'; +import { unboundTables } from './unbound-tables'; + +const usersTable = createStatementTables({ namespaceId: 'public', tableName: 'users' }).root; + +function insertUsers( + contract: ReturnType, + rows: readonly Record[], + conflict?: InsertConflictSkip, +): InsertAst { + return insertAst(contract, usersTable, rows, conflict); +} + +function usersColParam( + contract: ReturnType, + column: string, + value: unknown, +): ParamRef { + const columns = unboundTables(contract.storage)['users']?.columns as + | Record + | undefined; + const columnMeta = columns?.[column]; + return ParamRef.of(value, { + name: column, + codec: { codecId: columnMeta?.codecId ?? 'unknown' }, + }); +} + +describe('mutation statements', () => { + it('batches insert rows with stable column order and DEFAULT cells', () => { + const contract = withReturningCapability(getTestContract()); + const plan = insertUsers(contract, [ + { id: 10, name: 'Alice', email: 'alice@example.com' }, + { id: 11, name: 'Bob', email: 'bob@example.com', invited_by_id: 10 }, + ]).withReturning(returningProjection(contract, 'User', usersTable, undefined)); + + expect(plan.rows).toHaveLength(2); + expect(plan.rows[0]).toMatchObject({ + id: usersColParam(contract, 'id', 10), + name: usersColParam(contract, 'name', 'Alice'), + email: usersColParam(contract, 'email', 'alice@example.com'), + }); + expect(plan.rows[0]?.['invited_by_id']?.kind).toBe('default-value'); + expect(plan.rows[1]).toMatchObject({ + id: usersColParam(contract, 'id', 11), + name: usersColParam(contract, 'name', 'Bob'), + email: usersColParam(contract, 'email', 'bob@example.com'), + invited_by_id: usersColParam(contract, 'invited_by_id', 10), + }); + expect(plan.returning?.map((item) => item.alias)).toEqual([ + 'address', + 'email', + 'id', + 'invited_by_id', + 'name', + ]); + expect(plan.returning?.every((item) => item.expr.kind === 'column-ref')).toBe(true); + }); + + it('keeps explicit empty rows for all-default batch inserts', () => { + const contract = getTestContract(); + const plan = insertUsers(contract, [{}, {}]); + + expect(plan.rows).toEqual([{}, {}]); + }); + + it('attaches a targetless DO NOTHING clause when asked to skip conflicts', () => { + const contract = getTestContract(); + const plan = insertUsers(contract, [{ id: 10, name: 'Alice', email: 'alice@example.com' }], { + columns: [], + }); + + expect(plan.onConflict?.columns).toEqual([]); + expect(plan.onConflict?.action.kind).toBe('do-nothing'); + }); + + it('targets the named columns when asked to skip conflicts on them', () => { + const contract = getTestContract(); + const plan = insertUsers(contract, [{ id: 10, name: 'Alice', email: 'alice@example.com' }], { + columns: ['email'], + }); + + expect(plan.onConflict?.columns).toEqual([ColumnRef.of('users', 'email')]); + expect(plan.onConflict?.action.kind).toBe('do-nothing'); + }); + + it('leaves the clause off when not asked to skip conflicts', () => { + const contract = getTestContract(); + const plan = insertUsers(contract, [{ id: 10, name: 'Alice', email: 'alice@example.com' }]); + + expect(plan.onConflict).toBeUndefined(); + }); + + it('keeps the skip clause alongside RETURNING', () => { + const contract = withReturningCapability(getTestContract()); + const plan = insertUsers(contract, [{ id: 10, name: 'Alice', email: 'alice@example.com' }], { + columns: ['email'], + }).withReturning(returningProjection(contract, 'User', usersTable, ['id'])); + + expect(plan.onConflict?.columns).toEqual([ColumnRef.of('users', 'email')]); + expect(plan.onConflict?.action.kind).toBe('do-nothing'); + expect(plan.returning).toHaveLength(1); + }); + + it('rejects an insert with no rows', () => { + const contract = getTestContract(); + + expect(() => insertUsers(contract, [])).toThrow('at least one row'); + }); + + describe('groupRowsByColumnSignature()', () => { + const alice = { id: 1, name: 'Alice', email: 'a@a.com' }; + const bob = { id: 2, name: 'Bob', email: 'b@b.com' }; + const invitedCarol = { id: 3, name: 'Carol', email: 'c@c.com', invited_by_id: 1 }; + const invitedDiana = { id: 4, name: 'Diana', email: 'd@d.com', invited_by_id: 2 }; + + it('keeps rows that set the same columns in one group', () => { + expect(groupRowsByColumnSignature([alice, bob])).toEqual([[alice, bob]]); + }); + + it('starts a new group where the set of columns changes, in input order', () => { + expect(groupRowsByColumnSignature([alice, invitedCarol, bob])).toEqual([ + [alice], + [invitedCarol], + [bob], + ]); + }); + + it('groups adjacent rows with identical columns together', () => { + expect(groupRowsByColumnSignature([alice, bob, invitedCarol, invitedDiana])).toEqual([ + [alice, bob], + [invitedCarol, invitedDiana], + ]); + }); + + it('treats undefined values as absent columns', () => { + expect(groupRowsByColumnSignature([{ ...alice, invited_by_id: undefined }, bob])).toEqual([ + [alice, bob], + ]); + }); + }); + + it('builds an update and a delete without where when there are no filters', () => { + const contract = getTestContract(); + const tables = tablesForTable(contract, 'users', 'public'); + const where = countMutationWhere(contract, tables, [], undefined); + + expect(updateAst(contract, tables, { name: 'Alice' }, where).where).toBeUndefined(); + expect(deleteAst(contract, tables, where).where).toBeUndefined(); + }); + + it('keeps the filters as the where of an update', () => { + const contract = getTestContract(); + const tables = tablesForTable(contract, 'users', 'public'); + const byId = BinaryExpr.eq( + ColumnRef.of('users', 'id'), + ParamRef.of(7, { name: 'id', codec: { codecId: 'pg/int4@1' } }), + ); + + expect(updateAst(contract, tables, { name: 'Alice' }, combineWhereExprs([byId])).where).toEqual( + byId, + ); + expect( + updateAst( + contract, + tables, + { name: 'Bob' }, + countMutationWhere(contract, tables, [byId], undefined), + ).where, + ).toEqual(byId); + }); + + describe('table/column resolution errors', () => { + it('refuses an update of an unknown table', () => { + const contract = getTestContract(); + expect(() => + updateAst( + contract, + tablesForTable(contract, 'missing_table', 'public'), + { name: 'X' }, + undefined, + ), + ).toThrowError(/Unknown table "missing_table"/); + }); + + it('refuses an unknown column in an update', () => { + const contract = getTestContract(); + expect(() => + updateAst( + contract, + tablesForTable(contract, 'users', 'public'), + { not_a_real_column: 'X' }, + undefined, + ), + ).toThrowError(/Unknown column "not_a_real_column" in table "users"/); + }); + + it('refuses an insert into an unknown table', () => { + const contract = getTestContract(); + const missing = createStatementTables({ namespaceId: 'public', tableName: 'missing_table' }); + expect(() => insertAst(contract, missing.root, [{ id: 1 }], undefined)).toThrowError( + /Unknown table "missing_table"/, + ); + }); + + it('refuses an unknown column on an insert row', () => { + const contract = getTestContract(); + expect(() => insertUsers(contract, [{ id: 1, not_a_real_column: 'X' }])).toThrowError( + /Unknown column "not_a_real_column" in table "users"/, + ); + }); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/namespace-qualification.test.ts b/packages/3-extensions/sql-orm-client/test/namespace-qualification.test.ts index 437723414206..c7ffaea64358 100644 --- a/packages/3-extensions/sql-orm-client/test/namespace-qualification.test.ts +++ b/packages/3-extensions/sql-orm-client/test/namespace-qualification.test.ts @@ -9,7 +9,10 @@ import { describe, expect, it } from 'vitest'; import { PostgresSchema } from '../../../3-targets/postgres/1-target/src/core/postgres-schema'; import type { PostgresContract } from '../../../3-targets/postgres/2-adapter/src/core/types'; import { SqliteDatabase } from '../../../3-targets/sqlite/1-target/src/core/sqlite-unbound-database'; -import { compileDeleteCount, compileInsertReturning, compileSelect } from '../src/query-plan'; +import type { CollectionTables } from '../src/collection-tables'; +import { deleteAst, insertAst, returningProjection } from '../src/mutation-statements'; +import { compileSelect } from '../src/query-plan'; +import { deriveParamsFromAst } from '../src/query-plan-meta'; import { emptyTableState, tablesForTable } from './helpers'; const PUBLIC_NAMESPACE_ID = 'public'; @@ -57,6 +60,15 @@ const publicPostgresContract = { }), } as unknown as PostgresContract; +function insertUsers(users: CollectionTables) { + return insertAst( + publicPostgresContract, + users.root, + [{ id: 1, email: 'a@example.com' }], + undefined, + ).withReturning(returningProjection(publicPostgresContract, 'User', users.root, ['id', 'email'])); +} + describe('ORM namespace qualification', () => { it('throws on an absent namespace rather than silently picking a present one', () => { const contract = { @@ -84,22 +96,11 @@ describe('ORM namespace qualification', () => { ); expect((selectPlan.ast as { from: TableSource }).from.namespaceId).toBe('public'); - const insertPlan = compileInsertReturning( - publicPostgresContract, - 'public', - 'User', - 'users', - [{ id: 1, email: 'a@example.com' }], - ['id', 'email'], - ); - expect((insertPlan.ast as { table: TableSource }).table.namespaceId).toBe('public'); + const users = tablesForTable(publicPostgresContract, 'users', 'public'); + const insert = insertUsers(users); + expect(insert.table.namespaceId).toBe('public'); - const deletePlan = compileDeleteCount( - publicPostgresContract, - tablesForTable(publicPostgresContract, 'users', 'public'), - [], - ); - expect((deletePlan.ast as { table: TableSource }).table.namespaceId).toBe('public'); + expect(deleteAst(publicPostgresContract, users, undefined).table.namespaceId).toBe('public'); }); it('renders schema-qualified SQL for Postgres via the adapter lower path', () => { @@ -114,17 +115,10 @@ describe('ORM namespace qualification', () => { }).sql; expect(selectSql).toContain('FROM "public"."users"'); - const insertPlan = compileInsertReturning( - publicPostgresContract, - 'public', - 'User', - 'users', - [{ id: 1, email: 'a@example.com' }], - ['id', 'email'], - ); - const insertSql = adapter.lower(insertPlan.ast, { + const insert = insertUsers(tablesForTable(publicPostgresContract, 'users', 'public')); + const insertSql = adapter.lower(insert, { contract: publicPostgresContract, - params: insertPlan.params, + params: deriveParamsFromAst(insert).params, }).sql; expect(insertSql).toContain('INSERT INTO "public"."users"'); }); diff --git a/packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts b/packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts deleted file mode 100644 index aeaed64a8b41..000000000000 --- a/packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { - BinaryExpr, - ColumnRef, - type DeleteAst, - type InsertAst, - ParamRef, - ParamRef as ParamRefClass, - type UpdateAst, -} from '@internal/sql-relational-core/ast'; -import { describe, expect, it } from 'vitest'; -import { - compileDeleteCount, - compileInsertCount, - compileInsertReturning, - compileUpdateCount, - compileUpdateReturning, -} from '../src/query-plan'; -import { groupRowsByColumnSignature } from '../src/query-plan-mutations'; -import { withReturningCapability } from './collection-fixtures'; -import { getTestContract, tablesForTable } from './helpers'; -import { unboundTables } from './unbound-tables'; - -function assertInsertAst(ast: unknown): asserts ast is InsertAst { - expect((ast as { kind: string }).kind).toBe('insert'); -} - -function usersColParam( - contract: ReturnType, - column: string, - value: unknown, -): ParamRef { - const columns = unboundTables(contract.storage)['users']?.columns as - | Record - | undefined; - const columnMeta = columns?.[column]; - return ParamRef.of(value, { - name: column, - codec: { codecId: columnMeta?.codecId ?? 'unknown' }, - }); -} - -describe('query plan mutations', () => { - it('compileInsertReturning() batches rows with stable column order and DEFAULT cells', () => { - const contract = withReturningCapability(getTestContract()); - const plan = compileInsertReturning( - contract, - 'public', - 'User', - 'users', - [ - { id: 10, name: 'Alice', email: 'alice@example.com' }, - { id: 11, name: 'Bob', email: 'bob@example.com', invited_by_id: 10 }, - ], - undefined, - ); - - assertInsertAst(plan.ast); - expect(plan.params).toEqual([ - 10, - 'Alice', - 'alice@example.com', - 11, - 'Bob', - 'bob@example.com', - 10, - ]); - expect(plan.ast.rows).toHaveLength(2); - expect(plan.ast.rows[0]).toMatchObject({ - id: usersColParam(contract, 'id', 10), - name: usersColParam(contract, 'name', 'Alice'), - email: usersColParam(contract, 'email', 'alice@example.com'), - }); - expect(plan.ast.rows[0]?.['invited_by_id']?.kind).toBe('default-value'); - expect(plan.ast.rows[1]).toMatchObject({ - id: usersColParam(contract, 'id', 11), - name: usersColParam(contract, 'name', 'Bob'), - email: usersColParam(contract, 'email', 'bob@example.com'), - invited_by_id: usersColParam(contract, 'invited_by_id', 10), - }); - expect(plan.ast.returning?.map((item) => item.alias)).toEqual([ - 'address', - 'email', - 'id', - 'invited_by_id', - 'name', - ]); - expect(plan.ast.returning?.every((item) => item.expr.kind === 'column-ref')).toBe(true); - }); - - it('compileInsertCount() keeps explicit empty rows for all-default batch inserts', () => { - const contract = getTestContract(); - const plan = compileInsertCount(contract, 'public', 'users', [{}, {}]); - - assertInsertAst(plan.ast); - expect(plan.params).toEqual([]); - expect(plan.ast.rows).toEqual([{}, {}]); - }); - - it('compileInsertCount() attaches a targetless DO NOTHING clause when asked to skip conflicts', () => { - const contract = getTestContract(); - const plan = compileInsertCount( - contract, - 'public', - 'users', - [{ id: 10, name: 'Alice', email: 'alice@example.com' }], - { columns: [] }, - ); - - assertInsertAst(plan.ast); - expect(plan.ast.onConflict?.columns).toEqual([]); - expect(plan.ast.onConflict?.action.kind).toBe('do-nothing'); - }); - - it('compileInsertCount() targets the named columns when asked to skip conflicts on them', () => { - const contract = getTestContract(); - const plan = compileInsertCount( - contract, - 'public', - 'users', - [{ id: 10, name: 'Alice', email: 'alice@example.com' }], - { columns: ['email'] }, - ); - - assertInsertAst(plan.ast); - expect(plan.ast.onConflict?.columns).toEqual([ColumnRef.of('users', 'email')]); - expect(plan.ast.onConflict?.action.kind).toBe('do-nothing'); - }); - - it('compileInsertCount() leaves the clause off when not asked to skip conflicts', () => { - const contract = getTestContract(); - const plan = compileInsertCount(contract, 'public', 'users', [ - { id: 10, name: 'Alice', email: 'alice@example.com' }, - ]); - - assertInsertAst(plan.ast); - expect(plan.ast.onConflict).toBeUndefined(); - }); - - it('compileInsertReturning() attaches the skip clause alongside RETURNING', () => { - const contract = withReturningCapability(getTestContract()); - const plan = compileInsertReturning( - contract, - 'public', - 'User', - 'users', - [{ id: 10, name: 'Alice', email: 'alice@example.com' }], - ['id'], - { columns: ['email'] }, - ); - - assertInsertAst(plan.ast); - expect(plan.ast.onConflict?.columns).toEqual([ColumnRef.of('users', 'email')]); - expect(plan.ast.onConflict?.action.kind).toBe('do-nothing'); - expect(plan.ast.returning).toHaveLength(1); - }); - - it('compileInsertReturning() rejects empty rows array', () => { - const contract = withReturningCapability(getTestContract()); - - expect(() => - compileInsertReturning(contract, 'public', 'User', 'users', [], undefined), - ).toThrow('at least one row'); - }); - - it('compileInsertCount() rejects empty rows array', () => { - const contract = getTestContract(); - - expect(() => compileInsertCount(contract, 'public', 'users', [])).toThrow('at least one row'); - }); - - describe('groupRowsByColumnSignature()', () => { - const alice = { id: 1, name: 'Alice', email: 'a@a.com' }; - const bob = { id: 2, name: 'Bob', email: 'b@b.com' }; - const invitedCarol = { id: 3, name: 'Carol', email: 'c@c.com', invited_by_id: 1 }; - const invitedDiana = { id: 4, name: 'Diana', email: 'd@d.com', invited_by_id: 2 }; - - it('keeps rows that set the same columns in one group', () => { - expect(groupRowsByColumnSignature([alice, bob])).toEqual([[alice, bob]]); - }); - - it('starts a new group where the set of columns changes, in input order', () => { - expect(groupRowsByColumnSignature([alice, invitedCarol, bob])).toEqual([ - [alice], - [invitedCarol], - [bob], - ]); - }); - - it('groups adjacent rows with identical columns together', () => { - expect(groupRowsByColumnSignature([alice, bob, invitedCarol, invitedDiana])).toEqual([ - [alice, bob], - [invitedCarol, invitedDiana], - ]); - }); - - it('treats undefined values as absent columns', () => { - expect(groupRowsByColumnSignature([{ ...alice, invited_by_id: undefined }, bob])).toEqual([ - [alice, bob], - ]); - }); - }); - - it('compileUpdateCount() and compileDeleteCount() omit WHERE when filters are empty', () => { - const contract = getTestContract(); - - const updatePlan = compileUpdateCount( - contract, - tablesForTable(contract, 'users', 'public'), - { name: 'Alice' }, - [], - ); - expect(updatePlan.ast.kind).toBe('update'); - expect((updatePlan.ast as UpdateAst).where).toBeUndefined(); - expect(updatePlan.params).toEqual(['Alice']); - - const deletePlan = compileDeleteCount( - contract, - tablesForTable(contract, 'users', 'public'), - [], - ); - expect(deletePlan.ast.kind).toBe('delete'); - expect((deletePlan.ast as DeleteAst).where).toBeUndefined(); - expect(deletePlan.params).toEqual([]); - }); - - describe('UPDATE / DELETE WHERE preservation', () => { - function eqOnUserId(value: number) { - return BinaryExpr.eq( - ColumnRef.of('users', 'id'), - ParamRefClass.of(value, { - name: 'id', - codec: { codecId: 'pg/int4@1' }, - }), - ); - } - - it('compileUpdateReturning() preserves WHERE when filters are present', () => { - const contract = withReturningCapability(getTestContract()); - const plan = compileUpdateReturning( - contract, - 'User', - tablesForTable(contract, 'users', 'public'), - { name: 'Alice' }, - [eqOnUserId(7)], - undefined, - ); - expect(plan.ast.kind).toBe('update'); - expect((plan.ast as UpdateAst).where).toBeDefined(); - expect(plan.params).toEqual(['Alice', 7]); - }); - - it('compileUpdateCount() preserves WHERE when filters are present', () => { - const contract = getTestContract(); - const plan = compileUpdateCount( - contract, - tablesForTable(contract, 'users', 'public'), - { name: 'Bob' }, - [eqOnUserId(9)], - ); - expect((plan.ast as UpdateAst).where).toBeDefined(); - expect(plan.params).toEqual(['Bob', 9]); - }); - }); - - describe('table/column resolution errors', () => { - it('compileUpdateCount() rejects an unknown table', () => { - const contract = getTestContract(); - expect(() => - compileUpdateCount( - contract, - tablesForTable(contract, 'missing_table', 'public'), - { name: 'X' }, - [], - ), - ).toThrowError(/Unknown table "missing_table"/); - }); - - it('compileUpdateCount() rejects an unknown column for the table', () => { - const contract = getTestContract(); - expect(() => - compileUpdateCount( - contract, - tablesForTable(contract, 'users', 'public'), - { not_a_real_column: 'X' }, - [], - ), - ).toThrowError(/Unknown column "not_a_real_column" in table "users"/); - }); - - it('compileInsertCount() rejects an unknown table', () => { - const contract = getTestContract(); - expect(() => - compileInsertCount(contract, 'public', 'missing_table', [{ id: 1 }]), - ).toThrowError(/Unknown table "missing_table"/); - }); - - it('compileInsertCount() rejects an unknown column on an insert row', () => { - const contract = getTestContract(); - expect(() => - compileInsertCount(contract, 'public', 'users', [{ id: 1, not_a_real_column: 'X' }]), - ).toThrowError(/Unknown column "not_a_real_column" in table "users"/); - }); - }); -}); diff --git a/upgrade-instructions/pending/mutation-graph-nested/extension/instructions.md b/upgrade-instructions/pending/mutation-graph-nested/extension/instructions.md new file mode 100644 index 000000000000..8e1cc0ebe668 --- /dev/null +++ b/upgrade-instructions/pending/mutation-graph-nested/extension/instructions.md @@ -0,0 +1,3 @@ +--- +changes: [] +--- From c8e4bf3843ee68acafe1a5f4ac6a2305b469e620 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 15 Oct 2026 16:47:44 +0000 Subject: [PATCH 08/14] test(sql-orm-client): the SQLite nested-write fixture declares tag names unique Main now types a connect criterion by the target's unique fields; the fixture's tags table already has name unique, and the model now says so. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- .../test/sql-orm-client/nested-create-graph-sqlite.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts b/test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts index 81fcce4442cb..4258765b98f6 100644 --- a/test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts +++ b/test/integration/test/sql-orm-client/nested-create-graph-sqlite.test.ts @@ -27,7 +27,7 @@ const UserTag = model('UserTag', { })) .sql({ table: 'user_tags' }); const Tag = model('Tag', { - fields: { id: field.column(integerColumn).id(), name: field.column(textColumn) }, + fields: { id: field.column(integerColumn).id(), name: field.column(textColumn).unique() }, }).sql({ table: 'tags' }); const UserBase = model('User', { fields: { id: field.column(integerColumn).id(), name: field.column(textColumn) }, From 62e8f5217825e413866df955a118eae66628fff3 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 16 Oct 2026 08:41:07 +0000 Subject: [PATCH 09/14] chore(sql-contract-ts): regenerate the contract JSON schema for non-empty link column lists Generated with pnpm schemas:generate after the validator required at least one entry in a relation's link column lists. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- .../contract-ts/schemas/data-contract-sql-v1.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/2-sql-family/2-authoring/contract-ts/schemas/data-contract-sql-v1.json b/packages/2-sql-family/2-authoring/contract-ts/schemas/data-contract-sql-v1.json index 0309a4adadd8..3d834e1cd072 100644 --- a/packages/2-sql-family/2-authoring/contract-ts/schemas/data-contract-sql-v1.json +++ b/packages/2-sql-family/2-authoring/contract-ts/schemas/data-contract-sql-v1.json @@ -283,12 +283,14 @@ "properties": { "localFields": { "type": "array", + "minItems": 1, "items": { "type": "string" } }, "targetFields": { "type": "array", + "minItems": 1, "items": { "type": "string" } @@ -331,12 +333,14 @@ "properties": { "localFields": { "type": "array", + "minItems": 1, "items": { "type": "string" } }, "targetFields": { "type": "array", + "minItems": 1, "items": { "type": "string" } @@ -376,12 +380,14 @@ "properties": { "localFields": { "type": "array", + "minItems": 1, "items": { "type": "string" } }, "targetFields": { "type": "array", + "minItems": 1, "items": { "type": "string" } @@ -395,6 +401,7 @@ "properties": { "childColumns": { "type": "array", + "minItems": 1, "items": { "type": "string" } @@ -404,6 +411,7 @@ }, "parentColumns": { "type": "array", + "minItems": 1, "items": { "type": "string" } @@ -413,6 +421,7 @@ }, "targetColumns": { "type": "array", + "minItems": 1, "items": { "type": "string" } From e32ffd6790ae814e2c58a9d5b1fb7ba0c9646fd0 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 3 Nov 2026 10:50:13 +0000 Subject: [PATCH 10/14] fix(sql-orm-client): changedBy follows the kind of write each foreign-key action makes A step that a delete reaches follows the referencing table's onDelete and a step an update reaches follows its onUpdate. cascade keeps the kind of the step it came from; setNull and setDefault make the referencing table's step an update, since its foreign-key column changes. A table is visited once per kind, so a table reached both as an update and as a delete is followed both ways; each changed table is still returned once. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- .../src/mutation-graph/changed-by.ts | 47 +++++++--- .../test/mutation-graph/cascade-contract.ts | 86 ++++++++++++++++++- .../test/mutation-graph/changed-by.test.ts | 14 +++ 3 files changed, 132 insertions(+), 15 deletions(-) diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts index 9e7687c363f9..7ff89c7c1f25 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts @@ -10,12 +10,6 @@ interface Referencing { readonly onUpdate: ReferentialAction | undefined; } -const changingActions: ReadonlySet = new Set([ - 'cascade', - 'setNull', - 'setDefault', -]); - const referencingByContract = new WeakMap< Contract, ReadonlyMap @@ -55,6 +49,13 @@ function referencingOf( return referencing; } +type ChangeKind = Exclude; + +interface Step { + readonly table: TableCoordinate; + readonly kind: ChangeKind; +} + export function changedBy( contract: Contract, write: WriteKind, @@ -65,16 +66,34 @@ export function changedBy( } const referencing = referencingOf(contract); const changed = new Map([[tableKey(table), table]]); - const pending = [table]; - for (let next = pending.shift(); next !== undefined; next = pending.shift()) { - for (const reference of referencing.get(tableKey(next)) ?? []) { - const action = write === 'delete' ? reference.onDelete : reference.onUpdate; - const key = tableKey(reference.table); - if (changingActions.has(action) && !changed.has(key)) { - changed.set(key, reference.table); - pending.push(reference.table); + const visited = new Set([stepKey({ table, kind: write })]); + const pending: Step[] = [{ table, kind: write }]; + for (let step = pending.shift(); step !== undefined; step = pending.shift()) { + for (const reference of referencing.get(tableKey(step.table)) ?? []) { + const next = nextStep(step.kind, reference); + if (next !== undefined && !visited.has(stepKey(next))) { + visited.add(stepKey(next)); + changed.set(tableKey(next.table), next.table); + pending.push(next); } } } return [...changed.values()]; } + +function nextStep(kind: ChangeKind, reference: Referencing): Step | undefined { + const action = kind === 'delete' ? reference.onDelete : reference.onUpdate; + switch (action) { + case 'cascade': + return { table: reference.table, kind }; + case 'setNull': + case 'setDefault': + return { table: reference.table, kind: 'update' }; + default: + return undefined; + } +} + +function stepKey(step: Step): string { + return JSON.stringify([step.kind, step.table.namespaceId, step.table.name]); +} diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/cascade-contract.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/cascade-contract.ts index 558d75d13ff7..b35b0cc35c7b 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/cascade-contract.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/cascade-contract.ts @@ -55,6 +55,90 @@ const Right = model('Right', { }, }); +const Order = model('Order', { fields: { id: field.column(int4Column).id() } }); + +const Shipment = model('Shipment', { + fields: { + id: field.column(int4Column).id(), + orderId: field.column(int4Column).optional().unique(), + }, + relations: { + order: rel + .belongsTo(Order, { from: 'orderId', to: 'id' }) + .sql({ fk: { onDelete: 'setNull', onUpdate: 'restrict' } }), + }, +}); + +const Label = model('Label', { + fields: { id: field.column(int4Column).id(), shipmentOrderId: field.column(int4Column) }, + relations: { + shipment: rel + .belongsTo(Shipment, { from: 'shipmentOrderId', to: 'orderId' }) + .sql({ fk: { onDelete: 'restrict', onUpdate: 'cascade' } }), + }, +}); + +const Root = model('Root', { fields: { id: field.column(int4Column).id() } }); + +const Mid = model('Mid', { + fields: { id: field.column(int4Column).id(), rootId: field.column(int4Column) }, + relations: { + root: rel + .belongsTo(Root, { from: 'rootId', to: 'id' }) + .sql({ fk: { onDelete: 'cascade', onUpdate: 'restrict' } }), + }, +}); + +const Leaf = model('Leaf', { + fields: { + id: field.column(int4Column).id(), + rootId: field.column(int4Column).optional(), + midId: field.column(int4Column), + }, + relations: { + root: rel + .belongsTo(Root, { from: 'rootId', to: 'id' }) + .sql({ fk: { onDelete: 'setNull', onUpdate: 'restrict' } }), + mid: rel + .belongsTo(Mid, { from: 'midId', to: 'id' }) + .sql({ fk: { onDelete: 'cascade', onUpdate: 'restrict' } }), + }, +}); + +const DeletedWithLeaf = model('DeletedWithLeaf', { + fields: { id: field.column(int4Column).id(), leafId: field.column(int4Column) }, + relations: { + leaf: rel + .belongsTo(Leaf, { from: 'leafId', to: 'id' }) + .sql({ fk: { onDelete: 'cascade', onUpdate: 'restrict' } }), + }, +}); + +const UpdatedWithLeaf = model('UpdatedWithLeaf', { + fields: { id: field.column(int4Column).id(), leafId: field.column(int4Column) }, + relations: { + leaf: rel + .belongsTo(Leaf, { from: 'leafId', to: 'id' }) + .sql({ fk: { onDelete: 'restrict', onUpdate: 'cascade' } }), + }, +}); + export const cascadeContract = defineContract({ - models: { Org, Team, Member, Badge, Note, Left, Right }, + models: { + Org, + Team, + Member, + Badge, + Note, + Left, + Right, + Order, + Shipment, + Label, + Root, + Mid, + Leaf, + DeletedWithLeaf, + UpdatedWithLeaf, + }, }); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-graph/changed-by.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-graph/changed-by.test.ts index 08589ad1923e..c6f2208eca72 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-graph/changed-by.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-graph/changed-by.test.ts @@ -30,6 +30,20 @@ describe('changedBy', () => { expect(names('delete', 'Left')).toEqual(['public.Left', 'public.Right']); }); + it('follows onUpdate from a table a delete sets a column of to null', () => { + expect(names('delete', 'Order')).toEqual(['public.Order', 'public.Shipment', 'public.Label']); + }); + + it('follows a table reached as an update and as a delete both ways', () => { + expect([...names('delete', 'Root')].sort()).toEqual([ + 'public.DeletedWithLeaf', + 'public.Leaf', + 'public.Mid', + 'public.Root', + 'public.UpdatedWithLeaf', + ]); + }); + it('gives the table itself when no foreign key points at it', () => { expect(names('delete', 'Note')).toEqual(['public.Note']); }); From 5e8c06d90fb5db076aac622b615cf143d7d42c4f Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 3 Nov 2026 11:01:18 +0000 Subject: [PATCH 11/14] docs(nested-mutations): add the nested-variant-creates slice Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- projects/nested-mutations/plan.md | 16 +++++++++++----- projects/nested-mutations/spec.md | 1 + 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/projects/nested-mutations/plan.md b/projects/nested-mutations/plan.md index b3d959e510cd..dac5c258bf05 100644 --- a/projects/nested-mutations/plan.md +++ b/projects/nested-mutations/plan.md @@ -6,9 +6,9 @@ ## At a glance -Seven slices in two stages. Stage 1 (three slices, in order) moves every write method of the collection onto the mutation graph with no new behaviour, and removes the nested-write executor. Stage 2 (four slices) adds the project's operations as entries in the graph's translation. `has-one-unique` does not touch the ORM client and can be built at any time. +Eight slices in two stages. Stage 1 (three slices, in order) moves every write method of the collection onto the mutation graph with no new behaviour, and removes the nested-write executor. Stage 2 (five slices) adds the project's operations as entries in the graph's translation, and nested creates of a chosen variant. `has-one-unique` does not touch the ORM client and can be built at any time. -Seven is more than the one to four slices a project should have. Stage 1 could be a project of its own; it is kept here because the project owner asked for the graph to be worked into this project. +Eight is more than the one to four slices a project should have. Stage 1 could be a project of its own; it is kept here because the project owner asked for the graph to be worked into this project. PR [#30634](https://github.com/prisma/orm/pull/30634), the first build of `filtered-many-writes` on the executor, is closed without merging. Its mutator types, integration tests and docs are reused in slice 4. PR [#30642](https://github.com/prisma/orm/pull/30642), its ports, is rebased onto slice 4. @@ -42,19 +42,25 @@ PR [#30634](https://github.com/prisma/orm/pull/30634), the first build of `filte - **Hands to:** The array return and the mutator's `where` step; the peephole that inlines a junction `Find` as `IN (SELECT ...)`. Slice 7 adds `upsert` beside these and uses the array form in its ports. - **Focus:** Translation entries for `updateAll` and `deleteAll`; the inlining peephole, which ships in this slice because many-to-many `updateAll` and `deleteAll` are not usable without it; the row-value form of `IN` for composite keys, added to the SQL AST and both adapters. Reused from #30634: the mutator types for these operations, the integration tests, the README and error-reference text, the upgrade entry. Ports: the 22 tests the slice spec lists, from #30642. -5. **Slice `create-on-conflict`** — `projects/nested-mutations/slices/create-on-conflict/` +5. **Slice `nested-variant-creates`** — `projects/nested-mutations/slices/nested-variant-creates/` + - **Outcome:** A relation's mutator has `variant(value)`, as collections and include refinements already do: `t.variant('feature').create(…)` takes the base's fields plus that variant's own fields, without the discriminator, and creates a complete row on either storage layout. A variant stored in the base table is one `Insert` with the discriminator filled in; a variant stored in its own table is slice `graph-creates`' chain (base `Insert`, variant `Insert` reading the key by `PayloadData`, `CrossJoin`), linked to the parent as any nested create. Works in `create()` and `update()`, for one row and for an array. + - **Builds on:** Slice `graph-nested`'s translation; slice `filtered-many-writes`' mutator shape. + - **Hands to:** Project close-out. + - **Focus:** The narrowed mutator's types and the translation entry. Open when the slice is specified: whether `t.create(…)` without `variant()` on a polymorphic target becomes a type error as `create` on a polymorphic collection is (consistent, but breaks code that compiles today), and whether `connect`/`disconnect` on a variant are included. Main has no variant handling in nested creates: a variant's own fields are rejected with `ORM.FIELD_UNKNOWN`, and setting the discriminator of a variant stored in its own table writes only the base row. + +6. **Slice `create-on-conflict`** — `projects/nested-mutations/slices/create-on-conflict/` - **Outcome:** Nested `create` takes `{ onConflict: 'skip' | 'connect', conflictOn? }` in `create()` and `update()`. `'skip'` leaves out colliding rows on one-to-many relations. `'connect'` links the existing row on every relation layout and leaves its other fields unchanged. - **Builds on:** Slice 3's translation table. Independent of slice 4. - **Hands to:** Project close-out. Nothing downstream consumes it. - **Focus:** The options argument on nested `create` and its types; `'skip'` rejected on to-one and many-to-many; both values rejected on inheritance variants; `'connect'` not offered on top-level `createAll`; the `Insert` that returns the row on conflict, and the link written from it per layout by a data edge. Ports: `nested_create_many.rs` (5, with the `skipDuplicates` non-ported entry removed), `nested_connect_or_create.rs` (6), `prisma_11731.rs` (2), `if_node_siblig_dep_regression.rs` (1), `nested_pagination.rs` (3), `prisma_27452.rs` (1). -6. **Slice `has-one-unique`** — `projects/nested-mutations/slices/has-one-unique/` (independent of every other slice; can be built during stage 1) +7. **Slice `has-one-unique`** — `projects/nested-mutations/slices/has-one-unique/` (independent of every other slice; can be built during stage 1) - **Outcome:** TypeScript contract authoring rejects a `hasOne` relation whose `by` fields are not covered by a unique constraint on the child model, with an error naming the relation and the fields, as PSL authoring already does. - **Builds on:** None. - **Hands to:** The guarantee slice 7 relies on to use the child's foreign key as a conflict target. - **Focus:** The rule in `packages/2-sql/2-authoring/contract-ts`; the in-repo contracts that break under it (`test/e2e/framework/test/sqlite/fixtures/contract.ts`, `test/integration/test/sql-builder/fixtures/contract.ts`, `contract-no-pgvector.ts`, and the package's own tests) and their regenerated fixtures; the upgrade-instructions entry for app authors. The contract validator is not changed. -7. **Slice `nested-upsert`** — `projects/nested-mutations/slices/nested-upsert/` +8. **Slice `nested-upsert`** — `projects/nested-mutations/slices/nested-upsert/` - **Outcome:** Inside `update()`, a relation callback can return `r.upsert({ create, update, conflictOn? })`. On a to-one relation it updates the linked row or creates and links one. On a to-many relation it finds the row by conflict columns and updates it only if it is related to the parent; a conflicting unrelated row raises `ORM.NESTED_UPSERT_CONFLICT`. Works on Postgres and SQLite. - **Builds on:** Slice 4's array return and mutator shape; slice 6's unique foreign key on `1:1` relations. - **Hands to:** Project close-out. Nothing downstream consumes it. diff --git a/projects/nested-mutations/spec.md b/projects/nested-mutations/spec.md index 904b4a3fedd2..43d28b6c3a71 100644 --- a/projects/nested-mutations/spec.md +++ b/projects/nested-mutations/spec.md @@ -196,6 +196,7 @@ Postgres and SQLite adapters: both render the insert conflict clause and must re - [ ] Nested `create` accepts `{ onConflict: 'skip', conflictOn? }` on one-to-many relations in `create()` and `update()`. - [ ] Nested `create` accepts `{ onConflict: 'connect', conflictOn? }` on every relation layout in `create()` and `update()`. - [ ] TypeScript authoring rejects a `hasOne` over a foreign key with no unique constraint, with an error that names the relation and the fields; the in-repo fixtures that declared one are fixed; the upgrade instructions describe the change. +- [ ] A relation's mutator has `variant(value)`, and a nested create through it writes a complete row for a variant stored in the base table and for one stored in its own table, in `create()` and `update()`, shown by integration tests on Postgres and SQLite. - [ ] A relation callback may return an array, in both `create()` and `update()`, and operations run in array order. - [ ] For each of the three operations, an integration test on Postgres and on SQLite shows an unrelated row that matches the filter or conflict columns is left unchanged. - [ ] A nested to-many upsert that conflicts with an unrelated row throws `ORM.NESTED_UPSERT_CONFLICT`, documented in the error reference. From b546d9a084ba7cbcc6247acefe93d093a4b7d269 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 3 Nov 2026 11:02:11 +0000 Subject: [PATCH 12/14] docs(nested-mutations): keep main's nested variant create until nested-variant-creates Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- projects/nested-mutations/plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/nested-mutations/plan.md b/projects/nested-mutations/plan.md index dac5c258bf05..dfaf48ddce97 100644 --- a/projects/nested-mutations/plan.md +++ b/projects/nested-mutations/plan.md @@ -46,7 +46,7 @@ PR [#30634](https://github.com/prisma/orm/pull/30634), the first build of `filte - **Outcome:** A relation's mutator has `variant(value)`, as collections and include refinements already do: `t.variant('feature').create(…)` takes the base's fields plus that variant's own fields, without the discriminator, and creates a complete row on either storage layout. A variant stored in the base table is one `Insert` with the discriminator filled in; a variant stored in its own table is slice `graph-creates`' chain (base `Insert`, variant `Insert` reading the key by `PayloadData`, `CrossJoin`), linked to the parent as any nested create. Works in `create()` and `update()`, for one row and for an array. - **Builds on:** Slice `graph-nested`'s translation; slice `filtered-many-writes`' mutator shape. - **Hands to:** Project close-out. - - **Focus:** The narrowed mutator's types and the translation entry. Open when the slice is specified: whether `t.create(…)` without `variant()` on a polymorphic target becomes a type error as `create` on a polymorphic collection is (consistent, but breaks code that compiles today), and whether `connect`/`disconnect` on a variant are included. Main has no variant handling in nested creates: a variant's own fields are rejected with `ORM.FIELD_UNKNOWN`, and setting the discriminator of a variant stored in its own table writes only the base row. + - **Focus:** The narrowed mutator's types and the translation entry. Open when the slice is specified: whether `t.create(…)` without `variant()` on a polymorphic target becomes a type error as `create` on a polymorphic collection is (consistent, but breaks code that compiles today), and whether `connect`/`disconnect` on a variant are included. Main has no variant handling in nested creates: a variant's own fields are rejected with `ORM.FIELD_UNKNOWN`, and setting the discriminator of a variant stored in its own table writes only the base row. Until this slice lands, that behaviour is kept as on main (decided 2026-11-03). 6. **Slice `create-on-conflict`** — `projects/nested-mutations/slices/create-on-conflict/` - **Outcome:** Nested `create` takes `{ onConflict: 'skip' | 'connect', conflictOn? }` in `create()` and `update()`. `'skip'` leaves out colliding rows on one-to-many relations. `'connect'` links the existing row on every relation layout and leaves its other fields unchanged. From 8fca9f1bcaeb4422caf5846c570d1180dde01290 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 3 Nov 2026 15:30:53 +0000 Subject: [PATCH 13/14] refactor(sql-orm-client): changedBy keeps its foreign-key index in the shared contract cache The index of the foreign keys that reference each table is built once per contract through cachedFor, as the other contract-derived lookups are, instead of in a WeakMap of its own. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- .../src/mutation-graph/changed-by.ts | 49 ++++++++----------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts index 7ff89c7c1f25..836cac9b343d 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts @@ -1,6 +1,7 @@ import type { Contract } from '@internal/contract/types'; import type { ReferentialAction, SqlStorage } from '@internal/sql-contract/types'; import type { TableCoordinate } from '@internal/sql-relational-core/ast'; +import { cachedFor } from '../contract-cache'; export type WriteKind = 'insert' | 'update' | 'delete'; @@ -10,11 +11,6 @@ interface Referencing { readonly onUpdate: ReferentialAction | undefined; } -const referencingByContract = new WeakMap< - Contract, - ReadonlyMap ->(); - export function tableKey(table: TableCoordinate): string { return JSON.stringify([table.namespaceId, table.name]); } @@ -22,31 +18,28 @@ export function tableKey(table: TableCoordinate): string { function referencingOf( contract: Contract, ): ReadonlyMap { - const known = referencingByContract.get(contract); - if (known !== undefined) { - return known; - } - const referencing = new Map(); - for (const [namespaceId, namespace] of Object.entries(contract.storage.namespaces)) { - for (const [name, table] of Object.entries(namespace.entries.table ?? {})) { - for (const foreignKey of table.foreignKeys) { - const key = tableKey({ - namespaceId: foreignKey.target.namespaceId, - name: foreignKey.target.tableName, - }); - referencing.set(key, [ - ...(referencing.get(key) ?? []), - { - table: { namespaceId, name }, - onDelete: foreignKey.onDelete, - onUpdate: foreignKey.onUpdate, - }, - ]); + return cachedFor(contract, ['referencingForeignKeys'], () => { + const referencing = new Map(); + for (const [namespaceId, namespace] of Object.entries(contract.storage.namespaces)) { + for (const [name, table] of Object.entries(namespace.entries.table ?? {})) { + for (const foreignKey of table.foreignKeys) { + const key = tableKey({ + namespaceId: foreignKey.target.namespaceId, + name: foreignKey.target.tableName, + }); + referencing.set(key, [ + ...(referencing.get(key) ?? []), + { + table: { namespaceId, name }, + onDelete: foreignKey.onDelete, + onUpdate: foreignKey.onUpdate, + }, + ]); + } } } - } - referencingByContract.set(contract, referencing); - return referencing; + return referencing; + }); } type ChangeKind = Exclude; From d8cfac071178a8dc0c8020b330c00bcc62af1cc2 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 3 Nov 2026 15:35:36 +0000 Subject: [PATCH 14/14] refactor(sql-orm-client): cache the tables changedBy returns, not a foreign-key index changedBy caches its result per contract, write kind and table through cachedFor, as a frozen array. The walk finds the foreign keys that reference a table by scanning the contract's tables, which happens only on a cache miss; the separate index is removed. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Steven McClankerton --- .../src/mutation-graph/changed-by.ts | 87 +++++++++---------- 1 file changed, 41 insertions(+), 46 deletions(-) diff --git a/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts b/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts index 836cac9b343d..5fab1ffcc5b0 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-graph/changed-by.ts @@ -5,50 +5,23 @@ import { cachedFor } from '../contract-cache'; export type WriteKind = 'insert' | 'update' | 'delete'; +type ChangeKind = Exclude; + interface Referencing { readonly table: TableCoordinate; readonly onDelete: ReferentialAction | undefined; readonly onUpdate: ReferentialAction | undefined; } -export function tableKey(table: TableCoordinate): string { - return JSON.stringify([table.namespaceId, table.name]); -} - -function referencingOf( - contract: Contract, -): ReadonlyMap { - return cachedFor(contract, ['referencingForeignKeys'], () => { - const referencing = new Map(); - for (const [namespaceId, namespace] of Object.entries(contract.storage.namespaces)) { - for (const [name, table] of Object.entries(namespace.entries.table ?? {})) { - for (const foreignKey of table.foreignKeys) { - const key = tableKey({ - namespaceId: foreignKey.target.namespaceId, - name: foreignKey.target.tableName, - }); - referencing.set(key, [ - ...(referencing.get(key) ?? []), - { - table: { namespaceId, name }, - onDelete: foreignKey.onDelete, - onUpdate: foreignKey.onUpdate, - }, - ]); - } - } - } - return referencing; - }); -} - -type ChangeKind = Exclude; - interface Step { readonly table: TableCoordinate; readonly kind: ChangeKind; } +export function tableKey(table: TableCoordinate): string { + return JSON.stringify([table.namespaceId, table.name]); +} + export function changedBy( contract: Contract, write: WriteKind, @@ -57,21 +30,43 @@ export function changedBy( if (write === 'insert') { return [table]; } - const referencing = referencingOf(contract); - const changed = new Map([[tableKey(table), table]]); - const visited = new Set([stepKey({ table, kind: write })]); - const pending: Step[] = [{ table, kind: write }]; - for (let step = pending.shift(); step !== undefined; step = pending.shift()) { - for (const reference of referencing.get(tableKey(step.table)) ?? []) { - const next = nextStep(step.kind, reference); - if (next !== undefined && !visited.has(stepKey(next))) { - visited.add(stepKey(next)); - changed.set(tableKey(next.table), next.table); - pending.push(next); + return cachedFor(contract, ['changedBy', write, table.namespaceId, table.name], () => { + const changed = new Map([[tableKey(table), table]]); + const visited = new Set([stepKey({ table, kind: write })]); + const pending: Step[] = [{ table, kind: write }]; + for (let step = pending.shift(); step !== undefined; step = pending.shift()) { + for (const reference of referencesTo(contract, step.table)) { + const next = nextStep(step.kind, reference); + if (next !== undefined && !visited.has(stepKey(next))) { + visited.add(stepKey(next)); + changed.set(tableKey(next.table), next.table); + pending.push(next); + } } } - } - return [...changed.values()]; + return Object.freeze([...changed.values()]); + }); +} + +function referencesTo(contract: Contract, target: TableCoordinate): Referencing[] { + const key = tableKey(target); + return Object.entries(contract.storage.namespaces).flatMap(([namespaceId, namespace]) => + Object.entries(namespace.entries.table ?? {}).flatMap(([name, table]) => + table.foreignKeys + .filter( + (foreignKey) => + tableKey({ + namespaceId: foreignKey.target.namespaceId, + name: foreignKey.target.tableName, + }) === key, + ) + .map((foreignKey) => ({ + table: { namespaceId, name }, + onDelete: foreignKey.onDelete, + onUpdate: foreignKey.onUpdate, + })), + ), + ); } function nextStep(kind: ChangeKind, reference: Referencing): Step | undefined {