Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .changeset/27004-predicate-delete-walk-as-set.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

fix(objectql): a predicate delete walks the rows it matches as one set: each relation pointing at the object is asked once per 500 matched rows, not once per row (#27004)

Clause-②: yes (widening) — a predicate delete (`engine.delete(object, { where, multi: true })`, a flow's `delete_record` with `multi: true`) whose matched rows are held only by OTHER matched rows (a report and its manager through a `restrict` self-lookup, both matched) now deletes them; the row-by-row walk refused it, or not, depending on which row it met first. Nothing is narrowed.
Clause-②: yes (widening) — a predicate delete (`engine.delete(object, { where, multi: true })`, a flow's `delete_record` with `multi: true`) whose matched rows are held only by OTHER matched rows (a report and its manager through a `restrict` self-lookup, both matched) now deletes them; the row-by-row walk refused it, whichever of the two rows it met first. Nothing is narrowed.
Metadata-protocol: none

- **Before.** The predicate delete ran the by-id referential walk once per matched row, so its cost was matched rows times referencing relations: 1,001 rows of an object three relations point at took 4,004 dependents probes, about 1.7 s on SQLite.
- **Now.** One walk, rooted at the whole matched set (a by-id delete is a set of one). Each referencing relation is probed once per 500 matched rows with `$in` (a multiple lookup ORs each id's `$contains`), and each row is then judged and written as before: `restrict` refuses, `cascade` deletes (each child through its own delete, hooks and all), `set_null` clears, a multiple lookup loses only the deleted members and an emptied set is written as `[]`. The same 1,001-row delete takes 12 probes.
- **Unchanged.** A refusal's code and wording: it still names one record and counts the rows that refuse over that record (for a required multiple lookup, the rows the removal would empty). The atomic and split plans, the 500-id `$in` delete batches, the roll-up recompute after the delete, and the filter of every probe a by-id delete sends.
- **Now.** One walk, rooted at the whole matched set (a by-id delete is a set of one). Each referencing relation is probed once per 500 matched rows with `$in` (a multiple lookup ORs each id's `$contains`), and each row is then judged and written as before: `restrict` refuses, `cascade` deletes (each child the predicate did not match through its own delete, hooks and all), `set_null` clears, a multiple lookup loses only the deleted members and an emptied set is written as `[]`. The same 1,001-row delete takes 12 probes.
- **A by-id delete's probes, too.** Its own record is still probed with that record's filter, and each cascaded child's delete still probes its one record. The reads made before the first write changed: collecting the rows the cascade removes now probes the records found at one depth together (per object and relation, from the root's children down), and the check for an authored `restrict` that the delete's own cascade would override probes together the records that ask one relation. Two or more records are asked together with `$in` (on a multiple lookup, the OR of each id's `$contains`) where each used to get its own probe; a lone record keeps its own filter. Deleting a parent with two children on one cascading relation therefore probes each of the children's own cascading relations once with `$in`, where it used to send one probe per child. A driver answering these probes now receives `$in`; a batch with a single-valued row that names none of its ids by `String()` is asked again one id at a time.
- **A matched row another matched row's cascade reaches** (an object's `cascade` reference to itself, with parent and child both matched). The cascade no longer deletes it through a nested delete of its own; the predicate delete removes it with the other matched rows. The count `engine.delete` returns, which a flow's `delete_record` hands on as its `result`, now includes that row, where it used to leave it out, and the row's `beforeDelete` and `afterDelete` hooks fire once, as a matched row, where they used to fire a second time from the cascade's delete.
- **Which refusal is reported when more than one applies.** The row-by-row walk finished one matched row's relations before it started the next row, so it reported the first matched row that refused. The set walk asks each relation about every matched row before it moves to the next relation, so it reports the first relation that refuses, naming the first matched row that relation refuses for; and it checks every matched row for an authored `restrict` its own cascade would override before it walks the first relation.
- **Unchanged.** A refusal's code and wording: it still names one record and counts the rows that refuse over that record (for a required multiple lookup, the rows the removal would empty). The atomic and split plans, the 500-id `$in` delete batches, and the roll-up recompute after the delete.
- **Who sees it.** A bulk delete that removes rows holding each other: a matched row that another matched row holds is in the delete's own set, so it neither refuses nor is cleared first (the rule a cascade already applies to the rows it deletes). A holder outside the matched rows still refuses.
32 changes: 32 additions & 0 deletions packages/objectql/src/engine-cascade-delete-atomic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,38 @@ describe('[#26871] a predicate delete runs the referential walk for every matche
expect(driver.writes).toEqual([]);
expect(driver.rowsOf('detail')).toHaveLength(1);
});

it('counts a matched row another matched row cascades into, and fires its delete hooks once (#27004)', async () => {
// One walk over the matched set: the child is a member like its parent,
// so the cascade leaves it to the predicate's own id-set delete instead of
// deleting it through a nested delete of its own.
const crew = {
name: 'crew',
label: 'Crew',
fields: {
id: { name: 'id', type: 'text' as const, primaryKey: true },
name: { name: 'name', type: 'text' as const },
lead: { name: 'lead', type: 'lookup' as const, reference: 'crew', deleteBehavior: 'cascade' },
},
};
const { engine, driver } = await makeEngine([crew]);
const lead = await engine.insert('crew', { name: 'doomed', lead: null });
const member = await engine.insert('crew', { name: 'doomed', lead: lead.id });
const kept = await engine.insert('crew', { name: 'kept', lead: null });
const events: string[] = [];
for (const event of ['beforeDelete', 'afterDelete'] as const) {
engine.registerHook(event, async (ctx: any) => void events.push(`${event}:${ctx.input?.id}`), { object: 'crew' });
}

const removed = await engine.delete('crew', { where: { name: 'doomed' }, multi: true });

expect(removed).toBe(2);
expect(driver.rowsOf('crew').map((r: any) => r.id)).toEqual([kept.id]);
expect([...events].sort()).toEqual([
`afterDelete:${lead.id}`, `afterDelete:${member.id}`,
`beforeDelete:${lead.id}`, `beforeDelete:${member.id}`,
].sort());
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading