omry_db/
db.rs

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