1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use tracing::debug;
4
5pub trait StorageBackend: Send + Sync + std::fmt::Debug {
8 fn query(&self, sql: &str, params: &[QueryParam]) -> Result<Vec<Row>, StorageError>;
10
11 fn execute(&self, sql: &str, params: &[QueryParam]) -> Result<u64, StorageError>;
13
14 fn begin_transaction(&self) -> Result<(), StorageError>;
16
17 fn commit(&self) -> Result<(), StorageError>;
19
20 fn rollback(&self) -> Result<(), StorageError>;
22
23 fn backend_type(&self) -> &str;
25
26 fn is_healthy(&self) -> bool;
28}
29
30#[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#[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#[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#[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#[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#[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#[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 ¶ms {
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 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 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}