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 std::fmt::Display for StorageError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            StorageError::NotFound => f.write_str("not found"),
74            StorageError::Backend(m) => write!(f, "storage backend error: {m}"),
75        }
76    }
77}
78
79// So `storage()`/`reload()`/`rbac::init(..)` results work with `?` in a
80// `main() -> Result<(), Box<dyn Error>>` and integrate with error libraries.
81impl std::error::Error for StorageError {}
82
83impl From<StorageError> for CoreError {
84    fn from(e: StorageError) -> Self {
85        match e {
86            StorageError::NotFound => CoreError::NotFound,
87            StorageError::Backend(m) => CoreError::Internal(m),
88        }
89    }
90}
91
92#[async_trait]
93pub trait Storage: Send + Sync {
94    async fn list(&self, table: &str, opts: &QueryOptions) -> Result<ListPage, StorageError>;
95
96    async fn get(&self, table: &str, pk: &str, id: &str) -> Result<Option<Value>, StorageError>;
97
98    /// Fetch the first row where `column = value`. Used by auth to look up an
99    /// admin user by email. Backends that don't support it inherit the default
100    /// (unsupported); SQL/Mongo backends override it.
101    async fn find_one_by(
102        &self,
103        _table: &str,
104        _column: &str,
105        _value: &str,
106    ) -> Result<Option<Value>, StorageError> {
107        Err(StorageError::Backend(
108            "find_one_by not supported by this storage backend".into(),
109        ))
110    }
111
112    async fn create(
113        &self,
114        table: &str,
115        data: Map<String, Value>,
116    ) -> Result<CreateOutcome, StorageError>;
117
118    /// Returns the number of affected rows.
119    async fn update(
120        &self,
121        table: &str,
122        pk: &str,
123        id: &str,
124        data: Map<String, Value>,
125    ) -> Result<u64, StorageError>;
126
127    /// `soft = true` should set a `deleted` flag rather than removing the row.
128    /// Returns the number of affected rows.
129    async fn delete(
130        &self,
131        table: &str,
132        pk: &str,
133        id: &str,
134        soft: bool,
135    ) -> Result<u64, StorageError>;
136
137    /// Execute a raw, backend-specific statement — **SQL** for SeaORM (e.g. an
138    /// `INSERT`/`CREATE TABLE`), or a **JSON command document** for Mongo (e.g.
139    /// `{"insert":"products","documents":[{...}]}`). Intended for seeding and
140    /// migrations. Returns the number of affected records where the backend
141    /// reports it. Backends that don't support it inherit this error default.
142    async fn execute_raw(&self, _statement: &str) -> Result<u64, StorageError> {
143        Err(StorageError::Backend(
144            "execute_raw not supported by this storage backend".into(),
145        ))
146    }
147
148    async fn health(&self) -> bool;
149}
150
151static STORAGE: OnceCell<Box<dyn Storage>> = OnceCell::new();
152
153/// Register the global storage backend. Call once during startup.
154pub fn set_storage(storage: Box<dyn Storage>) {
155    if STORAGE.set(storage).is_err() {
156        tracing::warn!("adminx storage backend was already initialized; ignoring reset");
157    }
158}
159
160/// Access the global storage backend. Panics if never initialized.
161pub fn storage() -> &'static dyn Storage {
162    STORAGE
163        .get()
164        .expect("adminx storage backend not initialized; call set_storage() first")
165        .as_ref()
166}
167
168/// Seed the database by running a batch of raw statements against the active
169/// backend, in order, stopping at the first error. Write **SQL** when using a
170/// SeaORM backend, or **JSON command documents** when using Mongo — the same
171/// call works for both, you just author for whichever backend you registered.
172///
173/// ```ignore
174/// // SeaORM / Postgres:
175/// adminx::seed(&[
176///     "INSERT INTO categories (name, slug) VALUES ('Books','books') ON CONFLICT DO NOTHING",
177/// ]).await?;
178///
179/// // Mongo:
180/// adminx::seed(&[
181///     r#"{"insert":"categories","documents":[{"name":"Books","slug":"books"}]}"#,
182/// ]).await?;
183/// ```
184pub async fn seed(statements: &[&str]) -> Result<(), StorageError> {
185    let s = storage();
186    for stmt in statements {
187        s.execute_raw(stmt).await?;
188    }
189    Ok(())
190}