1use arc_core::read_model_store::{ReadModelError, ReadModelResult, ReadModelStore, Row, Upsert};
38use async_trait::async_trait;
39use diesel::prelude::*;
40use diesel::r2d2::{self, ConnectionManager};
41use diesel::sql_types::{BigInt, Nullable, Text};
42use diesel::sqlite::SqliteConnection;
43use std::sync::Arc;
44
45type Pool = r2d2::Pool<ConnectionManager<SqliteConnection>>;
46
47#[derive(Clone)]
49pub struct SqliteReadModelStore {
50 pool: Arc<Pool>,
51}
52
53impl SqliteReadModelStore {
54 pub async fn new(database_url: &str) -> ReadModelResult<Self> {
56 let manager = ConnectionManager::<SqliteConnection>::new(database_url);
57 let pool = Pool::builder().max_size(10).build(manager).map_err(|e| {
58 ReadModelError::other(format!("Failed to build read-model pool: {}", e))
59 })?;
60 Ok(SqliteReadModelStore {
61 pool: Arc::new(pool),
62 })
63 }
64
65 pub fn with_pool(pool: Pool) -> Self {
68 Self {
69 pool: Arc::new(pool),
70 }
71 }
72}
73
74#[derive(QueryableByName, Debug)]
75struct DataRow {
76 #[diesel(sql_type = Text)]
77 data: String,
78}
79
80fn check_ident(label: &str, ident: &str) -> ReadModelResult<()> {
86 if ident.is_empty() {
87 return Err(ReadModelError::other(format!("{label} cannot be empty")));
88 }
89 if !ident.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
90 return Err(ReadModelError::other(format!(
91 "invalid {label} '{ident}': only [A-Za-z0-9_] permitted"
92 )));
93 }
94 Ok(())
95}
96
97fn extract_version(row: &Row) -> ReadModelResult<i64> {
98 row.get("version").and_then(|v| v.as_i64()).ok_or_else(|| {
99 ReadModelError::write_failed(
100 "Upsert.row missing required i64 field 'version' for version-gated upsert",
101 )
102 })
103}
104
105fn parse_data_row(raw: &str) -> ReadModelResult<Row> {
106 serde_json::from_str(raw).map_err(|e| {
107 ReadModelError::query_failed(format!("Failed to parse projection row JSON: {e}"))
108 })
109}
110
111#[async_trait]
112impl ReadModelStore for SqliteReadModelStore {
113 async fn upsert(&self, op: Upsert) -> ReadModelResult<()> {
114 check_ident("table name", &op.table)?;
115 let version = extract_version(&op.row)?;
116 let data = serde_json::to_string(&op.row)
117 .map_err(|e| ReadModelError::write_failed(format!("Failed to serialize row: {e}")))?;
118 let pool = self.pool.clone();
119 let table = op.table.clone();
120 let key = op.key.clone();
121
122 tokio::task::spawn_blocking(move || -> ReadModelResult<()> {
123 let mut conn = pool.get().map_err(|e| {
124 ReadModelError::write_failed(format!("Failed to get connection: {e}"))
125 })?;
126
127 let sql = format!(
128 "INSERT INTO {table} (id, version, data) VALUES (?, ?, ?) \
129 ON CONFLICT(id) DO UPDATE SET version = excluded.version, data = excluded.data \
130 WHERE {table}.version < excluded.version"
131 );
132
133 diesel::sql_query(sql)
134 .bind::<Text, _>(key)
135 .bind::<BigInt, _>(version)
136 .bind::<Text, _>(data)
137 .execute(&mut *conn)
138 .map_err(|e| ReadModelError::write_failed(e.to_string()))?;
139 Ok(())
140 })
141 .await
142 .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))?
143 }
144
145 async fn delete(&self, table: &str, key: &str) -> ReadModelResult<()> {
146 check_ident("table name", table)?;
147 let pool = self.pool.clone();
148 let table = table.to_string();
149 let key = key.to_string();
150
151 tokio::task::spawn_blocking(move || -> ReadModelResult<()> {
152 let mut conn = pool.get().map_err(|e| {
153 ReadModelError::write_failed(format!("Failed to get connection: {e}"))
154 })?;
155 diesel::sql_query(format!("DELETE FROM {table} WHERE id = ?"))
156 .bind::<Text, _>(key)
157 .execute(&mut *conn)
158 .map_err(|e| ReadModelError::write_failed(e.to_string()))?;
159 Ok(())
160 })
161 .await
162 .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))?
163 }
164
165 async fn get(&self, table: &str, key: &str) -> ReadModelResult<Option<Row>> {
166 check_ident("table name", table)?;
167 let pool = self.pool.clone();
168 let table = table.to_string();
169 let key = key.to_string();
170
171 let raw = tokio::task::spawn_blocking(move || -> ReadModelResult<Option<String>> {
172 let mut conn = pool.get().map_err(|e| {
173 ReadModelError::query_failed(format!("Failed to get connection: {e}"))
174 })?;
175 let rows: Vec<DataRow> =
176 diesel::sql_query(format!("SELECT data FROM {table} WHERE id = ? LIMIT 1"))
177 .bind::<Text, _>(key)
178 .load(&mut *conn)
179 .map_err(|e| ReadModelError::query_failed(e.to_string()))?;
180 Ok(rows.into_iter().next().map(|r| r.data))
181 })
182 .await
183 .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))??;
184
185 match raw {
186 Some(s) => Ok(Some(parse_data_row(&s)?)),
187 None => Ok(None),
188 }
189 }
190
191 async fn find_by(
192 &self,
193 table: &str,
194 field: &str,
195 value: &serde_json::Value,
196 ) -> ReadModelResult<Vec<Row>> {
197 check_ident("table name", table)?;
198 check_ident("field name", field)?;
199 let pool = self.pool.clone();
200 let table = table.to_string();
201 let path = format!("$.{field}");
202
203 let bind_text: Option<String> = match value {
206 serde_json::Value::Null => None,
207 serde_json::Value::String(s) => Some(s.clone()),
208 serde_json::Value::Number(n) => Some(n.to_string()),
209 serde_json::Value::Bool(b) => Some(if *b { "1".into() } else { "0".into() }),
210 other => {
211 return Err(ReadModelError::query_failed(format!(
212 "find_by only accepts primitive values, got: {other}"
213 )))
214 }
215 };
216
217 let rows = tokio::task::spawn_blocking(move || -> ReadModelResult<Vec<String>> {
218 let mut conn = pool.get().map_err(|e| {
219 ReadModelError::query_failed(format!("Failed to get connection: {e}"))
220 })?;
221 let sql = format!("SELECT data FROM {table} WHERE json_extract(data, ?) IS ?");
222 let rows: Vec<DataRow> = diesel::sql_query(sql)
223 .bind::<Text, _>(path)
224 .bind::<Nullable<Text>, _>(bind_text)
225 .load(&mut *conn)
226 .map_err(|e| ReadModelError::query_failed(e.to_string()))?;
227 Ok(rows.into_iter().map(|r| r.data).collect())
228 })
229 .await
230 .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))??;
231
232 rows.iter().map(|r| parse_data_row(r)).collect()
233 }
234
235 async fn list(&self, table: &str) -> ReadModelResult<Vec<Row>> {
236 check_ident("table name", table)?;
237 let pool = self.pool.clone();
238 let table = table.to_string();
239
240 let rows = tokio::task::spawn_blocking(move || -> ReadModelResult<Vec<String>> {
241 let mut conn = pool.get().map_err(|e| {
242 ReadModelError::query_failed(format!("Failed to get connection: {e}"))
243 })?;
244 let rows: Vec<DataRow> = diesel::sql_query(format!("SELECT data FROM {table}"))
245 .load(&mut *conn)
246 .map_err(|e| ReadModelError::query_failed(e.to_string()))?;
247 Ok(rows.into_iter().map(|r| r.data).collect())
248 })
249 .await
250 .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))??;
251
252 rows.iter().map(|r| parse_data_row(r)).collect()
253 }
254
255 async fn truncate(&self, table: &str) -> ReadModelResult<()> {
256 check_ident("table name", table)?;
257 let pool = self.pool.clone();
258 let table = table.to_string();
259
260 tokio::task::spawn_blocking(move || -> ReadModelResult<()> {
261 let mut conn = pool.get().map_err(|e| {
262 ReadModelError::schema_failed(format!("Failed to get connection: {e}"))
263 })?;
264 diesel::sql_query(format!("DELETE FROM {table}"))
265 .execute(&mut *conn)
266 .map_err(|e| ReadModelError::schema_failed(e.to_string()))?;
267 Ok(())
268 })
269 .await
270 .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))?
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use serde_json::json;
278
279 async fn setup() -> SqliteReadModelStore {
280 let manager = ConnectionManager::<SqliteConnection>::new(":memory:");
281 let pool = Pool::builder().max_size(1).build(manager).expect("pool");
282 let mut conn = pool.get().expect("conn");
283 crate::test_support::migrate(&mut conn);
284 drop(conn);
285 SqliteReadModelStore::with_pool(pool)
286 }
287
288 fn user_row(id: &str, name: &str, email: &str, version: i64) -> Row {
289 json!({
290 "id": id,
291 "name": name,
292 "email": email,
293 "password_hash": "$argon2id$v=19$m=19456,t=2,p=1$x$y",
294 "version": version,
295 })
296 }
297
298 #[tokio::test]
299 async fn test_upsert_inserts_then_reads() {
300 let store = setup().await;
301 store
302 .upsert(Upsert::new(
303 "users_view",
304 "u1",
305 user_row("u1", "Alice", "a@b.c", 1),
306 ))
307 .await
308 .unwrap();
309
310 let got = store.get("users_view", "u1").await.unwrap().unwrap();
311 assert_eq!(got["name"], "Alice");
312 assert_eq!(got["version"], 1);
313 }
314
315 #[tokio::test]
316 async fn test_upsert_advances_on_higher_version() {
317 let store = setup().await;
318 store
319 .upsert(Upsert::new(
320 "users_view",
321 "u1",
322 user_row("u1", "Alice", "a@b.c", 1),
323 ))
324 .await
325 .unwrap();
326 store
327 .upsert(Upsert::new(
328 "users_view",
329 "u1",
330 user_row("u1", "Alice2", "a@b.c", 2),
331 ))
332 .await
333 .unwrap();
334
335 let got = store.get("users_view", "u1").await.unwrap().unwrap();
336 assert_eq!(got["name"], "Alice2");
337 assert_eq!(got["version"], 2);
338 }
339
340 #[tokio::test]
341 async fn test_upsert_idempotent_on_lower_or_equal_version() {
342 let store = setup().await;
344 store
345 .upsert(Upsert::new(
346 "users_view",
347 "u1",
348 user_row("u1", "Alice2", "a@b.c", 2),
349 ))
350 .await
351 .unwrap();
352
353 store
355 .upsert(Upsert::new(
356 "users_view",
357 "u1",
358 user_row("u1", "Should not stick", "a@b.c", 2),
359 ))
360 .await
361 .unwrap();
362 store
364 .upsert(Upsert::new(
365 "users_view",
366 "u1",
367 user_row("u1", "Stale", "a@b.c", 1),
368 ))
369 .await
370 .unwrap();
371
372 let got = store.get("users_view", "u1").await.unwrap().unwrap();
373 assert_eq!(got["name"], "Alice2");
374 assert_eq!(got["version"], 2);
375 }
376
377 #[tokio::test]
378 async fn test_find_by_email_uses_index() {
379 let store = setup().await;
380 store
381 .upsert(Upsert::new(
382 "users_view",
383 "u1",
384 user_row("u1", "Alice", "a@b.c", 1),
385 ))
386 .await
387 .unwrap();
388 store
389 .upsert(Upsert::new(
390 "users_view",
391 "u2",
392 user_row("u2", "Bob", "b@b.c", 1),
393 ))
394 .await
395 .unwrap();
396
397 let hits = store
398 .find_by("users_view", "email", &json!("b@b.c"))
399 .await
400 .unwrap();
401 assert_eq!(hits.len(), 1);
402 assert_eq!(hits[0]["id"], "u2");
403 }
404
405 #[tokio::test]
406 async fn test_delete_and_truncate() {
407 let store = setup().await;
408 store
409 .upsert(Upsert::new(
410 "users_view",
411 "u1",
412 user_row("u1", "Alice", "a@b.c", 1),
413 ))
414 .await
415 .unwrap();
416 store
417 .upsert(Upsert::new(
418 "users_view",
419 "u2",
420 user_row("u2", "Bob", "b@b.c", 1),
421 ))
422 .await
423 .unwrap();
424
425 store.delete("users_view", "u1").await.unwrap();
426 assert!(store.get("users_view", "u1").await.unwrap().is_none());
427 assert_eq!(store.list("users_view").await.unwrap().len(), 1);
428
429 store.truncate("users_view").await.unwrap();
430 assert!(store.list("users_view").await.unwrap().is_empty());
431 }
432
433 #[tokio::test]
434 async fn test_email_unique_constraint_rejects_collision() {
435 let store = setup().await;
439 store
440 .upsert(Upsert::new(
441 "users_view",
442 "u1",
443 user_row("u1", "Alice", "x@y.z", 1),
444 ))
445 .await
446 .unwrap();
447 let err = store
448 .upsert(Upsert::new(
449 "users_view",
450 "u2",
451 user_row("u2", "Bob", "x@y.z", 1),
452 ))
453 .await
454 .unwrap_err();
455 assert!(
456 matches!(err, ReadModelError::WriteFailed { ref message } if message.contains("UNIQUE")),
457 "expected UNIQUE constraint violation, got {err:?}"
458 );
459 }
460
461 #[tokio::test]
462 async fn test_table_name_validation_rejects_injection() {
463 let store = setup().await;
464 let err = store
465 .get("users_view; DROP TABLE users_view", "u1")
466 .await
467 .unwrap_err();
468 assert!(
469 matches!(err, ReadModelError::Other { ref message } if message.contains("table name")),
470 "expected identifier rejection, got {err:?}"
471 );
472 }
473}