export.rs (9225B)
1 use radroots_sql_core::{SqlExecutor, error::SqlError, utils}; 2 use serde::{Deserialize, Serialize}; 3 use sha2::{Digest, Sha256}; 4 5 use crate::backup::{ 6 DATABASE_BACKUP_VERSION, MigrationBackup, REPLICA_STORE_SCHEMA_VERSION, SchemaEntry, 7 escape_identifier, export_migrations, load_schema, 8 }; 9 10 pub const REPLICA_STORE_EXPORT_VERSION: &str = "1"; 11 12 #[derive(Debug, Clone, Serialize, Deserialize)] 13 pub struct TableCount { 14 pub name: String, 15 pub row_count: u64, 16 } 17 18 #[derive(Debug, Clone, Serialize, Deserialize)] 19 pub struct ReplicaStoreExportManifestRs { 20 pub export_version: String, 21 /// Replica schema compatibility version, independent of package SemVer. 22 pub replica_store_version: String, 23 pub backup_format_version: String, 24 pub schema_hash: String, 25 pub schema: Vec<SchemaEntry>, 26 pub migrations: Vec<MigrationBackup>, 27 pub table_counts: Vec<TableCount>, 28 } 29 30 pub fn export_manifest( 31 executor: &dyn SqlExecutor, 32 ) -> Result<ReplicaStoreExportManifestRs, SqlError> { 33 let schema = load_schema(executor)?; 34 let migrations = export_migrations(); 35 let table_counts = load_table_counts(executor, &schema)?; 36 let schema_hash = schema_hash(&schema); 37 Ok(ReplicaStoreExportManifestRs { 38 export_version: REPLICA_STORE_EXPORT_VERSION.to_string(), 39 replica_store_version: REPLICA_STORE_SCHEMA_VERSION.to_string(), 40 backup_format_version: DATABASE_BACKUP_VERSION.to_string(), 41 schema_hash, 42 schema, 43 migrations, 44 table_counts, 45 }) 46 } 47 48 fn load_table_counts( 49 executor: &dyn SqlExecutor, 50 schema: &[SchemaEntry], 51 ) -> Result<Vec<TableCount>, SqlError> { 52 #[derive(Deserialize)] 53 struct CountRow { 54 count: u64, 55 } 56 let mut counts = Vec::new(); 57 for entry in schema.iter().filter(|s| s.object_type == "table") { 58 let sql = format!( 59 "select count(1) as count from {}", 60 escape_identifier(&entry.name) 61 ); 62 let json = executor.query_raw(&sql, "[]")?; 63 let rows: Vec<CountRow> = utils::parse_json(&json)?; 64 let row_count = rows.first().map(|row| row.count).unwrap_or(0); 65 counts.push(TableCount { 66 name: entry.name.clone(), 67 row_count, 68 }); 69 } 70 Ok(counts) 71 } 72 73 fn schema_hash(schema: &[SchemaEntry]) -> String { 74 let mut hasher = Sha256::new(); 75 for entry in schema { 76 hasher.update(entry.object_type.as_bytes()); 77 hasher.update([0]); 78 hasher.update(entry.name.as_bytes()); 79 hasher.update([0]); 80 if let Some(table_name) = &entry.table_name { 81 hasher.update(table_name.as_bytes()); 82 } 83 hasher.update([0]); 84 if let Some(sql) = &entry.sql { 85 hasher.update(sql.as_bytes()); 86 } 87 hasher.update([255]); 88 } 89 hex::encode(hasher.finalize()) 90 } 91 92 #[cfg(test)] 93 mod tests { 94 use super::*; 95 use radroots_sql_core::ExecOutcome; 96 97 fn assert_sql_error_code<T: core::fmt::Debug>(result: Result<T, SqlError>, code: &str) { 98 let err = result.unwrap_err(); 99 assert_eq!(err.code(), code); 100 } 101 102 struct MockExecutor { 103 query_rules: Vec<(String, String)>, 104 fail_query_contains: Option<String>, 105 } 106 107 impl MockExecutor { 108 fn new(query_rules: Vec<(String, String)>, fail_query_contains: Option<String>) -> Self { 109 Self { 110 query_rules, 111 fail_query_contains, 112 } 113 } 114 } 115 116 impl SqlExecutor for MockExecutor { 117 fn exec(&self, _sql: &str, _params_json: &str) -> Result<ExecOutcome, SqlError> { 118 Ok(ExecOutcome { 119 changes: 1, 120 last_insert_id: 1, 121 }) 122 } 123 124 fn query_raw(&self, sql: &str, _params_json: &str) -> Result<String, SqlError> { 125 if let Some(needle) = &self.fail_query_contains 126 && sql.contains(needle) 127 { 128 return Err(SqlError::InvalidQuery(String::from("forced query failure"))); 129 } 130 for (needle, response) in &self.query_rules { 131 if sql.contains(needle) { 132 return Ok(response.clone()); 133 } 134 } 135 Ok(String::from("[]")) 136 } 137 138 fn begin(&self) -> Result<(), SqlError> { 139 Ok(()) 140 } 141 142 fn commit(&self) -> Result<(), SqlError> { 143 Ok(()) 144 } 145 146 fn rollback(&self) -> Result<(), SqlError> { 147 Ok(()) 148 } 149 } 150 151 #[test] 152 fn export_manifest_propagates_schema_query_errors() { 153 let executor = MockExecutor::new( 154 Vec::new(), 155 Some(String::from( 156 "select type, name, tbl_name as table_name, sql from sqlite_master", 157 )), 158 ); 159 assert_sql_error_code(export_manifest(&executor), "ERR_INVALID_QUERY"); 160 } 161 162 #[test] 163 fn export_manifest_propagates_table_count_query_errors() { 164 let schema_rows = serde_json::json!([ 165 { 166 "type": "table", 167 "name": "tb_a", 168 "table_name": "tb_a", 169 "sql": "CREATE TABLE tb_a (id TEXT);" 170 } 171 ]) 172 .to_string(); 173 let executor = MockExecutor::new( 174 vec![( 175 String::from("select type, name, tbl_name as table_name, sql from sqlite_master"), 176 schema_rows, 177 )], 178 Some(String::from("select count(1) as count from \"tb_a\"")), 179 ); 180 assert_sql_error_code(export_manifest(&executor), "ERR_INVALID_QUERY"); 181 } 182 183 #[test] 184 fn export_manifest_propagates_table_count_parse_errors() { 185 let schema_rows = serde_json::json!([ 186 { 187 "type": "table", 188 "name": "tb_a", 189 "table_name": "tb_a", 190 "sql": "CREATE TABLE tb_a (id TEXT);" 191 } 192 ]) 193 .to_string(); 194 let executor = MockExecutor::new( 195 vec![ 196 ( 197 String::from( 198 "select type, name, tbl_name as table_name, sql from sqlite_master", 199 ), 200 schema_rows, 201 ), 202 ( 203 String::from("select count(1) as count from \"tb_a\""), 204 String::from("{"), 205 ), 206 ], 207 None, 208 ); 209 assert_sql_error_code(export_manifest(&executor), "ERR_SERIALIZATION"); 210 } 211 212 #[test] 213 fn export_manifest_defaults_missing_count_row_to_zero() { 214 let schema_rows = serde_json::json!([ 215 { 216 "type": "table", 217 "name": "tb_a", 218 "table_name": "tb_a", 219 "sql": "CREATE TABLE tb_a (id TEXT);" 220 } 221 ]) 222 .to_string(); 223 let executor = MockExecutor::new( 224 vec![ 225 ( 226 String::from( 227 "select type, name, tbl_name as table_name, sql from sqlite_master", 228 ), 229 schema_rows, 230 ), 231 ( 232 String::from("select count(1) as count from \"tb_a\""), 233 String::from("[]"), 234 ), 235 ], 236 None, 237 ); 238 let manifest = export_manifest(&executor).expect("export should succeed"); 239 assert_eq!(manifest.replica_store_version, "1.0.0"); 240 assert_eq!(manifest.table_counts.len(), 1); 241 assert_eq!(manifest.table_counts[0].name, "tb_a"); 242 assert_eq!(manifest.table_counts[0].row_count, 0); 243 } 244 245 #[test] 246 fn schema_hash_handles_optional_fields() { 247 let with_all = SchemaEntry { 248 object_type: String::from("table"), 249 name: String::from("tb_a"), 250 table_name: Some(String::from("tb_a")), 251 sql: Some(String::from("CREATE TABLE tb_a (id TEXT);")), 252 }; 253 let without_optional = SchemaEntry { 254 object_type: String::from("index"), 255 name: String::from("ix_a"), 256 table_name: None, 257 sql: None, 258 }; 259 let hash = schema_hash(&[with_all, without_optional]); 260 assert_eq!(hash.len(), 64); 261 } 262 263 #[test] 264 fn mock_executor_trait_and_query_paths_are_covered() { 265 let executor = MockExecutor::new( 266 vec![(String::from("select 1"), String::from("[{\"count\":1}]"))], 267 None, 268 ); 269 let outcome = executor.exec("select 1", "[]").expect("exec"); 270 assert_eq!(outcome.changes, 1); 271 assert_eq!(outcome.last_insert_id, 1); 272 273 executor.begin().expect("begin"); 274 executor.commit().expect("commit"); 275 executor.rollback().expect("rollback"); 276 277 let matched = executor.query_raw("select 1", "[]").expect("matched query"); 278 assert_eq!(matched, "[{\"count\":1}]"); 279 let fallback = executor 280 .query_raw("select 2", "[]") 281 .expect("fallback query"); 282 assert_eq!(fallback, "[]"); 283 284 let failing = MockExecutor::new(Vec::new(), Some(String::from("select fail"))); 285 assert_sql_error_code(failing.query_raw("select fail", "[]"), "ERR_INVALID_QUERY"); 286 } 287 }