grammar+db: 3g — RETURNING on INSERT/UPDATE/DELETE (ADR-0033 §5)
Shared RETURNING_CLAUSE (reuses Phase-2 PROJECTION_LIST, now pub(crate)) as an optional tail on all three SQL DML shapes. `returning: bool` on the Command variants, set by the ast-builders and threaded to the worker. run_returning collects the returned rows as a DataResult (RETURNING mutates + yields in one pass), reusing resolve_select_column_types for bare-column type recovery; computed projections stay typeless. DeleteResult gains a `data` field rendered alongside the cascade summary. Follow-set fix: `returning` is added to the table-source and projection bare-alias follow-sets so an INSERT … SELECT row source stops before RETURNING instead of reading it as a table alias. Auto-fill × RETURNING: build_sql_insert stops row_source before the RETURNING token (keeping it preparable for shortid materialisation), and plan_shortid_autofill re-appends the RETURNING tail so generated shortids surface in RETURNING *. Tests (+17): grammar accept on all three; INSERT/UPDATE/DELETE RETURNING incl. *, aliases, multi-row, type recovery + computed- typeless; auto-fill × RETURNING (single + multi-row distinct ids); INSERT…SELECT…RETURNING execution; UPDATE…RETURNING zero-match; DELETE…RETURNING cascade+rows; app-level render of both. Dev sql_insert/sql_update/sql_delete entry words still removed in 3j. 1562 pass / 0 fail / 1 ignored. Clippy clean.
This commit is contained in:
+43
-3
@@ -71,6 +71,7 @@ fn seed(db: &Database, rt: &tokio::runtime::Runtime, sql: &str, target: &str) {
|
||||
target.to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.unwrap_or_else(|e| panic!("seed {sql:?}: {e:?}"));
|
||||
}
|
||||
@@ -82,8 +83,8 @@ fn run_delete(
|
||||
input: &str,
|
||||
) -> Result<DeleteResult, DbError> {
|
||||
match parse_command(input).expect("parse sql_delete") {
|
||||
Command::SqlDelete { sql, target_table } => {
|
||||
rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table))
|
||||
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:?}"),
|
||||
}
|
||||
@@ -117,7 +118,7 @@ fn parse_path_lowers_sql_delete_to_command() {
|
||||
let command = parse_command("sql_delete from Orders where id = 1")
|
||||
.expect("sql_delete parses in advanced mode");
|
||||
match command {
|
||||
Command::SqlDelete { sql, target_table } => {
|
||||
Command::SqlDelete { sql, target_table, .. } => {
|
||||
assert_eq!(sql, "delete from Orders where id = 1");
|
||||
assert_eq!(target_table, "Orders");
|
||||
}
|
||||
@@ -424,3 +425,42 @@ fn internal_target_table_rejected_at_parse() {
|
||||
"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, "sql_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, "sql_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");
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ fn single_row_insert_persists_and_counts() {
|
||||
"T".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("insert runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row inserted");
|
||||
@@ -93,6 +94,7 @@ fn multi_row_insert_persists_both_rows() {
|
||||
"T".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("multi-row insert runs");
|
||||
assert_eq!(result.rows_affected, 2, "two rows inserted");
|
||||
@@ -115,6 +117,7 @@ fn no_column_list_full_arity_insert_persists() {
|
||||
"T".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("full-arity insert runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
@@ -135,6 +138,7 @@ fn insert_appends_literal_line_to_history() {
|
||||
"T".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("insert runs");
|
||||
let body = std::fs::read_to_string(project.path().join("history.log"))
|
||||
@@ -157,6 +161,7 @@ fn failed_insert_rolls_back_and_does_not_repersist() {
|
||||
"T".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("first insert runs");
|
||||
// Second insert violates the primary key — it must fail and
|
||||
@@ -168,6 +173,7 @@ fn failed_insert_rolls_back_and_does_not_repersist() {
|
||||
"T".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
));
|
||||
assert!(outcome.is_err(), "duplicate PK must fail: {outcome:?}");
|
||||
let csv = read_csv(&project, "T").expect("T.csv still present");
|
||||
@@ -191,6 +197,7 @@ fn failed_multi_row_insert_is_atomic() {
|
||||
"T".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("seed row");
|
||||
// Row (2,…) is new but (1,…) collides on the PK — the whole
|
||||
@@ -201,6 +208,7 @@ fn failed_multi_row_insert_is_atomic() {
|
||||
"T".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
));
|
||||
assert!(outcome.is_err(), "multi-row PK conflict must fail: {outcome:?}");
|
||||
let csv = read_csv(&project, "T").expect("T.csv still present");
|
||||
@@ -296,6 +304,7 @@ fn insert_select_copies_rows_and_persists() {
|
||||
"source".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("seed source");
|
||||
let result = rt
|
||||
@@ -305,6 +314,7 @@ fn insert_select_copies_rows_and_persists() {
|
||||
"archive".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("INSERT … SELECT runs");
|
||||
assert_eq!(result.rows_affected, 2, "both source rows copied");
|
||||
@@ -327,6 +337,7 @@ fn insert_select_with_column_list_and_projection_persists() {
|
||||
"source".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("seed source");
|
||||
let result = rt
|
||||
@@ -336,6 +347,7 @@ fn insert_select_with_column_list_and_projection_persists() {
|
||||
"target".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("column-list + projection INSERT … SELECT runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
@@ -356,6 +368,7 @@ fn with_prefixed_insert_select_runs_and_persists() {
|
||||
"orders".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("seed orders");
|
||||
let result = rt
|
||||
@@ -365,6 +378,7 @@ fn with_prefixed_insert_select_runs_and_persists() {
|
||||
"archive".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("WITH-prefixed INSERT … SELECT runs");
|
||||
assert_eq!(result.rows_affected, 2);
|
||||
@@ -389,6 +403,7 @@ fn insert_select_from_self_runs_as_plain_insert() {
|
||||
"T".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("seed");
|
||||
let result = rt
|
||||
@@ -398,6 +413,7 @@ fn insert_select_from_self_runs_as_plain_insert() {
|
||||
"T".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("self-sourced INSERT … SELECT runs");
|
||||
assert_eq!(result.rows_affected, 2, "two rows copied with shifted PK");
|
||||
@@ -426,12 +442,14 @@ fn run_sqlinsert(
|
||||
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, got {other:?}"),
|
||||
}
|
||||
@@ -714,3 +732,168 @@ fn autofill_insert_select_narrower_projection_is_rejected() {
|
||||
assert!(outcome.is_err(), "narrower projection must be rejected: {outcome:?}");
|
||||
assert!(csv_rows(&project, "t").is_empty(), "nothing should land");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sub-phase 3g — RETURNING (ADR-0033 §5)
|
||||
// =================================================================
|
||||
|
||||
#[test]
|
||||
fn insert_returning_star_returns_inserted_row() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||||
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (id, b) values (1, 'Ada') returning *")
|
||||
.expect("INSERT … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row inserted");
|
||||
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the inserted row");
|
||||
assert_eq!(result.data.columns, vec!["id".to_string(), "b".to_string()]);
|
||||
assert_eq!(result.data.rows[0][1], Some("Ada".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_multirow_returning_id_yields_distinct_rows() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (id, b) values (1, 'a'), (2, 'b'), (3, 'c') returning id",
|
||||
)
|
||||
.expect("multi-row INSERT … RETURNING id runs");
|
||||
assert_eq!(result.rows_affected, 3, "three rows inserted");
|
||||
assert_eq!(result.data.columns, vec!["id".to_string()]);
|
||||
let ids: std::collections::BTreeSet<_> =
|
||||
result.data.rows.iter().map(|r| r[0].clone()).collect();
|
||||
assert_eq!(ids.len(), 3, "three distinct ids returned: {:?}", result.data.rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_returning_autofills_shortid_and_returns_it() {
|
||||
// The auto-fill × RETURNING interaction (3d × 3g): the worker
|
||||
// rewrites the INSERT to add the generated shortid, and the
|
||||
// rewrite must PRESERVE the RETURNING tail so the generated id
|
||||
// surfaces in the returned row.
|
||||
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') returning *")
|
||||
.expect("auto-fill INSERT … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row inserted (RETURNING-counted)");
|
||||
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the row");
|
||||
// `id` is the auto-filled shortid column; it must be non-empty in
|
||||
// the returned row (proving the rewrite kept RETURNING).
|
||||
let id_idx = result.data.columns.iter().position(|c| c == "id").expect("id column");
|
||||
let id_val = result.data.rows[0][id_idx].clone();
|
||||
assert!(id_val.is_some_and(|s| !s.is_empty()), "generated shortid surfaced via RETURNING");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_returning_recovers_bare_column_type() {
|
||||
// 3g type recovery: a bare-column RETURNING ref recovers its
|
||||
// playground type via the column-origin path (a `bool` column
|
||||
// renders as the word, not 0/1).
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
|
||||
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (id, active) values (1, true) returning active")
|
||||
.expect("INSERT … RETURNING active runs");
|
||||
assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered");
|
||||
assert_eq!(result.data.rows[0][0], Some("true".to_string()), "rendered as the bool word");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_returning_computed_expression_is_typeless() {
|
||||
// 3g: a computed RETURNING projection has no base-table origin,
|
||||
// so its recovered type is None (renders with neutral alignment).
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("n", Type::Int)], &["id"]);
|
||||
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (id, n) values (1, 5) returning n + 1")
|
||||
.expect("INSERT … RETURNING <expr> runs");
|
||||
assert_eq!(result.data.column_types, vec![None], "computed projection is typeless");
|
||||
assert_eq!(result.data.rows[0][0], Some("6".to_string()), "engine evaluated n + 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_returning_recovers_multiple_bare_column_types() {
|
||||
// 3g type recovery spans the playground vocabulary. RETURNING
|
||||
// reuses the SELECT column-origin path (`resolve_select_column_
|
||||
// types`), exhaustively type-tested on the SELECT side; this
|
||||
// pins a representative spread reached via the RETURNING tail.
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[
|
||||
("id", Type::Int),
|
||||
("txt", Type::Text),
|
||||
("amount", Type::Decimal),
|
||||
("ratio", Type::Real),
|
||||
("flag", Type::Bool),
|
||||
],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (id, txt, amount, ratio, flag) values (1, 'a', 9.50, 1.5, true) returning id, txt, amount, ratio, flag",
|
||||
)
|
||||
.expect("INSERT … RETURNING <cols> runs");
|
||||
assert_eq!(
|
||||
result.data.column_types,
|
||||
vec![
|
||||
Some(Type::Int),
|
||||
Some(Type::Text),
|
||||
Some(Type::Decimal),
|
||||
Some(Type::Real),
|
||||
Some(Type::Bool),
|
||||
],
|
||||
"each bare-column RETURNING ref recovered its playground type",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multirow_autofill_returning_yields_distinct_generated_ids() {
|
||||
// DA gate (3d × 3g): multi-row INSERT with an omitted shortid PK
|
||||
// AND RETURNING — the auto-fill rewrite produces N tuples with N
|
||||
// distinct generated ids, and RETURNING * must surface all N
|
||||
// rows each carrying its own generated id.
|
||||
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') returning *",
|
||||
)
|
||||
.expect("multi-row auto-fill INSERT … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 3, "three rows inserted");
|
||||
assert_eq!(result.data.rows.len(), 3, "three rows returned");
|
||||
let id_idx = result.data.columns.iter().position(|c| c == "id").expect("id column");
|
||||
let ids: std::collections::BTreeSet<_> =
|
||||
result.data.rows.iter().map(|r| r[id_idx].clone()).collect();
|
||||
assert_eq!(ids.len(), 3, "three DISTINCT generated ids via RETURNING: {:?}", result.data.rows);
|
||||
assert!(ids.iter().all(|v| v.as_ref().is_some_and(|s| !s.is_empty())), "all ids non-empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_select_returning_executes_and_returns_rows() {
|
||||
// DA gate: the grammar accepts INSERT … SELECT … RETURNING; this
|
||||
// pins that it also EXECUTES through run_returning (the SELECT row
|
||||
// source feeds the insert, and RETURNING yields the inserted rows).
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "src", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||||
create_cols(&db, &rt, "dst", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into src (id, b) values (1, 'x'), (2, 'y')").expect("seed src");
|
||||
let result = run_sqlinsert(&db, &rt, "sqlinsert into dst select * from src returning id, b")
|
||||
.expect("INSERT … SELECT … RETURNING runs");
|
||||
assert_eq!(result.rows_affected, 2, "two rows copied");
|
||||
assert_eq!(result.data.rows.len(), 2, "RETURNING yielded both inserted rows");
|
||||
let bs: std::collections::BTreeSet<_> =
|
||||
result.data.rows.iter().map(|r| r[1].clone()).collect();
|
||||
assert!(bs.contains(&Some("x".to_string())) && bs.contains(&Some("y".to_string())));
|
||||
}
|
||||
|
||||
+52
-3
@@ -58,6 +58,7 @@ fn seed(db: &Database, rt: &tokio::runtime::Runtime, sql: &str, target: &str) {
|
||||
target.to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.unwrap_or_else(|e| panic!("seed {sql:?}: {e:?}"));
|
||||
}
|
||||
@@ -69,8 +70,8 @@ fn run_update(
|
||||
input: &str,
|
||||
) -> Result<UpdateResult, DbError> {
|
||||
match parse_command(input).expect("parse sql_update") {
|
||||
Command::SqlUpdate { sql, target_table } => {
|
||||
rt.block_on(db.run_sql_update(sql, Some(input.to_string()), target_table))
|
||||
Command::SqlUpdate { sql, target_table, returning } => {
|
||||
rt.block_on(db.run_sql_update(sql, Some(input.to_string()), target_table, returning))
|
||||
}
|
||||
other => panic!("expected Command::SqlUpdate, got {other:?}"),
|
||||
}
|
||||
@@ -81,7 +82,7 @@ fn parse_path_lowers_sql_update_to_command() {
|
||||
let command = parse_command("sql_update Orders set total = 0 where id = 1")
|
||||
.expect("sql_update parses in advanced mode");
|
||||
match command {
|
||||
Command::SqlUpdate { sql, target_table } => {
|
||||
Command::SqlUpdate { sql, target_table, .. } => {
|
||||
assert_eq!(sql, "update Orders set total = 0 where id = 1");
|
||||
assert_eq!(target_table, "Orders");
|
||||
}
|
||||
@@ -203,3 +204,51 @@ fn update_appends_literal_line_to_history() {
|
||||
.expect("history.log present");
|
||||
assert!(body.contains(input), "history records the literal line: {body:?}");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sub-phase 3g — RETURNING (ADR-0033 §5)
|
||||
// =================================================================
|
||||
|
||||
#[test]
|
||||
fn update_returning_yields_modified_columns() {
|
||||
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, 'old'), (2, 'keep')", "t");
|
||||
let result = run_update(&db, &rt, "sql_update t set v = 'new' where id = 1 returning id, v")
|
||||
.expect("UPDATE … RETURNING runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row updated");
|
||||
assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()]);
|
||||
assert_eq!(result.data.rows.len(), 1);
|
||||
// RETURNING reflects the POST-update value.
|
||||
assert_eq!(result.data.rows[0][1], Some("new".to_string()), "modified value returned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_returning_recovers_bare_column_type() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, active) values (1, false)", "t");
|
||||
let result = run_update(&db, &rt, "sql_update t set active = true where id = 1 returning active")
|
||||
.expect("UPDATE … RETURNING active runs");
|
||||
assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered");
|
||||
assert_eq!(result.data.rows[0][0], Some("true".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_returning_matching_no_rows_is_ok_and_empty() {
|
||||
// DA gate: RETURNING makes data.columns non-empty even when no
|
||||
// rows match (unlike the 3e column-less case). The operation
|
||||
// succeeds with zero rows and an empty result set — no panic, no
|
||||
// phantom 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, 'keep')", "t");
|
||||
let result = run_update(&db, &rt, "sql_update t set v = 'x' where id = 999 returning id, v")
|
||||
.expect("no-match UPDATE … RETURNING is a success");
|
||||
assert_eq!(result.rows_affected, 0, "no rows matched");
|
||||
assert!(result.data.rows.is_empty(), "no rows returned");
|
||||
assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()], "columns still present");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user