mod support; use std::ffi::OsString; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use dever_core::source::SourceMap; use dever_runtime::database::{Field, Model, Schema, SchemaSql}; use dever_runtime::orm::{Row, Value}; use dever_runtime::sqlite::Database; const GUARD_MODEL: &str = r#" database z public type Guard { code: Text(16) } "#; #[derive(Debug, PartialEq)] struct ConnectionState { tables: Vec, history: Vec, data: Vec>, } #[derive(Debug, PartialEq)] struct CommittedConnections { a: ConnectionState, default: ConnectionState, report: ConnectionState, root: ConnectionState, } fn model_source(name: &str, database: Option<&str>, seed: &str) -> String { let database = database .map(|name| format!("database {name}\n\n")) .unwrap_or_default(); format!( "{database}public type {name} {{\n code: Text(16) unique\n}}\n\nseed {{\n {{\n code = \"{seed}\"\n }}\n}}\n" ) } fn sources() -> SourceMap { let mut sources = SourceMap::default(); // The z-bound Model is deliberately first. Runtime migration order must not follow this order. sources.add("zfirst/model/guard.dever", GUARD_MODEL); sources.add( "later/model/omega.dever", model_source("Omega", Some("a"), "omega"), ); sources.add( "earlier/model/beta.dever", model_source("Beta", Some("a"), "beta"), ); sources.add( "fallback/model/defaulted.dever", model_source("Defaulted", None, "default"), ); sources.add( "root/model/rooted.dever", model_source("Rooted", None, "root"), ); sources.add( "analytics/model/reported.dever", model_source("Reported", Some("report"), "report"), ); sources.add("main.dever", "public main() () {}\n"); sources } #[test] fn named_connection_models_are_valid_without_opening_a_database() { let sources = sources(); let program = dever_core::check(&sources).unwrap_or_else(|errors| { panic!( "{}", errors .iter() .map(|error| error.render(&sources)) .collect::() ) }); assert!( program .model_snapshot() .contains("model zfirst.model.guard name=Guard") ); } fn compile() -> (dever_core::native::NativeProgram, String, String) { let sources = sources(); let program = dever_core::check(&sources).unwrap_or_else(|errors| { panic!( "{}", errors .iter() .map(|error| error.render(&sources)) .collect::() ) }); let snapshot = program.model_snapshot(); let guard = snapshot .lines() .find(|line| line.starts_with("model zfirst.model.guard ")) .expect("guard Model snapshot"); let revision = snapshot_value(guard, "revision"); let seed_revision = snapshot_value(guard, "seed"); let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")); let native = dever_core::native::compile_application( &program, &sources, "main.main", &rustc, dever_runtime::config::RuntimeProfile { sqlite: true, postgres: false, }, ) .unwrap(); (native, revision, seed_revision) } fn snapshot_value(line: &str, name: &str) -> String { line.split_whitespace() .find_map(|part| part.strip_prefix(&format!("{name}="))) .expect("Model snapshot value") .to_owned() } fn configure(root: &Path) { fs::create_dir_all(root.join("config")).unwrap(); fs::create_dir_all(root.join("data/db")).unwrap(); fs::write( root.join("config/setting.json"), r#"{ "database": { "default": {"type":"sqlite","path":"data/db/default.db","max_connections":1,"max_page_size":20}, "a": {"type":"sqlite","path":"data/db/a.db","max_connections":1,"max_page_size":20}, "analytics": {"type":"sqlite","path":"data/db/analytics.db","max_connections":1,"max_page_size":20}, "report": {"type":"sqlite","path":"data/db/report.db","max_connections":1,"max_page_size":20}, "root": {"type":"sqlite","path":"data/db/root.db","max_connections":1,"max_page_size":20}, "z": {"type":"sqlite","path":"data/db/z.db","max_connections":1,"max_page_size":20} } }"#, ) .unwrap(); } fn database_path(root: &Path, name: &str) -> PathBuf { root.join("data/db").join(format!("{name}.db")) } fn run(native: &dever_core::native::NativeProgram) -> Output { Command::new(native.executable()).output().unwrap() } async fn execute(path: &Path, sql: String) { let database = Database::open(path, 1, 20).unwrap(); database.execute_batch(sql).await.unwrap(); } fn guard_table(table: &str) -> String { format!( "CREATE TABLE \"{table}\" (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT,\n \"code\" TEXT NOT NULL CHECK (length(\"code\") <= 16),\n \"created_at\" INTEGER NOT NULL DEFAULT (unixepoch() * 1000)\n)" ) } async fn prepare_guard_catalog_drift(path: &Path, revision: String, seed_revision: String) { let database = Database::open(path, 1, 20).unwrap(); database .migrate(Model { schema: Schema { model: "zfirst.model.guard".into(), table: "guard".into(), revision, seed_revision, fields: vec![ Field { name: "id".into(), ty: "id:zfirst.model.guard".into(), nullable: false, generated: true, default: None, rename_from: None, }, Field { name: "code".into(), ty: "Text(16)".into(), nullable: false, generated: false, default: None, rename_from: None, }, Field { name: "created_at".into(), ty: "DateTime".into(), nullable: false, generated: true, default: Some("generated:created_at".into()), rename_from: None, }, ], indexes: vec![], migrations: vec![], }, create_table: SchemaSql { sqlite: guard_table("guard"), postgres: String::new(), }, create_temporary_table: guard_table("_dever_guard_test"), create_indexes: vec![], postgres_columns: vec![], postgres_constraints: vec![], seeds: vec![], data_migrations: vec![], }) .await .unwrap(); database .execute_batch("ALTER TABLE guard ADD COLUMN rogue TEXT".into()) .await .unwrap(); } async fn connection_state(path: &Path, queries: &[&'static str]) -> ConnectionState { if !path.exists() { return ConnectionState { tables: vec![], history: vec![], data: vec![], }; } let database = Database::open(path, 1, 20).unwrap(); let tables = database .query( "SELECT name, sql FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != '_dever_history' ORDER BY name", vec![], ) .await .unwrap(); let has_history = database .query( "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = '_dever_history'", vec![], ) .await .unwrap(); let history = if has_history.is_empty() { vec![] } else { database .query( "SELECT model, kind, name, revision, definition FROM _dever_history ORDER BY model, kind, name", vec![], ) .await .unwrap() }; let mut data = Vec::new(); for query in queries { data.push(database.query(query, vec![]).await.unwrap()); } ConnectionState { tables, history, data, } } async fn committed_connections( a: &Path, default: &Path, report: &Path, root: &Path, ) -> CommittedConnections { CommittedConnections { a: connection_state( a, &[ "SELECT code FROM omega ORDER BY id", "SELECT code FROM beta ORDER BY id", ], ) .await, default: connection_state(default, &["SELECT code FROM defaulted ORDER BY id"]).await, report: connection_state(report, &["SELECT code FROM reported ORDER BY id"]).await, root: connection_state(root, &["SELECT code FROM rooted ORDER BY id"]).await, } } fn text(row: &Row, index: usize) -> &str { match row.get(index).unwrap() { Value::Text(value) => value, value => panic!("expected text at column {index}, got {value:?}"), } } fn table_names(state: &ConnectionState) -> Vec<&str> { state.tables.iter().map(|row| text(row, 0)).collect() } fn history_models(state: &ConnectionState) -> Vec<&str> { state .history .iter() .filter(|row| text(row, 1) == "schema") .map(|row| text(row, 0)) .collect() } fn assert_complete_history(state: &ConnectionState, models: &[&str]) { let expected = models .iter() .flat_map(|model| ["catalog", "schema", "seed"].map(|kind| (*model, kind, "current"))) .collect::>(); let actual = state .history .iter() .map(|row| (text(row, 0), text(row, 1), text(row, 2))) .collect::>(); assert_eq!(actual, expected); } #[tokio::test] async fn native_sqlite_connections_bind_and_recover_in_stable_order() { let (native, guard_revision, guard_seed_revision) = compile(); let root = native.executable().parent().unwrap(); configure(root); let a = database_path(root, "a"); let default = database_path(root, "default"); let analytics = database_path(root, "analytics"); let report = database_path(root, "report"); let root_database = database_path(root, "root"); let z = database_path(root, "z"); execute( &a, "CREATE TABLE beta (id INTEGER PRIMARY KEY, code INTEGER NOT NULL)".into(), ) .await; prepare_guard_catalog_drift(&z, guard_revision, guard_seed_revision).await; let first = run(&native); assert!(!first.status.success()); let first_error = String::from_utf8(first.stderr).unwrap(); assert!( first_error.contains("table 'beta' exists without Dever schema history"), "{first_error}" ); let first_a = connection_state(&a, &["SELECT code FROM omega ORDER BY id"]).await; assert_eq!(table_names(&first_a), ["beta", "omega"]); assert_eq!(history_models(&first_a), ["later.model.omega"]); assert_complete_history(&first_a, &["later.model.omega"]); assert_eq!(text(&first_a.data[0][0], 0), "omega"); for pending in [&default, &report, &root_database] { assert_eq!( connection_state(pending, &[]).await, ConnectionState { tables: vec![], history: vec![], data: vec![], } ); } execute(&a, "DROP TABLE beta".into()).await; let second = run(&native); assert!(!second.status.success()); let second_error = String::from_utf8(second.stderr).unwrap(); assert!( second_error.contains("table 'guard' does not match its recorded Dever schema"), "{second_error}" ); let second_connections = committed_connections(&a, &default, &report, &root_database).await; assert_eq!(table_names(&second_connections.a), ["beta", "omega"]); assert_eq!( history_models(&second_connections.a), ["earlier.model.beta", "later.model.omega"] ); assert_complete_history( &second_connections.a, &["earlier.model.beta", "later.model.omega"], ); assert_eq!(table_names(&second_connections.default), ["defaulted"]); assert_eq!( history_models(&second_connections.default), ["fallback.model.defaulted"] ); assert_complete_history(&second_connections.default, &["fallback.model.defaulted"]); assert_eq!(table_names(&second_connections.report), ["reported"]); assert_eq!( history_models(&second_connections.report), ["analytics.model.reported"] ); assert_complete_history(&second_connections.report, &["analytics.model.reported"]); assert_eq!(table_names(&second_connections.root), ["rooted"]); assert_eq!( history_models(&second_connections.root), ["root.model.rooted"] ); assert_complete_history(&second_connections.root, &["root.model.rooted"]); assert_eq!(text(&second_connections.a.data[0][0], 0), "omega"); assert_eq!(text(&second_connections.a.data[1][0], 0), "beta"); assert_eq!(text(&second_connections.default.data[0][0], 0), "default"); assert_eq!(text(&second_connections.report.data[0][0], 0), "report"); assert_eq!(text(&second_connections.root.data[0][0], 0), "root"); assert_eq!( connection_state(&analytics, &[]).await, ConnectionState { tables: vec![], history: vec![], data: vec![], } ); execute(&z, format!("DROP TABLE guard; {}", guard_table("guard"))).await; let third = run(&native); assert!( third.status.success(), "{}", String::from_utf8_lossy(&third.stderr) ); let third_connections = committed_connections(&a, &default, &report, &root_database).await; let third_z = connection_state(&z, &["SELECT code FROM guard ORDER BY id"]).await; assert_eq!(third_connections, second_connections); assert_eq!(table_names(&third_z), ["guard"]); assert_eq!(history_models(&third_z), ["zfirst.model.guard"]); assert_complete_history(&third_z, &["zfirst.model.guard"]); assert!(third_z.data[0].is_empty()); let fourth = run(&native); assert!( fourth.status.success(), "{}", String::from_utf8_lossy(&fourth.stderr) ); assert_eq!( committed_connections(&a, &default, &report, &root_database).await, third_connections ); assert_eq!( connection_state(&z, &["SELECT code FROM guard ORDER BY id"]).await, third_z ); }