use crate::io::api::DatabasePersistence;
use crate::io::ApiResult;
use crate::prelude::{create_dir_all, env, Mutex, OnceLock, PathBuf};
use crate::util::constants::app::{APPLICATION, DEFAULT_CACHE_TTL_SECONDS, ORGANIZATION, QUALIFIER};
use crate::util::constants::env::{CACHE_TTL, DATABASE_PATH};
use crate::util::Label;
use async_trait::async_trait;
use backend::{params, BackendRow, Connection, Params};
use chrono::{Duration, Utc};
use color_eyre::eyre::eyre;
use core::marker::PhantomData;
use directories::ProjectDirs;
use tracing::info;
pub mod backend;
pub(crate) mod macros;
pub mod schema;
use schema::Table;
static CONNECTION: OnceLock<Mutex<Connection>> = OnceLock::new();
pub type QueryParams = backend::ParamsFromIter<Vec<Box<dyn backend::ToSql>>>;
pub type SelectQuery = (String, QueryParams);
#[derive(Clone, Debug)]
pub enum PersistStatus {
Downloaded(usize),
AlreadyExists,
}
#[async_trait]
pub trait Operations {
fn insert<R>(&self, value: R) -> ApiResult<usize>
where
R: Row;
async fn persist<P>(&self, data: Option<P>) -> ApiResult<usize>
where
P: DatabasePersistence + Send;
async fn populate(&self, table: impl TableSchemaProvider + Send) -> ApiResult<PersistStatus>;
fn select<T, P>(&self, value: P) -> ApiResult<Option<T>>
where
T: Into<Table>,
P: Params;
}
pub trait Row: RowMetadata {
fn table(&self) -> Table;
fn fields() -> &'static [&'static str]
where
Self: Sized,
{
<Self as RowMetadata>::fields()
}
fn insert(self, _conn: &Connection) -> ApiResult<usize>
where
Self: Sized;
fn select_all(&self, path: Option<PathBuf>) -> ApiResult<Vec<Self>>
where
Self: Sized,
for<'row> Self: From<&'row BackendRow<'row>>,
{
let table = self.table();
let name = table.name();
let fields = <Self as RowMetadata>::fields().join(", ");
let query = format!("SELECT {fields} FROM {name}");
let (query, params) = self.build_select_query(&query);
table.rows(&query, params, path.as_ref())
}
fn select<F>(&self, path: Option<PathBuf>, predicate: F) -> ApiResult<Option<Self>>
where
Self: Sized,
for<'row> Self: From<&'row BackendRow<'row>>,
F: Fn(&Self) -> bool,
{
self.select_all(path).map(|rows| rows.into_iter().find(predicate))
}
fn build_select_query(&self, base: &str) -> SelectQuery;
fn next_row_id(&self, conn: &Connection) -> ApiResult<i64>
where
Self: Sized,
{
let table = self.table().name();
conn.query_row(&format!("SELECT COALESCE(MAX(id), 0) + 1 FROM {table}"), params![], |row| row.get(0))
.map_err(|why| eyre!("=> {} Failed to determine next id for {table} — {why}", Label::fail()))
}
}
pub trait RowMetadata {
fn fields() -> &'static [&'static str]
where
Self: Sized;
}
#[async_trait]
pub trait TableSchemaProvider: Copy + Into<Table> + 'static {
fn all() -> &'static [Self];
fn create_statement(&self) -> String;
fn name(&self) -> &'static str;
async fn populate(&self) -> ApiResult<usize>;
fn print(self, path: Option<PathBuf>);
fn rows<R, P>(&self, query: &str, params: P, path: Option<&PathBuf>) -> ApiResult<Vec<R>>
where
P: Params,
for<'row> R: Row + From<&'row BackendRow<'row>>;
}
#[derive(Clone, Debug)]
pub struct Database<T>
where
T: TableSchemaProvider,
{
path: Option<PathBuf>,
marker: PhantomData<T>,
}
impl<T> Default for Database<T>
where
T: TableSchemaProvider,
{
fn default() -> Self {
Self {
path: None,
marker: PhantomData,
}
}
}
#[async_trait]
impl<S> Operations for Database<S>
where
S: TableSchemaProvider + Sync,
{
fn insert<R>(&self, value: R) -> ApiResult<usize>
where
R: Row,
{
let row: R = value;
self.with_connection(|conn| row.insert(conn))
}
async fn persist<P>(&self, data: Option<P>) -> ApiResult<usize>
where
P: DatabasePersistence + Send,
{
let database = Database::<Table>::from_path(self.path.clone());
match data {
| Some(rows) => rows.persist(database).await,
| None => Err(eyre!("Failed to persist data")),
}
}
async fn populate(&self, table: impl TableSchemaProvider + Send) -> ApiResult<PersistStatus> {
match self.row_count(table) {
| Ok(count) if count > 0 => Ok(PersistStatus::AlreadyExists),
| Ok(_) => table.into().populate().await.map(PersistStatus::Downloaded),
| Err(why) => Err(why),
}
}
fn select<T, P>(&self, _value: P) -> ApiResult<Option<T>>
where
T: Into<Table>,
P: Params,
{
Err(eyre!("Database query is not implemented"))
}
}
impl<T> Database<T>
where
T: TableSchemaProvider,
{
pub fn from_path(path: Option<PathBuf>) -> Self {
Self { path, marker: PhantomData }
}
pub fn with_connection<U>(&self, callback: impl FnOnce(&Connection) -> ApiResult<U>) -> ApiResult<U> {
Self::db_connection(self.path.as_ref())
.and_then(|connection| {
connection
.lock()
.map_err(|why| eyre!("Failed to acquire database connection lock — {why}"))
})
.and_then(|guard| callback(&guard))
}
fn db_connection(path: Option<&PathBuf>) -> ApiResult<&'static Mutex<Connection>> {
match CONNECTION.get() {
| Some(connection) => Ok(connection),
| None => resolve_database_path(path)
.and_then(|db_path| {
Connection::open(db_path)
.map(Mutex::new)
.map_err(|why| eyre!("Failed to initialize database connection — {why}"))
})
.and_then(|connection| {
CONNECTION.set(connection).ok();
CONNECTION
.get()
.ok_or_else(|| eyre!("Failed to access database connection after initialization"))
}),
}
}
pub fn cache_ttl(&self) -> Duration {
#[cfg(feature = "std")]
{
if let Ok(env_ttl) = dotenvy::var(CACHE_TTL) {
if let Ok(seconds) = env_ttl.parse::<i64>() {
if seconds > 0 {
return Duration::seconds(seconds);
}
}
}
}
let default_ttl = Duration::seconds(DEFAULT_CACHE_TTL_SECONDS as i64);
if let Ok(env_ttl) = env::var(CACHE_TTL) {
if let Ok(seconds) = env_ttl.parse::<i64>() {
if seconds > 0 {
return Duration::seconds(seconds);
}
}
}
default_ttl
}
pub fn migrate_table(&self, table: impl Into<Table>) -> ApiResult<()> {
let table = table.into();
self.with_connection(|conn| {
let statement = table.create_statement();
conn.execute(&statement, params![])
.map_err(|why| eyre!("Failed to create table {} — {why}", table.name()))
.map(|_| ())
})
}
pub fn migrate(&self) -> ApiResult<()> {
T::all().iter().copied().try_fold((), |_, table| self.migrate_table(table)).inspect(|_| {
info!("=> {} Database migration", Label::pass());
})
}
pub fn clear(&self, table: impl Into<Table>) -> ApiResult<usize> {
let table = table.into();
self.with_connection(|conn| {
conn.execute(&format!("DELETE FROM {}", table.name()), params![]).or_else(|error| {
if error.to_string().contains("no such table") {
Ok(0)
} else {
Err(eyre!("Failed to clear {} — {error}", table.name()))
}
})
})
}
pub fn delete_expired_before(&self, table: impl Into<Table>, column: &str, cutoff: &str) -> ApiResult<usize> {
let table = table.into();
let sql = format!("DELETE FROM {} WHERE {} < ?", table.name(), column);
self.with_connection(|conn| {
conn.execute(&sql, params![cutoff]).or_else(|error| {
if error.to_string().contains("no such table") {
Ok(0)
} else {
Err(eyre!("Failed to clean up {} — {error}", table.name()))
}
})
})
}
pub fn row_count(&self, table: impl Into<Table>) -> ApiResult<usize> {
let name = table.into().name();
self.with_connection(|conn| {
conn.query_row(&format!("SELECT COUNT(*) FROM {}", name), params![], |row| row.get::<_, i64>(0))
.map_err(|why| eyre!("Failed to count rows in {} — {why}", name))
.and_then(|count| usize::try_from(count).map_err(|why| eyre!("Failed to convert row count for {} — {why}", name)))
})
}
pub fn trim_to_recent(&self, table: impl Into<Table>, id_column: &str, order_column: &str, limit: usize) -> ApiResult<usize> {
let table = table.into();
let sql = format!(
"DELETE FROM {table} WHERE {id_col} NOT IN (SELECT {id_col} FROM {table} ORDER BY {order_col} DESC LIMIT ?)",
table = table.name(),
id_col = id_column,
order_col = order_column,
);
self.with_connection(|conn| {
conn.execute(&sql, params![limit as i64])
.map_err(|why| eyre!("Failed to trim {} — {why}", table.name()))
})
}
pub fn cleanup_expired_cache(&self) -> ApiResult<usize> {
let ttl = self.cache_ttl();
let now = Utc::now();
#[allow(clippy::arithmetic_side_effects)]
let cutoff = (now - ttl).to_rfc3339();
self.delete_expired_before(Table::LinkCache, "expires_at", &cutoff)
.and_then(|link_count| {
self.trim_to_recent(Table::ValidationHistory, "id", "checked_at", 1000).map(|_| {
if link_count > 0 {
info!("{} Cleaned up {link_count} expired cache entries", Label::run());
}
link_count
})
})
}
pub fn clear_cache(&self) -> ApiResult<usize> {
self.clear(Table::LinkCache).and_then(|link_count| {
self.clear(Table::ValidationHistory).and_then(|validation_count| {
self.clear(Table::ResearchActivityCache).map(|cache_count| {
let total = link_count.saturating_add(validation_count).saturating_add(cache_count);
info!("{} Cleared {total} entries from cache tables", Label::pass());
total
})
})
})
}
pub fn reset(&self) -> ApiResult<usize> {
T::all()
.iter()
.copied()
.try_fold(0usize, |total, table| self.clear(table).map(|count| total.saturating_add(count)))
.inspect(|&total| {
info!("{} Cleared {total} entries from all tables", Label::pass());
})
}
}
fn database_name() -> &'static str {
#[cfg(feature = "duckdb")]
{
"acorn.duckdb"
}
#[cfg(not(feature = "duckdb"))]
{
"acorn.db"
}
}
pub fn resolve_database_path(path: Option<&PathBuf>) -> ApiResult<PathBuf> {
let path = path
.cloned()
.or_else(|| env::var(DATABASE_PATH).ok().filter(|value| !value.trim().is_empty()).map(PathBuf::from));
match path {
| Some(path) => Ok(path),
| None => ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
.ok_or_else(|| eyre!("Failed to determine project directories"))
.map(|directories| directories.cache_dir().join("database").join(database_name())),
}
.and_then(|path| {
path.parent()
.map(PathBuf::from)
.ok_or_else(|| eyre!("Failed to determine database parent directory"))
.and_then(|parent| {
create_dir_all(&parent)
.map_err(|why| eyre!("Failed to create database directory: {why}"))
.map(|_| path)
})
})
}
#[cfg(test)]
mod tests;