Skip to main content

armature_admin/
data.rs

1//! Pluggable data source for the admin interface.
2//!
3//! The admin dashboard does not ship an ORM. Instead, it renders whatever a
4//! [`DataSource`] hands back. Applications wire their own persistence layer by
5//! implementing this trait; the crate ships an [`InMemoryDataSource`] stub that
6//! is used by default (and in tests) so the generated router is fully
7//! functional out of the box.
8
9use crate::error::AdminError;
10use crate::model::ModelDefinition;
11use async_trait::async_trait;
12use parking_lot::RwLock;
13use serde_json::Value;
14use std::collections::HashMap;
15
16/// A resolved query for a list view.
17///
18/// `order_by` entries are pre-rendered `"field ASC"` / `"field DESC"` clauses
19/// (see [`crate::model::OrderingField::as_sql`] and
20/// [`crate::SortOrder::as_sql`]) so a SQL-backed data source can splice them
21/// directly, while the in-memory stub parses the leading field name.
22#[derive(Debug, Clone, Default)]
23pub struct DataQuery {
24    /// Zero-based row offset.
25    pub offset: usize,
26    /// Maximum number of rows to return.
27    ///
28    /// A value of `0` is the "no limit" sentinel: it means "return every
29    /// matching row" (used by CSV export), **not** "return zero rows". Every
30    /// [`DataSource`] implementation MUST honor this convention — a SQL-backed
31    /// source must omit the `LIMIT` clause entirely rather than emit `LIMIT 0`.
32    pub limit: usize,
33    /// Ordering clauses, most-significant first.
34    pub order_by: Vec<String>,
35    /// Free-text search query (matched against the model's `search_fields`).
36    pub search: Option<String>,
37    /// Exact-match filters (`field -> value`).
38    pub filters: HashMap<String, String>,
39}
40
41/// A page of rows plus the unpaginated total.
42#[derive(Debug, Clone, Default)]
43pub struct DataPage {
44    /// The rows for the requested page (each a JSON object).
45    pub rows: Vec<Value>,
46    /// Total number of rows matching the query, ignoring pagination.
47    pub total: usize,
48}
49
50/// Pluggable backing store for admin models.
51///
52/// All methods take the [`ModelDefinition`] so a single implementation can
53/// serve every registered model.
54#[async_trait]
55pub trait DataSource: Send + Sync {
56    /// Fetch a page of rows for a list view.
57    ///
58    /// # Note
59    ///
60    /// A [`DataQuery::limit`] of `0` means "no limit / return all matching
61    /// rows", not "return zero rows". Implementations MUST honor this: a
62    /// SQL-backed source must omit the `LIMIT` clause when `limit == 0` rather
63    /// than translate it to `LIMIT 0`.
64    async fn list(&self, model: &ModelDefinition, query: &DataQuery) -> DataPage;
65
66    /// Fetch a single record by primary-key value.
67    async fn get(&self, model: &ModelDefinition, id: &str) -> Option<Value>;
68
69    /// Total number of records for a model (used by the dashboard summaries).
70    async fn count(&self, model: &ModelDefinition) -> usize;
71
72    /// Create a record, returning its primary-key value.
73    async fn create(&self, model: &ModelDefinition, data: Value) -> Result<String, AdminError>;
74
75    /// Update an existing record.
76    async fn update(
77        &self,
78        model: &ModelDefinition,
79        id: &str,
80        data: Value,
81    ) -> Result<(), AdminError>;
82
83    /// Delete a record.
84    async fn delete(&self, model: &ModelDefinition, id: &str) -> Result<(), AdminError>;
85}
86
87/// An in-memory [`DataSource`] used as the default backing store.
88///
89/// Rows are keyed by model name; each row is a JSON object. This is a real,
90/// fully-functional store (create/read/update/delete, search, filter,
91/// ordering and pagination) — just not a persistent one.
92#[derive(Default)]
93pub struct InMemoryDataSource {
94    tables: RwLock<HashMap<String, Vec<Value>>>,
95}
96
97impl InMemoryDataSource {
98    /// Create an empty store.
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Seed a row for a model (test/setup helper). The row must be a JSON
104    /// object; a primary key is generated if absent.
105    pub fn seed(&self, model_name: impl Into<String>, mut row: Value) {
106        let model_name = model_name.into();
107        if let Value::Object(map) = &mut row
108            && !map.contains_key("id")
109        {
110            map.insert(
111                "id".to_string(),
112                Value::String(uuid::Uuid::new_v4().to_string()),
113            );
114        }
115        self.tables.write().entry(model_name).or_default().push(row);
116    }
117
118    /// Number of rows currently stored for a model.
119    pub fn len(&self, model_name: &str) -> usize {
120        self.tables
121            .read()
122            .get(model_name)
123            .map(|v| v.len())
124            .unwrap_or(0)
125    }
126
127    fn pk_of(model: &ModelDefinition, row: &Value) -> Option<String> {
128        row.get(&model.primary_key).map(value_to_plain_string)
129    }
130}
131
132#[async_trait]
133impl DataSource for InMemoryDataSource {
134    async fn list(&self, model: &ModelDefinition, query: &DataQuery) -> DataPage {
135        let tables = self.tables.read();
136        let all = match tables.get(&model.name) {
137            Some(rows) => rows,
138            None => return DataPage::default(),
139        };
140
141        // Filter (exact match) + search (substring over search_fields).
142        let mut matched: Vec<Value> = all
143            .iter()
144            .filter(|row| {
145                query.filters.iter().all(|(field, want)| {
146                    row.get(field)
147                        .map(|v| value_to_plain_string(v) == *want)
148                        .unwrap_or(false)
149                })
150            })
151            .filter(|row| match &query.search {
152                None => true,
153                Some(needle) => {
154                    let needle = needle.to_lowercase();
155                    model.search_fields.iter().any(|field| {
156                        row.get(field)
157                            .map(|v| value_to_plain_string(v).to_lowercase().contains(&needle))
158                            .unwrap_or(false)
159                    })
160                }
161            })
162            .cloned()
163            .collect();
164
165        // Ordering: honor the leading clause ("field ASC"/"field DESC").
166        if let Some(first) = query.order_by.first() {
167            let mut parts = first.split_whitespace();
168            if let Some(field) = parts.next() {
169                let descending = parts.next().map(|d| d.eq_ignore_ascii_case("DESC")) == Some(true);
170                matched.sort_by(|a, b| {
171                    let av = a.get(field).map(value_to_plain_string).unwrap_or_default();
172                    let bv = b.get(field).map(value_to_plain_string).unwrap_or_default();
173                    if descending { bv.cmp(&av) } else { av.cmp(&bv) }
174                });
175            }
176        }
177
178        let total = matched.len();
179        let rows = if query.limit == 0 {
180            matched.into_iter().skip(query.offset).collect()
181        } else {
182            matched
183                .into_iter()
184                .skip(query.offset)
185                .take(query.limit)
186                .collect()
187        };
188        DataPage { rows, total }
189    }
190
191    async fn get(&self, model: &ModelDefinition, id: &str) -> Option<Value> {
192        let tables = self.tables.read();
193        tables.get(&model.name).and_then(|rows| {
194            rows.iter()
195                .find(|row| Self::pk_of(model, row).as_deref() == Some(id))
196                .cloned()
197        })
198    }
199
200    async fn count(&self, model: &ModelDefinition) -> usize {
201        self.len(&model.name)
202    }
203
204    async fn create(&self, model: &ModelDefinition, mut data: Value) -> Result<String, AdminError> {
205        let map = data
206            .as_object_mut()
207            .ok_or_else(|| AdminError::Validation("record must be a JSON object".to_string()))?;
208
209        let id = match map.get(&model.primary_key) {
210            Some(v) if !v.is_null() => value_to_plain_string(v),
211            _ => {
212                let id = uuid::Uuid::new_v4().to_string();
213                map.insert(model.primary_key.clone(), Value::String(id.clone()));
214                id
215            }
216        };
217
218        self.tables
219            .write()
220            .entry(model.name.clone())
221            .or_default()
222            .push(data);
223        Ok(id)
224    }
225
226    async fn update(
227        &self,
228        model: &ModelDefinition,
229        id: &str,
230        data: Value,
231    ) -> Result<(), AdminError> {
232        let mut tables = self.tables.write();
233        let rows = tables
234            .get_mut(&model.name)
235            .ok_or_else(|| AdminError::RecordNotFound {
236                model: model.name.clone(),
237                id: id.to_string(),
238            })?;
239
240        let slot = rows
241            .iter_mut()
242            .find(|row| Self::pk_of(model, row).as_deref() == Some(id))
243            .ok_or_else(|| AdminError::RecordNotFound {
244                model: model.name.clone(),
245                id: id.to_string(),
246            })?;
247
248        // Merge provided fields onto the existing record, preserving the PK.
249        if let (Some(existing), Some(incoming)) = (slot.as_object_mut(), data.as_object()) {
250            for (k, v) in incoming {
251                if k == &model.primary_key {
252                    continue;
253                }
254                existing.insert(k.clone(), v.clone());
255            }
256        } else {
257            *slot = data;
258        }
259        Ok(())
260    }
261
262    async fn delete(&self, model: &ModelDefinition, id: &str) -> Result<(), AdminError> {
263        let mut tables = self.tables.write();
264        let rows = tables
265            .get_mut(&model.name)
266            .ok_or_else(|| AdminError::RecordNotFound {
267                model: model.name.clone(),
268                id: id.to_string(),
269            })?;
270
271        let before = rows.len();
272        rows.retain(|row| Self::pk_of(model, row).as_deref() != Some(id));
273        if rows.len() == before {
274            return Err(AdminError::RecordNotFound {
275                model: model.name.clone(),
276                id: id.to_string(),
277            });
278        }
279        Ok(())
280    }
281}
282
283/// Render a JSON scalar to a plain (unescaped) string for comparison/keys.
284pub(crate) fn value_to_plain_string(value: &Value) -> String {
285    match value {
286        Value::String(s) => s.clone(),
287        Value::Null => String::new(),
288        other => other.to_string(),
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::field::{FieldDefinition, FieldType};
296
297    fn user_model() -> ModelDefinition {
298        ModelDefinition::builder("user")
299            .id_field()
300            .field(FieldDefinition::new("name", FieldType::String).searchable())
301            .search_fields(["name"])
302            .list_display(["id", "name"])
303            .build()
304    }
305
306    #[tokio::test]
307    async fn stub_list_paginates_and_totals() {
308        let ds = InMemoryDataSource::new();
309        let model = user_model();
310        for i in 0..5 {
311            ds.seed(
312                "user",
313                serde_json::json!({ "id": i, "name": format!("u{i}") }),
314            );
315        }
316
317        let page = ds
318            .list(
319                &model,
320                &DataQuery {
321                    offset: 0,
322                    limit: 2,
323                    ..Default::default()
324                },
325            )
326            .await;
327
328        assert_eq!(page.rows.len(), 2, "limit must cap returned rows");
329        assert_eq!(page.total, 5, "total must ignore pagination");
330    }
331
332    #[tokio::test]
333    async fn stub_crud_roundtrip() {
334        let ds = InMemoryDataSource::new();
335        let model = user_model();
336
337        let id = ds
338            .create(&model, serde_json::json!({ "id": "7", "name": "Alice" }))
339            .await
340            .unwrap();
341        assert_eq!(id, "7");
342        assert_eq!(ds.get(&model, "7").await.unwrap()["name"], "Alice");
343
344        ds.update(&model, "7", serde_json::json!({ "name": "Bob" }))
345            .await
346            .unwrap();
347        assert_eq!(ds.get(&model, "7").await.unwrap()["name"], "Bob");
348
349        ds.delete(&model, "7").await.unwrap();
350        assert!(ds.get(&model, "7").await.is_none());
351    }
352
353    #[tokio::test]
354    async fn stub_search_filters_rows() {
355        let ds = InMemoryDataSource::new();
356        let model = user_model();
357        ds.seed("user", serde_json::json!({ "id": 1, "name": "Alice" }));
358        ds.seed("user", serde_json::json!({ "id": 2, "name": "Bob" }));
359
360        let page = ds
361            .list(
362                &model,
363                &DataQuery {
364                    search: Some("ali".to_string()),
365                    ..Default::default()
366                },
367            )
368            .await;
369        assert_eq!(page.total, 1);
370        assert_eq!(page.rows[0]["name"], "Alice");
371    }
372}