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
45 changes: 45 additions & 0 deletions .changeset/cascade-required-multivalue-per-row.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/objectql": patch
---

fix(engine): the required-FK escalation on a `multiple: true` lookup is judged per ROW — a parent delete is refused only over the rows member removal would EMPTY (#9688)

`cascadeDeleteRelations` escalated `set_null` → `restrict` on `fdef.required === true`
before the multi-value branch and before the dependents probe had run, so a delete was
refused for every row that referenced the record, whatever else that row's set held.
Measured with a real engine + stub driver: a child holding `accounts: [acct_a, acct_b]`
on a `required: true, multiple: true` lookup refused `DELETE acct_a` with
`DELETE_RESTRICTED` / 409 / `dependentCount: 1`, leaving the set untouched.

**The escalation's own rationale is what bounds it.** It exists because clearing a
required foreign key issues an UPDATE the child's validator rejects with a misleading
`"<field> 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
that failure is only reachable for a row the removal would EMPTY. Removing `acct_a`
above writes `[acct_b]`, a non-empty required set no validator objects to; the delete was
refused citing a failure that could not have happened.

**Now decided per row, after the dependents probe and the exact multi-value narrowing:**

- remainder non-empty → the member is removed and the delete proceeds (#9438 semantics,
which the #9447 ruling accepts);
- remainder empty (the deleted member was the last) → `DELETE_RESTRICTED` stands, because
`[]` violates `required` on a multi-value field under #9447 and is rejected by the
record validator since #9476;
- when both kinds of row reference the record the whole delete is refused, and
`dependentCount` now counts **only the rows that would be emptied** — previously it
counted every referencing row, naming rows the delete no longer objects to.

The judgement and the write share one function (`remainderAfterMemberRemoval`), so the
predicate that clears the write can never predict a shape the write would not produce.

**Unchanged:** single-valued `set_null` on a required lookup still escalates (clearing a
scalar FK always writes `null`), an authored `deleteBehavior: 'restrict'` still refuses
regardless of emptiness, `cascade` is untouched, and a non-required multi-value lookup
keeps removing the member as before.

The #9625 fixture pinning the previous, broader refusal is updated deliberately rather
than repaired — that is what it was pinned for — and the last-member refusal is pinned
beside it, since that pin is what makes the narrowing safe. Also pinned: the defaulted
`set_null` spelling reaches the same per-row judgement as the explicit one, an authored
`restrict` is not narrowed, and `dependentCount` reports the refused rows only.
4 changes: 4 additions & 0 deletions content/docs/api/data-api.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -228,6 +228,10 @@ Every relation pointing at the deleted record honours its own `deleteBehavior`
refused with `409 DELETE_RESTRICTED`. That happens whether the `set_null` was
defaulted or written out explicitly (see
[Required foreign keys](/docs/protocol/objectql/types#lookup)). On a
`multiple: true` required lookup the substitution is judged per referencing
ROW: the delete is refused only over rows whose set the member removal would
empty, and `dependentCount` counts just those rows — a row that keeps another
member is updated and does not block the delete. On a
`multiple: true` reference where `set_null` does run, it
removes just the deleted id from the array and keeps the rest, and a reference set
emptied that way reads back as `[]` — never `null`, so a client that branches on
Expand Down
2 changes: 1 addition & 1 deletion content/docs/data-modeling/field-types.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -315,7 +315,7 @@ Reference to a record in another object (foreign key).
|:---|:---|:---|:---|
| `reference` | `string` | **required** | Target object name (snake_case) |
| `referenceFilters` | `string[]` | — | **Removed** (#2377, ADR-0049) — no longer a recognized field property (unknown keys are stripped by the schema). Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) |
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted. On a *required* lookup `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared — **whether the `set_null` was defaulted or written out explicitly**, and on a `multiple: true` required lookup too. `cascade` and `restrict` are the values honored as written. Where `set_null` does run, a `multiple: true` lookup loses only the deleted **member** — the other members are kept, and a set emptied that way is stored as `[]`, never `null` |
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted. On a *required* lookup `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared — **whether the `set_null` was defaulted or written out explicitly**. On a `multiple: true` required lookup the escalation is judged per referencing row: only a row the member removal would leave EMPTY is refused. `cascade` and `restrict` are the values honored as written. Where `set_null` does run, a `multiple: true` lookup loses only the deleted **member** — the other members are kept, and a set emptied that way is stored as `[]`, never `null` |

```typescript
{ name: 'company', label: 'Company', type: 'lookup', reference: 'account' }
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/troubleshooting.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -207,7 +207,7 @@ client.data.find('project_task', { /* query */ });
**Cause:** The record has dependent child records via a `lookup` or `master_detail` field, and that field resolves to `restrict`. Two routes get there:

1. The field declares `deleteBehavior: 'restrict'`.
2. The field is a `required: true` lookup whose behavior is `set_null`. A required foreign key cannot be cleared, so `set_null` is escalated to `restrict` — including when `set_null` is written out explicitly, and including a `required: true` lookup with `multiple: true`. Check the refusal's `developerMessage`: the escalated route says `(<field> is required, so it cannot be cleared)`.
2. The field is a `required: true` lookup whose behavior is `set_null`. A required foreign key cannot be cleared, so `set_null` is escalated to `restrict` — including when `set_null` is written out explicitly. On a `multiple: true` required lookup the escalation is judged per referencing row, because `set_null` removes only the deleted member there: rows that keep another member are updated, and the delete is refused only over the rows the removal would leave EMPTY (`dependentCount` counts just those). Check the refusal's `developerMessage`: the escalated route says `(<field> is required, so it cannot be cleared)`.

**Fix:**
1. Delete or reassign the dependent records first
Expand Down
15 changes: 11 additions & 4 deletions content/docs/protocol/objectql/types.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -648,10 +648,17 @@ const opportunities = await engine.find('opportunity', {
> tests the *resolved* behavior, so it cannot tell the two apart: writing
> `set_null` explicitly on a required lookup does not opt out of the refusal,
> and it does not change the outcome in any way. `cascade` and `restrict` are
> the two values that are honored as written. On a `multiple: true` required
> lookup the refusal comes first as well, before the member-removal rule below
> applies — so the parent delete is refused even when the child's set holds
> other members.
> the two values that are honored as written.
>
> On a `multiple: true` required lookup the escalation is judged **per row**,
> after the referencing rows are known — because `set_null` there removes only
> the deleted **member** (see the member-removal rule above), and a required
> set is only violated when nothing is left. A row that still holds another
> member is written its remainder and the parent delete goes through; a row the
> removal would EMPTY keeps the refusal, since `[]` does not satisfy `required`
> on a multi-value field. When both kinds of row reference the record, the
> delete is refused and `dependentCount` counts only the rows that would have
> been emptied.
>
> On `master_detail` the same reading applies from the other side: `restrict`
> is the only value that deviates from `cascade`, so an explicit
Expand Down
Loading
Loading