6ff9144c7a
Make the INSERT row source a Choice between the VALUES clause and Subgrammar(&sql_select::SQL_SELECT_COMPOUND). SQL_SELECT_COMPOUND is itself a Choice that admits a leading WITH, so a WITH-prefixed SELECT row source (R4) parses through it for free; the two branches start on disjoint keywords (values vs select/with) so the Choice never ambiguously commits. No worker change — do_sql_insert already executes the validated SQL and re-persists, and the engine handles insert-from-query. Tests: grammar accept (plain / column-list+projection / WITH- prefixed / trailing-semi) and reject (__rdbms_* on the SELECT's FROM slot, incomplete select); integration parse-path lowering + worker round-trip (rows land, CSV re-persisted) incl. R4 WITH end- to-end; walker cross-cut that the Phase-2 unknown_column diagnostic fires on the INSERT…SELECT projection; DA-gate test that a self- sourced INSERT…SELECT runs as a plain insert (no cascade summary — that is DELETE-only). Still behind the dev `sqlinsert` entry word (shared `insert` is 3j). 1493 tests green, clippy clean.
378 lines
13 KiB
Rust
378 lines
13 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;
|
|
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(),
|
|
))
|
|
.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(),
|
|
))
|
|
.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(),
|
|
))
|
|
.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(),
|
|
))
|
|
.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(),
|
|
))
|
|
.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(),
|
|
));
|
|
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(),
|
|
))
|
|
.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(),
|
|
));
|
|
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(),
|
|
))
|
|
.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(),
|
|
))
|
|
.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(),
|
|
))
|
|
.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(),
|
|
))
|
|
.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(),
|
|
))
|
|
.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(),
|
|
))
|
|
.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(),
|
|
))
|
|
.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(),
|
|
))
|
|
.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:?}",
|
|
);
|
|
}
|