test: consolidate 25 integration crates into one it binary
Each top-level tests/*.rs was its own crate → its own binary, each statically linking the bundled engine + every dep. 26 of them, so an edit to the lib relinked all 26. Moved the 25 standalone files into tests/it/ under one tests/it/main.rs (the pattern typing_surface already uses); cargo auto-detects it as the `it` target. End state: 2 integration-test binaries instead of 26. Result: target/debug/deps 1.5 GB → 629 MB (-58%). Build time barely moved (clean 22.9s→22.4s, lib-edit relink 13.3s→12.4s) — wall-clock is dominated by compiling, not linking, so this is a disk win, not a speed win (see docs/plans/20260602-test-consolidation.md). Tests unchanged at 2151/0/1; clippy clean; no fixups needed. typing_surface_matrix stays its own already-consolidated binary. Tradeoff: the 25 files now share one crate (a compile error fails the whole `it` binary; module-scoped namespaces, no clashes) — negligible for a solo project.
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
//! Sub-phase 3f integration tests for the advanced-mode SQL
|
||||
//! `DELETE` surface (ADR-0033 §1/§7).
|
||||
//!
|
||||
//! Covers the parse path (the dev `sql_delete` scaffold lowers to
|
||||
//! `Command::SqlDelete`, reconstructing valid `delete from …` SQL)
|
||||
//! and the worker round-trip (execute, detect FK cascade by
|
||||
//! row-count diffing per ADR-0033 Amendment 2, re-persist the
|
||||
//! target *and every cascade-affected child* CSV, append
|
||||
//! `history.log`). A SQL `DELETE` without `WHERE` runs across all
|
||||
//! rows with no rail (ADR-0030 §12).
|
||||
//!
|
||||
//! The cascade tests pin the Amendment-2 decision: the SQL path
|
||||
//! uses the *same* count-diff mechanism as the DSL `do_delete`, so
|
||||
//! the two produce identical `DeleteResult.cascade` on identical
|
||||
//! schema/data (the `cascade_parity_with_dsl` test asserts this
|
||||
//! directly). The R2 invariant — a WHERE that itself contains a
|
||||
//! subquery — is correct by construction because the verbatim SQL
|
||||
//! executes once and the diff observes the result; no predicate
|
||||
//! bytes are extracted.
|
||||
|
||||
use rdbms_playground::db::{Database, DbError, DeleteResult};
|
||||
use rdbms_playground::dsl::{
|
||||
ColumnSpec, Command, ReferentialAction, RowFilter, Type, Value, 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()
|
||||
}
|
||||
|
||||
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:?}"));
|
||||
}
|
||||
|
||||
/// Seed via the SQL INSERT worker path (no shortid columns here, so
|
||||
/// it executes verbatim).
|
||||
fn seed(db: &Database, rt: &tokio::runtime::Runtime, sql: &str, target: &str) {
|
||||
rt.block_on(db.run_sql_insert(
|
||||
sql.to_string(),
|
||||
None,
|
||||
target.to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.unwrap_or_else(|e| panic!("seed {sql:?}: {e:?}"));
|
||||
}
|
||||
|
||||
/// Full-stack: parse the dev `sql_delete …` scaffold and run it.
|
||||
fn run_delete(
|
||||
db: &Database,
|
||||
rt: &tokio::runtime::Runtime,
|
||||
input: &str,
|
||||
) -> Result<DeleteResult, DbError> {
|
||||
match parse_command(input).expect("parse delete") {
|
||||
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, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Customers (id int pk, Name text)` parent and `Orders (id int
|
||||
/// pk, CustId int)` child, with `Customers.id → Orders.CustId`
|
||||
/// `ON DELETE CASCADE`. Seeds Alice (1) with two orders (10, 11)
|
||||
/// and Bob (2) with one order (12).
|
||||
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')", "Customers");
|
||||
seed(db, rt, "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)", "Orders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_path_lowers_sql_delete_to_command() {
|
||||
let command = parse_command("delete from Orders where id = 1")
|
||||
.expect("delete parses in advanced mode");
|
||||
match command {
|
||||
Command::SqlDelete { sql, target_table, .. } => {
|
||||
assert_eq!(sql, "delete from Orders where id = 1");
|
||||
assert_eq!(target_table, "Orders");
|
||||
}
|
||||
other => panic!("expected Command::SqlDelete, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_with_where_persists() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'gone'), (2, 'keep')", "t");
|
||||
let result = run_delete(&db, &rt, "delete from t where id = 1").expect("delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row deleted");
|
||||
assert!(result.cascade.is_empty(), "no children, no cascade");
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
assert!(csv.contains("keep"), "untouched row preserved: {csv:?}");
|
||||
assert!(!csv.contains("gone"), "deleted row removed from CSV: {csv:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_without_where_runs_across_all_rows() {
|
||||
// ADR-0030 §12: no `--all-rows` rail.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'a'), (2, 'b'), (3, 'c')", "t");
|
||||
let result = run_delete(&db, &rt, "delete from t").expect("unfiltered delete runs");
|
||||
assert_eq!(result.rows_affected, 3, "all rows deleted");
|
||||
// Empty tables produce no CSV (CLAUDE.md persistence note), so the
|
||||
// file is either absent or has only a header — either way, no data.
|
||||
let csv = read_csv(&project, "t").unwrap_or_default();
|
||||
assert!(!csv.contains('a') && !csv.contains('b') && !csv.contains('c'), "no rows left: {csv:?}");
|
||||
let remaining = rt
|
||||
.block_on(db.query_data("t".to_string(), None, None, None))
|
||||
.expect("query t");
|
||||
assert!(remaining.rows.is_empty(), "table empty after unfiltered delete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_delete_reports_summary_and_repersists_child() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
cascade_fixture(&db, &rt);
|
||||
// Delete Alice (customer 1) — cascades to her two orders (10, 11).
|
||||
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 reported");
|
||||
let effect = &result.cascade[0];
|
||||
assert_eq!(effect.relationship_name, "places");
|
||||
assert_eq!(effect.child_table, "Orders");
|
||||
// rows_changed == 2 pins the before-execute ordering: counted
|
||||
// after the delete it would be 0. Alice had exactly two orders.
|
||||
assert_eq!(effect.rows_changed, 2, "both of Alice's orders cascaded");
|
||||
// The child CSV must be re-persisted to reflect the cascade.
|
||||
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
|
||||
assert!(orders_csv.contains("12"), "Bob's order (12) preserved: {orders_csv:?}");
|
||||
assert!(!orders_csv.contains("10") && !orders_csv.contains("11"),
|
||||
"Alice's cascaded orders gone from CSV: {orders_csv:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_parity_with_dsl() {
|
||||
// ADR-0033 §2 / Amendment 2: the SQL DELETE cascade summary must
|
||||
// match the DSL `do_delete` output on the same schema/data —
|
||||
// because they use the identical count-diff mechanism. Run the
|
||||
// same operation through both paths on two identical fixtures and
|
||||
// compare the cascade vectors directly (CascadeEffect: PartialEq).
|
||||
let rt = rt();
|
||||
|
||||
let (_p_sql, db_sql, _d_sql) = open_project_db();
|
||||
cascade_fixture(&db_sql, &rt);
|
||||
let sql_result = run_delete(&db_sql, &rt, "delete from Customers where id = 1")
|
||||
.expect("SQL delete runs");
|
||||
|
||||
let (_p_dsl, db_dsl, _d_dsl) = open_project_db();
|
||||
cascade_fixture(&db_dsl, &rt);
|
||||
let dsl_result = rt
|
||||
.block_on(db_dsl.delete(
|
||||
"Customers".to_string(),
|
||||
RowFilter::eq("id", Value::Number("1".to_string())),
|
||||
Some("delete from Customers where id = 1".to_string()),
|
||||
))
|
||||
.expect("DSL delete runs");
|
||||
|
||||
assert_eq!(sql_result.rows_affected, dsl_result.rows_affected, "row counts match");
|
||||
assert_eq!(sql_result.cascade, dsl_result.cascade, "cascade summaries identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r2_where_with_subquery() {
|
||||
// R2 invariant (ADR-0033 §7 / Amendment 2): a WHERE containing a
|
||||
// subquery. Plan shape: `DELETE FROM orders WHERE customer_id IN
|
||||
// (SELECT id FROM customers WHERE …)`. The verbatim statement
|
||||
// executes once; no predicate extraction. Orders has no children,
|
||||
// so cascade is empty — the point is the subquery resolves.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
cascade_fixture(&db, &rt);
|
||||
let result = run_delete(
|
||||
&db,
|
||||
&rt,
|
||||
"delete from Orders where CustId in (select id from Customers where Name = 'Alice')",
|
||||
)
|
||||
.expect("subquery-WHERE delete runs");
|
||||
assert_eq!(result.rows_affected, 2, "Alice's two orders deleted");
|
||||
assert!(result.cascade.is_empty(), "Orders has no cascade children");
|
||||
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
|
||||
assert!(orders_csv.contains("12"), "Bob's order preserved: {orders_csv:?}");
|
||||
assert!(!orders_csv.contains("10") && !orders_csv.contains("11"),
|
||||
"Alice's orders deleted: {orders_csv:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r2_cascade_with_subquery_where() {
|
||||
// The strongest R2 case: the parent is the DELETE target AND the
|
||||
// WHERE subquery reads the very child table that will be cascade-
|
||||
// deleted. The engine evaluates the subquery against pre-delete
|
||||
// state, deletes the matched parent, then cascades — and the
|
||||
// count-diff observes the child rows that vanished. Pins both the
|
||||
// subquery correctness and the before-execute ordering together.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
cascade_fixture(&db, &rt);
|
||||
// order 11 belongs to Alice (CustId 1); the subquery yields 1, so
|
||||
// Alice is deleted and BOTH her orders (10, 11) cascade.
|
||||
let result = run_delete(
|
||||
&db,
|
||||
&rt,
|
||||
"delete from Customers where id in (select CustId from Orders where id = 11)",
|
||||
)
|
||||
.expect("cascade + subquery-WHERE delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "Alice deleted");
|
||||
assert_eq!(result.cascade.len(), 1, "one cascade relationship");
|
||||
assert_eq!(result.cascade[0].rows_changed, 2, "both Alice orders cascaded");
|
||||
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
|
||||
assert!(orders_csv.contains("12") && !orders_csv.contains("10") && !orders_csv.contains("11"),
|
||||
"only Bob's order remains: {orders_csv:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_appends_literal_line_to_history() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'x')", "t");
|
||||
let input = "delete from t where id = 1";
|
||||
run_delete(&db, &rt, input).expect("delete runs");
|
||||
let body = std::fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log present");
|
||||
assert!(body.contains(input), "history records the literal line: {body:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_to_two_children_reports_both() {
|
||||
// DA gate (untested branch): a parent with TWO cascade children
|
||||
// must emit a CascadeEffect per affected child, and re-persist
|
||||
// both. The single-relationship tests never exercise the loop
|
||||
// emitting more than one effect.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]);
|
||||
create_cols(&db, &rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
|
||||
create_cols(&db, &rt, "Reviews", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
|
||||
for (child, name) in [("Orders", "places"), ("Reviews", "writes")] {
|
||||
rt.block_on(db.add_relationship(
|
||||
Some(name.to_string()),
|
||||
"Customers".to_string(),
|
||||
"id".to_string(),
|
||||
child.to_string(),
|
||||
"CustId".to_string(),
|
||||
ReferentialAction::Cascade,
|
||||
ReferentialAction::NoAction,
|
||||
false,
|
||||
None,
|
||||
))
|
||||
.unwrap_or_else(|e| panic!("add rel {name}: {e:?}"));
|
||||
}
|
||||
seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers");
|
||||
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1), (11, 1)", "Orders");
|
||||
seed(&db, &rt, "insert into Reviews (id, CustId) values (20, 1)", "Reviews");
|
||||
|
||||
let result = run_delete(&db, &rt, "delete from Customers where id = 1")
|
||||
.expect("cascade-to-two delete runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
assert_eq!(result.cascade.len(), 2, "both cascade relationships reported");
|
||||
let by_child: std::collections::HashMap<&str, i64> = result
|
||||
.cascade
|
||||
.iter()
|
||||
.map(|e| (e.child_table.as_str(), e.rows_changed))
|
||||
.collect();
|
||||
assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded");
|
||||
assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded");
|
||||
// Both child CSVs re-persisted to the post-cascade (empty) state.
|
||||
let orders = rt.block_on(db.query_data("Orders".to_string(), None, None, None)).unwrap();
|
||||
let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None, None)).unwrap();
|
||||
assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied");
|
||||
let _ = &project;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_childless_parent_reports_no_cascade() {
|
||||
// DA gate (untested branch): a cascade relationship EXISTS, but
|
||||
// the deleted parent row has no children. The `diff > 0` guard
|
||||
// must yield an empty cascade and must NOT touch the child's CSV
|
||||
// (a `>= 0` regression would report a phantom 0-row cascade).
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
cascade_fixture(&db, &rt);
|
||||
// Carol (3) exists with no orders; deleting her cascades nothing.
|
||||
seed(&db, &rt, "insert into Customers (id, Name) values (3, 'Carol')", "Customers");
|
||||
let result = run_delete(&db, &rt, "delete from Customers where id = 3")
|
||||
.expect("childless-parent delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "Carol deleted");
|
||||
assert!(result.cascade.is_empty(), "no children → no cascade effect reported");
|
||||
// All three orders untouched.
|
||||
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
|
||||
assert!(
|
||||
orders_csv.contains("10") && orders_csv.contains("11") && orders_csv.contains("12"),
|
||||
"no order touched by a childless-parent delete: {orders_csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_violating_fk_fails_and_persists_nothing() {
|
||||
// DA gate (untested error path): with an ON DELETE NO ACTION
|
||||
// child, deleting a referenced parent is rejected by the engine.
|
||||
// `do_sql_delete` runs persistence+history INSIDE the tx AFTER a
|
||||
// successful execute, so a rejected delete must roll back: the
|
||||
// parent row survives and history records no line.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
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::NoAction, // on delete: reject if referenced
|
||||
ReferentialAction::NoAction,
|
||||
false,
|
||||
None,
|
||||
))
|
||||
.expect("add NO ACTION relationship");
|
||||
seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers");
|
||||
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1)", "Orders");
|
||||
|
||||
let input = "delete from Customers where id = 1";
|
||||
let result = run_delete(&db, &rt, input);
|
||||
assert!(result.is_err(), "delete of a referenced parent must be rejected");
|
||||
// Rolled back: Alice survives.
|
||||
let customers = rt.block_on(db.query_data("Customers".to_string(), None, None, None)).unwrap();
|
||||
assert_eq!(customers.rows.len(), 1, "parent row preserved after rejected delete");
|
||||
// No history line for the failed statement (written only on success).
|
||||
let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default();
|
||||
assert!(!history.contains(input), "failed delete not logged: {history:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_referential_cascade_counts_only_cascaded_rows() {
|
||||
// A self-referential ON DELETE CASCADE FK: deleting the root of a
|
||||
// chain cascades down within the same table. The directly-deleted
|
||||
// row is reported in rows_affected, so the cascade summary must
|
||||
// report only the *additional* rows removed via the self-
|
||||
// reference — not the raw before/after diff (which includes the
|
||||
// direct delete). Without the self-ref correction this reports 3.
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "T", &[("id", Type::Int), ("ParentId", Type::Int)], &["id"]);
|
||||
rt.block_on(db.add_relationship(
|
||||
Some("parent_of".to_string()),
|
||||
"T".to_string(),
|
||||
"id".to_string(),
|
||||
"T".to_string(),
|
||||
"ParentId".to_string(),
|
||||
ReferentialAction::Cascade,
|
||||
ReferentialAction::NoAction,
|
||||
false,
|
||||
None,
|
||||
))
|
||||
.expect("add self-referential relationship");
|
||||
seed(&db, &rt, "insert into T (id, ParentId) values (1, null), (2, 1), (3, 2)", "T");
|
||||
let result =
|
||||
run_delete(&db, &rt, "delete from T where id = 1").expect("self-ref delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row matched the WHERE directly");
|
||||
assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once");
|
||||
assert_eq!(
|
||||
result.cascade[0].rows_changed, 2,
|
||||
"only the 2 cascaded rows, not the directly-deleted root too"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_target_table_rejected_at_parse() {
|
||||
// ADR-0030 §6 / ADR-0033 §1: the `__rdbms_*` metadata tables are
|
||||
// rejected at the target slot — the parse fails, the statement
|
||||
// never reaches the worker.
|
||||
assert!(
|
||||
parse_command("delete from __rdbms_playground_columns").is_err(),
|
||||
"internal table must be rejected at the DELETE target slot"
|
||||
);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sub-phase 3g — RETURNING (ADR-0033 §5)
|
||||
// =================================================================
|
||||
|
||||
#[test]
|
||||
fn delete_returning_yields_predelete_row() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'gone'), (2, 'keep')", "t");
|
||||
let result = run_delete(&db, &rt, "delete from t where id = 1 returning *")
|
||||
.expect("DELETE … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row deleted");
|
||||
// RETURNING yields the row as it was BEFORE deletion.
|
||||
assert_eq!(result.data.rows.len(), 1, "the deleted row was returned");
|
||||
assert_eq!(result.data.rows[0][1], Some("gone".to_string()));
|
||||
// And it really is gone from the table.
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
assert!(!csv.contains("gone") && csv.contains("keep"), "row actually deleted: {csv:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_returning_with_cascade_surfaces_both() {
|
||||
// 3g: a parent DELETE … RETURNING must surface BOTH the returned
|
||||
// (deleted) parent rows AND the per-relationship cascade summary.
|
||||
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 returning *")
|
||||
.expect("cascading DELETE … RETURNING runs");
|
||||
assert_eq!(result.rows_affected, 1, "one parent row deleted");
|
||||
// RETURNING gave the deleted customer row.
|
||||
assert_eq!(result.data.rows.len(), 1, "deleted parent row returned");
|
||||
// Cascade summary still computed alongside the result set.
|
||||
assert_eq!(result.cascade.len(), 1, "cascade reported");
|
||||
assert_eq!(result.cascade[0].child_table, "Orders");
|
||||
assert_eq!(result.cascade[0].rows_changed, 2, "both Alice's orders cascaded");
|
||||
}
|
||||
Reference in New Issue
Block a user