grammar+db: 3f — SQL DELETE + cascade summary (ADR-0033 §1/§7)

New src/dsl/grammar/sql_delete.rs (FROM <table> [WHERE] [;]),
Command::SqlDelete, Request::RunSqlDelete, do_sql_delete worker.

do_sql_delete mirrors the DSL do_delete: detect FK cascade by
before/after child row-count diffing, re-persist target + every
cascade-affected child, history-on-success inside the tx. Reuses
CommandOutcome::Delete -> handle_dsl_delete_success, so the
per-relationship cascade summary formatter is shared, not duplicated.

ADR-0033 Amendment 2: supersedes §7's WHERE-injected pre-count. Its
premise (DSL handler builds pre-counts from the typed Expr) was wrong
— do_delete uses count-diff. The pre-count would also have broken the
§2 parity promise by reporting SET NULL the DSL path doesn't. Count-
diff gives exact parity, no WHERE-byte extraction, and withdraws R2.
SET NULL reporting deferred for both paths (user-confirmed).

Tests: +6 grammar unit, +12 integration (cascade parity with DSL,
both R2 subquery cases, before-execute order, no-WHERE, FK-rejection
rollback, childless-parent, two-child cascade). 1542 pass / 0 fail /
1 ignored. Clippy clean. Dev sql_delete entry word removed in 3j.
This commit is contained in:
claude@clouddev1
2026-05-22 14:59:01 +00:00
parent 70ecf5535e
commit 2c86a1313e
11 changed files with 856 additions and 3 deletions
+94
View File
@@ -1176,6 +1176,100 @@ Concretely:
shared-entry problem (`create`, …): tag the SQL DDL nodes
`Advanced`; the dispatcher handles the rest.
## Amendment 2 — Cascade summary on SQL DELETE: count-diff parity supersedes WHERE-injected pre-count (2026-05-22)
This amendment **supersedes §7's "Predicate extraction" subsection
and the pre-count mechanism in §7 steps 13**, and **withdraws Open
Implementation Risk R2**. It was written during sub-phase 3f, after
tracing §7's prescribed mechanism against the actual DSL `do_delete`
handler, and is recorded with explicit user approval before any 3f
code landed.
### The finding — §7's premise about the DSL handler is incorrect
§7 prescribes a per-child **pre-execution pre-count** of the form
`SELECT count(*) FROM <child> WHERE <fk_col> IN (SELECT <pk_col> FROM
<target_table> WHERE <user_where_predicate>)`, with the
`<user_where_predicate>` re-injected from the SQL DELETE's
WHERE-clause source bytes. Its stated justification:
> "The DSL handler reuses the typed `Expr` AST to construct the
> pre-count subqueries."
Tracing `do_delete` in `src/db.rs` shows this premise is factually
wrong. The DSL handler does **not** construct pre-count subqueries
from the typed `Expr`. It detects cascade effects by **before/after
row-count diffing**: read the inbound relationships, count each child
table's rows *before* the DELETE, execute the DELETE inside a
transaction, count each child *after*, and report the positive
difference as a `CascadeEffect`. No pre-count query and no
`Expr`-derived subquery exist anywhere in the path.
### Two consequences of the correct mechanism
1. **Count-diff reports `ON DELETE CASCADE` row removals only.** `ON
DELETE SET NULL` does not change a child's row count, so the DSL
path does not report it (its own code comment notes this).
Reporting SET NULL would require value-level diffing.
2. **§7's pre-count is in tension with the parity promise.** §2
promises "a DSL-style … DELETE in Advanced mode produces identical
observable behaviour whether it routes through the SQL or DSL
path", and the plan's 3f exit gate requires the SQL cascade summary
to *match* the DSL `do_delete` output. A pre-count that counted
`ON DELETE CASCADE` *and* `SET NULL` (as §7 step 1 says) would
report **more** than the DSL path — breaking that parity. To match
the DSL path it would have to drop SET NULL anyway, leaving
WHERE-byte extraction and the R2 N+1-subquery pathology as pure
cost with no benefit over count-diff.
### The replacement — count-diff parity
`do_sql_delete` mirrors `do_delete` exactly: read inbound
relationships, snapshot child row counts, run the **verbatim**
validated DELETE SQL inside a transaction via
`execute_with_fk_enrichment`, diff the child counts, build the same
`Vec<CascadeEffect>`, and re-persist the target plus every
cascade-affected child before commit. Because both handlers return
the same `DeleteResult` and both route through
`CommandOutcome::Delete` → `handle_dsl_delete_success`, the
per-relationship summary formatter is **already shared at the render
layer** — no formatter refactor and no duplicate path.
This dominates the §7 mechanism on every axis the 3f exit gate
measures:
- **Exact parity** with the DSL path — identical detection mechanism,
not merely a matching formatter.
- **No WHERE-clause byte extraction.** The verbatim DELETE (including
any subquery in its WHERE) executes once; the engine cascades; the
diff observes the result. The R2 invariant case (`DELETE FROM a
WHERE id IN (SELECT … )`) is correct by construction and carries no
N+1 cost. **R2 is withdrawn.**
- **Shared render** with no duplicate formatter, satisfying §7's
shared-formatter promise structurally.
### SET NULL reporting — deferred for both paths (user-confirmed)
3f keeps strict DSL parity: `ON DELETE CASCADE` row removals are
reported; `SET NULL` is not, on either path. Adding SET NULL
reporting (value-level diffing) is a tracked future enhancement to be
applied to **both** `do_delete` and `do_sql_delete` together so
parity is preserved. The user confirmed this deferral.
### Consequences of the amendment
- §7's "Predicate extraction" subsection and pre-count steps 13 are
superseded by count-diff; the per-relationship summary and
shared-formatter outcomes are unchanged.
- **R2 (cascade-summary predicate extraction) is withdrawn** — the
mechanism it budgeted for is no longer used.
- `Command::SqlDelete` carries `{ sql, target_table }` only — no
WHERE-clause field is needed (the worker never inspects the
predicate).
- The handoff-31 §4 "WHERE-byte-extraction is tractable for DELETE"
heads-up is moot — no extraction happens.
## See also
- ADR-0005 — the ten-type vocabulary INSERT works with.