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