Skip to main content

roboticus_db/
backend.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use tracing::debug;
4
5/// Abstract storage backend trait.
6/// SQLite is the default; PostgreSQL is available as an opt-in alternative.
7pub trait StorageBackend: Send + Sync + std::fmt::Debug {
8    /// Execute a query that returns rows.
9    fn query(&self, sql: &str, params: &[QueryParam]) -> Result<Vec<Row>, StorageError>;
10
11    /// Execute a statement that modifies data (INSERT, UPDATE, DELETE).
12    fn execute(&self, sql: &str, params: &[QueryParam]) -> Result<u64, StorageError>;
13
14    /// Begin a transaction.
15    fn begin_transaction(&self) -> Result<(), StorageError>;
16
17    /// Commit the current transaction.
18    fn commit(&self) -> Result<(), StorageError>;
19
20    /// Rollback the current transaction.
21    fn rollback(&self) -> Result<(), StorageError>;
22
23    /// Get the backend type name.
24    fn backend_type(&self) -> &str;
25
26    /// Check if the backend is healthy/connected.
27    fn is_healthy(&self) -> bool;
28}
29
30/// A generic query parameter.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub enum QueryParam {
33    Text(String),
34    Integer(i64),
35    Real(f64),
36    Blob(Vec<u8>),
37    Null,
38}
39
40impl From<&str> for QueryParam {
41    fn from(s: &str) -> Self {
42        QueryParam::Text(s.to_string())
43    }
44}
45
46impl From<String> for QueryParam {
47    fn from(s: String) -> Self {
48        QueryParam::Text(s)
49    }
50}
51
52impl From<i64> for QueryParam {
53    fn from(v: i64) -> Self {
54        QueryParam::Integer(v)
55    }
56}
57
58impl From<f64> for QueryParam {
59    fn from(v: f64) -> Self {
60        QueryParam::Real(v)
61    }
62}
63
64/// A generic row from a query result.
65#[derive(Debug, Clone)]
66pub struct Row {
67    pub columns: HashMap<String, ColumnValue>,
68}
69
70impl Row {
71    pub fn new() -> Self {
72        Self {
73            columns: HashMap::new(),
74        }
75    }
76
77    pub fn get_text(&self, col: &str) -> Option<&str> {
78        match self.columns.get(col) {
79            Some(ColumnValue::Text(s)) => Some(s),
80            _ => None,
81        }
82    }
83
84    pub fn get_integer(&self, col: &str) -> Option<i64> {
85        match self.columns.get(col) {
86            Some(ColumnValue::Integer(v)) => Some(*v),
87            _ => None,
88        }
89    }
90
91    pub fn get_real(&self, col: &str) -> Option<f64> {
92        match self.columns.get(col) {
93            Some(ColumnValue::Real(v)) => Some(*v),
94            _ => None,
95        }
96    }
97
98    pub fn get_blob(&self, col: &str) -> Option<&[u8]> {
99        match self.columns.get(col) {
100            Some(ColumnValue::Blob(b)) => Some(b),
101            _ => None,
102        }
103    }
104
105    pub fn is_null(&self, col: &str) -> bool {
106        matches!(self.columns.get(col), Some(ColumnValue::Null) | None)
107    }
108}
109
110impl Default for Row {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116/// A column value in a row.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub enum ColumnValue {
119    Text(String),
120    Integer(i64),
121    Real(f64),
122    Blob(Vec<u8>),
123    Null,
124}
125
126/// Storage error type.
127#[derive(Debug, Clone, thiserror::Error)]
128#[error("{kind}: {message}")]
129pub struct StorageError {
130    pub kind: StorageErrorKind,
131    pub message: String,
132}
133
134impl StorageError {
135    pub fn new(kind: StorageErrorKind, message: impl Into<String>) -> Self {
136        Self {
137            message: message.into(),
138            kind,
139        }
140    }
141}
142
143impl From<StorageError> for roboticus_core::error::RoboticusError {
144    fn from(e: StorageError) -> Self {
145        Self::Database(e.to_string())
146    }
147}
148
149/// Categories of storage errors.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
151pub enum StorageErrorKind {
152    #[error("connection_failed")]
153    ConnectionFailed,
154    #[error("query_failed")]
155    QueryFailed,
156    #[error("transaction_failed")]
157    TransactionFailed,
158    #[error("constraint_violation")]
159    ConstraintViolation,
160    #[error("not_found")]
161    NotFound,
162    #[error("internal")]
163    Internal,
164}
165
166/// In-memory storage backend for testing.
167#[derive(Debug)]
168pub struct InMemoryBackend {
169    _tables: std::sync::Mutex<HashMap<String, Vec<Row>>>,
170    in_transaction: std::sync::atomic::AtomicBool,
171}
172
173impl InMemoryBackend {
174    pub fn new() -> Self {
175        Self {
176            _tables: std::sync::Mutex::new(HashMap::new()),
177            in_transaction: std::sync::atomic::AtomicBool::new(false),
178        }
179    }
180}
181
182impl Default for InMemoryBackend {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188impl StorageBackend for InMemoryBackend {
189    fn query(&self, sql: &str, _params: &[QueryParam]) -> Result<Vec<Row>, StorageError> {
190        debug!(sql, "in-memory query");
191        Ok(Vec::new())
192    }
193
194    fn execute(&self, sql: &str, _params: &[QueryParam]) -> Result<u64, StorageError> {
195        debug!(sql, "in-memory execute");
196        Ok(0)
197    }
198
199    fn begin_transaction(&self) -> Result<(), StorageError> {
200        self.in_transaction
201            .store(true, std::sync::atomic::Ordering::Release);
202        Ok(())
203    }
204
205    fn commit(&self) -> Result<(), StorageError> {
206        self.in_transaction
207            .store(false, std::sync::atomic::Ordering::Release);
208        Ok(())
209    }
210
211    fn rollback(&self) -> Result<(), StorageError> {
212        self.in_transaction
213            .store(false, std::sync::atomic::Ordering::Release);
214        Ok(())
215    }
216
217    fn backend_type(&self) -> &str {
218        "in-memory"
219    }
220
221    fn is_healthy(&self) -> bool {
222        true
223    }
224}
225
226/// Backend configuration.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct BackendConfig {
229    #[serde(default = "default_backend")]
230    pub backend: String,
231    #[serde(default)]
232    pub postgres_url: Option<String>,
233}
234
235fn default_backend() -> String {
236    "sqlite".to_string()
237}
238
239impl Default for BackendConfig {
240    fn default() -> Self {
241        Self {
242            backend: default_backend(),
243            postgres_url: None,
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn in_memory_backend_type() {
254        let backend = InMemoryBackend::new();
255        assert_eq!(backend.backend_type(), "in-memory");
256        assert!(backend.is_healthy());
257    }
258
259    #[test]
260    fn in_memory_query() {
261        let backend = InMemoryBackend::new();
262        let rows = backend.query("SELECT * FROM test", &[]).unwrap();
263        assert!(rows.is_empty());
264    }
265
266    #[test]
267    fn in_memory_execute() {
268        let backend = InMemoryBackend::new();
269        let affected = backend
270            .execute(
271                "INSERT INTO test VALUES (?)",
272                &[QueryParam::Text("hello".into())],
273            )
274            .unwrap();
275        assert_eq!(affected, 0);
276    }
277
278    #[test]
279    fn in_memory_transaction() {
280        let backend = InMemoryBackend::new();
281        backend.begin_transaction().unwrap();
282        backend.commit().unwrap();
283        backend.begin_transaction().unwrap();
284        backend.rollback().unwrap();
285    }
286
287    #[test]
288    fn row_accessors() {
289        let mut row = Row::new();
290        row.columns
291            .insert("name".into(), ColumnValue::Text("Alice".into()));
292        row.columns.insert("age".into(), ColumnValue::Integer(30));
293        row.columns.insert("score".into(), ColumnValue::Real(9.5));
294        row.columns
295            .insert("data".into(), ColumnValue::Blob(vec![1, 2, 3]));
296        row.columns.insert("empty".into(), ColumnValue::Null);
297
298        assert_eq!(row.get_text("name"), Some("Alice"));
299        assert_eq!(row.get_integer("age"), Some(30));
300        assert_eq!(row.get_real("score"), Some(9.5));
301        assert_eq!(row.get_blob("data"), Some([1, 2, 3].as_slice()));
302        assert!(row.is_null("empty"));
303        assert!(row.is_null("nonexistent"));
304    }
305
306    #[test]
307    fn row_missing_column() {
308        let row = Row::new();
309        assert!(row.get_text("missing").is_none());
310        assert!(row.get_integer("missing").is_none());
311    }
312
313    #[test]
314    fn query_param_from_conversions() {
315        let p1 = QueryParam::from("hello");
316        assert!(matches!(p1, QueryParam::Text(_)));
317
318        let p2 = QueryParam::from(42_i64);
319        assert!(matches!(p2, QueryParam::Integer(42)));
320
321        let p3 = QueryParam::from(2.72_f64);
322        assert!(matches!(p3, QueryParam::Real(_)));
323    }
324
325    #[test]
326    fn storage_error_display() {
327        let err = StorageError::new(StorageErrorKind::QueryFailed, "bad SQL");
328        assert!(err.to_string().contains("query_failed"));
329        assert!(err.to_string().contains("bad SQL"));
330    }
331
332    #[test]
333    fn storage_error_kind_display() {
334        assert_eq!(
335            format!("{}", StorageErrorKind::ConnectionFailed),
336            "connection_failed"
337        );
338        assert_eq!(format!("{}", StorageErrorKind::NotFound), "not_found");
339    }
340
341    #[test]
342    fn backend_config_defaults() {
343        let config = BackendConfig::default();
344        assert_eq!(config.backend, "sqlite");
345        assert!(config.postgres_url.is_none());
346    }
347
348    #[test]
349    fn query_param_serde() {
350        let params = vec![
351            QueryParam::Text("hello".into()),
352            QueryParam::Integer(42),
353            QueryParam::Real(2.72),
354            QueryParam::Null,
355        ];
356        for p in &params {
357            let json = serde_json::to_string(p).unwrap();
358            let back: QueryParam = serde_json::from_str(&json).unwrap();
359            assert!(matches!(
360                (&p, &back),
361                (QueryParam::Text(_), QueryParam::Text(_))
362                    | (QueryParam::Integer(_), QueryParam::Integer(_))
363                    | (QueryParam::Real(_), QueryParam::Real(_))
364                    | (QueryParam::Null, QueryParam::Null)
365            ));
366        }
367    }
368
369    #[test]
370    fn query_param_from_owned_string() {
371        let s = String::from("owned");
372        let p = QueryParam::from(s);
373        match p {
374            QueryParam::Text(t) => assert_eq!(t, "owned"),
375            _ => panic!("expected Text variant"),
376        }
377    }
378
379    #[test]
380    fn row_get_real_returns_none_for_wrong_type() {
381        let mut row = Row::new();
382        row.columns
383            .insert("name".into(), ColumnValue::Text("not a number".into()));
384        assert!(row.get_real("name").is_none());
385    }
386
387    #[test]
388    fn row_get_blob_returns_none_for_wrong_type() {
389        let mut row = Row::new();
390        row.columns.insert("count".into(), ColumnValue::Integer(42));
391        assert!(row.get_blob("count").is_none());
392    }
393
394    #[test]
395    fn storage_error_kind_display_all_variants() {
396        assert_eq!(
397            format!("{}", StorageErrorKind::ConnectionFailed),
398            "connection_failed"
399        );
400        assert_eq!(format!("{}", StorageErrorKind::QueryFailed), "query_failed");
401        assert_eq!(
402            format!("{}", StorageErrorKind::TransactionFailed),
403            "transaction_failed"
404        );
405        assert_eq!(
406            format!("{}", StorageErrorKind::ConstraintViolation),
407            "constraint_violation"
408        );
409        assert_eq!(format!("{}", StorageErrorKind::NotFound), "not_found");
410        assert_eq!(format!("{}", StorageErrorKind::Internal), "internal");
411    }
412
413    #[test]
414    fn in_memory_backend_default() {
415        let backend = InMemoryBackend::default();
416        assert_eq!(backend.backend_type(), "in-memory");
417        assert!(backend.is_healthy());
418    }
419
420    #[test]
421    fn row_default() {
422        let row = Row::default();
423        assert!(row.columns.is_empty());
424    }
425
426    #[test]
427    fn storage_error_is_error_trait() {
428        let err = StorageError::new(StorageErrorKind::Internal, "test error");
429        // Verify it implements std::error::Error (the method exists)
430        let _: &dyn std::error::Error = &err;
431        assert!(err.to_string().contains("internal"));
432        assert!(err.to_string().contains("test error"));
433    }
434
435    #[test]
436    fn backend_config_serde_roundtrip() {
437        let config = BackendConfig {
438            backend: "postgres".to_string(),
439            postgres_url: Some("postgres://localhost/test".to_string()),
440        };
441        let json = serde_json::to_string(&config).unwrap();
442        let back: BackendConfig = serde_json::from_str(&json).unwrap();
443        assert_eq!(back.backend, "postgres");
444        assert_eq!(
445            back.postgres_url.as_deref(),
446            Some("postgres://localhost/test")
447        );
448    }
449
450    #[test]
451    fn backend_config_default_serde() {
452        // Test deserialization with empty object uses defaults
453        let config: BackendConfig = serde_json::from_str("{}").unwrap();
454        assert_eq!(config.backend, "sqlite");
455        assert!(config.postgres_url.is_none());
456    }
457
458    #[test]
459    fn query_param_blob_variant() {
460        let p = QueryParam::Blob(vec![1, 2, 3]);
461        let json = serde_json::to_string(&p).unwrap();
462        let back: QueryParam = serde_json::from_str(&json).unwrap();
463        assert!(matches!(back, QueryParam::Blob(_)));
464    }
465}