omry_db/
db.rs

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