Files
rdbms-playground/tests/sql_insert.rs
T
claude@clouddev1 78ad476d24 db+grammar: 3d — shortid auto-fill for SQL INSERT (ADR-0033 §6)
When an INSERT's column list omits one or more shortid columns,
the worker now fills them. Command::SqlInsert gains listed_columns
and row_source, captured in build_sql_insert from the matched path
(the row source is located by the first values/select/with Word
token, so a string literal like 'select' can't be mistaken for the
keyword). do_sql_insert calls plan_shortid_autofill, which — per
the user-confirmed Option B — materialises the row source by
running it as a query, generates a distinct shortid per row via the
existing generate_shortid_batch (deduped against stored values),
and reconstructs a parameterised multi-row INSERT over the listed
columns plus the omitted shortid columns. Uniform for VALUES and
INSERT…SELECT, and handles multiple omitted shortids in one row
(each gets its own batch). No explicit list, no omitted shortid, or
a zero-row source → execute verbatim (the 3b path). serial stays
engine-filled via rowid. history.log keeps the original line, never
the rewrite (§11).

Tests: VALUES single/multi-row distinct; explicit override
honoured; INSERT…SELECT distinct fills; combined serial(engine) +
shortid(worker); two shortids (PK + non-PK) both fill; one provided
+ one omitted; compound-PK shortid member; mixed-case column name
(ADR-0009 DA gate); original-source-in-history on the rewrite path.
Still behind the dev `sqlinsert` entry word (3j). 1503 green,
clippy clean.
2026-05-22 07:26:54 +00:00

664 lines
23 KiB
Rust

//! Sub-phase 3b integration tests for the advanced-mode SQL
//! `INSERT` surface (ADR-0033 §1).
//!
//! Covers:
//! - Worker round-trip: a validated `INSERT` runs against the
//! database, returns the affected-row count, and re-persists the
//! target table's CSV (ADR-0030 §11).
//! - Single-row, multi-row, explicit-column-list, and full-arity
//! (no column list) forms.
//! - `history.log` records the literal submitted line.
//! - A failing INSERT (PK conflict) rolls back and does NOT
//! re-persist the CSV.
//! - Parse path: `parse_command` (advanced mode) lowers the dev
//! `sqlinsert` scaffold to `Command::SqlInsert`, reconstructing
//! valid `insert …` SQL and extracting the target table.
//! - `__rdbms_*` target tables are rejected at the grammar layer.
//!
//! The dev `sqlinsert` entry word keeps the SQL INSERT path
//! isolated from the DSL `insert` word until sub-phase 3j; the
//! worker-level tests call `db.run_sql_insert` directly with the
//! real reconstructed SQL.
use rdbms_playground::db::{Database, DbError, InsertResult};
use rdbms_playground::dsl::{ColumnSpec, Command, Type, parse_command};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence");
(project, db, dir)
}
fn read_csv(project: &project::Project, table: &str) -> Option<String> {
std::fs::read_to_string(project.path().join("data").join(format!("{table}.csv"))).ok()
}
/// Create a two-column table `T(a int pk, b text)` — no
/// auto-generated columns, so 3b's explicit-values requirement is
/// satisfied by every INSERT below.
fn create_t(db: &Database, rt: &tokio::runtime::Runtime) {
rt.block_on(db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Text),
],
vec!["a".to_string()],
None,
))
.expect("create table T");
}
#[test]
fn single_row_insert_persists_and_counts() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_t(&db, &rt);
let result = rt
.block_on(db.run_sql_insert(
"insert into T (a, b) values (1, 'Ada')".to_string(),
Some("insert into T (a, b) values (1, 'Ada')".to_string()),
"T".to_string(),
Vec::new(),
String::new(),
))
.expect("insert runs");
assert_eq!(result.rows_affected, 1, "one row inserted");
let csv = read_csv(&project, "T").expect("T.csv written after insert");
assert!(csv.contains("Ada"), "CSV reflects the inserted row: {csv:?}");
}
#[test]
fn multi_row_insert_persists_both_rows() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_t(&db, &rt);
let result = rt
.block_on(db.run_sql_insert(
"insert into T (a, b) values (1, 'first'), (2, 'second')".to_string(),
None,
"T".to_string(),
Vec::new(),
String::new(),
))
.expect("multi-row insert runs");
assert_eq!(result.rows_affected, 2, "two rows inserted");
let csv = read_csv(&project, "T").expect("T.csv written");
assert!(
csv.contains("first") && csv.contains("second"),
"CSV reflects both inserted rows: {csv:?}",
);
}
#[test]
fn no_column_list_full_arity_insert_persists() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_t(&db, &rt);
let result = rt
.block_on(db.run_sql_insert(
"insert into T values (7, 'full-arity')".to_string(),
None,
"T".to_string(),
Vec::new(),
String::new(),
))
.expect("full-arity insert runs");
assert_eq!(result.rows_affected, 1);
let csv = read_csv(&project, "T").expect("T.csv written");
assert!(csv.contains("full-arity"), "CSV reflects the row: {csv:?}");
}
#[test]
fn insert_appends_literal_line_to_history() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_t(&db, &rt);
// ADR-0030 §11: the literal submitted line lands in history.log.
let source = "insert into T (a, b) values (1, 'logged')";
rt.block_on(db.run_sql_insert(
"insert into T (a, b) values (1, 'logged')".to_string(),
Some(source.to_string()),
"T".to_string(),
Vec::new(),
String::new(),
))
.expect("insert runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present after an INSERT");
assert!(
body.contains(source),
"history.log records the literal INSERT line: {body:?}",
);
}
#[test]
fn failed_insert_rolls_back_and_does_not_repersist() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_t(&db, &rt);
// First insert succeeds and persists.
rt.block_on(db.run_sql_insert(
"insert into T (a, b) values (1, 'kept')".to_string(),
None,
"T".to_string(),
Vec::new(),
String::new(),
))
.expect("first insert runs");
// Second insert violates the primary key — it must fail and
// leave persistence untouched (the transaction rolls back
// before finalize_persistence).
let outcome = rt.block_on(db.run_sql_insert(
"insert into T (a, b) values (1, 'discarded')".to_string(),
None,
"T".to_string(),
Vec::new(),
String::new(),
));
assert!(outcome.is_err(), "duplicate PK must fail: {outcome:?}");
let csv = read_csv(&project, "T").expect("T.csv still present");
assert!(
csv.contains("kept") && !csv.contains("discarded"),
"failed insert must not be persisted: {csv:?}",
);
}
#[test]
fn failed_multi_row_insert_is_atomic() {
// ADR-0033 §3b DA gate: a multi-row INSERT whose later tuple
// violates a constraint fails as one statement — no partial
// rows land, and the CSV is not rewritten.
let (project, db, _dir) = open_project_db();
let rt = rt();
create_t(&db, &rt);
rt.block_on(db.run_sql_insert(
"insert into T (a, b) values (1, 'existing')".to_string(),
None,
"T".to_string(),
Vec::new(),
String::new(),
))
.expect("seed row");
// Row (2,…) is new but (1,…) collides on the PK — the whole
// statement must fail with neither tuple applied.
let outcome = rt.block_on(db.run_sql_insert(
"insert into T (a, b) values (2, 'fresh'), (1, 'collides')".to_string(),
None,
"T".to_string(),
Vec::new(),
String::new(),
));
assert!(outcome.is_err(), "multi-row PK conflict must fail: {outcome:?}");
let csv = read_csv(&project, "T").expect("T.csv still present");
assert!(
csv.contains("existing") && !csv.contains("fresh") && !csv.contains("collides"),
"no tuple from the failed multi-row insert may land: {csv:?}",
);
}
#[test]
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");
match command {
Command::SqlInsert { sql, target_table, .. } => {
assert_eq!(sql, "insert into Orders (id, total) values (1, 99.5)");
assert_eq!(target_table, "Orders");
}
other => panic!("expected Command::SqlInsert, got {other:?}"),
}
}
#[test]
fn parse_path_rejects_internal_target_table() {
let result = parse_command("sqlinsert into __rdbms_playground_columns values (1)");
assert!(
result.is_err(),
"an internal `__rdbms_*` target must be rejected: {result:?}",
);
}
// =================================================================
// Sub-phase 3c — INSERT … SELECT
// =================================================================
/// Create a two-column table `name(a int pk, b text)`.
fn create_named(db: &Database, rt: &tokio::runtime::Runtime, name: &str) {
rt.block_on(db.create_table(
name.to_string(),
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Text),
],
vec!["a".to_string()],
None,
))
.unwrap_or_else(|e| panic!("create table {name}: {e:?}"));
}
#[test]
fn parse_path_lowers_insert_select_to_command() {
let command = parse_command("sqlinsert into archive select * from source")
.expect("INSERT … SELECT parses in advanced mode");
match command {
Command::SqlInsert { sql, target_table, .. } => {
assert_eq!(sql, "insert into archive select * from source");
assert_eq!(target_table, "archive");
}
other => panic!("expected Command::SqlInsert, got {other:?}"),
}
}
#[test]
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",
)
.expect("WITH-prefixed INSERT … SELECT parses");
match command {
Command::SqlInsert { sql, target_table, .. } => {
assert_eq!(
sql,
"insert into archive with t as (select * from orders) select * from t",
);
assert_eq!(target_table, "archive");
}
other => panic!("expected Command::SqlInsert, got {other:?}"),
}
}
#[test]
fn insert_select_copies_rows_and_persists() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_named(&db, &rt, "source");
create_named(&db, &rt, "archive");
rt.block_on(db.run_sql_insert(
"insert into source (a, b) values (1, 'one'), (2, 'two')".to_string(),
None,
"source".to_string(),
Vec::new(),
String::new(),
))
.expect("seed source");
let result = rt
.block_on(db.run_sql_insert(
"insert into archive select * from source".to_string(),
Some("insert into archive select * from source".to_string()),
"archive".to_string(),
Vec::new(),
String::new(),
))
.expect("INSERT … SELECT runs");
assert_eq!(result.rows_affected, 2, "both source rows copied");
let csv = read_csv(&project, "archive").expect("archive.csv written");
assert!(
csv.contains("one") && csv.contains("two"),
"archive CSV reflects the copied rows: {csv:?}",
);
}
#[test]
fn insert_select_with_column_list_and_projection_persists() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_named(&db, &rt, "source");
create_named(&db, &rt, "target");
rt.block_on(db.run_sql_insert(
"insert into source (a, b) values (5, 'five')".to_string(),
None,
"source".to_string(),
Vec::new(),
String::new(),
))
.expect("seed source");
let result = rt
.block_on(db.run_sql_insert(
"insert into target (a, b) select a, b from source".to_string(),
None,
"target".to_string(),
Vec::new(),
String::new(),
))
.expect("column-list + projection INSERT … SELECT runs");
assert_eq!(result.rows_affected, 1);
let csv = read_csv(&project, "target").expect("target.csv written");
assert!(csv.contains("five"), "target CSV reflects the row: {csv:?}");
}
#[test]
fn with_prefixed_insert_select_runs_and_persists() {
// R4 end-to-end: the CTE row source executes and lands rows.
let (project, db, _dir) = open_project_db();
let rt = rt();
create_named(&db, &rt, "orders");
create_named(&db, &rt, "archive");
rt.block_on(db.run_sql_insert(
"insert into orders (a, b) values (1, 'a'), (2, 'b')".to_string(),
None,
"orders".to_string(),
Vec::new(),
String::new(),
))
.expect("seed orders");
let result = rt
.block_on(db.run_sql_insert(
"insert into archive with t as (select * from orders) select * from t".to_string(),
None,
"archive".to_string(),
Vec::new(),
String::new(),
))
.expect("WITH-prefixed INSERT … SELECT runs");
assert_eq!(result.rows_affected, 2);
let csv = read_csv(&project, "archive").expect("archive.csv written");
assert!(
csv.contains('a') && csv.contains('b'),
"archive CSV reflects the CTE-sourced rows: {csv:?}",
);
}
#[test]
fn insert_select_from_self_runs_as_plain_insert() {
// DA gate: INSERT … SELECT where the source is the target
// executes as a plain insert (InsertResult — no cascade
// summary; cascade output is a DELETE-only concept, 3f).
let (project, db, _dir) = open_project_db();
let rt = rt();
create_named(&db, &rt, "T");
rt.block_on(db.run_sql_insert(
"insert into T (a, b) values (1, 'x'), (2, 'y')".to_string(),
None,
"T".to_string(),
Vec::new(),
String::new(),
))
.expect("seed");
let result = rt
.block_on(db.run_sql_insert(
"insert into T select a + 10, b from T".to_string(),
None,
"T".to_string(),
Vec::new(),
String::new(),
))
.expect("self-sourced INSERT … SELECT runs");
assert_eq!(result.rows_affected, 2, "two rows copied with shifted PK");
let csv = read_csv(&project, "T").expect("T.csv written");
assert!(
csv.contains("11") && csv.contains("12"),
"the shifted-PK copies landed: {csv:?}",
);
}
// =================================================================
// Sub-phase 3d — shortid auto-fill (worker)
// =================================================================
/// Full-stack: parse the dev `sqlinsert …` scaffold (so
/// `listed_columns` / `row_source` are extracted exactly as the
/// app does) and run it through the worker.
fn run_sqlinsert(
db: &Database,
rt: &tokio::runtime::Runtime,
input: &str,
) -> Result<InsertResult, DbError> {
match parse_command(input).expect("parse sqlinsert") {
Command::SqlInsert {
sql,
target_table,
listed_columns,
row_source,
} => rt.block_on(db.run_sql_insert(
sql,
Some(input.to_string()),
target_table,
listed_columns,
row_source,
)),
other => panic!("expected Command::SqlInsert, got {other:?}"),
}
}
fn create_cols(
db: &Database,
rt: &tokio::runtime::Runtime,
name: &str,
cols: &[(&str, Type)],
pk: &[&str],
) {
rt.block_on(db.create_table(
name.to_string(),
cols.iter().map(|(n, t)| ColumnSpec::new(*n, *t)).collect(),
pk.iter().map(|s| (*s).to_string()).collect(),
None,
))
.unwrap_or_else(|e| panic!("create table {name}: {e:?}"));
}
/// Data rows of a table's CSV (header skipped), each split on `,`.
/// The test data uses comma/quote-free values, so a plain split is
/// sufficient.
fn csv_rows(project: &project::Project, table: &str) -> Vec<Vec<String>> {
read_csv(project, table)
.unwrap_or_default()
.lines()
.skip(1)
.filter(|l| !l.is_empty())
.map(|l| l.split(',').map(str::to_string).collect())
.collect()
}
#[test]
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')")
.expect("auto-fill insert runs");
assert_eq!(result.rows_affected, 1);
let rows = csv_rows(&project, "t");
assert_eq!(rows.len(), 1, "one row: {rows:?}");
assert!(!rows[0][0].is_empty(), "id auto-filled: {rows:?}");
assert_eq!(rows[0][1], "x", "label preserved: {rows:?}");
}
#[test]
fn values_multirow_autofills_distinct_shortids() {
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 ('a'), ('b'), ('c')",
)
.expect("multi-row auto-fill runs");
assert_eq!(result.rows_affected, 3);
let rows = csv_rows(&project, "t");
let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect();
assert_eq!(ids.len(), 3, "three DISTINCT non-empty shortids: {rows:?}");
assert!(rows.iter().all(|r| !r[0].is_empty()), "no empty id: {rows:?}");
}
#[test]
fn explicit_shortid_value_is_respected() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
// The user provided `id` explicitly — it must be honoured
// verbatim (the override WARNING is sub-phase 3i).
run_sqlinsert(
&db,
&rt,
"sqlinsert into t (id, label) values ('hardcoded', 'x')",
)
.expect("explicit-id insert runs");
let rows = csv_rows(&project, "t");
assert_eq!(rows[0][0], "hardcoded", "explicit id preserved: {rows:?}");
}
#[test]
fn insert_select_autofills_distinct_shortids() {
let (project, db, _dir) = open_project_db();
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')")
.expect("seed source");
let result = run_sqlinsert(
&db,
&rt,
"sqlinsert into target (label) select label from source",
)
.expect("INSERT … SELECT auto-fill runs");
assert_eq!(result.rows_affected, 2);
let rows = csv_rows(&project, "target");
let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect();
assert_eq!(ids.len(), 2, "two DISTINCT fresh shortids: {rows:?}");
assert!(rows.iter().all(|r| !r[0].is_empty()), "no empty id: {rows:?}");
}
#[test]
fn combined_serial_and_shortid_autofill() {
let (project, db, _dir) = open_project_db();
let rt = rt();
// id: serial PK (engine rowid), code: shortid (worker), name.
create_cols(
&db,
&rt,
"t",
&[("id", Type::Serial), ("code", Type::ShortId), ("name", Type::Text)],
&["id"],
);
run_sqlinsert(&db, &rt, "sqlinsert into t (name) values ('x')")
.expect("combined auto-fill runs");
let rows = csv_rows(&project, "t");
assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!(rows[0][0], "1", "serial PK engine-filled: {rows:?}");
assert!(!rows[0][1].is_empty(), "shortid worker-filled: {rows:?}");
assert_eq!(rows[0][2], "x", "name preserved: {rows:?}");
}
#[test]
fn autofill_logs_original_source_not_rewritten_sql() {
// ADR-0030 §11: even though the worker rewrites the executed
// statement to bind synthesised shortids, history.log records
// the user's original line verbatim.
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')";
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");
assert!(body.contains(input), "original line logged: {body:?}");
// The rewritten parameterised INSERT must not leak into history.
assert!(
!body.contains("INSERT INTO") && !body.contains("?1"),
"rewritten SQL must not be logged: {body:?}",
);
}
#[test]
fn shortid_autofill_respects_mixed_case_column_name() {
// ADR-0009 / 3d DA gate: identifiers are case-preserving. The
// omitted-shortid detection must match the case-preserved
// schema name `MyId`, not a lowercased form.
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')")
.expect("mixed-case shortid auto-fill runs");
let rows = csv_rows(&project, "t");
assert_eq!(rows.len(), 1, "{rows:?}");
assert!(!rows[0][0].is_empty(), "MyId auto-filled: {rows:?}");
}
#[test]
fn two_shortids_pk_and_nonpk_both_autofill_distinctly() {
// Two shortid columns (one PK, one not), both omitted: each
// gets its own distinct-per-row batch.
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(
&db,
&rt,
"t",
&[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)],
&["id"],
);
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x'), ('y')")
.expect("two-shortid auto-fill runs");
assert_eq!(result.rows_affected, 2);
let rows = csv_rows(&project, "t");
let ids: std::collections::HashSet<&String> = rows.iter().map(|x| &x[0]).collect();
let codes: std::collections::HashSet<&String> = rows.iter().map(|x| &x[1]).collect();
assert_eq!(ids.len(), 2, "distinct ids: {rows:?}");
assert_eq!(codes.len(), 2, "distinct codes: {rows:?}");
assert!(
rows.iter().all(|x| !x[0].is_empty() && !x[1].is_empty()),
"both shortid columns filled on every row: {rows:?}",
);
}
#[test]
fn two_shortids_one_provided_one_autofilled() {
// The user provides one shortid and omits the other; the
// provided value is honoured and only the omitted one fills.
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(
&db,
&rt,
"t",
&[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)],
&["id"],
);
run_sqlinsert(&db, &rt, "sqlinsert 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:?}");
assert!(!rows[0][1].is_empty(), "omitted code auto-filled: {rows:?}");
}
#[test]
fn compound_pk_with_shortid_member_autofills() {
// A shortid that is part of a compound PK still auto-fills when
// omitted (membership in the PK is irrelevant to the fill).
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(
&db,
&rt,
"t",
&[("id", Type::ShortId), ("region", Type::Int), ("label", Type::Text)],
&["id", "region"],
);
run_sqlinsert(&db, &rt, "sqlinsert into t (region, label) values (1, 'x')")
.expect("compound-pk insert runs");
let rows = csv_rows(&project, "t");
assert!(
!rows[0][0].is_empty(),
"shortid PK member auto-filled: {rows:?}",
);
assert_eq!(rows[0][1], "1", "{rows:?}");
}