From c87363168fe4b3b1faac134db79147597a2aa05a Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 21 May 2026 18:51:21 +0000 Subject: [PATCH] =?UTF-8?q?grammar+db:=203b=20=E2=80=94=20SQL=20INSERT=20g?= =?UTF-8?q?rammar=20+=20minimal=20execution=20(ADR-0033=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQL_INSERT_SHAPE (INTO [(cols)] VALUES tuple(s)) with __rdbms_* target rejection; Command::SqlInsert{sql,target_table}; Request::RunSqlInsert + do_sql_insert worker (tx-guarded: execute, then finalize_persistence for CSV + history before commit, so failures roll back and don't re-persist). Auto-show is best-effort via last_insert_rowid range. Isolated behind a dev `sqlinsert` entry word (Advanced) so the SQL path is testable without making `insert` a shared word yet (that's 3j, after 3d auto-fill parity). Command::SqlInsert carries only sql+target_table; the plan's listed_columns/returning land in 3d/3g where they're read. 6 grammar accept/reject tests + 8 integration tests (single/multi-row, column-list, full-arity, history, rollback-on-failure, multi-row atomicity, parse-path reconstruction, internal-table rejection). 1452 baseline green. --- src/app.rs | 6 + src/db.rs | 100 ++++++++++++++++ src/dsl/command.rs | 16 +++ src/dsl/grammar/data.rs | 51 +++++++- src/dsl/grammar/mod.rs | 5 + src/dsl/grammar/sql_insert.rs | 197 ++++++++++++++++++++++++++++++ src/dsl/grammar/sql_select.rs | 5 +- src/runtime.rs | 8 ++ tests/sql_insert.rs | 219 ++++++++++++++++++++++++++++++++++ tests/typing_surface/mod.rs | 1 + 10 files changed, 605 insertions(+), 3 deletions(-) create mode 100644 src/dsl/grammar/sql_insert.rs create mode 100644 tests/sql_insert.rs diff --git a/src/app.rs b/src/app.rs index ea0df02..2bd4abb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1468,6 +1468,12 @@ impl App { // no single table name to fall back on. A query // failure routes through `Operation::Query`. C::Select { .. } => (Operation::Query, None, None), + // A SQL `INSERT` (ADR-0033) — route engine errors + // (FK / UNIQUE / NOT NULL) through the insert operation + // with the parsed target table. + C::SqlInsert { target_table, .. } => { + (Operation::Insert, 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. diff --git a/src/db.rs b/src/db.rs index 6055c2b..fffd516 100644 --- a/src/db.rs +++ b/src/db.rs @@ -580,6 +580,19 @@ enum Request { source: Option, reply: oneshot::Sender>, }, + /// Run a validated SQL `INSERT` typed in advanced mode + /// (ADR-0033 §1, sub-phase 3b). The grammar walker has + /// validated `sql` is in the supported subset; the worker + /// executes it as text, re-persists the target table's CSV + /// (ADR-0030 §11), and appends the literal line to + /// `history.log`. `target_table` comes from the parse so the + /// worker re-persists the right CSV without re-parsing. + RunSqlInsert { + 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` @@ -1037,6 +1050,28 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + /// Run a validated SQL `INSERT` and return the affected-row + /// count plus the inserted rows (ADR-0033 §1, sub-phase 3b). + /// `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_insert( + &self, + sql: String, + source: Option, + target_table: String, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::RunSqlInsert { + 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 @@ -1482,6 +1517,20 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req &sql, )); } + Request::RunSqlInsert { + sql, + source, + target_table, + reply, + } => { + let _ = reply.send(do_sql_insert( + conn, + persistence, + source.as_deref(), + &sql, + &target_table, + )); + } Request::RebuildFromText { project_path, source, @@ -5698,6 +5747,57 @@ fn do_run_select_request( Ok(data) } +/// Worker handler for `Request::RunSqlInsert` (ADR-0033 §1, +/// sub-phase 3b). Mirrors `do_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()` (so a +/// persistence failure rolls the insert back), then commit. +/// +/// Grammar-as-text (ADR-0030 §4): the values are literals in +/// `sql`, so no parameters are bound. FK / UNIQUE / NOT NULL +/// engine errors surface enriched via `execute_with_fk_enrichment` +/// + the friendly-error layer. +/// +/// Auto-show is best-effort: the inserted rows are the last +/// `rows_affected` rowids ending at `last_insert_rowid()`. For the +/// common case (sequential / engine-assigned rowids) this is +/// exactly the inserted rows; an INSERT that sets explicit +/// non-contiguous rowid/INTEGER-PK values may surface a partial +/// view. `RETURNING` (sub-phase 3g) is the precise tool. +fn do_sql_insert( + conn: &Connection, + persistence: Option<&Persistence>, + source: Option<&str>, + sql: &str, + target_table: &str, +) -> Result { + debug!(sql = %sql, table = %target_table, "sql_insert"); + let tx = conn + .unchecked_transaction() + .map_err(DbError::from_rusqlite)?; + let rows_affected = execute_with_fk_enrichment(conn, target_table, sql, &[])?; + let last = conn.last_insert_rowid(); + let rowids: Vec = if rows_affected == 0 { + Vec::new() + } else { + let n = rows_affected as i64; + ((last - n + 1)..=last).collect() + }; + let data = query_rows_by_rowid(conn, target_table, &rowids)?; + 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(InsertResult { + rows_affected, + data, + }) +} + /// 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 cfe4bfb..6425550 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -292,6 +292,18 @@ pub enum Command { Select { sql: String, }, + /// Run a validated SQL `INSERT` (ADR-0033 §1, sub-phase 3b). + /// Advanced mode only. Grammar-as-text (ADR-0030 §4): `sql` is + /// the validated statement the worker executes verbatim; + /// `target_table` is extracted from the parse so the worker can + /// re-persist that table's CSV after a successful insert + /// (ADR-0030 §11) without re-parsing the SQL. `listed_columns` + /// (3d, `shortid` auto-fill) and `returning` (3g) are added by + /// the sub-phases that read them. + SqlInsert { + 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. @@ -587,6 +599,7 @@ impl Command { Self::Replay { .. } => "replay", Self::Explain { .. } => "explain", Self::Select { .. } => "select", + Self::SqlInsert { .. } => "insert into", Self::App(app) => match app { AppCommand::Quit => "quit", AppCommand::Help => "help", @@ -654,6 +667,9 @@ impl Command { // result renders as a data view, not a structure // view, so an empty target is correct here. Self::Select { .. } => "", + // A SQL `INSERT` carries its parsed target table (for + // CSV re-persistence and ok-summary subject). + Self::SqlInsert { 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 733a693..47567ed 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_select, + sql_insert, sql_select, }; use crate::dsl::walker::context::WalkContext; use crate::dsl::value::Value; @@ -855,6 +855,37 @@ fn build_select(_path: &MatchedPath, source: &str) -> Result Result { + let target_table = path + .items + .iter() + .find_map(|item| match item.kind { + MatchedKind::Ident { + role: "insert_target_table", + .. + } => Some(item.text.clone()), + _ => None, + }) + .unwrap_or_default(); + // Everything after the entry word is the `INTO …` tail; prefix + // the real `insert` keyword for the engine. + let tail = path + .items + .first() + .map_or(source, |entry| &source[entry.span.1..]); + let sql = format!("insert {}", tail.trim()); + Ok(Command::SqlInsert { sql, target_table }) +} + // ================================================================= // CommandNodes // ================================================================= @@ -930,6 +961,24 @@ pub static WITH: CommandNode = CommandNode { help_id: None, usage_ids: &["parse.usage.select"],}; +/// SQL `INSERT` development scaffold (ADR-0033 sub-phase 3b–3i). +/// +/// Registered under the temporary entry word `sqlinsert` so the +/// SQL INSERT grammar and execution path can be exercised in +/// isolation, WITHOUT yet making `insert` a shared DSL/SQL entry +/// word. Sharing `insert` is sub-phase 3j, which depends on +/// `shortid` auto-fill (3d) so advanced-mode DSL inserts keep +/// parity rather than regressing through an incomplete SQL path. +/// This scaffold (entry word + reconstruction in `build_sql_insert`) +/// is removed when 3j wires the real `insert` entry word. +pub static SQL_INSERT: CommandNode = CommandNode { + entry: Word::keyword("sqlinsert"), + shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE), + ast_builder: build_sql_insert, + 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 6a7563c..9a90a74 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -28,6 +28,7 @@ pub mod ddl; pub mod expr; pub mod shared; pub mod sql_expr; +pub mod sql_insert; pub mod sql_select; use crate::dsl::command::Command; @@ -570,6 +571,10 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[ (&data::EXPLAIN, CommandCategory::Simple), (&data::SELECT, CommandCategory::Advanced), (&data::WITH, CommandCategory::Advanced), + // SQL INSERT development scaffold (sub-phase 3b–3i); the + // temporary `sqlinsert` entry word keeps it isolated from the + // DSL `insert` word until 3j wires the shared entry. + (&data::SQL_INSERT, CommandCategory::Advanced), ]; /// Whether `entry` names an advanced-mode-only command (ADR-0030 diff --git a/src/dsl/grammar/sql_insert.rs b/src/dsl/grammar/sql_insert.rs new file mode 100644 index 0000000..4fad97b --- /dev/null +++ b/src/dsl/grammar/sql_insert.rs @@ -0,0 +1,197 @@ +//! SQL `INSERT` grammar (ADR-0033 §1, sub-phase 3b). +//! +//! Grammar-as-text (ADR-0030 §4): the walker validates that the +//! `INSERT` 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-`INSERT` portion — +//! the entry-word dispatch consumes the leading `INSERT` keyword +//! before this shape walks (mirroring `sql_select::SQL_SELECT_TAIL`). +//! +//! Scope (3b): single- and multi-row `VALUES`, an optional +//! `(column_name_list)`, and the `__rdbms_*` target rejection. +//! `INSERT … SELECT` (3c), `shortid` auto-fill (3d), `RETURNING` +//! (3g), and `ON CONFLICT … ` UPSERT (3h) land in later +//! sub-phases. + +use crate::dsl::grammar::sql_expr; +use crate::dsl::grammar::sql_select::reject_internal_table; +use crate::dsl::grammar::{IdentSource, Node, Word}; + +static COMMA: Node = Node::Punct(','); + +/// The `INSERT` target table. `__rdbms_*` rejected (ADR-0030 §6 / +/// ADR-0033 §1). `writes_table` populates `current_table` / +/// `current_table_columns` so the optional column list and the +/// `VALUES` expressions get column completion against the target. +const TARGET_TABLE: Node = Node::Ident { + source: IdentSource::Tables, + role: "insert_target_table", + 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, +}; + +/// One column name inside the optional `(col1, col2, …)` list. +/// +/// `writes_user_listed_column` stays `false` in 3b — the worker +/// requires explicit values for every column, so the listed-column +/// set isn't needed yet. Sub-phase 3d (`shortid` auto-fill) turns +/// it on and threads `listed_columns` into `Command::SqlInsert`. +static COLUMN_NAME: Node = Node::Ident { + source: IdentSource::Columns, + role: "insert_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, +}; + +static COLUMN_LIST_NODES: &[Node] = &[ + Node::Punct('('), + Node::Repeated { + inner: &COLUMN_NAME, + separator: Some(&COMMA), + min: 1, + }, + Node::Punct(')'), +]; +const OPTIONAL_COLUMN_LIST: Node = Node::Optional(&Node::Seq(COLUMN_LIST_NODES)); + +/// One value expression inside a `VALUES` tuple. Consumes the +/// shared `sql_expr` grammar (ADR-0031), so literals, operators, +/// `CASE`, function calls, etc. are all admitted; the engine +/// evaluates them at execution time. +static VALUE_EXPR: Node = Node::Subgrammar(&sql_expr::SQL_OR_EXPR); + +static VALUE_TUPLE_NODES: &[Node] = &[ + Node::Punct('('), + Node::Repeated { + inner: &VALUE_EXPR, + separator: Some(&COMMA), + min: 1, + }, + Node::Punct(')'), +]; +/// `'(' sql_expr (',' sql_expr)* ')'` — one row of values. +static VALUE_TUPLE: Node = Node::Seq(VALUE_TUPLE_NODES); + +static VALUES_CLAUSE_NODES: &[Node] = &[ + Node::Word(Word::keyword("values")), + Node::Repeated { + inner: &VALUE_TUPLE, + separator: Some(&COMMA), + min: 1, + }, +]; +/// `VALUES tuple (',' tuple)*` — single- or multi-row. +const VALUES_CLAUSE: Node = Node::Seq(VALUES_CLAUSE_NODES); + +static SQL_INSERT_TAIL_NODES: &[Node] = &[ + Node::Word(Word::keyword("into")), + TARGET_TABLE, + OPTIONAL_COLUMN_LIST, + VALUES_CLAUSE, + Node::Optional(&Node::Punct(';')), +]; + +/// The post-`INSERT` portion of a SQL `INSERT` statement +/// (ADR-0033 §1): `INTO
[ '(' col_list ')' ] VALUES +/// (',' )* [ ';' ]`. +/// +/// The entry-word dispatch consumes the leading `INSERT` keyword +/// before this shape walks, so a `CommandNode` references it as +/// its `shape` (sub-phase 3b registers a development entry word; +/// sub-phase 3j wires the shared `insert` entry word). +pub static SQL_INSERT_SHAPE: Node = Node::Seq(SQL_INSERT_TAIL_NODES); + +// ================================================================= +// Tests — grammar accept/reject for the post-`INSERT` tail. +// ================================================================= + +#[cfg(test)] +mod tests { + use super::SQL_INSERT_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 INSERT tail. Returns `true` only + /// when the walk matches *and* consumes all of `input` + /// (trailing whitespace allowed). Schemaless context: 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_INSERT_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 INSERT tail"); + } + + fn bad(input: &str) { + assert!(!walks(input), "{input:?} should NOT walk as a complete INSERT tail"); + } + + #[test] + fn single_row_values() { + good("into orders values (1, 2.0)"); + good("into orders values (1, 'text', true, null)"); + good("into orders values (1);"); + } + + #[test] + fn multi_row_values() { + good("into orders values (1, 'a'), (2, 'b')"); + good("into orders values (1), (2), (3)"); + good("into orders values (1, 'a'), (2, 'b');"); + } + + #[test] + fn explicit_column_list() { + good("into orders (id, total) values (1, 2.0)"); + good("into orders (id) values (1)"); + good("into orders (a, b, c) values (1, 2, 3), (4, 5, 6)"); + } + + #[test] + fn value_expressions_admit_sql_expr() { + good("into t values (1 + 2)"); + good("into t values (case when 1 > 0 then 'y' else 'n' end)"); + } + + #[test] + fn internal_target_table_rejected() { + bad("into __rdbms_playground_columns values (1)"); + bad("into __rdbms_playground_relationships (a) values (1)"); + } + + #[test] + fn structurally_incomplete_or_wrong_rejected() { + // Missing VALUES. + bad("into orders"); + bad("into orders (id, total)"); + // Empty value tuple — at least one expression required. + bad("into orders values ()"); + // Missing INTO. + bad("orders values (1)"); + // Trailing comma with no following tuple. + bad("into orders values (1),"); + // Unclosed tuple. + bad("into orders values (1, 2"); + } +} diff --git a/src/dsl/grammar/sql_select.rs b/src/dsl/grammar/sql_select.rs index 266fffd..f363b12 100644 --- a/src/dsl/grammar/sql_select.rs +++ b/src/dsl/grammar/sql_select.rs @@ -84,8 +84,9 @@ use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; /// Reject internal `__rdbms_*` metadata tables in any /// table-source slot (ADR-0030 §6 reused by ADR-0032 §4 — extends /// to every Phase-2 table-source slot: `FROM`, `JOIN` targets, -/// CTE name, and the `FROM` inside any CTE body). -fn reject_internal_table(name: &str) -> Result<(), ValidationError> { +/// CTE name, and the `FROM` inside any CTE body; ADR-0033 §1 +/// reuses it on the SQL `INSERT` target slot). +pub(crate) fn reject_internal_table(name: &str) -> Result<(), ValidationError> { if name.to_ascii_lowercase().starts_with("__rdbms_") { Err(ValidationError { message_key: "select.internal_table", diff --git a/src/runtime.rs b/src/runtime.rs index 95fab1f..98b6935 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1881,6 +1881,14 @@ async fn execute_command_typed( .run_select(sql, src) .await .map(CommandOutcome::Query), + // A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as- + // text: the worker runs the validated `sql` and re-persists + // the parsed `target_table`'s CSV. Reuses the DSL insert + // outcome (affected-row count + auto-show). + Command::SqlInsert { sql, target_table } => database + .run_sql_insert(sql, src, target_table) + .await + .map(CommandOutcome::Insert), // `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_insert.rs b/tests/sql_insert.rs new file mode 100644 index 0000000..e47b869 --- /dev/null +++ b/tests/sql_insert.rs @@ -0,0 +1,219 @@ +//! Sub-phase 3b integration tests for the advanced-mode SQL +//! `INSERT` surface (ADR-0033 §1). +//! +//! Covers: +//! - Worker round-trip: a validated `INSERT` runs against the +//! database, returns the affected-row count, and re-persists the +//! target table's CSV (ADR-0030 §11). +//! - Single-row, multi-row, explicit-column-list, and full-arity +//! (no column list) forms. +//! - `history.log` records the literal submitted line. +//! - A failing INSERT (PK conflict) rolls back and does NOT +//! re-persist the CSV. +//! - Parse path: `parse_command` (advanced mode) lowers the dev +//! `sqlinsert` scaffold to `Command::SqlInsert`, reconstructing +//! valid `insert …` SQL and extracting the target table. +//! - `__rdbms_*` target tables are rejected at the grammar layer. +//! +//! The dev `sqlinsert` entry word keeps the SQL INSERT path +//! isolated from the DSL `insert` word until sub-phase 3j; the +//! worker-level tests call `db.run_sql_insert` directly with the +//! real reconstructed SQL. + +use rdbms_playground::db::Database; +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() +} + +/// Create a two-column table `T(a int pk, b text)` — no +/// auto-generated columns, so 3b's explicit-values requirement is +/// satisfied by every INSERT below. +fn create_t(db: &Database, rt: &tokio::runtime::Runtime) { + rt.block_on(db.create_table( + "T".to_string(), + vec![ + ColumnSpec::new("a", Type::Int), + ColumnSpec::new("b", Type::Text), + ], + vec!["a".to_string()], + None, + )) + .expect("create table T"); +} + +#[test] +fn single_row_insert_persists_and_counts() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_t(&db, &rt); + let result = rt + .block_on(db.run_sql_insert( + "insert into T (a, b) values (1, 'Ada')".to_string(), + Some("insert into T (a, b) values (1, 'Ada')".to_string()), + "T".to_string(), + )) + .expect("insert runs"); + assert_eq!(result.rows_affected, 1, "one row inserted"); + let csv = read_csv(&project, "T").expect("T.csv written after insert"); + assert!(csv.contains("Ada"), "CSV reflects the inserted row: {csv:?}"); +} + +#[test] +fn multi_row_insert_persists_both_rows() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_t(&db, &rt); + let result = rt + .block_on(db.run_sql_insert( + "insert into T (a, b) values (1, 'first'), (2, 'second')".to_string(), + None, + "T".to_string(), + )) + .expect("multi-row insert runs"); + assert_eq!(result.rows_affected, 2, "two rows inserted"); + let csv = read_csv(&project, "T").expect("T.csv written"); + assert!( + csv.contains("first") && csv.contains("second"), + "CSV reflects both inserted rows: {csv:?}", + ); +} + +#[test] +fn no_column_list_full_arity_insert_persists() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_t(&db, &rt); + let result = rt + .block_on(db.run_sql_insert( + "insert into T values (7, 'full-arity')".to_string(), + None, + "T".to_string(), + )) + .expect("full-arity insert runs"); + assert_eq!(result.rows_affected, 1); + let csv = read_csv(&project, "T").expect("T.csv written"); + assert!(csv.contains("full-arity"), "CSV reflects the row: {csv:?}"); +} + +#[test] +fn insert_appends_literal_line_to_history() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_t(&db, &rt); + // ADR-0030 §11: the literal submitted line lands in history.log. + let source = "insert into T (a, b) values (1, 'logged')"; + rt.block_on(db.run_sql_insert( + "insert into T (a, b) values (1, 'logged')".to_string(), + Some(source.to_string()), + "T".to_string(), + )) + .expect("insert runs"); + let body = std::fs::read_to_string(project.path().join("history.log")) + .expect("history.log present after an INSERT"); + assert!( + body.contains(source), + "history.log records the literal INSERT line: {body:?}", + ); +} + +#[test] +fn failed_insert_rolls_back_and_does_not_repersist() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_t(&db, &rt); + // First insert succeeds and persists. + rt.block_on(db.run_sql_insert( + "insert into T (a, b) values (1, 'kept')".to_string(), + None, + "T".to_string(), + )) + .expect("first insert runs"); + // Second insert violates the primary key — it must fail and + // leave persistence untouched (the transaction rolls back + // before finalize_persistence). + let outcome = rt.block_on(db.run_sql_insert( + "insert into T (a, b) values (1, 'discarded')".to_string(), + None, + "T".to_string(), + )); + assert!(outcome.is_err(), "duplicate PK must fail: {outcome:?}"); + let csv = read_csv(&project, "T").expect("T.csv still present"); + assert!( + csv.contains("kept") && !csv.contains("discarded"), + "failed insert must not be persisted: {csv:?}", + ); +} + +#[test] +fn failed_multi_row_insert_is_atomic() { + // ADR-0033 §3b DA gate: a multi-row INSERT whose later tuple + // violates a constraint fails as one statement — no partial + // rows land, and the CSV is not rewritten. + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_t(&db, &rt); + rt.block_on(db.run_sql_insert( + "insert into T (a, b) values (1, 'existing')".to_string(), + None, + "T".to_string(), + )) + .expect("seed row"); + // Row (2,…) is new but (1,…) collides on the PK — the whole + // statement must fail with neither tuple applied. + let outcome = rt.block_on(db.run_sql_insert( + "insert into T (a, b) values (2, 'fresh'), (1, 'collides')".to_string(), + None, + "T".to_string(), + )); + assert!(outcome.is_err(), "multi-row PK conflict must fail: {outcome:?}"); + let csv = read_csv(&project, "T").expect("T.csv still present"); + assert!( + csv.contains("existing") && !csv.contains("fresh") && !csv.contains("collides"), + "no tuple from the failed multi-row insert may land: {csv:?}", + ); +} + +#[test] +fn parse_path_lowers_sqlinsert_scaffold_to_command() { + // Advanced-mode parse of the dev scaffold reconstructs valid + // `insert …` SQL and extracts the target table. + let command = parse_command("sqlinsert into Orders (id, total) values (1, 99.5)") + .expect("sqlinsert parses in advanced mode"); + match command { + Command::SqlInsert { sql, target_table } => { + assert_eq!(sql, "insert into Orders (id, total) values (1, 99.5)"); + assert_eq!(target_table, "Orders"); + } + other => panic!("expected Command::SqlInsert, got {other:?}"), + } +} + +#[test] +fn parse_path_rejects_internal_target_table() { + let result = parse_command("sqlinsert into __rdbms_playground_columns values (1)"); + assert!( + result.is_err(), + "an internal `__rdbms_*` target must be rejected: {result:?}", + ); +} diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 412ecb0..3f6109f 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -219,6 +219,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { Replay { .. } => "Replay".into(), Explain { .. } => "Explain".into(), Select { .. } => "Select".into(), + SqlInsert { .. } => "SqlInsert".into(), App(app) => match app { AppCommand::Quit => "App(Quit)".into(), AppCommand::Help => "App(Help)".into(),