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.
This commit is contained in:
claude@clouddev1
2026-05-23 21:13:39 +00:00
parent c16196fc7f
commit d5c7f63513
22 changed files with 956 additions and 314 deletions
+58 -73
View File
@@ -32,8 +32,14 @@ use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
const TABLE_NAME_EXISTING: Node = Node::Ident {
source: IdentSource::Tables,
// Reject `__rdbms_*` internal tables at the table-source slot
// (ADR-0030 §6 — "every table-source slot"), matching the SQL
// grammar's `reject_internal_table`. Without this, simple-mode DSL
// data commands could read/write the internal metadata tables
// even though advanced-mode SQL rejects them (ADR-0033
// Amendment 3 / `/runda` finding B).
role: "table_name",
validator: None,
validator: Some(sql_select::reject_internal_table),
highlight_override: None,
writes_table: false,
writes_column: false,
@@ -49,8 +55,10 @@ writes_projection_alias: false,
/// dispatch typed slots per column.
const TABLE_NAME_INSERT: Node = Node::Ident {
source: IdentSource::Tables,
// Reject `__rdbms_*` internal tables (ADR-0030 §6; `/runda`
// finding B) — see `TABLE_NAME_EXISTING`.
role: "table_name",
validator: None,
validator: Some(sql_select::reject_internal_table),
highlight_override: None,
writes_table: true,
writes_column: false,
@@ -224,8 +232,12 @@ const INSERT_SHAPE: Node = Node::Seq(INSERT_NODES);
/// can resolve column types (Phase D).
const TABLE_NAME_WRITES: Node = Node::Ident {
source: IdentSource::Tables,
// Reject `__rdbms_*` internal tables (ADR-0030 §6; `/runda`
// finding B) — see `TABLE_NAME_EXISTING`. Shared by `update`,
// `delete`, and `show data`, so all three reject the internal
// metadata tables, matching the SQL grammar.
role: "table_name",
validator: None,
validator: Some(sql_select::reject_internal_table),
highlight_override: None,
writes_table: true,
writes_column: false,
@@ -856,14 +868,10 @@ fn build_select(_path: &MatchedPath, source: &str) -> Result<Command, Validation
}
/// Build `Command::SqlInsert` from a validated SQL `INSERT`
/// (ADR-0033 §1, sub-phase 3b). Extracts the target table from
/// the matched path so the worker re-persists the right CSV.
///
/// Dev-scaffold detail: the entry word is `sqlinsert` (not valid
/// SQL), so the statement is reconstructed as `insert` + the
/// matched tail. Sub-phase 3j wires the real `insert` entry word,
/// at which point this collapses to `source.trim()` like
/// `build_select`.
/// (ADR-0033 §1). Extracts the target table from the matched path
/// so the worker re-persists the right CSV. `insert` is now the
/// real (shared) entry word, so the validated `source` runs
/// verbatim — like `build_select` (sub-phase 3j).
fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
let target_table = path
.items
@@ -938,13 +946,10 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
.to_string()
})
.unwrap_or_default();
// Everything after the entry word is the `INTO …` tail; prefix
// the real `insert` keyword for the engine.
let tail = path
.items
.first()
.map_or(source, |entry| &source[entry.span.1..]);
let sql = format!("insert {}", tail.trim());
// The entry word is the real `insert` keyword (sub-phase 3j),
// so the validated line runs verbatim (grammar-as-text,
// ADR-0030 §4) — no keyword reconstruction.
let sql = source.trim().to_string();
Ok(Command::SqlInsert {
sql,
target_table,
@@ -966,13 +971,10 @@ fn path_has_returning(path: &MatchedPath) -> bool {
}
/// Build `Command::SqlUpdate` from a validated SQL `UPDATE`
/// (ADR-0033 §2, sub-phase 3e). Extracts the target table from the
/// matched path so the worker re-persists the right CSV.
///
/// Dev-scaffold detail: the entry word is `sql_update` (not valid
/// SQL), so the statement is reconstructed as `update` + the
/// matched tail. Sub-phase 3j wires the real `update` entry word,
/// at which point this collapses to `source.trim()`.
/// (ADR-0033 §2). Extracts the target table from the matched path
/// so the worker re-persists the right CSV. `update` is now the
/// real (shared) entry word, so the validated `source` runs
/// verbatim (sub-phase 3j).
fn build_sql_update(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
// The UPDATE target is the first `table_name` ident (it
// precedes any table referenced inside a SET / WHERE subquery).
@@ -986,11 +988,7 @@ fn build_sql_update(path: &MatchedPath, source: &str) -> Result<Command, Validat
_ => None,
})
.unwrap_or_default();
let tail = path
.items
.first()
.map_or(source, |entry| &source[entry.span.1..]);
let sql = format!("update {}", tail.trim());
let sql = source.trim().to_string();
Ok(Command::SqlUpdate {
sql,
target_table,
@@ -999,17 +997,13 @@ fn build_sql_update(path: &MatchedPath, source: &str) -> Result<Command, Validat
}
/// 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()`.
/// (ADR-0033 §1/§7). 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). `delete` is now the real
/// (shared) entry word, so the validated `source` runs verbatim
/// (sub-phase 3j).
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).
@@ -1023,11 +1017,7 @@ fn build_sql_delete(path: &MatchedPath, source: &str) -> Result<Command, Validat
_ => None,
})
.unwrap_or_default();
let tail = path
.items
.first()
.map_or(source, |entry| &source[entry.span.1..]);
let sql = format!("delete {}", tail.trim());
let sql = source.trim().to_string();
Ok(Command::SqlDelete {
sql,
target_table,
@@ -1110,51 +1100,46 @@ pub static WITH: CommandNode = CommandNode {
help_id: None,
usage_ids: &["parse.usage.select"],};
/// SQL `INSERT` development scaffold (ADR-0033 sub-phase 3b3i).
/// SQL `INSERT` — the `Advanced`-category node of the shared
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
///
/// Registered under the temporary entry word `sqlinsert` so the
/// SQL INSERT grammar and execution path can be exercised in
/// isolation, WITHOUT yet making `insert` a shared DSL/SQL entry
/// word. Sharing `insert` is sub-phase 3j, which depends on
/// `shortid` auto-fill (3d) so advanced-mode DSL inserts keep
/// parity rather than regressing through an incomplete SQL path.
/// This scaffold (entry word + reconstruction in `build_sql_insert`)
/// is removed when 3j wires the real `insert` entry word.
/// `insert` is a shared entry word: this `Advanced` SQL node and
/// the `Simple` DSL [`INSERT`] node both register under `insert`.
/// In Advanced mode the dispatcher (`walker::walk` / `decide`)
/// tries this SQL node first and falls back to the DSL node when
/// the SQL shape does not match; in Simple mode only the DSL node
/// is reachable (Amendment 3 — command identity is the mode-rooted
/// grammar-path outcome).
pub static SQL_INSERT: CommandNode = CommandNode {
entry: Word::keyword("sqlinsert"),
entry: Word::keyword("insert"),
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
ast_builder: build_sql_insert,
help_id: None,
usage_ids: &[],
};
/// SQL `UPDATE` development scaffold (ADR-0033 sub-phase 3e).
/// SQL `UPDATE` — the `Advanced` node of the shared `update` word.
///
/// Registered under the temporary entry word `sql_update` so the
/// SQL UPDATE grammar and execution path can be exercised in
/// isolation, WITHOUT yet making `update` a shared DSL/SQL entry
/// word. Sharing `update` is sub-phase 3j. This scaffold (entry
/// word + reconstruction in `build_sql_update`) is removed when 3j
/// wires the real `update` entry word.
/// ADR-0033 §2 / Amendment 1, sub-phase 3j. Pairs with the `Simple`
/// DSL [`UPDATE`] node; dispatch is SQL-first / DSL-fallback in
/// Advanced mode, DSL-only in Simple.
pub static SQL_UPDATE: CommandNode = CommandNode {
entry: Word::keyword("sql_update"),
entry: Word::keyword("update"),
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
ast_builder: build_sql_update,
help_id: None,
usage_ids: &[],
};
/// SQL `DELETE` development scaffold (ADR-0033 sub-phase 3f).
/// SQL `DELETE` — the `Advanced` node of the shared `delete` word.
///
/// 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.
/// ADR-0033 §2 / Amendment 1, sub-phase 3j. Pairs with the `Simple`
/// DSL [`DELETE`] node; dispatch is SQL-first / DSL-fallback in
/// Advanced mode, DSL-only in Simple. In Advanced mode `delete from t
/// --all-rows` falls back to the DSL node (the SQL shape has no
/// `--all-rows`).
pub static SQL_DELETE: CommandNode = CommandNode {
entry: Word::keyword("sql_delete"),
entry: Word::keyword("delete"),
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
ast_builder: build_sql_delete,
help_id: None,