9a23e28f30
Advanced-mode `update T set x = 42 --all-rows` parsed the `--all-rows`
DSL flag as the arithmetic `42 - -all - rows` over phantom columns
`all`/`rows` (Amendment 3's counter-example), masked only by the engine's
`--` comment leniency. The playground supports no `--` line comment, so
this was a misparse (ADR-0027: flag input known to fail at runtime).
Fix: walk_punct refuses a `-` that begins an adjacent `--`. Only the SQL
expression uses Node::Punct('-'), so this is scoped to it. The SET
expression then stops, the SQL UPDATE shape fails, and dispatch falls
back to the DSL Update { AllRows } — symmetry with delete … --all-rows.
Behaviour: `42 --all-rows` → DSL Update{AllRows}; spaced `42 - -3` stays
SqlUpdate (= 45, preserved); adjacent `42--3` → parse error (contrived;
no `--` comment support).
Tests: inverted parse test (+ arithmetic-preserved + adjacent-error
assertions); new full-pipeline update_all_rows_flag_in_advanced_updates_every_row.
Suite 1963/0/1; clippy clean.
604 lines
24 KiB
Rust
604 lines
24 KiB
Rust
//! 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<String> {
|
|
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<InsertResult, DbError> {
|
|
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<UpdateResult, DbError> {
|
|
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<DeleteResult, DbError> {
|
|
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<Vec<Option<String>>> {
|
|
rt.block_on(db.query_data(table.to_string(), None, 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(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"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<String>)> = 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::<std::collections::BTreeMap<_, _>>()
|
|
.into_iter()
|
|
.collect::<Vec<_>>(),
|
|
"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}");
|
|
}
|