grammar+db: 3e — SQL UPDATE grammar + execution (ADR-0033 §2)
New src/dsl/grammar/sql_update.rs: SQL_UPDATE_SHAPE =
<table> SET col = sql_expr (',' …)* [WHERE sql_expr] [';'], the
__rdbms_* target rejection, and the shared sql_expr on both the
assignment RHS and the predicate. No --all-rows rail — a SQL
UPDATE without WHERE runs as written (ADR-0030 §12). Reuses
sql_select::WHERE_CLAUSE (now pub(crate)) so the predicate
diagnostics are identical. The target uses the shared `table_name`
ident role (not a bespoke one) so the Phase-2 schema-existence and
predicate-warning passes collect it as a scope binding and check
the SET / WHERE columns for free — a bespoke role left them
unchecked (the cross-cut tests caught this).
Command::SqlUpdate { sql, target_table }; Request::RunSqlUpdate +
do_sql_update (execute validated SQL via execute_with_fk_enrichment,
re-persist the target CSV, append history.log). 3e surfaces the
affected-row count only; precise row output is RETURNING (3g), so
the update-success render skips a column-less data set rather than
showing a misleading "(no rows)" band. Behind the dev `sql_update`
entry word until 3j.
Tests: grammar accept/reject; integration (single/multi-col,
no-WHERE all-rows, sql_expr in SET, scalar subquery in SET,
zero-match success, history); walker cross-cut (unknown SET column
→ unknown_column, `= NULL` in WHERE → eq_null warning); app-level
render-guard both ways (column-less → count only; with columns →
table renders). 1524 green, clippy clean.
This commit is contained in:
+46
-1
@@ -20,7 +20,7 @@ use crate::dsl::command::{Command, Expr, RowFilter};
|
||||
use crate::dsl::grammar::{
|
||||
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
|
||||
shared::{column_value_list, current_column_value},
|
||||
sql_insert, sql_select,
|
||||
sql_insert, sql_select, sql_update,
|
||||
};
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
use crate::dsl::value::Value;
|
||||
@@ -923,6 +923,35 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
||||
})
|
||||
}
|
||||
|
||||
/// Build `Command::SqlUpdate` from a validated SQL `UPDATE`
|
||||
/// (ADR-0033 §2, sub-phase 3e). Extracts the target table from the
|
||||
/// matched path so the worker re-persists the right CSV.
|
||||
///
|
||||
/// Dev-scaffold detail: the entry word is `sql_update` (not valid
|
||||
/// SQL), so the statement is reconstructed as `update` + the
|
||||
/// matched tail. Sub-phase 3j wires the real `update` entry word,
|
||||
/// at which point this collapses to `source.trim()`.
|
||||
fn build_sql_update(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||||
// The UPDATE target is the first `table_name` ident (it
|
||||
// precedes any table referenced inside a SET / WHERE subquery).
|
||||
let target_table = path
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item.kind {
|
||||
MatchedKind::Ident {
|
||||
role: "table_name", ..
|
||||
} => Some(item.text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tail = path
|
||||
.items
|
||||
.first()
|
||||
.map_or(source, |entry| &source[entry.span.1..]);
|
||||
let sql = format!("update {}", tail.trim());
|
||||
Ok(Command::SqlUpdate { sql, target_table })
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// CommandNodes
|
||||
// =================================================================
|
||||
@@ -1016,6 +1045,22 @@ pub static SQL_INSERT: CommandNode = CommandNode {
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
/// SQL `UPDATE` development scaffold (ADR-0033 sub-phase 3e).
|
||||
///
|
||||
/// Registered under the temporary entry word `sql_update` so the
|
||||
/// SQL UPDATE grammar and execution path can be exercised in
|
||||
/// isolation, WITHOUT yet making `update` a shared DSL/SQL entry
|
||||
/// word. Sharing `update` is sub-phase 3j. This scaffold (entry
|
||||
/// word + reconstruction in `build_sql_update`) is removed when 3j
|
||||
/// wires the real `update` entry word.
|
||||
pub static SQL_UPDATE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("sql_update"),
|
||||
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
||||
ast_builder: build_sql_update,
|
||||
help_id: None,
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// Tests — `explain` grammar (ADR-0028 §1)
|
||||
// =================================================================
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod shared;
|
||||
pub mod sql_expr;
|
||||
pub mod sql_insert;
|
||||
pub mod sql_select;
|
||||
pub mod sql_update;
|
||||
|
||||
use crate::dsl::command::Command;
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
@@ -575,6 +576,10 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
// temporary `sqlinsert` entry word keeps it isolated from the
|
||||
// DSL `insert` word until 3j wires the shared entry.
|
||||
(&data::SQL_INSERT, CommandCategory::Advanced),
|
||||
// SQL UPDATE development scaffold (sub-phase 3e); the temporary
|
||||
// `sql_update` entry word keeps it isolated from the DSL
|
||||
// `update` word until 3j wires the shared entry.
|
||||
(&data::SQL_UPDATE, CommandCategory::Advanced),
|
||||
];
|
||||
|
||||
/// Whether `entry` names an advanced-mode-only command (ADR-0030
|
||||
|
||||
@@ -461,7 +461,10 @@ static WHERE_CLAUSE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("where")),
|
||||
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
|
||||
];
|
||||
static WHERE_CLAUSE: Node = Node::Seq(WHERE_CLAUSE_NODES);
|
||||
/// `WHERE sql_expr`. `pub(crate)` so the SQL DML statements
|
||||
/// (ADR-0033 — UPDATE / DELETE) reuse the exact same predicate
|
||||
/// clause, keeping the Phase-2 predicate diagnostics identical.
|
||||
pub(crate) static WHERE_CLAUSE: Node = Node::Seq(WHERE_CLAUSE_NODES);
|
||||
|
||||
static GROUP_BY_CLAUSE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("group")),
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! SQL `UPDATE` grammar (ADR-0033 §2, sub-phase 3e).
|
||||
//!
|
||||
//! Grammar-as-text (ADR-0030 §4): the walker validates that the
|
||||
//! `UPDATE` is in the supported subset; the worker executes the
|
||||
//! validated SQL text and re-persists the target table's CSV
|
||||
//! (ADR-0030 §11). The shape here is the post-`UPDATE` portion —
|
||||
//! the entry-word dispatch consumes the leading `UPDATE` keyword
|
||||
//! before this shape walks (mirroring `sql_insert::SQL_INSERT_SHAPE`,
|
||||
//! where the dev `sqlinsert` word stands in for `INSERT`).
|
||||
//!
|
||||
//! Scope (3e): `<table> SET assignment_list [ WHERE … ]`, the
|
||||
//! `__rdbms_*` target rejection, and the shared `sql_expr` on both
|
||||
//! the assignment RHS and the WHERE predicate. There is no
|
||||
//! `--all-rows` rail — a SQL `UPDATE` without `WHERE` runs as
|
||||
//! written (ADR-0030 §12). `RETURNING` (3g) lands later.
|
||||
|
||||
use crate::dsl::grammar::sql_expr;
|
||||
use crate::dsl::grammar::sql_select::{WHERE_CLAUSE, reject_internal_table};
|
||||
use crate::dsl::grammar::{IdentSource, Node, Word};
|
||||
|
||||
static COMMA: Node = Node::Punct(',');
|
||||
|
||||
/// The `UPDATE` target table. `__rdbms_*` rejected (ADR-0030 §6 /
|
||||
/// ADR-0033 §1). `writes_table` populates `current_table` /
|
||||
/// `current_table_columns` so the `SET` columns and the WHERE
|
||||
/// predicate get column completion against the target.
|
||||
///
|
||||
/// Uses the shared `table_name` role (not a bespoke one) so the
|
||||
/// Phase-2 schema-existence + predicate-warning passes collect it
|
||||
/// as a scope binding and check the SET / WHERE columns against it
|
||||
/// for free (ADR-0033 §2's "cross-cut from Phase-2 machinery").
|
||||
const TARGET_TABLE: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
role: "table_name",
|
||||
validator: Some(reject_internal_table),
|
||||
highlight_override: None,
|
||||
writes_table: true,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
/// The column on the left of one `SET col = expr` assignment.
|
||||
const ASSIGN_COLUMN: Node = Node::Ident {
|
||||
source: IdentSource::Columns,
|
||||
role: "update_set_column",
|
||||
validator: None,
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
/// `column_name '=' sql_expr` — the RHS reuses the shared
|
||||
/// expression grammar (ADR-0031), so literals, operators, `CASE`,
|
||||
/// function calls, and scalar subqueries are all admitted; the
|
||||
/// engine evaluates them at execution time.
|
||||
static ASSIGNMENT_NODES: &[Node] = &[
|
||||
ASSIGN_COLUMN,
|
||||
Node::Punct('='),
|
||||
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
|
||||
];
|
||||
static ASSIGNMENT: Node = Node::Seq(ASSIGNMENT_NODES);
|
||||
|
||||
/// `assignment ( ',' assignment )*`.
|
||||
const ASSIGNMENT_LIST: Node = Node::Repeated {
|
||||
inner: &ASSIGNMENT,
|
||||
separator: Some(&COMMA),
|
||||
min: 1,
|
||||
};
|
||||
|
||||
static SQL_UPDATE_TAIL_NODES: &[Node] = &[
|
||||
TARGET_TABLE,
|
||||
Node::Word(Word::keyword("set")),
|
||||
ASSIGNMENT_LIST,
|
||||
Node::Optional(&WHERE_CLAUSE),
|
||||
Node::Optional(&Node::Punct(';')),
|
||||
];
|
||||
|
||||
/// The post-`UPDATE` portion of a SQL `UPDATE` statement
|
||||
/// (ADR-0033 §2): `<table> SET col = expr (',' col = expr)*
|
||||
/// [ WHERE … ] [ ';' ]`.
|
||||
///
|
||||
/// The entry-word dispatch consumes the leading `UPDATE` keyword
|
||||
/// before this shape walks, so a `CommandNode` references it as
|
||||
/// its `shape` (sub-phase 3e registers a development entry word;
|
||||
/// sub-phase 3j wires the shared `update` entry word).
|
||||
pub static SQL_UPDATE_SHAPE: Node = Node::Seq(SQL_UPDATE_TAIL_NODES);
|
||||
|
||||
// =================================================================
|
||||
// Tests — grammar accept/reject for the post-`UPDATE` tail.
|
||||
// =================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SQL_UPDATE_SHAPE;
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
use crate::dsl::walker::driver::{NodeWalkResult, walk_node};
|
||||
use crate::dsl::walker::outcome::MatchedPath;
|
||||
|
||||
/// Walk `input` against the UPDATE tail. Returns `true` only
|
||||
/// when the walk matches *and* consumes all of `input`
|
||||
/// (trailing whitespace allowed). Schemaless: the shape is
|
||||
/// structural, so table/column idents match by shape and
|
||||
/// `reject_internal_table` still fires on `__rdbms_*`.
|
||||
fn walks(input: &str) -> bool {
|
||||
let mut ctx = WalkContext::new();
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
match walk_node(input, 0, &SQL_UPDATE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
|
||||
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn good(input: &str) {
|
||||
assert!(walks(input), "{input:?} should be a valid UPDATE tail");
|
||||
}
|
||||
|
||||
fn bad(input: &str) {
|
||||
assert!(!walks(input), "{input:?} should NOT walk as a complete UPDATE tail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_assignment_with_where() {
|
||||
good("t set v = 'x' where id = 1");
|
||||
good("t set v = 1 where id = 1;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_assignment() {
|
||||
good("t set a = 1, b = 2 where id = 1");
|
||||
good("orders set total = 0, note = 'void' where id = 7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_where_runs_across_all_rows() {
|
||||
// ADR-0030 §12: no `--all-rows` rail — a SQL UPDATE without
|
||||
// WHERE is structurally valid.
|
||||
good("t set active = false");
|
||||
good("t set a = 1, b = 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_rhs_admits_sql_expr() {
|
||||
good("t set total = price * quantity where id = 1");
|
||||
good("t set v = case when x > 0 then x else 0 end");
|
||||
good("t set v = (select max(other) from other_table) where id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_target_table_rejected() {
|
||||
bad("__rdbms_playground_columns set a = 1");
|
||||
bad("__rdbms_playground_relationships set a = 1 where id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structurally_incomplete_or_wrong_rejected() {
|
||||
// Missing SET.
|
||||
bad("t where id = 1");
|
||||
bad("t");
|
||||
// SET with no assignment.
|
||||
bad("t set");
|
||||
bad("t set where id = 1");
|
||||
// Assignment missing RHS.
|
||||
bad("t set v =");
|
||||
// Trailing comma with no following assignment.
|
||||
bad("t set a = 1,");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user