Skip to content

Commit 5ce34e0

Browse files
committed
test(objectql): pin a required tree's set_null escalation; changeset
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2030d88 commit 5ce34e0

2 files changed

Lines changed: 42 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a `tree` field's `deleteBehavior` now takes effect when its parent record is deleted, and the default clears the reference
6+
7+
Clause-②: no
8+
9+
The spec gives a `tree` field (a record's parent in a hierarchy on its own object) a `deleteBehavior`, like a `lookup`. When the field declares none, the parse fills in `set_null`. The engine's referential delete pass only followed `master_detail` and `lookup` fields. So deleting a parent node returned success and left every child pointing at the deleted id. An authored `cascade` or `restrict` on a `tree` did nothing either.
10+
11+
A by-id delete now applies a `tree` field's behaviour the way it applies a `lookup`'s:
12+
13+
- `set_null` (the default): the children stay, and their parent field is cleared.
14+
- `cascade`: the whole subtree is deleted, at any depth, and nothing outside it. A cycle in the data (a row whose parent is its own descendant, or itself) ends: each row is deleted once.
15+
- `restrict`: deleting a node that still has children is refused with `DELETE_RESTRICTED` (409), and the dependent named is the tree's own object. A leaf node still deletes.
16+
- On a required `tree` field, `set_null` cannot clear the parent, so deleting a node that has children is refused with `DELETE_RESTRICTED`, as it is for a required `lookup`.
17+
18+
Deleting a record of an object that has a `tree` field now runs as one transaction when the object is on the default datasource, as every delete that can cascade already does. If a node deep in the subtree refuses its delete (a `beforeDelete` hook, say), the nodes already deleted are restored and the call is refused. `lookup` and `master_detail` behave exactly as before.

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
* nothing outside it;
2626
* - `restrict` refuses with the ADR-0112 envelope naming the tree's own object
2727
* as the dependent, and keeps the row; a leaf still deletes;
28+
* - a REQUIRED tree's `set_null` escalates to that refusal, as a required
29+
* `lookup`'s does;
2830
* - a cycle in the data terminates: a corrupt row pointing at its own
2931
* descendant, and a row pointing at itself;
3032
* - a subtree cascade is one unit of work: a refusal from a node deep in it
@@ -300,6 +302,28 @@ describe('[#22957] a tree field\'s deleteBehavior takes effect on a parent delet
300302
});
301303
});
302304

305+
describe('required', () => {
306+
it('a required tree\'s set_null cannot clear the parent, so the parent delete is refused', async () => {
307+
// The escalation a required `lookup` already takes: a parent that must
308+
// be present cannot be cleared, so the delete is refused rather than
309+
// answered with the child's own `required` refusal.
310+
const { engine, driver } = await makeEngine([
311+
treeObject('zt_rooted', parsedTree('zt_rooted', { required: true })),
312+
]);
313+
// A required hierarchy's top points at itself; the engine refuses a
314+
// reference to a row that does not exist yet, so it is written at the
315+
// driver.
316+
const root = await driver.create('zt_rooted', { id: 'root', name: 'root', parent: 'root' });
317+
const c = await engine.insert('zt_rooted', { name: 'C', parent: root.id });
318+
319+
const err = await refusalOf(engine.delete('zt_rooted', { where: { id: root.id } } as any));
320+
321+
expect(err).toMatchObject({ code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'zt_rooted' });
322+
expect(driver.rowOf('zt_rooted', root.id)).toMatchObject({ name: 'root' });
323+
expect(driver.rowOf('zt_rooted', c.id)).toMatchObject({ name: 'C', parent: root.id });
324+
});
325+
});
326+
303327
describe('multiple: true', () => {
304328
it('is refused on a tree by the spec\'s parse, so no multi-valued tree reaches the walk through it', () => {
305329
const result = FieldSchema.safeParse({ type: 'tree', reference: 'zt_multi', multiple: true });

0 commit comments

Comments
 (0)