grammar+db: 3e — SQL UPDATE grammar + execution (ADR-0033 §2)

New src/dsl/grammar/sql_update.rs: SQL_UPDATE_SHAPE =
<table> SET col = sql_expr (',' …)* [WHERE sql_expr] [';'], the
__rdbms_* target rejection, and the shared sql_expr on both the
assignment RHS and the predicate. No --all-rows rail — a SQL
UPDATE without WHERE runs as written (ADR-0030 §12). Reuses
sql_select::WHERE_CLAUSE (now pub(crate)) so the predicate
diagnostics are identical. The target uses the shared `table_name`
ident role (not a bespoke one) so the Phase-2 schema-existence and
predicate-warning passes collect it as a scope binding and check
the SET / WHERE columns for free — a bespoke role left them
unchecked (the cross-cut tests caught this).

Command::SqlUpdate { sql, target_table }; Request::RunSqlUpdate +
do_sql_update (execute validated SQL via execute_with_fk_enrichment,
re-persist the target CSV, append history.log). 3e surfaces the
affected-row count only; precise row output is RETURNING (3g), so
the update-success render skips a column-less data set rather than
showing a misleading "(no rows)" band. Behind the dev `sql_update`
entry word until 3j.

Tests: grammar accept/reject; integration (single/multi-col,
no-WHERE all-rows, sql_expr in SET, scalar subquery in SET,
zero-match success, history); walker cross-cut (unknown SET column
→ unknown_column, `= NULL` in WHERE → eq_null warning); app-level
render-guard both ways (column-less → count only; with columns →
table renders). 1524 green, clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-22 13:57:21 +00:00
parent 18d34d0d36
commit 53808ed9d7
11 changed files with 646 additions and 5 deletions
+73 -2
View File
@@ -1251,8 +1251,14 @@ impl App {
fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) {
self.note_ok_summary(command);
self.note_system(crate::t!("ok.rows_updated", count = result.rows_affected));
for line in crate::output_render::render_data_table(&result.data) {
self.note_system(line);
// A column-less result carries no rows to tabulate (the SQL
// UPDATE path before `RETURNING`, ADR-0033 sub-phase 3e):
// surface just the count rather than a misleading
// "(no rows)" band. The DSL UPDATE always has columns.
if !result.data.columns.is_empty() {
for line in crate::output_render::render_data_table(&result.data) {
self.note_system(line);
}
}
}
@@ -1474,6 +1480,11 @@ impl App {
C::SqlInsert { target_table, .. } => {
(Operation::Insert, Some(target_table.as_str()), None)
}
// A SQL `UPDATE` (ADR-0033 §2) — route engine errors
// through the update operation with the parsed target.
C::SqlUpdate { target_table, .. } => {
(Operation::Update, Some(target_table.as_str()), None)
}
C::Replay { .. } => (Operation::Replay, None, None),
// An `explain` failure (e.g. unknown table) is best
// described by the wrapped query it failed to plan.
@@ -3336,4 +3347,64 @@ mod tests {
Some(crate::dsl::walker::Severity::Warning),
);
}
#[test]
fn sql_update_success_shows_count_without_no_rows_band() {
// ADR-0033 sub-phase 3e: a SQL UPDATE returns a column-less
// result (precise rows are RETURNING, 3g). The render must
// surface the affected-row count and NOT a misleading
// "(no rows)" table band.
let mut app = App::new();
app.update(AppEvent::DslUpdateSucceeded {
command: Command::SqlUpdate {
sql: "update t set v = 1".to_string(),
target_table: "t".to_string(),
},
result: crate::db::UpdateResult {
rows_affected: 2,
data: crate::db::DataResult {
table_name: "t".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();
assert!(
texts.iter().any(|t| t.contains("2 row(s) updated")),
"affected-row count surfaced: {texts:?}",
);
assert!(
!texts.iter().any(|t| t.contains("(no rows)")),
"no misleading empty-table band: {texts:?}",
);
}
#[test]
fn update_success_with_columns_renders_the_table() {
// The guard only suppresses a column-less result: a result
// carrying columns (the DSL UPDATE path) still renders.
let mut app = App::new();
app.update(AppEvent::DslUpdateSucceeded {
command: Command::SqlUpdate {
sql: "update t set v = 1".to_string(),
target_table: "t".to_string(),
},
result: crate::db::UpdateResult {
rows_affected: 1,
data: crate::db::DataResult {
table_name: "t".to_string(),
columns: vec!["id".to_string(), "v".to_string()],
column_types: vec![Some(Type::Int), Some(Type::Int)],
rows: vec![vec![Some("1".to_string()), Some("9".to_string())]],
},
},
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("id") && t.contains('v')),
"header row rendered: {texts:?}",
);
}
}