Skip to main content

vantage_mongodb/
operation.rs

1//! MongoDB-specific operation trait for building `MongoCondition` from typed columns.
2//!
3//! `MongoOperation<T>` is an extension trait blanket-implemented for all `Expressive<T>`
4//! types (including `Column<T>`, `Expression<T>`, scalars, etc). It produces
5//! `MongoCondition` (BSON documents) instead of `Expression<T>`.
6//!
7//! The field name is extracted from `self.expr().template` — works for simple
8//! column/identifier expressions. Complex left-hand expressions will produce
9//! the template string as the field name (MongoDB will treat it as a dotted path
10//! or literal field name).
11//!
12//! # Examples
13//!
14//! ```ignore
15//! use vantage_mongodb::operation::MongoOperation;
16//! use vantage_table::column::core::Column;
17//!
18//! let price = Column::<i64>::new("price");
19//! let cond = price.gt(100i64);
20//! // => MongoCondition::Doc(doc! { "price": { "$gt": 100 } })
21//!
22//! // Chaining works — MongoCondition implements Expressive<AnyMongoType>
23//! let cond = price.gt(10i64).eq(false);
24//! // => MongoCondition::Doc(doc! { "price": { "$not": { "$gt": 10 } } })
25//! ```
26
27use bson::{Bson, doc};
28use vantage_expressions::Expressive;
29
30use crate::condition::MongoCondition;
31use crate::types::{AnyMongoType, MongoType};
32
33/// Extract the field name from an `Expressive<T>` value.
34///
35/// For `Column<T>` this returns the column name (e.g. `"price"`).
36/// For complex expressions it returns the rendered template.
37fn field_name<T>(expr: &(impl Expressive<T> + ?Sized)) -> String {
38    expr.expr().template.clone()
39}
40
41/// Convert a value to `Bson` via `Into<AnyMongoType>` → `MongoType::to_bson()`.
42fn to_bson_val(value: impl Into<AnyMongoType>) -> Bson {
43    let any: AnyMongoType = value.into();
44    any.to_bson()
45}
46
47/// Negate a `MongoCondition` by wrapping each field condition with `$not`.
48fn negate(cond: MongoCondition) -> MongoCondition {
49    match cond {
50        MongoCondition::Doc(doc) => {
51            let mut negated = bson::Document::new();
52            for (key, val) in doc {
53                match val {
54                    // { field: { "$op": v } } → { field: { "$not": { "$op": v } } }
55                    Bson::Document(inner) => {
56                        negated.insert(key, doc! { "$not": inner });
57                    }
58                    // { field: v } → { field: { "$not": { "$eq": v } } }
59                    other => {
60                        negated.insert(key, doc! { "$not": { "$eq": other } });
61                    }
62                }
63            }
64            MongoCondition::Doc(negated)
65        }
66        MongoCondition::And(conditions) => {
67            MongoCondition::And(conditions.into_iter().map(negate).collect())
68        }
69        // Deferred can't be negated statically — pass through
70        other => other,
71    }
72}
73
74/// MongoDB-specific operations that produce `MongoCondition`.
75///
76/// Blanket-implemented for all `Expressive<T>` where values convert
77/// via `Into<AnyMongoType>`. Import this trait instead of
78/// `vantage_table::operation::Operation` when working with MongoDB.
79pub trait MongoOperation<T>: Expressive<T> {
80    /// `{ field: { "$eq": value } }`
81    ///
82    /// When called on a `MongoCondition`: `.eq(false)` negates, `.eq(true)` is identity.
83    fn eq(&self, value: impl Into<AnyMongoType>) -> MongoCondition
84    where
85        Self: Sized,
86    {
87        MongoCondition::Doc(doc! { field_name(self): { "$eq": to_bson_val(value) } })
88    }
89
90    /// `{ field: { "$ne": value } }`
91    fn ne(&self, value: impl Into<AnyMongoType>) -> MongoCondition
92    where
93        Self: Sized,
94    {
95        MongoCondition::Doc(doc! { field_name(self): { "$ne": to_bson_val(value) } })
96    }
97
98    /// `{ field: { "$gt": value } }`
99    fn gt(&self, value: impl Into<AnyMongoType>) -> MongoCondition
100    where
101        Self: Sized,
102    {
103        MongoCondition::Doc(doc! { field_name(self): { "$gt": to_bson_val(value) } })
104    }
105
106    /// `{ field: { "$gte": value } }`
107    fn gte(&self, value: impl Into<AnyMongoType>) -> MongoCondition
108    where
109        Self: Sized,
110    {
111        MongoCondition::Doc(doc! { field_name(self): { "$gte": to_bson_val(value) } })
112    }
113
114    /// `{ field: { "$lt": value } }`
115    fn lt(&self, value: impl Into<AnyMongoType>) -> MongoCondition
116    where
117        Self: Sized,
118    {
119        MongoCondition::Doc(doc! { field_name(self): { "$lt": to_bson_val(value) } })
120    }
121
122    /// `{ field: { "$lte": value } }`
123    fn lte(&self, value: impl Into<AnyMongoType>) -> MongoCondition
124    where
125        Self: Sized,
126    {
127        MongoCondition::Doc(doc! { field_name(self): { "$lte": to_bson_val(value) } })
128    }
129
130    /// `{ field: { "$in": [values...] } }`
131    fn in_<I, V>(&self, values: I) -> MongoCondition
132    where
133        Self: Sized,
134        I: IntoIterator<Item = V>,
135        V: Into<AnyMongoType>,
136    {
137        let arr: Vec<Bson> = values.into_iter().map(to_bson_val).collect();
138        MongoCondition::Doc(doc! { field_name(self): { "$in": arr } })
139    }
140
141    /// `{ field: null }` — matches documents where the field is null **or missing**.
142    /// Mongo's `$eq: null` semantics, which is what most callers want.
143    fn is_null(&self) -> MongoCondition
144    where
145        Self: Sized,
146    {
147        MongoCondition::Doc(doc! { field_name(self): Bson::Null })
148    }
149
150    /// `{ field: { "$ne": null } }` — matches documents where the field exists and is non-null.
151    fn is_not_null(&self) -> MongoCondition
152    where
153        Self: Sized,
154    {
155        MongoCondition::Doc(doc! { field_name(self): { "$ne": Bson::Null } })
156    }
157}
158
159/// Blanket: any `Expressive<T>` gets `MongoOperation<T>` for free.
160impl<T, S: Expressive<T>> MongoOperation<T> for S {}
161
162// ── MongoCondition chaining ──────────────────────────────────────────
163//
164// MongoCondition implements Expressive<AnyMongoType> so the blanket above
165// gives it MongoOperation<AnyMongoType>. We override the default methods
166// to handle boolean logic on conditions rather than building field docs.
167
168impl Expressive<AnyMongoType> for MongoCondition {
169    fn expr(&self) -> vantage_expressions::Expression<AnyMongoType> {
170        // MongoCondition isn't really an expression — this is a bridge
171        // so the blanket trait bound is satisfied.
172        vantage_expressions::Expression::new(format!("{:?}", self), vec![])
173    }
174}
175
176impl MongoCondition {
177    /// `.eq(false)` negates the condition; `.eq(true)` is identity.
178    /// This is the MongoCondition-aware version that overrides the blanket.
179    pub fn eq_bool(&self, value: bool) -> MongoCondition {
180        if value {
181            self.clone()
182        } else {
183            negate(self.clone())
184        }
185    }
186
187    /// `.ne(false)` is identity; `.ne(true)` negates.
188    pub fn ne_bool(&self, value: bool) -> MongoCondition {
189        if value {
190            negate(self.clone())
191        } else {
192            self.clone()
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use vantage_table::column::core::Column;
201
202    #[test]
203    fn test_column_eq() {
204        let name = Column::<String>::new("name");
205        let cond = name.eq("Alice");
206        match cond {
207            MongoCondition::Doc(doc) => {
208                assert_eq!(doc, doc! { "name": { "$eq": "Alice" } });
209            }
210            _ => panic!("expected Doc"),
211        }
212    }
213
214    #[test]
215    fn test_column_gt() {
216        let price = Column::<i64>::new("price");
217        let cond = price.gt(100i64);
218        match cond {
219            MongoCondition::Doc(doc) => {
220                assert_eq!(doc, doc! { "price": { "$gt": 100i64 } });
221            }
222            _ => panic!("expected Doc"),
223        }
224    }
225
226    #[test]
227    fn test_column_in() {
228        let status = Column::<String>::new("status");
229        let cond = status.in_(vec!["active", "pending"]);
230        match cond {
231            MongoCondition::Doc(doc) => {
232                assert_eq!(doc, doc! { "status": { "$in": ["active", "pending"] } });
233            }
234            _ => panic!("expected Doc"),
235        }
236    }
237
238    #[test]
239    fn test_chaining_gt_eq_false() {
240        let price = Column::<i64>::new("price");
241        // price.gt(10).eq_bool(false) means "NOT price > 10"
242        let cond = price.gt(10i64).eq_bool(false);
243        match cond {
244            MongoCondition::Doc(doc) => {
245                assert_eq!(doc, doc! { "price": { "$not": { "$gt": 10i64 } } });
246            }
247            _ => panic!("expected Doc"),
248        }
249    }
250
251    #[test]
252    fn test_chaining_gt_eq_true() {
253        let price = Column::<i64>::new("price");
254        // price.gt(10).eq_bool(true) is identity
255        let cond = price.gt(10i64).eq_bool(true);
256        match cond {
257            MongoCondition::Doc(doc) => {
258                assert_eq!(doc, doc! { "price": { "$gt": 10i64 } });
259            }
260            _ => panic!("expected Doc"),
261        }
262    }
263
264    #[test]
265    fn test_negate_simple_value() {
266        let cond = MongoCondition::Doc(doc! { "active": true });
267        let negated = negate(cond);
268        match negated {
269            MongoCondition::Doc(doc) => {
270                assert_eq!(doc, doc! { "active": { "$not": { "$eq": true } } });
271            }
272            _ => panic!("expected Doc"),
273        }
274    }
275
276    #[test]
277    fn test_negate_operator() {
278        let cond = MongoCondition::Doc(doc! { "price": { "$gt": 100 } });
279        let negated = negate(cond);
280        match negated {
281            MongoCondition::Doc(doc) => {
282                assert_eq!(doc, doc! { "price": { "$not": { "$gt": 100 } } });
283            }
284            _ => panic!("expected Doc"),
285        }
286    }
287
288    #[test]
289    fn test_condition_is_correct_type() {
290        let price = Column::<i64>::new("price");
291        let cond: MongoCondition = price.gt(100i64);
292        let _: MongoCondition = cond;
293    }
294
295    #[test]
296    fn test_is_null() {
297        let deleted_at = Column::<String>::new("deleted_at");
298        let cond = deleted_at.is_null();
299        match cond {
300            MongoCondition::Doc(doc) => {
301                assert_eq!(doc, doc! { "deleted_at": Bson::Null });
302            }
303            _ => panic!("expected Doc"),
304        }
305    }
306
307    #[test]
308    fn test_is_not_null() {
309        let email = Column::<String>::new("email");
310        let cond = email.is_not_null();
311        match cond {
312            MongoCondition::Doc(doc) => {
313                assert_eq!(doc, doc! { "email": { "$ne": Bson::Null } });
314            }
315            _ => panic!("expected Doc"),
316        }
317    }
318}