polars_redis/
query_builder.rs

1//! Query builder for translating filter expressions to RediSearch queries.
2//!
3//! This module provides utilities for converting simple filter predicates
4//! into RediSearch query syntax, enabling automatic predicate pushdown.
5//!
6//! # Supported Operations
7//!
8//! | Operation | RediSearch |
9//! |-----------|------------|
10//! | `Predicate::eq("age", 30)` | `@age:[30 30]` |
11//! | `Predicate::gt("age", 30)` | `@age:[(30 +inf]` |
12//! | `Predicate::between("age", 20, 40)` | `@age:[20 40]` |
13//! | `Predicate::text_search("title", "python")` | `@title:python` |
14//! | `Predicate::prefix("name", "jo")` | `@name:jo*` |
15//! | `Predicate::tag("status", "active")` | `@status:{active}` |
16//! | `Predicate::geo_radius("loc", -122.4, 37.7, 10.0, "km")` | `@loc:[-122.4 37.7 10 km]` |
17//! | `pred1.and(pred2)` | `query1 query2` |
18//! | `pred1.or(pred2)` | `query1 \| query2` |
19//! | `pred.not()` | `-(query)` |
20//!
21//! # Example
22//!
23//! ```ignore
24//! use polars_redis::query_builder::{Predicate, PredicateBuilder};
25//!
26//! // Build: @age:[(30 +inf] @status:{active}
27//! let query = PredicateBuilder::new()
28//!     .and(Predicate::gt("age", 30))
29//!     .and(Predicate::tag("status", "active"))
30//!     .build();
31//! ```
32
33use std::fmt;
34
35/// A single predicate that can be translated to RediSearch.
36#[derive(Debug, Clone)]
37pub enum Predicate {
38    // ========================================================================
39    // Comparison operators
40    // ========================================================================
41    /// Equality: `@field:{value}` for TAG, `@field:[value value]` for NUMERIC
42    Eq(String, Value),
43    /// Not equal: `-@field:{value}`
44    Ne(String, Value),
45    /// Greater than: `@field:[(value +inf]`
46    Gt(String, Value),
47    /// Greater than or equal: `@field:[value +inf]`
48    Gte(String, Value),
49    /// Less than: `@field:[-inf (value]`
50    Lt(String, Value),
51    /// Less than or equal: `@field:[-inf value]`
52    Lte(String, Value),
53    /// Between (inclusive): `@field:[min max]`
54    Between(String, Value, Value),
55
56    // ========================================================================
57    // Logical operators
58    // ========================================================================
59    /// AND of multiple predicates
60    And(Vec<Predicate>),
61    /// OR of multiple predicates
62    Or(Vec<Predicate>),
63    /// NOT: `-(query)`
64    Not(Box<Predicate>),
65
66    // ========================================================================
67    // Text search
68    // ========================================================================
69    /// Full-text search: `@field:term`
70    TextSearch(String, String),
71    /// Prefix match: `@field:prefix*`
72    Prefix(String, String),
73    /// Suffix match: `@field:*suffix`
74    Suffix(String, String),
75    /// Infix/contains match: `@field:*substring*`
76    Infix(String, String),
77    /// Wildcard match: `@field:pattern`
78    Wildcard(String, String),
79    /// Exact wildcard match: `@field:"w'pattern'"`
80    WildcardExact(String, String),
81    /// Fuzzy match: `@field:%term%` (distance 1), `@field:%%term%%` (distance 2)
82    Fuzzy(String, String, u8),
83    /// Phrase search: `@field:(word1 word2 word3)`
84    Phrase(String, Vec<String>),
85    /// Phrase search with slop and inorder: `@field:(word1 word2)=>{$slop:2;$inorder:true}`
86    PhraseWithOptions {
87        field: String,
88        words: Vec<String>,
89        slop: Option<u32>,
90        inorder: Option<bool>,
91    },
92    /// Optional term: `~@field:term` (boosts score but not required)
93    Optional(Box<Predicate>),
94
95    // ========================================================================
96    // Tag operations
97    // ========================================================================
98    /// Tag match: `@field:{tag}`
99    Tag(String, String),
100    /// Tag OR: `@field:{tag1|tag2|tag3}`
101    TagOr(String, Vec<String>),
102
103    // ========================================================================
104    // Multi-field search
105    // ========================================================================
106    /// Search across multiple fields: `@field1|field2|field3:term`
107    MultiFieldSearch(Vec<String>, String),
108
109    // ========================================================================
110    // Geo operations
111    // ========================================================================
112    /// Geo radius: `@field:[lon lat radius unit]`
113    GeoRadius(String, f64, f64, f64, String),
114    /// Geo polygon filter using WITHIN
115    GeoPolygon {
116        field: String,
117        /// Points as (lon, lat) pairs forming a closed polygon
118        points: Vec<(f64, f64)>,
119    },
120
121    // ========================================================================
122    // Null checks
123    // ========================================================================
124    /// Field is missing: `ismissing(@field)`
125    IsMissing(String),
126    /// Field exists: `-ismissing(@field)`
127    IsNotMissing(String),
128
129    // ========================================================================
130    // Scoring
131    // ========================================================================
132    /// Boost: `(query) => { $weight: value; }`
133    Boost(Box<Predicate>, f64),
134
135    // ========================================================================
136    // Vector search (KNN)
137    // ========================================================================
138    /// KNN vector search: `*=>[KNN k @field $vec]`
139    VectorKnn {
140        field: String,
141        k: usize,
142        /// Vector as bytes (to be passed as PARAMS)
143        vector_param: String,
144        /// Optional pre-filter query
145        pre_filter: Option<Box<Predicate>>,
146    },
147    /// Vector range search: `@field:[VECTOR_RANGE radius $vec]`
148    VectorRange {
149        field: String,
150        radius: f64,
151        vector_param: String,
152    },
153
154    // ========================================================================
155    // Raw/escape hatch
156    // ========================================================================
157    /// Raw RediSearch query (escape hatch)
158    Raw(String),
159}
160
161/// A value in a predicate.
162#[derive(Debug, Clone)]
163pub enum Value {
164    Int(i64),
165    Float(f64),
166    String(String),
167    Bool(bool),
168}
169
170impl fmt::Display for Value {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        match self {
173            Value::Int(n) => write!(f, "{}", n),
174            Value::Float(n) => write!(f, "{}", n),
175            Value::String(s) => write!(f, "{}", s),
176            Value::Bool(b) => write!(f, "{}", b),
177        }
178    }
179}
180
181impl Value {
182    /// Check if this value should be treated as numeric.
183    pub fn is_numeric(&self) -> bool {
184        matches!(self, Value::Int(_) | Value::Float(_))
185    }
186}
187
188// Convenience conversions
189impl From<i64> for Value {
190    fn from(v: i64) -> Self {
191        Value::Int(v)
192    }
193}
194
195impl From<i32> for Value {
196    fn from(v: i32) -> Self {
197        Value::Int(v as i64)
198    }
199}
200
201impl From<f64> for Value {
202    fn from(v: f64) -> Self {
203        Value::Float(v)
204    }
205}
206
207impl From<&str> for Value {
208    fn from(v: &str) -> Self {
209        Value::String(v.to_string())
210    }
211}
212
213impl From<String> for Value {
214    fn from(v: String) -> Self {
215        Value::String(v)
216    }
217}
218
219impl From<bool> for Value {
220    fn from(v: bool) -> Self {
221        Value::Bool(v)
222    }
223}
224
225impl Predicate {
226    // ========================================================================
227    // Comparison constructors
228    // ========================================================================
229
230    /// Create an equality predicate.
231    pub fn eq(field: impl Into<String>, value: impl Into<Value>) -> Self {
232        Predicate::Eq(field.into(), value.into())
233    }
234
235    /// Create a not-equal predicate.
236    pub fn ne(field: impl Into<String>, value: impl Into<Value>) -> Self {
237        Predicate::Ne(field.into(), value.into())
238    }
239
240    /// Create a greater-than predicate.
241    pub fn gt(field: impl Into<String>, value: impl Into<Value>) -> Self {
242        Predicate::Gt(field.into(), value.into())
243    }
244
245    /// Create a greater-than-or-equal predicate.
246    pub fn gte(field: impl Into<String>, value: impl Into<Value>) -> Self {
247        Predicate::Gte(field.into(), value.into())
248    }
249
250    /// Create a less-than predicate.
251    pub fn lt(field: impl Into<String>, value: impl Into<Value>) -> Self {
252        Predicate::Lt(field.into(), value.into())
253    }
254
255    /// Create a less-than-or-equal predicate.
256    pub fn lte(field: impl Into<String>, value: impl Into<Value>) -> Self {
257        Predicate::Lte(field.into(), value.into())
258    }
259
260    /// Create a between predicate (inclusive).
261    pub fn between(field: impl Into<String>, min: impl Into<Value>, max: impl Into<Value>) -> Self {
262        Predicate::Between(field.into(), min.into(), max.into())
263    }
264
265    // ========================================================================
266    // Text search constructors
267    // ========================================================================
268
269    /// Create a full-text search predicate.
270    pub fn text_search(field: impl Into<String>, term: impl Into<String>) -> Self {
271        Predicate::TextSearch(field.into(), term.into())
272    }
273
274    /// Create a prefix match predicate.
275    pub fn prefix(field: impl Into<String>, prefix: impl Into<String>) -> Self {
276        Predicate::Prefix(field.into(), prefix.into())
277    }
278
279    /// Create a suffix match predicate.
280    pub fn suffix(field: impl Into<String>, suffix: impl Into<String>) -> Self {
281        Predicate::Suffix(field.into(), suffix.into())
282    }
283
284    /// Create an infix/contains match predicate: `*substring*`
285    pub fn infix(field: impl Into<String>, substring: impl Into<String>) -> Self {
286        Predicate::Infix(field.into(), substring.into())
287    }
288
289    /// Create a wildcard match predicate with simple wildcards.
290    pub fn wildcard(field: impl Into<String>, pattern: impl Into<String>) -> Self {
291        Predicate::Wildcard(field.into(), pattern.into())
292    }
293
294    /// Create an exact wildcard match predicate: `w'pattern'`
295    /// Supports `*` (any chars) and `?` (single char) wildcards.
296    pub fn wildcard_exact(field: impl Into<String>, pattern: impl Into<String>) -> Self {
297        Predicate::WildcardExact(field.into(), pattern.into())
298    }
299
300    /// Create a fuzzy match predicate.
301    pub fn fuzzy(field: impl Into<String>, term: impl Into<String>, distance: u8) -> Self {
302        Predicate::Fuzzy(field.into(), term.into(), distance.clamp(1, 3))
303    }
304
305    /// Create a phrase search predicate.
306    pub fn phrase(field: impl Into<String>, words: Vec<impl Into<String>>) -> Self {
307        Predicate::Phrase(field.into(), words.into_iter().map(|w| w.into()).collect())
308    }
309
310    /// Create a phrase search predicate with slop and inorder options.
311    ///
312    /// # Arguments
313    /// * `field` - The field to search
314    /// * `words` - Words that should appear in the phrase
315    /// * `slop` - Number of intervening terms allowed (None = exact match)
316    /// * `inorder` - Whether words must appear in order (None = any order)
317    pub fn phrase_with_options(
318        field: impl Into<String>,
319        words: Vec<impl Into<String>>,
320        slop: Option<u32>,
321        inorder: Option<bool>,
322    ) -> Self {
323        Predicate::PhraseWithOptions {
324            field: field.into(),
325            words: words.into_iter().map(|w| w.into()).collect(),
326            slop,
327            inorder,
328        }
329    }
330
331    /// Mark a predicate as optional (boosts score but not required).
332    /// Generates: `~(query)`
333    pub fn optional(self) -> Self {
334        Predicate::Optional(Box::new(self))
335    }
336
337    // ========================================================================
338    // Tag constructors
339    // ========================================================================
340
341    /// Create a tag match predicate.
342    pub fn tag(field: impl Into<String>, tag: impl Into<String>) -> Self {
343        Predicate::Tag(field.into(), tag.into())
344    }
345
346    /// Create a tag OR predicate.
347    pub fn tag_or(field: impl Into<String>, tags: Vec<impl Into<String>>) -> Self {
348        Predicate::TagOr(field.into(), tags.into_iter().map(|t| t.into()).collect())
349    }
350
351    // ========================================================================
352    // Multi-field constructors
353    // ========================================================================
354
355    /// Create a multi-field text search: `@field1|field2:term`
356    pub fn multi_field_search(fields: Vec<impl Into<String>>, term: impl Into<String>) -> Self {
357        Predicate::MultiFieldSearch(fields.into_iter().map(|f| f.into()).collect(), term.into())
358    }
359
360    // ========================================================================
361    // Geo constructors
362    // ========================================================================
363
364    /// Create a geo radius predicate.
365    pub fn geo_radius(
366        field: impl Into<String>,
367        lon: f64,
368        lat: f64,
369        radius: f64,
370        unit: impl Into<String>,
371    ) -> Self {
372        Predicate::GeoRadius(field.into(), lon, lat, radius, unit.into())
373    }
374
375    /// Create a geo polygon predicate.
376    /// Points should form a closed polygon (first and last point should be the same).
377    pub fn geo_polygon(field: impl Into<String>, points: Vec<(f64, f64)>) -> Self {
378        Predicate::GeoPolygon {
379            field: field.into(),
380            points,
381        }
382    }
383
384    // ========================================================================
385    // Vector search constructors
386    // ========================================================================
387
388    /// Create a KNN vector search predicate.
389    ///
390    /// # Arguments
391    /// * `field` - The vector field name
392    /// * `k` - Number of nearest neighbors to return
393    /// * `vector_param` - Parameter name for the vector (will be passed via PARAMS)
394    pub fn vector_knn(field: impl Into<String>, k: usize, vector_param: impl Into<String>) -> Self {
395        Predicate::VectorKnn {
396            field: field.into(),
397            k,
398            vector_param: vector_param.into(),
399            pre_filter: None,
400        }
401    }
402
403    /// Create a KNN vector search with pre-filter.
404    pub fn vector_knn_with_filter(
405        field: impl Into<String>,
406        k: usize,
407        vector_param: impl Into<String>,
408        pre_filter: Predicate,
409    ) -> Self {
410        Predicate::VectorKnn {
411            field: field.into(),
412            k,
413            vector_param: vector_param.into(),
414            pre_filter: Some(Box::new(pre_filter)),
415        }
416    }
417
418    /// Create a vector range search predicate.
419    pub fn vector_range(
420        field: impl Into<String>,
421        radius: f64,
422        vector_param: impl Into<String>,
423    ) -> Self {
424        Predicate::VectorRange {
425            field: field.into(),
426            radius,
427            vector_param: vector_param.into(),
428        }
429    }
430
431    // ========================================================================
432    // Null check constructors
433    // ========================================================================
434
435    /// Check if field is missing.
436    pub fn is_missing(field: impl Into<String>) -> Self {
437        Predicate::IsMissing(field.into())
438    }
439
440    /// Check if field exists.
441    pub fn is_not_missing(field: impl Into<String>) -> Self {
442        Predicate::IsNotMissing(field.into())
443    }
444
445    // ========================================================================
446    // Raw constructor
447    // ========================================================================
448
449    /// Create a raw RediSearch query.
450    pub fn raw(query: impl Into<String>) -> Self {
451        Predicate::Raw(query.into())
452    }
453
454    // ========================================================================
455    // Combinators
456    // ========================================================================
457
458    /// Combine with AND.
459    pub fn and(self, other: Predicate) -> Self {
460        match self {
461            Predicate::And(mut preds) => {
462                preds.push(other);
463                Predicate::And(preds)
464            }
465            _ => Predicate::And(vec![self, other]),
466        }
467    }
468
469    /// Combine with OR.
470    pub fn or(self, other: Predicate) -> Self {
471        match self {
472            Predicate::Or(mut preds) => {
473                preds.push(other);
474                Predicate::Or(preds)
475            }
476            _ => Predicate::Or(vec![self, other]),
477        }
478    }
479
480    /// Negate this predicate.
481    pub fn negate(self) -> Self {
482        Predicate::Not(Box::new(self))
483    }
484
485    /// Boost this predicate's relevance score.
486    pub fn boost(self, weight: f64) -> Self {
487        Predicate::Boost(Box::new(self), weight)
488    }
489
490    // ========================================================================
491    // Query generation
492    // ========================================================================
493
494    /// Get parameters that need to be passed to FT.SEARCH via PARAMS.
495    /// Returns a list of (name, value) pairs.
496    pub fn get_params(&self) -> Vec<(String, String)> {
497        let mut params = Vec::new();
498        self.collect_params(&mut params);
499        params
500    }
501
502    /// Internal helper to collect params recursively.
503    fn collect_params(&self, params: &mut Vec<(String, String)>) {
504        match self {
505            Predicate::GeoPolygon { points, .. } => {
506                let coords: Vec<String> = points
507                    .iter()
508                    .map(|(lon, lat)| format!("{} {}", lon, lat))
509                    .collect();
510                let wkt = format!("POLYGON(({}))", coords.join(", "));
511                params.push(("poly".to_string(), wkt));
512            }
513            Predicate::VectorKnn {
514                vector_param,
515                pre_filter,
516                ..
517            } => {
518                // Vector data needs to be provided externally
519                // We just note the param name here
520                params.push((vector_param.clone(), String::new()));
521                if let Some(filter) = pre_filter {
522                    filter.collect_params(params);
523                }
524            }
525            Predicate::VectorRange { vector_param, .. } => {
526                params.push((vector_param.clone(), String::new()));
527            }
528            Predicate::And(preds) | Predicate::Or(preds) => {
529                for p in preds {
530                    p.collect_params(params);
531                }
532            }
533            Predicate::Not(inner) | Predicate::Optional(inner) | Predicate::Boost(inner, _) => {
534                inner.collect_params(params);
535            }
536            _ => {}
537        }
538    }
539
540    /// Convert to RediSearch query string.
541    pub fn to_query(&self) -> String {
542        match self {
543            // Comparisons
544            Predicate::Eq(field, value) => {
545                if value.is_numeric() {
546                    format!("@{}:[{} {}]", field, value, value)
547                } else {
548                    format!("@{}:{{{}}}", field, escape_tag_value(&value.to_string()))
549                }
550            }
551            Predicate::Ne(field, value) => {
552                if value.is_numeric() {
553                    format!("-@{}:[{} {}]", field, value, value)
554                } else {
555                    format!("-@{}:{{{}}}", field, escape_tag_value(&value.to_string()))
556                }
557            }
558            Predicate::Gt(field, value) => {
559                format!("@{}:[({} +inf]", field, value)
560            }
561            Predicate::Gte(field, value) => {
562                format!("@{}:[{} +inf]", field, value)
563            }
564            Predicate::Lt(field, value) => {
565                format!("@{}:[-inf ({}]", field, value)
566            }
567            Predicate::Lte(field, value) => {
568                format!("@{}:[-inf {}]", field, value)
569            }
570            Predicate::Between(field, min, max) => {
571                format!("@{}:[{} {}]", field, min, max)
572            }
573
574            // Logical
575            Predicate::And(preds) => {
576                if preds.is_empty() {
577                    "*".to_string()
578                } else {
579                    preds
580                        .iter()
581                        .map(|p| {
582                            let q = p.to_query();
583                            if matches!(p, Predicate::Or(_)) {
584                                format!("({})", q)
585                            } else {
586                                q
587                            }
588                        })
589                        .collect::<Vec<_>>()
590                        .join(" ")
591                }
592            }
593            Predicate::Or(preds) => {
594                if preds.is_empty() {
595                    "*".to_string()
596                } else {
597                    preds
598                        .iter()
599                        .map(|p| {
600                            let q = p.to_query();
601                            if matches!(p, Predicate::And(_)) {
602                                format!("({})", q)
603                            } else {
604                                q
605                            }
606                        })
607                        .collect::<Vec<_>>()
608                        .join(" | ")
609                }
610            }
611            Predicate::Not(inner) => {
612                format!("-({})", inner.to_query())
613            }
614
615            // Text search
616            Predicate::TextSearch(field, term) => {
617                format!("@{}:{}", field, escape_text_value(term))
618            }
619            Predicate::Prefix(field, prefix) => {
620                format!("@{}:{}*", field, escape_text_value(prefix))
621            }
622            Predicate::Suffix(field, suffix) => {
623                format!("@{}:*{}", field, escape_text_value(suffix))
624            }
625            Predicate::Infix(field, substring) => {
626                format!("@{}:*{}*", field, escape_text_value(substring))
627            }
628            Predicate::Wildcard(field, pattern) => {
629                format!("@{}:{}", field, pattern)
630            }
631            Predicate::WildcardExact(field, pattern) => {
632                format!("@{}:\"w'{}\"", field, pattern)
633            }
634            Predicate::Fuzzy(field, term, distance) => {
635                let pct = "%".repeat(*distance as usize);
636                format!("@{}:{}{}{}", field, pct, escape_text_value(term), pct)
637            }
638            Predicate::Phrase(field, words) => {
639                format!("@{}:({})", field, words.join(" "))
640            }
641            Predicate::PhraseWithOptions {
642                field,
643                words,
644                slop,
645                inorder,
646            } => {
647                let phrase = words.join(" ");
648                let mut attrs = Vec::new();
649                if let Some(s) = slop {
650                    attrs.push(format!("$slop: {}", s));
651                }
652                if let Some(io) = inorder {
653                    attrs.push(format!("$inorder: {}", io));
654                }
655                if attrs.is_empty() {
656                    format!("@{}:({})", field, phrase)
657                } else {
658                    format!("@{}:({}) => {{ {}; }}", field, phrase, attrs.join("; "))
659                }
660            }
661            Predicate::Optional(inner) => {
662                format!("~{}", inner.to_query())
663            }
664
665            // Tags
666            Predicate::Tag(field, tag) => {
667                format!("@{}:{{{}}}", field, escape_tag_value(tag))
668            }
669            Predicate::TagOr(field, tags) => {
670                let escaped: Vec<String> = tags.iter().map(|t| escape_tag_value(t)).collect();
671                format!("@{}:{{{}}}", field, escaped.join("|"))
672            }
673
674            // Multi-field search
675            Predicate::MultiFieldSearch(fields, term) => {
676                format!("@{}:{}", fields.join("|"), escape_text_value(term))
677            }
678
679            // Geo
680            Predicate::GeoRadius(field, lon, lat, radius, unit) => {
681                format!("@{}:[{} {} {} {}]", field, lon, lat, radius, unit)
682            }
683            Predicate::GeoPolygon { field, points } => {
684                // Format: @field:[WITHIN $poly] with PARAMS containing WKT polygon
685                // For query string, we output the WITHIN syntax
686                // The actual polygon data needs to be passed via PARAMS
687                // Points are (lon, lat) pairs
688                let _coords: Vec<String> = points
689                    .iter()
690                    .map(|(lon, lat)| format!("{} {}", lon, lat))
691                    .collect();
692                // Note: The actual WKT polygon is passed via PARAMS 2 poly "POLYGON((...))""
693                format!("@{}:[WITHIN $poly]", field)
694            }
695
696            // Null checks
697            Predicate::IsMissing(field) => {
698                format!("ismissing(@{})", field)
699            }
700            Predicate::IsNotMissing(field) => {
701                format!("-ismissing(@{})", field)
702            }
703
704            // Boost
705            Predicate::Boost(inner, weight) => {
706                format!("({}) => {{ $weight: {}; }}", inner.to_query(), weight)
707            }
708
709            // Vector search
710            Predicate::VectorKnn {
711                field,
712                k,
713                vector_param,
714                pre_filter,
715            } => {
716                let filter = pre_filter
717                    .as_ref()
718                    .map(|p| p.to_query())
719                    .unwrap_or_else(|| "*".to_string());
720                format!("{}=>[KNN {} @{} ${}]", filter, k, field, vector_param)
721            }
722            Predicate::VectorRange {
723                field,
724                radius,
725                vector_param,
726            } => {
727                format!("@{}:[VECTOR_RANGE {} ${}]", field, radius, vector_param)
728            }
729
730            // Raw
731            Predicate::Raw(query) => query.clone(),
732        }
733    }
734}
735
736/// Escape special characters in TAG values.
737fn escape_tag_value(s: &str) -> String {
738    let mut result = String::with_capacity(s.len());
739    for c in s.chars() {
740        match c {
741            ',' | '.' | '<' | '>' | '{' | '}' | '[' | ']' | '"' | '\'' | ':' | ';' | '!' | '@'
742            | '#' | '$' | '%' | '^' | '&' | '*' | '(' | ')' | '-' | '+' | '=' | '~' | ' ' => {
743                result.push('\\');
744                result.push(c);
745            }
746            _ => result.push(c),
747        }
748    }
749    result
750}
751
752/// Escape special characters in TEXT search values.
753fn escape_text_value(s: &str) -> String {
754    let mut result = String::with_capacity(s.len());
755    for c in s.chars() {
756        match c {
757            '@' | '{' | '}' | '[' | ']' | '(' | ')' | '|' | '-' | '~' => {
758                result.push('\\');
759                result.push(c);
760            }
761            _ => result.push(c),
762        }
763    }
764    result
765}
766
767/// Builder for constructing predicates fluently.
768#[derive(Debug, Clone, Default)]
769pub struct PredicateBuilder {
770    predicates: Vec<Predicate>,
771}
772
773impl PredicateBuilder {
774    /// Create a new empty builder.
775    pub fn new() -> Self {
776        Self::default()
777    }
778
779    /// Add an AND predicate.
780    pub fn and(mut self, predicate: Predicate) -> Self {
781        self.predicates.push(predicate);
782        self
783    }
784
785    /// Build the final query string.
786    pub fn build(self) -> String {
787        if self.predicates.is_empty() {
788            "*".to_string()
789        } else if self.predicates.len() == 1 {
790            self.predicates[0].to_query()
791        } else {
792            Predicate::And(self.predicates).to_query()
793        }
794    }
795
796    /// Build as a Predicate (for further composition).
797    pub fn build_predicate(self) -> Predicate {
798        if self.predicates.is_empty() {
799            Predicate::Raw("*".to_string())
800        } else if self.predicates.len() == 1 {
801            self.predicates.into_iter().next().unwrap()
802        } else {
803            Predicate::And(self.predicates)
804        }
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    // Comparison tests
813    #[test]
814    fn test_eq_numeric() {
815        let pred = Predicate::eq("age", 30);
816        assert_eq!(pred.to_query(), "@age:[30 30]");
817    }
818
819    #[test]
820    fn test_eq_string() {
821        let pred = Predicate::eq("status", "active");
822        assert_eq!(pred.to_query(), "@status:{active}");
823    }
824
825    #[test]
826    fn test_gt() {
827        let pred = Predicate::gt("age", 30);
828        assert_eq!(pred.to_query(), "@age:[(30 +inf]");
829    }
830
831    #[test]
832    fn test_gte() {
833        let pred = Predicate::gte("age", 30);
834        assert_eq!(pred.to_query(), "@age:[30 +inf]");
835    }
836
837    #[test]
838    fn test_lt() {
839        let pred = Predicate::lt("age", 30);
840        assert_eq!(pred.to_query(), "@age:[-inf (30]");
841    }
842
843    #[test]
844    fn test_lte() {
845        let pred = Predicate::lte("age", 30);
846        assert_eq!(pred.to_query(), "@age:[-inf 30]");
847    }
848
849    #[test]
850    fn test_between() {
851        let pred = Predicate::between("age", 20, 40);
852        assert_eq!(pred.to_query(), "@age:[20 40]");
853    }
854
855    #[test]
856    fn test_ne() {
857        let pred = Predicate::ne("status", "deleted");
858        assert_eq!(pred.to_query(), "-@status:{deleted}");
859    }
860
861    // Logical tests
862    #[test]
863    fn test_and() {
864        let pred = Predicate::gt("age", 30).and(Predicate::eq("status", "active"));
865        assert_eq!(pred.to_query(), "@age:[(30 +inf] @status:{active}");
866    }
867
868    #[test]
869    fn test_or() {
870        let pred = Predicate::eq("status", "active").or(Predicate::eq("status", "pending"));
871        assert_eq!(pred.to_query(), "@status:{active} | @status:{pending}");
872    }
873
874    #[test]
875    fn test_not() {
876        let pred = Predicate::eq("status", "deleted").negate();
877        assert_eq!(pred.to_query(), "-(@status:{deleted})");
878    }
879
880    #[test]
881    fn test_complex_and_or() {
882        let pred = Predicate::gt("age", 30)
883            .and(Predicate::eq("status", "active"))
884            .or(Predicate::lt("age", 20));
885        let query = pred.to_query();
886        assert!(query.contains("@age:[(30 +inf]"));
887        assert!(query.contains("@status:{active}"));
888        assert!(query.contains("|"));
889    }
890
891    // Text search tests
892    #[test]
893    fn test_text_search() {
894        let pred = Predicate::text_search("title", "python");
895        assert_eq!(pred.to_query(), "@title:python");
896    }
897
898    #[test]
899    fn test_prefix() {
900        let pred = Predicate::prefix("name", "jo");
901        assert_eq!(pred.to_query(), "@name:jo*");
902    }
903
904    #[test]
905    fn test_suffix() {
906        let pred = Predicate::suffix("name", "son");
907        assert_eq!(pred.to_query(), "@name:*son");
908    }
909
910    #[test]
911    fn test_fuzzy() {
912        let pred = Predicate::fuzzy("name", "john", 1);
913        assert_eq!(pred.to_query(), "@name:%john%");
914
915        let pred2 = Predicate::fuzzy("name", "john", 2);
916        assert_eq!(pred2.to_query(), "@name:%%john%%");
917    }
918
919    #[test]
920    fn test_phrase() {
921        let pred = Predicate::phrase("title", vec!["hello", "world"]);
922        assert_eq!(pred.to_query(), "@title:(hello world)");
923    }
924
925    // Tag tests
926    #[test]
927    fn test_tag() {
928        let pred = Predicate::tag("category", "science");
929        assert_eq!(pred.to_query(), "@category:{science}");
930    }
931
932    #[test]
933    fn test_tag_or() {
934        let pred = Predicate::tag_or("tags", vec!["urgent", "important"]);
935        assert_eq!(pred.to_query(), "@tags:{urgent|important}");
936    }
937
938    // Geo tests
939    #[test]
940    fn test_geo_radius() {
941        let pred = Predicate::geo_radius("location", -122.4, 37.7, 10.0, "km");
942        assert_eq!(pred.to_query(), "@location:[-122.4 37.7 10 km]");
943    }
944
945    // Null tests
946    #[test]
947    fn test_is_missing() {
948        let pred = Predicate::is_missing("email");
949        assert_eq!(pred.to_query(), "ismissing(@email)");
950    }
951
952    #[test]
953    fn test_is_not_missing() {
954        let pred = Predicate::is_not_missing("email");
955        assert_eq!(pred.to_query(), "-ismissing(@email)");
956    }
957
958    // Boost tests
959    #[test]
960    fn test_boost() {
961        let pred = Predicate::text_search("title", "python").boost(2.0);
962        assert_eq!(pred.to_query(), "(@title:python) => { $weight: 2; }");
963    }
964
965    // Builder tests
966    #[test]
967    fn test_builder() {
968        let query = PredicateBuilder::new()
969            .and(Predicate::gt("age", 30))
970            .and(Predicate::tag("status", "active"))
971            .build();
972        assert_eq!(query, "@age:[(30 +inf] @status:{active}");
973    }
974
975    #[test]
976    fn test_builder_empty() {
977        let query = PredicateBuilder::new().build();
978        assert_eq!(query, "*");
979    }
980
981    // Escaping tests
982    #[test]
983    fn test_escape_tag_value() {
984        let pred = Predicate::tag("email", "user@example.com");
985        assert_eq!(pred.to_query(), r"@email:{user\@example\.com}");
986    }
987
988    #[test]
989    fn test_float_values() {
990        let pred = Predicate::gt("score", 3.5);
991        assert_eq!(pred.to_query(), "@score:[(3.5 +inf]");
992    }
993
994    // =========================================================================
995    // New feature tests
996    // =========================================================================
997
998    // Infix/contains match
999    #[test]
1000    fn test_infix() {
1001        let pred = Predicate::infix("name", "sun");
1002        assert_eq!(pred.to_query(), "@name:*sun*");
1003    }
1004
1005    // Wildcard exact match
1006    #[test]
1007    fn test_wildcard_exact() {
1008        let pred = Predicate::wildcard_exact("name", "foo*bar?");
1009        assert_eq!(pred.to_query(), "@name:\"w'foo*bar?\"");
1010    }
1011
1012    // Phrase with slop and inorder
1013    #[test]
1014    fn test_phrase_with_slop() {
1015        let pred = Predicate::phrase_with_options("title", vec!["hello", "world"], Some(2), None);
1016        assert_eq!(pred.to_query(), "@title:(hello world) => { $slop: 2; }");
1017    }
1018
1019    #[test]
1020    fn test_phrase_with_inorder() {
1021        let pred =
1022            Predicate::phrase_with_options("title", vec!["hello", "world"], None, Some(true));
1023        assert_eq!(
1024            pred.to_query(),
1025            "@title:(hello world) => { $inorder: true; }"
1026        );
1027    }
1028
1029    #[test]
1030    fn test_phrase_with_slop_and_inorder() {
1031        let pred =
1032            Predicate::phrase_with_options("title", vec!["hello", "world"], Some(2), Some(true));
1033        assert_eq!(
1034            pred.to_query(),
1035            "@title:(hello world) => { $slop: 2; $inorder: true; }"
1036        );
1037    }
1038
1039    // Optional terms
1040    #[test]
1041    fn test_optional() {
1042        let pred = Predicate::text_search("title", "python").optional();
1043        assert_eq!(pred.to_query(), "~@title:python");
1044    }
1045
1046    #[test]
1047    fn test_optional_combined() {
1048        let required = Predicate::text_search("title", "redis");
1049        let optional = Predicate::text_search("title", "tutorial").optional();
1050        let pred = required.and(optional);
1051        assert_eq!(pred.to_query(), "@title:redis ~@title:tutorial");
1052    }
1053
1054    // Multi-field search
1055    #[test]
1056    fn test_multi_field_search() {
1057        let pred = Predicate::multi_field_search(vec!["title", "body"], "python");
1058        assert_eq!(pred.to_query(), "@title|body:python");
1059    }
1060
1061    #[test]
1062    fn test_multi_field_search_three_fields() {
1063        let pred = Predicate::multi_field_search(vec!["title", "body", "summary"], "redis");
1064        assert_eq!(pred.to_query(), "@title|body|summary:redis");
1065    }
1066
1067    // Geo polygon
1068    #[test]
1069    fn test_geo_polygon() {
1070        let points = vec![
1071            (0.0, 0.0),
1072            (0.0, 10.0),
1073            (10.0, 10.0),
1074            (10.0, 0.0),
1075            (0.0, 0.0),
1076        ];
1077        let pred = Predicate::geo_polygon("location", points);
1078        assert_eq!(pred.to_query(), "@location:[WITHIN $poly]");
1079    }
1080
1081    #[test]
1082    fn test_geo_polygon_params() {
1083        let points = vec![
1084            (0.0, 0.0),
1085            (0.0, 10.0),
1086            (10.0, 10.0),
1087            (10.0, 0.0),
1088            (0.0, 0.0),
1089        ];
1090        let pred = Predicate::geo_polygon("location", points);
1091        let params = pred.get_params();
1092        assert_eq!(params.len(), 1);
1093        assert_eq!(params[0].0, "poly");
1094        assert!(params[0].1.starts_with("POLYGON(("));
1095    }
1096
1097    // Vector KNN search
1098    #[test]
1099    fn test_vector_knn() {
1100        let pred = Predicate::vector_knn("embedding", 10, "query_vec");
1101        assert_eq!(pred.to_query(), "*=>[KNN 10 @embedding $query_vec]");
1102    }
1103
1104    #[test]
1105    fn test_vector_knn_with_filter() {
1106        let filter = Predicate::eq("category", "science");
1107        let pred = Predicate::vector_knn_with_filter("embedding", 10, "query_vec", filter);
1108        assert_eq!(
1109            pred.to_query(),
1110            "@category:{science}=>[KNN 10 @embedding $query_vec]"
1111        );
1112    }
1113
1114    // Vector range search
1115    #[test]
1116    fn test_vector_range() {
1117        let pred = Predicate::vector_range("embedding", 0.5, "query_vec");
1118        assert_eq!(pred.to_query(), "@embedding:[VECTOR_RANGE 0.5 $query_vec]");
1119    }
1120}