diff --git a/src/app.rs b/src/app.rs index 2bd4abb..af91bf0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1251,8 +1251,14 @@ impl App { fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) { self.note_ok_summary(command); self.note_system(crate::t!("ok.rows_updated", count = result.rows_affected)); - for line in crate::output_render::render_data_table(&result.data) { - self.note_system(line); + // A column-less result carries no rows to tabulate (the SQL + // UPDATE path before `RETURNING`, ADR-0033 sub-phase 3e): + // surface just the count rather than a misleading + // "(no rows)" band. The DSL UPDATE always has columns. + if !result.data.columns.is_empty() { + for line in crate::output_render::render_data_table(&result.data) { + self.note_system(line); + } } } @@ -1474,6 +1480,11 @@ impl App { C::SqlInsert { target_table, .. } => { (Operation::Insert, Some(target_table.as_str()), None) } + // A SQL `UPDATE` (ADR-0033 §2) — route engine errors + // through the update operation with the parsed target. + C::SqlUpdate { target_table, .. } => { + (Operation::Update, Some(target_table.as_str()), None) + } C::Replay { .. } => (Operation::Replay, None, None), // An `explain` failure (e.g. unknown table) is best // described by the wrapped query it failed to plan. @@ -3336,4 +3347,64 @@ mod tests { Some(crate::dsl::walker::Severity::Warning), ); } + + #[test] + fn sql_update_success_shows_count_without_no_rows_band() { + // ADR-0033 sub-phase 3e: a SQL UPDATE returns a column-less + // result (precise rows are RETURNING, 3g). The render must + // surface the affected-row count and NOT a misleading + // "(no rows)" table band. + let mut app = App::new(); + app.update(AppEvent::DslUpdateSucceeded { + command: Command::SqlUpdate { + sql: "update t set v = 1".to_string(), + target_table: "t".to_string(), + }, + result: crate::db::UpdateResult { + rows_affected: 2, + data: crate::db::DataResult { + table_name: "t".to_string(), + columns: Vec::new(), + column_types: Vec::new(), + rows: Vec::new(), + }, + }, + }); + let texts: Vec = app.output.iter().map(|l| l.text.clone()).collect(); + assert!( + texts.iter().any(|t| t.contains("2 row(s) updated")), + "affected-row count surfaced: {texts:?}", + ); + assert!( + !texts.iter().any(|t| t.contains("(no rows)")), + "no misleading empty-table band: {texts:?}", + ); + } + + #[test] + fn update_success_with_columns_renders_the_table() { + // The guard only suppresses a column-less result: a result + // carrying columns (the DSL UPDATE path) still renders. + let mut app = App::new(); + app.update(AppEvent::DslUpdateSucceeded { + command: Command::SqlUpdate { + sql: "update t set v = 1".to_string(), + target_table: "t".to_string(), + }, + result: crate::db::UpdateResult { + rows_affected: 1, + data: crate::db::DataResult { + table_name: "t".to_string(), + columns: vec!["id".to_string(), "v".to_string()], + column_types: vec![Some(Type::Int), Some(Type::Int)], + rows: vec![vec![Some("1".to_string()), Some("9".to_string())]], + }, + }, + }); + let texts: Vec = app.output.iter().map(|l| l.text.clone()).collect(); + assert!( + texts.iter().any(|t| t.contains("id") && t.contains('v')), + "header row rendered: {texts:?}", + ); + } } diff --git a/src/db.rs b/src/db.rs index ec4b416..9dc233f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -595,6 +595,16 @@ enum Request { row_source: String, reply: oneshot::Sender>, }, + /// Run a grammar-validated SQL `UPDATE` (ADR-0033 §2). The + /// worker executes `sql` as text, re-persists `target_table`'s + /// CSV (ADR-0030 §11), and appends the literal line to + /// `history.log`. + RunSqlUpdate { + sql: String, + source: Option, + target_table: String, + reply: oneshot::Sender>, + }, /// Capture the query plan for an explainable command via /// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner /// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN` @@ -1078,6 +1088,28 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + /// Run a validated SQL `UPDATE` and return the affected-row + /// count (ADR-0033 §2, sub-phase 3e). `sql` is the + /// grammar-validated statement text; `source` is the literal + /// submitted line for `history.log`; `target_table` is the + /// parsed target whose CSV is re-persisted. + pub async fn run_sql_update( + &self, + sql: String, + source: Option, + target_table: String, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::RunSqlUpdate { + sql, + source, + target_table, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + /// Capture the query plan for an explainable command /// (ADR-0028 §2). The wrapped command is not executed — /// `EXPLAIN QUERY PLAN` only inspects how the engine would @@ -1541,6 +1573,20 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req &row_source, )); } + Request::RunSqlUpdate { + sql, + source, + target_table, + reply, + } => { + let _ = reply.send(do_sql_update( + conn, + persistence, + source.as_deref(), + &sql, + &target_table, + )); + } Request::RebuildFromText { project_path, source, @@ -5975,6 +6021,54 @@ fn do_sql_insert( }) } +/// Worker handler for `Request::RunSqlUpdate` (ADR-0033 §2, +/// sub-phase 3e). Mirrors `do_sql_insert`'s persistence +/// discipline: run the validated SQL inside a transaction, +/// re-persist the target table's CSV + append `history.log` via +/// `finalize_persistence` *before* `tx.commit()`, then commit. +/// +/// Grammar-as-text (ADR-0030 §4): the assignment and predicate +/// values are literals in `sql`, so no parameters are bound. A SQL +/// `UPDATE` without `WHERE` runs across all rows as written +/// (ADR-0030 §12 — no `--all-rows` rail). An update matching zero +/// rows is a success (`rows_affected == 0`); the persistence +/// write-through still runs (re-persisting the unchanged CSV is a +/// no-op-equivalent and keeps the path uniform). +/// +/// Auto-show: 3e returns an empty [`DataResult`] — the affected +/// rows can't be shown precisely without `RETURNING` (sub-phase +/// 3g, which is the precise tool). The summary surfaces the +/// affected-row count; the renderer skips the (column-less) table. +fn do_sql_update( + conn: &Connection, + persistence: Option<&Persistence>, + source: Option<&str>, + sql: &str, + target_table: &str, +) -> Result { + debug!(sql = %sql, table = %target_table, "sql_update"); + let tx = conn + .unchecked_transaction() + .map_err(DbError::from_rusqlite)?; + let rows_affected = execute_with_fk_enrichment(conn, target_table, sql, &[])?; + let changes = Changes { + schema_dirty: false, + rewritten_tables: vec![target_table.to_string()], + ..Changes::default() + }; + finalize_persistence(conn, persistence, source, &changes)?; + tx.commit().map_err(DbError::from_rusqlite)?; + Ok(UpdateResult { + rows_affected, + data: DataResult { + table_name: target_table.to_string(), + columns: Vec::new(), + column_types: Vec::new(), + rows: Vec::new(), + }, + }) +} + /// Execute a grammar-validated SQL `SELECT` and collect its /// rows into a [`DataResult`] (ADR-0030 §6, ADR-0032 §12 + /// Amendment 1). diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 101cf88..6cecd0d 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -312,6 +312,14 @@ pub enum Command { listed_columns: Vec, row_source: String, }, + /// A SQL `UPDATE` validated by the walker (ADR-0033 §2, + /// advanced mode). Grammar-as-text: the worker executes `sql` + /// and re-persists `target_table`'s CSV (ADR-0030 §11). + /// `RETURNING` (3g) is added by the sub-phase that reads it. + SqlUpdate { + sql: String, + target_table: String, + }, /// App-lifecycle command (per ADR-0003). These work in both /// simple and advanced modes; the dispatcher branches on the /// `Command::App(...)` variant before mode-specific routing. @@ -608,6 +616,7 @@ impl Command { Self::Explain { .. } => "explain", Self::Select { .. } => "select", Self::SqlInsert { .. } => "insert into", + Self::SqlUpdate { .. } => "update", Self::App(app) => match app { AppCommand::Quit => "quit", AppCommand::Help => "help", @@ -677,7 +686,8 @@ impl Command { Self::Select { .. } => "", // A SQL `INSERT` carries its parsed target table (for // CSV re-persistence and ok-summary subject). - Self::SqlInsert { target_table, .. } => target_table, + Self::SqlInsert { target_table, .. } + | Self::SqlUpdate { target_table, .. } => target_table, // App commands aren't tied to schema entities — the // verb is the most identifying thing. The // display_subject override below provides a richer diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 9194e41..cc4cd57 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -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 Result { + // 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) // ================================================================= diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 9a90a74..652dce8 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -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 diff --git a/src/dsl/grammar/sql_select.rs b/src/dsl/grammar/sql_select.rs index f363b12..23d1a0f 100644 --- a/src/dsl/grammar/sql_select.rs +++ b/src/dsl/grammar/sql_select.rs @@ -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")), diff --git a/src/dsl/grammar/sql_update.rs b/src/dsl/grammar/sql_update.rs new file mode 100644 index 0000000..a1730da --- /dev/null +++ b/src/dsl/grammar/sql_update.rs @@ -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): ` 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): `
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,"); + } +} diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index b200f17..c69da70 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -3933,6 +3933,30 @@ mod tests { ); } + #[test] + fn sql_update_unknown_set_column_is_error() { + // ADR-0033 sub-phase 3e cross-cut: the schema-existence + // pass fires on the SET assignment column. + let schema = schema_with("t", &[("id", Type::Int), ("v", Type::Text)]); + let diags = diag_keys("sql_update t set nonexistent = 1 where id = 1", &schema); + assert!( + diags.iter().any(|d| d.contains("no such column")), + "expected unknown_column on the SET column; got {diags:?}", + ); + } + + #[test] + fn sql_update_eq_null_in_where_warns() { + // ADR-0033 sub-phase 3e cross-cut: the predicate-warning + // pass fires on `= NULL` in an UPDATE's WHERE. + let schema = schema_with("t", &[("id", Type::Int), ("v", Type::Int)]); + let diags = diag_keys("sql_update t set v = 1 where v = NULL", &schema); + assert!( + diags.iter().any(|d| d.contains("IS NULL")), + "expected eq_null warning on the WHERE; got {diags:?}", + ); + } + #[test] fn cte_name_is_valid_table_source() { let schema = schema_with("base", &[("id", Type::Int)]); diff --git a/src/runtime.rs b/src/runtime.rs index beacff5..313e6bd 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1894,6 +1894,14 @@ async fn execute_command_typed( .run_sql_insert(sql, src, target_table, listed_columns, row_source) .await .map(CommandOutcome::Insert), + // A SQL `UPDATE` (advanced mode; ADR-0033 §2). Grammar-as- + // text: the worker runs the validated `sql` and re-persists + // the parsed `target_table`'s CSV. Reuses the DSL update + // outcome (affected-row count). + Command::SqlUpdate { sql, target_table } => database + .run_sql_update(sql, src, target_table) + .await + .map(CommandOutcome::Update), // `EXPLAIN QUERY PLAN` never executes the wrapped // statement (ADR-0028 §2), so explaining a destructive // command is safe. `src` is unused here — explain is a diff --git a/tests/sql_update.rs b/tests/sql_update.rs new file mode 100644 index 0000000..04b4db7 --- /dev/null +++ b/tests/sql_update.rs @@ -0,0 +1,205 @@ +//! Sub-phase 3e integration tests for the advanced-mode SQL +//! `UPDATE` surface (ADR-0033 §2). +//! +//! Covers the parse path (the dev `sql_update` scaffold lowers to +//! `Command::SqlUpdate`, reconstructing valid `update …` SQL) and +//! the worker round-trip (execute, re-persist the target CSV, +//! append `history.log`). A SQL `UPDATE` without `WHERE` runs +//! 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::persistence::Persistence; +use rdbms_playground::project; + +fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio rt") +} + +fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("create tempdir"); + let project = + project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let persistence = Persistence::new(project.path().to_path_buf()); + let db = Database::open_with_persistence(project.db_path(), persistence) + .expect("open db with persistence"); + (project, db, dir) +} + +fn read_csv(project: &project::Project, table: &str) -> Option { + std::fs::read_to_string(project.path().join("data").join(format!("{table}.csv"))).ok() +} + +fn create_cols( + db: &Database, + rt: &tokio::runtime::Runtime, + name: &str, + cols: &[(&str, Type)], + pk: &[&str], +) { + rt.block_on(db.create_table( + name.to_string(), + cols.iter().map(|(n, t)| ColumnSpec::new(*n, *t)).collect(), + pk.iter().map(|s| (*s).to_string()).collect(), + None, + )) + .unwrap_or_else(|e| panic!("create table {name}: {e:?}")); +} + +/// Seed via the SQL INSERT worker path (no shortid columns here, so +/// it executes verbatim). +fn seed(db: &Database, rt: &tokio::runtime::Runtime, sql: &str, target: &str) { + rt.block_on(db.run_sql_insert( + sql.to_string(), + None, + target.to_string(), + Vec::new(), + String::new(), + )) + .unwrap_or_else(|e| panic!("seed {sql:?}: {e:?}")); +} + +/// Full-stack: parse the dev `sql_update …` scaffold and run it. +fn run_update( + db: &Database, + rt: &tokio::runtime::Runtime, + input: &str, +) -> Result { + match parse_command(input).expect("parse sql_update") { + Command::SqlUpdate { sql, target_table } => { + rt.block_on(db.run_sql_update(sql, Some(input.to_string()), target_table)) + } + other => panic!("expected Command::SqlUpdate, got {other:?}"), + } +} + +#[test] +fn parse_path_lowers_sql_update_to_command() { + let command = parse_command("sql_update Orders set total = 0 where id = 1") + .expect("sql_update parses in advanced mode"); + match command { + Command::SqlUpdate { sql, target_table } => { + assert_eq!(sql, "update Orders set total = 0 where id = 1"); + assert_eq!(target_table, "Orders"); + } + other => panic!("expected Command::SqlUpdate, got {other:?}"), + } +} + +#[test] +fn single_column_update_with_where_persists() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); + seed(&db, &rt, "insert into t (id, v) values (1, 'old'), (2, 'keep')", "t"); + let result = run_update(&db, &rt, "sql_update t set v = 'new' where id = 1") + .expect("update runs"); + assert_eq!(result.rows_affected, 1, "one row updated"); + let csv = read_csv(&project, "t").expect("t.csv"); + assert!(csv.contains("new"), "updated value present: {csv:?}"); + assert!(csv.contains("keep"), "untouched row preserved: {csv:?}"); + assert!(!csv.contains("old"), "old value replaced: {csv:?}"); +} + +#[test] +fn multi_column_update_persists() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("a", Type::Int), ("b", Type::Text)], + &["id"], + ); + seed(&db, &rt, "insert into t (id, a, b) values (1, 0, 'x')", "t"); + let result = run_update(&db, &rt, "sql_update t set a = 9, b = 'y' where id = 1") + .expect("multi-col update runs"); + assert_eq!(result.rows_affected, 1); + let csv = read_csv(&project, "t").expect("t.csv"); + assert!(csv.contains('9') && csv.contains('y'), "both columns updated: {csv:?}"); +} + +#[test] +fn update_without_where_runs_across_all_rows() { + // ADR-0030 §12: no `--all-rows` rail. + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]); + seed(&db, &rt, "insert into t (id, active) values (1, true), (2, true)", "t"); + let result = run_update(&db, &rt, "sql_update t set active = false") + .expect("unfiltered update runs"); + assert_eq!(result.rows_affected, 2, "all rows updated"); + let csv = read_csv(&project, "t").expect("t.csv"); + assert!(!csv.contains("true"), "no row left active: {csv:?}"); +} + +#[test] +fn update_with_sql_expr_in_set() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("price", Type::Int), ("qty", Type::Int), ("total", Type::Int)], + &["id"], + ); + seed(&db, &rt, "insert into t (id, price, qty, total) values (1, 6, 7, 0)", "t"); + let result = run_update(&db, &rt, "sql_update t set total = price * qty where id = 1") + .expect("expression update runs"); + assert_eq!(result.rows_affected, 1); + let csv = read_csv(&project, "t").expect("t.csv"); + assert!(csv.contains("42"), "engine evaluated price*qty: {csv:?}"); +} + +#[test] +fn update_with_subquery_in_set() { + // DA gate: the SET RHS admits a scalar subquery. + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "other", &[("n", Type::Int)], &["n"]); + create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Int)], &["id"]); + seed(&db, &rt, "insert into other (n) values (3), (8), (5)", "other"); + seed(&db, &rt, "insert into t (id, v) values (1, 0)", "t"); + let result = run_update( + &db, + &rt, + "sql_update t set v = (select max(n) from other) where id = 1", + ) + .expect("subquery-set update runs"); + assert_eq!(result.rows_affected, 1); + let csv = read_csv(&project, "t").expect("t.csv"); + assert!(csv.contains('8'), "subquery max landed: {csv:?}"); +} + +#[test] +fn update_matching_no_rows_is_ok() { + // DA gate: an UPDATE matching nothing succeeds (0 affected), + // the path doesn't crash, and the CSV is unchanged. + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); + seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t"); + let result = run_update(&db, &rt, "sql_update t set v = 'x' where id = 999") + .expect("no-match update is a success"); + assert_eq!(result.rows_affected, 0, "no rows matched"); + let csv = read_csv(&project, "t").expect("t.csv"); + assert!(csv.contains("keep") && !csv.contains('x'), "unchanged: {csv:?}"); +} + +#[test] +fn update_appends_literal_line_to_history() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); + seed(&db, &rt, "insert into t (id, v) values (1, 'old')", "t"); + let input = "sql_update t set v = 'new' where id = 1"; + run_update(&db, &rt, input).expect("update runs"); + let body = std::fs::read_to_string(project.path().join("history.log")) + .expect("history.log present"); + assert!(body.contains(input), "history records the literal line: {body:?}"); +} diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 3f6109f..81bd2f0 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -220,6 +220,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { Explain { .. } => "Explain".into(), Select { .. } => "Select".into(), SqlInsert { .. } => "SqlInsert".into(), + SqlUpdate { .. } => "SqlUpdate".into(), App(app) => match app { AppCommand::Quit => "App(Quit)".into(), AppCommand::Help => "App(Help)".into(),