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:
+60
@@ -1355,6 +1355,15 @@ impl App {
|
||||
for effect in &result.cascade {
|
||||
self.note_system(render_cascade_effect(effect));
|
||||
}
|
||||
// A `RETURNING` clause (ADR-0033 §5, 3g) carries the deleted
|
||||
// rows; the cascade summary above surfaces alongside them. A
|
||||
// column-less result (the DSL `delete` and SQL `DELETE`
|
||||
// without RETURNING) is skipped, exactly as for UPDATE.
|
||||
if !result.data.columns.is_empty() {
|
||||
for line in crate::output_render::render_data_table(&result.data) {
|
||||
self.note_system(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dsl_failure(
|
||||
@@ -3365,6 +3374,7 @@ mod tests {
|
||||
command: Command::SqlUpdate {
|
||||
sql: "update t set v = 1".to_string(),
|
||||
target_table: "t".to_string(),
|
||||
returning: false,
|
||||
},
|
||||
result: crate::db::UpdateResult {
|
||||
rows_affected: 2,
|
||||
@@ -3396,6 +3406,7 @@ mod tests {
|
||||
command: Command::SqlUpdate {
|
||||
sql: "update t set v = 1".to_string(),
|
||||
target_table: "t".to_string(),
|
||||
returning: false,
|
||||
},
|
||||
result: crate::db::UpdateResult {
|
||||
rows_affected: 1,
|
||||
@@ -3429,6 +3440,7 @@ mod tests {
|
||||
command: Command::SqlDelete {
|
||||
sql: "delete from Customers where id = 1".to_string(),
|
||||
target_table: "Customers".to_string(),
|
||||
returning: false,
|
||||
},
|
||||
result: crate::db::DeleteResult {
|
||||
rows_affected: 1,
|
||||
@@ -3438,6 +3450,12 @@ mod tests {
|
||||
rows_changed: 2,
|
||||
action: ReferentialAction::Cascade,
|
||||
}],
|
||||
data: crate::db::DataResult {
|
||||
table_name: "Customers".to_string(),
|
||||
columns: Vec::new(),
|
||||
column_types: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
},
|
||||
},
|
||||
});
|
||||
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
|
||||
@@ -3455,4 +3473,46 @@ mod tests {
|
||||
"per-relationship cascade summary surfaced: {texts:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_delete_returning_renders_cascade_and_result_table() {
|
||||
// ADR-0033 3g: a DELETE … RETURNING surfaces BOTH the cascade
|
||||
// summary AND the returned-rows table. Pins the render branch
|
||||
// that tabulates `result.data` when RETURNING populated it
|
||||
// (the column-less non-RETURNING path is skipped — see the
|
||||
// sibling test above).
|
||||
use crate::dsl::ReferentialAction;
|
||||
let mut app = App::new();
|
||||
app.update(AppEvent::DslDeleteSucceeded {
|
||||
command: Command::SqlDelete {
|
||||
sql: "delete from Customers where id = 1 returning *".to_string(),
|
||||
target_table: "Customers".to_string(),
|
||||
returning: true,
|
||||
},
|
||||
result: crate::db::DeleteResult {
|
||||
rows_affected: 1,
|
||||
cascade: vec![crate::db::CascadeEffect {
|
||||
relationship_name: "places".to_string(),
|
||||
child_table: "Orders".to_string(),
|
||||
rows_changed: 2,
|
||||
action: ReferentialAction::Cascade,
|
||||
}],
|
||||
data: crate::db::DataResult {
|
||||
table_name: "Customers".to_string(),
|
||||
columns: vec!["id".to_string(), "Name".to_string()],
|
||||
column_types: vec![Some(Type::Int), Some(Type::Text)],
|
||||
rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
|
||||
},
|
||||
},
|
||||
});
|
||||
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
|
||||
assert!(
|
||||
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")),
|
||||
"cascade summary still surfaces alongside RETURNING: {texts:?}",
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t.contains("Name")) && texts.iter().any(|t| t.contains("Alice")),
|
||||
"the returned (deleted) row is tabulated: {texts:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user