use crate::model::{DbRecord, Schema};
use deadpool_sqlite::rusqlite;
use deadpool_sqlite::rusqlite::Row;
use deadpool_sqlite::rusqlite::types::Value;
use deadpool_sqlite::{Config, Pool, Runtime};
use std::path::Path;
use std::rc::Rc;
use thiserror::Error;
use tracing::instrument;
const MAX_BIND_PARAMS: usize = 32766;
#[derive(Debug, Error)]
#[error(transparent)]
pub struct DatabaseError(DatabaseErrorRepr);
impl<E> From<E> for DatabaseError
where
E: Into<DatabaseErrorRepr>,
{
fn from(error: E) -> Self {
Self(error.into())
}
}
#[derive(Error, Debug)]
enum DatabaseErrorRepr {
#[error("error creating the DB pool: {0}")]
CreatePool(#[from] deadpool_sqlite::CreatePoolError),
#[error("error interacting with the DB pool: {0}")]
PoolInteract(String),
#[error("error getting connection from the DB pool: {0}")]
Pool(#[from] deadpool_sqlite::PoolError),
#[error("error in rusqlite: {0}")]
Rusqlite(#[from] rusqlite::Error),
#[error(
"The caller passed too many parameters to bind (got {0}, max allowed is {MAX_BIND_PARAMS})"
)]
TooManyBindParameters(usize),
}
impl From<deadpool_sqlite::InteractError> for DatabaseErrorRepr {
fn from(error: deadpool_sqlite::InteractError) -> Self {
Self::PoolInteract(error.to_string())
}
}
pub type Result<T> = std::result::Result<T, DatabaseError>;
impl TryFrom<&Row<'_>> for DbRecord {
type Error = rusqlite::Error;
fn try_from(row: &Row) -> std::result::Result<Self, Self::Error> {
Ok(Self {
id: row.get(0)?,
url: row.get(1)?,
client_datetime: row.get(2)?,
client_datetime_raw: row.get(3)?,
timestamp_flora: row.get(4)?,
title: row.get(5)?,
language: row.get(6)?,
web_archive_serialized: row.get(7)?,
})
}
}
#[derive(Debug, Clone)]
pub struct Database {
pool: Pool,
}
impl Database {
#[instrument(skip(path))]
async fn new(path: impl AsRef<Path>) -> Result<Self> {
let cfg = Config::new(path.as_ref());
let pool = cfg.create_pool(Runtime::Tokio1)?;
let conn = pool.get().await?;
conn.interact(|conn| rusqlite::vtab::array::load_module(conn))
.await??;
Ok(Self { pool })
}
#[instrument(skip(self))]
async fn create_table(&self, schema: &Schema) -> Result<()> {
let create_table_query = format!("CREATE TABLE IF NOT EXISTS {schema};");
let conn = self.pool.get().await?;
let _rows: usize = conn
.interact(move |conn| {
let mut stmt = conn.prepare(&create_table_query)?;
stmt.execute(())
})
.await??;
Ok(())
}
#[instrument(skip(path))]
pub async fn new_with_records_table(path: impl AsRef<Path>) -> Result<Self> {
let db = Self::new(path).await?;
db.create_table(&DbRecord::schema()).await?;
Ok(db)
}
#[instrument]
pub async fn count(&self) -> Result<i64> {
let query = r"SELECT count(*) FROM records;";
let conn = self.pool.get().await?;
let record_count = conn
.interact(move |conn| {
let mut stmt = conn.prepare(query)?;
stmt.query_one([], |row| row.get(0))
})
.await??;
Ok(record_count)
}
pub async fn insert(&self, record: DbRecord) -> Result<i64> {
let query = r"INSERT INTO records
VALUES (:id, :url, :client_datetime, :client_datetime_raw, :timestamp_flora, :title,
:language, :web_archive_serialized)";
let conn = self.pool.get().await?;
let row_id = conn
.interact(move |conn| {
let mut stmt = conn.prepare(query)?;
stmt.insert(rusqlite::named_params! {
":id": record.id,
":url": record.url,
":client_datetime": record.client_datetime,
":client_datetime_raw": record.client_datetime_raw,
":timestamp_flora": record.timestamp_flora,
":title": record.title,
":language": record.language,
":web_archive_serialized": record.web_archive_serialized,
})
})
.await??;
Ok(row_id)
}
#[instrument(skip(self))]
pub async fn delete(&self, id: i64) -> Result<usize> {
const QUERY: &str = r"DELETE FROM records WHERE id = ?1;";
let conn = self.pool.get().await?;
let rows_changed = conn
.interact(move |conn| {
let mut stmt = conn.prepare(QUERY)?;
stmt.execute([id])
})
.await??;
Ok(rows_changed)
}
pub async fn get_record(&self, id: i64) -> Result<DbRecord> {
let query = r"SELECT id, url, client_datetime, client_datetime_raw, timestamp_flora, title,
language, web_archive_serialized
FROM records WHERE id = ?1;";
let conn = self.pool.get().await?;
let result = conn
.interact(move |conn| {
let mut stmt = conn.prepare(query)?;
stmt.query_row([id], |row| {
let record = row.try_into()?;
Ok(record)
})
})
.await??;
Ok(result)
}
#[instrument]
pub async fn get_many_records(&self, ids: &[i64]) -> Result<Vec<DbRecord>> {
if ids.len() > MAX_BIND_PARAMS {
return Err(DatabaseErrorRepr::TooManyBindParameters(ids.len()).into());
}
tracing::debug!("Getting records.");
let query = r"SELECT id, url, client_datetime, client_datetime_raw, timestamp_flora, title,
language, web_archive_serialized
FROM records
WHERE id IN rarray(?1);";
let conn = self
.pool
.get()
.await
.inspect_err(|e| tracing::error!("Couldn't get a connection from the pool: {e}"))?;
let values = ids.iter().copied().map(Value::from).collect::<Vec<Value>>();
let records = conn
.interact(|conn| -> Result<Vec<DbRecord>> {
let values = Rc::new(values);
let mut stmt = conn
.prepare(query)
.inspect_err(|e| tracing::error!("Couldn't prepare the query: {e}"))?;
let rows = stmt
.query_map([values], |row| DbRecord::try_from(row))
.inspect_err(|e| tracing::error!("Couldn't execute the query: {e}"))?;
let mut records = vec![];
for maybe_record in rows {
let record = maybe_record
.inspect_err(|e| tracing::error!("Couldn't get the mapped record: {e}"))?;
records.push(record);
}
Ok(records)
})
.await
.inspect_err(|e| {
tracing::error!("Couldn't interact with the database connection: {e}");
})??;
tracing::debug!("Got {} records", records.len());
Ok(records)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::tests::test_record_params;
use anyhow::Result;
use chrono::{DateTime, FixedOffset};
use omry_archiving::record::Record;
use pretty_assertions::assert_eq;
use serde::Serialize;
use std::fs::File;
use std::ops::Deref;
use tempfile::NamedTempFile;
struct TempDb {
#[allow(dead_code)]
temp_db_file: NamedTempFile<File>,
db: Database,
}
impl TempDb {
async fn new() -> Result<Self> {
let temp_db_file = NamedTempFile::with_suffix(".db")?;
let db = Database::new_with_records_table(temp_db_file.path()).await?;
Ok(Self { temp_db_file, db })
}
}
impl Deref for TempDb {
type Target = Database;
fn deref(&self) -> &Self::Target {
&self.db
}
}
#[derive(Debug, Serialize)]
struct DbRecordDef {
id: Option<i64>,
url: String,
client_datetime: DateTime<FixedOffset>,
client_datetime_raw: Option<String>,
timestamp_flora: i64,
title: String,
language: Option<String>,
web_archive_serialized: Vec<u8>,
}
impl From<DbRecord> for DbRecordDef {
fn from(value: DbRecord) -> DbRecordDef {
let DbRecord {
id,
url,
client_datetime,
client_datetime_raw,
timestamp_flora,
title,
language,
web_archive_serialized,
} = value;
Self {
id,
url,
client_datetime,
client_datetime_raw,
timestamp_flora,
title,
language,
web_archive_serialized,
}
}
}
fn test_db_record() -> Result<DbRecord> {
let record = Record::new(test_record_params()?);
Ok(record.try_into()?)
}
#[tokio::test]
async fn can_create_table() -> Result<()> {
let db = TempDb::new().await?;
assert!(db.create_table(&DbRecord::schema()).await.is_ok());
Ok(())
}
#[tokio::test]
async fn can_add_record() -> Result<()> {
let db = TempDb::new().await?;
let record = test_db_record()?;
let result = db.insert(record).await?;
assert_eq!(result, 1);
Ok(())
}
#[tokio::test]
async fn can_get_record() -> Result<()> {
let db = TempDb::new().await?;
let record = test_db_record()?;
assert!(record.id.is_none());
db.insert(record).await?;
let expected_id = 1i64;
let retrieved_record = db.get_record(expected_id).await?;
let retrieved_record: DbRecordDef = retrieved_record.into();
insta::assert_ron_snapshot!(retrieved_record, {
".client_datetime" => "[client_datetime]",
".timestamp_flora" => "[timestamp]"
});
Ok(())
}
#[tokio::test]
async fn can_get_many_records() -> Result<()> {
let db = TempDb::new().await?;
let num_records_to_insert = 7;
let mut record_ids = Vec::with_capacity(num_records_to_insert);
for _ in 0..num_records_to_insert {
let record = test_db_record()?;
let record_id = db.insert(record).await?;
record_ids.push(record_id);
}
let selected_records = db
.get_many_records(&record_ids)
.await?
.into_iter()
.map(DbRecordDef::from)
.collect::<Vec<_>>();
assert_eq!(selected_records.len(), num_records_to_insert);
insta::assert_ron_snapshot!(selected_records, {
"[].client_datetime" => "[client_datetime]",
"[].timestamp_flora" => "[timestamp]"
});
Ok(())
}
#[tokio::test]
async fn passing_too_many_bind_parameters_returns_error() -> Result<()> {
let db = TempDb::new().await?;
let num_records_to_insert = MAX_BIND_PARAMS + 1;
db.insert(test_db_record()?).await?;
let mut ids = Vec::with_capacity(num_records_to_insert);
for index in 0..num_records_to_insert {
ids.push(i64::try_from(index + 1)?);
}
let result = db.get_many_records(&ids).await;
assert!(result.is_err());
#[allow(clippy::unwrap_used)]
let error = result.unwrap_err();
insta::assert_snapshot!(error);
Ok(())
}
#[tokio::test]
async fn can_get_count() -> Result<()> {
let db = TempDb::new().await?;
let num_records_to_insert = 7;
let mut record_ids = Vec::with_capacity(num_records_to_insert);
for _ in 0..num_records_to_insert {
let record = test_db_record()?;
let record_id = db.insert(record).await?;
record_ids.push(record_id);
}
let count: usize = db.count().await?.try_into()?;
assert_eq!(count, num_records_to_insert);
Ok(())
}
#[tokio::test]
async fn can_delete_record() -> Result<()> {
let db = TempDb::new().await?;
let record = test_db_record()?;
let id = db.insert(record).await?;
let rows_changed = db.delete(id).await?;
assert_eq!(rows_changed, 1);
Ok(())
}
}