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
+136
View File
@@ -605,6 +605,18 @@ enum Request {
target_table: String,
reply: oneshot::Sender<Result<UpdateResult, DbError>>,
},
/// Run a grammar-validated SQL `DELETE` (ADR-0033 §1/§7). The
/// worker executes `sql` as text, detects FK cascade by
/// row-count diffing the inbound children (Amendment 2),
/// re-persists `target_table`'s CSV plus every cascade-affected
/// child (ADR-0030 §11), and appends the literal line to
/// `history.log`.
RunSqlDelete {
sql: String,
source: Option<String>,
target_table: String,
reply: oneshot::Sender<Result<DeleteResult, DbError>>,
},
/// Capture the query plan for an explainable command via
/// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner
/// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN`
@@ -1110,6 +1122,29 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Run a validated SQL `DELETE` and return the affected-row
/// count plus any cascade effects (ADR-0033 §1/§7, sub-phase
/// 3f). `sql` is the grammar-validated statement text; `source`
/// is the literal submitted line for `history.log`;
/// `target_table` is the parsed target whose CSV (and whose
/// cascade-affected children's CSVs) are re-persisted.
pub async fn run_sql_delete(
&self,
sql: String,
source: Option<String>,
target_table: String,
) -> Result<DeleteResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlDelete {
sql,
source,
target_table,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Capture the query plan for an explainable command
/// (ADR-0028 §2). The wrapped command is not executed —
/// `EXPLAIN QUERY PLAN` only inspects how the engine would
@@ -1587,6 +1622,20 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
&target_table,
));
}
Request::RunSqlDelete {
sql,
source,
target_table,
reply,
} => {
let _ = reply.send(do_sql_delete(
conn,
persistence,
source.as_deref(),
&sql,
&target_table,
));
}
Request::RebuildFromText {
project_path,
source,
@@ -6069,6 +6118,93 @@ fn do_sql_update(
})
}
/// Worker handler for `Request::RunSqlDelete` (ADR-0033 §1/§7,
/// sub-phase 3f). Mirrors the DSL `do_delete` exactly, differing
/// only in that it executes the verbatim grammar-validated `sql`
/// rather than building the statement from a typed filter.
///
/// Cascade detection (ADR-0033 Amendment 2): the worker snapshots
/// each inbound child table's row count *before* the DELETE, runs
/// the statement inside a transaction (the engine applies any
/// `ON DELETE CASCADE`), counts the children again *after*, and
/// reports the positive difference as a [`CascadeEffect`]. This is
/// the identical mechanism the DSL path uses, so the SQL and DSL
/// DELETE produce the same per-relationship summary on the same
/// schema/data — and since both return a [`DeleteResult`] routed
/// through `CommandOutcome::Delete`, the render-layer formatter is
/// shared with no duplication. `ON DELETE SET NULL` leaves row
/// counts unchanged and so is not reported on either path (a
/// deferred enhancement for both).
///
/// Because the diff observes the result of executing the whole
/// statement, the WHERE clause is never inspected — a WHERE that
/// itself contains a subquery (the R2 invariant) is correct by
/// construction and carries no extra per-child query cost.
///
/// Persistence discipline matches `do_delete` / `do_sql_update`:
/// re-persist the target's CSV *and every cascade-affected child's*
/// CSV via `finalize_persistence` (which also appends `source` to
/// `history.log`) *before* `tx.commit()`, so a persistence failure
/// rolls the delete back. A DELETE matching zero rows is a success
/// (`rows_affected == 0`, empty cascade); the target's CSV is still
/// re-persisted, keeping the path uniform.
fn do_sql_delete(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
sql: &str,
target_table: &str,
) -> Result<DeleteResult, DbError> {
debug!(sql = %sql, table = %target_table, "sql_delete");
// Snapshot child-table row counts before the delete so cascade
// effects can be detected by diffing afterwards (Amendment 2;
// identical to `do_delete`). ON UPDATE CASCADE / ON DELETE SET
// NULL do not change row counts and so are not detected here.
let inbound = read_relationships_inbound(conn, target_table)?;
let mut before_counts: Vec<i64> = Vec::with_capacity(inbound.len());
for r in &inbound {
before_counts.push(count_rows(conn, &r.other_table)?);
}
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let rows_affected = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
// Compare child-table counts after the delete; positive diffs
// are cascade effects. Collect the cascaded tables so the
// persistence phase rewrites their CSVs too.
let mut cascade: Vec<CascadeEffect> = Vec::new();
let mut rewritten_tables: Vec<String> = vec![target_table.to_string()];
for (rel, before_count) in inbound.iter().zip(before_counts.iter()) {
let after_count = count_rows(conn, &rel.other_table)?;
let diff = before_count - after_count;
if diff > 0 {
cascade.push(CascadeEffect {
relationship_name: rel.name.clone(),
child_table: rel.other_table.clone(),
rows_changed: diff,
action: rel.on_delete,
});
rewritten_tables.push(rel.other_table.clone());
}
}
let changes = Changes {
schema_dirty: false,
rewritten_tables,
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(DeleteResult {
rows_affected,
cascade,
})
}
/// Execute a grammar-validated SQL `SELECT` and collect its
/// rows into a [`DataResult`] (ADR-0030 §6, ADR-0032 §12 +
/// Amendment 1).