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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user