feat: ADR-0036 Phase 2 — validate advanced-mode UPDATE SET literals + retain the value

Mirror Phase 1's capture-at-parse technique on the UPDATE SET assignment
list. build_sql_update calls the new capture_set_literals (data.rs), which
walks the matched tokens (no reparse, no grammar change) and classifies
each top-level `SET col = <rhs>` as a literal (Some, incl. signed numbers)
or an expression (None), using paren depth so a comma inside a function
call or a `where` inside a scalar subquery is not mistaken for a boundary,
and the trailing top-level WHERE is excluded.

Command::SqlUpdate gains set_literals; do_sql_update validates the literals
against their column types via the shared impl_value_for before the still
verbatim update; user_value_for_column reads them so a constraint error
names the offending value. WHERE stays unvalidated; execution and command
identity are unchanged.

Also corrects the stale data.rs header comment (DSL typed slots are wired,
not "deferred") and flips ADR-0036 + README to Phases 1–2 implemented.

Tests: 1934 passing (+4), 0 failed, 0 skipped, 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-26 22:20:12 +00:00
parent 2f0af31b3b
commit 8c3b13b313
10 changed files with 413 additions and 27 deletions
+49 -4
View File
@@ -711,6 +711,10 @@ enum Request {
source: Option<String>,
target_table: String,
returning: bool,
/// Captured literal `SET col = <literal>` values (`(col, None)` =
/// expression RHS) for app-level type validation before the
/// verbatim update (ADR-0036 Phase 2).
set_literals: Vec<(String, Option<Value>)>,
reply: oneshot::Sender<Result<UpdateResult, DbError>>,
},
/// Run a grammar-validated SQL `DELETE` (ADR-0033 §1/§7). The
@@ -1506,17 +1510,38 @@ 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
/// Run a validated SQL `UPDATE` with **no** captured literals (no
/// app-level value validation — the verbatim ADR-0033 path). Used by
/// worker-level callers that build the statement directly. The
/// runtime, which has a parsed command, uses
/// [`Self::run_sql_update_with_literals`] instead so the `SET`
/// literals are validated (ADR-0036 Phase 2). `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.
/// 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,
returning: bool,
) -> Result<UpdateResult, DbError> {
self.run_sql_update_with_literals(sql, source, target_table, returning, Vec::new())
.await
}
/// As [`Self::run_sql_update`], plus the literal `SET` values captured
/// at parse (`(col, None)` = expression RHS) so the worker can
/// validate each literal against its column type before the (still
/// verbatim) update and the error layer can name the offending value
/// (ADR-0036 Phase 2).
pub async fn run_sql_update_with_literals(
&self,
sql: String,
source: Option<String>,
target_table: String,
returning: bool,
set_literals: Vec<(String, Option<Value>)>,
) -> Result<UpdateResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlUpdate {
@@ -1524,6 +1549,7 @@ impl Database {
source,
target_table,
returning,
set_literals,
reply,
})
.await?;
@@ -2493,6 +2519,7 @@ fn handle_request(
source,
target_table,
returning,
set_literals,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_update(
@@ -2502,6 +2529,7 @@ fn handle_request(
&sql,
&target_table,
returning,
&set_literals,
));
}
Request::RunSqlDelete {
@@ -8545,10 +8573,27 @@ fn do_sql_update(
sql: &str,
target_table: &str,
returning: bool,
set_literals: &[(String, Option<Value>)],
) -> Result<UpdateResult, DbError> {
debug!(sql = %sql, table = %target_table, returning, "sql_update");
let canonical_table = require_canonical_table(conn, target_table)?;
let target_table = canonical_table.as_str();
// ADR-0036 Phase 2: validate each captured `SET col = <literal>`
// against its column type BEFORE the (still verbatim) update runs —
// sharing the DSL's per-type validators (`impl_value_for`, the same
// helper `build_update_sql` uses) for identical wording. Only literal
// assignments are checked; expression positions (`None`) and the
// `WHERE` predicate are left to the engine (ADR-0036 §2). Execution
// below is unchanged (no binding).
if set_literals.iter().any(|(_, v)| v.is_some()) {
let schema = read_schema(conn, target_table)?;
for (col, slot) in set_literals {
if let Some(value) = slot {
impl_value_for(&schema, col, value)?;
}
}
}
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;