modkit_odata/
builder.rs

1//! Typed `OData` query builder
2//!
3//! This module provides a generic, reusable typed query builder for `OData` that produces
4//! `ODataQuery` with correct filter hashing.
5//!
6//! # Design
7//!
8//! - **Schema trait**: Defines field enums and their string mappings (from `schema` module)
9//! - **`FieldRef`**: Type-safe field references with schema and Rust type markers
10//! - **Filter constructors**: Typed comparison and string operations returning AST expressions
11//! - **`QueryBuilder`**: Fluent API for building queries with filter/order/select/limit
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use modkit_odata::{Schema, FieldRef, QueryBuilder, SortDir};
17//!
18//! #[derive(Copy, Clone, Eq, PartialEq)]
19//! enum UserField {
20//!     Id,
21//!     Name,
22//!     Email,
23//! }
24//!
25//! struct UserSchema;
26//!
27//! impl Schema for UserSchema {
28//!     type Field = UserField;
29//!
30//!     fn field_name(field: Self::Field) -> &'static str {
31//!         match field {
32//!             UserField::Id => "id",
33//!             UserField::Name => "name",
34//!             UserField::Email => "email",
35//!         }
36//!     }
37//! }
38//!
39//! // Define typed field references
40//! const ID: FieldRef<UserSchema, uuid::Uuid> = FieldRef::new(UserField::Id);
41//! const NAME: FieldRef<UserSchema, String> = FieldRef::new(UserField::Name);
42//!
43//! // Build a query
44//! let user_id = uuid::Uuid::new_v4();
45//! let query = QueryBuilder::<UserSchema>::new()
46//!     .filter(ID.eq(user_id).and(NAME.contains("john")))
47//!     .order_by(NAME, SortDir::Asc)
48//!     .page_size(50)
49//!     .build();
50//! ```
51
52use crate::schema::{AsFieldKey, AsFieldName, FieldRef, Schema};
53use crate::{
54    ODataOrderBy, ODataQuery, OrderKey, SortDir, ast::Expr, pagination::short_filter_hash,
55};
56use std::marker::PhantomData;
57
58/// Typed query builder for `OData` queries.
59///
60/// This builder provides a fluent API for constructing `ODataQuery` instances
61/// with type-safe field references and automatic filter hashing.
62///
63/// # Example
64///
65/// ```rust,ignore
66/// let query = QueryBuilder::<UserSchema>::new()
67///     .filter(NAME.contains("john"))
68///     .order_by(NAME, SortDir::Asc)
69///     .select([NAME, EMAIL])
70///     .page_size(50)
71///     .build();
72/// ```
73pub struct QueryBuilder<S: Schema> {
74    filter: Option<Expr>,
75    order: Vec<OrderKey>,
76    select: Option<Vec<S::Field>>,
77    limit: Option<u64>,
78    _phantom: PhantomData<S>,
79}
80
81impl<S: Schema> QueryBuilder<S> {
82    /// Create a new empty query builder.
83    #[must_use]
84    pub fn new() -> Self {
85        Self {
86            filter: None,
87            order: Vec::new(),
88            select: None,
89            limit: None,
90            _phantom: PhantomData,
91        }
92    }
93
94    /// Set the filter expression.
95    ///
96    /// # Example
97    ///
98    /// ```rust,ignore
99    /// builder.filter(ID.eq(user_id).and(NAME.contains("john")))
100    /// ```
101    #[must_use]
102    pub fn filter(mut self, expr: Expr) -> Self {
103        self.filter = Some(expr);
104        self
105    }
106
107    /// Add an order-by clause.
108    ///
109    /// Can be called multiple times to add multiple sort keys.
110    ///
111    /// # Example
112    ///
113    /// ```rust,ignore
114    /// builder
115    ///     .order_by(NAME, SortDir::Asc)
116    ///     .order_by(ID, SortDir::Desc)
117    /// ```
118    #[must_use]
119    pub fn order_by<F>(mut self, field: F, dir: SortDir) -> Self
120    where
121        F: AsFieldName,
122    {
123        self.order.push(OrderKey {
124            field: field.as_field_name().to_owned(),
125            dir,
126        });
127        self
128    }
129
130    /// Set the select fields (field projection).
131    ///
132    /// # Example
133    ///
134    /// ```rust,ignore
135    /// builder.select([NAME, EMAIL])
136    /// builder.select(vec![NAME, EMAIL])
137    ///
138    /// // Backwards-compatible (still supported)
139    /// builder.select(&[&ID, &NAME, &EMAIL])
140    /// ```
141    #[must_use]
142    pub fn select<I>(mut self, fields: I) -> Self
143    where
144        I: IntoIterator,
145        I::Item: AsFieldKey<S>,
146    {
147        let iter = fields.into_iter();
148        let (lower, _) = iter.size_hint();
149        let mut out = Vec::with_capacity(lower);
150        for f in iter {
151            out.push(f.as_field_key());
152        }
153        self.select = Some(out);
154        self
155    }
156
157    /// Set the page size limit.
158    ///
159    /// # Example
160    ///
161    /// ```rust,ignore
162    /// builder.page_size(50)
163    /// ```
164    #[must_use]
165    pub fn page_size(mut self, limit: u64) -> Self {
166        self.limit = Some(limit);
167        self
168    }
169
170    /// Build the final `ODataQuery` with computed filter hash.
171    ///
172    /// The filter hash is computed using the stable hashing algorithm from
173    /// `pagination::short_filter_hash`.
174    pub fn build(self) -> ODataQuery {
175        let filter_hash = short_filter_hash(self.filter.as_ref());
176
177        let mut query = ODataQuery::new();
178
179        if let Some(expr) = self.filter {
180            query = query.with_filter(expr);
181        }
182
183        if !self.order.is_empty() {
184            query = query.with_order(ODataOrderBy(self.order));
185        }
186
187        if let Some(limit) = self.limit {
188            query = query.with_limit(limit);
189        }
190
191        if let Some(hash) = filter_hash {
192            query = query.with_filter_hash(hash);
193        }
194
195        if let Some(fields) = self.select {
196            let names: Vec<String> = fields
197                .into_iter()
198                .map(|k| FieldRef::<S, ()>::new(k).name().to_owned())
199                .collect();
200            query = query.with_select(names);
201        }
202
203        query
204    }
205}
206
207impl<S: Schema> Default for QueryBuilder<S> {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213#[cfg(test)]
214#[cfg_attr(coverage_nightly, coverage(off))]
215mod tests {
216    use super::*;
217    use crate::ast::{CompareOperator, Value};
218    use crate::schema::FieldRef;
219
220    #[derive(Copy, Clone, Eq, PartialEq, Debug)]
221    enum UserField {
222        Id,
223        Name,
224        Email,
225        Age,
226    }
227
228    struct UserSchema;
229
230    impl Schema for UserSchema {
231        type Field = UserField;
232
233        fn field_name(field: Self::Field) -> &'static str {
234            match field {
235                UserField::Id => "id",
236                UserField::Name => "name",
237                UserField::Email => "email",
238                UserField::Age => "age",
239            }
240        }
241    }
242
243    const NAME: FieldRef<UserSchema, String> = FieldRef::new(UserField::Name);
244    const EMAIL: FieldRef<UserSchema, String> = FieldRef::new(UserField::Email);
245    const AGE: FieldRef<UserSchema, i32> = FieldRef::new(UserField::Age);
246    const ID: FieldRef<UserSchema, uuid::Uuid> = FieldRef::new(UserField::Id);
247
248    #[test]
249    fn test_field_name_mapping() {
250        assert_eq!(NAME.name(), "name");
251        assert_eq!(EMAIL.name(), "email");
252        assert_eq!(AGE.name(), "age");
253    }
254
255    #[test]
256    fn test_simple_eq_filter() {
257        let user_id = uuid::Uuid::new_v4();
258        let query = QueryBuilder::<UserSchema>::new()
259            .filter(ID.eq(user_id))
260            .build();
261
262        assert!(query.has_filter());
263        assert!(query.filter_hash.is_some());
264    }
265
266    #[test]
267    fn test_string_contains() {
268        let query = QueryBuilder::<UserSchema>::new()
269            .filter(NAME.contains("john"))
270            .build();
271
272        assert!(query.has_filter());
273        if let Some(filter) = query.filter() {
274            if let Expr::Function(name, args) = filter {
275                assert_eq!(name, "contains");
276                assert_eq!(args.len(), 2);
277            } else {
278                panic!("Expected Function expression");
279            }
280        }
281    }
282
283    #[test]
284    fn test_string_startswith() {
285        let query = QueryBuilder::<UserSchema>::new()
286            .filter(NAME.startswith("jo"))
287            .build();
288
289        assert!(query.has_filter());
290        if let Some(filter) = query.filter() {
291            if let Expr::Function(name, _) = filter {
292                assert_eq!(name, "startswith");
293            } else {
294                panic!("Expected Function expression");
295            }
296        }
297    }
298
299    #[test]
300    fn test_string_endswith() {
301        let query = QueryBuilder::<UserSchema>::new()
302            .filter(EMAIL.endswith("@example.com"))
303            .build();
304
305        assert!(query.has_filter());
306        if let Some(filter) = query.filter() {
307            if let Expr::Function(name, _) = filter {
308                assert_eq!(name, "endswith");
309            } else {
310                panic!("Expected Function expression");
311            }
312        }
313    }
314
315    #[test]
316    fn test_comparison_operators() {
317        let query = QueryBuilder::<UserSchema>::new().filter(AGE.gt(18)).build();
318        assert!(query.has_filter());
319
320        let query = QueryBuilder::<UserSchema>::new().filter(AGE.ge(18)).build();
321        assert!(query.has_filter());
322
323        let query = QueryBuilder::<UserSchema>::new().filter(AGE.lt(65)).build();
324        assert!(query.has_filter());
325
326        let query = QueryBuilder::<UserSchema>::new().filter(AGE.le(65)).build();
327        assert!(query.has_filter());
328
329        let query = QueryBuilder::<UserSchema>::new().filter(AGE.ne(0)).build();
330        assert!(query.has_filter());
331    }
332
333    #[test]
334    fn test_and_combinator() {
335        let user_id = uuid::Uuid::new_v4();
336        let query = QueryBuilder::<UserSchema>::new()
337            .filter(ID.eq(user_id).and(AGE.gt(18)))
338            .build();
339
340        assert!(query.has_filter());
341        if let Some(filter) = query.filter() {
342            if let Expr::And(_, _) = filter {
343            } else {
344                panic!("Expected And expression");
345            }
346        }
347    }
348
349    #[test]
350    fn test_or_combinator() {
351        let query = QueryBuilder::<UserSchema>::new()
352            .filter(AGE.lt(18).or(AGE.gt(65)))
353            .build();
354
355        assert!(query.has_filter());
356        if let Some(filter) = query.filter() {
357            if let Expr::Or(_, _) = filter {
358            } else {
359                panic!("Expected Or expression");
360            }
361        }
362    }
363
364    #[test]
365    fn test_not_combinator() {
366        let query = QueryBuilder::<UserSchema>::new()
367            .filter(NAME.contains("test").not())
368            .build();
369
370        assert!(query.has_filter());
371        if let Some(filter) = query.filter() {
372            if let Expr::Not(_) = filter {
373            } else {
374                panic!("Expected Not expression");
375            }
376        }
377    }
378
379    #[test]
380    fn test_complex_filter() {
381        let user_id = uuid::Uuid::new_v4();
382        let query = QueryBuilder::<UserSchema>::new()
383            .filter(
384                ID.eq(user_id)
385                    .and(NAME.contains("john"))
386                    .and(AGE.ge(18).and(AGE.le(65))),
387            )
388            .build();
389
390        assert!(query.has_filter());
391        assert!(query.filter_hash.is_some());
392    }
393
394    #[test]
395    fn test_order_by_single() {
396        let query = QueryBuilder::<UserSchema>::new()
397            .order_by(NAME, SortDir::Asc)
398            .build();
399
400        assert_eq!(query.order.0.len(), 1);
401        assert_eq!(query.order.0[0].field, "name");
402        assert_eq!(query.order.0[0].dir, SortDir::Asc);
403    }
404
405    #[test]
406    fn test_order_by_multiple() {
407        let query = QueryBuilder::<UserSchema>::new()
408            .order_by(NAME, SortDir::Asc)
409            .order_by(AGE, SortDir::Desc)
410            .build();
411
412        assert_eq!(query.order.0.len(), 2);
413        assert_eq!(query.order.0[0].field, "name");
414        assert_eq!(query.order.0[0].dir, SortDir::Asc);
415        assert_eq!(query.order.0[1].field, "age");
416        assert_eq!(query.order.0[1].dir, SortDir::Desc);
417    }
418
419    #[test]
420    fn test_select_fields() {
421        let query = QueryBuilder::<UserSchema>::new()
422            .select([NAME, EMAIL])
423            .build();
424
425        assert!(query.has_select());
426        let fields = query.selected_fields().unwrap();
427        assert_eq!(fields.len(), 2);
428        assert_eq!(fields[0], "name");
429        assert_eq!(fields[1], "email");
430    }
431
432    #[test]
433    fn test_select_fields_vec() {
434        let query = QueryBuilder::<UserSchema>::new()
435            .select(vec![NAME, EMAIL])
436            .build();
437
438        assert!(query.has_select());
439        let fields = query.selected_fields().unwrap();
440        assert_eq!(fields, &["name", "email"]);
441    }
442
443    #[test]
444    fn test_select_fields_legacy_slice_syntax() {
445        let query = QueryBuilder::<UserSchema>::new()
446            .select(&[&NAME, &EMAIL])
447            .build();
448
449        assert!(query.has_select());
450        let fields = query.selected_fields().unwrap();
451        assert_eq!(fields, &["name", "email"]);
452    }
453
454    #[test]
455    fn test_page_size() {
456        let query = QueryBuilder::<UserSchema>::new().page_size(50).build();
457
458        assert_eq!(query.limit, Some(50));
459    }
460
461    #[test]
462    fn test_full_query_build() {
463        let user_id = uuid::Uuid::new_v4();
464        let query = QueryBuilder::<UserSchema>::new()
465            .filter(ID.eq(user_id).and(AGE.gt(18)))
466            .order_by(NAME, SortDir::Asc)
467            .select([NAME, EMAIL])
468            .page_size(25)
469            .build();
470
471        assert!(query.has_filter());
472        assert!(query.filter_hash.is_some());
473        assert_eq!(query.order.0.len(), 1);
474        assert!(query.has_select());
475        assert_eq!(query.limit, Some(25));
476    }
477
478    #[test]
479    fn test_filter_hash_stability() {
480        let user_id = uuid::Uuid::new_v4();
481
482        let query1 = QueryBuilder::<UserSchema>::new()
483            .filter(ID.eq(user_id))
484            .build();
485
486        let query2 = QueryBuilder::<UserSchema>::new()
487            .filter(ID.eq(user_id))
488            .build();
489
490        assert_eq!(query1.filter_hash, query2.filter_hash);
491        assert!(query1.filter_hash.is_some());
492    }
493
494    #[test]
495    fn test_filter_hash_different_for_different_filters() {
496        let query1 = QueryBuilder::<UserSchema>::new()
497            .filter(NAME.eq("alice"))
498            .build();
499
500        let query2 = QueryBuilder::<UserSchema>::new().filter(AGE.gt(18)).build();
501
502        assert_ne!(query1.filter_hash, query2.filter_hash);
503    }
504
505    #[test]
506    fn test_no_filter_no_hash() {
507        let query = QueryBuilder::<UserSchema>::new()
508            .order_by(NAME, SortDir::Asc)
509            .build();
510
511        assert!(!query.has_filter());
512        assert!(query.filter_hash.is_none());
513    }
514
515    #[test]
516    fn test_empty_query() {
517        let query = QueryBuilder::<UserSchema>::new().build();
518
519        assert!(!query.has_filter());
520        assert!(query.filter_hash.is_none());
521        assert!(query.order.is_empty());
522        assert!(!query.has_select());
523        assert_eq!(query.limit, None);
524    }
525
526    #[test]
527    fn test_normalized_filter_consistency() {
528        use crate::pagination::normalize_filter_for_hash;
529
530        let expr1 = NAME.eq("test");
531        let expr2 = NAME.eq("test");
532
533        let norm1 = normalize_filter_for_hash(&expr1);
534        let norm2 = normalize_filter_for_hash(&expr2);
535
536        assert_eq!(norm1, norm2);
537    }
538
539    #[test]
540    fn test_is_null() {
541        let query = QueryBuilder::<UserSchema>::new()
542            .filter(NAME.is_null())
543            .build();
544
545        assert!(query.has_filter());
546        if let Some(filter) = query.filter() {
547            if let Expr::Compare(_, op, value) = filter {
548                assert_eq!(*op, CompareOperator::Eq);
549                if let Expr::Value(Value::Null) = **value {
550                } else {
551                    panic!("Expected Value::Null");
552                }
553            } else {
554                panic!("Expected Compare expression");
555            }
556        }
557    }
558
559    #[test]
560    fn test_is_not_null() {
561        let query = QueryBuilder::<UserSchema>::new()
562            .filter(EMAIL.is_not_null())
563            .build();
564
565        assert!(query.has_filter());
566        if let Some(filter) = query.filter() {
567            if let Expr::Compare(_, op, value) = filter {
568                assert_eq!(*op, CompareOperator::Ne);
569                if let Expr::Value(Value::Null) = **value {
570                } else {
571                    panic!("Expected Value::Null");
572                }
573            } else {
574                panic!("Expected Compare expression");
575            }
576        }
577    }
578
579    #[cfg(feature = "chrono")]
580    #[test]
581    fn test_chrono_datetime_conversion() {
582        use chrono::Utc;
583
584        const CREATED_AT: FieldRef<UserSchema, chrono::DateTime<Utc>> =
585            FieldRef::new(UserField::Age);
586
587        let now = Utc::now();
588        let query = QueryBuilder::<UserSchema>::new()
589            .filter(CREATED_AT.eq(now))
590            .build();
591
592        assert!(query.has_filter());
593    }
594
595    #[cfg(feature = "chrono")]
596    #[test]
597    fn test_chrono_naive_date_conversion() {
598        use chrono::NaiveDate;
599
600        const DATE_FIELD: FieldRef<UserSchema, NaiveDate> = FieldRef::new(UserField::Age);
601
602        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
603        let query = QueryBuilder::<UserSchema>::new()
604            .filter(DATE_FIELD.eq(date))
605            .build();
606
607        assert!(query.has_filter());
608    }
609
610    #[cfg(feature = "chrono")]
611    #[test]
612    fn test_chrono_naive_time_conversion() {
613        use chrono::NaiveTime;
614
615        const TIME_FIELD: FieldRef<UserSchema, NaiveTime> = FieldRef::new(UserField::Age);
616
617        let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
618        let query = QueryBuilder::<UserSchema>::new()
619            .filter(TIME_FIELD.eq(time))
620            .build();
621
622        assert!(query.has_filter());
623    }
624}