zakat_sqlite/
sqlite.rs

1//! SQLite-based persistence for Zakat Ledger events.
2//!
3//! This module provides a production-ready `SqliteStore` implementation
4//! that persists ledger events to a SQLite database using `sqlx`.
5
6use async_trait::async_trait;
7use sqlx::{sqlite::SqlitePoolOptions, Row, SqlitePool};
8
9use zakat_core::types::{InvalidInputDetails, WealthType, ZakatError};
10use zakat_ledger::events::{LedgerEvent, TransactionType};
11use crate::persistence::LedgerStore;
12
13/// A SQLite-backed implementation of `LedgerStore`.
14///
15/// Uses connection pooling via `sqlx::SqlitePool` for efficient concurrent access.
16pub struct SqliteStore {
17    pool: SqlitePool,
18}
19
20impl SqliteStore {
21    /// Creates a new SQLite store and ensures the schema is initialized.
22    ///
23    /// # Arguments
24    /// * `db_url` - SQLite connection URL (e.g., `"sqlite::memory:"` or `"sqlite:ledger.db?mode=rwc"`)
25    ///
26    /// # Errors
27    /// Returns `ZakatError::NetworkError` if connection or migration fails.
28    pub async fn new(db_url: &str) -> Result<Self, ZakatError> {
29        let pool = SqlitePoolOptions::new()
30            .max_connections(5)
31            .connect(db_url)
32            .await
33            .map_err(|e| ZakatError::NetworkError(format!("SQLite connection error: {}", e)))?;
34
35        let store = Self { pool };
36        store.migrate().await?;
37        Ok(store)
38    }
39
40    /// Creates a new `SqliteStore` from an existing pool.
41    ///
42    /// Note: This does NOT run migrations. Use `new()` for automatic schema setup.
43    pub fn from_pool(pool: SqlitePool) -> Self {
44        Self { pool }
45    }
46
47    /// Runs database migrations to ensure the schema exists.
48    pub async fn migrate(&self) -> Result<(), ZakatError> {
49        // 1. Create migrations table
50        sqlx::query("CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY)")
51            .execute(&self.pool)
52            .await
53            .map_err(|e| ZakatError::NetworkError(format!("Migration init error: {}", e)))?;
54
55        // 2. Get current version
56        let current_version: Option<i32> = sqlx::query_scalar("SELECT MAX(version) FROM _migrations")
57            .fetch_optional(&self.pool)
58            .await
59            .map_err(|e| ZakatError::NetworkError(format!("Migration version check error: {}", e)))?;
60            
61        let version = current_version.unwrap_or(0);
62
63        // 3. Apply migrations
64        if version < 1 {
65            sqlx::query(
66                r#"
67                CREATE TABLE IF NOT EXISTS ledger_events (
68                    id TEXT PRIMARY KEY NOT NULL,
69                    date TEXT NOT NULL,
70                    amount TEXT NOT NULL,
71                    asset_type TEXT NOT NULL,
72                    transaction_type TEXT NOT NULL,
73                    description TEXT
74                )
75                "#,
76            )
77            .execute(&self.pool)
78            .await
79            .map_err(|e| ZakatError::NetworkError(format!("Migration v1 error: {}", e)))?;
80            
81            sqlx::query("INSERT INTO _migrations (version) VALUES (1)")
82                .execute(&self.pool)
83                .await
84                .map_err(|e| ZakatError::NetworkError(format!("Migration v1 version update error: {}", e)))?;
85        }
86
87        Ok(())
88    }
89
90    /// Returns a reference to the underlying connection pool.
91    pub fn pool(&self) -> &SqlitePool {
92        &self.pool
93    }
94}
95
96#[async_trait]
97impl LedgerStore for SqliteStore {
98    async fn save_event(&self, event: &LedgerEvent) -> Result<(), ZakatError> {
99        let id = event.id.to_string();
100        let date = event.date.format("%Y-%m-%d").to_string();
101        let amount = event.amount.to_string();
102        
103        // Serialize enums to JSON for safe storage
104        let asset_type = serde_json::to_string(&event.asset_type)
105            .map_err(|e| make_serialize_error("asset_type", &e.to_string()))?;
106        let transaction_type = serde_json::to_string(&event.transaction_type)
107            .map_err(|e| make_serialize_error("transaction_type", &e.to_string()))?;
108
109        sqlx::query(
110            r#"
111            INSERT INTO ledger_events (id, date, amount, asset_type, transaction_type, description)
112            VALUES (?, ?, ?, ?, ?, ?)
113            "#,
114        )
115        .bind(&id)
116        .bind(&date)
117        .bind(&amount)
118        .bind(&asset_type)
119        .bind(&transaction_type)
120        .bind(&event.description)
121        .execute(&self.pool)
122        .await
123        .map_err(|e| ZakatError::NetworkError(format!("SQLite insert error: {}", e)))?;
124
125        Ok(())
126    }
127
128    async fn load_events(&self) -> Result<Vec<LedgerEvent>, ZakatError> {
129        let rows = sqlx::query(
130            r#"
131            SELECT id, date, amount, asset_type, transaction_type, description
132            FROM ledger_events
133            ORDER BY date ASC
134            "#,
135        )
136        .fetch_all(&self.pool)
137        .await
138        .map_err(|e| ZakatError::NetworkError(format!("SQLite query error: {}", e)))?;
139
140        let mut events = Vec::with_capacity(rows.len());
141
142        for row in rows {
143            let id_str: String = row.get("id");
144            let date_str: String = row.get("date");
145            let amount_str: String = row.get("amount");
146            let asset_type_str: String = row.get("asset_type");
147            let transaction_type_str: String = row.get("transaction_type");
148            let description: Option<String> = row.get("description");
149
150            let id = uuid::Uuid::parse_str(&id_str)
151                .map_err(|e| make_parse_error("id", &id_str, &e.to_string()))?;
152
153            let date = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
154                .map_err(|e| make_parse_error("date", &date_str, &e.to_string()))?;
155
156            let amount = rust_decimal::Decimal::from_str_exact(&amount_str)
157                .map_err(|e| make_parse_error("amount", &amount_str, &e.to_string()))?;
158
159            let asset_type: WealthType = serde_json::from_str(&asset_type_str)
160                .map_err(|e| make_parse_error("asset_type", &asset_type_str, &e.to_string()))?;
161
162            let transaction_type: TransactionType = serde_json::from_str(&transaction_type_str)
163                .map_err(|e| make_parse_error("transaction_type", &transaction_type_str, &e.to_string()))?;
164
165            events.push(LedgerEvent {
166                id,
167                date,
168                amount,
169                asset_type,
170                transaction_type,
171                description,
172            });
173        }
174
175        Ok(events)
176    }
177}
178
179fn make_serialize_error(field: &str, error: &str) -> ZakatError {
180    ZakatError::InvalidInput(Box::new(InvalidInputDetails {
181        code: zakat_core::types::ZakatErrorCode::InvalidInput,
182        field: field.to_string(),
183        value: "serialize".to_string(),
184        reason_key: "error-serialize".to_string(),
185        args: Some(std::collections::HashMap::from([
186            ("error".to_string(), error.to_string()),
187        ])),
188        source_label: Some("SqliteStore".to_string()),
189        ..Default::default()
190    }))
191}
192
193fn make_parse_error(field: &str, value: &str, error: &str) -> ZakatError {
194    ZakatError::InvalidInput(Box::new(InvalidInputDetails {
195        code: zakat_core::types::ZakatErrorCode::InvalidInput,
196        field: field.to_string(),
197        value: value.to_string(),
198        reason_key: "error-parse".to_string(),
199        args: Some(std::collections::HashMap::from([
200            ("error".to_string(), error.to_string()),
201        ])),
202        source_label: Some("SqliteStore".to_string()),
203        ..Default::default()
204    }))
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use chrono::NaiveDate;
211    use rust_decimal_macros::dec;
212
213    #[tokio::test]
214    async fn test_sqlite_store_roundtrip() {
215        let store = SqliteStore::new("sqlite::memory:")
216            .await
217            .expect("Failed to connect to in-memory SQLite");
218
219        let event = LedgerEvent::new(
220            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
221            dec!(5000.50),
222            WealthType::Business,
223            TransactionType::Deposit,
224            Some("Initial deposit".to_string()),
225        );
226
227        store.save_event(&event).await.expect("Failed to save event");
228
229        let loaded = store.load_events().await.expect("Failed to load events");
230        assert_eq!(loaded.len(), 1);
231        assert_eq!(loaded[0].id, event.id);
232        assert_eq!(loaded[0].date, event.date);
233        assert_eq!(loaded[0].amount, event.amount);
234        assert_eq!(loaded[0].asset_type, event.asset_type);
235        assert_eq!(loaded[0].transaction_type, event.transaction_type);
236        assert_eq!(loaded[0].description, event.description);
237    }
238
239    #[tokio::test]
240    async fn test_sqlite_store_ordered_by_date() {
241        let store = SqliteStore::new("sqlite::memory:")
242            .await
243            .expect("Failed to connect");
244
245        let event1 = LedgerEvent::new(
246            NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
247            dec!(1000),
248            WealthType::Gold,
249            TransactionType::Deposit,
250            None,
251        );
252
253        let event2 = LedgerEvent::new(
254            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
255            dec!(2000),
256            WealthType::Silver,
257            TransactionType::Deposit,
258            None,
259        );
260
261        store.save_event(&event1).await.unwrap();
262        store.save_event(&event2).await.unwrap();
263
264        let loaded = store.load_events().await.unwrap();
265        assert_eq!(loaded.len(), 2);
266        assert_eq!(loaded[0].date, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
267        assert_eq!(loaded[1].date, NaiveDate::from_ymd_opt(2024, 3, 1).unwrap());
268    }
269
270    #[tokio::test]
271    async fn test_sqlite_store_wealth_type_other() {
272        let store = SqliteStore::new("sqlite::memory:")
273            .await
274            .expect("Failed to connect");
275
276        let event = LedgerEvent::new(
277            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
278            dec!(3000),
279            WealthType::Other("Cryptocurrency".to_string()),
280            TransactionType::Income,
281            Some("Bitcoin sale".to_string()),
282        );
283
284        store.save_event(&event).await.unwrap();
285        let loaded = store.load_events().await.unwrap();
286
287        assert_eq!(loaded.len(), 1);
288        assert_eq!(
289            loaded[0].asset_type,
290            WealthType::Other("Cryptocurrency".to_string())
291        );
292    }
293}