Skip to main content

omry_db/
db.rs

1//! Database abstractions.
2#![cfg(feature = "connection")]
3
4use deadpool_sqlite::rusqlite;
5use deadpool_sqlite::rusqlite::Row;
6use deadpool_sqlite::rusqlite::types::Value;
7use deadpool_sqlite::{Config, Object, Pool, Runtime};
8use rusqlite_migration::{M, Migrations};
9use std::path::Path;
10use std::rc::Rc;
11use thiserror::Error;
12use tracing::instrument;
13
14use crate::model::{DbRecord, DbRecordMeta};
15
16/// Maximum number of 'bound' (programmatically populated)
17/// parameters allowed in a query.
18const MAX_BIND_PARAMS: usize = 32766;
19
20/// Limit used when selecting all records.
21const SELECT_ALL_LIMIT: i64 = 50;
22
23/// Tries to query for rows in `records` using the given statement, and convert them to [`DbRecord`].
24macro_rules! try_query_db_records {
25    ($stmt:ident, $values:expr, $type:ty) => {{
26        $stmt
27            .query_map($values, |row| <$type>::try_from(row))
28            .inspect_err(|e| tracing::error!("Couldn't execute the query: {e}"))?
29            .collect::<std::result::Result<Vec<$type>, rusqlite::Error>>()?
30    }};
31
32    ($stmt:ident, $values:expr) => {
33        try_query_db_records!($stmt, $values, DbRecord)
34    };
35}
36
37const MIGRATIONS_SLICE: &[M<'_>] = &[
38    M::up(
39        r"CREATE TABLE IF NOT EXISTS records(id INTEGER NOT NULL PRIMARY KEY,
40url TEXT NOT NULL,
41client_datetime TEXT NOT NULL,
42client_datetime_raw TEXT,
43timestamp_flora INTEGER NOT NULL,
44title TEXT NOT NULL,
45language TEXT,
46web_archive_serialized BLOB NOT NULL);",
47    ),
48    M::up("ALTER TABLE records ADD COLUMN updated_at INTEGER;"),
49    M::up("CREATE INDEX timestamp ON records(timestamp_flora);"),
50];
51const MIGRATIONS: Migrations<'_> = Migrations::from_slice(MIGRATIONS_SLICE);
52
53/// Errors that may occur when interacting with database APIs.
54#[derive(Debug, Error)]
55#[error(transparent)]
56pub struct DatabaseError(DbErrorRepr);
57
58// Implement this manually so we can still conveniently use ? with errors
59// that map onto variants of DatabaseErrorRepr
60// (using #[from] would conflict with this).
61impl<E> From<E> for DatabaseError
62where
63    E: Into<DbErrorRepr>,
64{
65    fn from(error: E) -> Self {
66        Self(error.into())
67    }
68}
69
70/// Database errors.
71#[derive(Error, Debug)]
72enum DbErrorRepr {
73    /// Error when creating the database connection pool.
74    #[error("error creating the DB pool: {0}")]
75    CreatePool(#[from] deadpool_sqlite::CreatePoolError),
76
77    /// Error when interacting with the database pool.
78    // This error has a string payload and a manual implementation of [`From`]
79    // because `deadpool_sqlite::InteractError` isn't Sync.
80    #[error("error interacting with the DB pool: {0}")]
81    PoolInteract(String),
82
83    /// Error when getting a new connection from the database pool.
84    #[error("error getting connection from the DB pool: {0}")]
85    Pool(#[from] deadpool_sqlite::PoolError),
86
87    /// Any unspecified error returned by [`rusqlite`].
88    #[error("error in rusqlite: {0}")]
89    Rusqlite(#[from] rusqlite::Error),
90
91    /// Error returned when a query function was given too many
92    /// parameters to bind.
93    #[error(
94        "The caller passed too many parameters to bind (got {0}, max allowed is {MAX_BIND_PARAMS})"
95    )]
96    TooManyBindParameters(usize),
97
98    /// Error activating write-ahead logging.
99    #[error("Couldn't activate the WAL; journaling mode is {0}")]
100    WalActivation(String),
101
102    #[error("error performing migration: {0}")]
103    Migration(#[from] rusqlite_migration::Error),
104
105    #[error("error performing record update: {0}")]
106    Update(String),
107
108    #[error("error converting int: {0}")]
109    IntConversion(#[from] std::num::TryFromIntError),
110}
111
112impl From<deadpool_sqlite::InteractError> for DbErrorRepr {
113    fn from(error: deadpool_sqlite::InteractError) -> Self {
114        Self::PoolInteract(error.to_string())
115    }
116}
117
118/// Alias for results returned by the database APIs.
119pub type Result<T> = std::result::Result<T, DatabaseError>;
120
121impl TryFrom<&Row<'_>> for DbRecord {
122    type Error = rusqlite::Error;
123
124    fn try_from(row: &Row) -> std::result::Result<Self, Self::Error> {
125        Ok(Self {
126            id: row.get(0)?,
127            url: row.get(1)?,
128            client_datetime: row.get(2)?,
129            client_datetime_raw: row.get(3)?,
130            timestamp_flora: row.get(4)?,
131            title: row.get(5)?,
132            language: row.get(6)?,
133            document: row.get(7)?,
134            updated_at: row.get(8)?,
135        })
136    }
137}
138
139impl TryFrom<&Row<'_>> for DbRecordMeta {
140    type Error = rusqlite::Error;
141
142    fn try_from(row: &Row) -> std::result::Result<Self, Self::Error> {
143        Ok(Self {
144            id: row.get(0)?,
145            url: row.get(1)?,
146            client_datetime: row.get(2)?,
147            timestamp_flora: row.get(3)?,
148            title: row.get(4)?,
149            language: row.get(5)?,
150            updated_at: row.get(6)?,
151        })
152    }
153}
154/// Abstraction for flora's primary datastore (SQLite).
155#[derive(Debug, Clone)]
156pub struct Database {
157    /// The connection pool.
158    pool: Pool,
159}
160
161impl Database {
162    /// Connects to the SQLite database at the given URL and creates a new `Database`.
163    ///
164    /// # Errors
165    /// Returns an instance of [`DatabaseError`] on error.
166    #[instrument(skip(path))]
167    async fn new(path: impl AsRef<Path>) -> Result<Self> {
168        let cfg = Config::new(path.as_ref());
169        let pool = cfg.create_pool(Runtime::Tokio1)?;
170        Ok(Self { pool })
171    }
172
173    /// Gets a connection, and calls [`rusqlite::vtab::array::load_module`] with it.
174    ///
175    /// This method should be called any time you need to get
176    /// a connection that will use the `array` extension of [`rusqlite`].
177    /// If you don't use this method, the pool may return a connection that didn't
178    /// have `array` loaded, even if you had called this method previously.
179    ///
180    ///
181    /// See <https://docs.rs/rusqlite/latest/rusqlite/vtab/array/index.html>.
182    #[instrument(skip(self))]
183    async fn get_conn_array(&self) -> Result<Object> {
184        let conn = self.pool.get().await?;
185        conn.interact(|conn| {
186            tracing::debug!("Loading rusqlite::vtab::array");
187            rusqlite::vtab::array::load_module(conn)
188        })
189        .await?
190        .inspect(|()| tracing::debug!("Loaded rusqlite::vtab::array"))?;
191
192        Ok(conn)
193    }
194
195    /// Creates a table to store the records.
196    #[instrument(skip(self))]
197    async fn create_table(&self) -> Result<()> {
198        let conn = self.pool.get().await?;
199        conn.interact(move |conn| MIGRATIONS.to_latest(conn))
200            .await??;
201
202        Ok(())
203    }
204
205    /// Enables the WAL
206    ///
207    /// See <https://sqlite.org/wal.html>
208    ///
209    /// # Errors
210    ///
211    /// Returns [`DatabaseError`] if WAL activation fails.
212    #[instrument(skip(self))]
213    async fn activate_wal(&self) -> Result<()> {
214        const SUCCESS_VALUE: &str = "wal";
215
216        let conn = self.pool.get().await?;
217
218        tracing::debug!("Enabling the WAL.");
219
220        let journal_mode: String = conn
221            .interact(move |conn| {
222                conn.pragma_update_and_check(None, "journal_mode", "WAL", |v| v.get(0))
223            })
224            .await??;
225
226        if journal_mode.as_str() == SUCCESS_VALUE {
227            Ok(())
228        } else {
229            Err(DbErrorRepr::WalActivation(journal_mode).into())
230        }
231    }
232
233    /// Runs "PRAGMA optimize=0x10002".
234    ///
235    /// See <https://sqlite.org/lang_analyze.html>.
236    ///
237    /// # Errors
238    /// Returns [`DatabaseError`] if the operation fails.
239    pub async fn optimize(&self) -> Result<()> {
240        // Applications that use long-lived database connections should run "PRAGMA optimize=0x10002;"
241        const MASK: &str = "0x10002";
242        let conn = self.pool.get().await?;
243        tracing::trace!("running OPTIMIZE");
244
245        conn.interact(move |conn| conn.pragma_update(None, "optimize", MASK))
246            .await??;
247        Ok(())
248    }
249
250    /// Initializes the database, activates WAL, creates a 'records' table, and creates indexes.
251    ///
252    /// # Errors
253    /// Returns [`DatabaseError`] if the operation fails.
254    #[instrument(skip(path))]
255    pub async fn new_with_records_table(path: impl AsRef<Path>) -> Result<Self> {
256        let db = Self::new(path).await?;
257        db.activate_wal().await?;
258        db.create_table().await?;
259        db.optimize().await?;
260        Ok(db)
261    }
262
263    /// Queries the database to get a count of records it contains.
264    ///
265    /// # Errors
266    /// Returns [`DatabaseError`] if the operation fails.
267    #[instrument]
268    pub async fn count(&self) -> Result<i64> {
269        let query = r"SELECT count(*) FROM records;";
270        let conn = self.pool.get().await?;
271
272        let record_count = conn
273            .interact(move |conn| {
274                let mut stmt = conn.prepare(query)?;
275                stmt.query_one([], |row| row.get(0))
276            })
277            .await??;
278
279        Ok(record_count)
280    }
281
282    /// Inserts the given record into the `Database`.
283    ///
284    /// Returns the ID of the inserted row.
285    ///
286    /// # Errors
287    /// Returns [`DatabaseError`] if the insert operation fails.
288    #[instrument(skip_all, fields(record_url = record.url))]
289    pub async fn insert(&self, record: DbRecord) -> Result<i64> {
290        let query = r"INSERT INTO records
291        VALUES (:id, :url, :client_datetime, :client_datetime_raw, :timestamp_flora, :title,
292        :language, :web_archive_serialized, :updated_at)
293        RETURNING id;";
294
295        let DbRecord {
296            id,
297            url,
298            client_datetime,
299            client_datetime_raw,
300            timestamp_flora,
301            title,
302            language,
303            document,
304            updated_at,
305        } = record;
306
307        let conn = self.pool.get().await?;
308        let row_id = conn
309            .interact(move |conn| {
310                let mut stmt = conn.prepare(query)?;
311                stmt.query_one(
312                    rusqlite::named_params! {
313                        ":id": id,
314                        ":url": url,
315                        ":client_datetime": client_datetime,
316                        ":client_datetime_raw": client_datetime_raw,
317                        ":timestamp_flora": timestamp_flora,
318                        ":title": title,
319                        ":language": language,
320                        ":web_archive_serialized": document,
321                        ":updated_at": updated_at,
322                    },
323                    |row| row.get(0),
324                )
325            })
326            .await?
327            .inspect_err(|e| tracing::error!("Error on insert: {e}"))?;
328
329        Ok(row_id)
330    }
331
332    /// Deletes the record corresponding to the given id.
333    ///
334    /// Returns the number of documents affected
335    /// (should be 1 if a document matching the id was deleted,
336    /// and 0 if no matching document was found in the DB).
337    ///
338    /// # Errors
339    /// Returns [`DatabaseError`] if the operation fails.
340    #[instrument(skip(self))]
341    pub async fn delete(&self, id: i64) -> Result<usize> {
342        const QUERY: &str = r"DELETE FROM records WHERE id = ?1;";
343
344        let conn = self.pool.get().await?;
345        let rows_changed = conn
346            .interact(move |conn| {
347                let mut stmt = conn.prepare(QUERY)?;
348                stmt.execute([id])
349            })
350            .await??;
351
352        Ok(rows_changed)
353    }
354
355    /// Get a record from SQLite given an id.
356    ///
357    /// # Errors
358    /// Returns [`DatabaseError`] if the operation fails.
359    pub async fn get_record(&self, id: i64) -> Result<DbRecord> {
360        let query = r"SELECT id, url, client_datetime, client_datetime_raw, timestamp_flora, title,
361            language, web_archive_serialized, updated_at
362            FROM records WHERE id = ?1;";
363
364        let conn = self.pool.get().await?;
365        let result = conn
366            .interact(move |conn| {
367                let mut stmt = conn.prepare(query)?;
368                stmt.query_row([id], |row| {
369                    let record = row.try_into()?;
370                    Ok(record)
371                })
372            })
373            .await??;
374
375        Ok(result)
376    }
377
378    /// Get multiple records from SQLite given their IDs.
379    ///
380    /// # Errors
381    /// Returns [`DatabaseError`] if the operation fails.
382    #[instrument]
383    pub async fn get_many_records(&self, ids: &[i64]) -> Result<Vec<DbRecord>> {
384        if ids.len() > MAX_BIND_PARAMS {
385            return Err(DbErrorRepr::TooManyBindParameters(ids.len()).into());
386        }
387
388        tracing::debug!("Getting records.");
389
390        let query = r"SELECT id, url, client_datetime, client_datetime_raw, timestamp_flora, title,
391            language, web_archive_serialized, updated_at
392            FROM records
393            WHERE id IN rarray(?1);";
394
395        let conn = self
396            .get_conn_array()
397            .await
398            .inspect_err(|e| tracing::error!("Couldn't get a connection from the pool: {e}"))?;
399
400        let values = ids.iter().copied().map(Value::from).collect::<Vec<Value>>();
401
402        let records = conn
403            .interact(|conn| -> Result<Vec<DbRecord>> {
404                let values = Rc::new(values);
405
406                let mut stmt = conn
407                    .prepare(query)
408                    .inspect_err(|e| tracing::error!("Couldn't prepare the query: {e}"))?;
409
410                let records = try_query_db_records!(stmt, [values]);
411                Ok(records)
412            })
413            .await
414            .inspect_err(|e| {
415                tracing::error!("Couldn't interact with the database connection: {e}");
416            })??;
417
418        tracing::debug!("Got {} records", records.len());
419
420        Ok(records)
421    }
422
423    /// Selects all rows in the `records` table, without any pagination.
424    ///
425    /// # Errors
426    ///
427    /// Returns [`DatabaseError`] if the operation fails.
428    #[instrument(skip(self))]
429    pub async fn get_all_records(&self, start_row: Option<i64>) -> Result<Vec<DbRecord>> {
430        tracing::debug!("Getting all records");
431
432        // pagination adapted from <https://gist.github.com/ssokolow/262503>
433        let query = r"SELECT * FROM records
434WHERE id NOT IN (SELECT id FROM records
435                 ORDER BY id ASC LIMIT ?1)
436ORDER BY id ASC LIMIT ?2;";
437
438        // start at 0 if we aren't given an explicit start row
439        let start_row = start_row.unwrap_or_default();
440        let conn = self.pool.get().await?;
441        let records = conn
442            .interact(move |conn| -> Result<Vec<DbRecord>> {
443                let mut stmt = conn.prepare(query)?;
444                let records = try_query_db_records!(stmt, [start_row, SELECT_ALL_LIMIT]);
445                Ok(records)
446            })
447            .await??;
448
449        Ok(records)
450    }
451
452    /// Selects all rows in the `records` table, without any pagination,
453    /// and returns their metadata (all fields except serialized data
454    /// of the page content).
455    ///
456    /// # Errors
457    ///
458    /// Returns [`DatabaseError`] if the operation fails.
459    #[instrument(skip(self))]
460    pub async fn get_all_meta(&self, start_row: Option<i64>) -> Result<Vec<DbRecordMeta>> {
461        tracing::debug!("Getting metadata for all records");
462
463        let query = r"
464SELECT id, url, client_datetime, timestamp_flora, title, language, updated_at
465FROM records
466WHERE id NOT IN (SELECT id FROM records
467                 ORDER BY id ASC LIMIT ?1)
468ORDER BY id ASC LIMIT ?2;";
469
470        let start_row = start_row.unwrap_or_default();
471        let conn = self.pool.get().await?;
472        let records = conn
473            .interact(move |conn| -> Result<Vec<DbRecordMeta>> {
474                let mut stmt = conn.prepare(query)?;
475                let records =
476                    try_query_db_records!(stmt, [start_row, SELECT_ALL_LIMIT], DbRecordMeta);
477                Ok(records)
478            })
479            .await??;
480
481        Ok(records)
482    }
483
484    /// Returns all record IDs in SQLite.
485    ///
486    /// # Errors
487    ///
488    /// Returns [`DatabaseError`] if the operation fails.
489    #[instrument(skip(self))]
490    pub async fn get_all_ids(&self) -> Result<Vec<i64>> {
491        tracing::debug!("Getting all record IDs");
492
493        let query = r"SELECT id FROM records;";
494
495        let conn = self.pool.get().await?;
496        let ids = conn
497            .interact(move |conn| -> Result<Vec<i64>> {
498                let mut stmt = conn.prepare(query)?;
499                let rows = stmt.query_map([], |row| row.get(0))?;
500                let mut ids = vec![];
501                for row in rows {
502                    ids.push(row?);
503                }
504
505                Ok(ids)
506            })
507            .await??;
508
509        Ok(ids)
510    }
511
512    /// Returns all record IDs in descending order.
513    #[instrument(skip(self))]
514    async fn get_ids_by_recent(&self, limit: i64) -> Result<Vec<i64>> {
515        // order by timestamp because in rare cases IDs might not
516        // be monotonically increasing
517        // (see <https://sqlite.org/autoinc.html>)
518        let query = r"SELECT id FROM records
519ORDER BY timestamp_flora DESC
520LIMIT ?1;";
521
522        let conn = self.pool.get().await?;
523        let ids = conn
524            .interact(move |conn| -> Result<Vec<i64>> {
525                let mut stmt = conn.prepare(query)?;
526                let rows = stmt.query_map([limit], |row| row.get(0))?;
527                let mut ids = vec![];
528                for row in rows {
529                    ids.push(row?);
530                }
531
532                Ok(ids)
533            })
534            .await??;
535
536        Ok(ids)
537    }
538
539    /// Similar to [`Database::get_all_meta`], except orders records by
540    /// descending order of `timestamp_flora`.
541    ///
542    /// Returns an empty list if the given index is out of bounds.
543    #[allow(clippy::missing_errors_doc)]
544    #[instrument(skip(self))]
545    pub async fn most_recent_meta(&self, start_idx: Option<usize>) -> Result<Vec<DbRecordMeta>> {
546        tracing::trace!("Selecting recent RecordMeta");
547
548        // idea: use row_number() window function to do this in SQL instead
549        let limit = start_idx.unwrap_or_default();
550        let prev_ids = self.get_ids_by_recent(limit.try_into()?).await?;
551        let prev_ids = prev_ids
552            .iter()
553            .copied()
554            .map(Value::from)
555            .collect::<Vec<Value>>();
556
557        let query = r"
558SELECT id, url, client_datetime, timestamp_flora, title, language, updated_at
559FROM records
560WHERE id NOT IN rarray(?1)
561ORDER BY timestamp_flora DESC LIMIT ?2;";
562
563        let conn = self.get_conn_array().await?;
564        let records = conn
565            .interact(move |conn| -> Result<Vec<DbRecordMeta>> {
566                let ids = Rc::new(prev_ids);
567                let mut stmt = conn.prepare(query)?;
568                let records = try_query_db_records!(
569                    stmt,
570                    rusqlite::params![ids, SELECT_ALL_LIMIT],
571                    DbRecordMeta
572                );
573                Ok(records)
574            })
575            .await??;
576
577        Ok(records)
578    }
579
580    /// Updates the record by ID.
581    ///
582    /// # Errors
583    /// Returns [`DatabaseError`] if the update operation fails.
584    #[instrument(skip_all, fields(record_id = record.id))]
585    pub async fn update_record_data(&self, record: DbRecord) -> Result<i64> {
586        let query = r"UPDATE records
587SET web_archive_serialized = :document, updated_at = :updated_at
588WHERE records.id = :id
589RETURNING id;";
590
591        let DbRecord {
592            id,
593            document,
594            updated_at,
595            ..
596        } = record;
597        let id = id.ok_or_else(|| DbErrorRepr::Update("missing id".into()))?;
598        let updated_at =
599            updated_at.ok_or_else(|| DbErrorRepr::Update("missing updated_at".into()))?;
600
601        let conn = self.pool.get().await?;
602        let row_id = conn
603            .interact(move |conn| {
604                let mut stmt = conn.prepare(query)?;
605                stmt.query_one(
606                    rusqlite::named_params! {
607                        ":id": id,
608                        ":document": document,
609                        ":updated_at": updated_at,
610                    },
611                    |row| row.get(0),
612                )
613            })
614            .await?
615            .inspect_err(|e| tracing::error!("Error on update: {e}"))?;
616        Ok(row_id)
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use crate::model::tests::test_record_params;
624    use anyhow::{Context, Result};
625    use chrono::{DateTime, FixedOffset, Utc};
626    use omry_archiving::record::Record;
627    use pretty_assertions::assert_eq;
628    use serde::Serialize;
629    use std::fs::File;
630    use std::ops::Deref;
631    use tempfile::NamedTempFile;
632
633    /// Data structure to keep the temporary file holding the database alive,
634    /// because it will get unlinked on drop.
635    struct TempDb {
636        #[allow(dead_code)]
637        temp_db_file: NamedTempFile<File>,
638        db: Database,
639    }
640
641    impl TempDb {
642        async fn new() -> Result<Self> {
643            let temp_db_file = NamedTempFile::with_suffix(".db")?;
644            let db = Database::new_with_records_table(temp_db_file.path()).await?;
645            Ok(Self { temp_db_file, db })
646        }
647    }
648
649    impl Deref for TempDb {
650        type Target = Database;
651
652        fn deref(&self) -> &Self::Target {
653            &self.db
654        }
655    }
656
657    /// Wrapper for [`DbRecord`] that supports [`Serialize`] for redacting snapshots.
658    #[derive(Debug, Serialize)]
659    struct DbRecordDef {
660        id: Option<i64>,
661        url: String,
662        client_datetime: DateTime<FixedOffset>,
663        client_datetime_raw: Option<String>,
664        timestamp_flora: i64,
665        title: String,
666        language: Option<String>,
667        document: Vec<u8>,
668        updated_at: Option<i64>,
669    }
670
671    impl From<DbRecord> for DbRecordDef {
672        fn from(value: DbRecord) -> DbRecordDef {
673            let DbRecord {
674                id,
675                url,
676                client_datetime,
677                client_datetime_raw,
678                timestamp_flora,
679                title,
680                language,
681                document,
682                updated_at,
683            } = value;
684
685            Self {
686                id,
687                url,
688                client_datetime,
689                client_datetime_raw,
690                timestamp_flora,
691                title,
692                language,
693                document,
694                updated_at,
695            }
696        }
697    }
698
699    #[derive(Debug, Serialize)]
700    struct DbRecordMetaDef {
701        id: i64,
702        url: String,
703        client_datetime: DateTime<FixedOffset>,
704        timestamp_flora: i64,
705        title: String,
706        language: Option<String>,
707        updated_at: Option<i64>,
708    }
709
710    impl From<DbRecordMeta> for DbRecordMetaDef {
711        fn from(value: DbRecordMeta) -> Self {
712            let DbRecordMeta {
713                id,
714                url,
715                client_datetime,
716                timestamp_flora,
717                title,
718                language,
719                updated_at,
720            } = value;
721
722            Self {
723                id,
724                url,
725                client_datetime,
726                timestamp_flora,
727                title,
728                language,
729                updated_at,
730            }
731        }
732    }
733
734    #[allow(dead_code, reason = "Used in snapshot.")]
735    #[derive(Debug)]
736    struct RowInfo {
737        name: String,
738        type_: String,
739        notnull: bool,
740    }
741
742    impl TryFrom<&Row<'_>> for RowInfo {
743        type Error = rusqlite::Error;
744
745        fn try_from(row: &Row) -> std::result::Result<Self, Self::Error> {
746            let (name, type_, notnull) = (row.get(0)?, row.get(1)?, row.get(2)?);
747            Ok(Self {
748                name,
749                type_,
750                notnull,
751            })
752        }
753    }
754
755    fn test_db_record() -> Result<DbRecord> {
756        let record = Record::new(test_record_params()?);
757        Ok(record.try_into()?)
758    }
759
760    #[tokio::test]
761    async fn can_create_table() -> Result<()> {
762        let db = TempDb::new().await?;
763        let query = r#"SELECT name, type, "notnull" FROM pragma_table_info('records');"#;
764        let conn = db.pool.get().await?;
765        let row_infos = conn
766            .interact(move |conn| -> Result<Vec<RowInfo>> {
767                let mut stmt = conn.prepare(query)?;
768                let rows = stmt.query_map([], |row| RowInfo::try_from(row))?;
769                let mut values = vec![];
770                for row in rows {
771                    values.push(row?);
772                }
773                Ok(values)
774            })
775            .await
776            .map_err(|e| anyhow::anyhow!("{e}"))??;
777        insta::assert_debug_snapshot!(row_infos);
778        Ok(())
779    }
780
781    #[tokio::test]
782    async fn can_add_record() -> Result<()> {
783        let db = TempDb::new().await?;
784        let record = test_db_record()?;
785        let result = db.insert(record).await?;
786        assert_eq!(result, 1);
787        Ok(())
788    }
789
790    #[tokio::test]
791    async fn can_get_record() -> Result<()> {
792        let db = TempDb::new().await?;
793        let record = test_db_record()?;
794        assert!(record.id.is_none());
795        db.insert(record).await?;
796
797        let expected_id = 1i64;
798        let retrieved_record = db.get_record(expected_id).await?;
799
800        let retrieved_record: DbRecordDef = retrieved_record.into();
801        insta::assert_ron_snapshot!(retrieved_record, {
802            ".client_datetime" => "[client_datetime]",
803            ".timestamp_flora" => "[timestamp]"
804        });
805
806        Ok(())
807    }
808
809    #[tokio::test]
810    async fn can_get_many_records() -> Result<()> {
811        let db = TempDb::new().await?;
812
813        let num_records_to_insert = 7;
814        let mut record_ids = Vec::with_capacity(num_records_to_insert);
815
816        for _ in 0..num_records_to_insert {
817            let record = test_db_record()?;
818            let record_id = db.insert(record).await?;
819            record_ids.push(record_id);
820        }
821
822        let selected_records = db
823            .get_many_records(&record_ids)
824            .await?
825            .into_iter()
826            .map(DbRecordDef::from)
827            .collect::<Vec<_>>();
828
829        assert_eq!(selected_records.len(), num_records_to_insert);
830
831        insta::assert_ron_snapshot!(selected_records, {
832            "[].client_datetime" => "[client_datetime]",
833            "[].timestamp_flora" => "[timestamp]"
834        });
835
836        Ok(())
837    }
838
839    #[tokio::test]
840    async fn passing_too_many_bind_parameters_returns_error() -> Result<()> {
841        let db = TempDb::new().await?;
842
843        let num_records_to_insert = MAX_BIND_PARAMS + 1;
844        db.insert(test_db_record()?).await?;
845
846        let mut ids = Vec::with_capacity(num_records_to_insert);
847        for index in 0..num_records_to_insert {
848            ids.push(i64::try_from(index + 1)?);
849        }
850
851        let result = db.get_many_records(&ids).await;
852        assert!(result.is_err());
853
854        // At this point, we know this is definitely an error.
855        #[allow(clippy::unwrap_used)]
856        let error = result.unwrap_err();
857        insta::assert_snapshot!(error);
858
859        Ok(())
860    }
861
862    #[tokio::test]
863    async fn can_get_count() -> Result<()> {
864        let db = TempDb::new().await?;
865
866        let num_records_to_insert = 7;
867        let mut record_ids = Vec::with_capacity(num_records_to_insert);
868
869        for _ in 0..num_records_to_insert {
870            let record = test_db_record()?;
871            let record_id = db.insert(record).await?;
872            record_ids.push(record_id);
873        }
874
875        let count: usize = db.count().await?.try_into()?;
876
877        assert_eq!(count, num_records_to_insert);
878        Ok(())
879    }
880
881    #[tokio::test]
882    async fn can_delete_record() -> Result<()> {
883        let db = TempDb::new().await?;
884        let record = test_db_record()?;
885        let id = db.insert(record).await?;
886        let rows_changed = db.delete(id).await?;
887        assert_eq!(rows_changed, 1);
888        Ok(())
889    }
890
891    #[tokio::test]
892    async fn can_get_all_records() -> Result<()> {
893        let db = TempDb::new().await?;
894
895        // Insert more than the limit to test pagination.
896        let num_records_to_insert = (SELECT_ALL_LIMIT + 5).try_into()?;
897        for _ in 0..num_records_to_insert {
898            let record = test_db_record()?;
899            let _record_id = db.insert(record).await?;
900        }
901
902        let mut db_recs = Vec::with_capacity(num_records_to_insert);
903        let mut start_row = None;
904        loop {
905            let mut selected_records = db
906                .get_all_records(start_row)
907                .await?
908                .into_iter()
909                .map(DbRecordDef::from)
910                .collect::<Vec<_>>();
911
912            if selected_records.is_empty() {
913                break;
914            }
915
916            db_recs.append(&mut selected_records);
917            start_row = Some(db_recs.len().try_into()?);
918        }
919
920        assert_eq!(db_recs.len(), num_records_to_insert);
921
922        insta::assert_ron_snapshot!(db_recs, {
923            "[].client_datetime" => "[client_datetime]",
924            "[].timestamp_flora" => "[timestamp]",
925            "[].document" => "[data]"
926        });
927
928        Ok(())
929    }
930
931    /// This tests that we can still get all records
932    /// after deleting some from the middle (causing IDs to become incontiguous).
933    #[tokio::test]
934    async fn can_get_all_records_after_deleting_some() -> Result<()> {
935        let db = TempDb::new().await?;
936
937        // Insert more than the limit to test pagination.
938        let num_records_to_insert = (SELECT_ALL_LIMIT + 5).try_into()?;
939        let mut record_ids = Vec::with_capacity(num_records_to_insert);
940
941        for _ in 0..num_records_to_insert {
942            let record = test_db_record()?;
943            let record_id = db.insert(record).await?;
944            record_ids.push(record_id);
945        }
946
947        // remove IDs from `record_ids` to assert that
948        // `record_ids` length matches the number of records
949        // we get back in the end
950        let idx_to_delete = [3, 7, 11];
951        for idx in idx_to_delete {
952            let id = record_ids.remove(idx);
953            db.delete(id).await?;
954        }
955
956        let mut db_recs = Vec::with_capacity(num_records_to_insert);
957        let mut start_row = None;
958        loop {
959            let mut selected_records = db
960                .get_all_records(start_row)
961                .await?
962                .into_iter()
963                .map(DbRecordDef::from)
964                .collect::<Vec<_>>();
965
966            if selected_records.is_empty() {
967                break;
968            }
969
970            db_recs.append(&mut selected_records);
971            start_row = Some(db_recs.len().try_into()?);
972        }
973
974        assert_eq!(db_recs.len(), record_ids.len());
975        insta::assert_ron_snapshot!(db_recs, {
976            "[].client_datetime" => "[client_datetime]",
977            "[].timestamp_flora" => "[timestamp]",
978            "[].document" => "[data]"
979        });
980
981        Ok(())
982    }
983
984    #[tokio::test]
985    async fn can_get_all_meta() -> Result<()> {
986        let db = TempDb::new().await?;
987
988        let num_records_to_insert = (SELECT_ALL_LIMIT + 1).try_into()?;
989        let mut record_ids = Vec::with_capacity(num_records_to_insert);
990
991        for _ in 0..num_records_to_insert {
992            let record = test_db_record()?;
993            let record_id = db.insert(record).await?;
994            record_ids.push(record_id);
995        }
996
997        let mut start_row = None;
998        let mut db_recs = Vec::with_capacity(num_records_to_insert);
999        loop {
1000            let mut selected_recs = db
1001                .get_all_meta(start_row)
1002                .await?
1003                .into_iter()
1004                .map(DbRecordMetaDef::from)
1005                .collect::<Vec<_>>();
1006
1007            if selected_recs.is_empty() {
1008                break;
1009            }
1010
1011            db_recs.append(&mut selected_recs);
1012            start_row = Some(db_recs.len().try_into()?);
1013        }
1014
1015        assert_eq!(db_recs.len(), num_records_to_insert);
1016
1017        insta::assert_ron_snapshot!(db_recs, {
1018            "[].client_datetime" => "[client_datetime]",
1019            "[].timestamp_flora" => "[timestamp]",
1020        });
1021
1022        Ok(())
1023    }
1024
1025    #[tokio::test]
1026    async fn can_get_all_ids() -> Result<()> {
1027        let db = TempDb::new().await?;
1028
1029        let num_records_to_insert = 7;
1030        let mut record_ids = Vec::with_capacity(num_records_to_insert);
1031
1032        for _ in 0..num_records_to_insert {
1033            let record = test_db_record()?;
1034            let record_id = db.insert(record).await?;
1035            record_ids.push(record_id);
1036        }
1037
1038        let db_ids = db.get_all_ids().await?;
1039        assert_eq!(db_ids, record_ids);
1040        Ok(())
1041    }
1042
1043    #[tokio::test]
1044    async fn can_get_most_recent_meta() -> Result<()> {
1045        let db = TempDb::new().await?;
1046
1047        let num_recs_to_insert = (SELECT_ALL_LIMIT + 5).try_into()?;
1048        let ts_incr = 5;
1049        let mut record_ids = Vec::with_capacity(num_recs_to_insert);
1050
1051        let mut cur_timestamp = Utc::now().timestamp();
1052        for _ in 0..num_recs_to_insert {
1053            let mut rec = test_db_record()?;
1054            rec.timestamp_flora = cur_timestamp;
1055            let record_id = db.insert(rec).await?;
1056            record_ids.push(record_id);
1057            cur_timestamp += ts_incr;
1058        }
1059
1060        let mut recs = Vec::with_capacity(num_recs_to_insert);
1061        let mut start_idx = None;
1062        loop {
1063            let mut selected_recs = db
1064                .most_recent_meta(start_idx)
1065                .await?
1066                .into_iter()
1067                .map(DbRecordMetaDef::from)
1068                .collect::<Vec<_>>();
1069
1070            if selected_recs.is_empty() {
1071                break;
1072            }
1073
1074            recs.append(&mut selected_recs);
1075            start_idx = Some(recs.len());
1076        }
1077
1078        assert_eq!(recs.len(), num_recs_to_insert);
1079        let timestamps = recs.iter().map(|r| r.timestamp_flora).collect::<Vec<_>>();
1080        assert!(
1081            recs.is_sorted_by(|a, b| a.timestamp_flora > b.timestamp_flora),
1082            "{timestamps:#?}"
1083        );
1084        Ok(())
1085    }
1086
1087    #[tokio::test]
1088    async fn can_update_record() -> Result<()> {
1089        let db = TempDb::new().await?;
1090        let record = test_db_record()?;
1091        let id = db.insert(record).await.context("error inserting record")?;
1092
1093        // workaround for DbRecord not being `Clone`
1094        let mut record = db.get_record(id).await.context("error getting record")?;
1095        record.updated_at = Some(Utc::now().timestamp());
1096        let updated_id = db
1097            .update_record_data(record)
1098            .await
1099            .context("error updating record data")?;
1100        assert_eq!(id, updated_id);
1101        Ok(())
1102    }
1103}