Skip to main content

acorn/io/database/
mod.rs

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