//! Sub-phase 3k — Tier-3 end-to-end DML integration tests //! (ADR-0033, plan `docs/plans/20260520-adr-0033-phase-3.md` //! "Sub-phase 3k"). //! //! Where the per-sub-phase `tests/sql_{insert,update,delete}.rs` //! suites drive the worker directly with hand-written arguments, //! these tests exercise the **full advanced-mode path**: a literal //! line is parsed in Advanced mode (the same `parse_command` //! dispatch the runtime uses), the resulting `Command::Sql*` is //! executed through the worker, and the persisted CSV / history / //! result set are asserted. They cover the real-world DML shapes //! the 3k exit gate lists: //! //! - `INSERT … SELECT` cross-table //! - multi-row `INSERT` covering all ten playground types, with //! `RETURNING` recovering every type (matrix R5) //! - `UPDATE` with a subquery in `SET` //! - `DELETE` with cascade (per-relationship summary + multi-table //! re-persistence) //! - `UPSERT` round-trip (`DO UPDATE` then `DO NOTHING`) //! - `RETURNING` on each of `INSERT` / `UPDATE` / `DELETE` //! - `history.log` replay of every Phase-3 statement form //! - the OOS parse-rejections (ADR-0033 §13) //! - the `[ERR]`/`[WRN]` validity indicator firing on a SQL DML //! diagnostic (matrix A7) use ratatui::Terminal; use ratatui::backend::TestBackend; use rdbms_playground::app::App; use rdbms_playground::db::{Database, DbError, DeleteResult, InsertResult, UpdateResult}; use rdbms_playground::dsl::parser::parse_command_in_mode; use rdbms_playground::dsl::walker::Severity; use rdbms_playground::dsl::{ ColumnSpec, Command, ReferentialAction, RowFilter, Type, parse_command, }; use rdbms_playground::event::AppEvent; use rdbms_playground::mode::Mode; use rdbms_playground::persistence::Persistence; use rdbms_playground::project; use rdbms_playground::runtime::run_replay; use rdbms_playground::theme::Theme; use rdbms_playground::ui; // --------------------------------------------------------------- // Harness — mirrors the per-sub-phase suites' helpers. // --------------------------------------------------------------- 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 { std::fs::read_to_string(project.path().join("data").join(format!("{table}.csv"))).ok() } 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:?}")); } /// Parse `input` in Advanced mode and run the resulting SQL INSERT /// through the worker — the full parse → execute path. fn run_insert( db: &Database, rt: &tokio::runtime::Runtime, input: &str, ) -> Result { match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) { Command::SqlInsert { sql, target_table, listed_columns, row_source, returning, .. } => rt.block_on(db.run_sql_insert( sql, Some(input.to_string()), target_table, listed_columns, row_source, returning, )), other => panic!("expected Command::SqlInsert from {input:?}, got {other:?}"), } } fn run_update( db: &Database, rt: &tokio::runtime::Runtime, input: &str, ) -> Result { match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) { Command::SqlUpdate { sql, target_table, returning, set_literals } => rt.block_on( db.run_sql_update_with_literals( sql, Some(input.to_string()), target_table, returning, set_literals, ), ), other => panic!("expected Command::SqlUpdate from {input:?}, got {other:?}"), } } fn run_delete( db: &Database, rt: &tokio::runtime::Runtime, input: &str, ) -> Result { match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) { Command::SqlDelete { sql, target_table, returning } => rt.block_on( db.run_sql_delete(sql, Some(input.to_string()), target_table, returning), ), other => panic!("expected Command::SqlDelete from {input:?}, got {other:?}"), } } /// Seed rows through the SQL INSERT path (no auto-gen columns, so /// the statement executes verbatim). fn seed(db: &Database, rt: &tokio::runtime::Runtime, sql: &str) { run_insert(db, rt, sql).unwrap_or_else(|e| panic!("seed {sql:?}: {e:?}")); } fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec>> { rt.block_on(db.query_data(table.to_string(), None, None)) .unwrap_or_else(|e| panic!("query_data {table}: {e:?}")) .rows } // =============================================================== // INSERT … SELECT cross-table // =============================================================== #[test] fn e2e_insert_select_cross_table_copies_rows_and_persists_both() { let (project, db, _dir) = open_project_db(); let rt = rt(); create_cols(&db, &rt, "source", &[("id", Type::Int), ("v", Type::Text)], &["id"]); create_cols(&db, &rt, "archive", &[("id", Type::Int), ("v", Type::Text)], &["id"]); seed(&db, &rt, "insert into source (id, v) values (1, 'x'), (2, 'y')"); let result = run_insert(&db, &rt, "insert into archive select * from source") .expect("INSERT … SELECT runs"); assert_eq!(result.rows_affected, 2, "two source rows copied"); let archive_csv = read_csv(&project, "archive").expect("archive.csv"); assert!( archive_csv.contains('x') && archive_csv.contains('y'), "archive reflects both copied rows: {archive_csv:?}", ); let source_csv = read_csv(&project, "source").expect("source.csv"); assert!( source_csv.contains('x') && source_csv.contains('y'), "source is left intact: {source_csv:?}", ); } // =============================================================== // Multi-row INSERT covering all ten playground types + RETURNING // type recovery for every type (matrix R5). // =============================================================== #[test] fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type() { let (project, db, _dir) = open_project_db(); let rt = rt(); // serial PK + shortid auto-fill; the other eight columns are // user-supplied. `blob` has no value-literal grammar yet // (see src/dsl/value.rs), so it is inserted NULL — its *type* // still round-trips through the RETURNING column-origin path. create_cols( &db, &rt, "allten", &[ ("ser", Type::Serial), ("txt", Type::Text), ("i", Type::Int), ("r", Type::Real), ("dec", Type::Decimal), ("flag", Type::Bool), ("d", Type::Date), ("ts", Type::DateTime), ("bl", Type::Blob), ("sid", Type::ShortId), ], &["ser"], ); let result = run_insert( &db, &rt, "insert into allten (txt, i, r, dec, flag, d, ts, bl) values \ ('hi', 42, 1.5, 9.50, true, '2026-05-23', '2026-05-23 10:00:00', null), \ ('yo', 7, 2.5, 3.25, false, '2025-01-01', '2025-01-01 00:00:00', null) \ returning ser, txt, i, r, dec, flag, d, ts, bl, sid", ) .expect("multi-row INSERT … RETURNING runs"); assert_eq!(result.rows_affected, 2, "two rows inserted"); assert_eq!(result.data.rows.len(), 2, "RETURNING yields both rows"); // Every one of the ten playground types is recovered via the // RETURNING column-origin path (matrix R5). assert_eq!( result.data.column_types, vec![ Some(Type::Serial), Some(Type::Text), Some(Type::Int), Some(Type::Real), Some(Type::Decimal), Some(Type::Bool), Some(Type::Date), Some(Type::DateTime), Some(Type::Blob), Some(Type::ShortId), ], "RETURNING recovers each of the ten playground types; got {:?}", result.data.column_types, ); // Values round-trip: serial auto-incremented (1, 2), shortid // auto-filled (non-empty + distinct), the user values persisted. let rows = query(&db, &rt, "allten"); assert_eq!(rows.len(), 2, "both rows persisted"); let csv = read_csv(&project, "allten").expect("allten.csv"); assert!(csv.contains("hi") && csv.contains("yo"), "text round-trips: {csv:?}"); assert!(csv.contains("2026-05-23") && csv.contains("2025-01-01"), "dates round-trip: {csv:?}"); let sids: Vec<&str> = rows.iter().filter_map(|r| r[9].as_deref()).collect(); assert_eq!(sids.len(), 2, "both shortids present"); assert!(sids.iter().all(|s| !s.is_empty()), "shortids non-empty: {sids:?}"); assert_ne!(sids[0], sids[1], "auto-filled shortids are distinct: {sids:?}"); let sers: Vec<&str> = rows.iter().filter_map(|r| r[0].as_deref()).collect(); assert!(sers.contains(&"1") && sers.contains(&"2"), "serial auto-incremented: {sers:?}"); } // =============================================================== // UPDATE with a subquery in SET // =============================================================== #[test] fn e2e_update_with_subquery_in_set() { let (project, db, _dir) = open_project_db(); let rt = rt(); create_cols( &db, &rt, "customers", &[("id", Type::Int), ("name", Type::Text), ("last_order", Type::Int)], &["id"], ); create_cols( &db, &rt, "orders", &[("id", Type::Int), ("cust", Type::Int), ("amount", Type::Int)], &["id"], ); seed(&db, &rt, "insert into customers (id, name, last_order) values (1, 'A', 0), (2, 'B', 0)"); seed(&db, &rt, "insert into orders (id, cust, amount) values (10, 1, 50), (11, 1, 30), (12, 2, 99)"); let result = run_update( &db, &rt, "update customers set last_order = \ (select max(amount) from orders where cust = customers.id)", ) .expect("UPDATE with subquery in SET runs"); assert_eq!(result.rows_affected, 2, "both customers updated"); let rows = query(&db, &rt, "customers"); let c1 = rows.iter().find(|r| r[0].as_deref() == Some("1")).expect("customer 1"); let c2 = rows.iter().find(|r| r[0].as_deref() == Some("2")).expect("customer 2"); assert_eq!(c1[2].as_deref(), Some("50"), "customer 1 → max(50, 30) = 50"); assert_eq!(c2[2].as_deref(), Some("99"), "customer 2 → max(99) = 99"); let csv = read_csv(&project, "customers").expect("customers.csv"); assert!(csv.contains("50") && csv.contains("99"), "CSV reflects the update: {csv:?}"); } // =============================================================== // DELETE with cascade — per-relationship summary + multi-table // re-persistence. // =============================================================== fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) { create_cols(db, rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]); create_cols(db, rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]); rt.block_on(db.add_relationship( Some("places".to_string()), "Customers".to_string(), vec!["id".to_string()], "Orders".to_string(), vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, None, )) .expect("add cascade relationship"); seed(db, rt, "insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')"); seed(db, rt, "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)"); } #[test] fn e2e_delete_with_cascade_reports_summary_and_repersists_children() { let (project, db, _dir) = open_project_db(); let rt = rt(); cascade_fixture(&db, &rt); 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 affected"); let effect = &result.cascade[0]; assert_eq!(effect.child_table, "Orders"); assert_eq!(effect.rows_changed, 2, "Alice's two orders cascaded"); let orders_csv = read_csv(&project, "Orders").expect("Orders.csv re-persisted"); assert!(orders_csv.contains("12"), "Bob's order (12) preserved: {orders_csv:?}"); assert!(!orders_csv.contains("10"), "Alice's order 10 cascaded away: {orders_csv:?}"); assert!(!orders_csv.contains("11"), "Alice's order 11 cascaded away: {orders_csv:?}"); } // =============================================================== // UPSERT round-trip — DO UPDATE then DO NOTHING. // =============================================================== #[test] fn e2e_upsert_round_trip_do_update_then_do_nothing() { let (project, db, _dir) = open_project_db(); let rt = rt(); create_cols(&db, &rt, "kv", &[("id", Type::Int), ("name", Type::Text)], &["id"]); seed(&db, &rt, "insert into kv (id, name) values (1, 'old')"); // DO UPDATE on a conflict mutates the existing row. let upd = run_insert( &db, &rt, "insert into kv (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name", ) .expect("UPSERT DO UPDATE runs"); assert_eq!(upd.rows_affected, 1, "DO UPDATE touches the conflicting row"); let csv = read_csv(&project, "kv").expect("kv.csv"); assert!(csv.contains("new") && !csv.contains("old"), "row updated to 'new': {csv:?}"); // DO NOTHING on a conflict is a no-op. let nothing = run_insert( &db, &rt, "insert into kv (id, name) values (1, 'ignored') on conflict (id) do nothing", ) .expect("UPSERT DO NOTHING runs"); assert_eq!(nothing.rows_affected, 0, "DO NOTHING changes no rows"); let csv = read_csv(&project, "kv").expect("kv.csv"); assert!(csv.contains("new") && !csv.contains("ignored"), "row unchanged by DO NOTHING: {csv:?}"); } // =============================================================== // RETURNING on each of INSERT / UPDATE / DELETE. // =============================================================== #[test] fn e2e_returning_on_insert_update_delete() { let (_project, db, _dir) = open_project_db(); let rt = rt(); create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); let ins = run_insert(&db, &rt, "insert into t (id, v) values (1, 'a') returning id, v") .expect("INSERT … RETURNING runs"); assert_eq!(ins.data.rows.len(), 1, "INSERT RETURNING yields the inserted row"); assert_eq!(ins.data.rows[0][1].as_deref(), Some("a")); let upd = run_update(&db, &rt, "update t set v = 'b' where id = 1 returning v") .expect("UPDATE … RETURNING runs"); assert_eq!(upd.data.rows.len(), 1, "UPDATE RETURNING yields the modified row"); assert_eq!(upd.data.rows[0][0].as_deref(), Some("b")); let del = run_delete(&db, &rt, "delete from t where id = 1 returning *") .expect("DELETE … RETURNING runs"); assert_eq!(del.data.rows.len(), 1, "DELETE RETURNING yields the pre-delete row"); assert_eq!(del.data.rows[0][1].as_deref(), Some("b"), "pre-delete value surfaced"); assert!(query(&db, &rt, "t").is_empty(), "row is gone after the DELETE"); } // =============================================================== // history.log replay of every Phase-3 statement form. // =============================================================== #[test] fn e2e_replay_phase3_dml_forms_from_a_script() { let dir = tempfile::tempdir().expect("tempdir"); let project = project::open_or_create(None, Some(dir.path())).expect("project"); let db = Database::open_with_persistence( project.db_path(), Persistence::new(project.path().to_path_buf()), ) .expect("db"); let rt = rt(); // A script of Phase-3 SQL DML forms (plus the DDL needed to set // up). Replay parses each line in Advanced mode (ADR-0033 // Amendment 3), so the SQL forms route to the SQL worker path. std::fs::write( project.path().join("phase3.commands"), "create table T with pk id(int)\n\ add column T: v (text)\n\ insert into T (id, v) values (1, 'a'), (2, 'b'), (3, 'c')\n\ insert into T select id + 10, v from T where id = 1\n\ update T set v = 'z' where id = 2\n\ delete from T where id = 3\n", ) .expect("write script"); let events = rt.block_on(run_replay(&db, project.path(), "phase3.commands")); match events.last().expect("at least one event") { AppEvent::ReplayCompleted { count, .. } => { assert_eq!(*count, 6, "all six lines replayed; events: {events:?}"); } other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"), } // Faithful application: multi-row insert + INSERT…SELECT + // UPDATE + DELETE all landed. let rows = query(&db, &rt, "T"); let mut by_id: Vec<(String, Option)> = rows .iter() .map(|r| (r[0].clone().unwrap_or_default(), r[1].clone())) .collect(); by_id.sort(); assert_eq!( by_id, vec![ ("1".to_string(), Some("a".to_string())), ("11".to_string(), Some("a".to_string())), // INSERT…SELECT id+10 ("2".to_string(), Some("z".to_string())), // UPDATE // id 3 was DELETEd ] .into_iter() .collect::>() .into_iter() .collect::>(), "replayed DML applied faithfully; got {by_id:?}", ); } // =============================================================== // OOS parse-rejections (ADR-0033 §13) — behaviour confirmed; pin it. // =============================================================== #[test] fn e2e_out_of_scope_dml_forms_parse_reject() { let cases = [ ("OOS-1 DEFAULT VALUES", "insert into t default values"), ("OOS-2 INSERT OR REPLACE", "insert or replace into t values (1)"), ("OOS-2 INSERT OR IGNORE", "insert or ignore into t values (1)"), ("OOS-3 UPDATE … FROM", "update t set a = b.x from other b where t.id = b.id"), ("OOS-4 WITH … UPDATE", "with x as (select 1) update t set a = 1 where id = 1"), ("OOS-4 WITH … DELETE", "with x as (select 1) delete from t where id = 1"), ("OOS-5 INDEXED BY", "delete from t indexed by idx where id = 1"), ("OOS-5 NOT INDEXED", "update t not indexed set a = 1 where id = 1"), ("OOS-6 multi-statement (DELETE; DELETE)", "delete from t where id = 1; delete from t where id = 2"), ("OOS-6 multi-statement (INSERT; INSERT)", "insert into t values (1); insert into t values (2)"), ]; for (label, src) in cases { assert!( parse_command_in_mode(src, Mode::Advanced).is_err(), "{label}: {src:?} must parse-reject (ADR-0033 §13)", ); } } #[test] fn e2e_single_dml_statement_with_trailing_semicolon_parses() { // Guard for the OOS-6 multi-statement rejection above: a *single* // statement with a trailing `;` is still valid (ADR-0033 §1 — the // optional `;` tail), so the rejection above is genuinely about a // second statement, not the semicolon. assert!( matches!( parse_command_in_mode("delete from t where id = 1;", Mode::Advanced), Ok(Command::SqlDelete { .. }) ), "a single statement with a trailing semicolon must still parse", ); } #[test] fn e2e_update_all_rows_in_advanced_falls_back_to_dsl() { // ADR-0033 Amendment 4 reverses Amendment 3's counter-example: the // SQL `UPDATE`'s `SET` expression must NOT consume the DSL flag // `--all-rows`. An adjacent `--` is not two minus operators — the // playground has no `--` line comment — so the SQL shape fails and // dispatch falls back to the DSL `Update { AllRows }`, mirroring // `delete … --all-rows`. assert!( matches!( parse_command_in_mode("update Orders set total = 42 --all-rows", Mode::Advanced), Ok(Command::Update { filter: RowFilter::AllRows, .. }) ), "advanced `update … --all-rows` falls back to the DSL Update", ); // Legitimate spaced arithmetic is unaffected — the dashes are not // adjacent, so this stays a SQL UPDATE (total = 42 - (-3) = 45). assert!( matches!( parse_command_in_mode("update Orders set total = 42 - -3", Mode::Advanced), Ok(Command::SqlUpdate { .. }) ), "spaced `42 - -3` stays a SQL UPDATE", ); // An adjacent `--` before a number is no longer silently accepted as // arithmetic; with no `--all-rows` flag to fall back to, it is a // parse error (acceptable per Amendment 4 — contrived input, and the // playground does not support `--` comments). assert!( parse_command_in_mode("update Orders set total = 42--3", Mode::Advanced).is_err(), "adjacent `42--3` is a parse error (no `--` comment support)", ); } // =============================================================== // Validity indicator fires on a SQL DML diagnostic (matrix A7). // =============================================================== fn rendered_text(app: &mut App, theme: &Theme, width: u16, height: u16) -> String { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).expect("terminal"); terminal.draw(|f| ui::render(app, theme, f)).expect("draw"); let buffer = terminal.backend().buffer().clone(); let mut out = String::new(); for y in 0..buffer.area.height { for x in 0..buffer.area.width { out.push_str(buffer[(x, y)].symbol()); } out.push('\n'); } out } #[test] fn e2e_validity_indicator_fires_for_sql_dml_diagnostic() { // ADR-0027 §4 / ADR-0030 §8 / matrix A7: a SQL DML line whose // WHERE carries a predicate warning (`= NULL`) lights up the // `[WRN]` indicator in Advanced mode. The verdict is the same // computation the runtime stores in `input_indicator`. let mut app = App::new(); app.mode = Mode::Advanced; // Populate the schema cache so the diagnostic pass resolves // the column. app.schema_cache.tables.push("t".to_string()); app.schema_cache.columns.push("v".to_string()); app.schema_cache.table_columns.insert( "t".to_string(), vec![rdbms_playground::completion::TableColumn { name: "v".to_string(), user_type: Type::Int, not_null: false, has_default: false, }], ); app.input = "update t set v = 1 where v = NULL".to_string(); assert_eq!( app.input_validity_verdict(), Some(Severity::Warning), "a SQL DML `= NULL` predicate raises a WARNING verdict", ); // And the indicator renders the `[WRN]` label. app.input_indicator = app.input_validity_verdict(); let text = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!(text.contains("[WRN]"), "the SQL DML warning surfaces as [WRN]:\n{text}"); }