Skip to main content

adminx_mongo/
lib.rs

1// adminx-mongo/src/lib.rs
2//
3// MongoDB storage backend for adminx-core. Implements the same `Storage` trait
4// the SeaORM backend does, so a resource written once runs unchanged on SQL or
5// Mongo — the only difference is which `init` the app calls at startup.
6//
7// Note: Mongo's primary key is `_id`. This backend maps the default `"id"` onto
8// `_id` on both the query side (`id_filter`) and the read side (`doc_to_json`
9// exposes `_id` as `id`), so a resource written for SQL runs unchanged on Mongo
10// — no `primary_key()` override needed. Setting `primary_key() = "_id"`
11// explicitly also works.
12
13mod convert;
14
15use adminx_core::storage::{
16    set_storage, CreateOutcome, FilterClause, FilterOp, ListPage, QueryOptions, Storage,
17    StorageError,
18};
19use async_trait::async_trait;
20use futures_util::stream::TryStreamExt;
21use mongodb::bson::{doc, to_document, Bson, Document};
22use mongodb::options::FindOptions;
23use mongodb::{Client, Collection, Database};
24use serde_json::{Map, Value};
25
26use convert::{bson_to_json, doc_to_json, id_filter, json_map_to_doc};
27
28/// MongoDB-backed storage. Cheap to clone (the client is `Arc` internally).
29pub struct MongoStorage {
30    db: Database,
31}
32
33impl MongoStorage {
34    pub fn new(db: Database) -> Self {
35        Self { db }
36    }
37
38    fn collection(&self, name: &str) -> Collection<Document> {
39        self.db.collection::<Document>(name)
40    }
41}
42
43/// Connect to MongoDB and return a ready storage handle.
44pub async fn connect(uri: &str, db_name: &str) -> Result<MongoStorage, mongodb::error::Error> {
45    let client = Client::with_uri_str(uri).await?;
46    let db = client.database(db_name);
47    tracing::info!("✅ adminx-mongo connected to database '{}'", db_name);
48    Ok(MongoStorage::new(db))
49}
50
51/// Convenience: connect and register as the global adminx storage backend.
52pub async fn init(uri: &str, db_name: &str) -> Result<(), mongodb::error::Error> {
53    let storage = connect(uri, db_name).await?;
54    set_storage(Box::new(storage));
55    Ok(())
56}
57
58/// Connect and run a batch of **Mongo command documents** (JSON strings, e.g.
59/// `{"insert":"products","documents":[{...}]}`), in order, returning the total
60/// affected document count. Self-contained — no `set_storage` needed. Used by
61/// `adminx seed` and by app startup code.
62pub async fn seed(uri: &str, db_name: &str, statements: &[&str]) -> Result<u64, StorageError> {
63    let store = connect(uri, db_name).await.map_err(map_err)?;
64    let mut total = 0u64;
65    for stmt in statements {
66        total += store.execute_raw(stmt).await?;
67    }
68    Ok(total)
69}
70
71fn map_err(e: mongodb::error::Error) -> StorageError {
72    StorageError::Backend(e.to_string())
73}
74
75#[async_trait]
76impl Storage for MongoStorage {
77    async fn list(&self, table: &str, opts: &QueryOptions) -> Result<ListPage, StorageError> {
78        let collection = self.collection(table);
79        let query = build_filter_doc(&opts.filters);
80
81        let total = collection
82            .count_documents(query.clone(), None)
83            .await
84            .map_err(map_err)?;
85
86        let sort = opts.sort_by.as_ref().map(|col| {
87            let dir = if opts.sort_desc { -1i32 } else { 1i32 };
88            doc! { col: dir }
89        });
90        let find_options = FindOptions::builder()
91            .skip(Some(opts.offset()))
92            .limit(Some(opts.per_page as i64))
93            .sort(sort)
94            .build();
95
96        let mut cursor = collection
97            .find(query, find_options)
98            .await
99            .map_err(map_err)?;
100
101        let mut rows = Vec::new();
102        while let Some(doc) = cursor.try_next().await.map_err(map_err)? {
103            rows.push(doc_to_json(doc));
104        }
105
106        Ok(ListPage { rows, total })
107    }
108
109    async fn get(&self, table: &str, pk: &str, id: &str) -> Result<Option<Value>, StorageError> {
110        let doc = self
111            .collection(table)
112            .find_one(id_filter(pk, id), None)
113            .await
114            .map_err(map_err)?;
115        Ok(doc.map(doc_to_json))
116    }
117
118    async fn find_one_by(
119        &self,
120        table: &str,
121        column: &str,
122        value: &str,
123    ) -> Result<Option<Value>, StorageError> {
124        let doc = self
125            .collection(table)
126            .find_one(doc! { column: value }, None)
127            .await
128            .map_err(map_err)?;
129        Ok(doc.map(doc_to_json))
130    }
131
132    async fn create(
133        &self,
134        table: &str,
135        data: Map<String, Value>,
136    ) -> Result<CreateOutcome, StorageError> {
137        let document = json_map_to_doc(data);
138        let res = self
139            .collection(table)
140            .insert_one(document, None)
141            .await
142            .map_err(map_err)?;
143
144        let last_insert_id = match bson_to_json(res.inserted_id) {
145            Value::String(s) => Some(s),
146            other => Some(other.to_string()),
147        };
148        Ok(CreateOutcome { last_insert_id })
149    }
150
151    async fn update(
152        &self,
153        table: &str,
154        pk: &str,
155        id: &str,
156        data: Map<String, Value>,
157    ) -> Result<u64, StorageError> {
158        let set = json_map_to_doc(data);
159        let res = self
160            .collection(table)
161            .update_one(id_filter(pk, id), doc! { "$set": set }, None)
162            .await
163            .map_err(map_err)?;
164        Ok(res.modified_count)
165    }
166
167    async fn delete(
168        &self,
169        table: &str,
170        pk: &str,
171        id: &str,
172        soft: bool,
173    ) -> Result<u64, StorageError> {
174        let collection = self.collection(table);
175        let filter = id_filter(pk, id);
176
177        if soft {
178            let res = collection
179                .update_one(filter, doc! { "$set": { "deleted": true } }, None)
180                .await
181                .map_err(map_err)?;
182            Ok(res.modified_count)
183        } else {
184            let res = collection.delete_one(filter, None).await.map_err(map_err)?;
185            Ok(res.deleted_count)
186        }
187    }
188
189    async fn execute_raw(&self, statement: &str) -> Result<u64, StorageError> {
190        // The statement is a JSON Mongo command document, e.g.
191        // {"insert":"products","documents":[{...}]}.
192        let value: Value = serde_json::from_str(statement)
193            .map_err(|e| StorageError::Backend(format!("invalid JSON command: {e}")))?;
194        let command = to_document(&value)
195            .map_err(|e| StorageError::Backend(format!("command is not a document: {e}")))?;
196        let res = self.db.run_command(command, None).await.map_err(map_err)?;
197        // Write commands report the affected count in `n`.
198        let n = res
199            .get_i64("n")
200            .ok()
201            .or_else(|| res.get_i32("n").ok().map(|v| v as i64))
202            .unwrap_or(0);
203        Ok(n.max(0) as u64)
204    }
205
206    async fn health(&self) -> bool {
207        self.db.run_command(doc! { "ping": 1 }, None).await.is_ok()
208    }
209}
210
211/// Coerce a filter's text value into a BSON scalar: `true`/`false` → bool,
212/// integer text → i64, everything else stays a string.
213fn filter_bson(v: &str) -> Bson {
214    match v {
215        "true" => Bson::Boolean(true),
216        "false" => Bson::Boolean(false),
217        _ => match v.parse::<i64>() {
218            Ok(i) => Bson::Int64(i),
219            Err(_) => Bson::String(v.to_owned()),
220        },
221    }
222}
223
224/// Build a Mongo query document from column filters. `Eq` matches exactly;
225/// `Contains` becomes a case-insensitive regex; `Gte`/`Lte` merge into a single
226/// range sub-document per field (so a date range yields `{$gte, $lte}`).
227fn build_filter_doc(filters: &[FilterClause]) -> Document {
228    let mut doc = Document::new();
229    for f in filters {
230        match f.op {
231            FilterOp::Eq => {
232                doc.insert(f.field.clone(), filter_bson(&f.value));
233            }
234            FilterOp::Contains => {
235                doc.insert(
236                    f.field.clone(),
237                    doc! { "$regex": f.value.clone(), "$options": "i" },
238                );
239            }
240            FilterOp::Gte | FilterOp::Lte => {
241                let key = if f.op == FilterOp::Gte { "$gte" } else { "$lte" };
242                // Merge into any existing range sub-document on this field.
243                let mut sub = doc
244                    .get_document(&f.field)
245                    .cloned()
246                    .unwrap_or_default();
247                sub.insert(key, filter_bson(&f.value));
248                doc.insert(f.field.clone(), sub);
249            }
250        }
251    }
252    doc
253}