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:
@@ -1485,6 +1485,12 @@ impl App {
|
||||
C::SqlUpdate { target_table, .. } => {
|
||||
(Operation::Update, Some(target_table.as_str()), None)
|
||||
}
|
||||
// A SQL `DELETE` (ADR-0033 §1/§7) — route engine errors
|
||||
// (e.g. an FK violation with no cascade) through the
|
||||
// delete operation with the parsed target.
|
||||
C::SqlDelete { target_table, .. } => {
|
||||
(Operation::Delete, Some(target_table.as_str()), None)
|
||||
}
|
||||
C::Replay { .. } => (Operation::Replay, None, None),
|
||||
// An `explain` failure (e.g. unknown table) is best
|
||||
// described by the wrapped query it failed to plan.
|
||||
|
||||
@@ -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).
|
||||
|
||||
+15
-1
@@ -320,6 +320,18 @@ pub enum Command {
|
||||
sql: String,
|
||||
target_table: String,
|
||||
},
|
||||
/// A SQL `DELETE` validated by the walker (ADR-0033 §1/§7,
|
||||
/// advanced mode). Grammar-as-text: the worker executes `sql`,
|
||||
/// observes any FK cascade by row-count diffing (Amendment 2 —
|
||||
/// the same mechanism the DSL `do_delete` uses), and re-persists
|
||||
/// `target_table`'s CSV plus every cascade-affected child
|
||||
/// (ADR-0030 §11). The worker never inspects the WHERE clause, so
|
||||
/// no predicate is carried here. `RETURNING` (3g) is added by the
|
||||
/// sub-phase that reads it.
|
||||
SqlDelete {
|
||||
sql: String,
|
||||
target_table: String,
|
||||
},
|
||||
/// App-lifecycle command (per ADR-0003). These work in both
|
||||
/// simple and advanced modes; the dispatcher branches on the
|
||||
/// `Command::App(...)` variant before mode-specific routing.
|
||||
@@ -617,6 +629,7 @@ impl Command {
|
||||
Self::Select { .. } => "select",
|
||||
Self::SqlInsert { .. } => "insert into",
|
||||
Self::SqlUpdate { .. } => "update",
|
||||
Self::SqlDelete { .. } => "delete from",
|
||||
Self::App(app) => match app {
|
||||
AppCommand::Quit => "quit",
|
||||
AppCommand::Help => "help",
|
||||
@@ -687,7 +700,8 @@ impl Command {
|
||||
// A SQL `INSERT` carries its parsed target table (for
|
||||
// CSV re-persistence and ok-summary subject).
|
||||
Self::SqlInsert { target_table, .. }
|
||||
| Self::SqlUpdate { target_table, .. } => target_table,
|
||||
| Self::SqlUpdate { target_table, .. }
|
||||
| Self::SqlDelete { target_table, .. } => target_table,
|
||||
// App commands aren't tied to schema entities — the
|
||||
// verb is the most identifying thing. The
|
||||
// display_subject override below provides a richer
|
||||
|
||||
+51
-1
@@ -20,7 +20,7 @@ use crate::dsl::command::{Command, Expr, RowFilter};
|
||||
use crate::dsl::grammar::{
|
||||
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
|
||||
shared::{column_value_list, current_column_value},
|
||||
sql_insert, sql_select, sql_update,
|
||||
sql_delete, sql_insert, sql_select, sql_update,
|
||||
};
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
use crate::dsl::value::Value;
|
||||
@@ -952,6 +952,39 @@ fn build_sql_update(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
||||
Ok(Command::SqlUpdate { sql, target_table })
|
||||
}
|
||||
|
||||
/// Build `Command::SqlDelete` from a validated SQL `DELETE`
|
||||
/// (ADR-0033 §1/§7, sub-phase 3f). Extracts the target table from
|
||||
/// the matched path so the worker re-persists the right CSV and
|
||||
/// snapshots the right inbound children for cascade diffing. No
|
||||
/// WHERE clause is captured — the worker executes the verbatim SQL
|
||||
/// and never inspects the predicate (Amendment 2).
|
||||
///
|
||||
/// Dev-scaffold detail: the entry word is `sql_delete` (not valid
|
||||
/// SQL), so the statement is reconstructed as `delete` + the matched
|
||||
/// tail (which opens at `from`). Sub-phase 3j wires the real
|
||||
/// `delete` entry word, at which point this collapses to
|
||||
/// `source.trim()`.
|
||||
fn build_sql_delete(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||||
// The DELETE target is the first `table_name` ident (it precedes
|
||||
// any table referenced inside a WHERE subquery).
|
||||
let target_table = path
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item.kind {
|
||||
MatchedKind::Ident {
|
||||
role: "table_name", ..
|
||||
} => Some(item.text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tail = path
|
||||
.items
|
||||
.first()
|
||||
.map_or(source, |entry| &source[entry.span.1..]);
|
||||
let sql = format!("delete {}", tail.trim());
|
||||
Ok(Command::SqlDelete { sql, target_table })
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// CommandNodes
|
||||
// =================================================================
|
||||
@@ -1061,6 +1094,23 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
/// SQL `DELETE` development scaffold (ADR-0033 sub-phase 3f).
|
||||
///
|
||||
/// Registered under the temporary entry word `sql_delete` so the
|
||||
/// SQL DELETE grammar and execution path (including cascade-summary
|
||||
/// parity) can be exercised in isolation, WITHOUT yet making
|
||||
/// `delete` a shared DSL/SQL entry word. Sharing `delete` is
|
||||
/// sub-phase 3j. This scaffold (entry word + reconstruction in
|
||||
/// `build_sql_delete`) is removed when 3j wires the real `delete`
|
||||
/// entry word.
|
||||
pub static SQL_DELETE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("sql_delete"),
|
||||
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
||||
ast_builder: build_sql_delete,
|
||||
help_id: None,
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// Tests — `explain` grammar (ADR-0028 §1)
|
||||
// =================================================================
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod ddl;
|
||||
pub mod expr;
|
||||
pub mod shared;
|
||||
pub mod sql_expr;
|
||||
pub mod sql_delete;
|
||||
pub mod sql_insert;
|
||||
pub mod sql_select;
|
||||
pub mod sql_update;
|
||||
@@ -580,6 +581,10 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
// `sql_update` entry word keeps it isolated from the DSL
|
||||
// `update` word until 3j wires the shared entry.
|
||||
(&data::SQL_UPDATE, CommandCategory::Advanced),
|
||||
// SQL DELETE development scaffold (sub-phase 3f); the temporary
|
||||
// `sql_delete` entry word keeps it isolated from the DSL
|
||||
// `delete` word until 3j wires the shared entry.
|
||||
(&data::SQL_DELETE, CommandCategory::Advanced),
|
||||
];
|
||||
|
||||
/// Whether `entry` names an advanced-mode-only command (ADR-0030
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! SQL `DELETE` grammar (ADR-0033 §1/§7, sub-phase 3f).
|
||||
//!
|
||||
//! Grammar-as-text (ADR-0030 §4): the walker validates that the
|
||||
//! `DELETE` is in the supported subset; the worker executes the
|
||||
//! validated SQL text, observes any FK cascade by row-count diffing
|
||||
//! (ADR-0033 Amendment 2), and re-persists the target table's CSV
|
||||
//! plus every cascade-affected child (ADR-0030 §11). The shape here
|
||||
//! is the post-`DELETE` portion — the entry-word dispatch consumes
|
||||
//! the leading `DELETE` keyword before this shape walks, so the
|
||||
//! shape opens at `FROM` (mirroring `sql_update::SQL_UPDATE_SHAPE`,
|
||||
//! where the dev `sql_update` word stands in for `UPDATE`).
|
||||
//!
|
||||
//! Scope (3f): `FROM <table> [ WHERE … ] [ ';' ]`, the `__rdbms_*`
|
||||
//! target rejection, and the shared `sql_expr` on the WHERE
|
||||
//! predicate. There is no `--all-rows` rail — a SQL `DELETE` without
|
||||
//! `WHERE` runs as written (ADR-0030 §12). `RETURNING` (3g) lands
|
||||
//! later. The worker never inspects the WHERE clause (Amendment 2),
|
||||
//! so no predicate-byte extraction is needed.
|
||||
|
||||
use crate::dsl::grammar::sql_select::{WHERE_CLAUSE, reject_internal_table};
|
||||
use crate::dsl::grammar::{IdentSource, Node, Word};
|
||||
|
||||
/// The `DELETE` target table. `__rdbms_*` rejected (ADR-0030 §6 /
|
||||
/// ADR-0033 §1). `writes_table` populates `current_table` /
|
||||
/// `current_table_columns` so the WHERE predicate gets column
|
||||
/// completion against the target.
|
||||
///
|
||||
/// Uses the shared `table_name` role (not a bespoke one) so the
|
||||
/// Phase-2 schema-existence + predicate-warning passes collect it
|
||||
/// as a scope binding and check the WHERE columns against it for
|
||||
/// free (ADR-0033 §2's "cross-cut from Phase-2 machinery"; the
|
||||
/// handoff-31 §3e finding that a bespoke role leaves the WHERE
|
||||
/// unchecked).
|
||||
const TARGET_TABLE: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
role: "table_name",
|
||||
validator: Some(reject_internal_table),
|
||||
highlight_override: None,
|
||||
writes_table: true,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
static SQL_DELETE_TAIL_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("from")),
|
||||
TARGET_TABLE,
|
||||
Node::Optional(&WHERE_CLAUSE),
|
||||
Node::Optional(&Node::Punct(';')),
|
||||
];
|
||||
|
||||
/// The post-`DELETE` portion of a SQL `DELETE` statement
|
||||
/// (ADR-0033 §1): `FROM <table> [ WHERE … ] [ ';' ]`.
|
||||
///
|
||||
/// The entry-word dispatch consumes the leading `DELETE` keyword
|
||||
/// before this shape walks, so a `CommandNode` references it as its
|
||||
/// `shape` (sub-phase 3f registers a development entry word;
|
||||
/// sub-phase 3j wires the shared `delete` entry word).
|
||||
pub static SQL_DELETE_SHAPE: Node = Node::Seq(SQL_DELETE_TAIL_NODES);
|
||||
|
||||
// =================================================================
|
||||
// Tests — grammar accept/reject for the post-`DELETE` tail.
|
||||
// =================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SQL_DELETE_SHAPE;
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
use crate::dsl::walker::driver::{NodeWalkResult, walk_node};
|
||||
use crate::dsl::walker::outcome::MatchedPath;
|
||||
|
||||
/// Walk `input` against the DELETE tail. Returns `true` only
|
||||
/// when the walk matches *and* consumes all of `input`
|
||||
/// (trailing whitespace allowed). Schemaless: the shape is
|
||||
/// structural, so table/column idents match by shape and
|
||||
/// `reject_internal_table` still fires on `__rdbms_*`.
|
||||
fn walks(input: &str) -> bool {
|
||||
let mut ctx = WalkContext::new();
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
match walk_node(input, 0, &SQL_DELETE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
|
||||
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn good(input: &str) {
|
||||
assert!(walks(input), "{input:?} should be a valid DELETE tail");
|
||||
}
|
||||
|
||||
fn bad(input: &str) {
|
||||
assert!(!walks(input), "{input:?} should NOT walk as a complete DELETE tail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_with_where() {
|
||||
good("from orders where id = 1");
|
||||
good("from orders where id = 1;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_without_where_runs_across_all_rows() {
|
||||
// ADR-0030 §12: no `--all-rows` rail — a SQL DELETE without
|
||||
// WHERE is structurally valid.
|
||||
good("from orders");
|
||||
good("from orders;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn where_admits_sql_expr() {
|
||||
good("from orders where total > 100 and note is null");
|
||||
good("from orders where created < '2025-01-01'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn where_admits_subquery_r2() {
|
||||
// R2 invariant (ADR-0033 §7 / Amendment 2): the WHERE may
|
||||
// itself contain a subquery. The shape admits it; the worker
|
||||
// executes the verbatim statement and never extracts the
|
||||
// predicate, so the nested subquery is just part of the SQL.
|
||||
good("from orders where customer_id in (select id from customers where country = 'DE')");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_target_table_rejected() {
|
||||
bad("from __rdbms_playground_columns");
|
||||
bad("from __rdbms_playground_relationships where id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structurally_incomplete_or_wrong_rejected() {
|
||||
// Missing FROM.
|
||||
bad("orders where id = 1");
|
||||
bad("orders");
|
||||
// FROM with no table.
|
||||
bad("from");
|
||||
bad("from where id = 1");
|
||||
// Incomplete WHERE predicate.
|
||||
bad("from orders where");
|
||||
// DELETE has no SET clause — trailing SET must not consume.
|
||||
bad("from orders set v = 1");
|
||||
}
|
||||
}
|
||||
@@ -1902,6 +1902,16 @@ async fn execute_command_typed(
|
||||
.run_sql_update(sql, src, target_table)
|
||||
.await
|
||||
.map(CommandOutcome::Update),
|
||||
// A SQL `DELETE` (advanced mode; ADR-0033 §1/§7). Grammar-
|
||||
// as-text: the worker runs the validated `sql`, detects FK
|
||||
// cascade by row-count diffing, and re-persists the target
|
||||
// plus every cascade-affected child. Reuses the DSL delete
|
||||
// outcome (affected-row count + per-relationship cascade
|
||||
// summary).
|
||||
Command::SqlDelete { sql, target_table } => database
|
||||
.run_sql_delete(sql, src, target_table)
|
||||
.await
|
||||
.map(CommandOutcome::Delete),
|
||||
// `EXPLAIN QUERY PLAN` never executes the wrapped
|
||||
// statement (ADR-0028 §2), so explaining a destructive
|
||||
// command is safe. `src` is unused here — explain is a
|
||||
|
||||
Reference in New Issue
Block a user