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    /// Create a vector similarity (KNN) search query.
98    ///
99    /// Returns the k nearest neighbors by vector similarity.
100    ///
101    /// # Arguments
102    /// * `field` - The vector field name (must be indexed with vector_hnsw/vector_flat)
103    /// * `vector` - Query embedding vector (must match field dimensionality)
104    /// * `k` - Number of nearest neighbors to return
105    ///
106    /// # Example
107    /// ```ignore
108    /// let embedding = vec![0.1, 0.2, 0.3, /* ... 1536 dims for OpenAI */];
109    /// let query = Query::vector("embedding", embedding, 10);
110    /// let results = engine.search("documents", &query).await?;
111    /// ```
112    pub fn vector(field: impl Into<String>, vector: Vec<f32>, k: usize) -> Self {
113        Self::new(QueryNode::Vector(VectorQuery {
114            field: field.into(),
115            vector,
116            k,
117        }))
118    }
119
120    /// Create a filtered vector search query.
121    ///
122    /// Combines a filter query with vector KNN search. The filter is applied
123    /// first, then KNN is performed on the filtered results.
124    ///
125    /// # Example
126    /// ```ignore
127    /// let embedding = get_embedding("semantic search query");
128    /// let query = Query::vector_filtered(
129    ///     Query::tags("category", vec!["tech".into()]),
130    ///     "embedding",
131    ///     embedding,
132    ///     10
133    /// );
134    /// ```
135    pub fn vector_filtered(
136        filter: Query,
137        field: impl Into<String>,
138        vector: Vec<f32>,
139        k: usize,
140    ) -> Self {
141        // Combine filter with vector search using AND
142        // The translator will handle the special KNN syntax
143        Self::new(QueryNode::And(vec![
144            filter.root,
145            QueryNode::Vector(VectorQuery {
146                field: field.into(),
147                vector,
148                k,
149            }),
150        ]))
151    }
152
153    /// Combine with AND
154    pub fn and(self, other: Query) -> Self {
155        Self::new(QueryNode::And(vec![self.root, other.root]))
156    }
157
158    /// Combine with OR
159    pub fn or(self, other: Query) -> Self {
160        Self::new(QueryNode::Or(vec![self.root, other.root]))
161    }
162
163    /// Negate query
164    pub fn negate(self) -> Self {
165        Self::new(QueryNode::Not(Box::new(self.root)))
166    }
167}
168
169/// Query AST node
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub enum QueryNode {
172    /// Field query: @field:value
173    Field(FieldQuery),
174    /// Boolean AND: (query1 query2)
175    And(Vec<QueryNode>),
176    /// Boolean OR: (query1 | query2)
177    Or(Vec<QueryNode>),
178    /// Boolean NOT: -query
179    Not(Box<QueryNode>),
180    /// Vector KNN search: [KNN k @field $blob]
181    Vector(VectorQuery),
182}
183
184/// Vector similarity search query (KNN)
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186pub struct VectorQuery {
187    /// Vector field name (e.g., "embedding")
188    pub field: String,
189    /// Query vector (will be serialized as FLOAT32 blob)
190    pub vector: Vec<f32>,
191    /// Number of nearest neighbors to return
192    pub k: usize,
193}
194
195/// Field query
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct FieldQuery {
198    /// Field name (e.g., "name", "age", "tags")
199    pub field: String,
200    /// Comparison operator
201    pub operator: FieldOperator,
202    /// Query value
203    pub value: QueryValue,
204}
205
206/// Field comparison operator
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub enum FieldOperator {
209    /// Exact match: @field:value
210    Equals,
211    /// Contains text: @field:*value*
212    Contains,
213    /// Numeric/date range: @field:[min max]
214    Range,
215    /// Tag membership: @tags:{value1|value2}
216    In,
217    /// Prefix match: @field:prefix*
218    Prefix,
219    /// Fuzzy match: @field:%value%
220    Fuzzy,
221}
222
223/// Query value type
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225pub enum QueryValue {
226    /// Text value
227    Text(String),
228    /// Numeric value
229    Numeric(f64),
230    /// Numeric range [min, max]
231    NumericRange { min: Option<f64>, max: Option<f64> },
232    /// Tag values (OR semantics)
233    Tags(Vec<String>),
234    /// Boolean value
235    Boolean(bool),
236}
237
238/// Builder for complex queries
239#[derive(Default)]
240pub struct QueryBuilder {
241    nodes: Vec<QueryNode>,
242}
243
244impl QueryBuilder {
245    /// Create a new query builder
246    pub fn new() -> Self {
247        Self { nodes: Vec::new() }
248    }
249
250    /// Add a field equals constraint
251    pub fn field_eq(mut self, field: impl Into<String>, value: impl Into<String>) -> Self {
252        self.nodes.push(QueryNode::Field(FieldQuery {
253            field: field.into(),
254            operator: FieldOperator::Equals,
255            value: QueryValue::Text(value.into()),
256        }));
257        self
258    }
259
260    /// Add a numeric equals constraint
261    pub fn numeric_eq(mut self, field: impl Into<String>, value: f64) -> Self {
262        self.nodes.push(QueryNode::Field(FieldQuery {
263            field: field.into(),
264            operator: FieldOperator::Equals,
265            value: QueryValue::Numeric(value),
266        }));
267        self
268    }
269
270    /// Add a numeric range constraint
271    pub fn numeric_range(mut self, field: impl Into<String>, min: Option<f64>, max: Option<f64>) -> Self {
272        self.nodes.push(QueryNode::Field(FieldQuery {
273            field: field.into(),
274            operator: FieldOperator::Range,
275            value: QueryValue::NumericRange { min, max },
276        }));
277        self
278    }
279
280    /// Add a tag constraint
281    pub fn tags(mut self, field: impl Into<String>, values: Vec<String>) -> Self {
282        self.nodes.push(QueryNode::Field(FieldQuery {
283            field: field.into(),
284            operator: FieldOperator::In,
285            value: QueryValue::Tags(values),
286        }));
287        self
288    }
289
290    /// Add a contains constraint
291    pub fn contains(mut self, field: impl Into<String>, text: impl Into<String>) -> Self {
292        self.nodes.push(QueryNode::Field(FieldQuery {
293            field: field.into(),
294            operator: FieldOperator::Contains,
295            value: QueryValue::Text(text.into()),
296        }));
297        self
298    }
299
300    /// Add a prefix constraint
301    pub fn prefix(mut self, field: impl Into<String>, prefix: impl Into<String>) -> Self {
302        self.nodes.push(QueryNode::Field(FieldQuery {
303            field: field.into(),
304            operator: FieldOperator::Prefix,
305            value: QueryValue::Text(prefix.into()),
306        }));
307        self
308    }
309
310    /// Add a fuzzy constraint
311    pub fn fuzzy(mut self, field: impl Into<String>, text: impl Into<String>) -> Self {
312        self.nodes.push(QueryNode::Field(FieldQuery {
313            field: field.into(),
314            operator: FieldOperator::Fuzzy,
315            value: QueryValue::Text(text.into()),
316        }));
317        self
318    }
319
320    /// Build query with AND semantics (all constraints must match)
321    pub fn build_and(self) -> Query {
322        if self.nodes.is_empty() {
323            // Empty query matches everything (RediSearch: *)
324            Query::new(QueryNode::Field(FieldQuery {
325                field: "*".to_string(),
326                operator: FieldOperator::Equals,
327                value: QueryValue::Text("*".to_string()),
328            }))
329        } else if self.nodes.len() == 1 {
330            Query::new(self.nodes.into_iter().next().unwrap())
331        } else {
332            Query::new(QueryNode::And(self.nodes))
333        }
334    }
335
336    /// Build query with OR semantics (any constraint can match)
337    pub fn build_or(self) -> Query {
338        if self.nodes.is_empty() {
339            // Empty query matches everything
340            Query::new(QueryNode::Field(FieldQuery {
341                field: "*".to_string(),
342                operator: FieldOperator::Equals,
343                value: QueryValue::Text("*".to_string()),
344            }))
345        } else if self.nodes.len() == 1 {
346            Query::new(self.nodes.into_iter().next().unwrap())
347        } else {
348            Query::new(QueryNode::Or(self.nodes))
349        }
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_simple_field_query() {
359        let query = Query::field_eq("name", "Alice");
360        assert_eq!(
361            query.root,
362            QueryNode::Field(FieldQuery {
363                field: "name".to_string(),
364                operator: FieldOperator::Equals,
365                value: QueryValue::Text("Alice".to_string()),
366            })
367        );
368    }
369
370    #[test]
371    fn test_and_query() {
372        let query = Query::field_eq("name", "Alice")
373            .and(Query::numeric_range("age", Some(25.0), Some(40.0)));
374
375        match query.root {
376            QueryNode::And(nodes) => {
377                assert_eq!(nodes.len(), 2);
378            }
379            _ => panic!("Expected And node"),
380        }
381    }
382
383    #[test]
384    fn test_or_query() {
385        let query = Query::field_eq("status", "active")
386            .or(Query::field_eq("status", "pending"));
387
388        match query.root {
389            QueryNode::Or(nodes) => {
390                assert_eq!(nodes.len(), 2);
391            }
392            _ => panic!("Expected Or node"),
393        }
394    }
395
396    #[test]
397    fn test_not_query() {
398        let query = Query::field_eq("deleted", "true").negate();
399
400        match query.root {
401            QueryNode::Not(_) => {}
402            _ => panic!("Expected Not node"),
403        }
404    }
405
406    #[test]
407    fn test_tag_query() {
408        let query = Query::tags("tags", vec!["rust".to_string(), "database".to_string()]);
409
410        match query.root {
411            QueryNode::Field(FieldQuery { field, operator, value }) => {
412                assert_eq!(field, "tags");
413                assert_eq!(operator, FieldOperator::In);
414                assert_eq!(value, QueryValue::Tags(vec!["rust".to_string(), "database".to_string()]));
415            }
416            _ => panic!("Expected Field node"),
417        }
418    }
419
420    #[test]
421    fn test_query_builder_and() {
422        let query = QueryBuilder::new()
423            .field_eq("name", "Alice")
424            .numeric_range("age", Some(25.0), Some(40.0))
425            .tags("tags", vec!["rust".to_string()])
426            .build_and();
427
428        match query.root {
429            QueryNode::And(nodes) => {
430                assert_eq!(nodes.len(), 3);
431            }
432            _ => panic!("Expected And node"),
433        }
434    }
435
436    #[test]
437    fn test_query_builder_or() {
438        let query = QueryBuilder::new()
439            .field_eq("status", "active")
440            .field_eq("status", "pending")
441            .build_or();
442
443        match query.root {
444            QueryNode::Or(nodes) => {
445                assert_eq!(nodes.len(), 2);
446            }
447            _ => panic!("Expected Or node"),
448        }
449    }
450
451    #[test]
452    fn test_complex_query() {
453        // (name:Alice AND age:[25 40]) OR (name:Bob AND age:[30 50])
454        let alice_query = Query::field_eq("name", "Alice")
455            .and(Query::numeric_range("age", Some(25.0), Some(40.0)));
456
457        let bob_query = Query::field_eq("name", "Bob")
458            .and(Query::numeric_range("age", Some(30.0), Some(50.0)));
459
460        let query = alice_query.or(bob_query);
461
462        match query.root {
463            QueryNode::Or(nodes) => {
464                assert_eq!(nodes.len(), 2);
465                // Each should be an And node
466                for node in nodes {
467                    match node {
468                        QueryNode::And(inner_nodes) => {
469                            assert_eq!(inner_nodes.len(), 2);
470                        }
471                        _ => panic!("Expected And node"),
472                    }
473                }
474            }
475            _ => panic!("Expected Or node"),
476        }
477    }
478
479    #[test]
480    fn test_prefix_query() {
481        let query = Query::prefix("email", "admin@");
482        match query.root {
483            QueryNode::Field(FieldQuery { operator, .. }) => {
484                assert_eq!(operator, FieldOperator::Prefix);
485            }
486            _ => panic!("Expected Field node"),
487        }
488    }
489
490    #[test]
491    fn test_fuzzy_query() {
492        let query = Query::fuzzy("name", "alice");
493        match query.root {
494            QueryNode::Field(FieldQuery { operator, .. }) => {
495                assert_eq!(operator, FieldOperator::Fuzzy);
496            }
497            _ => panic!("Expected Field node"),
498        }
499    }
500
501    #[test]
502    fn test_empty_builder_and() {
503        let query = QueryBuilder::new().build_and();
504        // Should produce a match-all query
505        match query.root {
506            QueryNode::Field(FieldQuery { field, .. }) => {
507                assert_eq!(field, "*");
508            }
509            _ => panic!("Expected Field node"),
510        }
511    }
512}