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
+51 -1
View File
@@ -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)
// =================================================================
+5
View File
@@ -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
+145
View File
@@ -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");
}
}