Iteration 2: per-command write-through to project.yaml, CSVs, history.log
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.
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
//! 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}");
|
||||
}
|
||||
Reference in New Issue
Block a user