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
+41 -41
View File
@@ -222,8 +222,8 @@ fn failed_multi_row_insert_is_atomic() {
fn parse_path_lowers_sqlinsert_scaffold_to_command() {
// Advanced-mode parse of the dev scaffold reconstructs valid
// `insert …` SQL and extracts the target table.
let command = parse_command("sqlinsert into Orders (id, total) values (1, 99.5)")
.expect("sqlinsert parses in advanced mode");
let command = parse_command("insert into Orders (id, total) values (1, 99.5)")
.expect("insert parses in advanced mode");
match command {
Command::SqlInsert { sql, target_table, .. } => {
assert_eq!(sql, "insert into Orders (id, total) values (1, 99.5)");
@@ -235,7 +235,7 @@ fn parse_path_lowers_sqlinsert_scaffold_to_command() {
#[test]
fn parse_path_rejects_internal_target_table() {
let result = parse_command("sqlinsert into __rdbms_playground_columns values (1)");
let result = parse_command("insert into __rdbms_playground_columns values (1)");
assert!(
result.is_err(),
"an internal `__rdbms_*` target must be rejected: {result:?}",
@@ -262,7 +262,7 @@ fn create_named(db: &Database, rt: &tokio::runtime::Runtime, name: &str) {
#[test]
fn parse_path_lowers_insert_select_to_command() {
let command = parse_command("sqlinsert into archive select * from source")
let command = parse_command("insert into archive select * from source")
.expect("INSERT … SELECT parses in advanced mode");
match command {
Command::SqlInsert { sql, target_table, .. } => {
@@ -277,7 +277,7 @@ fn parse_path_lowers_insert_select_to_command() {
fn parse_path_lowers_with_prefixed_insert_select() {
// R4: a WITH-prefixed SELECT row source lowers verbatim.
let command = parse_command(
"sqlinsert into archive with t as (select * from orders) select * from t",
"insert into archive with t as (select * from orders) select * from t",
)
.expect("WITH-prefixed INSERT … SELECT parses");
match command {
@@ -436,7 +436,7 @@ fn run_sqlinsert(
rt: &tokio::runtime::Runtime,
input: &str,
) -> Result<InsertResult, DbError> {
match parse_command(input).expect("parse sqlinsert") {
match parse_command(input).expect("parse insert") {
Command::SqlInsert {
sql,
target_table,
@@ -489,7 +489,7 @@ fn values_autofills_omitted_shortid_pk() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x')")
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x')")
.expect("auto-fill insert runs");
assert_eq!(result.rows_affected, 1);
let rows = csv_rows(&project, "t");
@@ -506,7 +506,7 @@ fn values_multirow_autofills_distinct_shortids() {
let result = run_sqlinsert(
&db,
&rt,
"sqlinsert into t (label) values ('a'), ('b'), ('c')",
"insert into t (label) values ('a'), ('b'), ('c')",
)
.expect("multi-row auto-fill runs");
assert_eq!(result.rows_affected, 3);
@@ -526,7 +526,7 @@ fn explicit_shortid_value_is_respected() {
run_sqlinsert(
&db,
&rt,
"sqlinsert into t (id, label) values ('hardcoded', 'x')",
"insert into t (id, label) values ('hardcoded', 'x')",
)
.expect("explicit-id insert runs");
let rows = csv_rows(&project, "t");
@@ -539,12 +539,12 @@ fn insert_select_autofills_distinct_shortids() {
let rt = rt();
create_cols(&db, &rt, "source", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
create_cols(&db, &rt, "target", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
run_sqlinsert(&db, &rt, "sqlinsert into source (label) values ('a'), ('b')")
run_sqlinsert(&db, &rt, "insert into source (label) values ('a'), ('b')")
.expect("seed source");
let result = run_sqlinsert(
&db,
&rt,
"sqlinsert into target (label) select label from source",
"insert into target (label) select label from source",
)
.expect("INSERT … SELECT auto-fill runs");
assert_eq!(result.rows_affected, 2);
@@ -566,7 +566,7 @@ fn combined_serial_and_shortid_autofill() {
&[("id", Type::Serial), ("code", Type::ShortId), ("name", Type::Text)],
&["id"],
);
run_sqlinsert(&db, &rt, "sqlinsert into t (name) values ('x')")
run_sqlinsert(&db, &rt, "insert into t (name) values ('x')")
.expect("combined auto-fill runs");
let rows = csv_rows(&project, "t");
assert_eq!(rows.len(), 1, "{rows:?}");
@@ -583,7 +583,7 @@ fn autofill_logs_original_source_not_rewritten_sql() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
let input = "sqlinsert into t (label) values ('x')";
let input = "insert into t (label) values ('x')";
run_sqlinsert(&db, &rt, input).expect("auto-fill insert runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present");
@@ -603,7 +603,7 @@ fn shortid_autofill_respects_mixed_case_column_name() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("MyId", Type::ShortId), ("label", Type::Text)], &["MyId"]);
run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x')")
run_sqlinsert(&db, &rt, "insert into t (label) values ('x')")
.expect("mixed-case shortid auto-fill runs");
let rows = csv_rows(&project, "t");
assert_eq!(rows.len(), 1, "{rows:?}");
@@ -623,7 +623,7 @@ fn two_shortids_pk_and_nonpk_both_autofill_distinctly() {
&[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)],
&["id"],
);
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x'), ('y')")
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x'), ('y')")
.expect("two-shortid auto-fill runs");
assert_eq!(result.rows_affected, 2);
let rows = csv_rows(&project, "t");
@@ -650,7 +650,7 @@ fn two_shortids_one_provided_one_autofilled() {
&[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)],
&["id"],
);
run_sqlinsert(&db, &rt, "sqlinsert into t (id, label) values ('myid', 'x')")
run_sqlinsert(&db, &rt, "insert into t (id, label) values ('myid', 'x')")
.expect("partial-shortid insert runs");
let rows = csv_rows(&project, "t");
assert_eq!(rows[0][0], "myid", "provided id preserved: {rows:?}");
@@ -670,7 +670,7 @@ fn compound_pk_with_shortid_member_autofills() {
&[("id", Type::ShortId), ("region", Type::Int), ("label", Type::Text)],
&["id", "region"],
);
run_sqlinsert(&db, &rt, "sqlinsert into t (region, label) values (1, 'x')")
run_sqlinsert(&db, &rt, "insert into t (region, label) values (1, 'x')")
.expect("compound-pk insert runs");
let rows = csv_rows(&project, "t");
assert!(
@@ -690,7 +690,7 @@ fn autofill_does_not_mask_arity_mismatch() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
let outcome = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('a', 'b')");
let outcome = run_sqlinsert(&db, &rt, "insert into t (label) values ('a', 'b')");
assert!(
outcome.is_err(),
"arity mismatch must be rejected, not masked: {outcome:?}",
@@ -707,8 +707,8 @@ fn autofill_insert_select_wider_projection_is_rejected() {
let rt = rt();
create_cols(&db, &rt, "src", &[("a", Type::Text), ("b", Type::Text)], &["a"]);
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
run_sqlinsert(&db, &rt, "sqlinsert into src (a, b) values ('p', 'q')").expect("seed");
let outcome = run_sqlinsert(&db, &rt, "sqlinsert into t (label) select a, b from src");
run_sqlinsert(&db, &rt, "insert into src (a, b) values ('p', 'q')").expect("seed");
let outcome = run_sqlinsert(&db, &rt, "insert into t (label) select a, b from src");
assert!(outcome.is_err(), "wider projection must be rejected: {outcome:?}");
assert!(csv_rows(&project, "t").is_empty(), "nothing should land");
}
@@ -727,8 +727,8 @@ fn autofill_insert_select_narrower_projection_is_rejected() {
&[("id", Type::ShortId), ("x", Type::Text), ("y", Type::Text)],
&["id"],
);
run_sqlinsert(&db, &rt, "sqlinsert into src (a) values ('p')").expect("seed");
let outcome = run_sqlinsert(&db, &rt, "sqlinsert into t (x, y) select a from src");
run_sqlinsert(&db, &rt, "insert into src (a) values ('p')").expect("seed");
let outcome = run_sqlinsert(&db, &rt, "insert into t (x, y) select a from src");
assert!(outcome.is_err(), "narrower projection must be rejected: {outcome:?}");
assert!(csv_rows(&project, "t").is_empty(), "nothing should land");
}
@@ -742,7 +742,7 @@ fn insert_returning_star_returns_inserted_row() {
let (_project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (id, b) values (1, 'Ada') returning *")
let result = run_sqlinsert(&db, &rt, "insert into t (id, b) values (1, 'Ada') returning *")
.expect("INSERT … RETURNING * runs");
assert_eq!(result.rows_affected, 1, "one row inserted");
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the inserted row");
@@ -758,7 +758,7 @@ fn insert_multirow_returning_id_yields_distinct_rows() {
let result = run_sqlinsert(
&db,
&rt,
"sqlinsert into t (id, b) values (1, 'a'), (2, 'b'), (3, 'c') returning id",
"insert into t (id, b) values (1, 'a'), (2, 'b'), (3, 'c') returning id",
)
.expect("multi-row INSERT … RETURNING id runs");
assert_eq!(result.rows_affected, 3, "three rows inserted");
@@ -777,7 +777,7 @@ fn insert_returning_autofills_shortid_and_returns_it() {
let (_project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x') returning *")
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x') returning *")
.expect("auto-fill INSERT … RETURNING * runs");
assert_eq!(result.rows_affected, 1, "one row inserted (RETURNING-counted)");
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the row");
@@ -796,7 +796,7 @@ fn insert_returning_recovers_bare_column_type() {
let (_project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (id, active) values (1, true) returning active")
let result = run_sqlinsert(&db, &rt, "insert into t (id, active) values (1, true) returning active")
.expect("INSERT … RETURNING active runs");
assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered");
assert_eq!(result.data.rows[0][0], Some("true".to_string()), "rendered as the bool word");
@@ -809,7 +809,7 @@ fn insert_returning_computed_expression_is_typeless() {
let (_project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("n", Type::Int)], &["id"]);
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (id, n) values (1, 5) returning n + 1")
let result = run_sqlinsert(&db, &rt, "insert into t (id, n) values (1, 5) returning n + 1")
.expect("INSERT … RETURNING <expr> runs");
assert_eq!(result.data.column_types, vec![None], "computed projection is typeless");
assert_eq!(result.data.rows[0][0], Some("6".to_string()), "engine evaluated n + 1");
@@ -839,7 +839,7 @@ fn insert_returning_recovers_multiple_bare_column_types() {
let result = run_sqlinsert(
&db,
&rt,
"sqlinsert into t (id, txt, amount, ratio, flag) values (1, 'a', 9.50, 1.5, true) returning id, txt, amount, ratio, flag",
"insert into t (id, txt, amount, ratio, flag) values (1, 'a', 9.50, 1.5, true) returning id, txt, amount, ratio, flag",
)
.expect("INSERT … RETURNING <cols> runs");
assert_eq!(
@@ -867,7 +867,7 @@ fn multirow_autofill_returning_yields_distinct_generated_ids() {
let result = run_sqlinsert(
&db,
&rt,
"sqlinsert into t (label) values ('a'), ('b'), ('c') returning *",
"insert into t (label) values ('a'), ('b'), ('c') returning *",
)
.expect("multi-row auto-fill INSERT … RETURNING * runs");
assert_eq!(result.rows_affected, 3, "three rows inserted");
@@ -888,8 +888,8 @@ fn insert_select_returning_executes_and_returns_rows() {
let rt = rt();
create_cols(&db, &rt, "src", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
create_cols(&db, &rt, "dst", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
run_sqlinsert(&db, &rt, "sqlinsert into src (id, b) values (1, 'x'), (2, 'y')").expect("seed src");
let result = run_sqlinsert(&db, &rt, "sqlinsert into dst select * from src returning id, b")
run_sqlinsert(&db, &rt, "insert into src (id, b) values (1, 'x'), (2, 'y')").expect("seed src");
let result = run_sqlinsert(&db, &rt, "insert into dst select * from src returning id, b")
.expect("INSERT … SELECT … RETURNING runs");
assert_eq!(result.rows_affected, 2, "two rows copied");
assert_eq!(result.data.rows.len(), 2, "RETURNING yielded both inserted rows");
@@ -909,7 +909,7 @@ fn conflict_target_columns_excluded_from_listed_columns() {
// listed_columns (which drives shortid auto-fill) must NOT pick
// up the conflict-target columns. If it did, an omitted shortid
// would look "listed" and auto-fill would wrongly skip.
match parse_command("sqlinsert into t (name) values ('x') on conflict (id) do nothing")
match parse_command("insert into t (name) values ('x') on conflict (id) do nothing")
.expect("parse upsert")
{
Command::SqlInsert { listed_columns, .. } => {
@@ -946,11 +946,11 @@ fn autofill_upsert_real_conflict_preserves_clause_and_excluded() {
None,
))
.expect("create table with shortid pk + unique code");
run_sqlinsert(&db, &rt, "sqlinsert into t (code, label) values ('A', 'first')").expect("seed");
run_sqlinsert(&db, &rt, "insert into t (code, label) values ('A', 'first')").expect("seed");
let result = run_sqlinsert(
&db,
&rt,
"sqlinsert into t (code, label) values ('A', 'second') on conflict (code) do update set label = excluded.label",
"insert into t (code, label) values ('A', 'second') on conflict (code) do update set label = excluded.label",
)
.expect("auto-filled UPSERT with a real conflict (clause preserved)");
assert_eq!(result.rows_affected, 1, "the conflicting row was updated, not inserted");
@@ -965,11 +965,11 @@ fn on_conflict_do_nothing_keeps_existing_row() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
run_sqlinsert(&db, &rt, "sqlinsert into t (id, name) values (1, 'orig')").expect("seed");
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
let result = run_sqlinsert(
&db,
&rt,
"sqlinsert into t (id, name) values (1, 'new') on conflict (id) do nothing",
"insert into t (id, name) values (1, 'new') on conflict (id) do nothing",
)
.expect("ON CONFLICT DO NOTHING runs");
assert_eq!(result.rows_affected, 0, "conflicting row left untouched");
@@ -983,11 +983,11 @@ fn on_conflict_do_update_applies_excluded() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
run_sqlinsert(&db, &rt, "sqlinsert into t (id, name) values (1, 'orig')").expect("seed");
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
let result = run_sqlinsert(
&db,
&rt,
"sqlinsert into t (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name",
"insert into t (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name",
)
.expect("ON CONFLICT DO UPDATE runs");
assert_eq!(result.rows_affected, 1, "the conflicting row was updated");
@@ -1001,11 +1001,11 @@ fn on_conflict_do_nothing_without_target() {
let (_project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
run_sqlinsert(&db, &rt, "sqlinsert into t (id, name) values (1, 'orig')").expect("seed");
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
let result = run_sqlinsert(
&db,
&rt,
"sqlinsert into t (id, name) values (1, 'x') on conflict do nothing",
"insert into t (id, name) values (1, 'x') on conflict do nothing",
)
.expect("ON CONFLICT (no target) DO NOTHING runs");
assert_eq!(result.rows_affected, 0, "any-conflict do-nothing absorbed the duplicate");
@@ -1026,7 +1026,7 @@ fn autofill_preserves_on_conflict_clause() {
let result = run_sqlinsert(
&db,
&rt,
"sqlinsert into t (label) values ('x') on conflict (id) do nothing",
"insert into t (label) values ('x') on conflict (id) do nothing",
)
.expect("auto-fill INSERT with ON CONFLICT runs (clause preserved)");
assert_eq!(result.rows_affected, 1, "row inserted with a generated id");