//! 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`. `delete` is a shared entry word //! (sub-phase 3j): this `Advanced` SQL shape and the `Simple` DSL //! delete node both register under `delete`. //! //! Scope (3f): `FROM [ 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::{RETURNING_CLAUSE, 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(&RETURNING_CLAUSE), Node::Optional(&Node::Punct(';')), ]; /// The post-`DELETE` portion of a SQL `DELETE` statement /// (ADR-0033 §1): `FROM
[ 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 returning_tail_admitted() { // 3g: optional RETURNING projection_list tail. good("from orders where id = 1 returning *"); good("from orders returning id, total"); good("from orders where id = 1 returning id as gone;"); } #[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"); } }