Skip to main content

classy_sync/data_stores/sqlite/
storage.rs

1use crate::argument_parser::{CollectionType, SyncResources};
2use crate::data_stores::replicate_datastore::Datastore;
3use crate::data_stores::sync_requests::{
4    self, AllSync, AllSyncResult, ClassDataSync, SelectSync, SyncAction, SyncOptions,
5    TermSyncResult,
6};
7use crate::errors::{Error, SyncError};
8use log::{trace, warn};
9use rusqlite::{Connection, Transaction, params_from_iter};
10use serde_json::Value;
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13use std::result::Result;
14use std::{env, fs};
15
16const DEFAULT_MAX_RECORDS: u16 = 10_000;
17
18pub struct Sqlite {
19    conn: Connection,
20}
21
22impl Sqlite {
23    #[cfg(not(test))]
24    pub fn new() -> Result<Sqlite, Error> {
25        let db_path = env::var("SQLITE_DB_PATH")?;
26        let file_path = Path::new(&db_path);
27        Ok(Sqlite {
28            conn: Sqlite::get_db_connection(file_path)?,
29        })
30    }
31
32    #[cfg(test)]
33    pub fn new() -> Result<Sqlite, Error> {
34        let db_path = env::var("SQLITE_DB_PATH")?;
35        let file_path = Path::new(&db_path);
36        Ok(Sqlite {
37            conn: Sqlite::get_db_connection(file_path)?,
38        })
39    }
40
41    fn get_db_connection(file_path: &Path) -> Result<Connection, Error> {
42        if !file_path.exists() {
43            if let Some(parent_dir) = file_path.parent() {
44                fs::create_dir_all(parent_dir)?;
45            }
46            fs::File::create(file_path)?;
47            // TODO: embed the migrations into the build process and run up migrations
48            let up_migration_classy =
49                fs::read_to_string("src/data_stores/sqlite/migrations/001.up.sql").unwrap();
50            let up_migration_sync =
51                fs::read_to_string("src/data_stores/sqlite/migrations/002.up.sql").unwrap();
52            let conn = Connection::open(file_path)?;
53            conn.execute_batch(&up_migration_classy)?;
54            conn.execute_batch(&up_migration_sync)?;
55            Ok(conn)
56        } else {
57            // TODO: check to see if the migrations are up to date
58            Ok(Connection::open(file_path)?)
59        }
60    }
61    // this is the crux of the sqlite data store... being able to convert a `ClassDataSync` into a
62    // sqlite query
63    fn execute_sync(conn: &Transaction, sync: ClassDataSync) -> Result<(), Error> {
64        sync.verify_columns()?;
65        let result = match sync.sync_action {
66            SyncAction::Update => {
67                if sync.relevant_fields.is_none()
68                    || sync.relevant_fields.as_ref().unwrap().is_empty()
69                {
70                    warn!("Update sync with no changes: `{:?}`", sync);
71                    return Ok(());
72                }
73                let mut arg_counter: usize = 0;
74                let mut param_args: Vec<rusqlite::types::Value> = vec![];
75                let mut set_values = vec![];
76                for (col, val) in sync
77                    .relevant_fields
78                    .as_ref()
79                    .unwrap_or(&HashMap::new())
80                    .iter()
81                {
82                    match convert_to_sql_value(val) {
83                        Ok(v) => param_args.push(v),
84                        Err(e) => return Err(e),
85                    }
86                    arg_counter += 1;
87                    set_values.push(format!("{col} = ?{arg_counter}"))
88                }
89                let set_values = set_values.join(", ");
90                let mut where_values = vec![];
91                for (col, val) in sync.pk_fields.iter() {
92                    match convert_to_sql_value(val) {
93                        Ok(v) => param_args.push(v),
94                        Err(e) => return Err(e),
95                    }
96                    arg_counter += 1;
97                    where_values.push(format!("{col} = ?{arg_counter}"))
98                }
99
100                let where_values = where_values.join(" AND ");
101                let sql_string = format!(
102                    "UPDATE {} SET {} WHERE {};",
103                    sync.table_name, set_values, where_values
104                );
105                trace!("update: {}", &sql_string);
106                let maybe_statement = conn.prepare_cached(&sql_string);
107
108                maybe_statement.map(|mut s| s.execute(params_from_iter(param_args)))
109            }
110            SyncAction::Delete => {
111                let mut arg_counter: usize = 0;
112                let mut param_args: Vec<rusqlite::types::Value> = vec![];
113                let mut where_values = vec![];
114                for (col, val) in sync.pk_fields.iter() {
115                    match convert_to_sql_value(val) {
116                        Ok(v) => param_args.push(v),
117                        Err(e) => return Err(e),
118                    }
119                    arg_counter += 1;
120                    where_values.push(format!("{col} = ?{arg_counter}"))
121                }
122                let where_values = where_values.join(" AND ");
123
124                let sql_string = format!("DELETE FROM {} WHERE {};", sync.table_name, where_values);
125                trace!("delete: {}", &sql_string);
126                let maybe_statement = conn.prepare_cached(&sql_string);
127                maybe_statement.map(|mut s| s.execute(params_from_iter(param_args)))
128            }
129            SyncAction::Insert => {
130                let mut arg_counter: usize = 0;
131                let mut param_args: Vec<rusqlite::types::Value> = vec![];
132                let mut columns = vec![];
133                let mut values = vec![];
134                for (col, val) in sync.pk_fields.iter() {
135                    match convert_to_sql_value(val) {
136                        Ok(v) => param_args.push(v),
137                        Err(e) => return Err(e),
138                    }
139                    arg_counter += 1;
140                    columns.push(col.to_string());
141                    values.push(format!("?{arg_counter}"))
142                }
143                for (col, val) in sync
144                    .relevant_fields
145                    .as_ref()
146                    .unwrap_or(&HashMap::new())
147                    .iter()
148                {
149                    match convert_to_sql_value(val) {
150                        Ok(v) => param_args.push(v),
151                        Err(e) => return Err(e),
152                    }
153                    arg_counter += 1;
154                    columns.push(col.to_string());
155                    values.push(format!("?{arg_counter}"))
156                }
157                let columns = columns.join(", ");
158                let values = values.join(", ");
159
160                let sql_string = format!(
161                    "INSERT INTO {} ({}) VALUES ({});",
162                    sync.table_name, columns, values
163                );
164                trace!("insert: {} {:?}", &sql_string, param_args);
165                let maybe_statement = conn.prepare_cached(&sql_string);
166
167                maybe_statement.map(|mut s| s.execute(params_from_iter(param_args)))
168            }
169        };
170        match result {
171            Ok(statement) => match statement {
172                Ok(num) => {
173                    if num != 1 {
174                        warn!("Query affected {} rows expected 1", num)
175                    }
176                    Ok(())
177                }
178                Err(err) => Err(SyncError::new(format!("Error executing query {err:?}"))),
179            },
180            Err(err) => Err(SyncError::new(
181                format!("Error preparing statement {err:?}",),
182            )),
183        }
184    }
185
186    fn is_all_sync(&mut self) -> Result<bool, Error> {
187        Ok(self.conn.query_row(
188            r#" 
189            SELECT EXISTS (
190                SELECT 1 FROM _previous_all_collections
191            );
192            "#,
193            (),
194            |row| row.get(0),
195        )?)
196    }
197
198    fn is_select_sync(&mut self) -> Result<bool, Error> {
199        Ok(self.conn.query_row(
200            r#" 
201        SELECT (
202            EXISTS (SELECT 1 FROM _school_strategies) 
203        );
204        "#,
205            (),
206            |row| row.get(0),
207        )?)
208    }
209    fn get_all_request_options(&mut self) -> Result<AllSync, Error> {
210        if self.is_select_sync()? {
211            return Err(SyncError::new(
212                "Cannot sync all because term sync and or school sync was ran before",
213            ));
214        }
215        let last_sync: u64 = self.conn.query_row(
216            r#" 
217                SELECT COALESCE(MAX(synced_at), 0)
218                FROM _previous_all_collections;
219            "#,
220            (),
221            |row| row.get(0),
222        )?;
223        Ok(AllSync {
224            last_sync,
225            max_records_count: Some(DEFAULT_MAX_RECORDS),
226        })
227    }
228
229    fn get_select_request_options(&mut self) -> Result<SelectSync, Error> {
230        if self.is_all_sync()? {
231            return Err(SyncError::new(
232                "Cannot sync select because sync all has been run previously",
233            ));
234        }
235        let mut all_school_query = self.conn.prepare(
236            r#" 
237                SELECT s.school_id, COALESCE(MAX(p.synced_at), 0) AS sequence
238                FROM _school_strategies s
239                LEFT JOIN _previous_school_collections p ON s.school_id = p.school_id
240                WHERE s.term_collection_id IS NULL
241                GROUP BY s.school_id
242                ;
243            "#,
244        )?;
245        let school_to_last_sequence = all_school_query
246            .query_map((), |r| {
247                let res: (String, u64) = (r.get(0)?, r.get(1)?);
248                Ok(res)
249            })?
250            .collect::<Result<HashMap<_, _>, _>>()?;
251
252        let mut term_school_query = self.conn.prepare(
253            r#" 
254                SELECT s.school_id, s.term_collection_id, COALESCE(MAX(p.synced_at), 0) AS sequence
255                FROM _school_strategies s
256                LEFT JOIN _previous_term_collections p 
257                    ON s.school_id = p.school_id AND s.term_collection_id = p.term_collection_id
258                WHERE s.term_collection_id IS NOT NULL
259                GROUP BY s.school_id, s.term_collection_id
260                ;
261            "#,
262        )?;
263
264        let term_to_last_sequence = term_school_query
265            .query_map((), |r| {
266                let res: ((String, String), u64) = ((r.get(0)?, r.get(1)?), r.get(2)?);
267                Ok(res)
268            })?
269            .collect::<Result<HashMap<_, _>, _>>()?;
270
271        let mut term_sync = SelectSync::new();
272        for ((school_id, term_collection_id), sequence) in term_to_last_sequence {
273            if school_to_last_sequence.contains_key(&school_id) {
274                // this situation happens when an the scope of syncing goes from term to the whole
275                // school
276                // this exclusion is just for the next sync operation and then it should no longer
277                // be needed so long as the school's sync is >= the excluded sequence
278                term_sync.add_exclusion(school_id, term_collection_id, sequence)?;
279            } else {
280                term_sync.add_term_sync(school_id, term_collection_id, sequence)?;
281            }
282        }
283
284        for (school_id, sequence) in school_to_last_sequence {
285            term_sync.add_school_sync(school_id, sequence)?;
286        }
287        Ok(term_sync)
288    }
289}
290
291impl Datastore for Sqlite {
292    fn execute_all_request_sync(&mut self, all_sync_response: AllSyncResult) -> Result<(), Error> {
293        let tx = self.conn.transaction()?;
294        tx.execute(
295            r#" INSERT INTO _previous_all_collections (synced_at) 
296            VALUES ($1);
297        "#,
298            (all_sync_response.new_latest_sync,),
299        )?;
300        for sync in all_sync_response.sync_data.into_iter() {
301            Sqlite::execute_sync(&tx, sync)?
302        }
303        tx.commit()?;
304        Ok(())
305    }
306
307    fn execute_select_request_sync(
308        &mut self,
309        select_sync_request: SelectSync,
310        select_sync_response: TermSyncResult,
311    ) -> Result<(), Error> {
312        let _ = select_sync_request;
313        let tx = self.conn.transaction()?;
314        for (school_id, entry) in &select_sync_response.new_sync_term_sequences {
315            match entry {
316                sync_requests::SchoolEntry::TermToSequence(term_sequence) => {
317                    for (term, sequence) in term_sequence {
318                        tx.execute(
319                            r#"
320                            INSERT INTO _previous_term_collections (synced_at, school_id, term_collection_id) 
321                            VALUES ($1, $2, $3);
322                            "#,
323                            (sequence, school_id, term),
324                        )?;
325                    }
326                }
327                sync_requests::SchoolEntry::Sequence(sequence) => {
328                    tx.execute(
329                        r#"
330                        INSERT INTO _previous_school_collections (synced_at, school_id) 
331                        VALUES ($1, $2);
332                        "#,
333                        (sequence, school_id),
334                    )?;
335                }
336            }
337        }
338        for sync in select_sync_response.sync_data.into_iter() {
339            Sqlite::execute_sync(&tx, sync)?
340        }
341        tx.commit()?;
342        Ok(())
343    }
344
345    fn generate_sync_options(&mut self) -> Result<SyncOptions, Error> {
346        match (self.is_select_sync()?, self.is_all_sync()?) {
347            (true, true) => Err(SyncError::new(
348                "Dirty db state cannot be both select and all sync",
349            )),
350            (true, false) => Ok(SyncOptions::Select(self.get_select_request_options()?)),
351            (false, true) => Ok(SyncOptions::All(self.get_all_request_options()?)),
352            (false, false) => Err(SyncError::new(
353                "Sync stratgey not set! Set the resources to sync.",
354            )),
355        }
356    }
357
358    fn set_request_sync_resources(&mut self, resources: SyncResources) -> Result<(), Error> {
359        match resources {
360            SyncResources::Everything => {
361                if self.is_select_sync()? {
362                    return Err(SyncError::new(
363                        "Cannot set sync all because select syncs have already been done",
364                    ));
365                }
366                // is already set to sync all so do nothing
367                if self.is_all_sync()? {
368                    return Ok(());
369                }
370                self.conn.execute(
371                    r#"
372                    INSERT INTO _previous_all_collections (synced_at)
373                    VALUES (0);
374                    "#,
375                    (),
376                )?;
377            }
378            SyncResources::Select(select_sync_options) => {
379                if self.is_all_sync()? {
380                    return Err(SyncError::new(
381                        "Cannot set sync select because sync all has already been done",
382                    ));
383                }
384                let mut get_full_schools = self.conn.prepare(
385                    r#"
386                    SELECT school_id, term_collection_id
387                    FROM _school_strategies
388                    "#,
389                )?;
390                let mut full_school_collections: HashSet<(String, Option<String>)> = HashSet::new();
391                let full_school_collections_rows =
392                    get_full_schools.query_map((), |r| Ok((r.get(0)?, r.get(1)?)))?;
393                for f in full_school_collections_rows {
394                    full_school_collections.insert(f?);
395                }
396
397                for (school_id, collection_type) in select_sync_options.get_collections() {
398                    match collection_type {
399                        CollectionType::AllSchoolData => {
400                            if !full_school_collections.contains(&(school_id.clone(), None)) {
401                                self.conn.execute(
402                                    r#"
403                                    INSERT INTO _school_strategies 
404                                    (school_id, term_collection_id) 
405                                    VALUES (?, NULL)
406                                    "#,
407                                    [school_id],
408                                )?;
409                            }
410                        }
411                        CollectionType::SelectTermData(terms) => {
412                            if full_school_collections.contains(&(school_id.clone(), None)) {
413                                return Err(SyncError::new(format!(
414                                    "Cannot do select term sync for school `{school_id}` because the whole school as been synced"
415                                )));
416                            }
417                            for term in terms {
418                                if !full_school_collections
419                                    .contains(&(school_id.clone(), Some(term.clone())))
420                                {
421                                    self.conn.execute(
422                                        r#"
423                                        INSERT INTO _school_strategies 
424                                        (school_id, term_collection_id) 
425                                        VALUES (?, ?)
426                                        "#,
427                                        [school_id, term],
428                                    )?;
429                                }
430                            }
431                        }
432                    }
433                }
434            }
435        }
436        Ok(())
437    }
438
439    fn unset_request_sync_resources(&mut self, resources: SyncResources) -> Result<(), Error> {
440        let _ = resources;
441        todo!()
442    }
443}
444
445fn convert_to_sql_value(v: &Value) -> Result<rusqlite::types::Value, Error> {
446    match v {
447        Value::String(s) => Ok(rusqlite::types::Value::Text(s.to_string())),
448        Value::Null => Ok(rusqlite::types::Value::Null),
449        Value::Bool(b) => Ok(rusqlite::types::Value::Integer(*b as i64)),
450        Value::Number(n) => {
451            if let Some(n) = n.as_i64() {
452                Ok(rusqlite::types::Value::Integer(n))
453            } else if let Some(n) = n.as_f64() {
454                Ok(rusqlite::types::Value::Real(n))
455            } else {
456                Ok(rusqlite::types::Value::Null)
457            }
458        }
459        _ => Err(SyncError::new(format!("Unsupported type {v:?}"))),
460    }
461}
462
463#[cfg(test)]
464mod sync_tests {
465    use super::*;
466    use dotenv::dotenv;
467    use log::info;
468    use serde_json::from_str;
469    use std::{fs, path::PathBuf};
470
471    // note if not using an in-memory database only run a single test or use --test-threads=1
472    //   which will leave your database with the last sqlite test data in the db
473    #[test]
474    fn full_sync() {
475        dotenv().ok();
476        env_logger::init();
477        let mut conn;
478        if let Ok(path_to_sqlite_db) = env::var("TEST_SQLITE_DB_PATH") {
479            let file_path = Path::new(&path_to_sqlite_db);
480            if file_path.exists() {
481                fs::remove_file(file_path).unwrap()
482            }
483            conn = Sqlite::get_db_connection(file_path).unwrap();
484        } else {
485            conn = Connection::open_in_memory().unwrap();
486        }
487
488        let mut stored_syncs = Vec::new();
489        let directory_of_test_syncs = "test-syncs/maristfall2024";
490        for entry in fs::read_dir(directory_of_test_syncs).unwrap() {
491            let entry = entry.unwrap();
492            let path = entry.path();
493            if path.is_file() {
494                if let Some(extension) = path.extension() {
495                    if extension != "json" {
496                        continue;
497                    }
498                    if let Some(file_name) = path.file_name() {
499                        if let Some(file_name_str) = file_name.to_str() {
500                            stored_syncs.push(file_name_str.to_string());
501                        }
502                    }
503                }
504            }
505        }
506        stored_syncs.sort();
507
508        let mut base_path = PathBuf::new();
509        base_path.push(directory_of_test_syncs);
510        for test_sync in &stored_syncs {
511            let mut full_path = base_path.clone();
512            full_path.push(test_sync);
513            info!("Starting sync: {}", test_sync);
514            let tx = conn.transaction().unwrap();
515            let updates_text = fs::read_to_string(&full_path).unwrap();
516            let response: AllSyncResult = from_str(&updates_text).unwrap();
517            for update in response.sync_data {
518                Sqlite::execute_sync(&tx, update).unwrap()
519            }
520            tx.commit().unwrap();
521            info!("Finished sync: {}", test_sync);
522        }
523    }
524}