feat: ADR-0036 Phase 1 — validate advanced-mode INSERT literals + show the value

Capture literal VALUES at parse onto Command::SqlInsert (no grammar change,
no reparse); validate them against column types before the still-verbatim
insert (reusing impl_value_for for DSL-parity wording); read them in the
error enricher so a constraint error names the real value. Execution,
auto-fill, and command identity unchanged. Adds run_sql_insert_with_literals
(runtime path); run_sql_insert stays the no-capture raw entry.

Proven: malformed date 2025/01/15 now refused in advanced-mode SQL; replayed
UNIQUE shows the real value. Tests +3 (expression runs, multi-row, natural
order) + 2 flipped/strengthened. 1930 pass / 0 fail / 0 skip; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-26 21:58:25 +00:00
parent dc9a4759ce
commit 1d5534b2bd
8 changed files with 312 additions and 22 deletions
+7 -6
View File
@@ -340,17 +340,18 @@ fn replay_constraint_failure_shows_real_names_not_placeholders() {
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
};
// No unsubstituted placeholders (the safety net + enrichment).
// No unsubstituted placeholders.
assert!(
!error.contains("{table}") && !error.contains("{column}") && !error.contains("{value}"),
"no unsubstituted placeholders; got: {error}"
);
// The real table + column are shown (resolved from the engine
// message). The offending value is NOT shown: replay parses in
// advanced mode → `SqlInsert`, whose values are raw SQL text (ADR-0033
// verbatim execution), not retained typed values — so it degrades to
// the neutral "that value" rather than leaking `{value}`.
// The real table + column are shown (from the engine message), and —
// since ADR-0036 Phase 1 retains the captured literal on the SQL
// INSERT command — the **real offending value** is shown too (it used
// to degrade to the neutral "that value" because `SqlInsert` discarded
// its literals).
assert!(error.contains("T.email"), "names the real table.column; got: {error}");
assert!(error.contains("a@b.com"), "shows the real offending value; got: {error}");
}
#[test]
+1
View File
@@ -95,6 +95,7 @@ fn run_insert(
listed_columns,
row_source,
returning,
..
} => rt.block_on(db.run_sql_insert(
sql,
Some(input.to_string()),
+111 -15
View File
@@ -445,6 +445,7 @@ fn run_sqlinsert(
listed_columns,
row_source,
returning,
..
} => rt.block_on(db.run_sql_insert(
sql,
Some(input.to_string()),
@@ -1037,17 +1038,13 @@ fn autofill_preserves_on_conflict_clause() {
}
#[test]
fn sql_dml_skips_app_level_value_validation_that_the_dsl_enforces() {
// CHARACTERIZATION of a real divergence (ADR pending — see the
// verbatim-vs-structural DML discussion). The DSL insert path validates
// a value against the playground type system at *bind* time
// (`Value::bind_for_column` → `validate_date`); the verbatim SQL insert
// path hands the literal straight to the engine, whose STRICT `date`
// column is TEXT and accepts any string. `2025/01/15` is a malformed
// date (slashes, not dashes): the DSL rejects it, advanced-mode SQL
// currently accepts it. When the forthcoming ADR routes SQL literal
// values through the same validation, FLIP the SQL assertion to expect
// a rejection.
fn sql_dml_validates_literal_values_like_the_dsl() {
// ADR-0036 Phase 1: advanced-mode SQL `INSERT` now validates each
// literal value against its column type before the (still verbatim)
// insert runs, sharing the DSL's per-type validators. `2025/01/15` is
// a malformed date (slashes, not dashes) — the DSL 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, _d) = open_project_db();
let r = rt();
r.block_on(db.create_table(
@@ -1073,7 +1070,7 @@ fn sql_dml_skips_app_level_value_validation_that_the_dsl_enforces() {
"the DSL insert path validates `date` and rejects 2025/01/15; got {dsl:?}"
);
// SQL path (advanced mode, full pipeline) — currently ACCEPTS it.
// SQL path (advanced mode, full pipeline) — now REJECTS it too.
std::fs::write(
project.path().join("ins.commands"),
"insert into T (id, d) values (2, '2025/01/15')\n",
@@ -1081,8 +1078,107 @@ fn sql_dml_skips_app_level_value_validation_that_the_dsl_enforces() {
.expect("write script");
let events = r.block_on(run_replay(&db, project.path(), "ins.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 1),
"CHARACTERIZATION: verbatim SQL insert skips `date` validation and \
accepts 2025/01/15 the gap the ADR will close; events: {events:?}"
matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
"advanced-mode SQL validates the `date` literal and refuses \
2025/01/15 (ADR-0036 Phase 1); events: {events:?}"
);
// A valid date still inserts (the bound/verbatim path is unaffected).
std::fs::write(
project.path().join("ok.commands"),
"insert into T (id, d) values (3, '2025-01-15')\n",
)
.expect("write script");
let ok = r.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 inserts; events: {ok:?}"
);
}
#[test]
fn sql_insert_expression_value_is_not_validated_and_runs() {
// An expression position (not a bare literal) is left to the engine —
// ADR-0036 Phase 1 has nothing static to validate, so `1 + 2` into an
// int column computes 3 and inserts; it must not be mis-classified as
// a literal or rejected.
let (project, db, _d) = open_project_db();
let r = rt();
r.block_on(db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("n", Type::Int),
],
vec!["id".to_string()],
Some("create table T with pk id(int)".to_string()),
))
.expect("create T");
std::fs::write(
project.path().join("e.commands"),
"insert into T (id, n) values (1, 1 + 2)\n",
)
.expect("write script");
let events = r.block_on(run_replay(&db, project.path(), "e.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 1),
"the expression value executes (engine computes it); events: {events:?}"
);
}
#[test]
fn sql_insert_multi_row_validates_each_literal() {
// Validation applies to every literal row; a malformed `date` in the
// second tuple is caught (ADR-0036 Phase 1 — execution is verbatim, so
// multi-row comes for free).
let (project, db, _d) = open_project_db();
let r = rt();
r.block_on(db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("d", Type::Date),
],
vec!["id".to_string()],
Some("create table T with pk id(int)".to_string()),
))
.expect("create T");
std::fs::write(
project.path().join("m.commands"),
"insert into T (id, d) values (1, '2025-01-15'), (2, '2025/02/20')\n",
)
.expect("write script");
let events = r.block_on(run_replay(&db, project.path(), "m.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
"the malformed date in the second row is caught; events: {events:?}"
);
}
#[test]
fn sql_insert_natural_order_validates_against_schema_columns() {
// With no explicit column list, positions map to the schema's columns
// in definition order (engine semantics) — validation must use that
// mapping, not an explicit list (ADR-0036 Phase 1).
let (project, db, _d) = open_project_db();
let r = rt();
r.block_on(db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("d", Type::Date),
],
vec!["id".to_string()],
Some("create table T with pk id(int)".to_string()),
))
.expect("create T");
std::fs::write(
project.path().join("nat.commands"),
"insert into T values (1, '2025/02/20')\n",
)
.expect("write script");
let events = r.block_on(run_replay(&db, project.path(), "nat.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
"natural-order insert validates the date against column `d`; events: {events:?}"
);
}
+1
View File
@@ -273,6 +273,7 @@ async fn sql_insert(db: &Database, input: &str) {
listed_columns,
row_source,
returning,
..
} => {
db.run_sql_insert(
sql,