From 54100753981902b58e60a21e1de4a5f79f40edf3 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 7 May 2026 21:49:28 +0000 Subject: [PATCH] Persistence: empty table -> no CSV (per Iteration 2 follow-up) The Iteration-2 rule wrote a header-only CSV for every existing table, which surprised users who created a table and saw a file appear before any data went in. Tighten the rule: a CSV exists iff the table has rows. Persistence::write_table_data now delegates to delete_table_data when the snapshot is empty, removing any prior CSV. The schema-only invariant (YAML knows the table; CSV knows its rows) is preserved. The cascade-delete integration test was rewritten to assert the CSVs vanish; two new tests pin the rule (create -> no CSV; delete --all-rows -> CSV removed). Tests: 291 passing (256 lib + 9 + 9 + 17), 0 failing, 0 skipped. --- src/persistence/mod.rs | 12 +++++ tests/iteration2_persistence.rs | 88 +++++++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index b6823da..40a2c6c 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -155,7 +155,19 @@ impl Persistence { /// Write `data/.csv` from a table snapshot. Atomic /// per file. Creates the `data/` directory if missing /// (tolerant of fresh projects). + /// + /// **Empty tables produce no CSV.** A header-only file + /// would carry no information beyond what `project.yaml` + /// already records, so an empty snapshot is treated + /// identically to "drop this table's data file": the CSV + /// is removed if it exists, no file is created if it + /// doesn't. This keeps the rule "data lives in CSV; no + /// data, no CSV" consistent and avoids surprising users + /// with files they didn't ask for. pub fn write_table_data(&self, table: &TableSnapshot) -> Result<(), PersistenceError> { + if table.rows.is_empty() { + return self.delete_table_data(&table.name); + } let data_dir = self.project_path.join(DATA_DIR); fs::create_dir_all(&data_dir).map_err(|source| PersistenceError::Io { operation: "create", diff --git a/tests/iteration2_persistence.rs b/tests/iteration2_persistence.rs index 3d1b7b6..b1a0535 100644 --- a/tests/iteration2_persistence.rs +++ b/tests/iteration2_persistence.rs @@ -240,11 +240,89 @@ fn delete_with_cascade_rewrites_both_csvs() { 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}"); + // 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 { 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(); + }); + + // 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 { 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(); + // 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]