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, 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
94        // enable `rarray` (https://docs.rs/rusqlite/latest/rusqlite/vtab/array/index.html)
95        let conn = pool.get().await?;
96        conn.interact(|conn| rusqlite::vtab::array::load_module(conn))
97            .await??;
98
99        Ok(Self { pool })
100    }
101
102    #[instrument(skip(self))]
103    async fn create_table(&self, schema: &Schema) -> Result<()> {
104        let create_table_query = format!("CREATE TABLE IF NOT EXISTS {schema};");
105
106        let conn = self.pool.get().await?;
107
108        let _rows: usize = conn
109            .interact(move |conn| {
110                let mut stmt = conn.prepare(&create_table_query)?;
111                stmt.execute(())
112            })
113            .await??;
114
115        Ok(())
116    }
117
118    /// Initializes the database with a 'records' table.
119    ///
120    /// ## Errors
121    /// Returns [`DatabaseError`] if the operation fails.
122    #[instrument(skip(path))]
123    pub async fn new_with_records_table(path: impl AsRef<Path>) -> Result<Self> {
124        let db = Self::new(path).await?;
125        db.create_table(&DbRecord::schema()).await?;
126        Ok(db)
127    }
128
129    /// Queries the database to get a count of records it contains.
130    ///
131    /// ## Errors
132    /// Returns [`DatabaseError`] if the operation fails.
133    #[instrument]
134    pub async fn count(&self) -> Result<i64> {
135        let query = r"SELECT count(*) FROM records;";
136        let conn = self.pool.get().await?;
137
138        let record_count = conn
139            .interact(move |conn| {
140                let mut stmt = conn.prepare(query)?;
141                stmt.query_one([], |row| row.get(0))
142            })
143            .await??;
144
145        Ok(record_count)
146    }
147
148    /// Inserts the given record into the `Database`.
149    ///
150    /// Returns the ID of the inserted row.
151    ///
152    /// ## Errors
153    /// Returns [`DatabaseError`] if the insert operation fails.
154    pub async fn insert(&self, record: DbRecord) -> Result<i64> {
155        let query = r"INSERT INTO records
156        VALUES (:id, :url, :client_datetime, :client_datetime_raw, :timestamp_flora, :title,
157        :language, :web_archive_serialized)";
158        let conn = self.pool.get().await?;
159
160        let row_id = conn
161            .interact(move |conn| {
162                let mut stmt = conn.prepare(query)?;
163                stmt.insert(rusqlite::named_params! {
164                    ":id": record.id,
165                    ":url": record.url,
166                    ":client_datetime": record.client_datetime,
167                    ":client_datetime_raw": record.client_datetime_raw,
168                    ":timestamp_flora": record.timestamp_flora,
169                    ":title": record.title,
170                    ":language": record.language,
171                    ":web_archive_serialized": record.web_archive_serialized,
172                })
173            })
174            .await??;
175
176        Ok(row_id)
177    }
178
179    /// Deletes the record corresponding to the given id.
180    ///
181    /// ## Errors
182    /// Returns [`DatabaseError`] if the operation fails.
183    #[instrument(skip(self))]
184    pub async fn delete(&self, id: i64) -> Result<usize> {
185        const QUERY: &str = r"DELETE FROM records WHERE id = ?1;";
186
187        let conn = self.pool.get().await?;
188        let rows_changed = conn
189            .interact(move |conn| {
190                let mut stmt = conn.prepare(QUERY)?;
191                stmt.execute([id])
192            })
193            .await??;
194
195        Ok(rows_changed)
196    }
197
198    /// Get a record from SQLite given an id.
199    ///
200    /// ## Errors
201    /// Returns [`DatabaseError`] if the operation fails.
202    pub async fn get_record(&self, id: i64) -> Result<DbRecord> {
203        let query = r"SELECT id, url, client_datetime, client_datetime_raw, timestamp_flora, title,
204            language, web_archive_serialized
205            FROM records WHERE id = ?1;";
206
207        let conn = self.pool.get().await?;
208        let result = conn
209            .interact(move |conn| {
210                let mut stmt = conn.prepare(query)?;
211                stmt.query_row([id], |row| {
212                    let record = row.try_into()?;
213                    Ok(record)
214                })
215            })
216            .await??;
217
218        Ok(result)
219    }
220
221    /// Get multiple records from SQLite given their IDs.
222    ///
223    /// ## Errors
224    /// Returns [`DatabaseError`] if the operation fails.
225    #[instrument]
226    pub async fn get_many_records(&self, ids: &[i64]) -> Result<Vec<DbRecord>> {
227        if ids.len() > MAX_BIND_PARAMS {
228            return Err(DatabaseErrorRepr::TooManyBindParameters(ids.len()).into());
229        }
230
231        tracing::debug!("Getting records.");
232
233        let query = r"SELECT id, url, client_datetime, client_datetime_raw, timestamp_flora, title,
234            language, web_archive_serialized
235            FROM records
236            WHERE id IN rarray(?1);";
237
238        let conn = self
239            .pool
240            .get()
241            .await
242            .inspect_err(|e| tracing::error!("Couldn't get a connection from the pool: {e}"))?;
243
244        let values = ids.iter().copied().map(Value::from).collect::<Vec<Value>>();
245
246        let records = conn
247            .interact(|conn| -> Result<Vec<DbRecord>> {
248                let values = Rc::new(values);
249
250                let mut stmt = conn
251                    .prepare(query)
252                    .inspect_err(|e| tracing::error!("Couldn't prepare the query: {e}"))?;
253
254                let rows = stmt
255                    .query_map([values], |row| DbRecord::try_from(row))
256                    .inspect_err(|e| tracing::error!("Couldn't execute the query: {e}"))?;
257
258                let mut records = vec![];
259                for maybe_record in rows {
260                    let record = maybe_record
261                        .inspect_err(|e| tracing::error!("Couldn't get the mapped record: {e}"))?;
262                    records.push(record);
263                }
264
265                Ok(records)
266            })
267            .await
268            .inspect_err(|e| {
269                tracing::error!("Couldn't interact with the database connection: {e}");
270            })??;
271
272        tracing::debug!("Got {} records", records.len());
273
274        Ok(records)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::model::tests::test_record_params;
282    use anyhow::Result;
283    use chrono::{DateTime, FixedOffset};
284    use omry_archiving::record::Record;
285    use pretty_assertions::assert_eq;
286    use serde::Serialize;
287    use std::fs::File;
288    use std::ops::Deref;
289    use tempfile::NamedTempFile;
290
291    /// Data structure to keep the temporary file holding the database alive,
292    /// because it will get unlinked on drop.
293    struct TempDb {
294        #[allow(dead_code)]
295        temp_db_file: NamedTempFile<File>,
296        db: Database,
297    }
298
299    impl TempDb {
300        async fn new() -> Result<Self> {
301            let temp_db_file = NamedTempFile::with_suffix(".db")?;
302            let db = Database::new_with_records_table(temp_db_file.path()).await?;
303            Ok(Self { temp_db_file, db })
304        }
305    }
306
307    impl Deref for TempDb {
308        type Target = Database;
309
310        fn deref(&self) -> &Self::Target {
311            &self.db
312        }
313    }
314
315    /// Wrapper for [`DbRecord`] that supports [`Serialize`] for redacting snapshots.
316    #[derive(Debug, Serialize)]
317    struct DbRecordDef {
318        id: Option<i64>,
319        url: String,
320        client_datetime: DateTime<FixedOffset>,
321        client_datetime_raw: Option<String>,
322        timestamp_flora: i64,
323        title: String,
324        language: Option<String>,
325        web_archive_serialized: Vec<u8>,
326    }
327
328    impl From<DbRecord> for DbRecordDef {
329        fn from(value: DbRecord) -> DbRecordDef {
330            let DbRecord {
331                id,
332                url,
333                client_datetime,
334                client_datetime_raw,
335                timestamp_flora,
336                title,
337                language,
338                web_archive_serialized,
339            } = value;
340
341            Self {
342                id,
343                url,
344                client_datetime,
345                client_datetime_raw,
346                timestamp_flora,
347                title,
348                language,
349                web_archive_serialized,
350            }
351        }
352    }
353
354    fn test_db_record() -> Result<DbRecord> {
355        let record = Record::new(test_record_params()?);
356        Ok(record.try_into()?)
357    }
358
359    #[tokio::test]
360    async fn can_create_table() -> Result<()> {
361        let db = TempDb::new().await?;
362        assert!(db.create_table(&DbRecord::schema()).await.is_ok());
363        // TODO: Check the resulting DB schema matches what we gave to create_table
364        Ok(())
365    }
366
367    #[tokio::test]
368    async fn can_add_record() -> Result<()> {
369        let db = TempDb::new().await?;
370        let record = test_db_record()?;
371        let result = db.insert(record).await?;
372        assert_eq!(result, 1);
373        Ok(())
374    }
375
376    #[tokio::test]
377    async fn can_get_record() -> Result<()> {
378        let db = TempDb::new().await?;
379        let record = test_db_record()?;
380        assert!(record.id.is_none());
381        db.insert(record).await?;
382
383        let expected_id = 1i64;
384        let retrieved_record = db.get_record(expected_id).await?;
385
386        let retrieved_record: DbRecordDef = retrieved_record.into();
387        insta::assert_ron_snapshot!(retrieved_record, {
388            ".client_datetime" => "[client_datetime]",
389            ".timestamp_flora" => "[timestamp]"
390        });
391
392        Ok(())
393    }
394
395    #[tokio::test]
396    async fn can_get_many_records() -> Result<()> {
397        let db = TempDb::new().await?;
398
399        let num_records_to_insert = 7;
400        let mut record_ids = Vec::with_capacity(num_records_to_insert);
401
402        for _ in 0..num_records_to_insert {
403            let record = test_db_record()?;
404            let record_id = db.insert(record).await?;
405            record_ids.push(record_id);
406        }
407
408        let selected_records = db
409            .get_many_records(&record_ids)
410            .await?
411            .into_iter()
412            .map(DbRecordDef::from)
413            .collect::<Vec<_>>();
414
415        assert_eq!(selected_records.len(), num_records_to_insert);
416
417        insta::assert_ron_snapshot!(selected_records, {
418            "[].client_datetime" => "[client_datetime]",
419            "[].timestamp_flora" => "[timestamp]"
420        });
421
422        Ok(())
423    }
424
425    #[tokio::test]
426    async fn passing_too_many_bind_parameters_returns_error() -> Result<()> {
427        let db = TempDb::new().await?;
428
429        let num_records_to_insert = MAX_BIND_PARAMS + 1;
430        db.insert(test_db_record()?).await?;
431
432        let mut ids = Vec::with_capacity(num_records_to_insert);
433        for index in 0..num_records_to_insert {
434            ids.push(i64::try_from(index + 1)?);
435        }
436
437        let result = db.get_many_records(&ids).await;
438        assert!(result.is_err());
439
440        // At this point, we know this is definitely an error.
441        #[allow(clippy::unwrap_used)]
442        let error = result.unwrap_err();
443        insta::assert_snapshot!(error);
444
445        Ok(())
446    }
447
448    #[tokio::test]
449    async fn can_get_count() -> Result<()> {
450        let db = TempDb::new().await?;
451
452        let num_records_to_insert = 7;
453        let mut record_ids = Vec::with_capacity(num_records_to_insert);
454
455        for _ in 0..num_records_to_insert {
456            let record = test_db_record()?;
457            let record_id = db.insert(record).await?;
458            record_ids.push(record_id);
459        }
460
461        let count: usize = db.count().await?.try_into()?;
462
463        assert_eq!(count, num_records_to_insert);
464        Ok(())
465    }
466
467    #[tokio::test]
468    async fn can_delete_record() -> Result<()> {
469        let db = TempDb::new().await?;
470        let record = test_db_record()?;
471        let id = db.insert(record).await?;
472        let rows_changed = db.delete(id).await?;
473        assert_eq!(rows_changed, 1);
474        Ok(())
475    }
476}