Skip to main content

acorn/io/database/
mod.rs

1//! Database utilities for ACORN.
2//!
3//! This module provides a lazily initialized, process-global database connection
4//! guarded by a mutex and a helper to execute callbacks against that connection.
5use crate::io::api::DatabasePersistence;
6use crate::io::ApiResult;
7use crate::prelude::{create_dir_all, env, Mutex, OnceLock, PathBuf};
8use crate::util::constants::app::{APPLICATION, DEFAULT_CACHE_TTL_SECONDS, ORGANIZATION, QUALIFIER};
9use crate::util::constants::env::{CACHE_TTL, DATABASE_PATH};
10use crate::util::Label;
11use async_trait::async_trait;
12use backend::{params, BackendRow, Connection, Params};
13use chrono::{Duration, Utc};
14use color_eyre::eyre::eyre;
15use core::marker::PhantomData;
16use directories::ProjectDirs;
17use tracing::info;
18
19pub mod backend;
20pub(crate) mod macros;
21pub mod schema;
22
23use schema::Table;
24
25static CONNECTION: OnceLock<Mutex<Connection>> = OnceLock::new();
26
27/// SQL parameters produced for dynamic queries.
28pub type QueryParams = backend::ParamsFromIter<Vec<Box<dyn backend::ToSql>>>;
29/// SQL query string paired with its bound parameters.
30pub type SelectQuery = (String, QueryParams);
31/// Result of a persist operation indicating whether data was downloaded or already existed.
32#[derive(Clone, Debug)]
33pub enum PersistStatus {
34    /// Data was downloaded and persisted (contains row count).
35    Downloaded(usize),
36    /// Table already contained data, no action taken.
37    AlreadyExists,
38}
39/// Write and access data in a database
40#[async_trait]
41pub trait Operations {
42    /// Insert a value (row) into the database
43    fn insert<R>(&self, value: R) -> ApiResult<usize>
44    where
45        R: Row;
46    /// Save data to database
47    async fn persist<P>(&self, data: Option<P>) -> ApiResult<usize>
48    where
49        P: DatabasePersistence + Send;
50    /// Download and persist data for a table if the table is empty.
51    /// Returns `PersistStatus::Downloaded(n)` if data was downloaded and persisted,
52    /// or `PersistStatus::AlreadyExists` if the table already contained data.
53    async fn populate(&self, table: impl TableSchemaProvider + Send) -> ApiResult<PersistStatus>;
54    /// Select a value from the database
55    fn select<T, P>(&self, value: P) -> ApiResult<Option<T>>
56    where
57        T: Into<Table>,
58        P: Params;
59}
60/// Row metadata for database table mapping
61pub trait Row: RowMetadata {
62    /// Returns the table associated with this row type.
63    fn table(&self) -> Table;
64    /// Returns row field names generated by the `DatabaseRow` derive.
65    fn fields() -> &'static [&'static str]
66    where
67        Self: Sized,
68    {
69        <Self as RowMetadata>::fields()
70    }
71    /// Inserts this row into its corresponding table.
72    fn insert(self, _conn: &Connection) -> ApiResult<usize>
73    where
74        Self: Sized;
75    /// Selects rows from the database matching this row's field values.
76    fn select_all(&self, path: Option<PathBuf>) -> ApiResult<Vec<Self>>
77    where
78        Self: Sized,
79        for<'row> Self: From<&'row BackendRow<'row>>,
80    {
81        let table = self.table();
82        let name = table.name();
83        let fields = <Self as RowMetadata>::fields().join(", ");
84        let query = format!("SELECT {fields} FROM {name}");
85        let (query, params) = self.build_select_query(&query);
86        table.rows(&query, params, path.as_ref())
87    }
88    /// Selects the first row matching this row's filter values and predicate.
89    fn select<F>(&self, path: Option<PathBuf>, predicate: F) -> ApiResult<Option<Self>>
90    where
91        Self: Sized,
92        for<'row> Self: From<&'row BackendRow<'row>>,
93        F: Fn(&Self) -> bool,
94    {
95        self.select_all(path).map(|rows| rows.into_iter().find(predicate))
96    }
97    /// Builds a SQL SELECT query with optional WHERE/ORDER BY clauses.
98    fn build_select_query(&self, base: &str) -> SelectQuery;
99    /// Get ID for next row (used when inserting new row)
100    fn next_row_id(&self, conn: &Connection) -> ApiResult<i64>
101    where
102        Self: Sized,
103    {
104        let table = self.table().name();
105        conn.query_row(&format!("SELECT COALESCE(MAX(id), 0) + 1 FROM {table}"), params![], |row| row.get(0))
106            .map_err(|why| eyre!("=> {} Failed to determine next id for {table} — {why}", Label::fail()))
107    }
108}
109/// Metadata generated by `DatabaseRow` derive for row structs.
110pub trait RowMetadata {
111    /// Returns the list of field names for the row.
112    fn fields() -> &'static [&'static str]
113    where
114        Self: Sized;
115}
116/// Table schema provider for database operations
117#[async_trait]
118pub trait TableSchemaProvider: Copy + Into<Table> + 'static {
119    /// Returns all variants for the table type.
120    fn all() -> &'static [Self];
121    /// Returns the CREATE TABLE SQL statement for this table
122    fn create_statement(&self) -> String;
123    /// Returns the table name as a string
124    fn name(&self) -> &'static str;
125    /// Populate the table with data (typically downloaded use API module)
126    async fn populate(&self) -> ApiResult<usize>;
127    /// Print all table rows
128    fn print(self, path: Option<PathBuf>);
129    /// Get table rows
130    fn rows<R, P>(&self, query: &str, params: P, path: Option<&PathBuf>) -> ApiResult<Vec<R>>
131    where
132        P: Params,
133        for<'row> R: Row + From<&'row BackendRow<'row>>;
134}
135/// Database handle for a given table schema provider type
136#[derive(Clone, Debug)]
137pub struct Database<T>
138where
139    T: TableSchemaProvider,
140{
141    path: Option<PathBuf>,
142    marker: PhantomData<T>,
143}
144impl<T> Default for Database<T>
145where
146    T: TableSchemaProvider,
147{
148    fn default() -> Self {
149        Self {
150            path: None,
151            marker: PhantomData,
152        }
153    }
154}
155#[async_trait]
156impl<S> Operations for Database<S>
157where
158    S: TableSchemaProvider + Sync,
159{
160    fn insert<R>(&self, value: R) -> ApiResult<usize>
161    where
162        R: Row,
163    {
164        let row: R = value;
165        self.with_connection(|conn| row.insert(conn))
166    }
167    async fn persist<P>(&self, data: Option<P>) -> ApiResult<usize>
168    where
169        P: DatabasePersistence + Send,
170    {
171        let database = Database::<Table>::from_path(self.path.clone());
172        match data {
173            | Some(rows) => rows.persist(database).await,
174            | None => Err(eyre!("Failed to persist data")),
175        }
176    }
177    async fn populate(&self, table: impl TableSchemaProvider + Send) -> ApiResult<PersistStatus> {
178        match self.row_count(table) {
179            | Ok(count) if count > 0 => Ok(PersistStatus::AlreadyExists),
180            | Ok(_) => table.into().populate().await.map(PersistStatus::Downloaded),
181            | Err(why) => Err(why),
182        }
183    }
184    fn select<T, P>(&self, _value: P) -> ApiResult<Option<T>>
185    where
186        T: Into<Table>,
187        P: Params,
188    {
189        Err(eyre!("Database query is not implemented"))
190    }
191}
192impl<T> Database<T>
193where
194    T: TableSchemaProvider,
195{
196    /// Create a new database handle from an optional database path override.
197    pub fn from_path(path: Option<PathBuf>) -> Self {
198        Self { path, marker: PhantomData }
199    }
200    /// Executes a callback using the database path configured on this handle.
201    pub fn with_connection<U>(&self, callback: impl FnOnce(&Connection) -> ApiResult<U>) -> ApiResult<U> {
202        Self::db_connection(self.path.as_ref())
203            .and_then(|connection| {
204                connection
205                    .lock()
206                    .map_err(|why| eyre!("Failed to acquire database connection lock — {why}"))
207            })
208            .and_then(|guard| callback(&guard))
209    }
210    fn db_connection(path: Option<&PathBuf>) -> ApiResult<&'static Mutex<Connection>> {
211        match CONNECTION.get() {
212            | Some(connection) => Ok(connection),
213            | None => resolve_database_path(path)
214                .and_then(|db_path| {
215                    Connection::open(db_path)
216                        .map(Mutex::new)
217                        .map_err(|why| eyre!("Failed to initialize database connection — {why}"))
218                })
219                .and_then(|connection| {
220                    CONNECTION.set(connection).ok();
221                    CONNECTION
222                        .get()
223                        .ok_or_else(|| eyre!("Failed to access database connection after initialization"))
224                }),
225        }
226    }
227    /// Get the cache TTL value from environment or use default.
228    pub fn cache_ttl(&self) -> Duration {
229        #[cfg(feature = "std")]
230        {
231            if let Ok(env_ttl) = dotenvy::var(CACHE_TTL) {
232                if let Ok(seconds) = env_ttl.parse::<i64>() {
233                    if seconds > 0 {
234                        return Duration::seconds(seconds);
235                    }
236                }
237            }
238        }
239        let default_ttl = Duration::seconds(DEFAULT_CACHE_TTL_SECONDS as i64);
240        if let Ok(env_ttl) = env::var(CACHE_TTL) {
241            if let Ok(seconds) = env_ttl.parse::<i64>() {
242                if seconds > 0 {
243                    return Duration::seconds(seconds);
244                }
245            }
246        }
247        default_ttl
248    }
249    /// Create one table if it does not exist.
250    pub fn migrate_table(&self, table: impl Into<Table>) -> ApiResult<()> {
251        let table = table.into();
252        self.with_connection(|conn| {
253            let statement = table.create_statement();
254            conn.execute(&statement, params![])
255                .map_err(|why| eyre!("Failed to create table {} — {why}", table.name()))
256                .map(|_| ())
257        })
258    }
259    /// Create all tables defined by the variant provider type.
260    pub fn migrate(&self) -> ApiResult<()> {
261        T::all().iter().copied().try_fold((), |_, table| self.migrate_table(table)).inspect(|_| {
262            info!("=> {} Database migration", Label::pass());
263        })
264    }
265    /// Delete all rows from a table.
266    pub fn clear(&self, table: impl Into<Table>) -> ApiResult<usize> {
267        let table = table.into();
268        self.with_connection(|conn| {
269            conn.execute(&format!("DELETE FROM {}", table.name()), params![]).or_else(|error| {
270                if error.to_string().contains("no such table") {
271                    Ok(0)
272                } else {
273                    Err(eyre!("Failed to clear {} — {error}", table.name()))
274                }
275            })
276        })
277    }
278    /// Delete rows where a timestamp/text column is older than a cutoff value.
279    pub fn delete_expired_before(&self, table: impl Into<Table>, column: &str, cutoff: &str) -> ApiResult<usize> {
280        let table = table.into();
281        let sql = format!("DELETE FROM {} WHERE {} < ?", table.name(), column);
282        self.with_connection(|conn| {
283            conn.execute(&sql, params![cutoff]).or_else(|error| {
284                if error.to_string().contains("no such table") {
285                    Ok(0)
286                } else {
287                    Err(eyre!("Failed to clean up {} — {error}", table.name()))
288                }
289            })
290        })
291    }
292    /// Count rows in a table.
293    pub fn row_count(&self, table: impl Into<Table>) -> ApiResult<usize> {
294        let name = table.into().name();
295        self.with_connection(|conn| {
296            conn.query_row(&format!("SELECT COUNT(*) FROM {}", name), params![], |row| row.get::<_, i64>(0))
297                .map_err(|why| eyre!("Failed to count rows in {} — {why}", name))
298                .and_then(|count| usize::try_from(count).map_err(|why| eyre!("Failed to convert row count for {} — {why}", name)))
299        })
300    }
301    /// Keep only the most recent `limit` rows using an id/order column pair.
302    pub fn trim_to_recent(&self, table: impl Into<Table>, id_column: &str, order_column: &str, limit: usize) -> ApiResult<usize> {
303        let table = table.into();
304        let sql = format!(
305            "DELETE FROM {table} WHERE {id_col} NOT IN (SELECT {id_col} FROM {table} ORDER BY {order_col} DESC LIMIT ?)",
306            table = table.name(),
307            id_col = id_column,
308            order_col = order_column,
309        );
310        self.with_connection(|conn| {
311            conn.execute(&sql, params![limit as i64])
312                .map_err(|why| eyre!("Failed to trim {} — {why}", table.name()))
313        })
314    }
315    /// Clean up expired link cache entries and trim validation history.
316    pub fn cleanup_expired_cache(&self) -> ApiResult<usize> {
317        let ttl = self.cache_ttl();
318        let now = Utc::now();
319        #[allow(clippy::arithmetic_side_effects)]
320        let cutoff = (now - ttl).to_rfc3339();
321        self.delete_expired_before(Table::LinkCache, "expires_at", &cutoff)
322            .and_then(|link_count| {
323                self.trim_to_recent(Table::ValidationHistory, "id", "checked_at", 1000).map(|_| {
324                    if link_count > 0 {
325                        info!("{} Cleaned up {link_count} expired cache entries", Label::run());
326                    }
327                    link_count
328                })
329            })
330    }
331    /// Clear all cache-related tables.
332    pub fn clear_cache(&self) -> ApiResult<usize> {
333        self.clear(Table::LinkCache).and_then(|link_count| {
334            self.clear(Table::ValidationHistory).and_then(|validation_count| {
335                self.clear(Table::ResearchActivityCache).map(|cache_count| {
336                    let total = link_count.saturating_add(validation_count).saturating_add(cache_count);
337                    info!("{} Cleared {total} entries from cache tables", Label::pass());
338                    total
339                })
340            })
341        })
342    }
343    /// Clear (reset) all data from all tables.
344    pub fn reset(&self) -> ApiResult<usize> {
345        T::all()
346            .iter()
347            .copied()
348            .try_fold(0usize, |total, table| self.clear(table).map(|count| total.saturating_add(count)))
349            .inspect(|&total| {
350                info!("{} Cleared {total} entries from all tables", Label::pass());
351            })
352    }
353}
354fn database_name() -> &'static str {
355    #[cfg(feature = "duckdb")]
356    {
357        "acorn.duckdb"
358    }
359    #[cfg(not(feature = "duckdb"))]
360    {
361        "acorn.db"
362    }
363}
364/// Resolve the effective database path from an explicit argument, environment variable, or project defaults.
365pub fn resolve_database_path(path: Option<&PathBuf>) -> ApiResult<PathBuf> {
366    let path = path
367        .cloned()
368        .or_else(|| env::var(DATABASE_PATH).ok().filter(|value| !value.trim().is_empty()).map(PathBuf::from));
369    match path {
370        | Some(path) => Ok(path),
371        | None => ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
372            .ok_or_else(|| eyre!("Failed to determine project directories"))
373            .map(|directories| directories.cache_dir().join("database").join(database_name())),
374    }
375    .and_then(|path| {
376        path.parent()
377            .map(PathBuf::from)
378            .ok_or_else(|| eyre!("Failed to determine database parent directory"))
379            .and_then(|parent| {
380                create_dir_all(&parent)
381                    .map_err(|why| eyre!("Failed to create database directory: {why}"))
382                    .map(|_| path)
383            })
384    })
385}
386
387#[cfg(test)]
388mod tests;