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:
+71
@@ -1251,10 +1251,16 @@ impl App {
|
|||||||
fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) {
|
fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) {
|
||||||
self.note_ok_summary(command);
|
self.note_ok_summary(command);
|
||||||
self.note_system(crate::t!("ok.rows_updated", count = result.rows_affected));
|
self.note_system(crate::t!("ok.rows_updated", count = result.rows_affected));
|
||||||
|
// 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) {
|
for line in crate::output_render::render_data_table(&result.data) {
|
||||||
self.note_system(line);
|
self.note_system(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_dsl_add_column_success(
|
fn handle_dsl_add_column_success(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -1474,6 +1480,11 @@ impl App {
|
|||||||
C::SqlInsert { target_table, .. } => {
|
C::SqlInsert { target_table, .. } => {
|
||||||
(Operation::Insert, Some(target_table.as_str()), None)
|
(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),
|
C::Replay { .. } => (Operation::Replay, None, None),
|
||||||
// An `explain` failure (e.g. unknown table) is best
|
// An `explain` failure (e.g. unknown table) is best
|
||||||
// described by the wrapped query it failed to plan.
|
// described by the wrapped query it failed to plan.
|
||||||
@@ -3336,4 +3347,64 @@ mod tests {
|
|||||||
Some(crate::dsl::walker::Severity::Warning),
|
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<String> = 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<String> = app.output.iter().map(|l| l.text.clone()).collect();
|
||||||
|
assert!(
|
||||||
|
texts.iter().any(|t| t.contains("id") && t.contains('v')),
|
||||||
|
"header row rendered: {texts:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -595,6 +595,16 @@ enum Request {
|
|||||||
row_source: String,
|
row_source: String,
|
||||||
reply: oneshot::Sender<Result<InsertResult, DbError>>,
|
reply: oneshot::Sender<Result<InsertResult, DbError>>,
|
||||||
},
|
},
|
||||||
|
/// 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<String>,
|
||||||
|
target_table: String,
|
||||||
|
reply: oneshot::Sender<Result<UpdateResult, DbError>>,
|
||||||
|
},
|
||||||
/// Capture the query plan for an explainable command via
|
/// Capture the query plan for an explainable command via
|
||||||
/// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner
|
/// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner
|
||||||
/// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN`
|
/// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN`
|
||||||
@@ -1078,6 +1088,28 @@ impl Database {
|
|||||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
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<String>,
|
||||||
|
target_table: String,
|
||||||
|
) -> Result<UpdateResult, DbError> {
|
||||||
|
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
|
/// Capture the query plan for an explainable command
|
||||||
/// (ADR-0028 §2). The wrapped command is not executed —
|
/// (ADR-0028 §2). The wrapped command is not executed —
|
||||||
/// `EXPLAIN QUERY PLAN` only inspects how the engine would
|
/// `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,
|
&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 {
|
Request::RebuildFromText {
|
||||||
project_path,
|
project_path,
|
||||||
source,
|
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<UpdateResult, DbError> {
|
||||||
|
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
|
/// Execute a grammar-validated SQL `SELECT` and collect its
|
||||||
/// rows into a [`DataResult`] (ADR-0030 §6, ADR-0032 §12 +
|
/// rows into a [`DataResult`] (ADR-0030 §6, ADR-0032 §12 +
|
||||||
/// Amendment 1).
|
/// Amendment 1).
|
||||||
|
|||||||
+11
-1
@@ -312,6 +312,14 @@ pub enum Command {
|
|||||||
listed_columns: Vec<String>,
|
listed_columns: Vec<String>,
|
||||||
row_source: String,
|
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
|
/// App-lifecycle command (per ADR-0003). These work in both
|
||||||
/// simple and advanced modes; the dispatcher branches on the
|
/// simple and advanced modes; the dispatcher branches on the
|
||||||
/// `Command::App(...)` variant before mode-specific routing.
|
/// `Command::App(...)` variant before mode-specific routing.
|
||||||
@@ -608,6 +616,7 @@ impl Command {
|
|||||||
Self::Explain { .. } => "explain",
|
Self::Explain { .. } => "explain",
|
||||||
Self::Select { .. } => "select",
|
Self::Select { .. } => "select",
|
||||||
Self::SqlInsert { .. } => "insert into",
|
Self::SqlInsert { .. } => "insert into",
|
||||||
|
Self::SqlUpdate { .. } => "update",
|
||||||
Self::App(app) => match app {
|
Self::App(app) => match app {
|
||||||
AppCommand::Quit => "quit",
|
AppCommand::Quit => "quit",
|
||||||
AppCommand::Help => "help",
|
AppCommand::Help => "help",
|
||||||
@@ -677,7 +686,8 @@ impl Command {
|
|||||||
Self::Select { .. } => "",
|
Self::Select { .. } => "",
|
||||||
// A SQL `INSERT` carries its parsed target table (for
|
// A SQL `INSERT` carries its parsed target table (for
|
||||||
// CSV re-persistence and ok-summary subject).
|
// 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
|
// App commands aren't tied to schema entities — the
|
||||||
// verb is the most identifying thing. The
|
// verb is the most identifying thing. The
|
||||||
// display_subject override below provides a richer
|
// display_subject override below provides a richer
|
||||||
|
|||||||
+46
-1
@@ -20,7 +20,7 @@ use crate::dsl::command::{Command, Expr, RowFilter};
|
|||||||
use crate::dsl::grammar::{
|
use crate::dsl::grammar::{
|
||||||
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
|
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
|
||||||
shared::{column_value_list, current_column_value},
|
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::walker::context::WalkContext;
|
||||||
use crate::dsl::value::Value;
|
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
|
// CommandNodes
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -1016,6 +1045,22 @@ pub static SQL_INSERT: CommandNode = CommandNode {
|
|||||||
usage_ids: &[],
|
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)
|
// Tests — `explain` grammar (ADR-0028 §1)
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub mod shared;
|
|||||||
pub mod sql_expr;
|
pub mod sql_expr;
|
||||||
pub mod sql_insert;
|
pub mod sql_insert;
|
||||||
pub mod sql_select;
|
pub mod sql_select;
|
||||||
|
pub mod sql_update;
|
||||||
|
|
||||||
use crate::dsl::command::Command;
|
use crate::dsl::command::Command;
|
||||||
use crate::dsl::walker::context::WalkContext;
|
use crate::dsl::walker::context::WalkContext;
|
||||||
@@ -575,6 +576,10 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
|||||||
// temporary `sqlinsert` entry word keeps it isolated from the
|
// temporary `sqlinsert` entry word keeps it isolated from the
|
||||||
// DSL `insert` word until 3j wires the shared entry.
|
// DSL `insert` word until 3j wires the shared entry.
|
||||||
(&data::SQL_INSERT, CommandCategory::Advanced),
|
(&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
|
/// 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::Word(Word::keyword("where")),
|
||||||
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
|
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] = &[
|
static GROUP_BY_CLAUSE_NODES: &[Node] = &[
|
||||||
Node::Word(Word::keyword("group")),
|
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,");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
#[test]
|
||||||
fn cte_name_is_valid_table_source() {
|
fn cte_name_is_valid_table_source() {
|
||||||
let schema = schema_with("base", &[("id", Type::Int)]);
|
let schema = schema_with("base", &[("id", Type::Int)]);
|
||||||
|
|||||||
@@ -1894,6 +1894,14 @@ async fn execute_command_typed(
|
|||||||
.run_sql_insert(sql, src, target_table, listed_columns, row_source)
|
.run_sql_insert(sql, src, target_table, listed_columns, row_source)
|
||||||
.await
|
.await
|
||||||
.map(CommandOutcome::Insert),
|
.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
|
// `EXPLAIN QUERY PLAN` never executes the wrapped
|
||||||
// statement (ADR-0028 §2), so explaining a destructive
|
// statement (ADR-0028 §2), so explaining a destructive
|
||||||
// command is safe. `src` is unused here — explain is a
|
// command is safe. `src` is unused here — explain is a
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
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<UpdateResult, DbError> {
|
||||||
|
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:?}");
|
||||||
|
}
|
||||||
@@ -220,6 +220,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
|||||||
Explain { .. } => "Explain".into(),
|
Explain { .. } => "Explain".into(),
|
||||||
Select { .. } => "Select".into(),
|
Select { .. } => "Select".into(),
|
||||||
SqlInsert { .. } => "SqlInsert".into(),
|
SqlInsert { .. } => "SqlInsert".into(),
|
||||||
|
SqlUpdate { .. } => "SqlUpdate".into(),
|
||||||
App(app) => match app {
|
App(app) => match app {
|
||||||
AppCommand::Quit => "App(Quit)".into(),
|
AppCommand::Quit => "App(Quit)".into(),
|
||||||
AppCommand::Help => "App(Help)".into(),
|
AppCommand::Help => "App(Help)".into(),
|
||||||
|
|||||||
Reference in New Issue
Block a user