sync_engine/search/
query_builder.rs

1//! Query Builder - AST for search queries
2//!
3//! Provides a type-safe way to build search queries that can be translated
4//! to both RediSearch FT.SEARCH syntax and MySQL JSON_EXTRACT queries.
5//!
6//! # Example
7//!
8//! ```rust
9//! use sync_engine::search::{Query, QueryBuilder};
10//!
11//! // Simple field query
12//! let query = Query::field_eq("name", "Alice");
13//!
14//! // Complex query with builder
15//! let query = QueryBuilder::new()
16//!     .field_eq("name", "Alice")
17//!     .numeric_range("age", Some(25.0), Some(40.0))
18//!     .build_and();
19//!
20//! // Boolean combinations
21//! let query = Query::field_eq("status", "active")
22//!     .or(Query::field_eq("status", "pending"));
23//! ```
24
25use serde::{Deserialize, Serialize};
26
27/// Search query AST
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct Query {
30    /// Root query node
31    pub root: QueryNode,
32}
33
34impl Query {
35    /// Create a new query from a root node
36    pub fn new(root: QueryNode) -> Self {
37        Self { root }
38    }
39
40    /// Create a field equals query: @field:value
41    pub fn field_eq(field: impl Into<String>, value: impl Into<String>) -> Self {
42        Self::new(QueryNode::Field(FieldQuery {
43            field: field.into(),
44            operator: FieldOperator::Equals,
45            value: QueryValue::Text(value.into()),
46        }))
47    }
48
49    /// Create a tag query: @tags:{value1|value2}
50    pub fn tags(field: impl Into<String>, values: Vec<String>) -> Self {
51        Self::new(QueryNode::Field(FieldQuery {
52            field: field.into(),
53            operator: FieldOperator::In,
54            value: QueryValue::Tags(values),
55        }))
56    }
57
58    /// Create a numeric range query: @age:[min max]
59    pub fn numeric_range(field: impl Into<String>, min: Option<f64>, max: Option<f64>) -> Self {
60        Self::new(QueryNode::Field(FieldQuery {
61            field: field.into(),
62            operator: FieldOperator::Range,
63            value: QueryValue::NumericRange { min, max },
64        }))
65    }
66
67    /// Create a full-text search query (contains)
68    pub fn text_search(field: impl Into<String>, text: impl Into<String>) -> Self {
69        Self::new(QueryNode::Field(FieldQuery {
70            field: field.into(),
71            operator: FieldOperator::Contains,
72            value: QueryValue::Text(text.into()),
73        }))
74    }
75
76    /// Create a prefix match query: @field:prefix*
77    pub fn prefix(field: impl Into<String>, prefix: impl Into<String>) -> Self {
78        Self::new(QueryNode::Field(FieldQuery {
79            field: field.into(),
80            operator: FieldOperator::Prefix,
81            value: QueryValue::Text(prefix.into()),
82        }))
83    }
84
85    /// Create a fuzzy match query: @field:%value%
86    pub fn fuzzy(field: impl Into<String>, text: impl Into<String>) -> Self {
87        Self::new(QueryNode::Field(FieldQuery {
88            field: field.into(),
89            operator: FieldOperator::Fuzzy,
90            value: QueryValue::Text(text.into()),
91        }))
92    }
93
94    /// Combine with AND
95    pub fn and(self, other: Query) -> Self {
96        Self::new(QueryNode::And(vec![self.root, other.root]))
97    }
98
99    /// Combine with OR
100    pub fn or(self, other: Query) -> Self {
101        Self::new(QueryNode::Or(vec![self.root, other.root]))
102    }
103
104    /// Negate query
105    pub fn negate(self) -> Self {
106        Self::new(QueryNode::Not(Box::new(self.root)))
107    }
108}
109
110/// Query AST node
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub enum QueryNode {
113    /// Field query: @field:value
114    Field(FieldQuery),
115    /// Boolean AND: (query1 query2)
116    And(Vec<QueryNode>),
117    /// Boolean OR: (query1 | query2)
118    Or(Vec<QueryNode>),
119    /// Boolean NOT: -query
120    Not(Box<QueryNode>),
121}
122
123/// Field query
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct FieldQuery {
126    /// Field name (e.g., "name", "age", "tags")
127    pub field: String,
128    /// Comparison operator
129    pub operator: FieldOperator,
130    /// Query value
131    pub value: QueryValue,
132}
133
134/// Field comparison operator
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub enum FieldOperator {
137    /// Exact match: @field:value
138    Equals,
139    /// Contains text: @field:*value*
140    Contains,
141    /// Numeric/date range: @field:[min max]
142    Range,
143    /// Tag membership: @tags:{value1|value2}
144    In,
145    /// Prefix match: @field:prefix*
146    Prefix,
147    /// Fuzzy match: @field:%value%
148    Fuzzy,
149}
150
151/// Query value type
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub enum QueryValue {
154    /// Text value
155    Text(String),
156    /// Numeric value
157    Numeric(f64),
158    /// Numeric range [min, max]
159    NumericRange { min: Option<f64>, max: Option<f64> },
160    /// Tag values (OR semantics)
161    Tags(Vec<String>),
162    /// Boolean value
163    Boolean(bool),
164}
165
166/// Builder for complex queries
167#[derive(Default)]
168pub struct QueryBuilder {
169    nodes: Vec<QueryNode>,
170}
171
172impl QueryBuilder {
173    /// Create a new query builder
174    pub fn new() -> Self {
175        Self { nodes: Vec::new() }
176    }
177
178    /// Add a field equals constraint
179    pub fn field_eq(mut self, field: impl Into<String>, value: impl Into<String>) -> Self {
180        self.nodes.push(QueryNode::Field(FieldQuery {
181            field: field.into(),
182            operator: FieldOperator::Equals,
183            value: QueryValue::Text(value.into()),
184        }));
185        self
186    }
187
188    /// Add a numeric equals constraint
189    pub fn numeric_eq(mut self, field: impl Into<String>, value: f64) -> Self {
190        self.nodes.push(QueryNode::Field(FieldQuery {
191            field: field.into(),
192            operator: FieldOperator::Equals,
193            value: QueryValue::Numeric(value),
194        }));
195        self
196    }
197
198    /// Add a numeric range constraint
199    pub fn numeric_range(mut self, field: impl Into<String>, min: Option<f64>, max: Option<f64>) -> Self {
200        self.nodes.push(QueryNode::Field(FieldQuery {
201            field: field.into(),
202            operator: FieldOperator::Range,
203            value: QueryValue::NumericRange { min, max },
204        }));
205        self
206    }
207
208    /// Add a tag constraint
209    pub fn tags(mut self, field: impl Into<String>, values: Vec<String>) -> Self {
210        self.nodes.push(QueryNode::Field(FieldQuery {
211            field: field.into(),
212            operator: FieldOperator::In,
213            value: QueryValue::Tags(values),
214        }));
215        self
216    }
217
218    /// Add a contains constraint
219    pub fn contains(mut self, field: impl Into<String>, text: impl Into<String>) -> Self {
220        self.nodes.push(QueryNode::Field(FieldQuery {
221            field: field.into(),
222            operator: FieldOperator::Contains,
223            value: QueryValue::Text(text.into()),
224        }));
225        self
226    }
227
228    /// Add a prefix constraint
229    pub fn prefix(mut self, field: impl Into<String>, prefix: impl Into<String>) -> Self {
230        self.nodes.push(QueryNode::Field(FieldQuery {
231            field: field.into(),
232            operator: FieldOperator::Prefix,
233            value: QueryValue::Text(prefix.into()),
234        }));
235        self
236    }
237
238    /// Add a fuzzy constraint
239    pub fn fuzzy(mut self, field: impl Into<String>, text: impl Into<String>) -> Self {
240        self.nodes.push(QueryNode::Field(FieldQuery {
241            field: field.into(),
242            operator: FieldOperator::Fuzzy,
243            value: QueryValue::Text(text.into()),
244        }));
245        self
246    }
247
248    /// Build query with AND semantics (all constraints must match)
249    pub fn build_and(self) -> Query {
250        if self.nodes.is_empty() {
251            // Empty query matches everything (RediSearch: *)
252            Query::new(QueryNode::Field(FieldQuery {
253                field: "*".to_string(),
254                operator: FieldOperator::Equals,
255                value: QueryValue::Text("*".to_string()),
256            }))
257        } else if self.nodes.len() == 1 {
258            Query::new(self.nodes.into_iter().next().unwrap())
259        } else {
260            Query::new(QueryNode::And(self.nodes))
261        }
262    }
263
264    /// Build query with OR semantics (any constraint can match)
265    pub fn build_or(self) -> Query {
266        if self.nodes.is_empty() {
267            // Empty query matches everything
268            Query::new(QueryNode::Field(FieldQuery {
269                field: "*".to_string(),
270                operator: FieldOperator::Equals,
271                value: QueryValue::Text("*".to_string()),
272            }))
273        } else if self.nodes.len() == 1 {
274            Query::new(self.nodes.into_iter().next().unwrap())
275        } else {
276            Query::new(QueryNode::Or(self.nodes))
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_simple_field_query() {
287        let query = Query::field_eq("name", "Alice");
288        assert_eq!(
289            query.root,
290            QueryNode::Field(FieldQuery {
291                field: "name".to_string(),
292                operator: FieldOperator::Equals,
293                value: QueryValue::Text("Alice".to_string()),
294            })
295        );
296    }
297
298    #[test]
299    fn test_and_query() {
300        let query = Query::field_eq("name", "Alice")
301            .and(Query::numeric_range("age", Some(25.0), Some(40.0)));
302
303        match query.root {
304            QueryNode::And(nodes) => {
305                assert_eq!(nodes.len(), 2);
306            }
307            _ => panic!("Expected And node"),
308        }
309    }
310
311    #[test]
312    fn test_or_query() {
313        let query = Query::field_eq("status", "active")
314            .or(Query::field_eq("status", "pending"));
315
316        match query.root {
317            QueryNode::Or(nodes) => {
318                assert_eq!(nodes.len(), 2);
319            }
320            _ => panic!("Expected Or node"),
321        }
322    }
323
324    #[test]
325    fn test_not_query() {
326        let query = Query::field_eq("deleted", "true").negate();
327
328        match query.root {
329            QueryNode::Not(_) => {}
330            _ => panic!("Expected Not node"),
331        }
332    }
333
334    #[test]
335    fn test_tag_query() {
336        let query = Query::tags("tags", vec!["rust".to_string(), "database".to_string()]);
337
338        match query.root {
339            QueryNode::Field(FieldQuery { field, operator, value }) => {
340                assert_eq!(field, "tags");
341                assert_eq!(operator, FieldOperator::In);
342                assert_eq!(value, QueryValue::Tags(vec!["rust".to_string(), "database".to_string()]));
343            }
344            _ => panic!("Expected Field node"),
345        }
346    }
347
348    #[test]
349    fn test_query_builder_and() {
350        let query = QueryBuilder::new()
351            .field_eq("name", "Alice")
352            .numeric_range("age", Some(25.0), Some(40.0))
353            .tags("tags", vec!["rust".to_string()])
354            .build_and();
355
356        match query.root {
357            QueryNode::And(nodes) => {
358                assert_eq!(nodes.len(), 3);
359            }
360            _ => panic!("Expected And node"),
361        }
362    }
363
364    #[test]
365    fn test_query_builder_or() {
366        let query = QueryBuilder::new()
367            .field_eq("status", "active")
368            .field_eq("status", "pending")
369            .build_or();
370
371        match query.root {
372            QueryNode::Or(nodes) => {
373                assert_eq!(nodes.len(), 2);
374            }
375            _ => panic!("Expected Or node"),
376        }
377    }
378
379    #[test]
380    fn test_complex_query() {
381        // (name:Alice AND age:[25 40]) OR (name:Bob AND age:[30 50])
382        let alice_query = Query::field_eq("name", "Alice")
383            .and(Query::numeric_range("age", Some(25.0), Some(40.0)));
384
385        let bob_query = Query::field_eq("name", "Bob")
386            .and(Query::numeric_range("age", Some(30.0), Some(50.0)));
387
388        let query = alice_query.or(bob_query);
389
390        match query.root {
391            QueryNode::Or(nodes) => {
392                assert_eq!(nodes.len(), 2);
393                // Each should be an And node
394                for node in nodes {
395                    match node {
396                        QueryNode::And(inner_nodes) => {
397                            assert_eq!(inner_nodes.len(), 2);
398                        }
399                        _ => panic!("Expected And node"),
400                    }
401                }
402            }
403            _ => panic!("Expected Or node"),
404        }
405    }
406
407    #[test]
408    fn test_prefix_query() {
409        let query = Query::prefix("email", "admin@");
410        match query.root {
411            QueryNode::Field(FieldQuery { operator, .. }) => {
412                assert_eq!(operator, FieldOperator::Prefix);
413            }
414            _ => panic!("Expected Field node"),
415        }
416    }
417
418    #[test]
419    fn test_fuzzy_query() {
420        let query = Query::fuzzy("name", "alice");
421        match query.root {
422            QueryNode::Field(FieldQuery { operator, .. }) => {
423                assert_eq!(operator, FieldOperator::Fuzzy);
424            }
425            _ => panic!("Expected Field node"),
426        }
427    }
428
429    #[test]
430    fn test_empty_builder_and() {
431        let query = QueryBuilder::new().build_and();
432        // Should produce a match-all query
433        match query.root {
434            QueryNode::Field(FieldQuery { field, .. }) => {
435                assert_eq!(field, "*");
436            }
437            _ => panic!("Expected Field node"),
438        }
439    }
440}