omry_db/
db.rs

1//! Database abstractions.
2#![cfg(feature = "connection")]
3
4use crate::model::{DbRecord, Schema};
5use deadpool_sqlite::rusqlite;
6use deadpool_sqlite::rusqlite::Row;
7use deadpool_sqlite::rusqlite::types::Value;
8use deadpool_sqlite::{Config, Object, Pool, Runtime};
9use std::path::Path;
10use std::rc::Rc;
11use thiserror::Error;
12use tracing::instrument;
13
14/// Maximum number of 'bound' (programmatically populated)
15/// parameters allowed in a query.
16const MAX_BIND_PARAMS: usize = 32766;
17
18/// Tries to query for rows in `records` using the given statement, and convert them to [`DbRecord`].
19macro_rules! try_query_db_records {
20    ($stmt:ident, $values:expr) => {{
21        let rows = $stmt
22            .query_map($values, |row| DbRecord::try_from(row))
23            .inspect_err(|e| tracing::error!("Couldn't execute the query: {e}"))?;
24
25        let mut records = vec![];
26        for maybe_record in rows {
27            let record = maybe_record
28                .inspect_err(|e| tracing::error!("Couldn't get the mapped record: {e}"))?;
29            records.push(record);
30        }
31
32        records
33    }};
34}
35
36/// Errors that may occur when interacting with database APIs.
37#[derive(Debug, Error)]
38#[error(transparent)]
39pub struct DatabaseError(DbErrorRepr);
40
41// Implement this manually so we can still conveniently use ? with errors
42// that map onto variants of DatabaseErrorRepr
43// (using #[from] would conflict with this).
44impl<E> From<E> for DatabaseError
45where
46    E: Into<DbErrorRepr>,
47{
48    fn from(error: E) -> Self {
49        Self(error.into())
50    }
51}
52
53/// Database errors.
54#[derive(Error, Debug)]
55enum DbErrorRepr {
56    /// Error when creating the database connection pool.
57    #[error("error creating the DB pool: {0}")]
58    CreatePool(#[from] deadpool_sqlite::CreatePoolError),
59
60    /// Error when interacting with the database pool.
61    // This error has a string payload and a manual implementation of [`From`]
62    // because `deadpool_sqlite::InteractError` isn't Sync.
63    #[error("error interacting with the DB pool: {0}")]
64    PoolInteract(String),
65
66    /// Error when getting a new connection from the database pool.
67    #[error("error getting connection from the DB pool: {0}")]
68    Pool(#[from] deadpool_sqlite::PoolError),
69
70    /// Any unspecified error returned by [`rusqlite`].
71    #[error("error in rusqlite: {0}")]
72    Rusqlite(#[from] rusqlite::Error),
73
74    /// Error returned when a query function was given too many
75    /// parameters to bind.
76    #[error(
77        "The caller passed too many parameters to bind (got {0}, max allowed is {MAX_BIND_PARAMS})"
78    )]
79    TooManyBindParameters(usize),
80
81    /// Error activating write-ahead logging.
82    #[error("Couldn't activate the WAL; journaling mode is {0}")]
83    WalActivation(String),
84}
85
86impl From<deadpool_sqlite::InteractError> for DbErrorRepr {
87    fn from(error: deadpool_sqlite::InteractError) -> Self {
88        Self::PoolInteract(error.to_string())
89    }
90}
91
92/// Alias for results returned by the database APIs.
93pub type Result<T> = std::result::Result<T, DatabaseError>;
94
95impl TryFrom<&Row<'_>> for DbRecord {
96    type Error = rusqlite::Error;
97
98    fn try_from(row: &Row) -> std::result::Result<Self, Self::Error> {
99        Ok(Self {
100            id: row.get(0)?,
101            url: row.get(1)?,
102            client_datetime: row.get(2)?,
103            client_datetime_raw: row.get(3)?,
104            timestamp_flora: row.get(4)?,
105            title: row.get(5)?,
106            language: row.get(6)?,
107            web_archive_serialized: row.get(7)?,
108        })
109    }
110}
111
112/// Abstraction for flora's primary datastore (SQLite).
113#[derive(Debug, Clone)]
114pub struct Database {
115    /// The connection pool.
116    pool: Pool,
117}
118
119impl Database {
120    /// Connects to the SQLite database at the given URL and creates a new `Database`.
121    ///
122    /// ## Errors
123    /// Returns an instance of [`DatabaseError`] on error.
124    #[instrument(skip(path))]
125    async fn new(path: impl AsRef<Path>) -> Result<Self> {
126        let cfg = Config::new(path.as_ref());
127        let pool = cfg.create_pool(Runtime::Tokio1)?;
128        Ok(Self { pool })
129    }
130
131    /// Gets a connection, and calls [`rusqlite::vtab::array::load_module`] with it.
132    ///
133    /// This method should be called to get any connection that will be used
134    /// to perform queries using the `array` extension of [`rusqlite`].
135    ///
136    /// See <https://docs.rs/rusqlite/latest/rusqlite/vtab/array/index.html>.
137    #[instrument(skip(self))]
138    async fn get_conn_array(&self) -> Result<Object> {
139        let conn = self.pool.get().await?;
140        conn.interact(|conn| {
141            tracing::debug!("Loading rusqlite::vtab::array");
142            rusqlite::vtab::array::load_module(conn)
143        })
144        .await?
145        .inspect(|()| tracing::debug!("Loaded rusqlite::vtab::array"))?;
146
147        Ok(conn)
148    }
149
150    /// Creates a table to store the records.
151    #[instrument(skip(self))]
152    async fn create_table(&self, schema: &Schema) -> Result<()> {
153        let create_table_query = format!("CREATE TABLE IF NOT EXISTS {schema};");
154        let conn = self.pool.get().await?;
155        let _rows: usize = conn
156            .interact(move |conn| {
157                let mut stmt = conn.prepare(&create_table_query)?;
158                stmt.execute(())
159            })
160            .await??;
161
162        Ok(())
163    }
164
165    /// Creates indexes for the `records` table.
166    #[instrument(skip(self))]
167    async fn create_indexes(&self, schema: &Schema, columns: &[&str]) -> Result<()> {
168        let table_name = schema.table_name();
169        let conn = self.pool.get().await?;
170
171        for column in columns {
172            let idx_name = format!("idx_{column}");
173            let query =
174                format!("CREATE INDEX IF NOT EXISTS {idx_name} ON {table_name} ({column});");
175            tracing::debug!("Query: {query}");
176
177            tracing::info!("Creating index '{idx_name}' on '{table_name}'");
178            conn.interact(move |conn| {
179                let mut stmt = conn.prepare(&query)?;
180                stmt.execute([])
181            })
182            .await??;
183        }
184
185        Ok(())
186    }
187
188    /// Enables the WAL
189    ///
190    /// See <https://sqlite.org/wal.html>
191    ///
192    /// ## Errors
193    ///
194    /// Returns [`DatabaseError`] if WAL activation fails.
195    #[instrument(skip(self))]
196    pub async fn activate_wal(&self) -> Result<()> {
197        const SUCCESS_VALUE: &str = "wal";
198
199        let conn = self.pool.get().await?;
200
201        tracing::debug!("Enabling the WAL.");
202
203        let journal_mode: String = conn
204            .interact(move |conn| {
205                conn.pragma_update_and_check(None, "journal_mode", "WAL", |v| v.get(0))
206            })
207            .await??;
208
209        if journal_mode.as_str() == SUCCESS_VALUE {
210            Ok(())
211        } else {
212            Err(DbErrorRepr::WalActivation(journal_mode).into())
213        }
214    }
215
216    /// Initializes the database, activates WAL, creates a 'records' table, and creates indexes.
217    ///
218    /// ## Errors
219    /// Returns [`DatabaseError`] if the operation fails.
220    #[instrument(skip(path))]
221    pub async fn new_with_records_table(path: impl AsRef<Path>) -> Result<Self> {
222        let db = Self::new(path).await?;
223        let schema = DbRecord::schema();
224
225        db.activate_wal().await?;
226        db.create_table(&schema).await?;
227        db.create_indexes(&schema, &["url"]).await?;
228
229        Ok(db)
230    }
231
232    /// Queries the database to get a count of records it contains.
233    ///
234    /// ## Errors
235    /// Returns [`DatabaseError`] if the operation fails.
236    #[instrument]
237    pub async fn count(&self) -> Result<i64> {
238        let query = r"SELECT count(*) FROM records;";
239        let conn = self.pool.get().await?;
240
241        let record_count = conn
242            .interact(move |conn| {
243                let mut stmt = conn.prepare(query)?;
244                stmt.query_one([], |row| row.get(0))
245            })
246            .await??;
247
248        Ok(record_count)
249    }
250
251    /// Inserts the given record into the `Database`.
252    ///
253    /// Returns the ID of the inserted row.
254    ///
255    /// ## Errors
256    /// Returns [`DatabaseError`] if the insert operation fails.
257    pub async fn insert(&self, record: DbRecord) -> Result<i64> {
258        let query = r"INSERT INTO records
259        VALUES (:id, :url, :client_datetime, :client_datetime_raw, :timestamp_flora, :title,
260        :language, :web_archive_serialized)
261        RETURNING id;";
262
263        let conn = self.pool.get().await?;
264
265        let row_id = conn
266            .interact(move |conn| {
267                let mut stmt = conn.prepare(query)?;
268                stmt.query_one(
269                    rusqlite::named_params! {
270                        ":id": record.id,
271                        ":url": record.url,
272                        ":client_datetime": record.client_datetime,
273                        ":client_datetime_raw": record.client_datetime_raw,
274                        ":timestamp_flora": record.timestamp_flora,
275                        ":title": record.title,
276                        ":language": record.language,
277                        ":web_archive_serialized": record.web_archive_serialized,
278                    },
279                    |row| row.get(0),
280                )
281            })
282            .await??;
283
284        Ok(row_id)
285    }
286
287    /// Deletes the record corresponding to the given id.
288    ///
289    /// ## Errors
290    /// Returns [`DatabaseError`] if the operation fails.
291    #[instrument(skip(self))]
292    pub async fn delete(&self, id: i64) -> Result<usize> {
293        const QUERY: &str = r"DELETE FROM records WHERE id = ?1;";
294
295        let conn = self.pool.get().await?;
296        let rows_changed = conn
297            .interact(move |conn| {
298                let mut stmt = conn.prepare(QUERY)?;
299                stmt.execute([id])
300            })
301            .await??;
302
303        Ok(rows_changed)
304    }
305
306    /// Get a record from SQLite given an id.
307    ///
308    /// ## Errors
309    /// Returns [`DatabaseError`] if the operation fails.
310    pub async fn get_record(&self, id: i64) -> Result<DbRecord> {
311        let query = r"SELECT id, url, client_datetime, client_datetime_raw, timestamp_flora, title,
312            language, web_archive_serialized
313            FROM records WHERE id = ?1;";
314
315        let conn = self.pool.get().await?;
316        let result = conn
317            .interact(move |conn| {
318                let mut stmt = conn.prepare(query)?;
319                stmt.query_row([id], |row| {
320                    let record = row.try_into()?;
321                    Ok(record)
322                })
323            })
324            .await??;
325
326        Ok(result)
327    }
328
329    /// Get multiple records from SQLite given their IDs.
330    ///
331    /// ## Errors
332    /// Returns [`DatabaseError`] if the operation fails.
333    #[instrument]
334    pub async fn get_many_records(&self, ids: &[i64]) -> Result<Vec<DbRecord>> {
335        if ids.len() > MAX_BIND_PARAMS {
336            return Err(DbErrorRepr::TooManyBindParameters(ids.len()).into());
337        }
338
339        tracing::debug!("Getting records.");
340
341        let query = r"SELECT id, url, client_datetime, client_datetime_raw, timestamp_flora, title,
342            language, web_archive_serialized
343            FROM records
344            WHERE id IN rarray(?1);";
345
346        let conn = self
347            .get_conn_array()
348            .await
349            .inspect_err(|e| tracing::error!("Couldn't get a connection from the pool: {e}"))?;
350
351        let values = ids.iter().copied().map(Value::from).collect::<Vec<Value>>();
352
353        let records = conn
354            .interact(|conn| -> Result<Vec<DbRecord>> {
355                let values = Rc::new(values);
356
357                let mut stmt = conn
358                    .prepare(query)
359                    .inspect_err(|e| tracing::error!("Couldn't prepare the query: {e}"))?;
360
361                let records = try_query_db_records!(stmt, [values]);
362                Ok(records)
363            })
364            .await
365            .inspect_err(|e| {
366                tracing::error!("Couldn't interact with the database connection: {e}");
367            })??;
368
369        tracing::debug!("Got {} records", records.len());
370
371        Ok(records)
372    }
373
374    /// Selects all rows in the `records` table, without any pagination.
375    ///
376    /// ## Errors
377    ///
378    /// Returns [`DatabaseError`] if the operation fails.
379    #[instrument(skip(self))]
380    pub async fn get_all_records(&self) -> Result<Vec<DbRecord>> {
381        tracing::debug!("Getting all records");
382
383        let query = r"SELECT * FROM records";
384        let conn = self.pool.get().await?;
385        let records = conn
386            .interact(move |conn| -> Result<Vec<DbRecord>> {
387                let mut stmt = conn.prepare(query)?;
388                let records = try_query_db_records!(stmt, []);
389                Ok(records)
390            })
391            .await??;
392
393        Ok(records)
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::model::tests::test_record_params;
401    use anyhow::Result;
402    use chrono::{DateTime, FixedOffset};
403    use omry_archiving::record::Record;
404    use pretty_assertions::assert_eq;
405    use serde::Serialize;
406    use std::fs::File;
407    use std::ops::Deref;
408    use tempfile::NamedTempFile;
409
410    /// Data structure to keep the temporary file holding the database alive,
411    /// because it will get unlinked on drop.
412    struct TempDb {
413        #[allow(dead_code)]
414        temp_db_file: NamedTempFile<File>,
415        db: Database,
416    }
417
418    impl TempDb {
419        async fn new() -> Result<Self> {
420            let temp_db_file = NamedTempFile::with_suffix(".db")?;
421            let db = Database::new_with_records_table(temp_db_file.path()).await?;
422            Ok(Self { temp_db_file, db })
423        }
424    }
425
426    impl Deref for TempDb {
427        type Target = Database;
428
429        fn deref(&self) -> &Self::Target {
430            &self.db
431        }
432    }
433
434    /// Wrapper for [`DbRecord`] that supports [`Serialize`] for redacting snapshots.
435    #[derive(Debug, Serialize)]
436    struct DbRecordDef {
437        id: Option<i64>,
438        url: String,
439        client_datetime: DateTime<FixedOffset>,
440        client_datetime_raw: Option<String>,
441        timestamp_flora: i64,
442        title: String,
443        language: Option<String>,
444        web_archive_serialized: Vec<u8>,
445    }
446
447    impl From<DbRecord> for DbRecordDef {
448        fn from(value: DbRecord) -> DbRecordDef {
449            let DbRecord {
450                id,
451                url,
452                client_datetime,
453                client_datetime_raw,
454                timestamp_flora,
455                title,
456                language,
457                web_archive_serialized,
458            } = value;
459
460            Self {
461                id,
462                url,
463                client_datetime,
464                client_datetime_raw,
465                timestamp_flora,
466                title,
467                language,
468                web_archive_serialized,
469            }
470        }
471    }
472
473    fn test_db_record() -> Result<DbRecord> {
474        let record = Record::new(test_record_params()?);
475        Ok(record.try_into()?)
476    }
477
478    #[tokio::test]
479    async fn can_create_table() -> Result<()> {
480        let db = TempDb::new().await?;
481        assert!(db.create_table(&DbRecord::schema()).await.is_ok());
482        // TODO: Check the resulting DB schema matches what we gave to create_table
483        Ok(())
484    }
485
486    #[tokio::test]
487    async fn can_add_record() -> Result<()> {
488        let db = TempDb::new().await?;
489        let record = test_db_record()?;
490        let result = db.insert(record).await?;
491        assert_eq!(result, 1);
492        Ok(())
493    }
494
495    #[tokio::test]
496    async fn can_get_record() -> Result<()> {
497        let db = TempDb::new().await?;
498        let record = test_db_record()?;
499        assert!(record.id.is_none());
500        db.insert(record).await?;
501
502        let expected_id = 1i64;
503        let retrieved_record = db.get_record(expected_id).await?;
504
505        let retrieved_record: DbRecordDef = retrieved_record.into();
506        insta::assert_ron_snapshot!(retrieved_record, {
507            ".client_datetime" => "[client_datetime]",
508            ".timestamp_flora" => "[timestamp]"
509        });
510
511        Ok(())
512    }
513
514    #[tokio::test]
515    async fn can_get_many_records() -> Result<()> {
516        let db = TempDb::new().await?;
517
518        let num_records_to_insert = 7;
519        let mut record_ids = Vec::with_capacity(num_records_to_insert);
520
521        for _ in 0..num_records_to_insert {
522            let record = test_db_record()?;
523            let record_id = db.insert(record).await?;
524            record_ids.push(record_id);
525        }
526
527        let selected_records = db
528            .get_many_records(&record_ids)
529            .await?
530            .into_iter()
531            .map(DbRecordDef::from)
532            .collect::<Vec<_>>();
533
534        assert_eq!(selected_records.len(), num_records_to_insert);
535
536        insta::assert_ron_snapshot!(selected_records, {
537            "[].client_datetime" => "[client_datetime]",
538            "[].timestamp_flora" => "[timestamp]"
539        });
540
541        Ok(())
542    }
543
544    #[tokio::test]
545    async fn passing_too_many_bind_parameters_returns_error() -> Result<()> {
546        let db = TempDb::new().await?;
547
548        let num_records_to_insert = MAX_BIND_PARAMS + 1;
549        db.insert(test_db_record()?).await?;
550
551        let mut ids = Vec::with_capacity(num_records_to_insert);
552        for index in 0..num_records_to_insert {
553            ids.push(i64::try_from(index + 1)?);
554        }
555
556        let result = db.get_many_records(&ids).await;
557        assert!(result.is_err());
558
559        // At this point, we know this is definitely an error.
560        #[allow(clippy::unwrap_used)]
561        let error = result.unwrap_err();
562        insta::assert_snapshot!(error);
563
564        Ok(())
565    }
566
567    #[tokio::test]
568    async fn can_get_count() -> Result<()> {
569        let db = TempDb::new().await?;
570
571        let num_records_to_insert = 7;
572        let mut record_ids = Vec::with_capacity(num_records_to_insert);
573
574        for _ in 0..num_records_to_insert {
575            let record = test_db_record()?;
576            let record_id = db.insert(record).await?;
577            record_ids.push(record_id);
578        }
579
580        let count: usize = db.count().await?.try_into()?;
581
582        assert_eq!(count, num_records_to_insert);
583        Ok(())
584    }
585
586    #[tokio::test]
587    async fn can_delete_record() -> Result<()> {
588        let db = TempDb::new().await?;
589        let record = test_db_record()?;
590        let id = db.insert(record).await?;
591        let rows_changed = db.delete(id).await?;
592        assert_eq!(rows_changed, 1);
593        Ok(())
594    }
595
596    #[tokio::test]
597    async fn can_get_all_records() -> Result<()> {
598        let db = TempDb::new().await?;
599
600        let num_records_to_insert = 7;
601        let mut record_ids = Vec::with_capacity(num_records_to_insert);
602
603        for _ in 0..num_records_to_insert {
604            let record = test_db_record()?;
605            let record_id = db.insert(record).await?;
606            record_ids.push(record_id);
607        }
608
609        let selected_records = db
610            .get_all_records()
611            .await?
612            .into_iter()
613            .map(DbRecordDef::from)
614            .collect::<Vec<_>>();
615
616        assert_eq!(selected_records.len(), num_records_to_insert);
617
618        insta::assert_ron_snapshot!(selected_records, {
619            "[].client_datetime" => "[client_datetime]",
620            "[].timestamp_flora" => "[timestamp]",
621            "[].web_archive_serialized" => "[data]"
622        });
623
624        Ok(())
625    }
626}