use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
use thiserror::Error;
pub type Row = serde_json::Value;
#[derive(Debug, Error)]
pub enum ReadModelError {
#[error("Read model write failed: {message}")]
WriteFailed { message: String },
#[error("Read model query failed: {message}")]
QueryFailed { message: String },
#[error("Read model schema operation failed: {message}")]
SchemaFailed { message: String },
#[error("Read model error: {message}")]
Other { message: String },
}
impl ReadModelError {
pub fn write_failed(message: impl Into<String>) -> Self {
ReadModelError::WriteFailed {
message: message.into(),
}
}
pub fn query_failed(message: impl Into<String>) -> Self {
ReadModelError::QueryFailed {
message: message.into(),
}
}
pub fn schema_failed(message: impl Into<String>) -> Self {
ReadModelError::SchemaFailed {
message: message.into(),
}
}
pub fn other(message: impl Into<String>) -> Self {
ReadModelError::Other {
message: message.into(),
}
}
}
pub type ReadModelResult<T> = Result<T, ReadModelError>;
#[derive(Debug, Clone)]
pub struct Upsert {
pub table: String,
pub key: String,
pub row: Row,
}
impl Upsert {
pub fn new(table: impl Into<String>, key: impl Into<String>, row: Row) -> Self {
Self {
table: table.into(),
key: key.into(),
row,
}
}
}
#[async_trait]
pub trait ReadModelStore: Send + Sync {
async fn upsert(&self, op: Upsert) -> ReadModelResult<()>;
async fn delete(&self, table: &str, key: &str) -> ReadModelResult<()>;
async fn get(&self, table: &str, key: &str) -> ReadModelResult<Option<Row>>;
async fn find_by(
&self,
table: &str,
field: &str,
value: &serde_json::Value,
) -> ReadModelResult<Vec<Row>>;
async fn list(&self, table: &str) -> ReadModelResult<Vec<Row>>;
async fn truncate(&self, table: &str) -> ReadModelResult<()>;
}
pub struct InMemoryReadModelStore {
tables: Mutex<HashMap<String, HashMap<String, Row>>>,
}
impl InMemoryReadModelStore {
pub fn new() -> Self {
Self {
tables: Mutex::new(HashMap::new()),
}
}
pub fn get_rows(&self, table: &str) -> Vec<Row> {
self.tables
.lock()
.unwrap()
.get(table)
.map(|m| m.values().cloned().collect())
.unwrap_or_default()
}
pub fn total_rows(&self) -> usize {
self.tables.lock().unwrap().values().map(|m| m.len()).sum()
}
}
impl Default for InMemoryReadModelStore {
fn default() -> Self {
Self::new()
}
}
fn row_version(row: &Row) -> i64 {
row.get("version").and_then(|v| v.as_i64()).unwrap_or(0)
}
#[async_trait]
impl ReadModelStore for InMemoryReadModelStore {
async fn upsert(&self, op: Upsert) -> ReadModelResult<()> {
let mut tables = self.tables.lock().unwrap();
let table = tables.entry(op.table).or_default();
let incoming_version = row_version(&op.row);
let should_write = match table.get(&op.key) {
Some(existing) => row_version(existing) < incoming_version,
None => true,
};
if should_write {
table.insert(op.key, op.row);
}
Ok(())
}
async fn delete(&self, table: &str, key: &str) -> ReadModelResult<()> {
if let Some(t) = self.tables.lock().unwrap().get_mut(table) {
t.remove(key);
}
Ok(())
}
async fn get(&self, table: &str, key: &str) -> ReadModelResult<Option<Row>> {
Ok(self
.tables
.lock()
.unwrap()
.get(table)
.and_then(|t| t.get(key).cloned()))
}
async fn find_by(
&self,
table: &str,
field: &str,
value: &serde_json::Value,
) -> ReadModelResult<Vec<Row>> {
Ok(self
.tables
.lock()
.unwrap()
.get(table)
.map(|t| {
t.values()
.filter(|row| row.get(field) == Some(value))
.cloned()
.collect()
})
.unwrap_or_default())
}
async fn list(&self, table: &str) -> ReadModelResult<Vec<Row>> {
Ok(self.get_rows(table))
}
async fn truncate(&self, table: &str) -> ReadModelResult<()> {
self.tables.lock().unwrap().remove(table);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn row(id: &str, name: &str, version: i64) -> Row {
json!({"id": id, "name": name, "version": version})
}
#[tokio::test]
async fn test_upsert_inserts_when_absent() {
let store = InMemoryReadModelStore::new();
store
.upsert(Upsert::new("users_view", "u1", row("u1", "Alice", 1)))
.await
.unwrap();
let got = store.get("users_view", "u1").await.unwrap().unwrap();
assert_eq!(got["name"], "Alice");
}
#[tokio::test]
async fn test_upsert_replaces_when_version_advances() {
let store = InMemoryReadModelStore::new();
store
.upsert(Upsert::new("users_view", "u1", row("u1", "Alice", 1)))
.await
.unwrap();
store
.upsert(Upsert::new("users_view", "u1", row("u1", "Alice2", 2)))
.await
.unwrap();
let got = store.get("users_view", "u1").await.unwrap().unwrap();
assert_eq!(got["name"], "Alice2");
assert_eq!(got["version"], 2);
}
#[tokio::test]
async fn test_upsert_skips_when_version_stale() {
let store = InMemoryReadModelStore::new();
store
.upsert(Upsert::new("users_view", "u1", row("u1", "Alice2", 2)))
.await
.unwrap();
store
.upsert(Upsert::new("users_view", "u1", row("u1", "Alice1", 1)))
.await
.unwrap();
let got = store.get("users_view", "u1").await.unwrap().unwrap();
assert_eq!(got["name"], "Alice2");
assert_eq!(got["version"], 2);
}
#[tokio::test]
async fn test_find_by_returns_matching_rows() {
let store = InMemoryReadModelStore::new();
store
.upsert(Upsert::new(
"users_view",
"u1",
json!({"id": "u1", "email": "a@b.c", "version": 1}),
))
.await
.unwrap();
store
.upsert(Upsert::new(
"users_view",
"u2",
json!({"id": "u2", "email": "x@y.z", "version": 1}),
))
.await
.unwrap();
let hits = store
.find_by("users_view", "email", &json!("a@b.c"))
.await
.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0]["id"], "u1");
}
#[tokio::test]
async fn test_delete_and_truncate() {
let store = InMemoryReadModelStore::new();
store
.upsert(Upsert::new("users_view", "u1", row("u1", "Alice", 1)))
.await
.unwrap();
store
.upsert(Upsert::new("users_view", "u2", row("u2", "Bob", 1)))
.await
.unwrap();
store.delete("users_view", "u1").await.unwrap();
assert!(store.get("users_view", "u1").await.unwrap().is_none());
assert_eq!(store.list("users_view").await.unwrap().len(), 1);
store.truncate("users_view").await.unwrap();
assert_eq!(store.total_rows(), 0);
}
}