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:
@@ -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");
|
||||
}
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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}",
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")]);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user