grammar+db: 3b — SQL INSERT grammar + minimal execution (ADR-0033 §1)

SQL_INSERT_SHAPE (INTO <table> [(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.
This commit is contained in:
claude@clouddev1
2026-05-21 18:51:21 +00:00
parent 4e16d97fe0
commit c87363168f
10 changed files with 605 additions and 3 deletions
+6
View File
@@ -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.
+100
View File
@@ -580,6 +580,19 @@ enum Request {
source: Option<String>,
reply: oneshot::Sender<Result<DataResult, DbError>>,
},
/// 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<String>,
target_table: String,
reply: oneshot::Sender<Result<InsertResult, DbError>>,
},
/// 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<String>,
target_table: String,
) -> Result<InsertResult, DbError> {
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<InsertResult, DbError> {
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<i64> = 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).
+16
View File
@@ -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
+50 -1
View File
@@ -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<Command, Validation
})
}
/// Build `Command::SqlInsert` from a validated SQL `INSERT`
/// (ADR-0033 §1, sub-phase 3b). Extracts the target table from
/// the matched path so the worker re-persists the right CSV.
///
/// Dev-scaffold detail: the entry word is `sqlinsert` (not valid
/// SQL), so the statement is reconstructed as `insert` + the
/// matched tail. Sub-phase 3j wires the real `insert` entry word,
/// at which point this collapses to `source.trim()` like
/// `build_select`.
fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
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 3b3i).
///
/// 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)
// =================================================================
+5
View File
@@ -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 3b3i); 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
+197
View File
@@ -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 <table> [ '(' col_list ')' ] VALUES
/// <tuple> (',' <tuple>)* [ ';' ]`.
///
/// 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");
}
}
+3 -2
View File
@@ -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",
+8
View File
@@ -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
+219
View File
@@ -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<String> {
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:?}",
);
}
+1
View File
@@ -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(),