Skip to content

cascadeDeleteRelations refuses a required multi-value lookup delete even when member removal would leave the set non-empty #9688

Description

@os-steve

Filing unassigned — recording, not claiming. Measured while landing #9625 (docs/comment correction for the explicit-vs-defaulted set_null divergence); the fixture below ships in that PR pinning the CURRENT behaviour, so this card is the decision, not the pin.

Measured

cascadeDeleteRelations, packages/objectql/src/engine.ts. The escalation

if(behavior==='set_null'&&fdef.required===true){behavior='restrict';}

runs before the multiValued branch and keys on required alone. Measured with a real engine + stub driver, a required: true, multiple: true lookup whose child set holds two members:

  • child roster.accounts = [acct_a, acct_b], both live
  • DELETE acct_a — refused, code: DELETE_RESTRICTED, status: 409, dependentCount: 1
  • the set is left [acct_a, acct_b]; no member removal ran; the parent survives

Same outcome whether deleteBehavior: 'set_null' is written explicitly or left to default.

Removing acct_a would have left [acct_b] — a non-empty array, which the #9447 ruling (2026-08-18, required on a multi-value lookup means NON-EMPTY array) accepts. So the refusal is broader than the contract requires.

Why the escalation's own rationale does not reach this case

The engine's stated reason for escalating is that a set_null write would clear a required FK and trip the child's required-field validator with a misleading " is required" 400. On a multiple: true field the set_null limb does not clear the slot — since #9438 it removes the deleted MEMBER and writes the remainder. So whenever the remainder is non-empty, the premise of the escalation is simply false, and the delete is refused for a reason that does not apply.

The escalation IS correct for the sub-case where the deleted member is the LAST one: the write would produce [], which under #9447 does violate required. The right discrimination is therefore per-ROW (does removing this member empty THIS row's set?), not per-FIELD — and today it runs per-field, before dependents has even been probed.

Why this is not a straightforward "narrow it" — and why it is blocked

Today the blanket refusal is load-bearing. #9476 records that the required-means-non-empty half is declared but not enforced: isMissing in the record validator treats only undefined, null and blank strings as missing, so [] passes the required check on both insert and update. Narrow the escalation now and the last-member case stops being refused loudly and starts landing silently as an empty required set — strictly worse than the current over-restriction.

So the ordering is: #9476 first, then this. Grading it as deliberate conservatism versus over-restriction is not either/or — it is over-restriction in principle that is currently doing real work.

(2026-08-19 update, PM session: #9476 closed via merged PR #9780, so the ordering constraint above is satisfied and the spent Blocked-by: #9476 line has been removed from this body per protocol. See the ruling comment.)

Shape of the fix, once unblocked

Move the emptiness judgement after the dependents probe and the multi-value narrowing, and decide per row: a row whose set would still be non-empty after member removal takes the set_null limb; a row that would be emptied keeps the restrict refusal. Note the refusal envelope's dependentCount would then need to count only the rows that would be emptied, not every referencing row — a count that names rows the delete no longer refuses over is its own small defect.

Current behaviour is pinned

packages/objectql/src/engine-cascade-delete.test.ts (via #9625) carries [#9625] refuses a required MULTI-VALUE lookup even when member removal would leave the set non-empty, plus the control showing the same shape WITHOUT required does remove the member. Changing the behaviour here means updating that pin deliberately, which is the point of pinning it.

Refs: #9625 (where this was measured), #9476 (the enforcement gap this waited on — closed), #9447 (the ruling), #9438 (member removal).


Generated by Claude Code

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions