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
+70
View File
@@ -200,6 +200,76 @@ fn enrich_unique_update_resolves_value_from_assignments() {
});
}
#[test]
fn enrich_unique_sql_update_resolves_value_from_set_literals() {
// ADR-0036 Phase 2: an advanced-mode SQL `UPDATE` now retains its
// `SET` literals, so a UNIQUE violation names the offending value —
// closing the error-value gap for advanced mode, mirroring the DSL
// `Update` case above. The value flows from the parse-captured
// `set_literals` through `user_value_for_column`.
let db = db();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Int),
ColumnSpec::new("name".to_string(), Type::Text),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
None,
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
None,
)
.await
.unwrap();
// Advanced-mode SQL: set Bob's id to 1 — collides with Alice.
let input = "update Customers set id = 1 where name = 'Bob'";
let cmd = parse_command(input).expect("parses as advanced-mode SQL update");
let Command::SqlUpdate {
sql,
target_table,
returning,
set_literals,
} = cmd.clone()
else {
panic!("expected Command::SqlUpdate, got {cmd:?}");
};
// The literal `1` is a valid int, so Phase-2 validation passes and
// the engine-level UNIQUE violation is what surfaces.
let err = db
.run_sql_update_with_literals(sql, None, target_table, returning, set_literals)
.await
.unwrap_err();
assert!(matches!(
err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
));
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.column.as_deref(), Some("id"));
assert_eq!(
facts.value.as_deref(),
Some("1"),
"the offending SET value is named (from set_literals)"
);
});
}
// ---- NOT NULL ---------------------------------------------------
#[test]
+8 -2
View File
@@ -114,8 +114,14 @@ fn run_update(
input: &str,
) -> Result<UpdateResult, DbError> {
match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) {
Command::SqlUpdate { sql, target_table, returning } => rt.block_on(
db.run_sql_update(sql, Some(input.to_string()), target_table, returning),
Command::SqlUpdate { sql, target_table, returning, set_literals } => rt.block_on(
db.run_sql_update_with_literals(
sql,
Some(input.to_string()),
target_table,
returning,
set_literals,
),
),
other => panic!("expected Command::SqlUpdate from {input:?}, got {other:?}"),
}
+120 -4
View File
@@ -8,9 +8,11 @@
//! across all rows with no rail (ADR-0030 §12).
use rdbms_playground::db::{Database, DbError, UpdateResult};
use rdbms_playground::dsl::{ColumnSpec, Command, Type, parse_command};
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
use rdbms_playground::runtime::run_replay;
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
@@ -70,9 +72,15 @@ fn run_update(
input: &str,
) -> Result<UpdateResult, DbError> {
match parse_command(input).expect("parse update") {
Command::SqlUpdate { sql, target_table, returning } => {
rt.block_on(db.run_sql_update(sql, Some(input.to_string()), target_table, returning))
}
Command::SqlUpdate { sql, target_table, returning, set_literals } => rt.block_on(
db.run_sql_update_with_literals(
sql,
Some(input.to_string()),
target_table,
returning,
set_literals,
),
),
other => panic!("expected Command::SqlUpdate, got {other:?}"),
}
}
@@ -205,6 +213,114 @@ fn update_appends_literal_line_to_history() {
assert!(body.contains(input), "history records the literal line: {body:?}");
}
// =================================================================
// ADR-0036 Phase 2 — `SET` literal value validation
// =================================================================
#[test]
fn sql_update_validates_set_literals_like_the_dsl() {
// ADR-0036 Phase 2: advanced-mode SQL `UPDATE` now validates each
// literal `SET col = <literal>` value against its column type before
// the (still verbatim) update runs, sharing the DSL's per-type
// validators. `2025/01/15` is a malformed date (slashes, not dashes):
// the DSL update rejects it at bind time, and advanced-mode SQL now
// refuses it too (it used to splice the literal into text and let a
// STRICT TEXT column accept anything).
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("d", Type::Date)], &["id"]);
seed(&db, &rt, "insert into t (id, d) values (1, '2025-01-15')", "t");
// SQL path (advanced mode, full replay pipeline) — REJECTS the bad date.
std::fs::write(
project.path().join("bad.commands"),
"update t set d = '2025/01/15' where id = 1\n",
)
.expect("write script");
let events = rt.block_on(run_replay(&db, project.path(), "bad.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
"advanced-mode SQL validates the `date` SET literal and refuses \
2025/01/15 (ADR-0036 Phase 2); events: {events:?}"
);
// A well-formed date still updates (the verbatim path is unaffected).
std::fs::write(
project.path().join("ok.commands"),
"update t set d = '2025-02-20' where id = 1\n",
)
.expect("write script");
let ok = rt.block_on(run_replay(&db, project.path(), "ok.commands"));
assert!(
matches!(ok.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 1),
"a well-formed date still updates; events: {ok:?}"
);
}
#[test]
fn sql_update_captures_set_literal_classification() {
// ADR-0036 Phase 2 seam (the "one new seam to keep honest"): each
// top-level `SET` RHS is classified — a bare literal (string / signed
// number / bool / null) is captured as `Some`, while an expression
// (arithmetic / scalar subquery / function call / column ref) is
// `None` and left to the engine. Critically, a comma *inside* a
// function call and a `where` *inside* a subquery must NOT be mistaken
// for an assignment separator / SET-list terminator (paren-depth
// guard), and the trailing top-level `WHERE` predicate is not captured.
let cmd = parse_command(
"update t set a = '2025-01-15', b = price * qty, c = -5, \
d = (select max(n) from o where n < 100), e = true, \
f = coalesce(g, 0), h = null where id = 7",
)
.expect("advanced-mode SQL update parses");
match cmd {
Command::SqlUpdate { set_literals, .. } => {
assert_eq!(
set_literals,
vec![
("a".to_string(), Some(Value::Text("2025-01-15".to_string()))),
("b".to_string(), None),
("c".to_string(), Some(Value::Number("-5".to_string()))),
("d".to_string(), None),
("e".to_string(), Some(Value::Bool(true))),
("f".to_string(), None),
("h".to_string(), Some(Value::Null)),
],
"literals captured; arithmetic / subquery (with inner WHERE) / \
function call (with inner comma) skipped; trailing WHERE excluded",
);
}
other => panic!("expected Command::SqlUpdate, got {other:?}"),
}
}
#[test]
fn sql_update_validates_every_assignment_not_just_the_first() {
// A malformed literal in the *second* assignment is caught — the
// validation loop covers every `SET` literal, not only the first
// (ADR-0036 Phase 2). The first assignment (`v = 'ok'`) is well-formed.
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(
&db,
&rt,
"t",
&[("id", Type::Int), ("v", Type::Text), ("d", Type::Date)],
&["id"],
);
seed(&db, &rt, "insert into t (id, v, d) values (1, 'a', '2025-01-01')", "t");
std::fs::write(
project.path().join("multi.commands"),
"update t set v = 'ok', d = '2025/01/15' where id = 1\n",
)
.expect("write script");
let events = rt.block_on(run_replay(&db, project.path(), "multi.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
"the malformed date in the second assignment is caught; events: {events:?}"
);
}
// =================================================================
// Sub-phase 3g — RETURNING (ADR-0033 §5)
// =================================================================