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:
+123
-11
@@ -4,17 +4,25 @@
|
||||
//! show table), `insert`, `update`, `delete`. The walker route
|
||||
//! owns these end-to-end.
|
||||
//!
|
||||
//! Phase D scope deviation note: ADR-0024's Phase D describes
|
||||
//! "full schema awareness" via `DynamicSubgrammar
|
||||
//! (column_value_list)` that unfolds typed slots per column. This
|
||||
//! milestone lands the data commands at functional parity with
|
||||
//! the existing chumsky parser — value slots accept any
|
||||
//! literal regardless of column type, with type validation
|
||||
//! happening at bind time (matching today's behaviour). The
|
||||
//! `DynamicSubgrammar` machinery and schema-cache plumbing are
|
||||
//! deferred to a follow-up refinement; the trie shape is
|
||||
//! ready to consume them when the schema reference flows
|
||||
//! through `parse_command`.
|
||||
//! Schema awareness (ADR-0024 §Phase D): the DSL value slots are
|
||||
//! wired to `DynamicSubgrammar(column_value_list)` /
|
||||
//! `current_column_value` (see `INSERT_VALUES_LIST`,
|
||||
//! `insert_first_paren`, `PER_COLUMN_VALUE`), so the schema reference
|
||||
//! that flows through `parse_command` unfolds a typed slot per column:
|
||||
//! numeric-shape mismatch is caught at parse (`int`/`decimal`/`bool`
|
||||
//! slots in `shared.rs`) and the full semantic type (`date` / `shortid`
|
||||
//! format) is validated at bind time. So the simple-mode DSL gives data
|
||||
//! values per-column feedback end-to-end.
|
||||
//!
|
||||
//! The advanced-mode SQL DML surface (`build_sql_insert` /
|
||||
//! `build_sql_update` below) is a separate path: it executes the
|
||||
//! validated statement verbatim (ADR-0030 §4) and is NOT yet wired to
|
||||
//! the typed slots. ADR-0036 closes the resulting value-feedback gap
|
||||
//! without a grammar change by *capturing* each literal value position
|
||||
//! at parse (`capture_literal_rows` / `capture_set_literals`) and
|
||||
//! validating it against the column type in the worker — Phase 3 will
|
||||
//! later swap that capture for the same typed slots used here, adding
|
||||
//! live hints/highlighting.
|
||||
|
||||
use crate::dsl::command::{Command, Expr, RowFilter};
|
||||
use crate::dsl::grammar::{
|
||||
@@ -1080,13 +1088,117 @@ fn build_sql_update(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let sql = source.trim().to_string();
|
||||
// Capture the literal RHS of each top-level `SET col = <literal>`
|
||||
// assignment for app-level type validation + error enrichment
|
||||
// (ADR-0036 Phase 2). Purely from the matched tokens — no reparse.
|
||||
let set_literals = capture_set_literals(path);
|
||||
Ok(Command::SqlUpdate {
|
||||
sql,
|
||||
target_table,
|
||||
returning: path_has_returning(path),
|
||||
set_literals,
|
||||
})
|
||||
}
|
||||
|
||||
/// Capture the literal RHS of each top-level `SET col = <literal>`
|
||||
/// assignment from the matched path (ADR-0036 Phase 2). Returns
|
||||
/// `(col, Some(Value))` for a bare-literal RHS (incl. a signed number)
|
||||
/// and `(col, None)` for an expression RHS (arithmetic, function call,
|
||||
/// scalar subquery, column ref — nothing static to validate). Works
|
||||
/// purely from the tokens the walker already matched (no reparse).
|
||||
///
|
||||
/// Boundaries: the assignment LHS is the `update_set_column` ident (a
|
||||
/// role only ever emitted at the top level of an assignment — expression
|
||||
/// column refs carry `sql_expr_ident` / `sql_expr_qualified_ref`, so they
|
||||
/// are never confused with it). A *depth-0* comma separates assignments;
|
||||
/// a *depth-0* `where` / `returning` keyword (or `;` / end of path) ends
|
||||
/// the SET list. Parens raise the depth so a comma, `where`, or `=`
|
||||
/// inside a function call or scalar subquery on the RHS is never mistaken
|
||||
/// for an assignment / clause boundary or the assignment operator.
|
||||
fn capture_set_literals(path: &MatchedPath) -> Vec<(String, Option<Value>)> {
|
||||
let mut out: Vec<(String, Option<Value>)> = Vec::new();
|
||||
let mut after_set = false;
|
||||
let mut depth: i32 = 0;
|
||||
// The assignment currently being accumulated: its column name, its
|
||||
// RHS tokens so far, and whether the assignment `=` has been consumed.
|
||||
let mut cur_col: Option<String> = None;
|
||||
let mut cur_rhs: Vec<&MatchedItem> = Vec::new();
|
||||
let mut seen_eq = false;
|
||||
|
||||
// Finalise the pending assignment (if any) into `out`.
|
||||
fn flush(
|
||||
col: &mut Option<String>,
|
||||
rhs: &mut Vec<&MatchedItem>,
|
||||
out: &mut Vec<(String, Option<Value>)>,
|
||||
) {
|
||||
if let Some(c) = col.take() {
|
||||
out.push((c, classify_value_position(rhs)));
|
||||
}
|
||||
rhs.clear();
|
||||
}
|
||||
|
||||
for item in &path.items {
|
||||
if !after_set {
|
||||
// Scan only the SET list — skip everything up to (and
|
||||
// including) the `set` keyword. The first `update_set_column`
|
||||
// appears after it.
|
||||
if matches!(item.kind, MatchedKind::Word("set")) {
|
||||
after_set = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A depth-0 `where` / `returning` / `;` ends the SET list.
|
||||
if depth == 0
|
||||
&& matches!(
|
||||
item.kind,
|
||||
MatchedKind::Word("where" | "returning") | MatchedKind::Punct(';')
|
||||
)
|
||||
{
|
||||
break;
|
||||
}
|
||||
match &item.kind {
|
||||
MatchedKind::Punct('(') => {
|
||||
depth += 1;
|
||||
if cur_col.is_some() && seen_eq {
|
||||
cur_rhs.push(item);
|
||||
}
|
||||
}
|
||||
MatchedKind::Punct(')') => {
|
||||
depth -= 1;
|
||||
if cur_col.is_some() && seen_eq {
|
||||
cur_rhs.push(item);
|
||||
}
|
||||
}
|
||||
MatchedKind::Ident {
|
||||
role: "update_set_column",
|
||||
..
|
||||
} if depth == 0 => {
|
||||
// A new assignment begins — finalise the previous one.
|
||||
flush(&mut cur_col, &mut cur_rhs, &mut out);
|
||||
cur_col = Some(item.text.clone());
|
||||
seen_eq = false;
|
||||
}
|
||||
MatchedKind::Punct(',') if depth == 0 => {
|
||||
// Assignment separator — finalise the current assignment;
|
||||
// the next `update_set_column` starts the following one.
|
||||
flush(&mut cur_col, &mut cur_rhs, &mut out);
|
||||
}
|
||||
MatchedKind::Punct('=') if depth == 0 && !seen_eq && cur_col.is_some() => {
|
||||
// The assignment operator — consumed, not part of the RHS.
|
||||
seen_eq = true;
|
||||
}
|
||||
_ => {
|
||||
if cur_col.is_some() && seen_eq {
|
||||
cur_rhs.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Finalise the last assignment (ended by `where`/`returning`/`;`/EOF).
|
||||
flush(&mut cur_col, &mut cur_rhs, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Build `Command::SqlDelete` from a validated SQL `DELETE`
|
||||
/// (ADR-0033 §1/§7). Extracts the target table from the matched
|
||||
/// path so the worker re-persists the right CSV and snapshots the
|
||||
|
||||
Reference in New Issue
Block a user