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
+94
View File
@@ -595,6 +595,16 @@ enum Request {
row_source: String,
reply: oneshot::Sender<Result<InsertResult, DbError>>,
},
/// Run a grammar-validated SQL `UPDATE` (ADR-0033 §2). The
/// worker executes `sql` as text, re-persists `target_table`'s
/// CSV (ADR-0030 §11), and appends the literal line to
/// `history.log`.
RunSqlUpdate {
sql: String,
source: Option<String>,
target_table: String,
reply: oneshot::Sender<Result<UpdateResult, DbError>>,
},
/// Capture the query plan for an explainable command via
/// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner
/// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN`
@@ -1078,6 +1088,28 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Run a validated SQL `UPDATE` and return the affected-row
/// count (ADR-0033 §2, sub-phase 3e). `sql` is the
/// grammar-validated statement text; `source` is the literal
/// submitted line for `history.log`; `target_table` is the
/// parsed target whose CSV is re-persisted.
pub async fn run_sql_update(
&self,
sql: String,
source: Option<String>,
target_table: String,
) -> Result<UpdateResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlUpdate {
sql,
source,
target_table,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Capture the query plan for an explainable command
/// (ADR-0028 §2). The wrapped command is not executed —
/// `EXPLAIN QUERY PLAN` only inspects how the engine would
@@ -1541,6 +1573,20 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
&row_source,
));
}
Request::RunSqlUpdate {
sql,
source,
target_table,
reply,
} => {
let _ = reply.send(do_sql_update(
conn,
persistence,
source.as_deref(),
&sql,
&target_table,
));
}
Request::RebuildFromText {
project_path,
source,
@@ -5975,6 +6021,54 @@ fn do_sql_insert(
})
}
/// Worker handler for `Request::RunSqlUpdate` (ADR-0033 §2,
/// sub-phase 3e). Mirrors `do_sql_insert`'s persistence
/// discipline: run the validated SQL inside a transaction,
/// re-persist the target table's CSV + append `history.log` via
/// `finalize_persistence` *before* `tx.commit()`, then commit.
///
/// Grammar-as-text (ADR-0030 §4): the assignment and predicate
/// values are literals in `sql`, so no parameters are bound. A SQL
/// `UPDATE` without `WHERE` runs across all rows as written
/// (ADR-0030 §12 — no `--all-rows` rail). An update matching zero
/// rows is a success (`rows_affected == 0`); the persistence
/// write-through still runs (re-persisting the unchanged CSV is a
/// no-op-equivalent and keeps the path uniform).
///
/// Auto-show: 3e returns an empty [`DataResult`] — the affected
/// rows can't be shown precisely without `RETURNING` (sub-phase
/// 3g, which is the precise tool). The summary surfaces the
/// affected-row count; the renderer skips the (column-less) table.
fn do_sql_update(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
sql: &str,
target_table: &str,
) -> Result<UpdateResult, DbError> {
debug!(sql = %sql, table = %target_table, "sql_update");
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let rows_affected = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
let changes = Changes {
schema_dirty: false,
rewritten_tables: vec![target_table.to_string()],
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(UpdateResult {
rows_affected,
data: DataResult {
table_name: target_table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
})
}
/// Execute a grammar-validated SQL `SELECT` and collect its
/// rows into a [`DataResult`] (ADR-0030 §6, ADR-0032 §12 +
/// Amendment 1).