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
+91
View File
@@ -950,15 +950,106 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
// so the validated line runs verbatim (grammar-as-text,
// ADR-0030 §4) — no keyword reconstruction.
let sql = source.trim().to_string();
// Capture literal values per `VALUES` row for app-level type
// validation + error enrichment (ADR-0036 Phase 1). Only for a
// `VALUES` source (a `SELECT`/`WITH` source has no `values` keyword,
// so this stays empty). Bounded to the row-source region by the same
// `tail_start` the row_source slice used.
let values_start = path
.items
.iter()
.find(|i| matches!(i.kind, MatchedKind::Word("values")))
.map(|i| i.span.0);
let literal_rows = values_start.map_or_else(Vec::new, |vs| {
capture_literal_rows(path, vs, tail_start.unwrap_or(source.len()))
});
Ok(Command::SqlInsert {
sql,
target_table,
listed_columns,
row_source,
returning: path_has_returning(path),
literal_rows,
})
}
/// Capture the literal values of each `VALUES` tuple from the matched
/// path (ADR-0036 Phase 1). Each position is `Some(Value)` for a bare
/// literal (incl. a signed number — the leading sign is folded into the
/// number) and `None` for an expression position (a `func(x)`, `a+1`,
/// subquery, column ref — nothing static to validate). Works purely from
/// the tokens the walker already matched (no reparse); rows and positions
/// are delimited by tuple parens and depth-1 commas. `values_start` is the
/// byte offset of the `values` keyword; only items in `[values_start,
/// tail_end)` are considered (so any trailing `ON CONFLICT`/`RETURNING`
/// clause is excluded).
fn capture_literal_rows(
path: &MatchedPath,
values_start: usize,
tail_end: usize,
) -> Vec<Vec<Option<Value>>> {
let mut rows: Vec<Vec<Option<Value>>> = Vec::new();
let mut depth: i32 = 0;
let mut cur_row: Vec<Option<Value>> = Vec::new();
let mut pos: Vec<&MatchedItem> = Vec::new();
for item in &path.items {
if item.span.0 < values_start || item.span.0 >= tail_end {
continue;
}
match &item.kind {
MatchedKind::Word("values") => {}
MatchedKind::Punct('(') => {
depth += 1;
if depth == 1 {
cur_row = Vec::new();
pos.clear();
} else {
pos.push(item);
}
}
MatchedKind::Punct(')') => {
if depth == 1 {
cur_row.push(classify_value_position(&pos));
pos.clear();
rows.push(std::mem::take(&mut cur_row));
} else if depth > 1 {
pos.push(item);
}
depth -= 1;
}
MatchedKind::Punct(',') if depth == 1 => {
cur_row.push(classify_value_position(&pos));
pos.clear();
}
_ if depth >= 1 => pos.push(item),
_ => {}
}
}
rows
}
/// Classify one `VALUES` position's matched tokens into `Some(Value)` (a
/// bare literal) or `None` (an expression). A single literal token, or a
/// sign followed by a number, is a literal; anything else is an
/// expression (ADR-0036 §1).
fn classify_value_position(tokens: &[&MatchedItem]) -> Option<Value> {
match tokens {
[one] => item_to_value(one),
[sign, num]
if matches!(sign.kind, MatchedKind::Punct('-') | MatchedKind::Punct('+'))
&& matches!(num.kind, MatchedKind::NumberLit) =>
{
let text = if matches!(sign.kind, MatchedKind::Punct('-')) {
format!("-{}", num.text)
} else {
num.text.clone()
};
Some(Value::Number(text))
}
_ => None,
}
}
/// Whether the matched path contains a `RETURNING` clause
/// (ADR-0033 §5, sub-phase 3g). Located by the `returning` *Word
/// token* in the path — path-based, so a string literal can't be