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}