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
+27 -17
View File
@@ -228,21 +228,31 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() {
}
#[test]
fn replay_rejects_typed_slot_violation_at_parse_time() {
// Schema-aware replay (handoff-13 §2.1 fix): run_replay
// re-snapshots the schema per line and parses with
// parse_command_with_schema. So a wrong-type value in a
// value list is caught at *parse* time during replay —
// surfaced through the `replay.error_parse` wrapper ("parse
// error …") — exactly as the interactive path would, rather
// than only at bind time.
fn replay_rejects_wrong_type_value_in_a_hand_built_script() {
// Replay parses each line with the SAME schema-aware parser the
// interactive path uses, in **advanced mode** (the full surface),
// and executes the result — so a replayed line behaves exactly as
// if it had been typed interactively in advanced mode. Nothing is
// skipped or simplified during replay (handoff-13 §2.1: the schema
// is threaded so the parser is fully schema-aware).
//
// `'not a number'` (a string) lands in the int `count`
// slot. The schemaless parser would accept it (a string is
// a value literal) and only bind-time would reject; the
// schema-aware parser rejects it at parse time. Asserting
// the error went through the parse wrapper proves the
// schema was threaded.
// A real journal only ever contains commands that already executed
// successfully (history.log is success-only; ADR-0034's deferred
// journal replays `ok` lines only), so a wrong-type line like this
// never arises from a genuine replay. It only arises from a
// *hand-built* `.commands` script — the robustness case this test
// exercises: replay must reject the bad line and stop, leaving
// state intact, with the same error a user would see typing it.
//
// Where the rejection lands depends on the grammar the line
// matches, exactly as interactively: `insert into T values (…)` is
// SQL in advanced mode, and SQL defers column-type checking to the
// engine, so `'not a number'` in the int `count` column is rejected
// at **execute** time (the engine's column-type enforcement) rather
// than at parse time. Either way the line fails and is not applied.
// (Before sub-phase 3j, `insert` was a DSL-only entry word, so even
// advanced-mode parsing hit the DSL typed-slot rail and this was a
// parse-time rejection — ADR-0033 Amendment 3.)
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
@@ -261,12 +271,12 @@ fn replay_rejects_typed_slot_violation_at_parse_time() {
unreachable!()
};
assert!(
error.contains("parse error"),
"typed-slot violation should be caught at parse time, got: {error}",
!error.is_empty(),
"the rejected line must carry a reported error",
);
// The earlier two lines stayed applied; the failing insert
// did not run.
// did not run — state is intact.
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.expect("query_data");
+17 -17
View File
@@ -82,7 +82,7 @@ fn run_delete(
rt: &tokio::runtime::Runtime,
input: &str,
) -> Result<DeleteResult, DbError> {
match parse_command(input).expect("parse sql_delete") {
match parse_command(input).expect("parse delete") {
Command::SqlDelete { sql, target_table, returning } => {
rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning))
}
@@ -115,8 +115,8 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
#[test]
fn parse_path_lowers_sql_delete_to_command() {
let command = parse_command("sql_delete from Orders where id = 1")
.expect("sql_delete parses in advanced mode");
let command = parse_command("delete from Orders where id = 1")
.expect("delete parses in advanced mode");
match command {
Command::SqlDelete { sql, target_table, .. } => {
assert_eq!(sql, "delete from Orders where id = 1");
@@ -132,7 +132,7 @@ fn delete_with_where_persists() {
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'gone'), (2, 'keep')", "t");
let result = run_delete(&db, &rt, "sql_delete from t where id = 1").expect("delete runs");
let result = run_delete(&db, &rt, "delete from t where id = 1").expect("delete runs");
assert_eq!(result.rows_affected, 1, "one row deleted");
assert!(result.cascade.is_empty(), "no children, no cascade");
let csv = read_csv(&project, "t").expect("t.csv");
@@ -147,7 +147,7 @@ fn delete_without_where_runs_across_all_rows() {
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'a'), (2, 'b'), (3, 'c')", "t");
let result = run_delete(&db, &rt, "sql_delete from t").expect("unfiltered delete runs");
let result = run_delete(&db, &rt, "delete from t").expect("unfiltered delete runs");
assert_eq!(result.rows_affected, 3, "all rows deleted");
// Empty tables produce no CSV (CLAUDE.md persistence note), so the
// file is either absent or has only a header — either way, no data.
@@ -165,7 +165,7 @@ fn cascade_delete_reports_summary_and_repersists_child() {
let rt = rt();
cascade_fixture(&db, &rt);
// Delete Alice (customer 1) — cascades to her two orders (10, 11).
let result = run_delete(&db, &rt, "sql_delete from Customers where id = 1")
let result = run_delete(&db, &rt, "delete from Customers where id = 1")
.expect("cascading delete runs");
assert_eq!(result.rows_affected, 1, "one parent row deleted");
assert_eq!(result.cascade.len(), 1, "one cascade relationship reported");
@@ -193,7 +193,7 @@ fn cascade_parity_with_dsl() {
let (_p_sql, db_sql, _d_sql) = open_project_db();
cascade_fixture(&db_sql, &rt);
let sql_result = run_delete(&db_sql, &rt, "sql_delete from Customers where id = 1")
let sql_result = run_delete(&db_sql, &rt, "delete from Customers where id = 1")
.expect("SQL delete runs");
let (_p_dsl, db_dsl, _d_dsl) = open_project_db();
@@ -223,7 +223,7 @@ fn r2_where_with_subquery() {
let result = run_delete(
&db,
&rt,
"sql_delete from Orders where CustId in (select id from Customers where Name = 'Alice')",
"delete from Orders where CustId in (select id from Customers where Name = 'Alice')",
)
.expect("subquery-WHERE delete runs");
assert_eq!(result.rows_affected, 2, "Alice's two orders deleted");
@@ -250,7 +250,7 @@ fn r2_cascade_with_subquery_where() {
let result = run_delete(
&db,
&rt,
"sql_delete from Customers where id in (select CustId from Orders where id = 11)",
"delete from Customers where id in (select CustId from Orders where id = 11)",
)
.expect("cascade + subquery-WHERE delete runs");
assert_eq!(result.rows_affected, 1, "Alice deleted");
@@ -267,7 +267,7 @@ fn delete_appends_literal_line_to_history() {
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'x')", "t");
let input = "sql_delete from t where id = 1";
let input = "delete from t where id = 1";
run_delete(&db, &rt, input).expect("delete runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present");
@@ -303,7 +303,7 @@ fn cascade_to_two_children_reports_both() {
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1), (11, 1)", "Orders");
seed(&db, &rt, "insert into Reviews (id, CustId) values (20, 1)", "Reviews");
let result = run_delete(&db, &rt, "sql_delete from Customers where id = 1")
let result = run_delete(&db, &rt, "delete from Customers where id = 1")
.expect("cascade-to-two delete runs");
assert_eq!(result.rows_affected, 1);
assert_eq!(result.cascade.len(), 2, "both cascade relationships reported");
@@ -332,7 +332,7 @@ fn delete_childless_parent_reports_no_cascade() {
cascade_fixture(&db, &rt);
// Carol (3) exists with no orders; deleting her cascades nothing.
seed(&db, &rt, "insert into Customers (id, Name) values (3, 'Carol')", "Customers");
let result = run_delete(&db, &rt, "sql_delete from Customers where id = 3")
let result = run_delete(&db, &rt, "delete from Customers where id = 3")
.expect("childless-parent delete runs");
assert_eq!(result.rows_affected, 1, "Carol deleted");
assert!(result.cascade.is_empty(), "no children → no cascade effect reported");
@@ -370,7 +370,7 @@ fn delete_violating_fk_fails_and_persists_nothing() {
seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers");
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1)", "Orders");
let input = "sql_delete from Customers where id = 1";
let input = "delete from Customers where id = 1";
let result = run_delete(&db, &rt, input);
assert!(result.is_err(), "delete of a referenced parent must be rejected");
// Rolled back: Alice survives.
@@ -406,7 +406,7 @@ fn self_referential_cascade_counts_only_cascaded_rows() {
.expect("add self-referential relationship");
seed(&db, &rt, "insert into T (id, ParentId) values (1, null), (2, 1), (3, 2)", "T");
let result =
run_delete(&db, &rt, "sql_delete from T where id = 1").expect("self-ref delete runs");
run_delete(&db, &rt, "delete from T where id = 1").expect("self-ref delete runs");
assert_eq!(result.rows_affected, 1, "one row matched the WHERE directly");
assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once");
assert_eq!(
@@ -421,7 +421,7 @@ fn internal_target_table_rejected_at_parse() {
// rejected at the target slot — the parse fails, the statement
// never reaches the worker.
assert!(
parse_command("sql_delete from __rdbms_playground_columns").is_err(),
parse_command("delete from __rdbms_playground_columns").is_err(),
"internal table must be rejected at the DELETE target slot"
);
}
@@ -436,7 +436,7 @@ fn delete_returning_yields_predelete_row() {
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'gone'), (2, 'keep')", "t");
let result = run_delete(&db, &rt, "sql_delete from t where id = 1 returning *")
let result = run_delete(&db, &rt, "delete from t where id = 1 returning *")
.expect("DELETE … RETURNING * runs");
assert_eq!(result.rows_affected, 1, "one row deleted");
// RETURNING yields the row as it was BEFORE deletion.
@@ -454,7 +454,7 @@ fn delete_returning_with_cascade_surfaces_both() {
let (_project, db, _dir) = open_project_db();
let rt = rt();
cascade_fixture(&db, &rt);
let result = run_delete(&db, &rt, "sql_delete from Customers where id = 1 returning *")
let result = run_delete(&db, &rt, "delete from Customers where id = 1 returning *")
.expect("cascading DELETE … RETURNING runs");
assert_eq!(result.rows_affected, 1, "one parent row deleted");
// RETURNING gave the deleted customer row.
+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");
+13 -13
View File
@@ -69,7 +69,7 @@ fn run_update(
rt: &tokio::runtime::Runtime,
input: &str,
) -> Result<UpdateResult, DbError> {
match parse_command(input).expect("parse sql_update") {
match parse_command(input).expect("parse update") {
Command::SqlUpdate { sql, target_table, returning } => {
rt.block_on(db.run_sql_update(sql, Some(input.to_string()), target_table, returning))
}
@@ -79,8 +79,8 @@ fn run_update(
#[test]
fn parse_path_lowers_sql_update_to_command() {
let command = parse_command("sql_update Orders set total = 0 where id = 1")
.expect("sql_update parses in advanced mode");
let command = parse_command("update Orders set total = 0 where id = 1")
.expect("update parses in advanced mode");
match command {
Command::SqlUpdate { sql, target_table, .. } => {
assert_eq!(sql, "update Orders set total = 0 where id = 1");
@@ -96,7 +96,7 @@ fn single_column_update_with_where_persists() {
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'old'), (2, 'keep')", "t");
let result = run_update(&db, &rt, "sql_update t set v = 'new' where id = 1")
let result = run_update(&db, &rt, "update t set v = 'new' where id = 1")
.expect("update runs");
assert_eq!(result.rows_affected, 1, "one row updated");
let csv = read_csv(&project, "t").expect("t.csv");
@@ -117,7 +117,7 @@ fn multi_column_update_persists() {
&["id"],
);
seed(&db, &rt, "insert into t (id, a, b) values (1, 0, 'x')", "t");
let result = run_update(&db, &rt, "sql_update t set a = 9, b = 'y' where id = 1")
let result = run_update(&db, &rt, "update t set a = 9, b = 'y' where id = 1")
.expect("multi-col update runs");
assert_eq!(result.rows_affected, 1);
let csv = read_csv(&project, "t").expect("t.csv");
@@ -131,7 +131,7 @@ fn update_without_where_runs_across_all_rows() {
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
seed(&db, &rt, "insert into t (id, active) values (1, true), (2, true)", "t");
let result = run_update(&db, &rt, "sql_update t set active = false")
let result = run_update(&db, &rt, "update t set active = false")
.expect("unfiltered update runs");
assert_eq!(result.rows_affected, 2, "all rows updated");
let csv = read_csv(&project, "t").expect("t.csv");
@@ -150,7 +150,7 @@ fn update_with_sql_expr_in_set() {
&["id"],
);
seed(&db, &rt, "insert into t (id, price, qty, total) values (1, 6, 7, 0)", "t");
let result = run_update(&db, &rt, "sql_update t set total = price * qty where id = 1")
let result = run_update(&db, &rt, "update t set total = price * qty where id = 1")
.expect("expression update runs");
assert_eq!(result.rows_affected, 1);
let csv = read_csv(&project, "t").expect("t.csv");
@@ -169,7 +169,7 @@ fn update_with_subquery_in_set() {
let result = run_update(
&db,
&rt,
"sql_update t set v = (select max(n) from other) where id = 1",
"update t set v = (select max(n) from other) where id = 1",
)
.expect("subquery-set update runs");
assert_eq!(result.rows_affected, 1);
@@ -185,7 +185,7 @@ fn update_matching_no_rows_is_ok() {
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t");
let result = run_update(&db, &rt, "sql_update t set v = 'x' where id = 999")
let result = run_update(&db, &rt, "update t set v = 'x' where id = 999")
.expect("no-match update is a success");
assert_eq!(result.rows_affected, 0, "no rows matched");
let csv = read_csv(&project, "t").expect("t.csv");
@@ -198,7 +198,7 @@ fn update_appends_literal_line_to_history() {
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'old')", "t");
let input = "sql_update t set v = 'new' where id = 1";
let input = "update t set v = 'new' where id = 1";
run_update(&db, &rt, input).expect("update runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present");
@@ -215,7 +215,7 @@ fn update_returning_yields_modified_columns() {
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'old'), (2, 'keep')", "t");
let result = run_update(&db, &rt, "sql_update t set v = 'new' where id = 1 returning id, v")
let result = run_update(&db, &rt, "update t set v = 'new' where id = 1 returning id, v")
.expect("UPDATE … RETURNING runs");
assert_eq!(result.rows_affected, 1, "one row updated");
assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()]);
@@ -230,7 +230,7 @@ fn update_returning_recovers_bare_column_type() {
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
seed(&db, &rt, "insert into t (id, active) values (1, false)", "t");
let result = run_update(&db, &rt, "sql_update t set active = true where id = 1 returning active")
let result = run_update(&db, &rt, "update t set active = true where id = 1 returning active")
.expect("UPDATE … 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()));
@@ -246,7 +246,7 @@ fn update_returning_matching_no_rows_is_ok_and_empty() {
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t");
let result = run_update(&db, &rt, "sql_update t set v = 'x' where id = 999 returning id, v")
let result = run_update(&db, &rt, "update t set v = 'x' where id = 999 returning id, v")
.expect("no-match UPDATE … RETURNING is a success");
assert_eq!(result.rows_affected, 0, "no rows matched");
assert!(result.data.rows.is_empty(), "no rows returned");
+21 -7
View File
@@ -13,13 +13,14 @@
#![allow(dead_code, unreachable_pub)]
use rdbms_playground::completion::{
Completion, SchemaCache, TableColumn, candidates_at_cursor,
Completion, SchemaCache, TableColumn, candidates_at_cursor_in_mode,
};
use rdbms_playground::dsl::parser::parse_command_with_schema;
use rdbms_playground::dsl::parser::parse_command_with_schema_in_mode;
use rdbms_playground::dsl::types::Type;
use rdbms_playground::input_render::{
AmbientHint, InputState, ambient_hint, classify_input_with_schema,
AmbientHint, InputState, ambient_hint_in_mode, classify_input_with_schema_in_mode,
};
use rdbms_playground::mode::Mode;
pub mod insert_form_a;
pub mod insert_form_b;
@@ -174,11 +175,24 @@ pub struct Assessment {
}
/// Assess the typing surface at the given cell.
///
/// The whole typing-surface matrix exercises the **DSL** surface
/// (insert Forms A/B/C, `update`/`delete` with `where` / `--all-rows`,
/// the DSL expression grammar, DDL, app commands) — which is the
/// **Simple-mode** surface (ADR-0003). So every facet here is computed
/// in Simple mode, and consistently so: `ambient_hint` already
/// defaults to Simple, and the others are pinned to Simple via their
/// `*_in_mode` variants. This matters since sub-phase 3j made
/// `insert`/`update`/`delete` shared entry words (ADR-0033
/// Amendment 3): in Advanced mode those route to the SQL grammar
/// (different completion / hints / parse), whereas the DSL forms this
/// matrix documents live in Simple mode. The SQL surface is covered
/// by `tests/sql_*.rs` and the advanced-mode walker diagnostics.
pub fn assess(input: &str, cursor: usize, schema: &SchemaCache) -> Assessment {
let state = classify_input_with_schema(input, schema);
let hint = ambient_hint(input, cursor, None, schema);
let completion = candidates_at_cursor(input, cursor, schema);
let parse_result = match parse_command_with_schema(input, schema) {
let state = classify_input_with_schema_in_mode(input, schema, Mode::Simple);
let hint = ambient_hint_in_mode(input, cursor, None, schema, Mode::Simple);
let completion = candidates_at_cursor_in_mode(input, cursor, schema, Mode::Simple);
let parse_result = match parse_command_with_schema_in_mode(input, schema, Mode::Simple) {
Ok(cmd) => Ok(command_kind_label(&cmd)),
Err(e) => Err(parse_error_label(&e)),
};
@@ -11,7 +11,7 @@ Assessment {
),
hint: Some(
Prose(
"for `Name`: Type a quoted string (e.g. 'Alice') or null (`id` auto-generated — skipped here; list columns explicitly, e.g. `insert into T (...) values (...)`, to set it.)",
"for `Name`: Type a quoted string (e.g. 'Alice') or null (`id` auto-generated — skipped here; list columns explicitly, e.g. `insert into T (...) values (...)`, to set it.) (valid as SQL in advanced mode — `mode advanced` or prefix `:`)",
),
),
completion: Some(