grammar+db: 3b — SQL INSERT grammar + minimal execution (ADR-0033 §1)

SQL_INSERT_SHAPE (INTO <table> [(cols)] VALUES tuple(s)) with __rdbms_*
target rejection; Command::SqlInsert{sql,target_table}; Request::RunSqlInsert
+ do_sql_insert worker (tx-guarded: execute, then finalize_persistence for
CSV + history before commit, so failures roll back and don't re-persist).
Auto-show is best-effort via last_insert_rowid range.

Isolated behind a dev `sqlinsert` entry word (Advanced) so the SQL path is
testable without making `insert` a shared word yet (that's 3j, after 3d
auto-fill parity). Command::SqlInsert carries only sql+target_table; the
plan's listed_columns/returning land in 3d/3g where they're read.

6 grammar accept/reject tests + 8 integration tests (single/multi-row,
column-list, full-arity, history, rollback-on-failure, multi-row atomicity,
parse-path reconstruction, internal-table rejection). 1452 baseline green.
This commit is contained in:
claude@clouddev1
2026-05-21 18:51:21 +00:00
parent 4e16d97fe0
commit c87363168f
10 changed files with 605 additions and 3 deletions
+197
View File
@@ -0,0 +1,197 @@
//! SQL `INSERT` grammar (ADR-0033 §1, sub-phase 3b).
//!
//! Grammar-as-text (ADR-0030 §4): the walker validates that the
//! `INSERT` is in the supported subset; the worker executes the
//! validated SQL text and re-persists the target table's CSV
//! (ADR-0030 §11). The shape here is the post-`INSERT` portion —
//! the entry-word dispatch consumes the leading `INSERT` keyword
//! before this shape walks (mirroring `sql_select::SQL_SELECT_TAIL`).
//!
//! Scope (3b): single- and multi-row `VALUES`, an optional
//! `(column_name_list)`, and the `__rdbms_*` target rejection.
//! `INSERT … SELECT` (3c), `shortid` auto-fill (3d), `RETURNING`
//! (3g), and `ON CONFLICT … ` UPSERT (3h) land in later
//! sub-phases.
use crate::dsl::grammar::sql_expr;
use crate::dsl::grammar::sql_select::reject_internal_table;
use crate::dsl::grammar::{IdentSource, Node, Word};
static COMMA: Node = Node::Punct(',');
/// The `INSERT` target table. `__rdbms_*` rejected (ADR-0030 §6 /
/// ADR-0033 §1). `writes_table` populates `current_table` /
/// `current_table_columns` so the optional column list and the
/// `VALUES` expressions get column completion against the target.
const TARGET_TABLE: Node = Node::Ident {
source: IdentSource::Tables,
role: "insert_target_table",
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,
};
/// One column name inside the optional `(col1, col2, …)` list.
///
/// `writes_user_listed_column` stays `false` in 3b — the worker
/// requires explicit values for every column, so the listed-column
/// set isn't needed yet. Sub-phase 3d (`shortid` auto-fill) turns
/// it on and threads `listed_columns` into `Command::SqlInsert`.
static COLUMN_NAME: Node = Node::Ident {
source: IdentSource::Columns,
role: "insert_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static COLUMN_LIST_NODES: &[Node] = &[
Node::Punct('('),
Node::Repeated {
inner: &COLUMN_NAME,
separator: Some(&COMMA),
min: 1,
},
Node::Punct(')'),
];
const OPTIONAL_COLUMN_LIST: Node = Node::Optional(&Node::Seq(COLUMN_LIST_NODES));
/// One value expression inside a `VALUES` tuple. Consumes the
/// shared `sql_expr` grammar (ADR-0031), so literals, operators,
/// `CASE`, function calls, etc. are all admitted; the engine
/// evaluates them at execution time.
static VALUE_EXPR: Node = Node::Subgrammar(&sql_expr::SQL_OR_EXPR);
static VALUE_TUPLE_NODES: &[Node] = &[
Node::Punct('('),
Node::Repeated {
inner: &VALUE_EXPR,
separator: Some(&COMMA),
min: 1,
},
Node::Punct(')'),
];
/// `'(' sql_expr (',' sql_expr)* ')'` — one row of values.
static VALUE_TUPLE: Node = Node::Seq(VALUE_TUPLE_NODES);
static VALUES_CLAUSE_NODES: &[Node] = &[
Node::Word(Word::keyword("values")),
Node::Repeated {
inner: &VALUE_TUPLE,
separator: Some(&COMMA),
min: 1,
},
];
/// `VALUES tuple (',' tuple)*` — single- or multi-row.
const VALUES_CLAUSE: Node = Node::Seq(VALUES_CLAUSE_NODES);
static SQL_INSERT_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("into")),
TARGET_TABLE,
OPTIONAL_COLUMN_LIST,
VALUES_CLAUSE,
Node::Optional(&Node::Punct(';')),
];
/// The post-`INSERT` portion of a SQL `INSERT` statement
/// (ADR-0033 §1): `INTO <table> [ '(' col_list ')' ] VALUES
/// <tuple> (',' <tuple>)* [ ';' ]`.
///
/// The entry-word dispatch consumes the leading `INSERT` keyword
/// before this shape walks, so a `CommandNode` references it as
/// its `shape` (sub-phase 3b registers a development entry word;
/// sub-phase 3j wires the shared `insert` entry word).
pub static SQL_INSERT_SHAPE: Node = Node::Seq(SQL_INSERT_TAIL_NODES);
// =================================================================
// Tests — grammar accept/reject for the post-`INSERT` tail.
// =================================================================
#[cfg(test)]
mod tests {
use super::SQL_INSERT_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 INSERT tail. Returns `true` only
/// when the walk matches *and* consumes all of `input`
/// (trailing whitespace allowed). Schemaless context: 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_INSERT_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 INSERT tail");
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete INSERT tail");
}
#[test]
fn single_row_values() {
good("into orders values (1, 2.0)");
good("into orders values (1, 'text', true, null)");
good("into orders values (1);");
}
#[test]
fn multi_row_values() {
good("into orders values (1, 'a'), (2, 'b')");
good("into orders values (1), (2), (3)");
good("into orders values (1, 'a'), (2, 'b');");
}
#[test]
fn explicit_column_list() {
good("into orders (id, total) values (1, 2.0)");
good("into orders (id) values (1)");
good("into orders (a, b, c) values (1, 2, 3), (4, 5, 6)");
}
#[test]
fn value_expressions_admit_sql_expr() {
good("into t values (1 + 2)");
good("into t values (case when 1 > 0 then 'y' else 'n' end)");
}
#[test]
fn internal_target_table_rejected() {
bad("into __rdbms_playground_columns values (1)");
bad("into __rdbms_playground_relationships (a) values (1)");
}
#[test]
fn structurally_incomplete_or_wrong_rejected() {
// Missing VALUES.
bad("into orders");
bad("into orders (id, total)");
// Empty value tuple — at least one expression required.
bad("into orders values ()");
// Missing INTO.
bad("orders values (1)");
// Trailing comma with no following tuple.
bad("into orders values (1),");
// Unclosed tuple.
bad("into orders values (1, 2");
}
}