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