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}");
|
||||
}
|
||||
@@ -175,7 +175,7 @@ fn db_persists_across_open_close_cycles() {
|
||||
},
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
)
|
||||
None)
|
||||
.await
|
||||
.expect("create_table");
|
||||
});
|
||||
|
||||
+40
-26
@@ -42,6 +42,20 @@ 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");
|
||||
@@ -72,16 +86,16 @@ fn typing_then_submitting_a_dsl_command_emits_execute_action() {
|
||||
);
|
||||
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::CreateTable {
|
||||
assert_one_execute_dsl(
|
||||
&actions,
|
||||
&Command::CreateTable {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![ColumnSpec {
|
||||
name: "id".to_string(),
|
||||
ty: 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);
|
||||
@@ -262,7 +276,7 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
|
||||
}],
|
||||
primary_key: vec!["id".to_string()],
|
||||
};
|
||||
assert_eq!(actions, vec![Action::ExecuteDsl(expected_cmd.clone())]);
|
||||
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)]);
|
||||
@@ -302,13 +316,13 @@ fn add_column_flow_updates_structure_view() {
|
||||
|
||||
type_str(&mut app, "add column to table Customers: Name (text)");
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::AddColumn {
|
||||
assert_one_execute_dsl(
|
||||
&actions,
|
||||
&Command::AddColumn {
|
||||
table: "Customers".to_string(),
|
||||
column: "Name".to_string(),
|
||||
ty: Type::Text,
|
||||
})]
|
||||
},
|
||||
);
|
||||
|
||||
let updated = fake_table(
|
||||
@@ -336,11 +350,11 @@ fn drop_table_flow_clears_items_list() {
|
||||
|
||||
type_str(&mut app, "drop table Customers");
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::DropTable {
|
||||
name: "Customers".to_string()
|
||||
})]
|
||||
assert_one_execute_dsl(
|
||||
&actions,
|
||||
&Command::DropTable {
|
||||
name: "Customers".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
@@ -366,9 +380,9 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
||||
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade",
|
||||
);
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::AddRelationship {
|
||||
assert_one_execute_dsl(
|
||||
&actions,
|
||||
&Command::AddRelationship {
|
||||
name: None,
|
||||
parent_table: "Customers".to_string(),
|
||||
parent_column: "Id".to_string(),
|
||||
@@ -377,7 +391,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
create_fk: false,
|
||||
})]
|
||||
},
|
||||
);
|
||||
|
||||
// The runtime now feeds back the parent (Customers) so the
|
||||
@@ -470,13 +484,13 @@ fn insert_flow_emits_action_and_renders_data() {
|
||||
|
||||
type_str(&mut app, "insert into Customers values ('Alice')");
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::Insert {
|
||||
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.
|
||||
@@ -516,12 +530,12 @@ 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_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::Delete {
|
||||
assert_one_execute_dsl(
|
||||
&actions,
|
||||
&Command::Delete {
|
||||
table: "Customers".to_string(),
|
||||
filter: RowFilter::AllRows,
|
||||
})]
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user