Files
rdbms-playground/src/dsl/grammar/sql_delete.rs
T
claude@clouddev1 d5c7f63513 grammar+walker: 3j — shared insert/update/delete entry words (ADR-0033 §2 / Amendments 1 & 3)
Wire `insert`/`update`/`delete` as shared DSL/SQL entry words through the
category-grouped dispatcher (ADR-0033 Amendment 1): the Advanced SQL nodes
move off the dev words (`sqlinsert`/`sql_update`/`sql_delete`) to the real
keywords, registered alongside the Simple DSL nodes. Remove the dev-word
scaffold; collapse build_sql_{insert,update,delete} to source.trim();
de-duplicate the two REGISTRY entry-word listing sites.

Dispatch model (ADR-0033 Amendment 3, written this round):
- A command is the mode-rooted grammar-path outcome; identity is intrinsic.
  Advanced mode tries SQL first, falling back to the Simple DSL command when
  no SQL branch matches a token (`delete … --all-rows` falls back;
  `update … --all-rows` does not — the SET expression absorbs it, harmless
  since the engine treats `--all-rows` as a comment).
- Simple mode commits the DSL candidate for a shared word, surfacing the real
  DSL error; bare "this is SQL" is reserved for SQL-only entry words
  (`select`/`with`). A content rejection on the SQL candidate (internal
  table) is committed, never masked by the DSL fallback.

Combined DSL-error + advanced-SQL pointer (ADR-0033 Amendment 3): a Simple-mode
definite DSL error that would run as SQL in advanced mode gains the
`advanced_mode.also_valid_sql` suffix — in the live hint (ambient_hint_in_mode)
and on submit (dispatch_dsl), via the shared advanced_alternative_note — so the
actionable DSL fix and the mode pointer coexist (submit covers constructs that
surface only on submit, e.g. `delete … returning`).

Internal-table rejection symmetrised (/runda finding B, ADR-0030 §6): the DSL
data-command target slots (insert/update/delete/show data/show table) gained
reject_internal_table, so `__rdbms_*` tables are refused in Simple mode too —
previously only the advanced SQL grammar rejected them.

Mode-awareness: classify_input_with_schema_in_mode and
invalid_ident_at_cursor_in_mode stop leaking the advanced SQL view into
simple-mode hints for shared words.

Tests: dev-word inputs migrated to the real words (advanced); DSL grammar /
completion / phase-D / db tests parse in Simple mode (the DSL surface); replay
keeps its advanced-mode model (one stale assertion fixed); dispatcher routing,
combined-pointer, and internal-table tests added. Suite 1626 pass / 0 fail /
1 ignored; clippy --all-targets -D warnings clean.

Defer M4 (execution-time mode side-channel; tracked in requirements.md) to its
own ADR.
2026-05-23 21:13:39 +00:00

156 lines
5.9 KiB
Rust

//! 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 <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::{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 <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 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");
}
}