test: consolidate 25 integration crates into one it binary

Each top-level tests/*.rs was its own crate → its own binary, each
statically linking the bundled engine + every dep. 26 of them, so an
edit to the lib relinked all 26. Moved the 25 standalone files into
tests/it/ under one tests/it/main.rs (the pattern typing_surface
already uses); cargo auto-detects it as the `it` target. End state: 2
integration-test binaries instead of 26.

Result: target/debug/deps 1.5 GB → 629 MB (-58%). Build time barely
moved (clean 22.9s→22.4s, lib-edit relink 13.3s→12.4s) — wall-clock is
dominated by compiling, not linking, so this is a disk win, not a speed
win (see docs/plans/20260602-test-consolidation.md). Tests unchanged at
2151/0/1; clippy clean; no fixups needed. typing_surface_matrix stays
its own already-consolidated binary.

Tradeoff: the 25 files now share one crate (a compile error fails the
whole `it` binary; module-scoped namespaces, no clashes) — negligible
for a solo project.
This commit is contained in:
claude@clouddev1
2026-06-02 22:13:03 +00:00
parent 42f95533ac
commit 9efae59c3c
27 changed files with 122 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
//! Regression: SQL identifiers are case-insensitive, so a user may refer
//! to a table by any capitalization. The engine resolves the name
//! case-insensitively, but our metadata tables and CSV files are keyed by
//! the *stored* case — so before the fix, an operation naming the table in
//! a different case drifted the metadata / silently skipped the CSV write
//! (losing data on reload). Every table-naming executor now canonicalizes
//! the name to its stored case first. These tests pin that behaviour
//! across schema and data operations, including fresh-rebuild round-trips
//! (which reconstruct purely from the text artifacts, so any drift shows).
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type, Value};
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
use rdbms_playground::runtime::run_replay;
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
fn open() -> (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 db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.expect("db");
(project, db, dir)
}
/// Drop the db handle, delete the `.db`, reopen, and rebuild purely from
/// the text artifacts (`project.yaml` + CSVs) — where any metadata/CSV
/// drift from a case-variant operation would surface.
fn fresh_rebuild(
old: Database,
project: &project::Project,
r: &tokio::runtime::Runtime,
) -> Database {
use rdbms_playground::project::PLAYGROUND_DB;
drop(old);
std::fs::remove_file(project.path().join(PLAYGROUND_DB)).expect("remove db");
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.expect("db");
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), None))
.expect("rebuild");
db
}
fn replay(project: &project::Project, db: &Database, r: &tokio::runtime::Runtime, script: &str) {
std::fs::write(project.path().join("ci.commands"), script).expect("write script");
let events = r.block_on(run_replay(db, project.path(), "ci.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { .. })),
"script replayed cleanly; events: {events:?}"
);
}
fn tables(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
r.block_on(db.list_tables()).expect("list_tables")
}
#[test]
fn rename_column_with_case_variant_table_keeps_metadata_in_step() {
// The engine renames the column on the real table; the user-type
// metadata must follow even when the table is named in a different
// case (without canonicalization the metadata UPDATE misses and
// `amount` loses its `int` user-type).
let (_p, db, _d) = open();
let r = rt();
r.block_on(db.create_table(
"Items".to_string(),
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("qty", Type::Int)],
vec!["id".to_string()],
Some("create".to_string()),
))
.expect("create Items");
r.block_on(db.rename_column(
"items".to_string(), // ← case variant of `Items`
"qty".to_string(),
"amount".to_string(),
Some("rename".to_string()),
))
.expect("rename column via a case-variant table name");
let desc = r
.block_on(db.describe_table("Items".to_string(), None))
.expect("describe Items");
let amount = desc
.columns
.iter()
.find(|c| c.name == "amount")
.expect("the column was renamed to `amount`");
assert_eq!(
amount.user_type,
Some(Type::Int),
"the user-type metadata followed the case-variant rename (no drift)"
);
}
#[test]
fn insert_with_case_variant_table_persists_and_survives_rebuild() {
// The data-loss case: a wrong-case INSERT executes on the real table
// (engine is case-insensitive), but the CSV write must target the
// stored case — otherwise the row is silently absent from the CSV and
// lost on a fresh rebuild.
let (project, db, _d) = open();
let r = rt();
replay(
&project,
&db,
&r,
"create table Items with pk id(int)\n\
add column Items: note (text)\n\
insert into items (id, note) values (1, 'kept')\n",
);
let db = fresh_rebuild(db, &project, &r);
let rows = r
.block_on(db.query_data("Items".to_string(), None, None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)");
assert_eq!(rows[0][1].as_deref(), Some("kept"));
}
#[test]
fn add_column_with_case_variant_table_survives_rebuild() {
let (project, db, _d) = open();
let r = rt();
replay(
&project,
&db,
&r,
"create table Items with pk id(int)\n\
alter table items add column qty int check (qty >= 0)\n",
);
let db = fresh_rebuild(db, &project, &r);
let desc = r.block_on(db.describe_table("Items".to_string(), None)).expect("describe");
let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added");
assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild");
// The CHECK is intact too (a negative qty is refused under the real table).
assert!(
r.block_on(db.insert(
"Items".to_string(),
Some(vec!["id".into(), "qty".into()]),
vec![Value::Number("1".into()), Value::Number("-3".into())],
Some("i".into()),
))
.is_err(),
"the CHECK added via a case-variant ALTER is enforced"
);
}
#[test]
fn drop_table_with_case_variant_name_clears_table_and_csv() {
let (project, db, _d) = open();
let r = rt();
replay(
&project,
&db,
&r,
"create table Items with pk id(int)\n\
add column Items: note (text)\n\
insert into Items (id, note) values (1, 'x')\n\
drop table items\n",
);
assert!(!tables(&db, &r).contains(&"Items".to_string()), "the table was dropped");
let csv = project.path().join(project::DATA_DIR).join("Items.csv");
assert!(!csv.exists(), "the CSV was removed despite the case-variant drop");
// A fresh rebuild yields no Items (the metadata/yaml has no orphan).
let db = fresh_rebuild(db, &project, &r);
assert!(!tables(&db, &r).contains(&"Items".to_string()));
}
#[test]
fn rename_table_accepts_case_variant_source() {
// `alter table orders rename to Sales` when the table is stored as
// `Orders` now resolves the source case-insensitively and renames it.
let (project, db, _d) = open();
let r = rt();
replay(
&project,
&db,
&r,
"create table Orders with pk id(int)\n\
insert into Orders (id) values (1)\n\
alter table orders rename to Sales\n",
);
let t = tables(&db, &r);
assert!(
t.contains(&"Sales".to_string()) && !t.contains(&"Orders".to_string()),
"the case-variant source resolved and the table was renamed: {t:?}"
);
let db = fresh_rebuild(db, &project, &r);
assert!(tables(&db, &r).contains(&"Sales".to_string()));
}
#[test]
fn add_relationship_with_case_variant_tables_survives_rebuild() {
// The relationship metadata must store the canonical table names, or
// `describe` (which matches by stored case) would not show it, and a
// rebuild would emit a relationship against the wrong-case name.
let (project, db, _d) = open();
let r = rt();
replay(
&project,
&db,
&r,
"create table Parent with pk id(int)\n\
create table Child with pk id(int)\n\
add column Child: parent_id (int)\n\
add 1:n relationship from parent.id to child.parent_id\n",
);
// The parent's inbound relationship is visible under the stored case.
let p = r.block_on(db.describe_table("Parent".to_string(), None)).expect("describe Parent");
assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case");
assert_eq!(p.inbound_relationships[0].other_table, "Child");
let db = fresh_rebuild(db, &project, &r);
let p = r.block_on(db.describe_table("Parent".to_string(), None)).expect("describe Parent");
assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild");
assert_eq!(p.inbound_relationships[0].other_table, "Child");
}
+416
View File
@@ -0,0 +1,416 @@
//! Executor-level guards on the shared column operations (ADR-0035 §4e).
//!
//! These guards live in `do_add_column` / `do_drop_column` /
//! `do_rename_column`, so they apply to BOTH the simple-mode DSL
//! commands (exercised here) and the advanced-mode SQL `ALTER TABLE`
//! (which reaches the same executors). Two guards:
//! 1. internal `__rdbms_*` tables are refused as "no such table";
//! 2. dropping/renaming a column a table-level CHECK references is
//! refused up-front (the 4a.3 deferral; it also fixes a latent
//! rename-drift bug that would break a later rebuild).
use rdbms_playground::db::Database;
use rdbms_playground::dsl::command::Constraint;
use rdbms_playground::dsl::{ChangeColumnMode, ColumnSpec, ReferentialAction, Type};
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::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_and_undo(project.db_path(), persistence, true)
.expect("open db with persistence");
(project, db, dir)
}
/// `T (id int pk, a int, b int, c text)` with a table-level CHECK
/// `a < b`.
fn make_t_with_check(db: &Database, r: &tokio::runtime::Runtime) {
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
ColumnSpec::new("c", Type::Text),
],
vec!["id".to_string()],
vec![],
vec!["a < b".to_string()],
vec![],
false,
Some("create table T (id int primary key, a int, b int, c text, check (a < b))".to_string()),
))
.expect("create T with table CHECK");
}
#[test]
fn simple_column_ops_refuse_internal_tables() {
let (_p, db, _d) = open();
let r = rt();
let internal = "__rdbms_playground_columns".to_string();
assert!(
r.block_on(db.add_column(
internal.clone(),
ColumnSpec::new("x", Type::Int),
Some("add column".to_string())
))
.is_err(),
"add column on an internal table is refused"
);
assert!(
r.block_on(db.drop_column(internal.clone(), "table_name".to_string(), false, None))
.is_err(),
"drop column on an internal table is refused"
);
assert!(
r.block_on(db.rename_column(
internal.clone(),
"table_name".to_string(),
"tn".to_string(),
None
))
.is_err(),
"rename column on an internal table is refused"
);
// `change column` (the simple surface; also the SQL `ALTER COLUMN
// TYPE` decomposition target — ADR-0035 §4f) is refused too: the
// guard lives in `do_change_column_type`. It refuses up-front as
// "no such table" (the sibling-executor contract), not via the
// incidental "no user-facing type metadata" path internal tables
// happen to hit.
let err = r
.block_on(db.change_column_type(
internal.clone(),
"table_name".to_string(),
Type::Int,
ChangeColumnMode::Default,
None,
))
.expect_err("change column type on an internal table is refused");
assert!(
format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal from the internal-table guard, got: {err:?}"
);
// `add constraint` (the simple surface; also the SQL `ALTER TABLE …
// ADD CONSTRAINT` decomposition target — ADR-0035 §4g) is refused:
// the guard lives in `do_add_constraint`.
let err = r
.block_on(db.add_constraint(
internal,
"table_name".to_string(),
Constraint::NotNull,
None,
))
.expect_err("add constraint on an internal table is refused");
assert!(
format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal from the internal-table guard, got: {err:?}"
);
}
#[test]
fn add_relationship_refuses_internal_tables() {
// The guard lives in `do_add_relationship` (ADR-0035 §4g) and covers
// both the parent and the child endpoint — so the simple `add 1:n
// relationship` and the SQL `ALTER TABLE … ADD FOREIGN KEY` (which
// reaches the same executor) cannot touch an internal table.
let (_p, db, _d) = open();
let r = rt();
let internal = "__rdbms_playground_relationships".to_string();
// Internal *parent* — refused up-front.
let err = r
.block_on(db.add_relationship(
None,
internal.clone(),
"name".to_string(),
"C".to_string(),
"x".to_string(),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
))
.expect_err("relationship with an internal parent is refused");
assert!(
format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal (internal parent), got: {err:?}"
);
// Internal *child* — also refused (a real parent exists).
r.block_on(db.sql_create_table(
"P".to_string(),
vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table P (id int primary key)".to_string()),
))
.expect("create P");
let err = r
.block_on(db.add_relationship(
None,
"P".to_string(),
"id".to_string(),
internal,
"x".to_string(),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
))
.expect_err("relationship with an internal child is refused");
assert!(
format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal (internal child), got: {err:?}"
);
}
#[test]
fn drop_column_referenced_by_a_table_check_is_refused() {
let (_p, db, _d) = open();
let r = rt();
make_t_with_check(&db, &r);
// `a` is referenced by the CHECK `a < b` → refused (both surfaces;
// here via the simple `drop column`). The refusal explains why
// (ADR-0035 Amendment 1, gap D).
let msg = r
.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None))
.expect_err("dropping a CHECK-referenced column is refused")
.friendly_message();
assert!(
msg.contains("CHECK constraint refers to"),
"the refusal explains why; got: {msg}"
);
// `c` is not referenced → the drop succeeds.
r.block_on(db.drop_column("T".to_string(), "c".to_string(), false, None))
.expect("dropping an unreferenced column succeeds");
}
/// `T (id int pk, a int, b int, c text)` with a composite UNIQUE (a, b)
/// (ADR-0035 Amendment 1).
fn make_t_with_composite_unique(db: &Database, r: &tokio::runtime::Runtime) {
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
ColumnSpec::new("c", Type::Text),
],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table T (id int primary key, a int, b int, c text)".to_string()),
))
.expect("create T");
r.block_on(db.alter_add_unique(
"T".to_string(),
vec!["a".to_string(), "b".to_string()],
Some("alter table T add unique (a, b)".to_string()),
))
.expect("add composite UNIQUE (a, b)");
}
/// `T (id int pk, email text UNIQUE, note text)` — a single-column UNIQUE
/// (ADR-0029, rides on the column `unique` flag, not `unique_constraints`).
fn make_t_with_single_unique(db: &Database, r: &tokio::runtime::Runtime) {
let mut email = ColumnSpec::new("email", Type::Text);
email.unique = true;
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Int),
email,
ColumnSpec::new("note", Type::Text),
],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table T (id int primary key, email text unique, note text)".to_string()),
))
.expect("create T with a single-column UNIQUE");
}
#[test]
fn drop_column_with_a_single_column_unique_is_refused_with_actionable_message() {
let (_p, db, _d) = open();
let r = rt();
make_t_with_single_unique(&db, &r);
// `email` carries a single-column UNIQUE → the engine refuses the drop.
// Surface a friendly, actionable refusal pointing at the column-level
// drop-constraint (ADR-0029), not the engine's opaque generic refusal
// (ADR-0035 Amendment 1, gap B).
let err = r
.block_on(db.drop_column("T".to_string(), "email".to_string(), false, None))
.expect_err("dropping a single-column-UNIQUE column is refused");
let msg = err.friendly_message();
assert!(
msg.to_lowercase().contains("unique"),
"names the constraint kind; got: {msg}"
);
assert!(
msg.contains("drop constraint unique from T.email"),
"points at the column-level drop-constraint; got: {msg}"
);
// `note` has no constraint → the drop succeeds.
r.block_on(db.drop_column("T".to_string(), "note".to_string(), false, None))
.expect("dropping an unconstrained column succeeds");
}
#[test]
fn drop_column_covered_by_a_composite_unique_is_refused_with_the_derived_name() {
let (_p, db, _d) = open();
let r = rt();
make_t_with_composite_unique(&db, &r);
// `a` participates in UNIQUE (a, b) → refused up-front, naming the
// derived constraint and the drop command (ADR-0035 Amendment 1, F1).
// Without this guard the drop reaches the engine and surfaces an
// unhelpful generic refusal.
let err = r
.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None))
.expect_err("dropping a composite-UNIQUE column is refused");
let msg = err.friendly_message();
assert!(msg.contains("unique_a_b"), "names the derived constraint; got: {msg}");
assert!(
msg.contains("drop constraint unique_a_b"),
"points at the actionable drop command; got: {msg}"
);
// `c` is in no UNIQUE → the drop succeeds.
r.block_on(db.drop_column("T".to_string(), "c".to_string(), false, None))
.expect("dropping an uncovered column succeeds");
}
#[test]
fn rename_column_referenced_by_a_table_check_is_refused() {
let (_p, db, _d) = open();
let r = rt();
make_t_with_check(&db, &r);
// `a` is referenced → refused (without this guard, a native rename
// would silently drift the CHECK metadata and break rebuild). The
// refusal explains why (ADR-0035 Amendment 1, gap D).
let msg = r
.block_on(db.rename_column("T".to_string(), "a".to_string(), "z".to_string(), None))
.expect_err("renaming a CHECK-referenced column is refused")
.friendly_message();
assert!(
msg.contains("CHECK constraint refers to"),
"the refusal explains why; got: {msg}"
);
// `c` is not referenced → rename succeeds.
r.block_on(db.rename_column("T".to_string(), "c".to_string(), "note".to_string(), None))
.expect("renaming an unreferenced column succeeds");
}
/// `T (id int pk, price int, discount int CHECK(discount < price),
/// qty int CHECK(qty >= 0))` — column-level CHECKs (ADR-0035 §4e).
fn make_t_with_column_checks(db: &Database, r: &tokio::runtime::Runtime) {
let mut discount = ColumnSpec::new("discount", Type::Int);
discount.check_sql = Some("discount < price".to_string());
let mut qty = ColumnSpec::new("qty", Type::Int);
qty.check_sql = Some("qty >= 0".to_string());
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("price", Type::Int),
discount,
qty,
],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table T (...)".to_string()),
))
.expect("create T with column CHECKs");
}
#[test]
fn rename_column_with_a_column_level_check_is_refused() {
// A native RENAME would leave the stored column-level CHECK text
// stale (drift → broken rebuild), so it is refused — including a
// column's own self-check.
let (_p, db, _d) = open();
let r = rt();
make_t_with_column_checks(&db, &r);
// `qty`'s own check `qty >= 0` references qty → refused.
assert!(
r.block_on(db.rename_column("T".to_string(), "qty".to_string(), "amount".to_string(), None))
.is_err(),
"renaming a column with its own column-level CHECK is refused"
);
// `price` is referenced by `discount`'s check `discount < price`.
assert!(
r.block_on(db.rename_column("T".to_string(), "price".to_string(), "cost".to_string(), None))
.is_err(),
"renaming a column referenced by another column's CHECK is refused"
);
// `id` is referenced by no CHECK → rename succeeds.
r.block_on(db.rename_column("T".to_string(), "id".to_string(), "pk".to_string(), None))
.expect("renaming an unreferenced column succeeds");
}
#[test]
fn drop_column_referenced_by_another_columns_check_is_refused_but_own_check_drops() {
let (p, db, _d) = open();
let r = rt();
make_t_with_column_checks(&db, &r);
// `price` is referenced by `discount`'s check → refused.
assert!(
r.block_on(db.drop_column("T".to_string(), "price".to_string(), false, None))
.is_err(),
"dropping a column another column's CHECK references is refused"
);
// `qty` has only its OWN check → it drops with the column.
r.block_on(db.drop_column("T".to_string(), "qty".to_string(), false, None))
.expect("dropping a column whose only CHECK is its own succeeds");
// Rebuild still works (the remaining `discount < price` CHECK's
// columns survive).
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild succeeds after dropping the self-checked column");
}
#[test]
fn rebuild_survives_after_dropping_an_unreferenced_column() {
// Guard is not over-broad: a table that carries a CHECK still
// rebuilds after an unrelated column is dropped (the CHECK's
// referenced columns remain).
let (p, db, _d) = open();
let r = rt();
make_t_with_check(&db, &r);
r.block_on(db.drop_column("T".to_string(), "c".to_string(), false, None))
.expect("drop unreferenced column");
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild succeeds — the CHECK still references existing columns");
// The CHECK is intact: it still enforces a < b.
assert!(
r.block_on(db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "a".to_string(), "b".to_string()]),
vec![
rdbms_playground::dsl::Value::Number("1".to_string()),
rdbms_playground::dsl::Value::Number("5".to_string()),
rdbms_playground::dsl::Value::Number("3".to_string()),
],
Some("insert".to_string()),
))
.is_err(),
"CHECK a < b still enforced after the rebuild (5 < 3 is false)"
);
}
+182
View File
@@ -0,0 +1,182 @@
//! ADR-0002 user-facing posture: regression audit.
//!
//! ADR-0002's "User-facing posture" section commits to never
//! exposing the underlying engine's name in user-visible
//! strings. The chosen product (and its idioms — STRICT,
//! PRAGMA, the rusqlite crate) is an implementation detail;
//! students should leave with knowledge of relational concepts,
//! not of one specific RDBMS.
//!
//! This test file exists so that a future change can't silently
//! regress that posture. The strings asserted here are a
//! representative cross-section of user-reachable surfaces:
//!
//! - CLI usage banner (`HELP_TEXT`).
//! - In-app `help` output (`note_help`).
//! - DSL parse-error wording.
//! - Realistic `DbError` payloads carried via
//! `friendly_message()` (the surface the runtime forwards to
//! `AppEvent::DslFailed`).
//!
//! See ADR-0002 §"User-facing posture" for the contract.
//! Code comments and ADR prose are explicitly allowed to name
//! the engine — only user-facing strings are policed.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::app::App;
use rdbms_playground::cli::help_text;
use rdbms_playground::db::{DbError, SqliteErrorKind};
use rdbms_playground::dsl::parse_command;
use rdbms_playground::event::AppEvent;
const FORBIDDEN: &[&str] = &[
// Product names.
"SQLite", "sqlite",
// Crate name.
"rusqlite",
// Engine-specific keywords / idioms.
"STRICT", "PRAGMA",
];
/// Report the first forbidden token found in `s`, with byte
/// offset, so failure output points at exactly what leaked.
fn engine_vocab_leak(s: &str) -> Option<(&'static str, usize)> {
for needle in FORBIDDEN {
if let Some(pos) = s.find(needle) {
return Some((needle, pos));
}
}
None
}
fn assert_clean(label: &str, s: &str) {
if let Some((needle, pos)) = engine_vocab_leak(s) {
panic!(
"ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}"
);
}
}
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) {
app.update(key(KeyCode::Enter));
}
fn collect_output(app: &App) -> String {
app.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn cli_help_text_uses_no_engine_vocabulary() {
assert_clean("CLI help_text()", &help_text());
}
#[test]
fn in_app_help_uses_no_engine_vocabulary() {
let mut app = App::new();
type_str(&mut app, "help");
submit(&mut app);
assert_clean("in-app help", &collect_output(&app));
}
#[test]
fn parse_errors_use_no_engine_vocabulary() {
// A representative set of failing inputs: structural
// (missing colon, wrong keyword), unknown type, and the
// change-column flag conflict. All must produce
// engine-free messages.
let inputs: &[&str] = &[
// structural: column-name-first typo (the parser
// tiny-win recipe from handoff-5).
"change column Tag in Customers: Tag (text)",
// unknown type token.
"create table T with pk id(varchar)",
// mutually exclusive flags on change column.
"change column T: c (int) --force-conversion --dont-convert",
// missing required clause.
"create table T",
// garbage.
"this is not a command",
];
for input in inputs {
let err = parse_command(input)
.expect_err(&format!("expected parse failure for `{input}`"));
let rendered = format!("{err:?}");
assert_clean(&format!("parse error for `{input}`"), &rendered);
}
}
#[test]
fn db_error_friendly_message_uses_no_engine_vocabulary() {
// A representative set of `DbError` payloads, mirroring the
// shapes the runtime actually surfaces via
// `AppEvent::DslFailed { error: DbError::friendly_message }`.
// These cover the three code-constructed variants: Sqlite
// (engine-classified, message comes from rusqlite or our own
// hand-rolled "no such ..."), Unsupported (refusals), and
// InvalidValue (input validation).
let cases: Vec<(&str, DbError)> = vec![
(
"no-such-table",
DbError::Sqlite {
message: "no such table: Customers".to_string(),
kind: SqliteErrorKind::NoSuchTable,
},
),
(
"no-such-column",
DbError::Sqlite {
message: "no such column: Customers.zip".to_string(),
kind: SqliteErrorKind::NoSuchColumn,
},
),
(
"unique-violation",
DbError::Sqlite {
message: "UNIQUE constraint failed: T.id".to_string(),
kind: SqliteErrorKind::UniqueViolation,
},
),
(
"fk-violation",
DbError::Sqlite {
message: "FOREIGN KEY constraint failed".to_string(),
kind: SqliteErrorKind::Other,
},
),
(
"unsupported-refusal",
DbError::Unsupported(
"cannot drop primary-key column `T.id`. \
Drop the table or change the primary key first."
.to_string(),
),
),
(
"invalid-value",
DbError::InvalidValue("expected 3 value(s), got 2".to_string()),
),
];
for (label, err) in cases {
assert_clean(label, &err.friendly_message());
}
}
+694
View File
@@ -0,0 +1,694 @@
//! Integration tests for `runtime::enrich_dsl_failure`
//! (ADR-0019 §6).
//!
//! Each test:
//! 1. Bootstraps a real `Database` (in-memory).
//! 2. Constructs the schema/data needed to trigger one
//! class of engine error.
//! 3. Provokes the failure through the public Database API,
//! capturing the resulting `DbError`.
//! 4. Calls `enrich_dsl_failure` and asserts the
//! `FailureContext` carries the schema-resolved facts a
//! learner would expect to see in the rendered error.
//!
//! Pinpoint diagnostic-table presence is verified for the
//! UNIQUE INSERT case (the most pedagogically valuable
//! pinpoint today).
use tokio::runtime::Runtime;
use rdbms_playground::db::{Database, DbError, SqliteErrorKind};
use rdbms_playground::dsl::{
action::ReferentialAction, ColumnSpec, Command, RowFilter, Type, Value,
};
use rdbms_playground::dsl::parser::parse_command;
use rdbms_playground::runtime::enrich_dsl_failure;
fn rt() -> Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
fn db() -> Database {
Database::open(":memory:").expect("open in-memory db")
}
// ---- UNIQUE -----------------------------------------------------
#[test]
fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
let db = db();
rt().block_on(async {
// Create a table with a serial PK; insert a row; insert
// again with the same PK value to trigger UNIQUE.
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Int),
ColumnSpec::new("name".to_string(), Type::Text),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())],
None,
)
.await
.unwrap();
// Second insert with the same PK — UNIQUE violation.
let cmd = Command::Insert {
table: "Customers".to_string(),
columns: Some(vec!["id".to_string(), "name".to_string()]),
values: vec![
Value::Number("5".to_string()),
Value::Text("Bob".to_string()),
],
};
let err = db
.insert(
"Customers".to_string(),
Some(vec!["id".to_string(), "name".to_string()]),
vec![
Value::Number("5".to_string()),
Value::Text("Bob".to_string()),
],
None,
)
.await
.unwrap_err();
assert!(matches!(
err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
));
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.table.as_deref(), Some("Customers"));
assert_eq!(facts.column.as_deref(), Some("id"));
assert_eq!(facts.value.as_deref(), Some("5"));
// Pinpoint: existing row with id=5 should be present.
let table = facts.diagnostic_table.expect("UNIQUE pinpoint expected");
assert_eq!(table.headers, vec!["id".to_string(), "name".to_string()]);
assert_eq!(table.rows.len(), 1);
assert_eq!(table.rows[0][0], "5");
assert_eq!(table.rows[0][1], "Alice");
});
}
#[test]
fn enrich_unique_insert_natural_order_short_form_resolves_value_via_schema() {
// `insert into T (1)` — natural-order short form, the
// helper falls back to schema-driven lookup.
let db = db();
rt().block_on(async {
db.create_table(
"thing".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.insert(
"thing".to_string(),
None,
vec![Value::Number("1".to_string())],
None,
)
.await
.unwrap();
let cmd = Command::Insert {
table: "thing".to_string(),
columns: None,
values: vec![Value::Number("1".to_string())],
};
let err = db
.insert(
"thing".to_string(),
None,
vec![Value::Number("1".to_string())],
None,
)
.await
.unwrap_err();
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.value.as_deref(), Some("1"));
assert!(facts.diagnostic_table.is_some());
});
}
#[test]
fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
// ADR-0036 Phase 1 follow-up: a no-column-list (natural-order) SQL
// INSERT also names the offending value in a constraint error. The
// schema maps each VALUES position to its column, in declaration
// order — ALL columns (advanced-mode Form B auto-fills nothing, so
// the user supplies a value for every column).
let db = db();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Int),
ColumnSpec::new("name".to_string(), Type::Text),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())],
None,
)
.await
.unwrap();
// Natural-order SQL insert (no column list) collides on id=5.
let input = "insert into Customers values (5, 'Bob')";
let cmd = parse_command(input).expect("parses as advanced-mode SQL insert");
let Command::SqlInsert {
sql,
target_table,
listed_columns,
row_source,
returning,
literal_rows,
} = cmd.clone()
else {
panic!("expected Command::SqlInsert, got {cmd:?}");
};
assert!(listed_columns.is_empty(), "natural-order form has no column list");
let err = db
.run_sql_insert_with_literals(
sql,
None,
target_table,
listed_columns,
row_source,
returning,
literal_rows,
)
.await
.unwrap_err();
assert!(matches!(
err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
));
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.column.as_deref(), Some("id"));
assert_eq!(
facts.value.as_deref(),
Some("5"),
"the offending value is named even without an explicit column list",
);
});
}
#[test]
fn enrich_unique_update_resolves_value_from_assignments() {
let db = db();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Int),
ColumnSpec::new("name".to_string(), Type::Text),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
None,
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
None,
)
.await
.unwrap();
// Try to update Bob's id to 1 — collides with Alice.
let cmd = Command::Update {
table: "Customers".to_string(),
assignments: vec![("id".to_string(), Value::Number("1".to_string()))],
filter: RowFilter::eq("name", Value::Text("Bob".to_string())),
};
let err = db
.update(
"Customers".to_string(),
vec![("id".to_string(), Value::Number("1".to_string()))],
RowFilter::eq("name", Value::Text("Bob".to_string())),
None,
)
.await
.unwrap_err();
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.column.as_deref(), Some("id"));
assert_eq!(facts.value.as_deref(), Some("1"));
});
}
#[test]
fn enrich_unique_sql_update_resolves_value_from_set_literals() {
// ADR-0036 Phase 2: an advanced-mode SQL `UPDATE` now retains its
// `SET` literals, so a UNIQUE violation names the offending value —
// closing the error-value gap for advanced mode, mirroring the DSL
// `Update` case above. The value flows from the parse-captured
// `set_literals` through `user_value_for_column`.
let db = db();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Int),
ColumnSpec::new("name".to_string(), Type::Text),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
None,
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
None,
)
.await
.unwrap();
// Advanced-mode SQL: set Bob's id to 1 — collides with Alice.
let input = "update Customers set id = 1 where name = 'Bob'";
let cmd = parse_command(input).expect("parses as advanced-mode SQL update");
let Command::SqlUpdate {
sql,
target_table,
returning,
set_literals,
} = cmd.clone()
else {
panic!("expected Command::SqlUpdate, got {cmd:?}");
};
// The literal `1` is a valid int, so Phase-2 validation passes and
// the engine-level UNIQUE violation is what surfaces.
let err = db
.run_sql_update_with_literals(sql, None, target_table, returning, set_literals)
.await
.unwrap_err();
assert!(matches!(
err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
));
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.column.as_deref(), Some("id"));
assert_eq!(
facts.value.as_deref(),
Some("1"),
"the offending SET value is named (from set_literals)"
);
});
}
// ---- NOT NULL ---------------------------------------------------
#[test]
fn enrich_not_null_resolves_table_and_column() {
let db = db();
rt().block_on(async {
// Create a table with a NOT NULL column. The current
// schema_to_ddl emits NOT NULL on PK columns; make
// a non-PK column NOT NULL via a multi-column PK
// setup, then the second column is NOT NULL because
// it's part of the PK.
// (We're testing the enrichment, not the constraint
// emission — even a PK NOT NULL works.)
db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("a".to_string(), Type::Int),
ColumnSpec::new("b".to_string(), Type::Text),
],
vec!["a".to_string(), "b".to_string()],
None,
)
.await
.unwrap();
// Try to insert with NULL for the second PK column.
let cmd = Command::Insert {
table: "T".to_string(),
columns: Some(vec!["a".to_string(), "b".to_string()]),
values: vec![Value::Number("1".to_string()), Value::Null],
};
let err = db
.insert(
"T".to_string(),
Some(vec!["a".to_string(), "b".to_string()]),
vec![Value::Number("1".to_string()), Value::Null],
None,
)
.await
.unwrap_err();
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.table.as_deref(), Some("T"));
assert_eq!(facts.column.as_deref(), Some("b"));
// Per design: no value field for NOT NULL (the value is null).
assert!(facts.value.is_none());
// No pinpoint for NOT NULL.
assert!(facts.diagnostic_table.is_none());
});
}
// ---- FOREIGN KEY (child-side, INSERT) ---------------------------
#[test]
fn enrich_fk_insert_resolves_parent_table_column_and_value() {
let db = db();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Int),
ColumnSpec::new("CustId".to_string(), Type::Int),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
// Insert into Orders with a CustId that has no parent.
let cmd = Command::Insert {
table: "Orders".to_string(),
columns: Some(vec!["id".to_string(), "CustId".to_string()]),
values: vec![
Value::Number("1".to_string()),
Value::Number("999".to_string()),
],
};
let err = db
.insert(
"Orders".to_string(),
Some(vec!["id".to_string(), "CustId".to_string()]),
vec![
Value::Number("1".to_string()),
Value::Number("999".to_string()),
],
None,
)
.await
.unwrap_err();
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.table.as_deref(), Some("Orders"));
assert_eq!(facts.column.as_deref(), Some("CustId"));
assert_eq!(facts.parent_table.as_deref(), Some("Customers"));
assert_eq!(facts.parent_column.as_deref(), Some("id"));
assert_eq!(facts.value.as_deref(), Some("999"));
// FK pinpoint not implemented in v1.
assert!(facts.diagnostic_table.is_none());
});
}
#[test]
fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() {
// Regression: `insert into Orders values (4, 11.99)` —
// natural-order multi-value INSERT, no explicit columns,
// and the schema has a serial PK that gets auto-skipped.
// Enrichment must still resolve parent_table /
// parent_column / value via the schema-aware lookup.
let db = db();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("CustId".to_string(), Type::Int),
ColumnSpec::new("Total".to_string(), Type::Real),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
// Natural-order: serial PK auto-fills, so positional
// values map to (CustId, Total). CustId=4 has no
// matching parent → FK violation.
let cmd = Command::Insert {
table: "Orders".to_string(),
columns: None,
values: vec![
Value::Number("4".to_string()),
Value::Number("11.99".to_string()),
],
};
let err = db
.insert(
"Orders".to_string(),
None,
vec![
Value::Number("4".to_string()),
Value::Number("11.99".to_string()),
],
None,
)
.await
.unwrap_err();
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.parent_table.as_deref(), Some("Customers"));
assert_eq!(facts.parent_column.as_deref(), Some("id"));
assert_eq!(
facts.value.as_deref(),
Some("4"),
"natural-order with serial PK skip should map values[0] to CustId"
);
});
}
// ---- FOREIGN KEY (parent-side, DELETE) --------------------------
#[test]
fn enrich_fk_delete_resolves_child_table() {
let db = db();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Int),
ColumnSpec::new("CustId".to_string(), Type::Int),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("1".to_string())],
None,
)
.await
.unwrap();
db.insert(
"Orders".to_string(),
None,
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
None,
)
.await
.unwrap();
// Delete the parent that has children — engine refuses.
let cmd = Command::Delete {
table: "Customers".to_string(),
filter: RowFilter::eq("id", Value::Number("1".to_string())),
};
let err = db
.delete(
"Customers".to_string(),
RowFilter::eq("id", Value::Number("1".to_string())),
None,
)
.await
.unwrap_err();
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.table.as_deref(), Some("Customers"));
assert_eq!(facts.child_table.as_deref(), Some("Orders"));
});
}
// ---- CHECK (ADR-0029 §10) ---------------------------------------
#[test]
fn enrich_check_insert_resolves_table_column_value_and_rule() {
let db = db();
rt().block_on(async {
// `Scores(id serial pk)` plus a non-PK `score` column
// carrying `CHECK (score >= 0)`.
db.create_table(
"Scores".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
let score_spec = match parse_command(
"create table __probe with pk score(int) check (score >= 0)",
)
.expect("probe create parses")
{
Command::CreateTable { columns, .. } => {
columns.into_iter().next().expect("one column")
}
other => panic!("expected CreateTable, got {other:?}"),
};
db.add_column("Scores".to_string(), score_spec, None)
.await
.unwrap();
// An insert that violates the CHECK.
let cmd = Command::Insert {
table: "Scores".to_string(),
columns: Some(vec!["score".to_string()]),
values: vec![Value::Number("-5".to_string())],
};
let err = db
.insert(
"Scores".to_string(),
Some(vec!["score".to_string()]),
vec![Value::Number("-5".to_string())],
None,
)
.await
.unwrap_err();
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.table.as_deref(), Some("Scores"));
assert_eq!(facts.column.as_deref(), Some("score"));
assert_eq!(facts.value.as_deref(), Some("-5"));
let rule = facts.check_rule.expect("the CHECK rule is resolved");
assert!(
rule.contains("score"),
"the resolved rule names the column: {rule}",
);
});
}
// ---- non-engine error → empty enrichment ------------------------
#[test]
fn enrich_unsupported_returns_default_facts() {
let db = db();
rt().block_on(async {
let err = DbError::Unsupported("nope".to_string());
let cmd = Command::DropTable { name: "X".to_string() };
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert!(facts.table.is_none());
assert!(facts.column.is_none());
assert!(facts.value.is_none());
assert!(facts.parent_table.is_none());
assert!(facts.child_table.is_none());
assert!(facts.diagnostic_table.is_none());
});
}
+446
View File
@@ -0,0 +1,446 @@
//! Iteration-2 integration tests: per-command write-through
//! to `project.yaml`, `data/<table>.csv`, and `history.log`
//! (ADR-0015 §3-§6).
//!
//! These tests exercise the full path from
//! `Database::open_with_persistence` through a successful
//! command into the on-disk text targets. They use
//! `Database::open_with_persistence(...)` so the worker
//! thread runs the persistence callbacks the runtime would.
use std::fs;
use std::path::Path;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
self, DATA_DIR, HISTORY_LOG, PROJECT_YAML,
};
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
/// Open a project under a fresh data root and return the
/// `Database` (with persistence wired) plus the path so the
/// test can inspect on-disk state. The project is held alive
/// implicitly via the leaked `TempDir` returned alongside.
fn open_project(
data: &tempfile::TempDir,
) -> (project::Project, Database, std::path::PathBuf) {
let project = project::open_or_create(None, Some(data.path())).expect("open project");
let path = project.path().to_path_buf();
let persistence = Persistence::new(path.clone());
let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence");
(project, db, path)
}
fn read_history(project_path: &Path) -> Vec<String> {
let body = fs::read_to_string(project_path.join(HISTORY_LOG)).unwrap_or_default();
body.lines().map(str::to_string).collect()
}
fn read_yaml(project_path: &Path) -> String {
fs::read_to_string(project_path.join(PROJECT_YAML)).expect("project.yaml")
}
fn read_csv(project_path: &Path, table: &str) -> Option<String> {
fs::read_to_string(project_path.join(DATA_DIR).join(format!("{table}.csv"))).ok()
}
#[test]
fn create_table_writes_yaml_and_history() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("Name".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
});
let yaml = read_yaml(&path);
assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}");
assert!(yaml.contains("primary_key: [id]"), "yaml: {yaml}");
assert!(yaml.contains("type: serial"), "yaml: {yaml}");
assert!(yaml.contains("type: text"), "yaml: {yaml}");
let history = read_history(&path);
assert_eq!(history.len(), 1, "expected one history line; got {history:?}");
assert!(history[0].ends_with("|ok|create table Customers with pk id(serial)"));
}
#[test]
fn insert_writes_csv_and_history() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("Name".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Text("Alice".to_string())],
Some("insert into Customers ('Alice')".to_string()),
)
.await
.unwrap();
});
let csv = read_csv(&path, "Customers").expect("Customers.csv missing");
let lines: Vec<&str> = csv.trim_end().lines().collect();
assert_eq!(lines[0], "id,Name");
assert_eq!(lines[1], "1,Alice");
let history = read_history(&path);
assert!(
history.iter().any(|l| l.ends_with("|ok|insert into Customers ('Alice')")),
"history missing insert: {history:?}",
);
}
#[test]
fn drop_table_removes_its_csv() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
Some(vec!["id".to_string()]),
vec![Value::Number("42".to_string())],
Some("insert into Customers (id) values (42)".to_string()),
)
.await
.unwrap();
// The CSV exists before drop.
assert!(read_csv(&path, "Customers").is_some());
db.drop_table(
"Customers".to_string(),
Some("drop table Customers".to_string()),
)
.await
.unwrap();
});
assert!(read_csv(&path, "Customers").is_none(), "CSV should be deleted");
let yaml = read_yaml(&path);
assert!(!yaml.contains("- name: Customers"), "table should be gone from yaml:\n{yaml}");
}
#[test]
fn delete_with_cascade_rewrites_both_csvs() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("CustId".to_string(), Type::Int),
],
vec!["id".to_string()],
Some("create table Orders with pk id(serial), CustId(int)".to_string()),
)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
Some(
"add 1:n relationship from Customers.id to Orders.CustId on delete cascade"
.to_string(),
),
)
.await
.unwrap();
// Customers has only a serial PK; long-form INSERT with
// an explicit id keeps the test independent of short-form
// semantics for "all-auto-generated" tables.
db.insert(
"Customers".to_string(),
Some(vec!["id".to_string()]),
vec![Value::Number("1".to_string())],
Some("insert into Customers (id) values (1)".to_string()),
)
.await
.unwrap();
db.insert(
"Orders".to_string(),
Some(vec!["CustId".to_string()]),
vec![Value::Number("1".to_string())],
Some("insert into Orders (CustId) values (1)".to_string()),
)
.await
.unwrap();
// Cascade delete from Customers should also clean Orders.
let result = db
.delete(
"Customers".to_string(),
RowFilter::eq("id", Value::Number("1".to_string())),
Some("delete from Customers where id=1".to_string()),
)
.await
.unwrap();
assert_eq!(result.rows_affected, 1);
});
// Both CSVs should be gone after the cascade leaves both
// tables empty: empty table -> no CSV (the rule from
// Persistence::write_table_data; see ADR-0015 §4 commentary).
assert!(
read_csv(&path, "Customers").is_none(),
"Customers.csv should be gone after cascade leaves it empty",
);
assert!(
read_csv(&path, "Orders").is_none(),
"Orders.csv should be gone after cascade leaves it empty",
);
}
#[test]
fn create_table_does_not_write_csv_for_empty_table() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("Name".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
});
// Schema landed in YAML.
let yaml = read_yaml(&path);
assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}");
// ...but no CSV until there's data.
assert!(
read_csv(&path, "Customers").is_none(),
"no CSV should exist for an empty table",
);
}
#[test]
fn delete_all_rows_removes_csv() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("Name".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Text("Alice".to_string())],
Some("insert into Customers ('Alice')".to_string()),
)
.await
.unwrap();
// CSV exists once there's data.
assert!(read_csv(&path, "Customers").is_some());
db.delete(
"Customers".to_string(),
RowFilter::AllRows,
Some("delete from Customers --all-rows".to_string()),
)
.await
.unwrap();
});
assert!(
read_csv(&path, "Customers").is_none(),
"CSV should be removed when the table becomes empty",
);
}
#[test]
fn show_table_appends_history_only() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
let yaml_before = read_yaml(&path);
db.describe_table(
"Customers".to_string(),
Some("show table Customers".to_string()),
)
.await
.unwrap();
let yaml_after = read_yaml(&path);
// YAML body did not change for a read-only command.
assert_eq!(yaml_before, yaml_after);
});
let history = read_history(&path);
assert!(
history.iter().any(|l| l.ends_with("|ok|show table Customers")),
"history missing show entry: {history:?}",
);
}
#[test]
fn failed_command_does_not_append_history_or_change_yaml() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
let yaml_before = read_yaml(&path);
// Same name again — should fail.
let err = db
.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.expect_err("must fail");
let _ = err;
let yaml_after = read_yaml(&path);
assert_eq!(yaml_before, yaml_after, "failed cmd must not change yaml");
});
let history = read_history(&path);
// Only the first (successful) create_table should have logged.
let create_count = history
.iter()
.filter(|l| l.contains("|ok|create table Customers"))
.count();
assert_eq!(create_count, 1, "expected exactly one logged create; got: {history:?}");
}
#[test]
fn project_yaml_carries_relationship_after_add() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("CustId".to_string(), Type::Int),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
Some(
"add 1:n relationship from Customers.id to Orders.CustId on delete cascade"
.to_string(),
),
)
.await
.unwrap();
});
let yaml = read_yaml(&path);
assert!(yaml.contains("- name: Customers_id_to_Orders_CustId"), "yaml: {yaml}");
assert!(yaml.contains("on_delete: cascade"), "yaml: {yaml}");
assert!(yaml.contains("on_update: no_action"), "yaml: {yaml}");
}
+459
View File
@@ -0,0 +1,459 @@
//! Iteration-3 integration tests: rebuild from text on a
//! missing `.db` (ADR-0015 §7).
//!
//! These tests:
//!
//! 1. Build a populated project via Iteration 2's write-through
//! path so YAML and CSVs end up on disk.
//! 2. Delete `playground.db`.
//! 3. Re-open the project and call `rebuild_from_text`.
//! 4. Verify the schema, relationships, and row data round-trip.
use std::fs;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{self, PLAYGROUND_DB};
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
#[test]
fn rebuild_restores_schema_only_project() {
let data = tempdir();
// Phase 1: populate via write-through.
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("Name".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
path
};
// Phase 2: delete the .db so the next open triggers rebuild.
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
// Phase 3: reopen and rebuild.
let project = project::Project::open(&project_path).unwrap();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None)
.await
.expect("rebuild");
});
// Phase 4: confirm Customers exists with the right shape.
let desc = rt()
.block_on(async { db.describe_table("Customers".to_string(), None).await })
.expect("describe_table");
assert_eq!(desc.name, "Customers");
let cols: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(cols, vec!["id", "Name"]);
}
#[test]
fn rebuild_restores_rows_from_csv() {
let data = tempdir();
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("Name".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Text("Alice".to_string())],
Some("insert".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Text("Bob".to_string())],
Some("insert".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
path
};
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
let project = project::Project::open(&project_path).unwrap();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None)
.await
.expect("rebuild");
});
let rows = rt()
.block_on(async { db.query_data("Customers".to_string(), None, None, None).await })
.expect("query_data");
assert_eq!(rows.rows.len(), 2);
let names: Vec<Option<String>> = rows.rows.iter().map(|r| r[1].clone()).collect();
assert_eq!(names[0].as_deref(), Some("Alice"));
assert_eq!(names[1].as_deref(), Some("Bob"));
}
#[test]
fn rebuild_restores_relationships_and_cascade_behaviour() {
let data = tempdir();
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
Some("create".to_string()),
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("CustId".to_string(), Type::Int),
],
vec!["id".to_string()],
Some("create".to_string()),
)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
Some("rel".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
Some(vec!["id".to_string()]),
vec![Value::Number("1".to_string())],
Some("insert".to_string()),
)
.await
.unwrap();
db.insert(
"Orders".to_string(),
Some(vec!["CustId".to_string()]),
vec![Value::Number("1".to_string())],
Some("insert".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
path
};
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
let project = project::Project::open(&project_path).unwrap();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None)
.await
.expect("rebuild");
});
// Relationship is back: cascade-delete from Customers
// should also clean Orders.
let result = rt()
.block_on(async {
db.delete(
"Customers".to_string(),
rdbms_playground::dsl::RowFilter::AllRows,
Some("delete".to_string()),
)
.await
})
.expect("delete");
assert_eq!(result.rows_affected, 1);
assert_eq!(result.cascade.len(), 1, "expected one cascade entry: {result:?}");
assert_eq!(result.cascade[0].child_table, "Orders");
}
#[test]
fn rebuild_reports_fatal_error_on_bad_csv_row() {
let data = tempdir();
// Create a project, populate, then corrupt the CSV.
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async {
db.create_table(
"Numbers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("n".to_string(), Type::Int),
],
vec!["id".to_string()],
Some("create".to_string()),
)
.await
.unwrap();
db.insert(
"Numbers".to_string(),
Some(vec!["n".to_string()]),
vec![Value::Number("1".to_string())],
Some("insert".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
path
};
// Hand-corrupt the CSV: replace the int with a non-number.
let csv_path = project_path.join("data").join("Numbers.csv");
let body = fs::read_to_string(&csv_path).unwrap();
let corrupt = body.replace(",1\n", ",not-a-number\n");
fs::write(&csv_path, corrupt).unwrap();
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
let project = project::Project::open(&project_path).unwrap();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.unwrap();
let err = rt()
.block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None).await
})
.expect_err("must fail with row-level error");
let msg = format!("{err}");
assert!(msg.contains("row 2"), "msg should name the row: {msg}");
assert!(msg.contains("Numbers"), "msg should name the table: {msg}");
assert!(msg.contains("integer"), "msg should explain the type mismatch: {msg}");
}
#[test]
fn rebuild_preserves_created_at_from_yaml() {
let data = tempdir();
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async {
db.create_table(
"T".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
Some("create".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
path
};
// Substitute a recognizable timestamp into project.yaml.
let yaml_path = project_path.join("project.yaml");
let body = fs::read_to_string(&yaml_path).unwrap();
let edited = body
.lines()
.map(|l| {
if l.trim_start().starts_with("created_at:") {
" created_at: 2020-01-02T03:04:05Z".to_string()
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
fs::write(&yaml_path, format!("{edited}\n")).unwrap();
// Delete the .db, rebuild from text.
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
let project = project::Project::open(&project_path).unwrap();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None)
.await
.expect("rebuild");
});
// Trigger any successful command so project.yaml is
// rewritten from the now-rebuilt db state.
rt().block_on(async {
db.describe_table("T".to_string(), Some("show table T".to_string()))
.await
.unwrap();
// describe is read-only; force a rewrite by adding a column.
db.add_column(
"T".to_string(),
ColumnSpec::new("Note", Type::Text),
Some("add column".to_string()),
)
.await
.unwrap();
});
let final_yaml = fs::read_to_string(&yaml_path).unwrap();
assert!(
final_yaml.contains("created_at: 2020-01-02T03:04:05Z"),
"yaml should preserve the edited created_at:\n{final_yaml}",
);
}
/// Indexes round-trip through `project.yaml` and a full rebuild
/// (ADR-0025): create an index, drop the `.db`, rebuild from
/// text, confirm the index is back.
#[test]
fn rebuild_restores_indexes() {
let data = tempdir();
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("Email".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
db.add_index(
Some("idx_email".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
Some("add index as idx_email on Customers (Email)".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
path
};
// The index must be recorded in project.yaml — the `.db` is
// a derived artifact and gets discarded next.
let yaml = fs::read_to_string(project_path.join(project::PROJECT_YAML)).unwrap();
assert!(yaml.contains("idx_email"), "yaml should record the index:\n{yaml}");
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
let project = project::Project::open(&project_path).unwrap();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None)
.await
.expect("rebuild");
});
let desc = rt()
.block_on(async { db.describe_table("Customers".to_string(), None).await })
.expect("describe_table");
assert_eq!(desc.indexes.len(), 1, "index should survive rebuild");
assert_eq!(desc.indexes[0].name, "idx_email");
assert_eq!(desc.indexes[0].columns, vec!["Email".to_string()]);
}
+187
View File
@@ -0,0 +1,187 @@
//! Iteration-4a integration tests: the explicit `rebuild`
//! app-level command (ADR-0015 §7, §11).
//!
//! Covers the App-level dispatch (typing `rebuild` opens the
//! confirmation modal) and the worker-level wipe-and-rebuild
//! against a populated database. The runtime's spawn glue
//! is exercised manually here since we don't boot a Tokio
//! event loop in tests; we drive `Database::rebuild_from_text`
//! directly to verify it works on a populated db.
use std::fs;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::{App, Modal, RebuildConfirmModal};
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type, Value};
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
#[test]
fn typing_rebuild_emits_prepare_action() {
let mut app = App::new();
type_str(&mut app, "rebuild");
let actions = submit(&mut app);
assert_eq!(actions, vec![Action::PrepareRebuild]);
// No modal yet — the runtime still has to compute the
// summary and post `RebuildPrepared` back.
assert!(app.modal.is_none());
}
#[test]
fn rebuild_prepared_event_opens_modal_with_summary() {
let mut app = App::new();
app.update(AppEvent::RebuildPrepared {
summary: "3 tables and 47 rows will be reconstructed".to_string(),
});
match app.modal.as_ref() {
Some(Modal::RebuildConfirm(RebuildConfirmModal { summary })) => {
assert!(summary.contains("3 tables"));
}
other => panic!("expected RebuildConfirm modal, got {other:?}"),
}
}
#[test]
fn modal_y_emits_rebuild_action_and_closes() {
let mut app = App::new();
app.update(AppEvent::RebuildPrepared {
summary: "summary".to_string(),
});
let actions = app.update(key(KeyCode::Char('Y')));
assert_eq!(actions.len(), 1);
let Action::Rebuild { source } = &actions[0] else {
panic!("expected Rebuild action, got {:?}", actions[0]);
};
assert_eq!(source, "rebuild");
assert!(app.modal.is_none(), "modal should close on confirm");
}
#[test]
fn modal_n_or_esc_dismisses_without_action() {
for code in [KeyCode::Char('N'), KeyCode::Esc] {
let mut app = App::new();
app.update(AppEvent::RebuildPrepared {
summary: "summary".to_string(),
});
let actions = app.update(key(code));
assert!(actions.is_empty(), "no actions emitted on dismiss");
assert!(app.modal.is_none(), "modal should close on dismiss");
}
}
#[test]
fn modal_swallows_unrelated_keys() {
let mut app = App::new();
app.update(AppEvent::RebuildPrepared {
summary: "summary".to_string(),
});
// A regular character key should not type into the input
// field while the modal is up.
app.update(key(KeyCode::Char('x')));
assert!(app.input.is_empty(), "modal should swallow key input");
assert!(app.modal.is_some(), "modal still active after unrelated key");
}
#[test]
fn rebuild_against_populated_db_wipes_and_reloads() {
let data = tempdir();
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("Name".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Text("Alice".to_string())],
Some("insert".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
path
};
// Hand-edit the CSV to introduce a different row content.
// Rebuild should pick up the edited content.
let csv_path = project_path.join("data").join("Customers.csv");
let edited = fs::read_to_string(&csv_path).unwrap().replace("Alice", "Edna");
fs::write(&csv_path, edited).unwrap();
// Reopen with persistence (the .db still exists but has
// "Alice"). Run rebuild — it should wipe and reload.
let project = project::Project::open(&project_path).unwrap();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string()))
.await
.expect("rebuild");
});
let rows = rt()
.block_on(async { db.query_data("Customers".to_string(), None, None, None).await })
.unwrap();
assert_eq!(rows.rows.len(), 1);
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
// history.log should contain the rebuild entry.
let history = fs::read_to_string(project_path.join("history.log")).unwrap();
assert!(
history.lines().any(|l| l.ends_with("|ok|rebuild")),
"history.log missing rebuild entry:\n{history}",
);
}
+575
View File
@@ -0,0 +1,575 @@
//! Iteration-4b integration tests: `save` / `save as` /
//! `new` / `load` (ADR-0015 §11) and the modal infrastructure
//! that hosts their dialogs.
//!
//! Modal flows are tested at the App layer (synthetic events).
//! Filesystem effects (recursive copy, project switching at
//! runtime) are tested through the public `project` and
//! `runtime` helpers without booting a Tokio loop.
use std::fs;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::{
App, LoadPickerEntry, LoadPickerModal, LoadPickerSubMode, Modal, PathEntryModal,
PathEntryPurpose,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
self, Project, ProjectKind, copy_project, safely_delete_temp_project,
};
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
#[test]
fn help_command_lists_supported_commands() {
let mut app = App::new();
type_str(&mut app, "help");
let actions = submit(&mut app);
assert!(actions.is_empty());
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
for keyword in ["quit", "rebuild", "save", "load", "new", "create table"] {
assert!(
body.contains(keyword),
"help output missing `{keyword}`:\n{body}",
);
}
}
#[test]
fn help_describes_auto_generated_type_behaviour() {
// ADR-0017 / ADR-0018: the in-app help must surface the
// auto-fill contract for serial / shortid columns and the
// change-column conversion flags. Captured as a regression
// check so a future help-text edit doesn't silently drop the
// pedagogical lines.
let mut app = App::new();
type_str(&mut app, "help");
submit(&mut app);
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
for keyword in [
"--force-conversion",
"--dont-convert",
"Auto-generated types",
"auto-filled",
"UNIQUE",
] {
assert!(
body.contains(keyword),
"help output missing `{keyword}`:\n{body}",
);
}
}
#[test]
fn save_on_temp_opens_path_entry_modal() {
let mut app = App::new();
app.project_is_temp = true;
type_str(&mut app, "save");
let actions = submit(&mut app);
assert!(actions.is_empty());
match app.modal.as_ref() {
Some(Modal::PathEntry(PathEntryModal { purpose, title, .. })) => {
assert_eq!(*purpose, PathEntryPurpose::SaveAs);
assert_eq!(title, "Save");
}
other => panic!("expected PathEntry modal, got {other:?}"),
}
}
#[test]
fn save_on_named_project_emits_hint_and_no_modal() {
let mut app = App::new();
app.project_is_temp = false;
type_str(&mut app, "save");
let actions = submit(&mut app);
assert!(actions.is_empty());
assert!(app.modal.is_none());
let last = app.output.iter().last().expect("an output line");
assert!(
last.text.contains("already auto-saved"),
"got: {}",
last.text,
);
}
#[test]
fn save_as_always_opens_path_entry_modal() {
let mut app = App::new();
app.project_is_temp = false;
type_str(&mut app, "save as");
let actions = submit(&mut app);
assert!(actions.is_empty());
match app.modal.as_ref() {
Some(Modal::PathEntry(PathEntryModal { purpose, title, .. })) => {
assert_eq!(*purpose, PathEntryPurpose::SaveAs);
assert_eq!(title, "Save as");
}
other => panic!("expected PathEntry modal, got {other:?}"),
}
}
#[test]
fn new_command_emits_action() {
let mut app = App::new();
type_str(&mut app, "new");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::NewProject {
source: "new".to_string()
}]
);
}
#[test]
fn load_command_emits_open_picker_action() {
let mut app = App::new();
type_str(&mut app, "load");
let actions = submit(&mut app);
assert_eq!(actions, vec![Action::OpenLoadPicker]);
}
#[test]
fn path_entry_modal_typing_and_enter_emits_save_as() {
let mut app = App::new();
app.project_is_temp = true;
type_str(&mut app, "save as");
submit(&mut app);
// Type a name and press Enter.
type_str(&mut app, "MyOrders");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
let Action::SaveAs { target, source } = &actions[0] else {
panic!("expected SaveAs, got {:?}", actions[0]);
};
assert_eq!(target, "MyOrders");
assert_eq!(source, "save as");
assert!(app.modal.is_none());
}
#[test]
fn path_entry_modal_esc_cancels() {
let mut app = App::new();
app.project_is_temp = true;
type_str(&mut app, "save as");
submit(&mut app);
type_str(&mut app, "TheBest");
let actions = app.update(key(KeyCode::Esc));
assert!(actions.is_empty());
assert!(app.modal.is_none());
}
#[test]
fn path_entry_modal_backspace_edits_input() {
let mut app = App::new();
app.project_is_temp = true;
type_str(&mut app, "save as");
submit(&mut app);
type_str(&mut app, "abc");
app.update(key(KeyCode::Backspace));
match app.modal.as_ref() {
Some(Modal::PathEntry(m)) => assert_eq!(m.input, "ab"),
other => panic!("expected PathEntry, got {other:?}"),
}
}
#[test]
fn load_picker_renders_entries_and_navigates() {
let mut app = App::new();
app.update(AppEvent::LoadPickerReady {
entries: vec![
LoadPickerEntry {
display_name: "Newer".to_string(),
modified: "2026-05-07 14:30".to_string(),
path: std::path::PathBuf::from("/tmp/newer"),
is_temp: true,
},
LoadPickerEntry {
display_name: "Older".to_string(),
modified: "2026-05-01 09:15".to_string(),
path: std::path::PathBuf::from("/tmp/older"),
is_temp: false,
},
],
});
let Some(Modal::LoadPicker(picker)) = app.modal.clone() else {
panic!("expected LoadPicker modal");
};
assert_eq!(picker.selected, 0);
assert!(matches!(picker.sub_mode, LoadPickerSubMode::List));
// Down → select index 1.
app.update(key(KeyCode::Down));
let Some(Modal::LoadPicker(picker)) = app.modal.clone() else {
panic!("expected LoadPicker still active");
};
assert_eq!(picker.selected, 1);
// Enter → emit LoadProject for entries[1].
let actions = app.update(key(KeyCode::Enter));
assert_eq!(actions.len(), 1);
let Action::LoadProject { path, source } = &actions[0] else {
panic!("expected LoadProject, got {:?}", actions[0]);
};
assert_eq!(path, std::path::Path::new("/tmp/older"));
assert_eq!(source, "load");
}
#[test]
fn load_picker_b_enters_path_entry_submode() {
let mut app = App::new();
app.update(AppEvent::LoadPickerReady {
entries: vec![LoadPickerEntry {
display_name: "Foo".to_string(),
modified: "2026-05-07 14:30".to_string(),
path: std::path::PathBuf::from("/tmp/foo"),
is_temp: true,
}],
});
app.update(key(KeyCode::Char('b')));
let Some(Modal::LoadPicker(LoadPickerModal {
sub_mode: LoadPickerSubMode::PathEntry { input, .. },
..
})) = app.modal.clone()
else {
panic!("expected LoadPicker in PathEntry sub-mode");
};
assert_eq!(input, "");
type_str(&mut app, "/some/path");
let actions = app.update(key(KeyCode::Enter));
let Action::LoadProject { path, .. } = &actions[0] else {
panic!("expected LoadProject");
};
assert_eq!(path, std::path::Path::new("/some/path"));
}
#[test]
fn empty_data_root_load_picker_opens_in_path_entry_mode() {
let mut app = App::new();
app.update(AppEvent::LoadPickerReady { entries: vec![] });
match app.modal.as_ref() {
Some(Modal::LoadPicker(LoadPickerModal {
sub_mode: LoadPickerSubMode::PathEntry { .. },
..
})) => {}
other => panic!("expected LoadPicker in PathEntry sub-mode, got {other:?}"),
}
}
#[test]
fn project_switched_event_updates_state() {
let mut app = App::new();
app.project_name = Some("Old".to_string());
app.project_is_temp = true;
app.tables = vec!["Stale".to_string()];
app.update(AppEvent::ProjectSwitched {
display_name: "New Name".to_string(),
is_temp: false,
history_entries: Vec::new(),
mode: rdbms_playground::mode::Mode::Simple,
});
assert_eq!(app.project_name.as_deref(), Some("New Name"));
assert!(!app.project_is_temp);
assert!(app.tables.is_empty(), "tables should clear on switch");
}
// === Filesystem-level tests for project::copy_project ===
#[test]
fn copy_project_excludes_lock_file() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let src = project.path().to_path_buf();
// Confirm the lock exists in the source.
assert!(src.join(".rdbms-playground.lock").exists());
let dst = data.path().join("CopyDestination");
copy_project(&src, &dst).unwrap();
// Destination has the project skeleton but not the lock.
assert!(dst.join("project.yaml").exists());
assert!(dst.join("data").is_dir());
assert!(!dst.join(".rdbms-playground.lock").exists());
drop(project);
}
#[test]
fn copy_project_refuses_existing_destination() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let src = project.path().to_path_buf();
let dst = data.path().join("ExistingDir");
fs::create_dir(&dst).unwrap();
let err = copy_project(&src, &dst).expect_err("must refuse");
assert!(format!("{err}").contains("already exists"));
}
#[test]
fn project_kind_recovered_from_dirname_on_open() {
let data = tempdir();
// Create a temp project. Its dirname will contain `[temp]`.
let temp = project::open_or_create(None, Some(data.path())).unwrap();
let temp_path = temp.path().to_path_buf();
drop(temp);
// Reopen — should still report Temp.
let reopened = Project::open(&temp_path).unwrap();
assert_eq!(reopened.kind(), ProjectKind::Temp);
drop(reopened);
// Now copy to a named directory.
let named_dir = data.path().join("MyProject");
copy_project(&temp_path, &named_dir).unwrap();
let opened_named = Project::open(&named_dir).unwrap();
assert_eq!(opened_named.kind(), ProjectKind::Named);
assert_eq!(opened_named.display_name(), "My Project");
}
#[test]
fn fresh_temp_is_unmodified() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
assert!(project.is_unmodified_temp());
}
#[test]
fn temp_with_a_table_is_no_longer_unmodified() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
db.create_table(
"T".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
Some("create".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
let reopened = Project::open(&path).unwrap();
assert!(
!reopened.is_unmodified_temp(),
"a temp with a table should not be considered unmodified",
);
}
#[test]
fn named_project_is_never_unmodified_temp() {
let data = tempdir();
let temp = project::open_or_create(None, Some(data.path())).unwrap();
let temp_path = temp.path().to_path_buf();
drop(temp);
let named = data.path().join("MyOrders");
copy_project(&temp_path, &named).unwrap();
let opened = Project::open(&named).unwrap();
// Even though the schema is empty, kind is Named.
assert_eq!(opened.kind(), ProjectKind::Named);
assert!(!opened.is_unmodified_temp());
}
#[test]
fn safely_delete_removes_genuine_unmodified_temp() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
drop(project); // release lock so we can delete
assert!(path.exists());
safely_delete_temp_project(&path, data.path()).expect("should delete");
assert!(!path.exists());
}
#[test]
fn safely_delete_refuses_path_outside_data_root() {
let data = tempdir();
let other = tempdir();
// Construct a directory outside the data root that LOOKS
// like a temp project (has [temp] marker + project.yaml).
let foreign = other.path().join("20260507-[temp]-fake-fake-fake");
fs::create_dir_all(&foreign).unwrap();
fs::write(
foreign.join("project.yaml"),
"version: 1\nproject:\n created_at: x\ntables: []\nrelationships: []\n",
)
.unwrap();
let err = safely_delete_temp_project(&foreign, data.path()).expect_err("must refuse");
assert!(format!("{err}").contains("not inside"), "got: {err}");
assert!(foreign.exists(), "foreign dir must still exist");
}
#[test]
fn safely_delete_refuses_directory_without_temp_marker() {
let data = tempdir();
// Create a project directory under the data root that
// doesn't carry the [temp] marker.
let projects_dir = data.path().join(project::PROJECTS_SUBDIR);
fs::create_dir_all(&projects_dir).unwrap();
let named = projects_dir.join("MyOrders");
fs::create_dir(&named).unwrap();
fs::write(named.join("project.yaml"), "version: 1\n").unwrap();
let err = safely_delete_temp_project(&named, data.path()).expect_err("must refuse");
assert!(format!("{err}").contains("[temp]"), "got: {err}");
assert!(named.exists());
}
#[test]
fn safely_delete_refuses_directory_with_unexpected_file() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
// Drop a stranger file into the project dir.
fs::write(path.join("notes.md"), "user notes\n").unwrap();
drop(project);
let err = safely_delete_temp_project(&path, data.path()).expect_err("must refuse");
assert!(format!("{err}").contains("unexpected file"), "got: {err}");
assert!(path.exists());
assert!(path.join("notes.md").exists());
}
#[test]
fn safely_delete_allows_migration_backups_and_tmp_files() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
fs::write(path.join("project.yaml.v1.bak"), "old\n").unwrap();
fs::write(path.join("project.yaml.tmp"), "stage\n").unwrap();
drop(project);
safely_delete_temp_project(&path, data.path()).expect("should delete");
assert!(!path.exists());
}
#[test]
fn safely_delete_allows_undo_snapshot_ring() {
// A temp that was modified then undone back to empty can still
// carry the `.snapshots/` ring; it must remain auto-deletable
// (ADR-0006 Amendment 1).
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let snaps = path.join(".snapshots");
fs::create_dir_all(snaps.join("3")).unwrap();
fs::write(snaps.join("index.yaml"), "next_id: 4\nundo: []\nredo: []\n").unwrap();
fs::write(snaps.join("3").join("playground.db"), [0u8; 16]).unwrap();
drop(project);
safely_delete_temp_project(&path, data.path()).expect("should delete");
assert!(!path.exists());
}
#[cfg(unix)]
#[test]
fn safely_delete_refuses_symlink_top_level() {
use std::os::unix::fs::symlink;
let data = tempdir();
let real_target = tempdir();
let projects_dir = data.path().join(project::PROJECTS_SUBDIR);
fs::create_dir_all(&projects_dir).unwrap();
let link = projects_dir.join("20260507-[temp]-aaa-bbb-ccc");
symlink(real_target.path(), &link).unwrap();
let err = safely_delete_temp_project(&link, data.path()).expect_err("must refuse");
assert!(format!("{err}").contains("symbolic link"), "got: {err}");
// Real target untouched.
assert!(real_target.path().exists());
// Symlink itself untouched.
assert!(link.exists());
}
#[test]
fn unmodified_temp_with_residual_csv_in_data_dir_is_not_unmodified() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
// Hand-drop a CSV into the data dir without going through
// the persistence layer. Schema in yaml is still empty.
let csv = project.path().join("data").join("Stranger.csv");
fs::write(&csv, "id\n1\n").unwrap();
assert!(
!project.is_unmodified_temp(),
"non-empty data dir must disqualify the unmodified-temp check",
);
}
#[test]
fn list_projects_sorts_by_mtime() {
let data = tempdir();
// Create two projects in succession; the second has a
// newer mtime on its project.yaml.
let _first = project::open_or_create(None, Some(data.path())).unwrap();
let _first_path = _first.path().to_path_buf();
drop(_first);
// Sleep a hair to ensure different mtimes on filesystems
// with second-resolution timestamps.
std::thread::sleep(std::time::Duration::from_millis(1100));
let _second = project::open_or_create(None, Some(data.path())).unwrap();
let _second_path = _second.path().to_path_buf();
drop(_second);
let listings = project::list_projects(data.path());
assert!(listings.len() >= 2, "got {} listings", listings.len());
// Newer first.
assert!(listings[0].path > listings[1].path || listings[0].modified >= listings[1].modified);
for l in &listings {
// Both are temp projects (auto-named with [temp]).
assert_eq!(l.kind, ProjectKind::Temp);
}
}
+371
View File
@@ -0,0 +1,371 @@
//! Iteration-5 integration tests: `export` / `import`
//! (ADR-0015 §11 + ADR-0007 amendment 1).
//!
//! Command parsing is exercised at the App layer (synthetic
//! events). Filesystem-level export and import semantics are
//! tested against the public `archive` helpers without booting
//! a Tokio loop.
use std::fs;
use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::App;
use rdbms_playground::archive::{
default_export_filename, export_project, extract_into, inspect_zip,
next_export_sequence, resolve_import_target,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML};
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
fn make_demo_project(root: &std::path::Path, name: &str) -> PathBuf {
let p = root.join(name);
fs::create_dir_all(&p).unwrap();
fs::write(
p.join(PROJECT_YAML),
"version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n",
)
.unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|seed\n").unwrap();
fs::write(p.join(PLAYGROUND_DB), [0u8; 16]).unwrap();
p
}
// --- Command-parsing tests -------------------------------------
#[test]
fn export_with_no_arg_emits_default_action() {
let mut app = App::new();
type_str(&mut app, "export");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Export { target, source } => {
assert!(target.is_none());
assert_eq!(source, "export");
}
other => panic!("expected Export, got {other:?}"),
}
}
#[test]
fn export_with_path_argument_passes_through_target() {
let mut app = App::new();
type_str(&mut app, "export backups/MyExport.zip");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Export { target, .. } => {
assert_eq!(target.as_deref(), Some("backups/MyExport.zip"));
}
other => panic!("expected Export, got {other:?}"),
}
}
#[test]
fn export_with_only_whitespace_after_keyword_errors() {
let mut app = App::new();
type_str(&mut app, "export ");
let actions = submit(&mut app);
// Trailing whitespace is trimmed by submit() before
// dispatch, so "export " trims to "export" and emits
// the default Export action — exactly the same outcome
// as a bare `export`. That is the desired behaviour.
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Export { target, .. } => assert!(target.is_none()),
other => panic!("expected Export, got {other:?}"),
}
}
#[test]
fn import_without_arg_emits_error() {
let mut app = App::new();
type_str(&mut app, "import");
let actions = submit(&mut app);
assert!(actions.is_empty());
let last = app.output.back().unwrap();
assert!(last.text.contains("usage: import"), "got: {}", last.text);
}
#[test]
fn import_with_zip_path_emits_action_without_target() {
let mut app = App::new();
type_str(&mut app, "import some/file.zip");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Import {
zip_path,
as_target,
..
} => {
assert_eq!(zip_path, "some/file.zip");
assert!(as_target.is_none());
}
other => panic!("expected Import, got {other:?}"),
}
}
#[test]
fn import_with_zip_and_as_target_emits_both() {
let mut app = App::new();
type_str(&mut app, "import some/file.zip as MyImported");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Import {
zip_path,
as_target,
..
} => {
assert_eq!(zip_path, "some/file.zip");
assert_eq!(as_target.as_deref(), Some("MyImported"));
}
other => panic!("expected Import, got {other:?}"),
}
}
#[test]
fn import_grammar_only_splits_on_space_around_as() {
// A zip path that *contains* the substring "as" without
// surrounding spaces must NOT be split — the separator
// is " as " (space-as-space) only.
let mut app = App::new();
type_str(&mut app, "import path/asfile.zip");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Import {
zip_path,
as_target,
..
} => {
assert_eq!(zip_path, "path/asfile.zip");
assert!(as_target.is_none());
}
other => panic!("expected Import, got {other:?}"),
}
}
#[test]
fn import_with_empty_target_after_as_errors() {
let mut app = App::new();
type_str(&mut app, "import foo.zip as ");
let actions = submit(&mut app);
// "as " trailing whitespace is trimmed by .split_once + .trim,
// making the as-target empty. We surface this as a usage
// error rather than silently importing without a target. The
// failed line is journalled `err` (ADR-0034) but no import
// dispatches.
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"expected only a JournalFailure, no import dispatch; got {actions:?}",
);
// The friendly parse-error rendering produces multiple
// output lines (caret, message, usage). Scan for the anchor
// phrase rather than asserting on the final line. The
// round-5 refactor moved this error from `handle_import_command`
// (single note) into the parser's pre-chumsky path (multi-
// line rendering via dispatch_dsl).
let anywhere = app
.output
.iter()
.any(|l| l.text.contains("import") && l.text.contains("target"));
assert!(
anywhere,
"expected 'import' + 'target' somewhere in output: {:?}",
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
);
}
#[test]
fn help_lists_export_and_import() {
let mut app = App::new();
type_str(&mut app, "help");
submit(&mut app);
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(body.contains("export"), "help missing export: {body}");
assert!(body.contains("import"), "help missing import: {body}");
}
// --- Filesystem-level export/import semantics ------------------
#[test]
fn full_round_trip_export_then_extract() {
let tmp = tempdir();
let project = make_demo_project(tmp.path(), "MyDemo");
let zip = tmp.path().join("MyDemo-export-01.zip");
export_project(&project, "MyDemo", &zip).unwrap();
let inspect = inspect_zip(&zip).unwrap();
assert_eq!(inspect.top_folder, "MyDemo");
let target = tmp.path().join("imported");
extract_into(&zip, &target, &inspect.top_folder).unwrap();
assert!(target.join(PROJECT_YAML).exists());
assert!(target.join("data").join("Customers.csv").exists());
// history.log and playground.db were excluded from the zip,
// so neither lands in the imported project.
assert!(!target.join(HISTORY_LOG).exists());
assert!(!target.join(PLAYGROUND_DB).exists());
}
#[test]
fn next_export_sequence_increments_per_existing_file() {
let tmp = tempdir();
let date = rdbms_playground::project::naming::today_local();
let (n1_name, n1) = next_export_sequence(tmp.path(), "Demo").unwrap();
assert_eq!(n1, 1);
fs::write(tmp.path().join(&n1_name), "").unwrap();
let (n2_name, n2) = next_export_sequence(tmp.path(), "Demo").unwrap();
assert_eq!(n2, 2);
assert_eq!(n2_name, default_export_filename(&date, "Demo", 2));
}
#[test]
fn resolve_import_target_auto_suffixes_on_collision() {
let tmp = tempdir();
fs::create_dir(tmp.path().join("Imported")).unwrap();
let (resolved, suffix) = resolve_import_target(tmp.path(), "Imported").unwrap();
assert_eq!(resolved, tmp.path().join("Imported-02"));
assert_eq!(suffix, 2);
}
#[test]
fn resolve_import_target_uses_direct_name_when_free() {
let tmp = tempdir();
let (resolved, suffix) = resolve_import_target(tmp.path(), "Fresh").unwrap();
assert_eq!(resolved, tmp.path().join("Fresh"));
assert_eq!(suffix, 0);
}
// --- End-to-end: real Project → export → import → rebuild ----
#[test]
fn end_to_end_export_then_import_real_project() {
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type, Value};
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")
}
let data = tempdir();
// Build a populated source project.
let src_path = {
let p = project::Project::create_named(&data.path().join("Source")).unwrap();
let db = Database::open_with_persistence(
p.db_path(),
Persistence::new(p.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("Name".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
// Serial id auto-fills, so the values list
// covers the non-serial columns only.
vec![Value::Text("Alice".to_string())],
Some("insert into Customers values ('Alice')".to_string()),
)
.await
.unwrap();
});
let path = p.path().to_path_buf();
drop(db);
drop(p);
path
};
// Export.
let zip_path = data.path().join("Source-export.zip");
export_project(&src_path, "Source", &zip_path).unwrap();
assert!(zip_path.exists());
// Inspect: top folder is the project name we exported with.
let inspect = inspect_zip(&zip_path).unwrap();
assert_eq!(inspect.top_folder, "Source");
// Import to a fresh location and rebuild from text.
let dst = data.path().join("Imported");
extract_into(&zip_path, &dst, &inspect.top_folder).unwrap();
assert!(dst.join(PROJECT_YAML).exists());
// playground.db is excluded from the export, so the
// imported project starts without one — exactly the
// scenario rebuild_from_text is designed for.
assert!(!dst.join(PLAYGROUND_DB).exists());
let imported = project::Project::open(&dst).unwrap();
let imported_db = Database::open_with_persistence(
imported.db_path(),
Persistence::new(imported.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
imported_db
.rebuild_from_text(imported.path().to_path_buf(), None)
.await
.expect("rebuild");
});
// Round-trip: the inserted row is back.
let data_view = rt()
.block_on(async { imported_db.query_data("Customers".to_string(), None, None, None).await })
.expect("query data");
assert_eq!(data_view.rows.len(), 1);
// Serial id auto-filled to 1; Name was the inserted value.
let cells: Vec<Option<&str>> = data_view.rows[0].iter().map(|c| c.as_deref()).collect();
assert_eq!(cells, vec![Some("1"), Some("Alice")]);
}
+240
View File
@@ -0,0 +1,240 @@
//! Iteration-6 integration tests: `--resume` + persistent
//! input history + migration framework scaffold (ADR-0015 §7,
//! §9, §12).
//!
//! Boots no Tokio runtime and no terminal — these tests
//! exercise the persistent state behind `--resume` (the
//! `last_project` file under the data root) and the input
//! history hydration off `history.log`.
use std::fs;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::app::App;
use rdbms_playground::cli::{Args, ArgsError};
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
self, LAST_PROJECT_FILE, Project, read_last_project, write_last_project,
};
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
// --- Args parsing for --resume ---------------------------------
#[test]
fn args_parses_resume_flag() {
let a = Args::parse(["--resume"]).unwrap();
assert!(a.resume);
assert!(a.project_path.is_none());
}
#[test]
fn args_resume_with_positional_path_is_an_error() {
let err = Args::parse(["--resume", "/tmp/foo"]).unwrap_err();
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
}
#[test]
fn args_resume_after_positional_path_also_errors() {
let err = Args::parse(["/tmp/foo", "--resume"]).unwrap_err();
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
}
#[test]
fn args_help_listing_mentions_resume() {
assert!(rdbms_playground::cli::help_text().contains("--resume"));
}
// --- last_project read/write ----------------------------------
#[test]
fn last_project_round_trips_through_disk() {
let tmp = tempdir();
let target = tmp.path().join("MyProject");
fs::create_dir(&target).unwrap();
write_last_project(tmp.path(), &target).unwrap();
let on_disk = fs::read_to_string(tmp.path().join(LAST_PROJECT_FILE)).unwrap();
assert!(on_disk.contains("MyProject"));
assert_eq!(read_last_project(tmp.path()).unwrap(), Some(target));
}
#[test]
fn last_project_is_overwritten_each_call() {
let tmp = tempdir();
let a = tmp.path().join("A");
let b = tmp.path().join("B");
fs::create_dir(&a).unwrap();
fs::create_dir(&b).unwrap();
write_last_project(tmp.path(), &a).unwrap();
write_last_project(tmp.path(), &b).unwrap();
assert_eq!(read_last_project(tmp.path()).unwrap(), Some(b));
}
#[test]
fn last_project_create_temp_path_resolves_to_existing_dir() {
// Sanity: the path we record is in fact something that
// exists when --resume tries to reopen it. This protects
// against future refactors that might write a placeholder.
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap();
write_last_project(tmp.path(), project.path()).unwrap();
let read_back = read_last_project(tmp.path()).unwrap();
assert_eq!(read_back.as_deref(), Some(project.path()));
assert!(read_back.unwrap().exists());
}
#[test]
fn read_last_project_handles_missing_data_root_directory() {
let tmp = tempdir();
let nested = tmp.path().join("does/not/exist/yet");
// Reading from a directory that hasn't been created at
// all should be Ok(None), not an error — the runtime's
// first launch lands here.
assert!(read_last_project(&nested).unwrap().is_none());
}
// --- Stale path on resume: read returns Some(path) but the
// path does not exist. The runtime is responsible for
// surfacing this; we verify the building block here.
#[test]
fn last_project_returns_stale_path_verbatim_for_runtime_to_detect() {
let tmp = tempdir();
let stale = tmp.path().join("Vanished");
write_last_project(tmp.path(), &stale).unwrap();
let read_back = read_last_project(tmp.path()).unwrap();
assert_eq!(read_back.as_deref(), Some(stale.as_path()));
assert!(!stale.exists());
}
// --- Project lifecycle writes last_project ---------------------
// (Smoke test: launching open_or_create then opening again
// should be the same as write_last_project + reopen.)
// --- History hydration on project open ----------------------
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
#[test]
fn read_recent_history_returns_empty_when_log_missing() {
let tmp = tempdir();
let p = Persistence::new(tmp.path().to_path_buf());
let entries = p.read_recent_history(10).unwrap();
assert!(entries.is_empty());
}
#[test]
fn read_recent_history_returns_appended_entries_in_order() {
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf());
p.append_history("create table A with pk").unwrap();
p.append_history("create table B with pk").unwrap();
p.append_history("create table C with pk").unwrap();
let entries = p.read_recent_history(10).unwrap();
assert_eq!(
entries,
vec![
"create table A with pk".to_string(),
"create table B with pk".to_string(),
"create table C with pk".to_string(),
]
);
}
#[test]
fn hydration_reads_both_ok_and_err_records() {
// ADR-0034 §1/§2: failed commands are journalled `err`, and
// input-history hydration reads ALL records (ok + err) so a
// typo'd / rejected command from a previous session is
// recallable after restart — matching the in-session ring's
// "record everything" behaviour.
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf());
p.append_history("create table A with pk").unwrap();
p.append_history_failure("insert into A (1, 2, 3)").unwrap();
p.append_history("show data A").unwrap();
let entries = p.read_recent_history(10).unwrap();
assert_eq!(
entries,
vec![
"create table A with pk".to_string(),
"insert into A (1, 2, 3)".to_string(), // the err record is recalled
"show data A".to_string(),
],
"hydration includes the err record",
);
}
#[test]
fn seed_history_replaces_in_memory_history() {
let mut app = App::new();
// Pre-existing in-session entries — should be replaced.
for c in "abc".chars() {
app.update(key(KeyCode::Char(c)));
}
app.update(key(KeyCode::Enter));
assert_eq!(app.history, vec!["abc".to_string()]);
app.seed_history(vec!["x".to_string(), "y".to_string()]);
assert_eq!(app.history, vec!["x".to_string(), "y".to_string()]);
}
#[test]
fn seed_history_preserves_chronological_order_for_navigation() {
let mut app = App::new();
app.seed_history(vec![
"old".to_string(),
"middle".to_string(),
"newest".to_string(),
]);
// Up should recall "newest" first (the most recent
// entry, which is at the back of the vec by convention).
app.update(key(KeyCode::Up));
assert_eq!(app.input, "newest");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "middle");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "old");
}
#[test]
fn project_switched_event_seeds_history_from_payload() {
let mut app = App::new();
app.update(AppEvent::ProjectSwitched {
display_name: "Foo".to_string(),
is_temp: false,
history_entries: vec!["aa".to_string(), "bb".to_string()],
mode: rdbms_playground::mode::Mode::Simple,
});
assert_eq!(app.history, vec!["aa".to_string(), "bb".to_string()]);
// Up navigates within the seeded entries.
app.update(key(KeyCode::Up));
assert_eq!(app.input, "bb");
}
#[test]
fn data_root_with_no_last_project_is_resume_safe() {
let tmp = tempdir();
// Fresh data root with no projects, no last_project.
let _project = project::open_or_create(None, Some(tmp.path())).unwrap();
// open_or_create itself doesn't write last_project (the
// runtime does, after a successful open). That's fine —
// the runtime test would write it. Verify that
// read_last_project here returns None as expected.
assert!(read_last_project(tmp.path()).unwrap().is_none());
}
+34
View File
@@ -0,0 +1,34 @@
//! Consolidated integration-test binary (see
//! `docs/plans/20260602-test-consolidation.md`).
//!
//! Each former top-level `tests/*.rs` is now a module here, so the
//! whole suite links into **one** binary instead of 26 — turning an
//! edit-the-lib-then-test cycle from 26 separate links into one.
//! `tests/typing_surface_matrix.rs` stays a separate binary (it is
//! already a consolidated `mod`-based target).
mod case_insensitive_names;
mod column_op_guards;
mod engine_vocabulary_audit;
mod friendly_enrichment;
mod iteration2_persistence;
mod iteration3_rebuild;
mod iteration4a_rebuild_command;
mod iteration4b_lifecycle_commands;
mod iteration5_export_import;
mod iteration6_resume_history;
mod parse_error_pedagogy;
mod project_lifecycle;
mod replay_command;
mod sql_alter_table;
mod sql_create_index;
mod sql_create_table;
mod sql_delete;
mod sql_dml_e2e;
mod sql_drop_index;
mod sql_drop_table;
mod sql_insert;
mod sql_select;
mod sql_update;
mod undo_snapshots;
mod walking_skeleton;
+231
View File
@@ -0,0 +1,231 @@
//! Tier-3 integration tests for ADR-0021 (per-command usage in
//! parse errors). Drives synthetic crossterm events through
//! `App::update` and asserts on the rendered output lines.
//!
//! Each test exercises the full input → parse → error-render
//! chain. The unit tests in `dsl::usage::tests` cover the
//! registry logic in isolation; these tests pin the user-visible
//! composition (caret + structural error + usage block, or the
//! available-commands fallback).
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::{App, OutputKind};
use rdbms_playground::event::AppEvent;
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
/// Run `input` through the app and return every error-kind
/// output line. Asserts the submission parse-failed — which now
/// emits exactly a `JournalFailure` (ADR-0034: the failed line is
/// journalled `err`) and dispatches no command to the worker.
fn error_lines_for(input: &str) -> Vec<String> {
let mut app = App::new();
type_str(&mut app, input);
let actions = submit(&mut app);
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"expected parse failure (only a JournalFailure) for {input:?}, got {actions:?}",
);
app.output
.iter()
.filter(|l| l.kind == OutputKind::Error)
.map(|l| l.text.clone())
.collect()
}
fn dump(input: &str, lines: &[String]) -> String {
format!(
"INPUT: {input:?}\nERROR LINES:\n{}",
lines.join("\n"),
)
}
#[test]
fn create_alone_renders_create_table_usage() {
let lines = error_lines_for("create");
let dump_msg = dump("create", &lines);
assert!(
lines.iter().any(|l| l.starts_with("parse error")),
"{dump_msg}",
);
assert!(
lines.iter().any(|l| l == "usage:"),
"missing usage: header\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("create table") && l.contains("with pk")),
"missing create_table usage template\n{dump_msg}",
);
}
#[test]
fn add_alone_renders_both_add_family_usages() {
let lines = error_lines_for("add");
let dump_msg = dump("add", &lines);
// Aggregation across `choice` (ADR-0020): the structural
// error line lists both add-family entries.
assert!(
lines.iter().any(|l| {
l.starts_with("parse error")
&& l.contains("`1`")
&& l.contains("`column`")
}),
"expected aggregated `1` or `column` in structural error\n{dump_msg}",
);
// Usage block (ADR-0021): both add-* templates surface.
assert!(
lines.iter().any(|l| l.contains("add column")),
"missing add_column usage\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("add 1:n relationship")),
"missing add_relationship usage\n{dump_msg}",
);
}
#[test]
fn drop_alone_renders_all_three_drop_family_usages() {
let lines = error_lines_for("drop");
let dump_msg = dump("drop", &lines);
assert!(
lines.iter().any(|l| l.contains("drop table")),
"missing drop_table usage\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("drop column")),
"missing drop_column usage\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("drop relationship")),
"missing drop_relationship usage\n{dump_msg}",
);
}
#[test]
fn show_alone_renders_both_show_family_usages() {
let lines = error_lines_for("show");
let dump_msg = dump("show", &lines);
assert!(
lines.iter().any(|l| l.contains("show data")),
"missing show_data usage\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("show table")),
"missing show_table usage\n{dump_msg}",
);
}
#[test]
fn unknown_command_falls_back_to_available_commands_list() {
let lines = error_lines_for("frobulate Customers");
let dump_msg = dump("frobulate Customers", &lines);
// No "usage:" header — the no-prefix fallback path renders
// the available-commands list instead.
assert!(
lines.iter().all(|l| l != "usage:"),
"should not render usage: header for unknown command\n{dump_msg}",
);
let available = lines
.iter()
.find(|l| l.starts_with("available commands:"))
.unwrap_or_else(|| panic!("missing available commands line\n{dump_msg}"));
// The list must include all ten command-entry keywords.
for cmd in [
"add", "change", "create", "delete", "drop", "insert",
"rename", "replay", "show", "update",
] {
assert!(
available.contains(&format!("`{cmd}`")),
"available commands missing `{cmd}`: {available}",
);
}
}
#[test]
fn update_partial_renders_update_usage_template() {
// `update Customers set Active=false` parses through to
// end-of-input; the missing `where` / `--all-rows` clause
// triggers the structural error. The entry keyword is
// `update`, so the update usage template is shown.
let lines = error_lines_for("update Customers set Active=false");
let dump_msg = dump("update Customers set Active=false", &lines);
assert!(
lines.iter().any(|l| l.contains("update <Table> set")),
"missing update usage template\n{dump_msg}",
);
}
#[test]
fn create_table_without_pk_renders_create_table_usage() {
// The custom `try_map` error fires after `create table
// Customers` is fully consumed; failure position points at
// the start of the matched range, but matched_entry's `<=`
// condition still resolves the entry keyword.
let lines = error_lines_for("create table Customers");
let dump_msg = dump("create table Customers", &lines);
// Custom error wording (not just structural) is preserved.
assert!(
lines
.iter()
.any(|l| l.starts_with("parse error") && l.contains("with pk")),
"missing custom-error wording about with pk\n{dump_msg}",
);
// And the usage template surfaces as well.
assert!(
lines
.iter()
.any(|l| l.contains("create table") && l.contains("with pk")),
"missing create_table usage template\n{dump_msg}",
);
}
#[test]
fn insert_partial_renders_insert_usage_template() {
// `insert into T` needs either column-list or value-list to
// follow. Parser reports a structural error; usage template
// surfaces.
let lines = error_lines_for("insert into T");
let dump_msg = dump("insert into T", &lines);
assert!(
lines.iter().any(|l| l.contains("insert into <Table>")),
"missing insert usage template\n{dump_msg}",
);
}
#[test]
fn caret_aligns_under_offending_token() {
// The caret line is whitespace + `^`. After the "running: "
// prefix (9 chars) plus the byte offset of the failure
// position, the `^` should sit directly under the
// offending character. For `frobulate Customers`, the
// failure is at position 0, so the caret is at column 9.
let lines = error_lines_for("frobulate Customers");
let caret = lines
.iter()
.find(|l| l.trim_start_matches(' ').starts_with('^'))
.expect("missing caret line");
let leading_spaces = caret.chars().take_while(|c| *c == ' ').count();
assert_eq!(
leading_spaces, 9,
"caret should sit at column 9 (under `f` of `frobulate` after the `running: ` prefix); got {leading_spaces} spaces in {caret:?}",
);
}
+197
View File
@@ -0,0 +1,197 @@
//! Iteration-1 integration tests: end-to-end project lifecycle
//! through the public API the runtime uses on startup.
//!
//! These tests do NOT run the Tokio loop or the terminal; they
//! exercise the same `project::open_or_create` entry point the
//! runtime calls, plus a `Database::open` against the resulting
//! path, to confirm the file-backed SQLite database actually
//! lands inside the project directory and is queryable.
use std::fs;
use rdbms_playground::cli::Args;
use rdbms_playground::db::Database;
use rdbms_playground::project::{
self, GITIGNORE, HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, PROJECTS_SUBDIR,
};
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
#[test]
fn no_args_creates_temp_project_under_data_root() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path()))
.expect("open_or_create with empty CLI");
let path = project.path();
assert!(path.exists(), "project dir should exist");
assert!(path.starts_with(data.path()));
assert_eq!(
path.parent().and_then(|p| p.file_name()).map(|s| s.to_string_lossy().into_owned()),
Some(PROJECTS_SUBDIR.to_string()),
);
// Skeleton files.
assert!(path.join(PROJECT_YAML).exists());
assert!(path.join("data").is_dir());
assert!(path.join(HISTORY_LOG).exists());
assert!(path.join(GITIGNORE).exists());
assert!(path.join(".rdbms-playground.lock").exists());
// .gitignore must NOT include history.log (ADR-0007 amendment).
let gi = fs::read_to_string(path.join(GITIGNORE)).unwrap();
assert!(!gi.contains("history.log"));
// …but it must ignore the undo ring (ADR-0006 Amendment 1).
assert!(gi.contains("/.snapshots/"));
}
#[test]
fn db_opens_inside_project_and_creates_the_file() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let db_path = project.db_path();
// Before opening, the .db file does not exist.
assert!(!db_path.exists());
let _db = Database::open(&db_path).expect("open db at project path");
// After opening, sqlite has created the file.
assert!(db_path.exists());
assert_eq!(db_path.parent(), Some(project.path()));
}
#[test]
fn second_open_of_same_project_is_refused_by_lock() {
let data = tempdir();
let first = project::open_or_create(None, Some(data.path())).unwrap();
let path = first.path().to_path_buf();
let err = project::Project::open(&path).expect_err("second open should fail");
let msg = format!("{err}");
assert!(
msg.contains("already open"),
"expected lock-held error, got: {msg}"
);
}
#[test]
fn open_succeeds_after_first_project_is_dropped() {
let data = tempdir();
let path = {
let p = project::open_or_create(None, Some(data.path())).unwrap();
p.path().to_path_buf()
};
// Lock should have been released; reopen succeeds.
let _reopened = project::Project::open(&path).expect("reopen after drop");
}
#[test]
fn positional_path_opens_existing_project() {
let data = tempdir();
let path = {
let p = project::open_or_create(None, Some(data.path())).unwrap();
p.path().to_path_buf()
};
// Now drive open_or_create with the path as if it were a
// CLI positional argument.
let project = project::open_or_create(Some(&path), None)
.expect("open via positional path");
assert_eq!(project.path(), path);
}
#[test]
fn positional_nonexistent_path_is_refused() {
let data = tempdir();
let bogus = data.path().join("nope");
let err = project::open_or_create(Some(&bogus), Some(data.path()))
.expect_err("must refuse nonexistent path");
let msg = format!("{err}");
assert!(msg.contains("does not exist"), "got: {msg}");
}
#[test]
fn cli_args_thread_through_to_project_creation() {
// End-to-end: CLI parsing → open_or_create → on-disk project.
let data = tempdir();
let data_str = data.path().to_string_lossy().into_owned();
let args = Args::parse(["--data-dir", data_str.as_str()]).expect("parse args");
assert_eq!(args.data_dir.as_deref(), Some(data.path()));
assert!(args.project_path.is_none());
let project = project::open_or_create(args.project_path.as_deref(), args.data_dir.as_deref())
.expect("create temp via parsed CLI");
assert!(project.path().starts_with(data.path()));
}
#[test]
fn data_dir_override_does_not_touch_default_os_dir() {
// Sanity check that --data-dir really replaces the default —
// creating two temp projects under the override should leave
// them both there, and the OS-standard data dir is not
// touched. We can't easily inspect the OS-standard dir
// without actually creating things in it, so we settle for
// confirming the override directory is the active one.
let data = tempdir();
let p1 = project::open_or_create(None, Some(data.path())).unwrap();
let p1_path = p1.path().to_path_buf();
drop(p1);
let p2 = project::open_or_create(None, Some(data.path())).unwrap();
let p2_path = p2.path().to_path_buf();
assert!(p1_path.starts_with(data.path()));
assert!(p2_path.starts_with(data.path()));
assert_ne!(p1_path, p2_path, "two temp projects must have distinct names");
}
#[test]
fn db_persists_across_open_close_cycles() {
// Iteration 1's headline UX win: quitting no longer loses
// work. With a file-backed database, data written in one
// session is visible after re-opening the project.
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db_path = project.db_path();
// Write something via SQLite directly. (The DSL/runtime path
// would do the same but isn't reachable from a sync test.)
{
let db = Database::open(&db_path).expect("open db");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
db.create_table(
"Customers".to_string(),
vec![
rdbms_playground::dsl::ColumnSpec::new("id".to_string(), rdbms_playground::dsl::Type::Serial),
rdbms_playground::dsl::ColumnSpec::new("Name".to_string(), rdbms_playground::dsl::Type::Text),
],
vec!["id".to_string()],
None)
.await
.expect("create_table");
});
}
// Drop the project (releases the lock).
drop(project);
// Re-open and confirm the table is still there.
let reopened = project::Project::open(&path).expect("reopen");
let db = Database::open(reopened.db_path()).expect("re-open db");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let tables = rt.block_on(async { db.list_tables().await }).expect("list_tables");
assert!(tables.iter().any(|t| t == "Customers"), "got: {tables:?}");
// Sanity: the project.yaml and history.log are still empty
// skeleton files (Iteration 2 will populate them).
assert!(reopened.path().join(PROJECT_YAML).exists());
assert!(reopened.path().join(PLAYGROUND_DB).exists());
}
+572
View File
@@ -0,0 +1,572 @@
//! Integration tests for the `replay <path>` command (U4).
//!
//! Exercises the runtime's `run_replay` directly rather than
//! booting a Tokio event loop — the inner replay logic is the
//! interesting unit, and `spawn_replay` is just the mpsc shim
//! around it.
//!
//! Covers (per handoff §A3):
//! - Happy path: 3-line file dispatches 3 commands, project
//! state reflects the dispatched DDL/DML.
//! - Blank lines and `# comments` are skipped silently.
//! - Per-line failure: the runtime reports the line number of
//! the offending entry and stops without dispatching the rest.
//! Earlier successful commands are NOT rolled back.
//! - Empty file → ReplayCompleted with count 0.
//! - Missing file → ReplayFailed with line_number 0.
//! - Nested replay (`replay foo` inside the file being replayed)
//! is refused with a clear message.
//! - history.log invariant: replaying a file produces the same
//! per-command history entries as if the user had typed each
//! line interactively.
use std::fs;
use std::path::Path;
use tokio::runtime::Runtime;
use rdbms_playground::db::Database;
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
use rdbms_playground::runtime::run_replay;
fn rt() -> Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
/// Open a fresh project + persistence-wired database under
/// `data_root`, returning both. Used as the canonical test
/// harness — most tests only need to write a script file and
/// call `run_replay`.
fn open_project_db(data_root: &Path) -> (project::Project, Database) {
let project = project::open_or_create(None, Some(data_root))
.expect("open_or_create");
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.expect("open db");
(project, db)
}
fn write_script(project_path: &Path, name: &str, body: &str) {
fs::write(project_path.join(name), body).expect("write script");
}
fn assert_completed(events: &[AppEvent], expected_count: usize) {
let last = events.last().expect("at least one event");
match last {
AppEvent::ReplayCompleted { count, .. } => {
assert_eq!(
*count, expected_count,
"ReplayCompleted count mismatch (events: {events:?})"
);
}
other => panic!("expected ReplayCompleted, got {other:?}"),
}
}
fn assert_failed_at(events: &[AppEvent], expected_line: usize) -> &AppEvent {
let last = events.last().expect("at least one event");
match last {
AppEvent::ReplayFailed { line_number, .. } => {
assert_eq!(
*line_number, expected_line,
"ReplayFailed line_number mismatch (events: {events:?})"
);
last
}
other => panic!("expected ReplayFailed, got {other:?}"),
}
}
#[test]
fn replay_runs_advanced_sql_create_table_as_a_write() {
// ADR-0035 §10: `create` is a schema-write entry word (not in the
// ADR-0034 app-lifecycle skip set), so an advanced-mode SQL
// `CREATE TABLE` line replays as a write — re-applied, not skipped
// — and executes structurally (the table is rebuilt from the line).
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"ddl.commands",
"create table Widget (id serial primary key, name text)\n\
insert into Widget (name) values ('gadget')\n",
);
let events = rt().block_on(async { run_replay(&db, project.path(), "ddl.commands").await });
assert_completed(&events, 2);
// The SQL DDL line actually created the structural table…
let desc = rt()
.block_on(async { db.describe_table("Widget".to_string(), None).await })
.expect("describe");
let names: Vec<String> = desc.columns.iter().map(|c| c.name.clone()).collect();
assert_eq!(names, vec!["id".to_string(), "name".to_string()]);
// …and the following insert (serial id auto-filled) ran against it.
let rows = rt()
.block_on(async { db.query_data("Widget".to_string(), None, None, None).await })
.expect("query")
.rows;
assert_eq!(rows.len(), 1);
}
#[test]
fn replay_three_lines_dispatches_three_commands() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"seed.commands",
"create table T with pk id(int)\n\
add column T: name (text)\n\
insert into T (1, 'Alice')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
assert_completed(&events, 3);
// The dispatched commands actually mutated state.
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.expect("query_data");
assert_eq!(data_result.rows.len(), 1, "row inserted");
assert_eq!(data_result.rows[0][1].as_deref(), Some("Alice"));
}
#[test]
fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() {
// ADR-0034 §3 + Problem 3 (handoff-34 §4): `replay history.log`
// must work. The journal is the pipe format
// `<iso8601>|<status>|<source>`; replay extracts `<source>`, runs
// `ok` records, and skips `err` ones (like blank / `#` lines — a
// skipped failure is not a replay failure).
//
// This is the ADR-0034 headline reproduction. It is RED before the
// fix: today `run_replay` feeds the whole `2026-…|ok|…` line to the
// parser, which dies on line 1 (the timestamp is not a command).
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"history.log",
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
2026-05-24T10:00:01Z|ok|add column T: v (text)\n\
2026-05-24T10:00:02Z|err|insert into T values (1, 2, 3, 4)\n\
2026-05-24T10:00:03Z|ok|insert into T (id, v) values (1, 'alpha')\n",
);
let events =
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
// Three `ok` records replayed; the `err` record is skipped (not
// counted, not a failure).
assert_completed(&events, 3);
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.expect("query_data");
assert_eq!(data_result.rows.len(), 1, "only the ok INSERT applied");
assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha"));
}
#[test]
fn replay_skips_app_lifecycle_commands_silently() {
// ADR-0034: a real `history.log` contains app-lifecycle commands
// (`save as` / `load` / `new` / `export` / `mode` / `rebuild` /
// `undo` / `redo` …).
// Replay skips them — they are session navigation, not schema/data
// reconstruction, and the worker dispatch cannot run them (it would
// panic on a parsed app command, or abort on the modal forms that
// don't parse). These skip SILENTLY (no warning).
let data = tempdir();
let (project, db) = open_project_db(data.path());
// Every silent-skip app-lifecycle form, including the modal forms
// that don't parse on the command line (`save as` / `load` / `new`),
// the bare incomplete form (`mode`), and the safety-critical `quit`
// (a journalled quit must NOT quit during replay). None may abort;
// none warns.
write_script(
project.path(),
"history.log",
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
2026-05-24T10:00:01Z|ok|save as backup\n\
2026-05-24T10:00:02Z|ok|load other\n\
2026-05-24T10:00:03Z|ok|new scratch\n\
2026-05-24T10:00:04Z|ok|mode advanced\n\
2026-05-24T10:00:05Z|ok|mode\n\
2026-05-24T10:00:06Z|ok|messages verbose\n\
2026-05-24T10:00:07Z|ok|export out.zip\n\
2026-05-24T10:00:08Z|ok|rebuild\n\
2026-05-24T10:00:09Z|ok|help\n\
2026-05-24T10:00:10Z|ok|quit\n\
2026-05-24T10:00:11Z|ok|undo\n\
2026-05-24T10:00:12Z|ok|redo\n\
2026-05-24T10:00:13Z|ok|add column T: v (text)\n\
2026-05-24T10:00:14Z|ok|insert into T (id, v) values (1, 'alpha')\n",
);
let events =
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
// Three data/schema commands ran; every app-lifecycle line was
// skipped silently (no panic, no abort, no warnings, no quit).
match events.last().expect("event") {
AppEvent::ReplayCompleted { count, warnings, .. } => {
assert_eq!(*count, 3, "only the 3 write commands ran; events: {events:?}");
assert!(warnings.is_empty(), "these skips are silent; got {warnings:?}");
}
other => panic!("expected ReplayCompleted, got {other:?}"),
}
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.expect("query_data");
assert!(
data_result.columns.iter().any(|c| c == "v"),
"the add-column line applied; columns: {:?}",
data_result.columns,
);
assert_eq!(data_result.rows.len(), 1, "the insert applied");
assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha"));
}
#[test]
fn replay_skips_import_with_a_warning() {
// ADR-0034: `import` is skipped like other app commands, but warns
// — skipping it can leave the replayed state incomplete (the
// imported data is not reconstructed).
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"history.log",
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
2026-05-24T10:00:01Z|ok|import shared.zip as Imported\n",
);
let events =
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
match events.last().expect("event") {
AppEvent::ReplayCompleted { count, warnings, .. } => {
assert_eq!(*count, 1, "only the create ran; events: {events:?}");
assert!(
warnings.iter().any(|w| w.contains("import shared.zip")),
"expected an import skip warning; got {warnings:?}",
);
}
other => panic!("expected ReplayCompleted, got {other:?}"),
}
}
#[test]
fn replay_skips_blank_lines_and_comments() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"seed.commands",
"# this is a comment\n\
\n\
create table T with pk id(int)\n\
\n\
# another comment\n\
# comment with leading whitespace\n\
add column T: name (text)\n\
\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
// Only two non-blank, non-comment lines.
assert_completed(&events, 2);
}
#[test]
fn replay_empty_file_completes_with_zero_commands() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(project.path(), "empty.commands", "");
let events = rt().block_on(async {
run_replay(&db, project.path(), "empty.commands").await
});
assert_completed(&events, 0);
}
#[test]
fn replay_only_comments_completes_with_zero_commands() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"comments.commands",
"# just\n# comments\n\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "comments.commands").await
});
assert_completed(&events, 0);
}
#[test]
fn replay_constraint_failure_shows_real_names_not_placeholders() {
// F2 follow-up (ADR-0035 Amendment 1): a replayed command that hits a
// UNIQUE violation renders with the REAL table/column/value (enriched
// like the interactive path) — never a literal `{table}` / `{column}`
// / `{value}` placeholder. Before the fix, replay rendered via a
// contextless `friendly_message()` and leaked the markers.
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"dup.commands",
"create table T with pk id(int)\n\
add column T: email (text)\n\
add constraint unique to T.email\n\
insert into T (id, email) values (1, 'a@b.com')\n\
insert into T (id, email) values (2, 'a@b.com')\n",
);
let events = rt().block_on(async { run_replay(&db, project.path(), "dup.commands").await });
let failed = assert_failed_at(&events, 5);
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
};
// No unsubstituted placeholders.
assert!(
!error.contains("{table}") && !error.contains("{column}") && !error.contains("{value}"),
"no unsubstituted placeholders; got: {error}"
);
// The real table + column are shown (from the engine message), and —
// since ADR-0036 Phase 1 retains the captured literal on the SQL
// INSERT command — the **real offending value** is shown too (it used
// to degrade to the neutral "that value" because `SqlInsert` discarded
// its literals).
assert!(error.contains("T.email"), "names the real table.column; got: {error}");
assert!(error.contains("a@b.com"), "shows the real offending value; got: {error}");
}
#[test]
fn replay_missing_file_fails_with_line_number_zero() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
let events = rt().block_on(async {
run_replay(&db, project.path(), "no-such-file.commands").await
});
let failed = assert_failed_at(&events, 0);
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
};
assert!(
error.contains("could not open"),
"expected `could not open` in error: {error}"
);
}
#[test]
fn replay_aborts_on_first_parse_failure_and_reports_line() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"bad.commands",
// Line 1: ok. Line 2: ok. Line 3: parse error
// (`broken keyword X` — not a recognised command).
"create table T with pk id(int)\n\
add column T: name (text)\n\
this is not a command\n\
insert into T (1, 'should not happen')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "bad.commands").await
});
let failed = assert_failed_at(&events, 3);
let AppEvent::ReplayFailed { error, command, .. } = failed else {
unreachable!()
};
assert!(error.contains("parse error"), "got: {error}");
assert_eq!(command, "this is not a command");
// The failing line stops dispatch — no row was inserted —
// but earlier commands stayed applied (table T exists with
// the `name` column).
let desc = rt()
.block_on(async { db.describe_table("T".to_string(), None).await })
.expect("describe_table");
assert!(
desc.columns.iter().any(|c| c.name == "name"),
"earlier add column should have stayed applied"
);
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.expect("query_data");
assert!(
data_result.rows.is_empty(),
"post-failure insert should not have run"
);
}
#[test]
fn replay_rejects_wrong_type_value_in_a_hand_built_script() {
// Replay parses each line with the SAME schema-aware parser the
// interactive path uses, in **advanced mode** (the full surface),
// and executes the result — so a replayed line behaves exactly as
// if it had been typed interactively in advanced mode. Nothing is
// skipped or simplified during replay (handoff-13 §2.1: the schema
// is threaded so the parser is fully schema-aware).
//
// A real journal only ever contains commands that already executed
// successfully (history.log is success-only; ADR-0034's deferred
// journal replays `ok` lines only), so a wrong-type line like this
// never arises from a genuine replay. It only arises from a
// *hand-built* `.commands` script — the robustness case this test
// exercises: replay must reject the bad line and stop, leaving
// state intact, with the same error a user would see typing it.
//
// Where the rejection lands depends on the grammar the line
// matches, exactly as interactively: `insert into T values (…)` is
// SQL in advanced mode, and SQL defers column-type checking to the
// engine, so `'not a number'` in the int `count` column is rejected
// at **execute** time (the engine's column-type enforcement) rather
// than at parse time. Either way the line fails and is not applied.
// (Before sub-phase 3j, `insert` was a DSL-only entry word, so even
// advanced-mode parsing hit the DSL typed-slot rail and this was a
// parse-time rejection — ADR-0033 Amendment 3.)
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"typed.commands",
"create table T with pk id(int)\n\
add column T: count (int)\n\
insert into T values (1, 'not a number')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "typed.commands").await
});
let failed = assert_failed_at(&events, 3);
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
};
assert!(
!error.is_empty(),
"the rejected line must carry a reported error",
);
// The earlier two lines stayed applied; the failing insert
// did not run — state is intact.
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.expect("query_data");
assert!(
data_result.rows.is_empty(),
"the rejected insert must not have dispatched",
);
}
#[test]
fn replay_aborts_on_first_runtime_failure_and_reports_line() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"bad.commands",
// Line 2 references a table that doesn't exist; the
// engine refuses, replay stops and reports line 2.
"create table T with pk id(int)\n\
add column NotATable: x (text)\n\
insert into T (1)\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "bad.commands").await
});
let _ = assert_failed_at(&events, 2);
}
#[test]
fn replay_skips_nested_replay_with_a_warning() {
// ADR-0034: a nested `replay` is no longer refused (which would
// force a user to hand-edit a journal that happens to contain a
// `replay` they once ran). It is skipped — sidestepping the
// infinite-loop footgun by construction — and warned about,
// because the nested file's commands are not reconstructed.
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(project.path(), "inner.commands", "create table T with pk id(int)\n");
write_script(
project.path(),
"outer.commands",
"create table U with pk id(int)\nreplay inner.commands\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "outer.commands").await
});
// The outer `create table U` ran; the nested `replay` was
// skipped (count 1), with a warning.
match events.last().expect("event") {
AppEvent::ReplayCompleted { count, warnings, .. } => {
assert_eq!(*count, 1, "only the outer create ran; events: {events:?}");
assert!(
warnings.iter().any(|w| w.contains("nested") && w.contains("replay inner.commands")),
"expected a nested-replay skip warning; got {warnings:?}",
);
}
other => panic!("expected ReplayCompleted (nested replay skipped), got {other:?}"),
}
// The nested file's table was NOT created (the replay was skipped).
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None, None).await });
assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
}
#[test]
fn replay_history_log_records_subcommands_only() {
// Per handoff §A3: replaying produces the same per-command
// history.log entries as if each line had been typed
// interactively. The replay invocation itself MUST NOT
// appear in history.log (otherwise `replay history.log`
// would re-trigger itself recursively).
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"seed.commands",
"create table T with pk id(int)\nadd column T: name (text)\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
assert_completed(&events, 2);
let history = fs::read_to_string(project.path().join("history.log"))
.expect("history.log exists");
// Per-command entries landed.
assert!(
history.lines().any(|l| l.contains("create table T with pk id(int)")),
"history.log missing create line:\n{history}"
);
assert!(
history.lines().any(|l| l.contains("add column T: name (text)")),
"history.log missing add column line:\n{history}"
);
// The replay invocation itself did NOT land — that's
// the App layer's responsibility (Action::Replay never
// reaches the per-command persistence path).
assert!(
!history.lines().any(|l| l.contains("replay seed.commands")),
"history.log unexpectedly contains the replay invocation:\n{history}"
);
}
File diff suppressed because it is too large Load Diff
+360
View File
@@ -0,0 +1,360 @@
//! Sub-phase 4d integration tests for advanced-mode SQL
//! `CREATE [UNIQUE] INDEX [IF NOT EXISTS]` (ADR-0035 §4d).
//!
//! `SqlCreateIndex` executes through the same `do_add_index` machinery
//! as the simple `add index`, plus the `unique` flag and the
//! `IF NOT EXISTS` no-op-with-note (`CreateIndexOutcome::Skipped`).
//! Parsing (text → `Command::SqlCreateIndex`) is covered by the
//! `sql_create_index_tests` in `src/dsl/grammar/ddl.rs`.
use rdbms_playground::db::{CreateIndexOutcome, Database};
use rdbms_playground::dsl::{ColumnSpec, Type, Value};
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(undo: bool) -> (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_and_undo(project.db_path(), persistence, undo)
.expect("open db with persistence");
(project, db, dir)
}
/// Create `T (id int primary key, email text)`.
fn make_t(db: &Database, r: &tokio::runtime::Runtime) {
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("email", Type::Text)],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table T (id int primary key, email text)".to_string()),
))
.expect("create T");
}
fn insert_row(db: &Database, r: &tokio::runtime::Runtime, id: i64, email: &str) -> bool {
r.block_on(db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "email".to_string()]),
vec![Value::Number(id.to_string()), Value::Text(email.to_string())],
Some(format!("insert into T (id, email) values ({id}, '{email}')")),
))
.is_ok()
}
fn index(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<(Vec<String>, bool)> {
r.block_on(db.describe_table("T".to_string(), None))
.expect("describe")
.indexes
.into_iter()
.find(|i| i.name == name)
.map(|i| (i.columns, i.unique))
}
#[test]
fn create_plain_index() {
let (_p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
let out = r
.block_on(db.sql_create_index(
Some("ix".to_string()),
"T".to_string(),
vec!["email".to_string()],
false,
false,
Some("create index ix on T (email)".to_string()),
))
.expect("create index");
assert!(matches!(out, CreateIndexOutcome::Created(_)));
assert_eq!(index(&db, &r, "ix"), Some((vec!["email".to_string()], false)));
}
#[test]
fn create_unique_index_round_trips_and_survives_rebuild_and_enforces() {
let (p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
r.block_on(db.sql_create_index(
Some("ux".to_string()),
"T".to_string(),
vec!["email".to_string()],
true,
false,
Some("create unique index ux on T (email)".to_string()),
))
.expect("create unique index");
// Reported as unique.
assert_eq!(index(&db, &r, "ux"), Some((vec!["email".to_string()], true)));
// Persisted to project.yaml as a unique index.
let yaml = std::fs::read_to_string(p.path().join("project.yaml")).expect("read project.yaml");
assert!(yaml.contains("unique: true"), "project.yaml:\n{yaml}");
// Uniqueness is enforced by the engine.
assert!(insert_row(&db, &r, 1, "a@x"));
assert!(!insert_row(&db, &r, 2, "a@x"), "duplicate email refused by the unique index");
// Rebuild from the text artifacts: the index comes back UNIQUE
// (the rebuild re-emits CREATE UNIQUE INDEX), not demoted to plain.
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild");
assert_eq!(
index(&db, &r, "ux"),
Some((vec!["email".to_string()], true)),
"the unique flag survived rebuild"
);
// Still enforced after rebuild.
assert!(!insert_row(&db, &r, 3, "a@x"), "uniqueness enforced after rebuild too");
}
#[test]
fn create_unique_index_on_duplicate_data_is_refused() {
let (_p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
assert!(insert_row(&db, &r, 1, "dup@x"));
assert!(insert_row(&db, &r, 2, "dup@x"));
// A unique index can't be created over columns that already hold
// duplicate values — the engine refuses at creation.
let res = r.block_on(db.sql_create_index(
Some("ux".to_string()),
"T".to_string(),
vec!["email".to_string()],
true,
false,
Some("create unique index ux on T (email)".to_string()),
));
assert!(res.is_err(), "unique index over duplicate data is refused");
}
#[test]
fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() {
let (p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
r.block_on(db.sql_create_index(
Some("ix".to_string()),
"T".to_string(),
vec!["email".to_string()],
false,
false,
Some("create index ix on T (email)".to_string()),
))
.expect("first create");
// A second IF NOT EXISTS create of the same name is a no-op.
let line = "create index if not exists ix on T (email)";
let out = r
.block_on(db.sql_create_index(
Some("ix".to_string()),
"T".to_string(),
vec!["email".to_string()],
false,
true,
Some(line.to_string()),
))
.expect("IF NOT EXISTS on an existing index name succeeds as a no-op");
match out {
CreateIndexOutcome::Skipped(name) => assert_eq!(name, "ix"),
CreateIndexOutcome::Created(_) => panic!("expected Skipped, got Created"),
}
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
assert!(log.contains(line), "the skipped create should be journalled; log:\n{log}");
}
#[test]
fn unnamed_if_not_exists_skips_when_the_auto_named_index_exists() {
// The unnamed form resolves the auto-name `<T>_<cols>_idx`; the skip
// pre-check must resolve the SAME name (shared `resolve_index_name`).
// First an unnamed create (auto-named T_email_idx), then an unnamed
// IF NOT EXISTS create of the same columns → skip on the auto-name.
let (_p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
r.block_on(db.sql_create_index(
None,
"T".to_string(),
vec!["email".to_string()],
false,
false,
Some("create index on T (email)".to_string()),
))
.expect("unnamed create");
let out = r
.block_on(db.sql_create_index(
None,
"T".to_string(),
vec!["email".to_string()],
false,
true,
Some("create index if not exists on T (email)".to_string()),
))
.expect("unnamed IF NOT EXISTS over the auto-named index is a no-op");
match out {
CreateIndexOutcome::Skipped(name) => assert_eq!(name, "T_email_idx"),
CreateIndexOutcome::Created(_) => panic!("expected Skipped on the auto-name, got Created"),
}
}
#[test]
fn if_not_exists_short_circuits_only_a_name_collision() {
// `IF NOT EXISTS` skips only when the *name* already exists. A
// *different*-named create over already-indexed columns is not a
// name collision, so it still hits the ADR-0025 redundant-set guard
// (the playground's pedagogical refusal, not raw-SQL semantics).
let (_p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
r.block_on(db.sql_create_index(
Some("ix".to_string()),
"T".to_string(),
vec!["email".to_string()],
false,
false,
Some("create index ix on T (email)".to_string()),
))
.expect("first create");
// Same columns, a *new* name, with IF NOT EXISTS → not a name
// collision, so the redundant-set refusal still fires.
let res = r.block_on(db.sql_create_index(
Some("ix2".to_string()),
"T".to_string(),
vec!["email".to_string()],
false,
true,
Some("create index if not exists ix2 on T (email)".to_string()),
));
assert!(
res.is_err(),
"IF NOT EXISTS does not bypass the redundant-column-set guard for a new name"
);
}
#[test]
fn plain_duplicate_name_errors() {
let (_p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
r.block_on(db.sql_create_index(
Some("ix".to_string()),
"T".to_string(),
vec!["email".to_string()],
false,
false,
Some("create index ix on T (email)".to_string()),
))
.expect("first create");
// Same name again, *without* IF NOT EXISTS → error.
let res = r.block_on(db.sql_create_index(
Some("ix".to_string()),
"T".to_string(),
vec!["id".to_string()],
false,
false,
Some("create index ix on T (id)".to_string()),
));
assert!(res.is_err(), "duplicate index name without IF NOT EXISTS errors");
}
#[test]
fn plain_and_unique_over_the_same_columns_are_not_duplicates() {
// The redundant-set guard keys on (columns, unique): a plain and a
// unique index over the same columns are distinct (different
// semantics). They need distinct explicit names (the auto-name would
// collide).
let (_p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
r.block_on(db.sql_create_index(
Some("ix_plain".to_string()),
"T".to_string(),
vec!["email".to_string()],
false,
false,
Some("create index ix_plain on T (email)".to_string()),
))
.expect("plain");
r.block_on(db.sql_create_index(
Some("ix_unique".to_string()),
"T".to_string(),
vec!["email".to_string()],
true,
false,
Some("create unique index ix_unique on T (email)".to_string()),
))
.expect("unique over the same columns is allowed (distinct kind)");
assert_eq!(index(&db, &r, "ix_plain").map(|(_, u)| u), Some(false));
assert_eq!(index(&db, &r, "ix_unique").map(|(_, u)| u), Some(true));
// But an *exact* duplicate (same columns AND same uniqueness) is
// still refused.
let res = r.block_on(db.sql_create_index(
Some("ix_plain2".to_string()),
"T".to_string(),
vec!["email".to_string()],
false,
false,
Some("create index ix_plain2 on T (email)".to_string()),
));
assert!(res.is_err(), "a second plain index over the same columns is redundant");
}
#[test]
fn create_index_on_an_internal_table_is_refused_on_both_surfaces() {
// Internal `__rdbms_*` tables are hidden from the user; indexing one
// is refused as "no such table" — via the SQL surface and the simple
// `add index` surface alike (the guard lives in the shared
// `do_add_index`, ADR-0035 §4d).
let (_p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
// SQL CREATE INDEX on an internal table → error.
let sql = r.block_on(db.sql_create_index(
Some("bad".to_string()),
"__rdbms_playground_columns".to_string(),
vec!["table_name".to_string()],
false,
false,
Some("create index bad on __rdbms_playground_columns (table_name)".to_string()),
));
assert!(sql.is_err(), "SQL CREATE INDEX on an internal table is refused");
// Simple `add index` on an internal table → error (same guard).
let dsl = r.block_on(db.add_index(
Some("bad2".to_string()),
"__rdbms_playground_columns".to_string(),
vec!["table_name".to_string()],
Some("add index as bad2 on __rdbms_playground_columns (table_name)".to_string()),
));
assert!(dsl.is_err(), "simple add index on an internal table is refused");
}
#[test]
fn create_index_is_one_undo_step() {
let (_p, db, _d) = open(true); // undo enabled
let r = rt();
make_t(&db, &r);
r.block_on(db.sql_create_index(
Some("ix".to_string()),
"T".to_string(),
vec!["email".to_string()],
true,
false,
Some("create unique index ix on T (email)".to_string()),
))
.expect("create index");
assert!(index(&db, &r, "ix").is_some());
// One undo removes the index.
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the create was one undo step");
assert!(index(&db, &r, "ix").is_none(), "undo removed the index");
}
File diff suppressed because it is too large Load Diff
+466
View File
@@ -0,0 +1,466 @@
//! Sub-phase 3f integration tests for the advanced-mode SQL
//! `DELETE` surface (ADR-0033 §1/§7).
//!
//! Covers the parse path (the dev `sql_delete` scaffold lowers to
//! `Command::SqlDelete`, reconstructing valid `delete from …` SQL)
//! and the worker round-trip (execute, detect FK cascade by
//! row-count diffing per ADR-0033 Amendment 2, re-persist the
//! target *and every cascade-affected child* CSV, append
//! `history.log`). A SQL `DELETE` without `WHERE` runs across all
//! rows with no rail (ADR-0030 §12).
//!
//! The cascade tests pin the Amendment-2 decision: the SQL path
//! uses the *same* count-diff mechanism as the DSL `do_delete`, so
//! the two produce identical `DeleteResult.cascade` on identical
//! schema/data (the `cascade_parity_with_dsl` test asserts this
//! directly). The R2 invariant — a WHERE that itself contains a
//! subquery — is correct by construction because the verbatim SQL
//! executes once and the diff observes the result; no predicate
//! bytes are extracted.
use rdbms_playground::db::{Database, DbError, DeleteResult};
use rdbms_playground::dsl::{
ColumnSpec, Command, ReferentialAction, RowFilter, Type, Value, 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(),
false,
))
.unwrap_or_else(|e| panic!("seed {sql:?}: {e:?}"));
}
/// Full-stack: parse the dev `sql_delete …` scaffold and run it.
fn run_delete(
db: &Database,
rt: &tokio::runtime::Runtime,
input: &str,
) -> Result<DeleteResult, DbError> {
match parse_command(input).expect("parse delete") {
Command::SqlDelete { sql, target_table, returning } => {
rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning))
}
other => panic!("expected Command::SqlDelete, got {other:?}"),
}
}
/// `Customers (id int pk, Name text)` parent and `Orders (id int
/// pk, CustId int)` child, with `Customers.id → Orders.CustId`
/// `ON DELETE CASCADE`. Seeds Alice (1) with two orders (10, 11)
/// and Bob (2) with one order (12).
fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
create_cols(db, rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]);
create_cols(db, rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
rt.block_on(db.add_relationship(
Some("places".to_string()),
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
None,
))
.expect("add cascade relationship");
seed(db, rt, "insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')", "Customers");
seed(db, rt, "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)", "Orders");
}
#[test]
fn parse_path_lowers_sql_delete_to_command() {
let command = parse_command("delete from Orders where id = 1")
.expect("delete parses in advanced mode");
match command {
Command::SqlDelete { sql, target_table, .. } => {
assert_eq!(sql, "delete from Orders where id = 1");
assert_eq!(target_table, "Orders");
}
other => panic!("expected Command::SqlDelete, got {other:?}"),
}
}
#[test]
fn delete_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, 'gone'), (2, 'keep')", "t");
let result = run_delete(&db, &rt, "delete from t where id = 1").expect("delete runs");
assert_eq!(result.rows_affected, 1, "one row deleted");
assert!(result.cascade.is_empty(), "no children, no cascade");
let csv = read_csv(&project, "t").expect("t.csv");
assert!(csv.contains("keep"), "untouched row preserved: {csv:?}");
assert!(!csv.contains("gone"), "deleted row removed from CSV: {csv:?}");
}
#[test]
fn delete_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), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'a'), (2, 'b'), (3, 'c')", "t");
let result = run_delete(&db, &rt, "delete from t").expect("unfiltered delete runs");
assert_eq!(result.rows_affected, 3, "all rows deleted");
// Empty tables produce no CSV (CLAUDE.md persistence note), so the
// file is either absent or has only a header — either way, no data.
let csv = read_csv(&project, "t").unwrap_or_default();
assert!(!csv.contains('a') && !csv.contains('b') && !csv.contains('c'), "no rows left: {csv:?}");
let remaining = rt
.block_on(db.query_data("t".to_string(), None, None, None))
.expect("query t");
assert!(remaining.rows.is_empty(), "table empty after unfiltered delete");
}
#[test]
fn cascade_delete_reports_summary_and_repersists_child() {
let (project, db, _dir) = open_project_db();
let rt = rt();
cascade_fixture(&db, &rt);
// Delete Alice (customer 1) — cascades to her two orders (10, 11).
let result = run_delete(&db, &rt, "delete from Customers where id = 1")
.expect("cascading delete runs");
assert_eq!(result.rows_affected, 1, "one parent row deleted");
assert_eq!(result.cascade.len(), 1, "one cascade relationship reported");
let effect = &result.cascade[0];
assert_eq!(effect.relationship_name, "places");
assert_eq!(effect.child_table, "Orders");
// rows_changed == 2 pins the before-execute ordering: counted
// after the delete it would be 0. Alice had exactly two orders.
assert_eq!(effect.rows_changed, 2, "both of Alice's orders cascaded");
// The child CSV must be re-persisted to reflect the cascade.
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
assert!(orders_csv.contains("12"), "Bob's order (12) preserved: {orders_csv:?}");
assert!(!orders_csv.contains("10") && !orders_csv.contains("11"),
"Alice's cascaded orders gone from CSV: {orders_csv:?}");
}
#[test]
fn cascade_parity_with_dsl() {
// ADR-0033 §2 / Amendment 2: the SQL DELETE cascade summary must
// match the DSL `do_delete` output on the same schema/data —
// because they use the identical count-diff mechanism. Run the
// same operation through both paths on two identical fixtures and
// compare the cascade vectors directly (CascadeEffect: PartialEq).
let rt = rt();
let (_p_sql, db_sql, _d_sql) = open_project_db();
cascade_fixture(&db_sql, &rt);
let sql_result = run_delete(&db_sql, &rt, "delete from Customers where id = 1")
.expect("SQL delete runs");
let (_p_dsl, db_dsl, _d_dsl) = open_project_db();
cascade_fixture(&db_dsl, &rt);
let dsl_result = rt
.block_on(db_dsl.delete(
"Customers".to_string(),
RowFilter::eq("id", Value::Number("1".to_string())),
Some("delete from Customers where id = 1".to_string()),
))
.expect("DSL delete runs");
assert_eq!(sql_result.rows_affected, dsl_result.rows_affected, "row counts match");
assert_eq!(sql_result.cascade, dsl_result.cascade, "cascade summaries identical");
}
#[test]
fn r2_where_with_subquery() {
// R2 invariant (ADR-0033 §7 / Amendment 2): a WHERE containing a
// subquery. Plan shape: `DELETE FROM orders WHERE customer_id IN
// (SELECT id FROM customers WHERE …)`. The verbatim statement
// executes once; no predicate extraction. Orders has no children,
// so cascade is empty — the point is the subquery resolves.
let (project, db, _dir) = open_project_db();
let rt = rt();
cascade_fixture(&db, &rt);
let result = run_delete(
&db,
&rt,
"delete from Orders where CustId in (select id from Customers where Name = 'Alice')",
)
.expect("subquery-WHERE delete runs");
assert_eq!(result.rows_affected, 2, "Alice's two orders deleted");
assert!(result.cascade.is_empty(), "Orders has no cascade children");
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
assert!(orders_csv.contains("12"), "Bob's order preserved: {orders_csv:?}");
assert!(!orders_csv.contains("10") && !orders_csv.contains("11"),
"Alice's orders deleted: {orders_csv:?}");
}
#[test]
fn r2_cascade_with_subquery_where() {
// The strongest R2 case: the parent is the DELETE target AND the
// WHERE subquery reads the very child table that will be cascade-
// deleted. The engine evaluates the subquery against pre-delete
// state, deletes the matched parent, then cascades — and the
// count-diff observes the child rows that vanished. Pins both the
// subquery correctness and the before-execute ordering together.
let (project, db, _dir) = open_project_db();
let rt = rt();
cascade_fixture(&db, &rt);
// order 11 belongs to Alice (CustId 1); the subquery yields 1, so
// Alice is deleted and BOTH her orders (10, 11) cascade.
let result = run_delete(
&db,
&rt,
"delete from Customers where id in (select CustId from Orders where id = 11)",
)
.expect("cascade + subquery-WHERE delete runs");
assert_eq!(result.rows_affected, 1, "Alice deleted");
assert_eq!(result.cascade.len(), 1, "one cascade relationship");
assert_eq!(result.cascade[0].rows_changed, 2, "both Alice orders cascaded");
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
assert!(orders_csv.contains("12") && !orders_csv.contains("10") && !orders_csv.contains("11"),
"only Bob's order remains: {orders_csv:?}");
}
#[test]
fn delete_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, 'x')", "t");
let input = "delete from t where id = 1";
run_delete(&db, &rt, input).expect("delete 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:?}");
}
#[test]
fn cascade_to_two_children_reports_both() {
// DA gate (untested branch): a parent with TWO cascade children
// must emit a CascadeEffect per affected child, and re-persist
// both. The single-relationship tests never exercise the loop
// emitting more than one effect.
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]);
create_cols(&db, &rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
create_cols(&db, &rt, "Reviews", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
for (child, name) in [("Orders", "places"), ("Reviews", "writes")] {
rt.block_on(db.add_relationship(
Some(name.to_string()),
"Customers".to_string(),
"id".to_string(),
child.to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
None,
))
.unwrap_or_else(|e| panic!("add rel {name}: {e:?}"));
}
seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers");
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1), (11, 1)", "Orders");
seed(&db, &rt, "insert into Reviews (id, CustId) values (20, 1)", "Reviews");
let result = run_delete(&db, &rt, "delete from Customers where id = 1")
.expect("cascade-to-two delete runs");
assert_eq!(result.rows_affected, 1);
assert_eq!(result.cascade.len(), 2, "both cascade relationships reported");
let by_child: std::collections::HashMap<&str, i64> = result
.cascade
.iter()
.map(|e| (e.child_table.as_str(), e.rows_changed))
.collect();
assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded");
assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded");
// Both child CSVs re-persisted to the post-cascade (empty) state.
let orders = rt.block_on(db.query_data("Orders".to_string(), None, None, None)).unwrap();
let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None, None)).unwrap();
assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied");
let _ = &project;
}
#[test]
fn delete_childless_parent_reports_no_cascade() {
// DA gate (untested branch): a cascade relationship EXISTS, but
// the deleted parent row has no children. The `diff > 0` guard
// must yield an empty cascade and must NOT touch the child's CSV
// (a `>= 0` regression would report a phantom 0-row cascade).
let (project, db, _dir) = open_project_db();
let rt = rt();
cascade_fixture(&db, &rt);
// Carol (3) exists with no orders; deleting her cascades nothing.
seed(&db, &rt, "insert into Customers (id, Name) values (3, 'Carol')", "Customers");
let result = run_delete(&db, &rt, "delete from Customers where id = 3")
.expect("childless-parent delete runs");
assert_eq!(result.rows_affected, 1, "Carol deleted");
assert!(result.cascade.is_empty(), "no children → no cascade effect reported");
// All three orders untouched.
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
assert!(
orders_csv.contains("10") && orders_csv.contains("11") && orders_csv.contains("12"),
"no order touched by a childless-parent delete: {orders_csv:?}"
);
}
#[test]
fn delete_violating_fk_fails_and_persists_nothing() {
// DA gate (untested error path): with an ON DELETE NO ACTION
// child, deleting a referenced parent is rejected by the engine.
// `do_sql_delete` runs persistence+history INSIDE the tx AFTER a
// successful execute, so a rejected delete must roll back: the
// parent row survives and history records no line.
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]);
create_cols(&db, &rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
rt.block_on(db.add_relationship(
Some("places".to_string()),
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::NoAction, // on delete: reject if referenced
ReferentialAction::NoAction,
false,
None,
))
.expect("add NO ACTION relationship");
seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers");
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1)", "Orders");
let input = "delete from Customers where id = 1";
let result = run_delete(&db, &rt, input);
assert!(result.is_err(), "delete of a referenced parent must be rejected");
// Rolled back: Alice survives.
let customers = rt.block_on(db.query_data("Customers".to_string(), None, None, None)).unwrap();
assert_eq!(customers.rows.len(), 1, "parent row preserved after rejected delete");
// No history line for the failed statement (written only on success).
let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default();
assert!(!history.contains(input), "failed delete not logged: {history:?}");
}
#[test]
fn self_referential_cascade_counts_only_cascaded_rows() {
// A self-referential ON DELETE CASCADE FK: deleting the root of a
// chain cascades down within the same table. The directly-deleted
// row is reported in rows_affected, so the cascade summary must
// report only the *additional* rows removed via the self-
// reference — not the raw before/after diff (which includes the
// direct delete). Without the self-ref correction this reports 3.
let (_project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "T", &[("id", Type::Int), ("ParentId", Type::Int)], &["id"]);
rt.block_on(db.add_relationship(
Some("parent_of".to_string()),
"T".to_string(),
"id".to_string(),
"T".to_string(),
"ParentId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
None,
))
.expect("add self-referential relationship");
seed(&db, &rt, "insert into T (id, ParentId) values (1, null), (2, 1), (3, 2)", "T");
let result =
run_delete(&db, &rt, "delete from T where id = 1").expect("self-ref delete runs");
assert_eq!(result.rows_affected, 1, "one row matched the WHERE directly");
assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once");
assert_eq!(
result.cascade[0].rows_changed, 2,
"only the 2 cascaded rows, not the directly-deleted root too"
);
}
#[test]
fn internal_target_table_rejected_at_parse() {
// ADR-0030 §6 / ADR-0033 §1: the `__rdbms_*` metadata tables are
// rejected at the target slot — the parse fails, the statement
// never reaches the worker.
assert!(
parse_command("delete from __rdbms_playground_columns").is_err(),
"internal table must be rejected at the DELETE target slot"
);
}
// =================================================================
// Sub-phase 3g — RETURNING (ADR-0033 §5)
// =================================================================
#[test]
fn delete_returning_yields_predelete_row() {
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, 'gone'), (2, 'keep')", "t");
let result = run_delete(&db, &rt, "delete from t where id = 1 returning *")
.expect("DELETE … RETURNING * runs");
assert_eq!(result.rows_affected, 1, "one row deleted");
// RETURNING yields the row as it was BEFORE deletion.
assert_eq!(result.data.rows.len(), 1, "the deleted row was returned");
assert_eq!(result.data.rows[0][1], Some("gone".to_string()));
// And it really is gone from the table.
let csv = read_csv(&project, "t").expect("t.csv");
assert!(!csv.contains("gone") && csv.contains("keep"), "row actually deleted: {csv:?}");
}
#[test]
fn delete_returning_with_cascade_surfaces_both() {
// 3g: a parent DELETE … RETURNING must surface BOTH the returned
// (deleted) parent rows AND the per-relationship cascade summary.
let (_project, db, _dir) = open_project_db();
let rt = rt();
cascade_fixture(&db, &rt);
let result = run_delete(&db, &rt, "delete from Customers where id = 1 returning *")
.expect("cascading DELETE … RETURNING runs");
assert_eq!(result.rows_affected, 1, "one parent row deleted");
// RETURNING gave the deleted customer row.
assert_eq!(result.data.rows.len(), 1, "deleted parent row returned");
// Cascade summary still computed alongside the result set.
assert_eq!(result.cascade.len(), 1, "cascade reported");
assert_eq!(result.cascade[0].child_table, "Orders");
assert_eq!(result.cascade[0].rows_changed, 2, "both Alice's orders cascaded");
}
+603
View File
@@ -0,0 +1,603 @@
//! Sub-phase 3k — Tier-3 end-to-end DML integration tests
//! (ADR-0033, plan `docs/plans/20260520-adr-0033-phase-3.md`
//! "Sub-phase 3k").
//!
//! Where the per-sub-phase `tests/sql_{insert,update,delete}.rs`
//! suites drive the worker directly with hand-written arguments,
//! these tests exercise the **full advanced-mode path**: a literal
//! line is parsed in Advanced mode (the same `parse_command`
//! dispatch the runtime uses), the resulting `Command::Sql*` is
//! executed through the worker, and the persisted CSV / history /
//! result set are asserted. They cover the real-world DML shapes
//! the 3k exit gate lists:
//!
//! - `INSERT … SELECT` cross-table
//! - multi-row `INSERT` covering all ten playground types, with
//! `RETURNING` recovering every type (matrix R5)
//! - `UPDATE` with a subquery in `SET`
//! - `DELETE` with cascade (per-relationship summary + multi-table
//! re-persistence)
//! - `UPSERT` round-trip (`DO UPDATE` then `DO NOTHING`)
//! - `RETURNING` on each of `INSERT` / `UPDATE` / `DELETE`
//! - `history.log` replay of every Phase-3 statement form
//! - the OOS parse-rejections (ADR-0033 §13)
//! - the `[ERR]`/`[WRN]` validity indicator firing on a SQL DML
//! diagnostic (matrix A7)
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use rdbms_playground::app::App;
use rdbms_playground::db::{Database, DbError, DeleteResult, InsertResult, UpdateResult};
use rdbms_playground::dsl::parser::parse_command_in_mode;
use rdbms_playground::dsl::walker::Severity;
use rdbms_playground::dsl::{
ColumnSpec, Command, ReferentialAction, RowFilter, Type, parse_command,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::mode::Mode;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
use rdbms_playground::runtime::run_replay;
use rdbms_playground::theme::Theme;
use rdbms_playground::ui;
// ---------------------------------------------------------------
// Harness — mirrors the per-sub-phase suites' helpers.
// ---------------------------------------------------------------
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:?}"));
}
/// Parse `input` in Advanced mode and run the resulting SQL INSERT
/// through the worker — the full parse → execute path.
fn run_insert(
db: &Database,
rt: &tokio::runtime::Runtime,
input: &str,
) -> Result<InsertResult, DbError> {
match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) {
Command::SqlInsert {
sql,
target_table,
listed_columns,
row_source,
returning,
..
} => rt.block_on(db.run_sql_insert(
sql,
Some(input.to_string()),
target_table,
listed_columns,
row_source,
returning,
)),
other => panic!("expected Command::SqlInsert from {input:?}, got {other:?}"),
}
}
fn run_update(
db: &Database,
rt: &tokio::runtime::Runtime,
input: &str,
) -> Result<UpdateResult, DbError> {
match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) {
Command::SqlUpdate { sql, target_table, returning, set_literals } => rt.block_on(
db.run_sql_update_with_literals(
sql,
Some(input.to_string()),
target_table,
returning,
set_literals,
),
),
other => panic!("expected Command::SqlUpdate from {input:?}, got {other:?}"),
}
}
fn run_delete(
db: &Database,
rt: &tokio::runtime::Runtime,
input: &str,
) -> Result<DeleteResult, DbError> {
match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) {
Command::SqlDelete { sql, target_table, returning } => rt.block_on(
db.run_sql_delete(sql, Some(input.to_string()), target_table, returning),
),
other => panic!("expected Command::SqlDelete from {input:?}, got {other:?}"),
}
}
/// Seed rows through the SQL INSERT path (no auto-gen columns, so
/// the statement executes verbatim).
fn seed(db: &Database, rt: &tokio::runtime::Runtime, sql: &str) {
run_insert(db, rt, sql).unwrap_or_else(|e| panic!("seed {sql:?}: {e:?}"));
}
fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec<Vec<Option<String>>> {
rt.block_on(db.query_data(table.to_string(), None, None, None))
.unwrap_or_else(|e| panic!("query_data {table}: {e:?}"))
.rows
}
// ===============================================================
// INSERT … SELECT cross-table
// ===============================================================
#[test]
fn e2e_insert_select_cross_table_copies_rows_and_persists_both() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "source", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
create_cols(&db, &rt, "archive", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into source (id, v) values (1, 'x'), (2, 'y')");
let result = run_insert(&db, &rt, "insert into archive select * from source")
.expect("INSERT … SELECT runs");
assert_eq!(result.rows_affected, 2, "two source rows copied");
let archive_csv = read_csv(&project, "archive").expect("archive.csv");
assert!(
archive_csv.contains('x') && archive_csv.contains('y'),
"archive reflects both copied rows: {archive_csv:?}",
);
let source_csv = read_csv(&project, "source").expect("source.csv");
assert!(
source_csv.contains('x') && source_csv.contains('y'),
"source is left intact: {source_csv:?}",
);
}
// ===============================================================
// Multi-row INSERT covering all ten playground types + RETURNING
// type recovery for every type (matrix R5).
// ===============================================================
#[test]
fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type() {
let (project, db, _dir) = open_project_db();
let rt = rt();
// serial PK + shortid auto-fill; the other eight columns are
// user-supplied. `blob` has no value-literal grammar yet
// (see src/dsl/value.rs), so it is inserted NULL — its *type*
// still round-trips through the RETURNING column-origin path.
create_cols(
&db,
&rt,
"allten",
&[
("ser", Type::Serial),
("txt", Type::Text),
("i", Type::Int),
("r", Type::Real),
("dec", Type::Decimal),
("flag", Type::Bool),
("d", Type::Date),
("ts", Type::DateTime),
("bl", Type::Blob),
("sid", Type::ShortId),
],
&["ser"],
);
let result = run_insert(
&db,
&rt,
"insert into allten (txt, i, r, dec, flag, d, ts, bl) values \
('hi', 42, 1.5, 9.50, true, '2026-05-23', '2026-05-23 10:00:00', null), \
('yo', 7, 2.5, 3.25, false, '2025-01-01', '2025-01-01 00:00:00', null) \
returning ser, txt, i, r, dec, flag, d, ts, bl, sid",
)
.expect("multi-row INSERT … RETURNING runs");
assert_eq!(result.rows_affected, 2, "two rows inserted");
assert_eq!(result.data.rows.len(), 2, "RETURNING yields both rows");
// Every one of the ten playground types is recovered via the
// RETURNING column-origin path (matrix R5).
assert_eq!(
result.data.column_types,
vec![
Some(Type::Serial),
Some(Type::Text),
Some(Type::Int),
Some(Type::Real),
Some(Type::Decimal),
Some(Type::Bool),
Some(Type::Date),
Some(Type::DateTime),
Some(Type::Blob),
Some(Type::ShortId),
],
"RETURNING recovers each of the ten playground types; got {:?}",
result.data.column_types,
);
// Values round-trip: serial auto-incremented (1, 2), shortid
// auto-filled (non-empty + distinct), the user values persisted.
let rows = query(&db, &rt, "allten");
assert_eq!(rows.len(), 2, "both rows persisted");
let csv = read_csv(&project, "allten").expect("allten.csv");
assert!(csv.contains("hi") && csv.contains("yo"), "text round-trips: {csv:?}");
assert!(csv.contains("2026-05-23") && csv.contains("2025-01-01"), "dates round-trip: {csv:?}");
let sids: Vec<&str> = rows.iter().filter_map(|r| r[9].as_deref()).collect();
assert_eq!(sids.len(), 2, "both shortids present");
assert!(sids.iter().all(|s| !s.is_empty()), "shortids non-empty: {sids:?}");
assert_ne!(sids[0], sids[1], "auto-filled shortids are distinct: {sids:?}");
let sers: Vec<&str> = rows.iter().filter_map(|r| r[0].as_deref()).collect();
assert!(sers.contains(&"1") && sers.contains(&"2"), "serial auto-incremented: {sers:?}");
}
// ===============================================================
// UPDATE with a subquery in SET
// ===============================================================
#[test]
fn e2e_update_with_subquery_in_set() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(
&db,
&rt,
"customers",
&[("id", Type::Int), ("name", Type::Text), ("last_order", Type::Int)],
&["id"],
);
create_cols(
&db,
&rt,
"orders",
&[("id", Type::Int), ("cust", Type::Int), ("amount", Type::Int)],
&["id"],
);
seed(&db, &rt, "insert into customers (id, name, last_order) values (1, 'A', 0), (2, 'B', 0)");
seed(&db, &rt, "insert into orders (id, cust, amount) values (10, 1, 50), (11, 1, 30), (12, 2, 99)");
let result = run_update(
&db,
&rt,
"update customers set last_order = \
(select max(amount) from orders where cust = customers.id)",
)
.expect("UPDATE with subquery in SET runs");
assert_eq!(result.rows_affected, 2, "both customers updated");
let rows = query(&db, &rt, "customers");
let c1 = rows.iter().find(|r| r[0].as_deref() == Some("1")).expect("customer 1");
let c2 = rows.iter().find(|r| r[0].as_deref() == Some("2")).expect("customer 2");
assert_eq!(c1[2].as_deref(), Some("50"), "customer 1 → max(50, 30) = 50");
assert_eq!(c2[2].as_deref(), Some("99"), "customer 2 → max(99) = 99");
let csv = read_csv(&project, "customers").expect("customers.csv");
assert!(csv.contains("50") && csv.contains("99"), "CSV reflects the update: {csv:?}");
}
// ===============================================================
// DELETE with cascade — per-relationship summary + multi-table
// re-persistence.
// ===============================================================
fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
create_cols(db, rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]);
create_cols(db, rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
rt.block_on(db.add_relationship(
Some("places".to_string()),
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
None,
))
.expect("add cascade relationship");
seed(db, rt, "insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')");
seed(db, rt, "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)");
}
#[test]
fn e2e_delete_with_cascade_reports_summary_and_repersists_children() {
let (project, db, _dir) = open_project_db();
let rt = rt();
cascade_fixture(&db, &rt);
let result = run_delete(&db, &rt, "delete from Customers where id = 1")
.expect("cascading DELETE runs");
assert_eq!(result.rows_affected, 1, "one parent row deleted");
assert_eq!(result.cascade.len(), 1, "one cascade relationship affected");
let effect = &result.cascade[0];
assert_eq!(effect.child_table, "Orders");
assert_eq!(effect.rows_changed, 2, "Alice's two orders cascaded");
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv re-persisted");
assert!(orders_csv.contains("12"), "Bob's order (12) preserved: {orders_csv:?}");
assert!(!orders_csv.contains("10"), "Alice's order 10 cascaded away: {orders_csv:?}");
assert!(!orders_csv.contains("11"), "Alice's order 11 cascaded away: {orders_csv:?}");
}
// ===============================================================
// UPSERT round-trip — DO UPDATE then DO NOTHING.
// ===============================================================
#[test]
fn e2e_upsert_round_trip_do_update_then_do_nothing() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "kv", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
seed(&db, &rt, "insert into kv (id, name) values (1, 'old')");
// DO UPDATE on a conflict mutates the existing row.
let upd = run_insert(
&db,
&rt,
"insert into kv (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name",
)
.expect("UPSERT DO UPDATE runs");
assert_eq!(upd.rows_affected, 1, "DO UPDATE touches the conflicting row");
let csv = read_csv(&project, "kv").expect("kv.csv");
assert!(csv.contains("new") && !csv.contains("old"), "row updated to 'new': {csv:?}");
// DO NOTHING on a conflict is a no-op.
let nothing = run_insert(
&db,
&rt,
"insert into kv (id, name) values (1, 'ignored') on conflict (id) do nothing",
)
.expect("UPSERT DO NOTHING runs");
assert_eq!(nothing.rows_affected, 0, "DO NOTHING changes no rows");
let csv = read_csv(&project, "kv").expect("kv.csv");
assert!(csv.contains("new") && !csv.contains("ignored"), "row unchanged by DO NOTHING: {csv:?}");
}
// ===============================================================
// RETURNING on each of INSERT / UPDATE / DELETE.
// ===============================================================
#[test]
fn e2e_returning_on_insert_update_delete() {
let (_project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
let ins = run_insert(&db, &rt, "insert into t (id, v) values (1, 'a') returning id, v")
.expect("INSERT … RETURNING runs");
assert_eq!(ins.data.rows.len(), 1, "INSERT RETURNING yields the inserted row");
assert_eq!(ins.data.rows[0][1].as_deref(), Some("a"));
let upd = run_update(&db, &rt, "update t set v = 'b' where id = 1 returning v")
.expect("UPDATE … RETURNING runs");
assert_eq!(upd.data.rows.len(), 1, "UPDATE RETURNING yields the modified row");
assert_eq!(upd.data.rows[0][0].as_deref(), Some("b"));
let del = run_delete(&db, &rt, "delete from t where id = 1 returning *")
.expect("DELETE … RETURNING runs");
assert_eq!(del.data.rows.len(), 1, "DELETE RETURNING yields the pre-delete row");
assert_eq!(del.data.rows[0][1].as_deref(), Some("b"), "pre-delete value surfaced");
assert!(query(&db, &rt, "t").is_empty(), "row is gone after the DELETE");
}
// ===============================================================
// history.log replay of every Phase-3 statement form.
// ===============================================================
#[test]
fn e2e_replay_phase3_dml_forms_from_a_script() {
let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("project");
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.expect("db");
let rt = rt();
// A script of Phase-3 SQL DML forms (plus the DDL needed to set
// up). Replay parses each line in Advanced mode (ADR-0033
// Amendment 3), so the SQL forms route to the SQL worker path.
std::fs::write(
project.path().join("phase3.commands"),
"create table T with pk id(int)\n\
add column T: v (text)\n\
insert into T (id, v) values (1, 'a'), (2, 'b'), (3, 'c')\n\
insert into T select id + 10, v from T where id = 1\n\
update T set v = 'z' where id = 2\n\
delete from T where id = 3\n",
)
.expect("write script");
let events = rt.block_on(run_replay(&db, project.path(), "phase3.commands"));
match events.last().expect("at least one event") {
AppEvent::ReplayCompleted { count, .. } => {
assert_eq!(*count, 6, "all six lines replayed; events: {events:?}");
}
other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"),
}
// Faithful application: multi-row insert + INSERT…SELECT +
// UPDATE + DELETE all landed.
let rows = query(&db, &rt, "T");
let mut by_id: Vec<(String, Option<String>)> = rows
.iter()
.map(|r| (r[0].clone().unwrap_or_default(), r[1].clone()))
.collect();
by_id.sort();
assert_eq!(
by_id,
vec![
("1".to_string(), Some("a".to_string())),
("11".to_string(), Some("a".to_string())), // INSERT…SELECT id+10
("2".to_string(), Some("z".to_string())), // UPDATE
// id 3 was DELETEd
]
.into_iter()
.collect::<std::collections::BTreeMap<_, _>>()
.into_iter()
.collect::<Vec<_>>(),
"replayed DML applied faithfully; got {by_id:?}",
);
}
// ===============================================================
// OOS parse-rejections (ADR-0033 §13) — behaviour confirmed; pin it.
// ===============================================================
#[test]
fn e2e_out_of_scope_dml_forms_parse_reject() {
let cases = [
("OOS-1 DEFAULT VALUES", "insert into t default values"),
("OOS-2 INSERT OR REPLACE", "insert or replace into t values (1)"),
("OOS-2 INSERT OR IGNORE", "insert or ignore into t values (1)"),
("OOS-3 UPDATE … FROM", "update t set a = b.x from other b where t.id = b.id"),
("OOS-4 WITH … UPDATE", "with x as (select 1) update t set a = 1 where id = 1"),
("OOS-4 WITH … DELETE", "with x as (select 1) delete from t where id = 1"),
("OOS-5 INDEXED BY", "delete from t indexed by idx where id = 1"),
("OOS-5 NOT INDEXED", "update t not indexed set a = 1 where id = 1"),
("OOS-6 multi-statement (DELETE; DELETE)", "delete from t where id = 1; delete from t where id = 2"),
("OOS-6 multi-statement (INSERT; INSERT)", "insert into t values (1); insert into t values (2)"),
];
for (label, src) in cases {
assert!(
parse_command_in_mode(src, Mode::Advanced).is_err(),
"{label}: {src:?} must parse-reject (ADR-0033 §13)",
);
}
}
#[test]
fn e2e_single_dml_statement_with_trailing_semicolon_parses() {
// Guard for the OOS-6 multi-statement rejection above: a *single*
// statement with a trailing `;` is still valid (ADR-0033 §1 — the
// optional `;` tail), so the rejection above is genuinely about a
// second statement, not the semicolon.
assert!(
matches!(
parse_command_in_mode("delete from t where id = 1;", Mode::Advanced),
Ok(Command::SqlDelete { .. })
),
"a single statement with a trailing semicolon must still parse",
);
}
#[test]
fn e2e_update_all_rows_in_advanced_falls_back_to_dsl() {
// ADR-0033 Amendment 4 reverses Amendment 3's counter-example: the
// SQL `UPDATE`'s `SET` expression must NOT consume the DSL flag
// `--all-rows`. An adjacent `--` is not two minus operators — the
// playground has no `--` line comment — so the SQL shape fails and
// dispatch falls back to the DSL `Update { AllRows }`, mirroring
// `delete … --all-rows`.
assert!(
matches!(
parse_command_in_mode("update Orders set total = 42 --all-rows", Mode::Advanced),
Ok(Command::Update { filter: RowFilter::AllRows, .. })
),
"advanced `update … --all-rows` falls back to the DSL Update",
);
// Legitimate spaced arithmetic is unaffected — the dashes are not
// adjacent, so this stays a SQL UPDATE (total = 42 - (-3) = 45).
assert!(
matches!(
parse_command_in_mode("update Orders set total = 42 - -3", Mode::Advanced),
Ok(Command::SqlUpdate { .. })
),
"spaced `42 - -3` stays a SQL UPDATE",
);
// An adjacent `--` before a number is no longer silently accepted as
// arithmetic; with no `--all-rows` flag to fall back to, it is a
// parse error (acceptable per Amendment 4 — contrived input, and the
// playground does not support `--` comments).
assert!(
parse_command_in_mode("update Orders set total = 42--3", Mode::Advanced).is_err(),
"adjacent `42--3` is a parse error (no `--` comment support)",
);
}
// ===============================================================
// Validity indicator fires on a SQL DML diagnostic (matrix A7).
// ===============================================================
fn rendered_text(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| ui::render(app, theme, f)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
out.push_str(buffer[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn e2e_validity_indicator_fires_for_sql_dml_diagnostic() {
// ADR-0027 §4 / ADR-0030 §8 / matrix A7: a SQL DML line whose
// WHERE carries a predicate warning (`= NULL`) lights up the
// `[WRN]` indicator in Advanced mode. The verdict is the same
// computation the runtime stores in `input_indicator`.
let mut app = App::new();
app.mode = Mode::Advanced;
// Populate the schema cache so the diagnostic pass resolves
// the column.
app.schema_cache.tables.push("t".to_string());
app.schema_cache.columns.push("v".to_string());
app.schema_cache.table_columns.insert(
"t".to_string(),
vec![rdbms_playground::completion::TableColumn {
name: "v".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
}],
);
app.input = "update t set v = 1 where v = NULL".to_string();
assert_eq!(
app.input_validity_verdict(),
Some(Severity::Warning),
"a SQL DML `= NULL` predicate raises a WARNING verdict",
);
// And the indicator renders the `[WRN]` label.
app.input_indicator = app.input_validity_verdict();
let text = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(text.contains("[WRN]"), "the SQL DML warning surfaces as [WRN]:\n{text}");
}
+123
View File
@@ -0,0 +1,123 @@
//! Sub-phase 4d integration tests for advanced-mode SQL
//! `DROP INDEX [IF EXISTS]` (ADR-0035 §4d).
//!
//! `SqlDropIndex` executes through the same `do_drop_index` machinery as
//! the simple `drop index <name>`; the only new behaviour is `IF EXISTS`
//! as a no-op-with-note (`DropIndexOutcome::Skipped`). These drive the
//! worker directly; parsing (text → `Command::SqlDropIndex`) is covered
//! by the `sql_drop_index_tests` in `src/dsl/grammar/ddl.rs`.
use rdbms_playground::db::{Database, DropIndexOutcome};
use rdbms_playground::dsl::{ColumnSpec, Type};
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(undo: bool) -> (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_and_undo(project.db_path(), persistence, undo)
.expect("open db with persistence");
(project, db, dir)
}
/// Create `T (id int primary key, email text)` and an index on `email`.
fn make_t_with_index(db: &Database, r: &tokio::runtime::Runtime) -> String {
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("email", Type::Text)],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table T (id int primary key, email text)".to_string()),
))
.expect("create T");
let desc = r
.block_on(db.add_index(
Some("T_email_idx".to_string()),
"T".to_string(),
vec!["email".to_string()],
Some("add index as T_email_idx on T (email)".to_string()),
))
.expect("add index");
assert_eq!(desc.indexes.len(), 1, "index created");
"T_email_idx".to_string()
}
fn index_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
r.block_on(db.describe_table("T".to_string(), None))
.expect("describe")
.indexes
.into_iter()
.map(|i| i.name)
.collect()
}
#[test]
fn drop_index_removes_an_existing_index_and_shows_the_table() {
let (_p, db, _d) = open(false);
let r = rt();
let name = make_t_with_index(&db, &r);
let out = r
.block_on(db.sql_drop_index(name, false, Some("drop index T_email_idx".to_string())))
.expect("drop index");
// Dropped carries the de-indexed table's structure (auto-show).
match out {
DropIndexOutcome::Dropped(desc) => {
assert_eq!(desc.name, "T");
assert!(desc.indexes.is_empty(), "the index is gone from the structure");
}
DropIndexOutcome::Skipped => panic!("expected Dropped, got Skipped"),
}
assert!(index_names(&db, &r).is_empty());
}
#[test]
fn if_exists_on_an_absent_index_is_a_noop_and_journalled() {
let (p, db, _d) = open(false);
let r = rt();
let line = "drop index if exists ghost_idx";
let out = r
.block_on(db.sql_drop_index("ghost_idx".to_string(), true, Some(line.to_string())))
.expect("IF EXISTS on an absent index succeeds as a no-op");
assert!(matches!(out, DropIndexOutcome::Skipped));
// The no-op is still journalled (ADR-0034), like the create-skip.
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}");
}
#[test]
fn plain_drop_of_an_absent_index_errors() {
let (_p, db, _d) = open(false);
let r = rt();
let res = r.block_on(db.sql_drop_index(
"ghost_idx".to_string(),
false,
Some("drop index ghost_idx".to_string()),
));
assert!(res.is_err(), "plain DROP INDEX on an absent index errors (no IF EXISTS)");
}
#[test]
fn drop_index_is_one_undo_step_and_restores_the_index() {
let (_p, db, _d) = open(true); // undo enabled
let r = rt();
let name = make_t_with_index(&db, &r);
r.block_on(db.sql_drop_index(name.clone(), false, Some("drop index T_email_idx".to_string())))
.expect("drop index");
assert!(index_names(&db, &r).is_empty());
// One undo brings the index back.
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step");
assert_eq!(index_names(&db, &r), vec![name], "undo restored the index");
}
+155
View File
@@ -0,0 +1,155 @@
//! Sub-phase 4c integration tests for advanced-mode SQL
//! `DROP TABLE [IF EXISTS]` (ADR-0035 §4).
//!
//! `SqlDropTable` executes through the same `do_drop_table` machinery
//! as the simple `drop table` (cascade / inbound-relationship refusal /
//! metadata cleanup); the only new behaviour is `IF EXISTS` as a
//! no-op-with-note (`DropOutcome::Skipped`). These drive the worker
//! directly; parsing (text → `Command::SqlDropTable`) is covered by the
//! `sql_drop_table_tests` in `src/dsl/grammar/ddl.rs`.
use rdbms_playground::db::{Database, DropOutcome};
use rdbms_playground::dsl::{ColumnSpec, SqlForeignKey, Type, Value};
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(undo: bool) -> (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_and_undo(project.db_path(), persistence, undo)
.expect("open db with persistence");
(project, db, dir)
}
/// Create a simple `T (id int primary key, body text)`.
fn make_t(db: &Database, r: &tokio::runtime::Runtime) {
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("body", Type::Text)],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table T (id int primary key, body text)".to_string()),
))
.expect("create T");
}
#[test]
fn drop_table_removes_an_existing_table() {
let (_p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
let out = r
.block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string())))
.expect("drop");
assert!(matches!(out, DropOutcome::Dropped));
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
}
#[test]
fn if_exists_on_an_absent_table_is_a_noop_and_journalled() {
let (p, db, _d) = open(false);
let r = rt();
let line = "drop table if exists Ghost";
let out = r
.block_on(db.sql_drop_table("Ghost".to_string(), true, Some(line.to_string())))
.expect("IF EXISTS on an absent table succeeds as a no-op");
assert!(matches!(out, DropOutcome::Skipped));
// The no-op is still journalled (ADR-0034), like the create-skip.
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}");
}
#[test]
fn plain_drop_of_an_absent_table_errors() {
let (_p, db, _d) = open(false);
let r = rt();
let res = r.block_on(db.sql_drop_table("Ghost".to_string(), false, Some("drop table Ghost".to_string())));
assert!(res.is_err(), "plain DROP TABLE on an absent table errors (no IF EXISTS)");
}
#[test]
fn dropping_a_referenced_parent_is_refused() {
// Parity with `do_drop_table`: a table with inbound relationships
// can't be dropped (ADR-0013), via the SQL path too.
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"parent".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table parent (id serial primary key, label text)".to_string()),
))
.expect("create parent");
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![SqlForeignKey {
name: None,
child_column: "pid".to_string(),
parent_table: "parent".to_string(),
parent_column: Some("id".to_string()),
on_delete: rdbms_playground::dsl::ReferentialAction::NoAction,
on_update: rdbms_playground::dsl::ReferentialAction::NoAction,
}],
false,
Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
))
.expect("create child with FK");
// The parent is referenced — refused (even with IF EXISTS, since the
// table *does* exist; the refusal is about the relationship).
assert!(
r.block_on(db.sql_drop_table("parent".to_string(), false, Some("drop table parent".to_string())))
.is_err(),
"a referenced parent can't be dropped"
);
// Dropping the child first succeeds, then the parent.
r.block_on(db.sql_drop_table("child".to_string(), false, Some("drop table child".to_string())))
.expect("drop child");
r.block_on(db.sql_drop_table("parent".to_string(), false, Some("drop table parent".to_string())))
.expect("now the parent drops");
}
#[test]
fn drop_table_is_one_undo_step_and_restores_data() {
let (_p, db, _d) = open(true); // undo enabled
let r = rt();
make_t(&db, &r);
r.block_on(db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "body".to_string()]),
vec![Value::Number("1".to_string()), Value::Text("hi".to_string())],
Some("insert".to_string()),
))
.expect("row");
r.block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string())))
.expect("drop");
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
// One undo brings the table — and its row — back.
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step");
assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
let data = r
.block_on(db.query_data("T".to_string(), None, None, None))
.expect("query");
assert_eq!(data.rows.len(), 1, "the dropped row was restored by undo");
}
File diff suppressed because it is too large Load Diff
+693
View File
@@ -0,0 +1,693 @@
//! Phase 1 integration tests for the advanced-mode SQL `SELECT`
//! surface (ADR-0030 / ADR-0031).
//!
//! Covers:
//! - Advanced-mode `select` dispatches as `Command::Select`
//! through `App::submit` end to end.
//! - Simple-mode mode gate: `select` is recognised as SQL and
//! yields the precise "this is SQL" hint instead of executing
//! (ADR-0030 §2).
//! - `:` one-shot from simple mode dispatches the SELECT.
//! - `__rdbms_*` internal-table references are rejected at the
//! grammar layer (ADR-0030 §6).
//! - Worker round-trip: a validated SELECT runs against the
//! database and returns the row set as a [`DataResult`]
//! (with `column_types: Vec<None>` per ADR-0030 §6).
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::{App, OutputKind};
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value};
use rdbms_playground::event::AppEvent;
use rdbms_playground::mode::Mode;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
// =================================================================
// App-level dispatch
// =================================================================
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) -> Vec<Action> {
let mut actions = Vec::new();
for c in s.chars() {
actions.extend(app.update(key(KeyCode::Char(c))));
}
actions
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
#[test]
fn advanced_mode_select_dispatches_as_command_select() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "select 1");
let actions = submit(&mut app);
match actions.as_slice() {
[Action::ExecuteDsl {
command: Command::Select { sql },
source,
..
}] => {
assert!(
sql.contains("select 1"),
"Command::Select carries the validated SQL text: {sql:?}",
);
assert!(
source.contains("select 1"),
"the source line is preserved for history.log: {source:?}",
);
}
other => panic!("expected one ExecuteDsl(Select); got {other:?}"),
}
}
#[test]
fn simple_mode_select_yields_sql_hint_and_does_not_dispatch() {
let mut app = App::new();
// Default mode is Simple.
assert_eq!(app.mode, Mode::Simple);
type_str(&mut app, "select * from anywhere");
let actions = submit(&mut app);
// The failed simple-mode submission is journalled `err`
// (ADR-0034) but dispatches no command.
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"simple-mode `select` must not dispatch (only journal err); got {actions:?}",
);
// The error output spans multiple lines (the message and a
// caret pointer). The hint catalog key
// `advanced_mode.sql_in_simple` (ADR-0030 §2) names the
// input as SQL and points at the recovery paths.
let error_text: String = app
.output
.iter()
.filter(|l| l.kind == OutputKind::Error)
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
error_text.contains("SQL"),
"hint identifies the input as SQL; full error output:\n{error_text}",
);
assert!(
error_text.contains("advanced") && error_text.contains(":"),
"hint points at the recovery paths; full error output:\n{error_text}",
);
}
#[test]
fn colon_one_shot_from_simple_mode_dispatches_select() {
let mut app = App::new();
assert_eq!(app.mode, Mode::Simple);
type_str(&mut app, ":select 1");
let actions = submit(&mut app);
// Persistent mode is unchanged.
assert_eq!(app.mode, Mode::Simple);
match actions.as_slice() {
[Action::ExecuteDsl {
command: Command::Select { sql },
..
}] => {
assert!(
sql.contains("select 1") && !sql.starts_with(':'),
"the `:` is stripped before the SQL is queued: {sql:?}",
);
}
other => panic!("expected one ExecuteDsl(Select); got {other:?}"),
}
}
#[test]
fn advanced_mode_select_from_internal_table_is_rejected() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "select * from __rdbms_playground_columns");
let actions = submit(&mut app);
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"internal-table reference must not dispatch (only journal err); got {actions:?}",
);
let error_text: String = app
.output
.iter()
.filter(|l| l.kind == OutputKind::Error)
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
error_text.contains("internal") || error_text.contains("system"),
"the rejection names the offence; full error output:\n{error_text}",
);
}
// =================================================================
// Worker round-trip — actual SQL execution
// =================================================================
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)
}
#[test]
fn database_run_select_constant_returns_a_single_row() {
let (_p, db, _dir) = open_project_db();
let data = rt()
.block_on(db.run_select(
"select 1".to_string(),
Some("select 1".to_string()),
))
.expect("`select 1` runs clean");
assert_eq!(data.rows.len(), 1, "one result row");
assert_eq!(data.rows[0].len(), 1, "one column");
assert_eq!(
data.rows[0][0].as_deref(),
Some("1"),
"the literal `1` round-trips as a single integer cell",
);
// ADR-0030 §6: a SELECT's result columns carry no playground
// type — every entry is `None` (computed expressions render
// with neutral alignment in the data-table renderer).
assert!(
data.column_types.iter().all(Option::is_none),
"all result column types are None: {:?}",
data.column_types,
);
}
#[test]
fn database_run_select_from_user_table_returns_inserted_rows() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("Name", Type::Text),
],
vec!["id".to_string()],
None,
)
.await
.expect("create table");
db.insert(
"T".to_string(),
None,
vec![Value::Text("Ada".to_string())],
None,
)
.await
.expect("insert row");
});
let data = rt
.block_on(db.run_select("select Name from T".to_string(), None))
.expect("SELECT runs");
assert_eq!(data.rows.len(), 1);
assert_eq!(data.rows[0][0].as_deref(), Some("Ada"));
assert_eq!(data.columns, vec!["Name".to_string()]);
}
// ---- ADR-0032 §12 + Amendment 1: column-origin type recovery ----
#[test]
fn database_run_select_recovers_bool_column_type() {
// Lifts Phase-1 §4.5: `SELECT is_active FROM products`
// previously rendered the bool as `0` / `1`. With the
// engine's column-origin metadata wired through, the
// result carries `Some(Type::Bool)` and the renderer
// formats it as `true` / `false`.
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"Products".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("Active", Type::Bool),
],
vec!["id".to_string()],
None,
)
.await
.expect("create table");
db.insert(
"Products".to_string(),
None,
vec![Value::Bool(true)],
None,
)
.await
.expect("insert row");
db.insert(
"Products".to_string(),
None,
vec![Value::Bool(false)],
None,
)
.await
.expect("insert row");
});
let data = rt
.block_on(db.run_select("select Active from Products".to_string(), None))
.expect("SELECT runs");
assert_eq!(data.rows.len(), 2);
assert_eq!(data.column_types, vec![Some(Type::Bool)]);
assert_eq!(data.rows[0][0].as_deref(), Some("true"));
assert_eq!(data.rows[1][0].as_deref(), Some("false"));
}
#[test]
fn database_run_select_recovers_text_type_through_alias() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"Users".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("Name", Type::Text),
],
vec!["id".to_string()],
None,
)
.await
.expect("create table");
db.insert(
"Users".to_string(),
None,
vec![Value::Text("Ada".to_string())],
None,
)
.await
.expect("insert");
});
// The `AS n` alias remaps the result column name; the
// origin metadata still points at `Users.Name`, so the
// playground type is recovered.
let data = rt
.block_on(
db.run_select("select Name as n from Users".to_string(), None),
)
.expect("SELECT runs");
assert_eq!(data.columns, vec!["n".to_string()]);
assert_eq!(data.column_types, vec![Some(Type::Text)]);
}
#[test]
fn database_run_select_computed_expression_stays_typeless() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("Score", Type::Int),
],
vec!["id".to_string()],
None,
)
.await
.expect("create table");
db.insert("T".to_string(), None, vec![Value::Number("5".to_string())], None)
.await
.expect("insert");
});
let data = rt
.block_on(db.run_select("select Score + 1 from T".to_string(), None))
.expect("SELECT runs");
assert_eq!(data.column_types, vec![None]);
}
// ---- ADR-0032 §11.5: engine-error patterns verified against
// actual SQLite output. The friendly-error layer's
// translate_generic matches engine messages by substring;
// these tests prove the patterns match what the pinned
// SQLite version *actually produces* in 2026, not a
// hand-coded approximation.
#[test]
fn engine_aggregate_in_where_routes_through_catalog() {
use rdbms_playground::db::DbError;
use rdbms_playground::friendly;
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("score", Type::Int),
],
vec!["id".to_string()],
None,
)
.await
.expect("create table");
});
// Aggregate function in WHERE is engine-rejected per
// ADR-0032 §11.4. Run the bad query and confirm the
// friendly layer routes the message through engine.aggregate_misuse.
let err = rt
.block_on(db.run_select(
"select id from T where count(score) > 0".to_string(),
None,
))
.expect_err("engine should reject aggregate in WHERE");
let DbError::Sqlite { .. } = &err else {
panic!("expected Sqlite engine error; got {err:?}");
};
let friendly = friendly::translate_error(
&err,
&friendly::TranslateContext::default(),
);
let rendered = friendly.render();
assert!(
rendered.contains("aggregate"),
"expected engine.aggregate_misuse catalog wording in friendly output; got {rendered:?}",
);
// Engine name (SQLite) must not appear (ADR-0002 posture).
assert!(
!rendered.to_lowercase().contains("sqlite"),
"friendly output leaks engine name: {rendered:?}",
);
}
#[test]
fn engine_group_by_missing_routes_through_catalog() {
use rdbms_playground::db::DbError;
use rdbms_playground::friendly;
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("score", Type::Int),
ColumnSpec::new("category", Type::Text),
],
vec!["id".to_string()],
None,
)
.await
.expect("create table");
// SQLite is permissive about GROUP BY by default. To
// trigger the engine.group_by_required path we need an
// explicit MIN/MAX with a non-grouped column at strict
// affinity. Use a query that DOES fail under standard
// SQL semantics — SQLite returns an arbitrary row for
// ambiguous queries, so a pure GROUP-BY violation
// doesn't reliably error without `pragma`. The test
// instead exercises the `do_run_select` path with a
// query designed to *not* error so we can verify the
// pattern matcher doesn't false-positive on benign
// messages. Real GROUP BY validation lives in §11.4
// (engine territory) and SQLite's permissive default
// means the catalog routing is documented as a
// best-effort safety net.
db.insert(
"T".to_string(),
None,
vec![
Value::Number("10".to_string()),
Value::Text("a".to_string()),
],
None,
)
.await
.expect("insert");
});
// Benign query — confirms the pattern matcher doesn't
// false-positive on phrasings that happen to contain
// "group by" elsewhere. Any successful query is fine.
let _ = rt
.block_on(db.run_select(
"select category, count(*) from T group by category".to_string(),
None,
))
.expect("benign GROUP BY query runs");
// Direct unit test on the matcher: ensure a message that
// mentions GROUP BY routes through the catalog.
let synthetic = DbError::Sqlite {
message:
"column must appear in the GROUP BY clause or be used in an aggregate function"
.to_string(),
kind: rdbms_playground::db::SqliteErrorKind::Other,
};
let rendered = friendly::translate_error(
&synthetic,
&friendly::TranslateContext::default(),
)
.render();
assert!(
rendered.contains("GROUP BY"),
"engine.group_by_required wording missing; got {rendered:?}",
);
}
#[test]
fn engine_scalar_subquery_too_many_rows_routes_through_catalog() {
use rdbms_playground::db::DbError;
use rdbms_playground::friendly;
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("v", Type::Int),
],
vec!["id".to_string()],
None,
)
.await
.expect("create table");
for n in 1..=3 {
db.insert(
"T".to_string(),
None,
vec![Value::Number(n.to_string())],
None,
)
.await
.expect("insert");
}
});
// Scalar subquery context with a multi-row body. SQLite is
// also permissive here (silently picks one row) by default;
// verify both paths:
// 1. The benign multi-row query runs cleanly (matcher
// doesn't false-positive on a benign success).
// 2. A synthetic engine message routes through the
// catalog (the matcher would fire if SQLite ever
// surfaced this verbatim).
let _ = rt
.block_on(db.run_select(
"select (select v from T) from T".to_string(),
None,
))
.expect("benign scalar subquery query runs");
let synthetic = DbError::Sqlite {
message: "scalar subquery returned more than one row".to_string(),
kind: rdbms_playground::db::SqliteErrorKind::Other,
};
let rendered = friendly::translate_error(
&synthetic,
&friendly::TranslateContext::default(),
)
.render();
assert!(
rendered.contains("more than one row"),
"engine.scalar_subquery_too_many_rows wording missing; got {rendered:?}",
);
}
#[test]
fn database_run_select_type_recovery_works_on_empty_table() {
// ADR-0032 §12 + Amendment 1 — column-origin metadata is a
// property of the PREPARED STATEMENT, not the rows the
// query returns. SQLite's `sqlite3_column_origin_name`
// populates from the parsed query's source table even
// when no row matches.
//
// This test pins that invariant: a fresh table with no
// rows still yields the right `column_types` entry. It
// also justifies the all-types test below using NULL for
// col_blob (the DSL Value enum has no Blob variant, but
// since metadata doesn't read row values, a NULL cell
// doesn't compromise the recovery).
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"Empty".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("col_text", Type::Text),
ColumnSpec::new("col_blob", Type::Blob),
],
vec!["id".to_string()],
None,
)
.await
.expect("create table");
});
// No INSERT — the table is empty.
let data_text = rt
.block_on(db.run_select("select col_text from Empty".to_string(), None))
.expect("SELECT runs even on empty table");
assert!(data_text.rows.is_empty());
assert_eq!(data_text.column_types, vec![Some(Type::Text)]);
let data_blob = rt
.block_on(db.run_select("select col_blob from Empty".to_string(), None))
.expect("SELECT runs even on empty table");
assert!(data_blob.rows.is_empty());
assert_eq!(
data_blob.column_types,
vec![Some(Type::Blob)],
"Blob metadata must be recoverable even with no row data",
);
}
#[test]
fn database_run_select_recovers_all_ten_playground_types() {
// ADR-0032 §12 + Amendment 1 — every playground type
// round-trips through column-origin metadata on a bare
// projection ref. One table holds one column of each
// type; a SELECT of that column produces the right
// `column_types[0]` entry.
//
// `serial` and `shortid` are auto-generated. `col_blob`
// is left NULL in the inserted row because the DSL Value
// enum has no Blob variant — but per
// `database_run_select_type_recovery_works_on_empty_table`
// above, column-origin metadata is row-independent, so
// the NULL cell doesn't compromise this test's correctness.
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"AllTypes".to_string(),
vec![
ColumnSpec::new("pk", Type::Serial),
ColumnSpec::new("col_text", Type::Text),
ColumnSpec::new("col_int", Type::Int),
ColumnSpec::new("col_real", Type::Real),
ColumnSpec::new("col_decimal", Type::Decimal),
ColumnSpec::new("col_bool", Type::Bool),
ColumnSpec::new("col_date", Type::Date),
ColumnSpec::new("col_datetime", Type::DateTime),
ColumnSpec::new("col_blob", Type::Blob),
ColumnSpec::new("col_shortid", Type::ShortId),
],
vec!["pk".to_string()],
None,
)
.await
.expect("create table");
// Blob has no DSL literal form, so col_blob takes the
// default NULL on insert. Column-origin metadata is
// based on the column DEFINITION, not the row value
// (Amendment 1), so the type recovery still succeeds.
db.insert(
"AllTypes".to_string(),
Some(vec![
"col_text".to_string(),
"col_int".to_string(),
"col_real".to_string(),
"col_decimal".to_string(),
"col_bool".to_string(),
"col_date".to_string(),
"col_datetime".to_string(),
]),
vec![
Value::Text("hello".to_string()),
Value::Number("42".to_string()),
Value::Number("3.14".to_string()),
Value::Number("1.50".to_string()),
Value::Bool(true),
Value::Text("2026-05-20".to_string()),
Value::Text("2026-05-20T12:00:00".to_string()),
],
None,
)
.await
.expect("insert row");
});
// Each row pairs a column name with the expected
// playground type recovered by column-origin lookup.
let cases: &[(&str, Type)] = &[
("pk", Type::Serial),
("col_text", Type::Text),
("col_int", Type::Int),
("col_real", Type::Real),
("col_decimal", Type::Decimal),
("col_bool", Type::Bool),
("col_date", Type::Date),
("col_datetime", Type::DateTime),
("col_blob", Type::Blob),
("col_shortid", Type::ShortId),
];
for (col, expected_type) in cases {
let sql = format!("select {col} from AllTypes");
let data = rt
.block_on(db.run_select(sql.clone(), None))
.expect("SELECT runs");
assert_eq!(
data.column_types,
vec![Some(*expected_type)],
"type recovery failed for `{sql}`",
);
}
}
#[test]
fn database_run_select_appends_to_history_when_source_present() {
let (project, db, _dir) = open_project_db();
let history_path = project.path().join("history.log");
// ADR-0030 §11: the literal submitted line lands in
// history.log so replay re-runs it.
let _ = rt()
.block_on(db.run_select(
"select 1".to_string(),
Some("select 1".to_string()),
))
.expect("SELECT runs");
let body = std::fs::read_to_string(&history_path)
.expect("history.log present after a SELECT");
assert!(
body.contains("select 1"),
"history.log records the literal SELECT line: {body:?}",
);
}
+525
View File
@@ -0,0 +1,525 @@
//! 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::completion::{SchemaCache, TableColumn};
use rdbms_playground::db::{Database, DbError, UpdateResult};
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
use rdbms_playground::event::AppEvent;
use rdbms_playground::input_render::{
AmbientHint, InputState, ambient_hint_in_mode, classify_input_with_schema_in_mode,
};
use rdbms_playground::mode::Mode;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
use rdbms_playground::runtime::run_replay;
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(),
false,
))
.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 update") {
Command::SqlUpdate { sql, target_table, returning, set_literals } => rt.block_on(
db.run_sql_update_with_literals(
sql,
Some(input.to_string()),
target_table,
returning,
set_literals,
),
),
other => panic!("expected Command::SqlUpdate, got {other:?}"),
}
}
#[test]
fn parse_path_lowers_sql_update_to_command() {
let command = parse_command("update Orders set total = 0 where id = 1")
.expect("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, "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, "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, "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, "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,
"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, "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 = "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:?}");
}
// =================================================================
// ADR-0036 Phase 2 — `SET` literal value validation
// =================================================================
#[test]
fn sql_update_validates_set_literals_like_the_dsl() {
// ADR-0036 Phase 2: advanced-mode SQL `UPDATE` now validates each
// literal `SET col = <literal>` value against its column type before
// the (still verbatim) update runs, sharing the DSL's per-type
// validators. `2025/01/15` is a malformed date (slashes, not dashes):
// the DSL update rejects it at bind time, and advanced-mode SQL now
// refuses it too (it used to splice the literal into text and let a
// STRICT TEXT column accept anything).
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("d", Type::Date)], &["id"]);
seed(&db, &rt, "insert into t (id, d) values (1, '2025-01-15')", "t");
// SQL path (advanced mode, full replay pipeline) — REJECTS the bad date.
std::fs::write(
project.path().join("bad.commands"),
"update t set d = '2025/01/15' where id = 1\n",
)
.expect("write script");
let events = rt.block_on(run_replay(&db, project.path(), "bad.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
"advanced-mode SQL validates the `date` SET literal and refuses \
2025/01/15 (ADR-0036 Phase 2); events: {events:?}"
);
// A well-formed date still updates (the verbatim path is unaffected).
std::fs::write(
project.path().join("ok.commands"),
"update t set d = '2025-02-20' where id = 1\n",
)
.expect("write script");
let ok = rt.block_on(run_replay(&db, project.path(), "ok.commands"));
assert!(
matches!(ok.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 1),
"a well-formed date still updates; events: {ok:?}"
);
}
#[test]
fn sql_update_captures_set_literal_classification() {
// ADR-0036 Phase 2 seam (the "one new seam to keep honest"): each
// top-level `SET` RHS is classified — a bare literal (string / signed
// number / bool / null) is captured as `Some`, while an expression
// (arithmetic / scalar subquery / function call / column ref) is
// `None` and left to the engine. Critically, a comma *inside* a
// function call and a `where` *inside* a subquery must NOT be mistaken
// for an assignment separator / SET-list terminator (paren-depth
// guard), and the trailing top-level `WHERE` predicate is not captured.
let cmd = parse_command(
"update t set a = '2025-01-15', b = price * qty, c = -5, \
d = (select max(n) from o where n < 100), e = true, \
f = coalesce(g, 0), h = null where id = 7",
)
.expect("advanced-mode SQL update parses");
match cmd {
Command::SqlUpdate { set_literals, .. } => {
assert_eq!(
set_literals,
vec![
("a".to_string(), Some(Value::Text("2025-01-15".to_string()))),
("b".to_string(), None),
("c".to_string(), Some(Value::Number("-5".to_string()))),
("d".to_string(), None),
("e".to_string(), Some(Value::Bool(true))),
("f".to_string(), None),
("h".to_string(), Some(Value::Null)),
],
"literals captured; arithmetic / subquery (with inner WHERE) / \
function call (with inner comma) skipped; trailing WHERE excluded",
);
}
other => panic!("expected Command::SqlUpdate, got {other:?}"),
}
}
#[test]
fn sql_update_validates_every_assignment_not_just_the_first() {
// A malformed literal in the *second* assignment is caught — the
// validation loop covers every `SET` literal, not only the first
// (ADR-0036 Phase 2). The first assignment (`v = 'ok'`) is well-formed.
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(
&db,
&rt,
"t",
&[("id", Type::Int), ("v", Type::Text), ("d", Type::Date)],
&["id"],
);
seed(&db, &rt, "insert into t (id, v, d) values (1, 'a', '2025-01-01')", "t");
std::fs::write(
project.path().join("multi.commands"),
"update t set v = 'ok', d = '2025/01/15' where id = 1\n",
)
.expect("write script");
let events = rt.block_on(run_replay(&db, project.path(), "multi.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
"the malformed date in the second assignment is caught; events: {events:?}"
);
}
// =================================================================
// Sub-phase 3g — RETURNING (ADR-0033 §5)
// =================================================================
#[test]
fn update_returning_yields_modified_columns() {
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, "update t set v = 'new' where id = 1 returning id, v")
.expect("UPDATE … RETURNING runs");
assert_eq!(result.rows_affected, 1, "one row updated");
assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()]);
assert_eq!(result.data.rows.len(), 1);
// RETURNING reflects the POST-update value.
assert_eq!(result.data.rows[0][1], Some("new".to_string()), "modified value returned");
}
#[test]
fn update_returning_recovers_bare_column_type() {
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, false)", "t");
let result = run_update(&db, &rt, "update t set active = true where id = 1 returning active")
.expect("UPDATE … RETURNING active runs");
assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered");
assert_eq!(result.data.rows[0][0], Some("true".to_string()));
}
#[test]
fn update_returning_matching_no_rows_is_ok_and_empty() {
// DA gate: RETURNING makes data.columns non-empty even when no
// rows match (unlike the 3e column-less case). The operation
// succeeds with zero rows and an empty result set — no panic, no
// phantom row.
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, "update t set v = 'x' where id = 999 returning id, v")
.expect("no-match UPDATE … RETURNING is a success");
assert_eq!(result.rows_affected, 0, "no rows matched");
assert!(result.data.rows.is_empty(), "no rows returned");
assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()], "columns still present");
}
// =================================================================
// ADR-0036 Phase 3a — live typed-slot hints + highlighting for
// advanced-mode `SET col = <rhs>` (boundary-aware lookahead).
// =================================================================
/// Build a `SchemaCache` for the advanced-mode typing-surface tests
/// (mirrors `tests/typing_surface`'s `build_schema`).
fn schema_cache(tables: &[(&str, &[(&str, Type)])]) -> SchemaCache {
let mut cache = SchemaCache::default();
for (table, cols) in tables {
let table_cols: Vec<TableColumn> = cols
.iter()
.map(|(n, t)| TableColumn {
name: (*n).to_string(),
user_type: *t,
not_null: false,
has_default: false,
})
.collect();
cache.tables.push((*table).to_string());
for c in &table_cols {
if !cache.columns.contains(&c.name) {
cache.columns.push(c.name.clone());
}
}
cache.table_columns.insert((*table).to_string(), table_cols);
}
cache
}
#[test]
fn advanced_update_set_value_offers_typed_slot_hint_for_column() {
// ADR-0036 Phase 3a: at a `SET col = ` value position the
// advanced-mode SQL UPDATE now drives the same column-typed slot
// hint the DSL gives — "for `Email`: type a quoted string …" —
// instead of the type-blind sql_expr surface.
let schema = schema_cache(&[(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
)]);
let input = "update Customers set Email=";
let hint = ambient_hint_in_mode(input, input.len(), None, &schema, Mode::Advanced);
let Some(AmbientHint::Prose(prose)) = hint else {
panic!("expected a Prose hint at the typed value slot, got {hint:?}");
};
assert!(prose.contains("Email"), "hint names the column `Email`: {prose:?}");
assert!(
prose.contains("quoted string"),
"text-column hint says `quoted string`: {prose:?}"
);
}
#[test]
fn advanced_update_set_date_value_hint_says_yyyy_mm_dd() {
let schema = schema_cache(&[("Things", &[("k", Type::Int), ("dt", Type::Date)])]);
let input = "update Things set dt=";
let hint = ambient_hint_in_mode(input, input.len(), None, &schema, Mode::Advanced);
let Some(AmbientHint::Prose(prose)) = hint else {
panic!("expected a Prose hint at the date value slot, got {hint:?}");
};
assert!(
prose.contains("YYYY-MM-DD"),
"date-column hint references the YYYY-MM-DD format: {prose:?}"
);
}
#[test]
fn advanced_update_set_int_value_type_mismatch_is_caught_live() {
// A decimal literal at an `int` column now fails to parse in
// advanced mode (the typed slot's integer validator fires while
// typing) — previously the verbatim sql_expr surface accepted it
// and only Phase 2's execution-time validation caught it.
let schema = schema_cache(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
let bad = classify_input_with_schema_in_mode(
"update Things set k = 3.14 where k = 0",
&schema,
Mode::Advanced,
);
assert!(
!matches!(bad, InputState::Valid),
"a decimal at an int column is rejected live (typed slot), got {bad:?}"
);
// A well-formed integer literal still parses cleanly.
let ok = classify_input_with_schema_in_mode(
"update Things set k = 5 where k = 0",
&schema,
Mode::Advanced,
);
assert!(matches!(ok, InputState::Valid), "a valid int literal parses: {ok:?}");
}
#[test]
fn advanced_update_set_expression_still_parses_via_sql_expr() {
// Regression guard: the boundary-aware lookahead must fall through
// to sql_expr for anything that is not a lone literal — arithmetic,
// a literal-prefixed expression, a function call, a scalar subquery.
// None of these may be stolen by the typed slot.
let schema = schema_cache(&[
("Things", &[("k", Type::Int), ("note", Type::Text)]),
("other", &[("n", Type::Int)]),
]);
for input in [
"update Things set k = 3 + 2 where k = 0", // literal-prefixed expression
"update Things set k = (select max(n) from other) where k = 0", // scalar subquery
"update Things set note = upper(note) where k = 0", // function call
"update Things set k = -5 where k = 0", // signed number → sql_expr
] {
let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced);
assert!(
matches!(state, InputState::Valid),
"{input:?} must still parse via sql_expr, got {state:?}"
);
}
}
// =================================================================
// ADR-0033 Amendment 4 — `update … --all-rows` falls back to the DSL
// =================================================================
#[test]
fn update_all_rows_flag_in_advanced_updates_every_row() {
// `update … --all-rows` falls back to the DSL Update { AllRows } in
// advanced mode (run_replay parses each line in advanced mode) and
// updates every row — the full pipeline end to end, not just the
// parse-level dispatch (covered in tests/sql_dml_e2e.rs).
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Int)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 1), (2, 2)", "t");
std::fs::write(
project.path().join("allrows.commands"),
"update t set v = 9 --all-rows\n",
)
.expect("write script");
let events = rt.block_on(run_replay(&db, project.path(), "allrows.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 1, .. })),
"the --all-rows update replays through the DSL fall-back; events: {events:?}"
);
let rows = rt
.block_on(db.query_data("t".to_string(), None, None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 2, "both rows present");
assert!(
rows.iter().all(|r| r[1].as_deref() == Some("9")),
"every row's v set to 9 (all-rows update); rows: {rows:?}"
);
}
+460
View File
@@ -0,0 +1,460 @@
//! Tier-3 integration tests for the undo/snapshot ring wired into
//! the db worker (ADR-0006 Amendment 1, §8 step 3).
//!
//! These drive the real `Database` worker: a mutation takes a
//! pre-op snapshot, `undo` restores it through the live connection,
//! `redo` re-applies, a batch collapses to a single undo step, and
//! `--no-undo` (undo disabled) takes no snapshots at all.
use std::path::Path;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Command, RowFilter, Type, Value, parse_command};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
/// Open a fresh temp project with undo enabled (or not).
fn open_project(
data: &tempfile::TempDir,
undo_enabled: bool,
) -> (project::Project, Database, std::path::PathBuf) {
let project = project::open_or_create(None, Some(data.path())).expect("open project");
let path = project.path().to_path_buf();
let persistence = Persistence::new(path.clone());
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo_enabled)
.expect("open db");
(project, db, path)
}
async fn make_customers(db: &Database) {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("Name".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
}
async fn insert_named(db: &Database, name: &str) {
db.insert(
"Customers".to_string(),
None,
vec![Value::Text(name.to_string())],
Some(format!("insert into Customers ('{name}')")),
)
.await
.unwrap();
}
async fn row_count(db: &Database) -> usize {
db.query_data("Customers".to_string(), None, None, None)
.await
.unwrap()
.rows
.len()
}
fn snapshots_dir(path: &Path) -> std::path::PathBuf {
path.join(".snapshots")
}
#[test]
fn mutation_snapshots_and_undo_restores_through_the_worker() {
let data = tempdir();
let (_p, db, path) = open_project(&data, true);
rt().block_on(async {
make_customers(&db).await;
insert_named(&db, "Alice").await;
insert_named(&db, "Bob").await;
assert_eq!(row_count(&db).await, 2);
// Destructive op: delete Bob (id = 2).
db.delete(
"Customers".to_string(),
RowFilter::eq("id", Value::Number("2".to_string())),
Some("delete from Customers where id = 2".to_string()),
)
.await
.unwrap();
assert_eq!(row_count(&db).await, 1);
// The pending undo names the delete.
let peek = db.peek_undo().await.unwrap().expect("an undo entry");
assert_eq!(peek.command, "delete from Customers where id = 2");
// Undo restores Bob.
let undone = db.undo().await.unwrap().expect("undo applied");
assert_eq!(undone.command, "delete from Customers where id = 2");
assert_eq!(row_count(&db).await, 2, "Bob restored by undo");
});
assert!(snapshots_dir(&path).exists(), "snapshots dir created");
}
#[test]
fn redo_reapplies_the_undone_command() {
let data = tempdir();
let (_p, db, _path) = open_project(&data, true);
rt().block_on(async {
make_customers(&db).await;
insert_named(&db, "Alice").await;
insert_named(&db, "Bob").await;
db.delete(
"Customers".to_string(),
RowFilter::eq("id", Value::Number("2".to_string())),
Some("delete from Customers where id = 2".to_string()),
)
.await
.unwrap();
assert_eq!(row_count(&db).await, 1);
db.undo().await.unwrap();
assert_eq!(row_count(&db).await, 2);
let redone = db.redo().await.unwrap().expect("redo applied");
assert_eq!(redone.command, "delete from Customers where id = 2");
assert_eq!(row_count(&db).await, 1, "delete re-applied by redo");
});
}
#[test]
fn new_work_after_undo_clears_redo() {
let data = tempdir();
let (_p, db, _path) = open_project(&data, true);
rt().block_on(async {
make_customers(&db).await;
insert_named(&db, "Alice").await;
db.delete(
"Customers".to_string(),
RowFilter::AllRows,
Some("delete from Customers --all-rows".to_string()),
)
.await
.unwrap();
db.undo().await.unwrap();
assert!(db.peek_redo().await.unwrap().is_some(), "redo available");
// New destructive work.
insert_named(&db, "Carol").await;
assert!(
db.peek_redo().await.unwrap().is_none(),
"new work discards the redo stack"
);
});
}
#[test]
fn undo_disabled_takes_no_snapshots() {
let data = tempdir();
let (_p, db, path) = open_project(&data, false);
rt().block_on(async {
make_customers(&db).await;
insert_named(&db, "Alice").await;
db.delete(
"Customers".to_string(),
RowFilter::AllRows,
Some("delete from Customers --all-rows".to_string()),
)
.await
.unwrap();
// Nothing to undo, and no snapshot machinery on disk.
assert!(db.undo().await.unwrap().is_none(), "undo is a no-op when disabled");
assert!(db.peek_undo().await.unwrap().is_none());
});
assert!(
!snapshots_dir(&path).exists(),
"no .snapshots dir when undo is disabled"
);
}
#[test]
fn batch_records_a_single_undo_step() {
let data = tempdir();
let (_p, db, _path) = open_project(&data, true);
rt().block_on(async {
make_customers(&db).await; // one undo entry (the create)
// A batch of three inserts → one boundary snapshot.
db.begin_batch(Some("replay history.log".to_string()))
.await
.unwrap();
insert_named(&db, "Alice").await;
insert_named(&db, "Bob").await;
insert_named(&db, "Carol").await;
db.end_batch().await.unwrap();
assert_eq!(row_count(&db).await, 3);
// The single batch undo names the batch command.
let peek = db.peek_undo().await.unwrap().expect("batch undo entry");
assert_eq!(peek.command, "replay history.log");
// One undo rolls the whole batch back to the pre-batch state
// (table exists, no rows).
db.undo().await.unwrap();
assert_eq!(row_count(&db).await, 0, "whole batch undone in one step");
// The next undo is the create_table (table gone).
let next = db.peek_undo().await.unwrap().expect("create entry");
assert_eq!(next.command, "create table Customers with pk id(serial)");
});
}
#[test]
fn empty_undo_and_redo_are_no_ops() {
let data = tempdir();
let (_p, db, _path) = open_project(&data, true);
rt().block_on(async {
assert!(db.undo().await.unwrap().is_none());
assert!(db.redo().await.unwrap().is_none());
assert!(db.peek_undo().await.unwrap().is_none());
assert!(db.peek_redo().await.unwrap().is_none());
});
}
// ---- Step 7: full-stack flow across DSL *and* SQL (R21 / R22) ----
//
// R22: the snapshot hook lives in the worker dispatch, so SQL DML
// (SqlInsert/SqlUpdate/SqlDelete) is snapshotted exactly like DSL.
async fn make_t(db: &Database) {
db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Int),
ColumnSpec::new("n".to_string(), Type::Int),
],
vec!["id".to_string()],
Some("create table T with pk id(int), n(int)".to_string()),
)
.await
.unwrap();
}
async fn dsl_insert(db: &Database, id: i64, n: i64) {
db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "n".to_string()]),
vec![Value::Number(id.to_string()), Value::Number(n.to_string())],
Some(format!("insert into T (id, n) values ({id}, {n})")),
)
.await
.unwrap();
}
async fn sql_insert(db: &Database, input: &str) {
match parse_command(input).unwrap() {
Command::SqlInsert {
sql,
target_table,
listed_columns,
row_source,
returning,
..
} => {
db.run_sql_insert(
sql,
Some(input.to_string()),
target_table,
listed_columns,
row_source,
returning,
)
.await
.unwrap();
}
other => panic!("expected SqlInsert from {input:?}, got {other:?}"),
}
}
async fn sql_delete(db: &Database, input: &str) {
match parse_command(input).unwrap() {
Command::SqlDelete {
sql,
target_table,
returning,
} => {
db.run_sql_delete(sql, Some(input.to_string()), target_table, returning)
.await
.unwrap();
}
other => panic!("expected SqlDelete from {input:?}, got {other:?}"),
}
}
async fn count_t(db: &Database) -> usize {
db.query_data("T".to_string(), None, None, None)
.await
.unwrap()
.rows
.len()
}
#[test]
fn undo_steps_back_across_dsl_and_sql_mutations() {
let data = tempdir();
let (_p, db, _path) = open_project(&data, true);
rt().block_on(async {
make_t(&db).await; // snapshot 1: create
dsl_insert(&db, 1, 10).await; // snapshot 2: DSL insert
sql_insert(&db, "insert into T (id, n) values (2, 20)").await; // snapshot 3: SQL insert
assert_eq!(count_t(&db).await, 2);
sql_delete(&db, "delete from T where id = 1").await; // snapshot 4: SQL delete
assert_eq!(count_t(&db).await, 1);
// Walk back through SQL then DSL boundaries.
db.undo().await.unwrap();
assert_eq!(count_t(&db).await, 2, "SQL delete undone");
db.undo().await.unwrap();
assert_eq!(count_t(&db).await, 1, "SQL insert undone");
db.undo().await.unwrap();
assert_eq!(count_t(&db).await, 0, "DSL insert undone");
// Redo re-applies the DSL insert.
db.redo().await.unwrap();
assert_eq!(count_t(&db).await, 1, "DSL insert redone");
});
}
#[test]
fn undo_restores_db_and_csv_consistently() {
let data = tempdir();
let (_p, db, path) = open_project(&data, true);
rt().block_on(async {
db.create_table(
"T".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Int),
ColumnSpec::new("name".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create table T".to_string()),
)
.await
.unwrap();
db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "name".to_string()]),
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
Some("insert Alice".to_string()),
)
.await
.unwrap();
sql_insert(&db, "insert into T (id, name) values (2, 'Bob')").await;
sql_delete(&db, "delete from T where id = 2").await;
let csv = std::fs::read_to_string(path.join("data").join("T.csv")).unwrap();
assert!(
csv.contains("Alice") && !csv.contains("Bob"),
"post-delete csv: {csv}"
);
db.undo().await.unwrap();
// Both the database read model and the on-disk CSV are
// restored — the (db, csv) pair stays consistent.
assert_eq!(
db.query_data("T".to_string(), None, None, None)
.await
.unwrap()
.rows
.len(),
2
);
let csv2 = std::fs::read_to_string(path.join("data").join("T.csv")).unwrap();
assert!(csv2.contains("Bob"), "csv restored on disk: {csv2}");
});
}
#[test]
fn redo_is_cleared_when_new_work_commits_without_a_snapshot() {
// Regression for a /runda finding: with the non-fatal
// snapshot-failure policy, a committed mutation whose snapshot
// can't be staged left the redo stack stale — a later `redo`
// would silently discard the new work. Any committed user work
// must clear redo, even when no snapshot was recorded.
let data = tempdir();
let (_p, db, path) = open_project(&data, true);
rt().block_on(async {
make_t(&db).await;
dsl_insert(&db, 1, 10).await;
sql_delete(&db, "delete from T where id = 1").await; // → 0 rows
db.undo().await.unwrap(); // redo now holds the delete; → 1 row
assert!(db.peek_redo().await.unwrap().is_some(), "redo populated");
});
// Force the next staging to fail while the rest of the ring stays
// writable: a plain file where the `.staging` dir is expected makes
// `stage` error, but `clear_redo` (index + payload deletes in the
// ring root) still succeeds.
let staging = path.join(".snapshots").join(".staging");
std::fs::write(&staging, b"block").unwrap();
rt().block_on(async {
dsl_insert(&db, 2, 20).await; // commits; snapshot staging fails
assert_eq!(count_t(&db).await, 2, "new work applied");
assert!(
db.peek_redo().await.unwrap().is_none(),
"stale redo must be cleared when new work commits without a snapshot"
);
// Redo is now a no-op — it cannot resurrect the discarded state.
assert!(db.redo().await.unwrap().is_none());
assert_eq!(count_t(&db).await, 2, "new work preserved");
});
}
#[test]
fn undo_ring_persists_across_reopen() {
let data = tempdir();
let (project, db, path) = open_project(&data, true);
let db_path = project.db_path();
rt().block_on(async {
make_t(&db).await;
dsl_insert(&db, 1, 10).await;
sql_delete(&db, "delete from T where id = 1").await;
assert_eq!(count_t(&db).await, 0);
});
// Close the worker, then reopen the *same* project (lock still
// held by `project`). The persisted ring must survive.
drop(db);
let persistence = Persistence::new(path);
let db2 = Database::open_with_persistence_and_undo(&db_path, persistence, true)
.expect("reopen db");
rt().block_on(async {
let peek = db2
.peek_undo()
.await
.unwrap()
.expect("ring persisted across reopen");
assert_eq!(peek.command, "delete from T where id = 1");
db2.undo().await.unwrap();
assert_eq!(count_t(&db2).await, 1, "row restored after reopen + undo");
});
}
+663
View File
@@ -0,0 +1,663 @@
//! Tier 3 integration tests for the walking skeleton (per ADR-0008).
//!
//! These tests drive synthetic crossterm events through `App::update`
//! and assert on the resulting state and rendered buffer. They
//! exercise the full input → state → render path without a real
//! terminal, so they run on every commit and catch regressions in
//! the wiring between modules.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use rdbms_playground::action::Action;
use rdbms_playground::app::{App, OutputKind};
use rdbms_playground::db::{
ColumnDescription, DataResult, InsertResult, RelationshipEnd, TableDescription,
};
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, RowFilter, Type, Value};
use rdbms_playground::event::AppEvent;
use rdbms_playground::mode::Mode;
use rdbms_playground::theme::Theme;
use rdbms_playground::ui;
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) -> Vec<Action> {
let mut actions = Vec::new();
for c in s.chars() {
actions.extend(app.update(key(KeyCode::Char(c))));
}
actions
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
/// Assert that `actions` is exactly one `Action::ExecuteDsl`
/// whose parsed command equals `expected`. The original source
/// text carried alongside the command is allowed to be
/// anything — tests construct the expected `Command` directly
/// and don't care about the verbatim user input.
#[track_caller]
fn assert_one_execute_dsl(actions: &[Action], expected: &Command) {
assert_eq!(actions.len(), 1, "expected exactly one action; got {actions:?}");
match &actions[0] {
Action::ExecuteDsl { command, .. } => assert_eq!(command, expected),
other => panic!("expected ExecuteDsl, got {other:?}"),
}
}
fn rendered_text(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create terminal");
terminal
.draw(|f| ui::render(app, theme, f))
.expect("draw frame");
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
out.push_str(buffer[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn typing_then_submitting_a_dsl_command_emits_execute_action() {
let mut app = App::new();
let theme = Theme::dark();
type_str(&mut app, "create table Customers with pk");
let pre_render = rendered_text(&mut app, &theme, 80, 24);
assert!(
pre_render.contains("create table Customers"),
"input field should display the typed text:\n{pre_render}"
);
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::CreateTable {
name: "Customers".to_string(),
columns: vec![ColumnSpec::new("id".to_string(), Type::Serial)],
primary_key: vec!["id".to_string()],
},
);
assert!(app.input.is_empty(), "input buffer cleared on submit");
let post_render = rendered_text(&mut app, &theme, 80, 24);
assert!(
post_render.contains("running:"),
"output panel should show the running notice:\n{post_render}"
);
}
#[test]
fn typing_invalid_simple_input_shows_a_parse_error_not_an_echo() {
let mut app = App::new();
let theme = Theme::dark();
type_str(&mut app, "hello world");
let actions = submit(&mut app);
// The failed line journals `err` (ADR-0034) but does not echo
// or dispatch a command.
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"expected only a JournalFailure; got {actions:?}",
);
let rendered = rendered_text(&mut app, &theme, 80, 24);
assert!(
rendered.contains("parse error"),
"output panel should show the parse error:\n{rendered}"
);
}
#[test]
fn mode_switch_changes_label_and_subsequent_echoes() {
let mut app = App::new();
let theme = Theme::dark();
let initial = rendered_text(&mut app, &theme, 80, 24);
assert!(initial.contains("SIMPLE"));
assert!(!initial.contains("ADVANCED"));
type_str(&mut app, "mode advanced");
submit(&mut app);
assert_eq!(app.mode, Mode::Advanced);
let after_switch = rendered_text(&mut app, &theme, 80, 24);
assert!(after_switch.contains("ADVANCED"));
type_str(&mut app, "select 1");
submit(&mut app);
let last = app.output.back().expect("output present");
assert_eq!(last.mode_at_submission, Mode::Advanced);
assert_eq!(last.kind, OutputKind::Echo);
}
#[test]
fn colon_escape_in_simple_mode_is_one_shot() {
let mut app = App::new();
type_str(&mut app, ":select 1");
submit(&mut app);
assert_eq!(app.mode, Mode::Simple);
// The line ran under the one-shot effective Advanced mode
// (ADR-0030 §2): the `:` is stripped, the SQL grammar
// dispatches `select 1`, and the echoed line carries the
// submission's effective mode.
let echoed = app
.output
.iter()
.rfind(|l| l.kind == OutputKind::Echo)
.expect("echo output present");
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
assert!(
echoed.text.contains("select 1") && !echoed.text.contains(":select"),
"echo carries the stripped input: {:?}",
echoed.text,
);
// Subsequent submission (unrecognised in simple mode) parse-errors,
// not echoes — confirming the mode reverted.
type_str(&mut app, "list things");
submit(&mut app);
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert_eq!(last.mode_at_submission, Mode::Simple);
}
#[test]
fn quit_command_returns_quit_action() {
let mut app = App::new();
type_str(&mut app, "quit");
let actions = submit(&mut app);
assert_eq!(actions, vec![Action::Quit]);
}
#[test]
fn rendering_works_at_minimum_useful_size() {
// Sanity check that the layout does not panic at small sizes.
let mut app = App::new();
let theme = Theme::dark();
let _ = rendered_text(&mut app, &theme, 40, 12);
}
#[test]
fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
let mut app = App::new();
let theme = Theme::dark();
// No `:` yet — prompt shows SIMPLE.
type_str(&mut app, "sel");
let before = rendered_text(&mut app, &theme, 80, 24);
assert!(before.contains("SIMPLE"));
assert!(!before.contains("Advanced:"));
// Reset and type `:` first — prompt should flip immediately.
app.input.clear();
type_str(&mut app, ":");
let after_colon = rendered_text(&mut app, &theme, 80, 24);
assert!(
after_colon.contains("Advanced:"),
"input panel should show 'Advanced:' once `:` is typed:\n{after_colon}"
);
assert!(!after_colon.contains("SIMPLE"));
// Backspace through both the auto-inserted space and the `:`
// itself reverts the prompt.
while !app.input.is_empty() {
app.update(key(KeyCode::Backspace));
}
let after_revert = rendered_text(&mut app, &theme, 80, 24);
assert!(after_revert.contains("SIMPLE"));
assert!(!after_revert.contains("Advanced:"));
}
#[test]
fn status_bar_lists_quit_and_submit_in_all_modes() {
let mut app = App::new();
let theme = Theme::dark();
let simple = rendered_text(&mut app, &theme, 80, 24);
assert!(simple.contains("Enter"), "status bar lists Enter");
assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C");
assert!(simple.contains("mode advanced"));
type_str(&mut app, "mode advanced");
submit(&mut app);
let advanced = rendered_text(&mut app, &theme, 80, 24);
assert!(advanced.contains("Enter"));
assert!(advanced.contains("Ctrl-C"));
assert!(advanced.contains("mode simple"));
}
// ---------------------------------------------------------------
// Full DSL flow tests.
//
// These tests simulate the runtime by feeding the AppEvent::Dsl*
// events that the runtime would post after dispatching a command
// to the database. That keeps these tests deterministic and runtime
// agnostic — the actual database is exercised in the db module's
// own #[tokio::test] suite.
// ---------------------------------------------------------------
fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
TableDescription {
name: name.to_string(),
columns: columns
.iter()
.map(|(n, t, pk)| ColumnDescription {
name: (*n).to_string(),
user_type: Some(*t),
sqlite_type: t.sqlite_strict_type().to_string(),
notnull: false,
primary_key: *pk,
unique: false,
default: None,
check: None,
})
.collect(),
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
}
}
#[test]
fn create_table_flow_updates_tables_list_and_structure_view() {
let mut app = App::new();
let theme = Theme::dark();
// User types and submits.
type_str(&mut app, "create table Customers with pk");
let actions = submit(&mut app);
let expected_cmd = Command::CreateTable {
name: "Customers".to_string(),
columns: vec![ColumnSpec::new("id".to_string(), Type::Serial)],
primary_key: vec!["id".to_string()],
};
assert_one_execute_dsl(&actions, &expected_cmd);
// Runtime would now dispatch and feed back DslSucceeded + TablesRefreshed.
let desc = fake_table("Customers", &[("id", Type::Serial, true)]);
app.update(AppEvent::DslSucceeded {
command: expected_cmd,
description: Some(desc.clone()),
echo: None,
});
app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()]));
assert_eq!(app.tables, vec!["Customers".to_string()]);
assert_eq!(app.current_table, Some(desc));
let rendered = rendered_text(&mut app, &theme, 80, 24);
assert!(
rendered.contains("Customers"),
"items panel should list Customers:\n{rendered}"
);
// ADR-0040: success is the ✓ marker on the command's echo line
// (the `[ok] create table Customers` summary line was retired).
assert!(
rendered.contains("create table Customers with pk ✓"),
"the command echo should resolve to a success marker:\n{rendered}"
);
// The structure table renders one line per column; the
// `id` row shows both the name and its `serial` type
// separated by box-drawing characters.
assert!(
rendered.lines().any(|l| l.contains("id") && l.contains("serial")),
"output should show the id/serial column row:\n{rendered}"
);
}
#[test]
fn add_column_flow_updates_structure_view() {
let mut app = App::new();
// Simulate the prior create_table state.
app.tables = vec!["Customers".to_string()];
app.current_table = Some(fake_table(
"Customers",
&[("id", Type::Serial, true)],
));
type_str(&mut app, "add column to table Customers: Name (text)");
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
not_null: false,
unique: false,
default: None,
check: None,
},
);
let updated = fake_table(
"Customers",
&[("id", Type::Serial, true), ("Name", Type::Text, false)],
);
app.update(AppEvent::DslSucceeded {
command: Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
not_null: false,
unique: false,
default: None,
check: None,
},
description: Some(updated.clone()),
echo: None,
});
assert_eq!(app.current_table, Some(updated));
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(
rendered.lines().any(|l| l.contains("Name") && l.contains("text")),
"expected the Name/text column row:\n{rendered}",
);
}
#[test]
fn drop_table_flow_clears_items_list() {
let mut app = App::new();
app.tables = vec!["Customers".to_string()];
app.current_table = Some(fake_table("Customers", &[("id", Type::Serial, true)]));
type_str(&mut app, "drop table Customers");
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::DropTable {
name: "Customers".to_string(),
},
);
app.update(AppEvent::DslSucceeded {
command: Command::DropTable {
name: "Customers".to_string(),
},
description: None,
echo: None,
});
app.update(AppEvent::TablesRefreshed(Vec::new()));
assert!(app.tables.is_empty());
assert!(app.current_table.is_none());
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(rendered.contains("(none yet)"));
// ADR-0040: `drop table` is content-less, so the echo's ✓ marker
// is the entire success signal (replacing `[ok] drop table …`).
assert!(
rendered.contains("drop table Customers ✓"),
"the drop echo should resolve to a success marker:\n{rendered}"
);
}
#[test]
fn add_relationship_flow_shows_parent_side_with_inbound_section() {
let mut app = App::new();
type_str(
&mut app,
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade",
);
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::AddRelationship {
name: None,
parent_table: "Customers".to_string(),
parent_column: "Id".to_string(),
child_table: "Orders".to_string(),
child_column: "CustId".to_string(),
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
create_fk: false,
},
);
// The runtime now feeds back the parent (Customers) so the
// user sees the new relationship via the "Referenced by"
// section — same direction as the command's `from <Parent>`
// reading.
let customers = TableDescription {
name: "Customers".to_string(),
columns: vec![ColumnDescription {
name: "Id".to_string(),
user_type: Some(Type::Serial),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
unique: false,
default: None,
check: None,
}],
outbound_relationships: Vec::new(),
inbound_relationships: vec![RelationshipEnd {
name: "Customers_Id_to_Orders_CustId".to_string(),
other_table: "Orders".to_string(),
other_column: "CustId".to_string(),
local_column: "Id".to_string(),
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
}],
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
app.update(AppEvent::DslSucceeded {
command: Command::AddRelationship {
name: None,
parent_table: "Customers".to_string(),
parent_column: "Id".to_string(),
child_table: "Orders".to_string(),
child_column: "CustId".to_string(),
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
create_fk: false,
},
description: Some(customers),
echo: None,
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(rendered.contains("Referenced by:"), "{rendered}");
assert!(rendered.contains("Orders.CustId"), "{rendered}");
assert!(rendered.contains("on delete cascade"), "{rendered}");
// The [ok] subject lists the endpoints. Long lines wrap in
// the panel, so we check the first half of the phrase only.
assert!(
rendered.contains("from Customers.Id"),
"{rendered}"
);
}
#[test]
fn add_relationship_flow_shows_inbound_section_on_parent() {
let mut app = App::new();
let customers = TableDescription {
name: "Customers".to_string(),
columns: vec![ColumnDescription {
name: "Id".to_string(),
user_type: Some(Type::Serial),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
unique: false,
default: None,
check: None,
}],
outbound_relationships: Vec::new(),
inbound_relationships: vec![RelationshipEnd {
name: "Customers_Id_to_Orders_CustId".to_string(),
other_table: "Orders".to_string(),
other_column: "CustId".to_string(),
local_column: "Id".to_string(),
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
}],
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
app.update(AppEvent::DslSucceeded {
command: Command::AddColumn {
table: "Customers".to_string(),
column: "extra".to_string(),
ty: Type::Text,
not_null: false,
unique: false,
default: None,
check: None,
},
description: Some(customers),
echo: None,
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(rendered.contains("Referenced by:"), "{rendered}");
assert!(rendered.contains("Orders.CustId → Id"), "{rendered}");
}
#[test]
fn insert_flow_emits_action_and_renders_data() {
let mut app = App::new();
type_str(&mut app, "insert into Customers values ('Alice')");
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::Insert {
table: "Customers".to_string(),
columns: None,
values: vec![Value::Text("Alice".to_string())],
},
);
// Simulate the runtime feeding back an InsertResult.
let data = DataResult {
table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string()],
column_types: vec![Some(Type::Serial), Some(Type::Text)],
rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
};
app.update(AppEvent::DslInsertSucceeded {
command: Command::Insert {
table: "Customers".to_string(),
columns: None,
values: vec![Value::Text("Alice".to_string())],
},
result: InsertResult {
rows_affected: 1,
data,
},
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(
rendered.contains("1 row(s) inserted"),
"should show row count:\n{rendered}"
);
assert!(
rendered.contains("Alice"),
"should auto-show new row:\n{rendered}"
);
assert!(
rendered.contains("id") && rendered.contains("Name"),
"should show column headers:\n{rendered}"
);
}
#[test]
fn delete_with_all_rows_emits_correct_action() {
let mut app = App::new();
type_str(&mut app, "delete from Customers --all-rows");
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::Delete {
table: "Customers".to_string(),
filter: RowFilter::AllRows,
},
);
}
#[test]
fn show_data_for_empty_table_renders_placeholder() {
let mut app = App::new();
let data = DataResult {
table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string()],
column_types: vec![Some(Type::Serial), Some(Type::Text)],
rows: Vec::new(),
};
app.update(AppEvent::DslDataSucceeded {
command: Command::ShowData {
name: "Customers".to_string(),
filter: None,
limit: None,
},
data,
echo: None,
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(rendered.contains("(no rows)"), "{rendered}");
}
#[test]
fn dsl_failure_shows_friendly_error_in_output() {
let mut app = App::new();
type_str(&mut app, "drop table Ghost");
submit(&mut app);
app.update(AppEvent::DslFailed {
command: Command::DropTable {
name: "Ghost".to_string(),
},
error: rdbms_playground::db::DbError::Sqlite {
message: "no such table: Ghost".to_string(),
kind: rdbms_playground::db::SqliteErrorKind::NoSuchTable,
},
facts: rdbms_playground::friendly::FailureContext::default(),
source: String::new(),
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(
rendered.contains("Ghost"),
"error should mention the table:\n{rendered}"
);
assert!(
rendered.contains("no such table"),
"error should include the friendly message:\n{rendered}"
);
}
#[test]
fn validity_indicator_renders_err_and_wrn_labels() {
// ADR-0027 §4: the input row shows a `[ERR]` / `[WRN]`
// label at its right edge, or nothing when clean.
use rdbms_playground::dsl::walker::Severity;
let mut app = App::new();
let clean = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(!clean.contains("[ERR]"), "clean input shows no label:\n{clean}");
assert!(!clean.contains("[WRN]"), "clean input shows no label:\n{clean}");
app.input_indicator = Some(Severity::Error);
let err = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(err.contains("[ERR]"), "ERROR verdict shows [ERR]:\n{err}");
app.input_indicator = Some(Severity::Warning);
let wrn = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(wrn.contains("[WRN]"), "WARNING verdict shows [WRN]:\n{wrn}");
}