lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }