Skip to main content

revue/query/
mod.rs

1//! Query DSL for filtering and searching items
2//!
3//! Provides a powerful search query language inspired by eilmeldung's
4//! article filtering with enhanced capabilities.
5//!
6//! # Query Syntax
7//!
8//! - **Free text**: `hello world` - matches items containing these words
9//! - **Field match**: `author:john` - exact field match
10//! - **Contains**: `title~rust` - field contains value
11//! - **Not equal**: `status:!draft` - field not equal to value
12//! - **Comparison**: `age:>18`, `price:<100` - numeric comparisons
13//! - **Boolean**: `active:true`, `published:false`
14//! - **Date**: `after:2024-01-01`, `before:2024-12-31`
15//!
16//! # Example
17//!
18//! ```rust,ignore
19//! use revue::query::{Query, Queryable};
20//!
21//! struct Article {
22//!     title: String,
23//!     author: String,
24//!     published: bool,
25//! }
26//!
27//! impl Queryable for Article {
28//!     fn field_value(&self, field: &str) -> Option<QueryValue> {
29//!         match field {
30//!             "title" => Some(QueryValue::String(self.title.clone())),
31//!             "author" => Some(QueryValue::String(self.author.clone())),
32//!             "published" => Some(QueryValue::Bool(self.published)),
33//!             _ => None,
34//!         }
35//!     }
36//!
37//!     fn full_text(&self) -> String {
38//!         format!("{} {}", self.title, self.author)
39//!     }
40//! }
41//!
42//! let query = Query::parse("author:john published:true").unwrap();
43//! let articles = vec![/* ... */];
44//! let filtered: Vec<_> = articles.iter().filter(|a| query.matches(a)).collect();
45//! ```
46
47mod parser;
48
49pub use parser::ParseError;
50
51/// Value types for query matching
52#[derive(Debug, Clone, PartialEq)]
53pub enum QueryValue {
54    /// String value
55    String(String),
56    /// Integer value
57    Int(i64),
58    /// Float value
59    Float(f64),
60    /// Boolean value
61    Bool(bool),
62    /// Date value (ISO 8601 string)
63    Date(String),
64    /// Null/None value
65    Null,
66}
67
68impl QueryValue {
69    /// Create a string value
70    pub fn string(s: impl Into<String>) -> Self {
71        Self::String(s.into())
72    }
73
74    /// Create an integer value
75    pub fn int(n: i64) -> Self {
76        Self::Int(n)
77    }
78
79    /// Create a float value
80    pub fn float(n: f64) -> Self {
81        Self::Float(n)
82    }
83
84    /// Create a boolean value
85    pub fn bool(b: bool) -> Self {
86        Self::Bool(b)
87    }
88
89    /// Check if value contains substring (case-insensitive)
90    pub fn contains(&self, needle: &str) -> bool {
91        match self {
92            Self::String(s) => {
93                // Convert needle once instead of twice
94                let needle_lower = needle.to_lowercase();
95                s.to_lowercase().contains(&needle_lower)
96            }
97            Self::Int(n) => n.to_string().contains(needle),
98            Self::Float(n) => n.to_string().contains(needle),
99            Self::Bool(b) => b.to_string() == needle.to_lowercase(),
100            Self::Date(d) => d.contains(needle),
101            Self::Null => false,
102        }
103    }
104
105    /// Check equality with a string
106    pub fn equals_str(&self, other: &str) -> bool {
107        match self {
108            Self::String(s) => {
109                // Convert other once instead of twice
110                let other_lower = other.to_lowercase();
111                s.to_lowercase() == other_lower
112            }
113            Self::Int(n) => other.parse::<i64>().map(|o| *n == o).unwrap_or(false),
114            Self::Float(n) => other
115                .parse::<f64>()
116                .map(|o| (*n - o).abs() < f64::EPSILON)
117                .unwrap_or(false),
118            Self::Bool(b) => {
119                let other_lower = other.to_lowercase();
120                (*b && (other_lower == "true" || other_lower == "yes" || other_lower == "1"))
121                    || (!*b
122                        && (other_lower == "false" || other_lower == "no" || other_lower == "0"))
123            }
124            Self::Date(d) => d == other,
125            Self::Null => other.to_lowercase() == "null" || other.is_empty(),
126        }
127    }
128
129    /// Compare values (for >, <, >=, <=)
130    pub fn compare(&self, other: &str) -> Option<std::cmp::Ordering> {
131        match self {
132            Self::Int(n) => other.parse::<i64>().ok().map(|o| n.cmp(&o)),
133            Self::Float(n) => other.parse::<f64>().ok().and_then(|o| n.partial_cmp(&o)),
134            Self::String(s) => Some(s.cmp(&other.to_string())),
135            Self::Date(d) => Some(d.cmp(&other.to_string())),
136            _ => None,
137        }
138    }
139}
140
141/// Comparison operators
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum Operator {
144    /// Equals (field:value)
145    Eq,
146    /// Not equals (field:!value)
147    Ne,
148    /// Greater than (field:>value)
149    Gt,
150    /// Less than (field:<value)
151    Lt,
152    /// Greater or equal (field:>=value)
153    Ge,
154    /// Less or equal (field:<=value)
155    Le,
156    /// Contains (field~value)
157    Contains,
158}
159
160/// A single filter condition
161#[derive(Debug, Clone)]
162pub enum Filter {
163    /// Free text search
164    Text(String),
165    /// Field comparison
166    Field {
167        /// Field name
168        name: String,
169        /// Comparison operator
170        op: Operator,
171        /// Value to compare against
172        value: String,
173    },
174    /// AND combination
175    And(Box<Filter>, Box<Filter>),
176    /// OR combination
177    Or(Box<Filter>, Box<Filter>),
178    /// NOT negation
179    Not(Box<Filter>),
180}
181
182impl Filter {
183    /// Create a text filter
184    pub fn text(s: impl Into<String>) -> Self {
185        Self::Text(s.into())
186    }
187
188    /// Create an equality filter
189    pub fn eq(field: impl Into<String>, value: impl Into<String>) -> Self {
190        Self::Field {
191            name: field.into(),
192            op: Operator::Eq,
193            value: value.into(),
194        }
195    }
196
197    /// Create a not-equals filter
198    pub fn ne(field: impl Into<String>, value: impl Into<String>) -> Self {
199        Self::Field {
200            name: field.into(),
201            op: Operator::Ne,
202            value: value.into(),
203        }
204    }
205
206    /// Create a contains filter
207    pub fn contains(field: impl Into<String>, value: impl Into<String>) -> Self {
208        Self::Field {
209            name: field.into(),
210            op: Operator::Contains,
211            value: value.into(),
212        }
213    }
214
215    /// Create a greater-than filter
216    pub fn gt(field: impl Into<String>, value: impl Into<String>) -> Self {
217        Self::Field {
218            name: field.into(),
219            op: Operator::Gt,
220            value: value.into(),
221        }
222    }
223
224    /// Create a less-than filter
225    pub fn lt(field: impl Into<String>, value: impl Into<String>) -> Self {
226        Self::Field {
227            name: field.into(),
228            op: Operator::Lt,
229            value: value.into(),
230        }
231    }
232
233    /// Combine with AND
234    pub fn and(self, other: Filter) -> Self {
235        Self::And(Box::new(self), Box::new(other))
236    }
237
238    /// Combine with OR
239    pub fn or(self, other: Filter) -> Self {
240        Self::Or(Box::new(self), Box::new(other))
241    }
242
243    /// Negate
244    pub fn negate(self) -> Self {
245        Self::Not(Box::new(self))
246    }
247
248    /// Check if item matches this filter
249    pub fn matches<T: Queryable>(&self, item: &T) -> bool {
250        match self {
251            Self::Text(text) => {
252                let full_text = item.full_text();
253                // Convert full_text once instead of per-word
254                let full_text_lower = full_text.to_lowercase();
255                text.split_whitespace()
256                    .all(|word| full_text_lower.contains(&word.to_lowercase()))
257            }
258            Self::Field { name, op, value } => {
259                if let Some(field_value) = item.field_value(name) {
260                    match op {
261                        Operator::Eq => field_value.equals_str(value),
262                        Operator::Ne => !field_value.equals_str(value),
263                        Operator::Contains => field_value.contains(value),
264                        Operator::Gt => {
265                            field_value.compare(value) == Some(std::cmp::Ordering::Greater)
266                        }
267                        Operator::Lt => {
268                            field_value.compare(value) == Some(std::cmp::Ordering::Less)
269                        }
270                        Operator::Ge => matches!(
271                            field_value.compare(value),
272                            Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
273                        ),
274                        Operator::Le => matches!(
275                            field_value.compare(value),
276                            Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
277                        ),
278                    }
279                } else {
280                    false
281                }
282            }
283            Self::And(a, b) => a.matches(item) && b.matches(item),
284            Self::Or(a, b) => a.matches(item) || b.matches(item),
285            Self::Not(f) => !f.matches(item),
286        }
287    }
288}
289
290/// Sort direction
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
292pub enum SortDirection {
293    /// Ascending order (A-Z, 0-9)
294    #[default]
295    Ascending,
296    /// Descending order (Z-A, 9-0)
297    Descending,
298}
299
300/// Sort specification
301#[derive(Debug, Clone)]
302pub struct SortBy {
303    /// Field to sort by
304    pub field: String,
305    /// Sort direction
306    pub direction: SortDirection,
307}
308
309impl SortBy {
310    /// Create ascending sort
311    pub fn asc(field: impl Into<String>) -> Self {
312        Self {
313            field: field.into(),
314            direction: SortDirection::Ascending,
315        }
316    }
317
318    /// Create descending sort
319    pub fn desc(field: impl Into<String>) -> Self {
320        Self {
321            field: field.into(),
322            direction: SortDirection::Descending,
323        }
324    }
325}
326
327/// A complete query with filters, sorting, and pagination
328#[derive(Debug, Clone, Default)]
329pub struct Query {
330    /// Filter conditions (ANDed together)
331    pub filters: Vec<Filter>,
332    /// Sort specification
333    pub sort: Option<SortBy>,
334    /// Maximum results
335    pub limit: Option<usize>,
336    /// Skip first N results
337    pub offset: Option<usize>,
338}
339
340impl Query {
341    /// Create an empty query (matches everything)
342    pub fn new() -> Self {
343        Self::default()
344    }
345
346    /// Parse a query string
347    ///
348    /// # Errors
349    ///
350    /// Returns `Err(ParseError)` if:
351    /// - The query syntax is invalid
352    /// - A field operator is not recognized
353    /// - A value cannot be parsed for its expected type
354    pub fn parse(input: &str) -> Result<Self, ParseError> {
355        parser::parse(input)
356    }
357
358    /// Add a filter
359    pub fn filter(mut self, filter: Filter) -> Self {
360        self.filters.push(filter);
361        self
362    }
363
364    /// Add text search
365    pub fn text(self, text: impl Into<String>) -> Self {
366        self.filter(Filter::text(text))
367    }
368
369    /// Add field equality filter
370    pub fn field_eq(self, field: impl Into<String>, value: impl Into<String>) -> Self {
371        self.filter(Filter::eq(field, value))
372    }
373
374    /// Add field contains filter
375    pub fn field_contains(self, field: impl Into<String>, value: impl Into<String>) -> Self {
376        self.filter(Filter::contains(field, value))
377    }
378
379    /// Set sort
380    pub fn sort_by(mut self, sort: SortBy) -> Self {
381        self.sort = Some(sort);
382        self
383    }
384
385    /// Sort ascending
386    pub fn sort_asc(self, field: impl Into<String>) -> Self {
387        self.sort_by(SortBy::asc(field))
388    }
389
390    /// Sort descending
391    pub fn sort_desc(self, field: impl Into<String>) -> Self {
392        self.sort_by(SortBy::desc(field))
393    }
394
395    /// Set limit
396    pub fn limit(mut self, limit: usize) -> Self {
397        self.limit = Some(limit);
398        self
399    }
400
401    /// Set offset
402    pub fn offset(mut self, offset: usize) -> Self {
403        self.offset = Some(offset);
404        self
405    }
406
407    /// Check if an item matches all filters
408    pub fn matches<T: Queryable>(&self, item: &T) -> bool {
409        if self.filters.is_empty() {
410            return true;
411        }
412        self.filters.iter().all(|f| f.matches(item))
413    }
414
415    /// Filter a slice of items
416    pub fn filter_items<'a, T: Queryable>(&self, items: &'a [T]) -> Vec<&'a T> {
417        let mut result: Vec<_> = items.iter().filter(|item| self.matches(*item)).collect();
418
419        // Apply sorting
420        if let Some(ref sort) = self.sort {
421            result.sort_by(|a, b| {
422                let a_val = a.field_value(&sort.field);
423                let b_val = b.field_value(&sort.field);
424
425                let ordering = match (&a_val, &b_val) {
426                    (Some(QueryValue::String(a)), Some(QueryValue::String(b))) => a.cmp(b),
427                    (Some(QueryValue::Int(a)), Some(QueryValue::Int(b))) => a.cmp(b),
428                    (Some(QueryValue::Float(a)), Some(QueryValue::Float(b))) => {
429                        a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
430                    }
431                    (Some(QueryValue::Date(a)), Some(QueryValue::Date(b))) => a.cmp(b),
432                    (Some(_), None) => std::cmp::Ordering::Less,
433                    (None, Some(_)) => std::cmp::Ordering::Greater,
434                    _ => std::cmp::Ordering::Equal,
435                };
436
437                match sort.direction {
438                    SortDirection::Ascending => ordering,
439                    SortDirection::Descending => ordering.reverse(),
440                }
441            });
442        }
443
444        // Apply offset
445        if let Some(offset) = self.offset {
446            result = result.into_iter().skip(offset).collect();
447        }
448
449        // Apply limit
450        if let Some(limit) = self.limit {
451            result.truncate(limit);
452        }
453
454        result
455    }
456
457    /// Check if query is empty (matches everything)
458    pub fn is_empty(&self) -> bool {
459        self.filters.is_empty() && self.sort.is_none() && self.limit.is_none()
460    }
461}
462
463/// Trait for items that can be queried
464pub trait Queryable {
465    /// Get field value by name
466    fn field_value(&self, field: &str) -> Option<QueryValue>;
467
468    /// Get full text representation for free-text search
469    fn full_text(&self) -> String;
470}
471
472/// Helper macro for implementing Queryable
473#[macro_export]
474macro_rules! impl_queryable {
475    ($type:ty, full_text: $full_text:expr, fields: { $($field:literal => $accessor:expr),* $(,)? }) => {
476        impl $crate::query::Queryable for $type {
477            fn field_value(&self, field: &str) -> Option<$crate::query::QueryValue> {
478                match field {
479                    $($field => Some($accessor(self)),)*
480                    _ => None,
481                }
482            }
483
484            fn full_text(&self) -> String {
485                $full_text(self)
486            }
487        }
488    };
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    struct TestItem {
496        name: String,
497        age: i64,
498        active: bool,
499    }
500
501    impl Queryable for TestItem {
502        fn field_value(&self, field: &str) -> Option<QueryValue> {
503            match field {
504                "name" => Some(QueryValue::String(self.name.clone())),
505                "age" => Some(QueryValue::Int(self.age)),
506                "active" => Some(QueryValue::Bool(self.active)),
507                _ => None,
508            }
509        }
510
511        fn full_text(&self) -> String {
512            self.name.clone()
513        }
514    }
515
516    #[test]
517    fn test_text_filter() {
518        let item = TestItem {
519            name: "John Doe".into(),
520            age: 30,
521            active: true,
522        };
523
524        assert!(Filter::text("john").matches(&item));
525        assert!(Filter::text("doe").matches(&item));
526        assert!(Filter::text("john doe").matches(&item));
527        assert!(!Filter::text("jane").matches(&item));
528    }
529
530    #[test]
531    fn test_field_eq() {
532        let item = TestItem {
533            name: "John".into(),
534            age: 30,
535            active: true,
536        };
537
538        assert!(Filter::eq("name", "john").matches(&item));
539        assert!(Filter::eq("age", "30").matches(&item));
540        assert!(Filter::eq("active", "true").matches(&item));
541        assert!(!Filter::eq("name", "jane").matches(&item));
542    }
543
544    #[test]
545    fn test_field_ne() {
546        let item = TestItem {
547            name: "John".into(),
548            age: 30,
549            active: true,
550        };
551
552        assert!(Filter::ne("name", "jane").matches(&item));
553        assert!(!Filter::ne("name", "john").matches(&item));
554    }
555
556    #[test]
557    fn test_field_contains() {
558        let item = TestItem {
559            name: "John Doe".into(),
560            age: 30,
561            active: true,
562        };
563
564        assert!(Filter::contains("name", "oh").matches(&item));
565        assert!(!Filter::contains("name", "xyz").matches(&item));
566    }
567
568    #[test]
569    fn test_field_comparison() {
570        let item = TestItem {
571            name: "John".into(),
572            age: 30,
573            active: true,
574        };
575
576        assert!(Filter::gt("age", "20").matches(&item));
577        assert!(!Filter::gt("age", "40").matches(&item));
578        assert!(Filter::lt("age", "40").matches(&item));
579        assert!(!Filter::lt("age", "20").matches(&item));
580    }
581
582    #[test]
583    fn test_and_or() {
584        let item = TestItem {
585            name: "John".into(),
586            age: 30,
587            active: true,
588        };
589
590        let and_filter = Filter::eq("name", "john").and(Filter::eq("active", "true"));
591        assert!(and_filter.matches(&item));
592
593        let or_filter = Filter::eq("name", "jane").or(Filter::eq("name", "john"));
594        assert!(or_filter.matches(&item));
595    }
596
597    #[test]
598    fn test_not() {
599        let item = TestItem {
600            name: "John".into(),
601            age: 30,
602            active: true,
603        };
604
605        assert!(Filter::eq("name", "jane").negate().matches(&item));
606        assert!(!Filter::eq("name", "john").negate().matches(&item));
607    }
608
609    #[test]
610    fn test_query() {
611        let items = vec![
612            TestItem {
613                name: "Alice".into(),
614                age: 25,
615                active: true,
616            },
617            TestItem {
618                name: "Bob".into(),
619                age: 30,
620                active: false,
621            },
622            TestItem {
623                name: "Charlie".into(),
624                age: 35,
625                active: true,
626            },
627        ];
628
629        let query = Query::new().filter(Filter::eq("active", "true"));
630        let result = query.filter_items(&items);
631        assert_eq!(result.len(), 2);
632
633        let query = Query::new().filter(Filter::gt("age", "27"));
634        let result = query.filter_items(&items);
635        assert_eq!(result.len(), 2);
636    }
637
638    #[test]
639    fn test_query_sort() {
640        let items = vec![
641            TestItem {
642                name: "Charlie".into(),
643                age: 35,
644                active: true,
645            },
646            TestItem {
647                name: "Alice".into(),
648                age: 25,
649                active: true,
650            },
651            TestItem {
652                name: "Bob".into(),
653                age: 30,
654                active: false,
655            },
656        ];
657
658        let query = Query::new().sort_asc("age");
659        let result = query.filter_items(&items);
660        assert_eq!(result[0].name, "Alice");
661        assert_eq!(result[1].name, "Bob");
662        assert_eq!(result[2].name, "Charlie");
663
664        let query = Query::new().sort_desc("age");
665        let result = query.filter_items(&items);
666        assert_eq!(result[0].name, "Charlie");
667    }
668
669    #[test]
670    fn test_query_limit_offset() {
671        let items = vec![
672            TestItem {
673                name: "A".into(),
674                age: 1,
675                active: true,
676            },
677            TestItem {
678                name: "B".into(),
679                age: 2,
680                active: true,
681            },
682            TestItem {
683                name: "C".into(),
684                age: 3,
685                active: true,
686            },
687            TestItem {
688                name: "D".into(),
689                age: 4,
690                active: true,
691            },
692        ];
693
694        let query = Query::new().limit(2);
695        let result = query.filter_items(&items);
696        assert_eq!(result.len(), 2);
697
698        let query = Query::new().offset(1).limit(2);
699        let result = query.filter_items(&items);
700        assert_eq!(result.len(), 2);
701        assert_eq!(result[0].name, "B");
702    }
703
704    // QueryValue method tests
705    #[test]
706    fn test_query_value_string() {
707        let val = QueryValue::string("hello");
708        assert_eq!(val, QueryValue::String("hello".to_string()));
709    }
710
711    #[test]
712    fn test_query_value_int() {
713        let val = QueryValue::int(42);
714        assert_eq!(val, QueryValue::Int(42));
715    }
716
717    #[test]
718    fn test_query_value_float() {
719        let val = QueryValue::float(3.14);
720        assert_eq!(val, QueryValue::Float(3.14));
721    }
722
723    #[test]
724    fn test_query_value_bool() {
725        let val = QueryValue::bool(true);
726        assert_eq!(val, QueryValue::Bool(true));
727    }
728
729    #[test]
730    fn test_query_value_contains_string() {
731        let val = QueryValue::string("Hello World");
732        assert!(val.contains("hello"));
733        assert!(val.contains("WORLD"));
734        assert!(!val.contains("xyz"));
735    }
736
737    #[test]
738    fn test_query_value_contains_int() {
739        let val = QueryValue::int(12345);
740        assert!(val.contains("123"));
741        assert!(!val.contains("999"));
742    }
743
744    #[test]
745    fn test_query_value_contains_float() {
746        let val = QueryValue::float(3.14);
747        assert!(val.contains("3.14"));
748        assert!(val.contains("3"));
749        assert!(!val.contains("999"));
750    }
751
752    #[test]
753    fn test_query_value_contains_bool() {
754        let val = QueryValue::bool(true);
755        assert!(val.contains("true"));
756        assert!(val.contains("TRUE"));
757        assert!(!val.contains("false"));
758
759        let val = QueryValue::bool(false);
760        assert!(val.contains("false"));
761        assert!(!val.contains("true"));
762    }
763
764    #[test]
765    fn test_query_value_contains_null() {
766        let val = QueryValue::Null;
767        assert!(!val.contains("anything"));
768    }
769
770    #[test]
771    fn test_query_value_equals_str_string() {
772        let val = QueryValue::string("Hello");
773        assert!(val.equals_str("hello"));
774        assert!(val.equals_str("HELLO"));
775        assert!(!val.equals_str("world"));
776    }
777
778    #[test]
779    fn test_query_value_equals_str_int() {
780        let val = QueryValue::int(42);
781        assert!(val.equals_str("42"));
782        assert!(!val.equals_str("999"));
783    }
784
785    #[test]
786    fn test_query_value_equals_str_bool() {
787        let val = QueryValue::bool(true);
788        assert!(val.equals_str("true"));
789        assert!(!val.equals_str("false"));
790    }
791
792    #[test]
793    fn test_query_value_equals_str_null() {
794        let val = QueryValue::Null;
795        assert!(val.equals_str("null"));
796        assert!(val.equals_str(""));
797        assert!(!val.equals_str("value"));
798    }
799
800    #[test]
801    fn test_query_value_compare_int() {
802        let val = QueryValue::int(50);
803        assert_eq!(val.compare("30"), Some(std::cmp::Ordering::Greater));
804        assert_eq!(val.compare("50"), Some(std::cmp::Ordering::Equal));
805        assert_eq!(val.compare("70"), Some(std::cmp::Ordering::Less));
806    }
807
808    #[test]
809    fn test_query_value_compare_float() {
810        let val = QueryValue::float(3.5);
811        assert_eq!(val.compare("2.5"), Some(std::cmp::Ordering::Greater));
812        assert_eq!(val.compare("3.5"), Some(std::cmp::Ordering::Equal));
813        assert_eq!(val.compare("4.5"), Some(std::cmp::Ordering::Less));
814    }
815}