Skip to main content

adminx_core/
storage.rs

1// adminx-core/src/storage.rs
2//
3// The database abstraction. Every backend (SeaORM for SQL, Mongo, ...) is a
4// `Storage` implementation registered once, globally. Resource default CRUD is
5// written against this trait and never names a concrete database.
6
7use crate::error::CoreError;
8use async_trait::async_trait;
9use once_cell::sync::OnceCell;
10use serde_json::{Map, Value};
11
12/// A single column filter applied to a list query.
13#[derive(Debug, Clone)]
14pub struct FilterClause {
15    pub field: String,
16    pub op: FilterOp,
17    pub value: String,
18}
19
20/// How a [`FilterClause`] value is matched.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum FilterOp {
23    /// Exact match (`col = value`).
24    Eq,
25    /// Case-insensitive substring match (`col LIKE %value%`).
26    Contains,
27    /// Greater than or equal (`col >= value`), used for date-range "from".
28    Gte,
29    /// Less than or equal (`col <= value`), used for date-range "to".
30    Lte,
31}
32
33/// Pagination + ordering + filters distilled from a request query string.
34#[derive(Debug, Clone)]
35pub struct QueryOptions {
36    pub page: u64,
37    pub per_page: u64,
38    pub sort_by: Option<String>,
39    pub sort_desc: bool,
40    /// Active column filters (empty when none requested).
41    pub filters: Vec<FilterClause>,
42}
43
44impl QueryOptions {
45    pub fn offset(&self) -> u64 {
46        (self.page.max(1) - 1) * self.per_page
47    }
48}
49
50/// One page of rows plus the total count for pagination.
51#[derive(Debug, Clone)]
52pub struct ListPage {
53    pub rows: Vec<Value>,
54    pub total: u64,
55}
56
57/// Result of an insert. `last_insert_id` is backend-dependent (MySQL yields an
58/// autoincrement id; Postgres a RETURNING id when available).
59#[derive(Debug, Clone, Default)]
60pub struct CreateOutcome {
61    pub last_insert_id: Option<String>,
62}
63
64#[derive(Debug, Clone)]
65pub enum StorageError {
66    NotFound,
67    Backend(String),
68}
69
70impl From<StorageError> for CoreError {
71    fn from(e: StorageError) -> Self {
72        match e {
73            StorageError::NotFound => CoreError::NotFound,
74            StorageError::Backend(m) => CoreError::Internal(m),
75        }
76    }
77}
78
79#[async_trait]
80pub trait Storage: Send + Sync {
81    async fn list(&self, table: &str, opts: &QueryOptions) -> Result<ListPage, StorageError>;
82
83    async fn get(&self, table: &str, pk: &str, id: &str) -> Result<Option<Value>, StorageError>;
84
85    /// Fetch the first row where `column = value`. Used by auth to look up an
86    /// admin user by email. Backends that don't support it inherit the default
87    /// (unsupported); SQL/Mongo backends override it.
88    async fn find_one_by(
89        &self,
90        _table: &str,
91        _column: &str,
92        _value: &str,
93    ) -> Result<Option<Value>, StorageError> {
94        Err(StorageError::Backend(
95            "find_one_by not supported by this storage backend".into(),
96        ))
97    }
98
99    async fn create(
100        &self,
101        table: &str,
102        data: Map<String, Value>,
103    ) -> Result<CreateOutcome, StorageError>;
104
105    /// Returns the number of affected rows.
106    async fn update(
107        &self,
108        table: &str,
109        pk: &str,
110        id: &str,
111        data: Map<String, Value>,
112    ) -> Result<u64, StorageError>;
113
114    /// `soft = true` should set a `deleted` flag rather than removing the row.
115    /// Returns the number of affected rows.
116    async fn delete(
117        &self,
118        table: &str,
119        pk: &str,
120        id: &str,
121        soft: bool,
122    ) -> Result<u64, StorageError>;
123
124    /// Execute a raw, backend-specific statement — **SQL** for SeaORM (e.g. an
125    /// `INSERT`/`CREATE TABLE`), or a **JSON command document** for Mongo (e.g.
126    /// `{"insert":"products","documents":[{...}]}`). Intended for seeding and
127    /// migrations. Returns the number of affected records where the backend
128    /// reports it. Backends that don't support it inherit this error default.
129    async fn execute_raw(&self, _statement: &str) -> Result<u64, StorageError> {
130        Err(StorageError::Backend(
131            "execute_raw not supported by this storage backend".into(),
132        ))
133    }
134
135    async fn health(&self) -> bool;
136}
137
138static STORAGE: OnceCell<Box<dyn Storage>> = OnceCell::new();
139
140/// Register the global storage backend. Call once during startup.
141pub fn set_storage(storage: Box<dyn Storage>) {
142    if STORAGE.set(storage).is_err() {
143        tracing::warn!("adminx storage backend was already initialized; ignoring reset");
144    }
145}
146
147/// Access the global storage backend. Panics if never initialized.
148pub fn storage() -> &'static dyn Storage {
149    STORAGE
150        .get()
151        .expect("adminx storage backend not initialized; call set_storage() first")
152        .as_ref()
153}
154
155/// Seed the database by running a batch of raw statements against the active
156/// backend, in order, stopping at the first error. Write **SQL** when using a
157/// SeaORM backend, or **JSON command documents** when using Mongo — the same
158/// call works for both, you just author for whichever backend you registered.
159///
160/// ```ignore
161/// // SeaORM / Postgres:
162/// adminx::seed(&[
163///     "INSERT INTO categories (name, slug) VALUES ('Books','books') ON CONFLICT DO NOTHING",
164/// ]).await?;
165///
166/// // Mongo:
167/// adminx::seed(&[
168///     r#"{"insert":"categories","documents":[{"name":"Books","slug":"books"}]}"#,
169/// ]).await?;
170/// ```
171pub async fn seed(statements: &[&str]) -> Result<(), StorageError> {
172    let s = storage();
173    for stmt in statements {
174        s.execute_raw(stmt).await?;
175    }
176    Ok(())
177}