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 diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
278 use serde_json::json;
279
280 const MIGRATIONS: EmbeddedMigrations = embed_migrations!("../../migrations");
281
282 async fn setup() -> SqliteReadModelStore {
283 let manager = ConnectionManager::<SqliteConnection>::new(":memory:");
284 let pool = Pool::builder().max_size(1).build(manager).expect("pool");
285 let mut conn = pool.get().expect("conn");
286 conn.run_pending_migrations(MIGRATIONS).expect("migrations");
287 drop(conn);
288 SqliteReadModelStore::with_pool(pool)
289 }
290
291 fn user_row(id: &str, name: &str, email: &str, version: i64) -> Row {
292 json!({
293 "id": id,
294 "name": name,
295 "email": email,
296 "password_hash": "$argon2id$v=19$m=19456,t=2,p=1$x$y",
297 "version": version,
298 })
299 }
300
301 #[tokio::test]
302 async fn test_upsert_inserts_then_reads() {
303 let store = setup().await;
304 store
305 .upsert(Upsert::new(
306 "users_view",
307 "u1",
308 user_row("u1", "Alice", "a@b.c", 1),
309 ))
310 .await
311 .unwrap();
312
313 let got = store.get("users_view", "u1").await.unwrap().unwrap();
314 assert_eq!(got["name"], "Alice");
315 assert_eq!(got["version"], 1);
316 }
317
318 #[tokio::test]
319 async fn test_upsert_advances_on_higher_version() {
320 let store = setup().await;
321 store
322 .upsert(Upsert::new(
323 "users_view",
324 "u1",
325 user_row("u1", "Alice", "a@b.c", 1),
326 ))
327 .await
328 .unwrap();
329 store
330 .upsert(Upsert::new(
331 "users_view",
332 "u1",
333 user_row("u1", "Alice2", "a@b.c", 2),
334 ))
335 .await
336 .unwrap();
337
338 let got = store.get("users_view", "u1").await.unwrap().unwrap();
339 assert_eq!(got["name"], "Alice2");
340 assert_eq!(got["version"], 2);
341 }
342
343 #[tokio::test]
344 async fn test_upsert_idempotent_on_lower_or_equal_version() {
345 let store = setup().await;
347 store
348 .upsert(Upsert::new(
349 "users_view",
350 "u1",
351 user_row("u1", "Alice2", "a@b.c", 2),
352 ))
353 .await
354 .unwrap();
355
356 store
358 .upsert(Upsert::new(
359 "users_view",
360 "u1",
361 user_row("u1", "Should not stick", "a@b.c", 2),
362 ))
363 .await
364 .unwrap();
365 store
367 .upsert(Upsert::new(
368 "users_view",
369 "u1",
370 user_row("u1", "Stale", "a@b.c", 1),
371 ))
372 .await
373 .unwrap();
374
375 let got = store.get("users_view", "u1").await.unwrap().unwrap();
376 assert_eq!(got["name"], "Alice2");
377 assert_eq!(got["version"], 2);
378 }
379
380 #[tokio::test]
381 async fn test_find_by_email_uses_index() {
382 let store = setup().await;
383 store
384 .upsert(Upsert::new(
385 "users_view",
386 "u1",
387 user_row("u1", "Alice", "a@b.c", 1),
388 ))
389 .await
390 .unwrap();
391 store
392 .upsert(Upsert::new(
393 "users_view",
394 "u2",
395 user_row("u2", "Bob", "b@b.c", 1),
396 ))
397 .await
398 .unwrap();
399
400 let hits = store
401 .find_by("users_view", "email", &json!("b@b.c"))
402 .await
403 .unwrap();
404 assert_eq!(hits.len(), 1);
405 assert_eq!(hits[0]["id"], "u2");
406 }
407
408 #[tokio::test]
409 async fn test_delete_and_truncate() {
410 let store = setup().await;
411 store
412 .upsert(Upsert::new(
413 "users_view",
414 "u1",
415 user_row("u1", "Alice", "a@b.c", 1),
416 ))
417 .await
418 .unwrap();
419 store
420 .upsert(Upsert::new(
421 "users_view",
422 "u2",
423 user_row("u2", "Bob", "b@b.c", 1),
424 ))
425 .await
426 .unwrap();
427
428 store.delete("users_view", "u1").await.unwrap();
429 assert!(store.get("users_view", "u1").await.unwrap().is_none());
430 assert_eq!(store.list("users_view").await.unwrap().len(), 1);
431
432 store.truncate("users_view").await.unwrap();
433 assert!(store.list("users_view").await.unwrap().is_empty());
434 }
435
436 #[tokio::test]
437 async fn test_email_unique_constraint_rejects_collision() {
438 let store = setup().await;
442 store
443 .upsert(Upsert::new(
444 "users_view",
445 "u1",
446 user_row("u1", "Alice", "x@y.z", 1),
447 ))
448 .await
449 .unwrap();
450 let err = store
451 .upsert(Upsert::new(
452 "users_view",
453 "u2",
454 user_row("u2", "Bob", "x@y.z", 1),
455 ))
456 .await
457 .unwrap_err();
458 assert!(
459 matches!(err, ReadModelError::WriteFailed { ref message } if message.contains("UNIQUE")),
460 "expected UNIQUE constraint violation, got {err:?}"
461 );
462 }
463
464 #[tokio::test]
465 async fn test_table_name_validation_rejects_injection() {
466 let store = setup().await;
467 let err = store
468 .get("users_view; DROP TABLE users_view", "u1")
469 .await
470 .unwrap_err();
471 assert!(
472 matches!(err, ReadModelError::Other { ref message } if message.contains("table name")),
473 "expected identifier rejection, got {err:?}"
474 );
475 }
476}