use crate::error::AdminError;
use crate::model::ModelDefinition;
use async_trait::async_trait;
use parking_lot::RwLock;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct DataQuery {
pub offset: usize,
pub limit: usize,
pub order_by: Vec<String>,
pub search: Option<String>,
pub filters: HashMap<String, String>,
}
#[derive(Debug, Clone, Default)]
pub struct DataPage {
pub rows: Vec<Value>,
pub total: usize,
}
#[async_trait]
pub trait DataSource: Send + Sync {
async fn list(&self, model: &ModelDefinition, query: &DataQuery) -> DataPage;
async fn get(&self, model: &ModelDefinition, id: &str) -> Option<Value>;
async fn count(&self, model: &ModelDefinition) -> usize;
async fn create(&self, model: &ModelDefinition, data: Value) -> Result<String, AdminError>;
async fn update(
&self,
model: &ModelDefinition,
id: &str,
data: Value,
) -> Result<(), AdminError>;
async fn delete(&self, model: &ModelDefinition, id: &str) -> Result<(), AdminError>;
}
#[derive(Default)]
pub struct InMemoryDataSource {
tables: RwLock<HashMap<String, Vec<Value>>>,
}
impl InMemoryDataSource {
pub fn new() -> Self {
Self::default()
}
pub fn seed(&self, model_name: impl Into<String>, mut row: Value) {
let model_name = model_name.into();
if let Value::Object(map) = &mut row
&& !map.contains_key("id")
{
map.insert(
"id".to_string(),
Value::String(uuid::Uuid::new_v4().to_string()),
);
}
self.tables.write().entry(model_name).or_default().push(row);
}
pub fn len(&self, model_name: &str) -> usize {
self.tables
.read()
.get(model_name)
.map(|v| v.len())
.unwrap_or(0)
}
fn pk_of(model: &ModelDefinition, row: &Value) -> Option<String> {
row.get(&model.primary_key).map(value_to_plain_string)
}
}
#[async_trait]
impl DataSource for InMemoryDataSource {
async fn list(&self, model: &ModelDefinition, query: &DataQuery) -> DataPage {
let tables = self.tables.read();
let all = match tables.get(&model.name) {
Some(rows) => rows,
None => return DataPage::default(),
};
let mut matched: Vec<Value> = all
.iter()
.filter(|row| {
query.filters.iter().all(|(field, want)| {
row.get(field)
.map(|v| value_to_plain_string(v) == *want)
.unwrap_or(false)
})
})
.filter(|row| match &query.search {
None => true,
Some(needle) => {
let needle = needle.to_lowercase();
model.search_fields.iter().any(|field| {
row.get(field)
.map(|v| value_to_plain_string(v).to_lowercase().contains(&needle))
.unwrap_or(false)
})
}
})
.cloned()
.collect();
if let Some(first) = query.order_by.first() {
let mut parts = first.split_whitespace();
if let Some(field) = parts.next() {
let descending = parts.next().map(|d| d.eq_ignore_ascii_case("DESC")) == Some(true);
matched.sort_by(|a, b| {
let av = a.get(field).map(value_to_plain_string).unwrap_or_default();
let bv = b.get(field).map(value_to_plain_string).unwrap_or_default();
if descending { bv.cmp(&av) } else { av.cmp(&bv) }
});
}
}
let total = matched.len();
let rows = if query.limit == 0 {
matched.into_iter().skip(query.offset).collect()
} else {
matched
.into_iter()
.skip(query.offset)
.take(query.limit)
.collect()
};
DataPage { rows, total }
}
async fn get(&self, model: &ModelDefinition, id: &str) -> Option<Value> {
let tables = self.tables.read();
tables.get(&model.name).and_then(|rows| {
rows.iter()
.find(|row| Self::pk_of(model, row).as_deref() == Some(id))
.cloned()
})
}
async fn count(&self, model: &ModelDefinition) -> usize {
self.len(&model.name)
}
async fn create(&self, model: &ModelDefinition, mut data: Value) -> Result<String, AdminError> {
let map = data
.as_object_mut()
.ok_or_else(|| AdminError::Validation("record must be a JSON object".to_string()))?;
let id = match map.get(&model.primary_key) {
Some(v) if !v.is_null() => value_to_plain_string(v),
_ => {
let id = uuid::Uuid::new_v4().to_string();
map.insert(model.primary_key.clone(), Value::String(id.clone()));
id
}
};
self.tables
.write()
.entry(model.name.clone())
.or_default()
.push(data);
Ok(id)
}
async fn update(
&self,
model: &ModelDefinition,
id: &str,
data: Value,
) -> Result<(), AdminError> {
let mut tables = self.tables.write();
let rows = tables
.get_mut(&model.name)
.ok_or_else(|| AdminError::RecordNotFound {
model: model.name.clone(),
id: id.to_string(),
})?;
let slot = rows
.iter_mut()
.find(|row| Self::pk_of(model, row).as_deref() == Some(id))
.ok_or_else(|| AdminError::RecordNotFound {
model: model.name.clone(),
id: id.to_string(),
})?;
if let (Some(existing), Some(incoming)) = (slot.as_object_mut(), data.as_object()) {
for (k, v) in incoming {
if k == &model.primary_key {
continue;
}
existing.insert(k.clone(), v.clone());
}
} else {
*slot = data;
}
Ok(())
}
async fn delete(&self, model: &ModelDefinition, id: &str) -> Result<(), AdminError> {
let mut tables = self.tables.write();
let rows = tables
.get_mut(&model.name)
.ok_or_else(|| AdminError::RecordNotFound {
model: model.name.clone(),
id: id.to_string(),
})?;
let before = rows.len();
rows.retain(|row| Self::pk_of(model, row).as_deref() != Some(id));
if rows.len() == before {
return Err(AdminError::RecordNotFound {
model: model.name.clone(),
id: id.to_string(),
});
}
Ok(())
}
}
pub(crate) fn value_to_plain_string(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::field::{FieldDefinition, FieldType};
fn user_model() -> ModelDefinition {
ModelDefinition::builder("user")
.id_field()
.field(FieldDefinition::new("name", FieldType::String).searchable())
.search_fields(["name"])
.list_display(["id", "name"])
.build()
}
#[tokio::test]
async fn stub_list_paginates_and_totals() {
let ds = InMemoryDataSource::new();
let model = user_model();
for i in 0..5 {
ds.seed(
"user",
serde_json::json!({ "id": i, "name": format!("u{i}") }),
);
}
let page = ds
.list(
&model,
&DataQuery {
offset: 0,
limit: 2,
..Default::default()
},
)
.await;
assert_eq!(page.rows.len(), 2, "limit must cap returned rows");
assert_eq!(page.total, 5, "total must ignore pagination");
}
#[tokio::test]
async fn stub_crud_roundtrip() {
let ds = InMemoryDataSource::new();
let model = user_model();
let id = ds
.create(&model, serde_json::json!({ "id": "7", "name": "Alice" }))
.await
.unwrap();
assert_eq!(id, "7");
assert_eq!(ds.get(&model, "7").await.unwrap()["name"], "Alice");
ds.update(&model, "7", serde_json::json!({ "name": "Bob" }))
.await
.unwrap();
assert_eq!(ds.get(&model, "7").await.unwrap()["name"], "Bob");
ds.delete(&model, "7").await.unwrap();
assert!(ds.get(&model, "7").await.is_none());
}
#[tokio::test]
async fn stub_search_filters_rows() {
let ds = InMemoryDataSource::new();
let model = user_model();
ds.seed("user", serde_json::json!({ "id": 1, "name": "Alice" }));
ds.seed("user", serde_json::json!({ "id": 2, "name": "Bob" }));
let page = ds
.list(
&model,
&DataQuery {
search: Some("ali".to_string()),
..Default::default()
},
)
.await;
assert_eq!(page.total, 1);
assert_eq!(page.rows[0]["name"], "Alice");
}
}