//! 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()).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).await }) .expect("query_data"); assert_eq!(rows.rows.len(), 2); let names: Vec> = 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(), vec!["id".to_string()], "Orders".to_string(), vec!["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::>() .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()) .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()).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()]); }