Skip to content

Commit b2b3124

Browse files
fix(objectql)!: a predicate delete honours deleteBehavior for every matched row: restrict refuses, master_detail cascades, set_null clears (#26871) (#26930)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XG5f9ptoMdvkj41yUX3JGGd)_ Fixes #26871 Clause-②: no (narrowing) — a predicate delete (`engine.delete(object, { where, multi: true })`, reached by a flow `delete_record` node with `multi: true` and a filter) refuses with 409 `DELETE_RESTRICTED` where it succeeded, when a row it matches is held by a `restrict` reference. Nothing is widened. Metadata-protocol: none Skills-docs: none — `skills/objectstack-data/rules/relationships.md`, `content/docs/data-modeling/field-types.mdx` and `content/docs/getting-started/common-patterns.mdx` already state `deleteBehavior` with no path exception; nothing in `skills/**` or the docs taught that a predicate delete skips it. ## 维护者速读 - **改了什么**:按条件批量删除(`multi: true` + filter,流程的 `delete_record` 批量节点走的就是它)现在对每一条命中的记录执行与按 id 删除相同的引用检查:`restrict` 拒绝整次删除,`master_detail` 子记录级联删除,`set_null` 引用被清空。 - **为什么改**:此前批量删除直接走驱动的 `deleteMany`,完全跳过 `deleteBehavior`,流程批量删父记录会留下孤儿子记录和悬空引用,而同样的记录逐条删除会被拒绝或级联。声明了却不兑现。 - **风险与回滚**:只影响"有关系字段指向它"的对象;没有任何引用的对象仍是一条 `deleteMany`、不读行、不开事务。原来能成功的批量删除,若命中行被 `restrict` 引用,现在会 409 拒绝(整体回滚)。回滚即 revert 本 PR。 - **先看**:`packages/objectql/src/engine.ts` 的 `delete()` 谓词分支(`[#26871]` 段);`packages/objectql/src/engine-cascade-delete-atomic.test.ts` 的 `3b` 段;`packages/services/service-automation/src/builtin/delete-record-predicate-referential-walk.integration.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/engine-cascade-delete-atomic.test.ts`,预期 19 条全绿,其中 `[#26871]` 段 6 条。 - **需要你做的**:无,席位落地。 ## What changes `ObjectQL.delete()`'s predicate arm (`multi: true`, no scalar `where.id`) now runs the by-id delete's referential walk for every row it matches. The arm lives in `delete()` in `packages/objectql/src/engine.ts`; `engine-delete-dispatch.ts` only re-exports the dispatch predicate and is untouched. Seat ruling arm A: no second copy of the walk, no new option on `engine.delete`, no refusal of `multi: true`. - **Cheap when there is nothing to walk.** The arm asks the by-id branch's own question, `planCascadeAtomicity(object)`. `'none'` (no `master_detail` / `lookup` / `tree` points at the object) keeps today's single `driver.deleteMany` with the composed AST: no row read, no transaction. - **Otherwise** it reads the matched ids through the composed, row-scoped AST (`fields: ['id']`, the write's driver options), calls the existing `cascadeDeleteRelations` for each id, then deletes exactly those rows by id (`id $in`, batches of 500). By id rather than by re-running the predicate, because the walk's own writes (a `set_null` on a self-reference) can move a row into or out of the predicate; the rows deleted are the rows walked. The answer is still the count the driver reports. - **Atomicity is the by-id branch's.** `'atomic'` wraps the read, the walks and the delete in one `transaction()`, so a `restrict` on any matched row rolls back every cascade already done; `'split'` runs unwrapped and warns once (`warnCascadeNotAtomic`), as by id. - Because the walk is `cascadeDeleteRelations` itself, #26864's own-cascade restrict rule (merged into this branch from `main`) applies to the predicate path with no further code. - The `cascadeDeleteRelations` docblock line "Only runs for single-id deletes — multi/predicate deletes skip cascade" described the defect and now says the walk runs for every matched row. The `engine-cascade-delete-atomic.test.ts` case "opens no transaction when the object HAS dependents declared but the delete is by predicate" pinned the defect too; it is replaced by the predicate control on an object nothing references, and the dependents case moves to the new section, which pins one transaction. ## Tests - `packages/objectql/src/engine-cascade-delete-atomic.test.ts`, new section 3b (`[#26871]`, 6 cases, snapshot-rollback driver): a `restrict` on one matched row refuses the whole delete with 409 `DELETE_RESTRICTED` and rolls back the cascade already done for another matched row; `master_detail` children cascade and `set_null` lookups clear for every matched row while an unmatched parent's rows stay; the walk and the delete ride ONE transaction handle; a self-referencing `set_null` that makes another row match the predicate does not get that row deleted; a detail restricting its own master refuses (the #26718 rule, inherited); zero matched rows write nothing. Plus the predicate control: no inbound relation, one `deleteMany`, no transaction, no warning. The fake driver learned `$in` (the by-id-set delete). - `packages/services/service-automation/src/builtin/delete-record-predicate-referential-walk.integration.test.ts` (new, real `ObjectKernel` + `ObjectQLPlugin` + `SqlDriver` better-sqlite3 + `AutomationServicePlugin`): a flow `delete_record` with `multi: true` and a filter matching two parents. Restrict on one: the run fails, the node's answer carries `innerRefusal.code` `DELETE_RESTRICTED`, and every row (both parents, the detail and the lookup the walk reached first) is still there, counted out of the driver. No restrict: both parents' `master_detail` details are deleted, their `set_null` lookups are cleared, and the unmatched parent's rows stay. - `packages/objectql/src/engine-nested-relation-lowering.test.ts`: the "every verb that takes a where" pin recorded ONE `deleteMany` carrying the lowered filter. The ledger object has relations pointing at it, so the predicate delete now reads its matched ids first; the pin now asserts that read carries the lowered filter and that nothing is written when nothing matches. The driver still never receives the nested form. ## Acceptance notes - **In-process `multi: true` delete callers, measured.** `plugin-auth` `removeOrganizationAuthStructure` (`sys_team_member`, `sys_user_permission_set`), `plugin-sharing` `deleteRowsForDeletedRecords` / `deleteRowsByIds` / `revokeRuleGrants*` (`sys_record_share`), `service-automation` `suspended-run-store` (`sys_automation_run`, the claim compare-and-set and its capability probe): no field in `packages/**` or `examples/**` references any of those objects (searched both the string-literal and `SystemObjectName` constant spellings), so each still plans `'none'` and keeps its single `deleteMany` and its count answer. `lifecycle-service`'s production reap deletes row by row with a scalar `where.id` (dispatched by id even beside `multi: true`); its predicate fallback is reachable only by an engine with no `find`, a two-method test double. No caller depends on the walk being skipped on an object that has rules. - **Stale prose, outside this PR's write scope, now false or half-true:** the comment above `removeOrganizationAuthStructure` (`plugin-auth/src/organization-delete.ts`, "a multi-row delete runs no cascade"), the comment above `lifecycle-service`'s per-id reap ("by-id deletes get referential cascade handling a filter-delete would bypass"), and the `sys_organization` predicate refusal (`organizationPredicateDeleteRefusal`'s `developerMessage` "it runs no referential pass" and the `[#24948]` comment in `delete()`). The refusal itself is unchanged here; whether it stays is a seat question in the report. - **Each matched row is walked as its own root**, the same as the bulk `deleteManyData` door deleting by id row by row: a `restrict` held by ANOTHER matched row of the same delete refuses (it is outside that root's cascade set). Unchanged from deleting the same rows by id. - **Cost.** A predicate delete on an object with inbound relations now reads its matched ids and probes each relation per row, like N by-id deletes. No ceiling is added (none was asked); an object with no inbound relation pays nothing new. - **Observed, not measured end to end:** neither the predicate delete nor the predicate update recomputes roll-up summaries (`priorRecord` is bound only by id). Pre-existing, unrelated to the walk; noted, not filed. ## Verification All local readings below are on head `27b890d0` (the merge of `main` at `2720874a`, which carries #26864, plus the last test commit), worktree `objectstack-issue-26871`, shared container. <details><summary>Red first, on base a51ba63 (before the fix)</summary> - `pnpm --filter @objectstack/objectql exec vitest run src/engine-cascade-delete-atomic.test.ts`: 4 failed / 14 passed. The restrict case: `promise resolved "2" instead of rejecting`; the self-reference case: the report kept `boss = root` (no set_null ran). - `pnpm --filter @objectstack/service-automation exec vitest run src/builtin/delete-record-predicate-referential-walk.integration.test.ts` (objectql dist built from base): 2 failed. Restrict: `the run fails on the restrict: expected true to be false`; cascade: the matched parent's detail row was still there. </details> <details><summary>Green, and the ablation</summary> - objectql full suite `pnpm --filter @objectstack/objectql test`: Test Files 475 passed (475), Tests 8855 passed (8855). - service-automation full suite `pnpm --filter @objectstack/service-automation test`: Test Files 214 passed, Tests 2746 passed (on the pre-merge head `ace80cb2`); on the merged head the new file plus `crud-bulk-intent.test.ts`: 12 passed. - `pnpm --filter @objectstack/objectql typecheck` exit 0, `pnpm --filter @objectstack/service-automation typecheck` exit 0 (both include `check:test-typecheck`). - `eslint --no-inline-config packages/objectql packages/services/service-automation`: exit 0, no output. - Ablation (`scripts/ablation-replace.mjs`, anchor `if (plan === 'none') {` replaced so every predicate delete takes the old single-`deleteMany` arm; anchor 1 to 0, blob 9138382 to d31446d5dd01): objectql atomic file 5 failed / 14 passed (the five walk cases; the zero-row case and the controls stay green); objectql rebuilt, `ablation-dist-preflight` found the marker in dist; flow file 2 failed. Restore: blob back to 9138382 == HEAD, `git diff HEAD` empty; objectql rebuilt, preflight `--absent` clean over 14 files; flow file 2 passed. </details> <details><summary>Gates (derived by dispatch-gates --commands on the real diff)</summary> - 65 families derived; each run with its exit code captured before any pipe: 65 exit 0. `pnpm check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3, no dist for 41 packages) and exit 0 after a full `turbo run build` (116 tasks); `check:query-options-erasure` first counted one new test-code erasure (the new flow test's driver read), fixed by typing it, then exit 0; the teardown self-test needed its pinned fixture commit fetched. - `dispatch-gates --ran`: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. - Everything else (the `check:*` farm, `Test Core`, `Test UI`, the rest of the required nine) is CI's: see this PR's check-runs. </details> <details><summary>Dogfood battery (owed by the narrowing arm)</summary> - `pnpm --filter @objectstack/dogfood test` under the verify lock on `27b890d0` after a full build: VERDICT command-exit 0, Test Files 333 passed, 3 skipped (336), Tests 2706 passed, 20 skipped (2726). </details> <details><summary>Overlap</summary> - #26864 (#26718) merged to `main` while this was in flight; `main` is merged into this branch, and the predicate arm inherits its own-cascade restrict rule (pinned above). `git merge-tree` of this head against `origin/main` at `8074feb6` is clean. - #26881 (#25522 cut 17) was open at the start; its merge-tree with this branch was clean. No body the #25522 split moved is edited here: `delete()` and `cascadeDeleteRelations` both still live in `engine.ts`. </details> Session: `session_01FEkQ5ptsvnYe5ZErgvP66h` --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5ptsvnYe5ZErgvP66h)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b1b153a commit b2b3124

7 files changed

Lines changed: 479 additions & 35 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: a predicate delete (`multi: true` with a filter) honours `deleteBehavior` for every row it matches: a `restrict` refuses, a `master_detail` cascades, a `set_null` clears (#26871)
6+
7+
Clause-②: no (narrowing) — a predicate delete (`engine.delete(object, { where, multi: true })`, which a flow's `delete_record` node with `multi: true` and a filter reaches) refuses with 409 `DELETE_RESTRICTED` where it succeeded: when a row it matches is held by a `restrict` reference (authored, or a required lookup's `set_null`, which is refused as `restrict`). Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: a declared deleteBehavior is now honoured on the predicate delete path too. The refusal is the existing DELETE_RESTRICTED, so there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the predicate delete (`@objectstack/objectql`), served by every caller of `delete` with `multi: true` and a filter, the flow `delete_record` node among them.
13+
14+
- **Before.** A predicate delete went straight to the driver's bulk delete and ran no referential walk. No `restrict` refused it, the matched rows' `master_detail` children were left behind, and `set_null` lookups kept pointing at deleted rows. The same rows deleted one at a time by id (`DELETE /api/v1/data/:object/:id`, the bulk `deleteManyData` door) refused or cascaded.
15+
- **Now.** When any relation of the delete family (`master_detail`, `lookup`, `tree`) points at the object, the predicate delete reads the rows it matches under the caller's scope, runs the by-id delete's referential walk for each, and then deletes exactly those rows, all in one unit of work on a single datasource. A `restrict` on any matched row refuses the whole delete with the existing `409 DELETE_RESTRICTED`, and nothing is deleted; `master_detail` children (and every `cascade`) are deleted; `set_null` references are cleared. The answer is still the count of rows the delete removed.
16+
- **Unchanged.** An object no relation points at keeps the single bulk delete: no row is read and no transaction is opened. A cascade that reaches another datasource runs without a transaction and warns once, as the by-id delete does. A predicate delete of `sys_organization` is still refused.
17+
- **Who sees it.** A flow (or in-process caller) that bulk-deletes rows held by a `restrict` reference now fails instead of deleting them; one that bulk-deletes masters now deletes their details and clears their lookups instead of leaving them dangling.
18+
19+
**The remedy.** Delete or reassign the restricting rows first, then run the bulk delete. If the referencing rows are meant to go with the rows deleted, declare `deleteBehavior: 'cascade'` on that reference.

‎packages/objectql/src/engine-cascade-delete-atomic.test.ts‎

Lines changed: 163 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ function makeDriver(opts: { transactional?: boolean } = {}) {
6868
if (!where || typeof where !== 'object') return true;
6969
for (const [k, v] of Object.entries(where)) {
7070
if (k.startsWith('$')) continue;
71+
// [#26871] The predicate delete removes the rows it walked by id set.
72+
if (v && typeof v === 'object' && Array.isArray((v as any).$in)) {
73+
if (!(v as any).$in.some((x: unknown) => String(x) === String(row[k]))) return false;
74+
continue;
75+
}
7176
const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
7277
if ((row[k] ?? null) !== (exp ?? null)) return false;
7378
}
@@ -404,16 +409,168 @@ describe('a plain non-cascade delete is unchanged (#7413 control)', () => {
404409
expect(rec.at('warn')).toHaveLength(0);
405410
});
406411

407-
it('opens no transaction when the object HAS dependents declared but the delete is by predicate', async () => {
408-
// The predicate branch never called `cascadeDeleteRelations` and still does
409-
// not — the wrap is the by-id branch's alone.
410-
const { engine, driver } = await makeEngine([parent, kid]);
411-
await engine.insert('parent', { name: 'P' });
412+
it('opens no transaction for a predicate delete when no relation points at the object', async () => {
413+
// [#26871] The predicate twin of the control above: nothing to walk, so the
414+
// predicate arm keeps its single `deleteMany` — no id resolution, no wrap.
415+
const { engine, driver, rec } = await makeEngine([loner]);
416+
await engine.insert('loner', { name: 'solo' });
417+
await engine.insert('loner', { name: 'solo' });
412418
driver.writes.length = 0;
413419

414-
await engine.delete('parent', { where: { name: 'P' }, multi: true } as any);
420+
const removed = await engine.delete('loner', { where: { name: 'solo' }, multi: true });
415421

422+
expect(removed).toBe(2);
416423
expect(driver.begun).toHaveLength(0);
424+
expect(driver.rowsOf('loner')).toHaveLength(0);
425+
for (const w of driver.writes) expect(w.transaction).toBeUndefined();
426+
expect(rec.at('warn')).toHaveLength(0);
427+
});
428+
});
429+
430+
// ---------------------------------------------------------------------------
431+
// 3b. [#26871] A predicate delete walks every matched row, as one unit of work.
432+
// ---------------------------------------------------------------------------
433+
434+
/** A `master_detail` detail of `parent`: the default behaviour is `cascade`. */
435+
const detail = {
436+
name: 'detail',
437+
label: 'Detail',
438+
fields: {
439+
id: { name: 'id', type: 'text' as const, primaryKey: true },
440+
parent: { name: 'parent', type: 'master_detail' as const, reference: 'parent' },
441+
},
442+
};
443+
/** An authored `restrict` lookup to `parent`. */
444+
const guard = {
445+
name: 'guard',
446+
label: 'Guard',
447+
fields: {
448+
id: { name: 'id', type: 'text' as const, primaryKey: true },
449+
parent: { name: 'parent', type: 'lookup' as const, reference: 'parent', deleteBehavior: 'restrict' },
450+
},
451+
};
452+
453+
describe('[#26871] a predicate delete runs the referential walk for every matched row', () => {
454+
it('a restrict on one matched row refuses the whole delete, and nothing is deleted', async () => {
455+
const { engine, driver } = await makeEngine([parent, detail, guard]);
456+
const p1 = await engine.insert('parent', { name: 'doomed' });
457+
const p2 = await engine.insert('parent', { name: 'doomed' });
458+
await engine.insert('parent', { name: 'kept' });
459+
await engine.insert('detail', { parent: p1.id });
460+
await engine.insert('guard', { parent: p2.id });
461+
462+
await expect(engine.delete('parent', { where: { name: 'doomed' }, multi: true }))
463+
.rejects.toMatchObject({ code: 'DELETE_RESTRICTED', status: 409, object: 'parent', dependentObject: 'guard' });
464+
465+
// p1's detail was cascaded before p2's restrict refused; the one unit of
466+
// work puts it back, so the 409 means nothing changed.
467+
expect(driver.rowsOf('parent')).toHaveLength(3);
468+
expect(driver.rowsOf('detail')).toHaveLength(1);
469+
expect(driver.rowsOf('guard')).toHaveLength(1);
470+
expect(driver.begun).toHaveLength(1);
471+
expect(driver.rolledBack).toHaveLength(1);
472+
expect(driver.committed).toHaveLength(0);
473+
});
474+
475+
it('cascades master_detail children and clears set_null references of every matched row', async () => {
476+
const { engine, driver } = await makeEngine([parent, detail, link]);
477+
const p1 = await engine.insert('parent', { name: 'doomed' });
478+
const p2 = await engine.insert('parent', { name: 'doomed' });
479+
const kept = await engine.insert('parent', { name: 'kept' });
480+
await engine.insert('detail', { parent: p1.id });
481+
await engine.insert('detail', { parent: p2.id });
482+
const keptDetail = await engine.insert('detail', { parent: kept.id });
483+
const l1 = await engine.insert('link', { parent: p1.id });
484+
const l2 = await engine.insert('link', { parent: p2.id });
485+
const keptLink = await engine.insert('link', { parent: kept.id });
486+
487+
const removed = await engine.delete('parent', { where: { name: 'doomed' }, multi: true });
488+
489+
expect(removed).toBe(2);
490+
expect(driver.rowsOf('parent').map((r: any) => r.id)).toEqual([kept.id]);
491+
expect(driver.rowsOf('detail').map((r: any) => r.id)).toEqual([keptDetail.id]);
492+
const links = new Map(driver.rowsOf('link').map((r: any) => [r.id, r.parent]));
493+
expect(links.get(l1.id)).toBeNull();
494+
expect(links.get(l2.id)).toBeNull();
495+
expect(links.get(keptLink.id)).toBe(kept.id);
496+
expect(driver.begun).toHaveLength(1);
497+
expect(driver.committed).toHaveLength(1);
498+
});
499+
500+
it('routes the walk and the predicate delete onto ONE transaction handle', async () => {
501+
const { engine, driver } = await makeEngine([parent, detail, link]);
502+
const p = await engine.insert('parent', { name: 'doomed' });
503+
await engine.insert('detail', { parent: p.id });
504+
await engine.insert('link', { parent: p.id });
505+
driver.writes.length = 0;
506+
507+
await engine.delete('parent', { where: { name: 'doomed' }, multi: true });
508+
509+
expect(driver.begun).toHaveLength(1);
510+
const handle = driver.begun[0];
511+
expect(driver.writes.some((w: any) => w.object === 'parent' && w.op === 'delete')).toBe(true);
512+
expect(driver.writes.some((w: any) => w.object === 'detail' && w.op === 'delete')).toBe(true);
513+
expect(driver.writes.some((w: any) => w.object === 'link' && w.op === 'update')).toBe(true);
514+
for (const w of driver.writes) expect(w.transaction).toBe(handle);
515+
});
516+
517+
it('deletes only the rows it walked, even when the walk makes another row match the predicate', async () => {
518+
// A self-referencing set_null lookup: clearing `boss` on the report makes
519+
// it match `{ boss: null }`. The delete removes the rows it resolved and
520+
// walked, never a row the walk itself brought into the predicate.
521+
const node = {
522+
name: 'node',
523+
label: 'Node',
524+
fields: {
525+
id: { name: 'id', type: 'text' as const, primaryKey: true },
526+
name: { name: 'name', type: 'text' as const },
527+
boss: { name: 'boss', type: 'lookup' as const, reference: 'node' },
528+
},
529+
};
530+
const { engine, driver } = await makeEngine([node]);
531+
const root = await engine.insert('node', { name: 'root', boss: null });
532+
const report = await engine.insert('node', { name: 'report', boss: root.id });
533+
534+
const removed = await engine.delete('node', { where: { boss: null }, multi: true });
535+
536+
expect(removed).toBe(1);
537+
expect(driver.rowsOf('node').map((r: any) => [r.id, r.boss ?? null])).toEqual([[report.id, null]]);
538+
});
539+
540+
it('a detail that also restricts its own master refuses the predicate delete, as it does by id (#26718)', async () => {
541+
// The predicate arm runs the by-id walk itself, so every rule the walk
542+
// judges holds here too: the detail is in its master's cascade set by its
543+
// own master_detail, and its restrict to that master still refuses.
544+
const pledge = {
545+
name: 'pledge',
546+
label: 'Pledge',
547+
fields: {
548+
id: { name: 'id', type: 'text' as const, primaryKey: true },
549+
parent: { name: 'parent', type: 'master_detail' as const, reference: 'parent' },
550+
guard: { name: 'guard', type: 'lookup' as const, reference: 'parent', deleteBehavior: 'restrict' },
551+
},
552+
};
553+
const { engine, driver } = await makeEngine([parent, pledge]);
554+
const p = await engine.insert('parent', { name: 'doomed' });
555+
await engine.insert('pledge', { parent: p.id, guard: p.id });
556+
557+
await expect(engine.delete('parent', { where: { name: 'doomed' }, multi: true }))
558+
.rejects.toMatchObject({ code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'pledge' });
559+
expect(driver.rowsOf('parent')).toHaveLength(1);
560+
expect(driver.rowsOf('pledge')).toHaveLength(1);
561+
});
562+
563+
it('a matched set of zero rows walks nothing and deletes nothing', async () => {
564+
const { engine, driver } = await makeEngine([parent, detail]);
565+
const p = await engine.insert('parent', { name: 'kept' });
566+
await engine.insert('detail', { parent: p.id });
567+
driver.writes.length = 0;
568+
569+
const removed = await engine.delete('parent', { where: { name: 'nobody' }, multi: true });
570+
571+
expect(removed).toBe(0);
572+
expect(driver.writes).toEqual([]);
573+
expect(driver.rowsOf('detail')).toHaveLength(1);
417574
});
418575
});
419576

‎packages/objectql/src/engine-nested-relation-lowering.test.ts‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -203,15 +203,21 @@ describe('[#20802] the nested-relation form is lowered at the engine\'s where se
203203
expect(outer.length, verb).toBeGreaterThan(0);
204204
for (const read of outer) expect(read.ast.where, verb).toEqual(lowered);
205205
}
206-
for (const [verb, call] of [
207-
['update', () => engine.update(OBJECT, { title: 'x' }, { where, multi: true })],
208-
['delete', () => engine.delete(OBJECT, { where, multi: true })],
209-
] as const) {
210-
rec.writes.length = 0;
211-
await call();
212-
expect(rec.writes, verb).toHaveLength(1);
213-
expect(rec.writes[0].ast.where, verb).toEqual(lowered);
214-
}
206+
rec.writes.length = 0;
207+
await engine.update(OBJECT, { title: 'x' }, { where, multi: true });
208+
expect(rec.writes, 'update').toHaveLength(1);
209+
expect(rec.writes[0].ast.where, 'update').toEqual(lowered);
210+
// [#26871] Relations point at this ledger (the owner's `account`, its own
211+
// `parent` tree), so a predicate delete first reads the rows it will walk
212+
// and then deletes those rows by id. The predicate reaches the driver on
213+
// that read, lowered; nothing matches here, so nothing is written.
214+
rec.reads.length = 0;
215+
rec.writes.length = 0;
216+
await engine.delete(OBJECT, { where, multi: true });
217+
const resolution = rec.reads.filter((r) => r.object === OBJECT && Array.isArray(r.ast?.fields) && r.ast.fields.includes('id'));
218+
expect(resolution, 'delete').toHaveLength(1);
219+
expect(resolution[0].ast.where, 'delete').toEqual(lowered);
220+
expect(rec.writes, 'delete').toHaveLength(0);
215221
expect(engine.judgeFilter(OBJECT, where)).toEqual({ ok: true });
216222
expect(engine.judgeFilter(OBJECT, { owners: { region: 'NA' } })).toEqual({ ok: true });
217223
});

0 commit comments

Comments
 (0)