5c076f6d8f
Every successful user command now persists through to YAML, the
affected CSVs, and history.log inside the same SQLite transaction,
with the commit-db-last ordering from ADR-0015 §6: validate ->
mutate -> stage text + fsync -> atomic rename -> append history ->
commit. A failure in any text-write step rolls back the SQLite tx,
so disk state is unchanged on failure. Persistence failures are
routed through a new AppEvent::PersistenceFatal which sets a
fatal_message on the App, emits Action::Quit, and is printed to
stderr after terminal teardown so the banner remains above the
shell prompt (ADR-0015 §8).
New persistence module owns the file formats: hand-rolled YAML
schema writer, per-type CSV encoder (RFC 4180, NULL distinct from
empty string, base64 blobs), append-only history.log with ISO-8601
timestamps and successful-only entries. Atomic per-file writes via
tmp + fsync + rename.
The db worker holds an Option<Persistence>; tests still use
Database::open(":memory:") with no persistence. Action::ExecuteDsl
gains a source field carrying the user-typed text, threaded
through to history.log.
Tests: 289 passing (256 lib + 7 new integration + 9 lifecycle + 17
walking-skeleton), 0 failing, 0 skipped. Clippy clean with nursery
lints.
372 lines
12 KiB
Rust
372 lines
12 KiB
Rust
//! 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 { name: "id".to_string(), ty: Type::Serial },
|
|
ColumnSpec { name: "Name".to_string(), ty: 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 { name: "id".to_string(), ty: Type::Serial },
|
|
ColumnSpec { name: "Name".to_string(), ty: 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 { name: "id".to_string(), ty: 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 { name: "id".to_string(), ty: 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 { name: "id".to_string(), ty: Type::Serial },
|
|
ColumnSpec { name: "CustId".to_string(), ty: 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::Where {
|
|
column: "id".to_string(),
|
|
value: Value::Number("1".to_string()),
|
|
},
|
|
Some("delete from Customers where id=1".to_string()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.rows_affected, 1);
|
|
});
|
|
|
|
let customers_csv = read_csv(&path, "Customers").expect("Customers.csv");
|
|
let orders_csv = read_csv(&path, "Orders").expect("Orders.csv");
|
|
// Both CSVs should be header-only after cascade.
|
|
assert_eq!(customers_csv.lines().count(), 1, "got: {customers_csv}");
|
|
assert_eq!(orders_csv.lines().count(), 1, "got: {orders_csv}");
|
|
}
|
|
|
|
#[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 { name: "id".to_string(), ty: 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 { name: "id".to_string(), ty: 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 { name: "id".to_string(), ty: 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 { name: "id".to_string(), ty: Type::Serial }],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![
|
|
ColumnSpec { name: "id".to_string(), ty: Type::Serial },
|
|
ColumnSpec { name: "CustId".to_string(), ty: 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}");
|
|
}
|