1use 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
27pub type QueryParams = backend::ParamsFromIter<Vec<Box<dyn backend::ToSql>>>;
29pub type SelectQuery = (String, QueryParams);
31#[derive(Clone, Debug)]
33pub enum PersistStatus {
34 Downloaded(usize),
36 AlreadyExists,
38}
39#[async_trait]
41pub trait Operations {
42 fn insert<R>(&self, value: R) -> ApiResult<usize>
44 where
45 R: Row;
46 async fn persist<P>(&self, data: Option<P>) -> ApiResult<usize>
48 where
49 P: DatabasePersistence + Send;
50 async fn populate(&self, table: impl TableSchemaProvider + Send) -> ApiResult<PersistStatus>;
54 fn select<T, P>(&self, value: P) -> ApiResult<Option<T>>
56 where
57 T: Into<Table>,
58 P: Params;
59}
60pub trait Row: RowMetadata {
62 fn table(&self) -> Table;
64 fn fields() -> &'static [&'static str]
66 where
67 Self: Sized,
68 {
69 <Self as RowMetadata>::fields()
70 }
71 fn insert(self, _conn: &Connection) -> ApiResult<usize>
73 where
74 Self: Sized;
75 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 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 fn build_select_query(&self, base: &str) -> SelectQuery;
99 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}
109pub trait RowMetadata {
111 fn fields() -> &'static [&'static str]
113 where
114 Self: Sized;
115}
116#[async_trait]
118pub trait TableSchemaProvider: Copy + Into<Table> + 'static {
119 fn all() -> &'static [Self];
121 fn create_statement(&self) -> String;
123 fn name(&self) -> &'static str;
125 async fn populate(&self) -> ApiResult<usize>;
127 fn print(self, path: Option<PathBuf>);
129 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#[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 pub fn from_path(path: Option<PathBuf>) -> Self {
198 Self { path, marker: PhantomData }
199 }
200 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 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 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 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 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 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 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 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 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 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 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}
364pub 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;