1use 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
32pub type QueryParams = backend::ParamsFromIter<Vec<Box<dyn backend::ToSql>>>;
34pub type SelectQuery = (String, QueryParams);
36#[derive(Clone, Debug)]
38pub enum PersistStatus {
39 Downloaded(usize),
41 AlreadyExists,
43}
44#[async_trait]
46pub trait Operations {
47 fn insert<R>(&self, value: R) -> ApiResult<usize>
49 where
50 R: Row;
51 async fn persist<P>(&self, data: Option<P>) -> ApiResult<usize>
53 where
54 P: DatabasePersistence + Send;
55 async fn populate(&self, table: impl TableSchemaProvider + Send) -> ApiResult<PersistStatus>;
59 fn select<T, P>(&self, value: P) -> ApiResult<Option<T>>
61 where
62 T: Into<Table>,
63 P: Params;
64}
65pub trait Row: RowMetadata {
67 fn table(&self) -> Table;
69 fn fields() -> &'static [&'static str]
71 where
72 Self: Sized,
73 {
74 <Self as RowMetadata>::fields()
75 }
76 fn insert(self, _conn: &Connection) -> ApiResult<usize>
78 where
79 Self: Sized;
80 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 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 fn build_select_query(&self, base: &str) -> SelectQuery;
104 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}
114pub trait RowMetadata {
116 fn fields() -> &'static [&'static str]
118 where
119 Self: Sized;
120}
121#[async_trait]
123pub trait TableSchemaProvider: Copy + Into<Table> + 'static {
124 fn all() -> &'static [Self];
126 fn create_statement(&self) -> String;
128 fn name(&self) -> &'static str;
130 async fn populate(&self, path: Option<PathBuf>) -> ApiResult<usize>;
132 fn print(self, path: Option<PathBuf>);
134 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#[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 pub fn from_path(path: Option<PathBuf>) -> Self {
203 Self { path, marker: PhantomData }
204 }
205 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 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 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 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 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 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 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 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 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 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 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}
353pub 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;