Skip to main content

rskit_database/
database.rs

1//! Vendor-neutral database client contracts and the in-memory backend.
2
3use std::collections::VecDeque;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::time::Instant;
6
7use async_trait::async_trait;
8use parking_lot::Mutex;
9use rskit_bootstrap::{Component, Health};
10use rskit_errors::{AppError, AppResult, ErrorCode};
11use serde::{Deserialize, Serialize};
12
13use crate::config::{DatabaseConfig, MemoryDatabaseConfig};
14
15/// Backend-neutral database statement.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct DatabaseQuery {
18    /// Statement text in the backend's query language.
19    pub statement: String,
20    /// Positional parameters encoded as backend-neutral JSON values.
21    #[serde(default)]
22    pub parameters: Vec<serde_json::Value>,
23}
24
25impl DatabaseQuery {
26    /// Create a query without positional parameters.
27    #[must_use]
28    pub fn new(statement: impl Into<String>) -> Self {
29        Self {
30            statement: statement.into(),
31            parameters: Vec::new(),
32        }
33    }
34
35    /// Add one positional parameter.
36    #[must_use]
37    pub fn with_parameter(mut self, value: impl Into<serde_json::Value>) -> Self {
38        self.parameters.push(value.into());
39        self
40    }
41}
42
43/// Backend-neutral database execution result.
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45pub struct DatabaseResult {
46    /// Rows affected by the statement, when the backend reports it.
47    pub rows_affected: u64,
48}
49
50/// Active database transaction.
51#[async_trait]
52pub trait DatabaseTransaction: Send + Sync {
53    /// Execute a statement inside the transaction.
54    async fn execute(&self, query: DatabaseQuery) -> AppResult<DatabaseResult>;
55    /// Commit the transaction.
56    async fn commit(self: Box<Self>) -> AppResult<()>;
57    /// Roll back the transaction.
58    async fn rollback(self: Box<Self>) -> AppResult<()>;
59}
60
61/// Vendor-neutral database client.
62#[async_trait]
63pub trait DatabaseClient: Send + Sync {
64    /// Execute a statement.
65    async fn execute(&self, query: DatabaseQuery) -> AppResult<DatabaseResult>;
66    /// Begin a transaction.
67    async fn begin(&self) -> AppResult<Box<dyn DatabaseTransaction>>;
68    /// Validate that the backend is usable.
69    async fn ping(&self) -> AppResult<()>;
70}
71
72/// In-memory database backend for local development and tests.
73#[derive(Debug)]
74pub struct InMemoryDatabase {
75    config: MemoryDatabaseConfig,
76    connected: AtomicBool,
77    history: Mutex<VecDeque<DatabaseQuery>>,
78}
79
80impl InMemoryDatabase {
81    /// Create an in-memory database backend.
82    #[must_use]
83    pub fn new(config: MemoryDatabaseConfig) -> Self {
84        Self {
85            config,
86            connected: AtomicBool::new(true),
87            history: Mutex::new(VecDeque::new()),
88        }
89    }
90
91    /// Return recorded statements retained by this backend.
92    #[must_use]
93    pub fn recorded_queries(&self) -> Vec<DatabaseQuery> {
94        self.history.lock().iter().cloned().collect()
95    }
96
97    fn record(&self, query: DatabaseQuery) {
98        if self.config.statement_history == 0 {
99            return;
100        }
101        let mut history = self.history.lock();
102        if history.len() == self.config.statement_history {
103            history.pop_front();
104        }
105        history.push_back(query);
106    }
107}
108
109impl Default for InMemoryDatabase {
110    fn default() -> Self {
111        Self::new(MemoryDatabaseConfig::default())
112    }
113}
114
115#[async_trait]
116impl DatabaseClient for InMemoryDatabase {
117    async fn execute(&self, query: DatabaseQuery) -> AppResult<DatabaseResult> {
118        if !self.connected.load(Ordering::SeqCst) {
119            return Err(AppError::new(
120                ErrorCode::ConnectionFailed,
121                "in-memory database is stopped",
122            ));
123        }
124        if query.statement.trim().is_empty() {
125            return Err(AppError::new(
126                ErrorCode::InvalidInput,
127                "database query statement is required",
128            ));
129        }
130        self.record(query);
131        Ok(DatabaseResult { rows_affected: 1 })
132    }
133
134    async fn begin(&self) -> AppResult<Box<dyn DatabaseTransaction>> {
135        self.ping().await?;
136        Ok(Box::new(InMemoryTransaction {
137            started_at: Instant::now(),
138            queries: Mutex::new(Vec::new()),
139            committed: AtomicBool::new(false),
140            rolled_back: AtomicBool::new(false),
141        }))
142    }
143
144    async fn ping(&self) -> AppResult<()> {
145        if self.connected.load(Ordering::SeqCst) {
146            Ok(())
147        } else {
148            Err(AppError::new(
149                ErrorCode::ConnectionFailed,
150                "in-memory database is stopped",
151            ))
152        }
153    }
154}
155
156#[async_trait]
157impl Component for InMemoryDatabase {
158    fn name(&self) -> &str {
159        "database"
160    }
161
162    async fn start(&self) -> AppResult<()> {
163        self.connected.store(true, Ordering::SeqCst);
164        Ok(())
165    }
166
167    async fn stop(&self) -> AppResult<()> {
168        self.connected.store(false, Ordering::SeqCst);
169        Ok(())
170    }
171
172    fn health(&self) -> Health {
173        if self.connected.load(Ordering::SeqCst) {
174            Health::healthy("database")
175        } else {
176            Health::unhealthy("database", "backend is stopped")
177        }
178    }
179}
180
181#[derive(Debug)]
182struct InMemoryTransaction {
183    started_at: Instant,
184    queries: Mutex<Vec<DatabaseQuery>>,
185    committed: AtomicBool,
186    rolled_back: AtomicBool,
187}
188
189#[async_trait]
190impl DatabaseTransaction for InMemoryTransaction {
191    async fn execute(&self, query: DatabaseQuery) -> AppResult<DatabaseResult> {
192        if query.statement.trim().is_empty() {
193            return Err(AppError::new(
194                ErrorCode::InvalidInput,
195                "database query statement is required",
196            ));
197        }
198        self.queries.lock().push(query);
199        Ok(DatabaseResult { rows_affected: 1 })
200    }
201
202    async fn commit(self: Box<Self>) -> AppResult<()> {
203        if self.rolled_back.load(Ordering::SeqCst) {
204            return Err(AppError::new(
205                ErrorCode::InvalidInput,
206                "database transaction was already rolled back",
207            ));
208        }
209        self.committed.store(true, Ordering::SeqCst);
210        let _elapsed = self.started_at.elapsed();
211        Ok(())
212    }
213
214    async fn rollback(self: Box<Self>) -> AppResult<()> {
215        if self.committed.load(Ordering::SeqCst) {
216            return Err(AppError::new(
217                ErrorCode::InvalidInput,
218                "database transaction was already committed",
219            ));
220        }
221        self.rolled_back.store(true, Ordering::SeqCst);
222        Ok(())
223    }
224}
225
226pub(crate) fn memory_from_config(config: &DatabaseConfig) -> InMemoryDatabase {
227    InMemoryDatabase::new(config.memory.clone())
228}
229
230#[cfg(test)]
231mod tests {
232    use std::sync::atomic::AtomicBool;
233
234    use parking_lot::Mutex;
235    use rskit_bootstrap::Component;
236
237    use super::*;
238
239    #[tokio::test]
240    async fn in_memory_database_records_bounded_history_and_component_state() {
241        let database = InMemoryDatabase::new(MemoryDatabaseConfig {
242            name: "test".to_owned(),
243            statement_history: 1,
244        });
245
246        assert_eq!(database.name(), "database");
247        database
248            .execute(DatabaseQuery::new("select 1").with_parameter(1))
249            .await
250            .unwrap();
251        database
252            .execute(DatabaseQuery::new("select 2"))
253            .await
254            .unwrap();
255        let recorded = database.recorded_queries();
256        assert_eq!(recorded.len(), 1);
257        assert_eq!(recorded[0].statement, "select 2");
258        assert!(database.health().is_healthy());
259
260        database.stop().await.unwrap();
261        assert_eq!(
262            database.ping().await.unwrap_err().code(),
263            ErrorCode::ConnectionFailed
264        );
265        assert_eq!(
266            database
267                .execute(DatabaseQuery::new("select 3"))
268                .await
269                .unwrap_err()
270                .code(),
271            ErrorCode::ConnectionFailed
272        );
273        assert!(!database.health().is_healthy());
274
275        database.start().await.unwrap();
276        assert_eq!(
277            database
278                .execute(DatabaseQuery::new("   "))
279                .await
280                .unwrap_err()
281                .code(),
282            ErrorCode::InvalidInput
283        );
284    }
285
286    #[tokio::test]
287    async fn in_memory_transactions_validate_execute_and_guard_terminal_state() {
288        let tx = InMemoryDatabase::default().begin().await.unwrap();
289        assert_eq!(
290            tx.execute(DatabaseQuery::new(" "))
291                .await
292                .unwrap_err()
293                .code(),
294            ErrorCode::InvalidInput
295        );
296        assert_eq!(
297            tx.execute(DatabaseQuery::new("insert"))
298                .await
299                .unwrap()
300                .rows_affected,
301            1
302        );
303        tx.commit().await.unwrap();
304
305        let committed = Box::new(InMemoryTransaction {
306            started_at: Instant::now(),
307            queries: Mutex::new(Vec::new()),
308            committed: AtomicBool::new(true),
309            rolled_back: AtomicBool::new(false),
310        });
311        assert_eq!(
312            committed.rollback().await.unwrap_err().code(),
313            ErrorCode::InvalidInput
314        );
315
316        let rolled_back = Box::new(InMemoryTransaction {
317            started_at: Instant::now(),
318            queries: Mutex::new(Vec::new()),
319            committed: AtomicBool::new(false),
320            rolled_back: AtomicBool::new(true),
321        });
322        assert_eq!(
323            rolled_back.commit().await.unwrap_err().code(),
324            ErrorCode::InvalidInput
325        );
326
327        InMemoryDatabase::default()
328            .begin()
329            .await
330            .unwrap()
331            .rollback()
332            .await
333            .unwrap();
334    }
335
336    #[test]
337    fn zero_statement_history_disables_recording() {
338        let database = InMemoryDatabase::new(MemoryDatabaseConfig {
339            name: "test".to_owned(),
340            statement_history: 0,
341        });
342
343        database.record(DatabaseQuery::new("select"));
344
345        assert!(database.recorded_queries().is_empty());
346    }
347}