sync_engine/search/
query_builder.rs

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