sync_engine/search/
redis_translator.rs

1//! RediSearch Translator
2//!
3//! Translates Query AST to RediSearch FT.SEARCH syntax.
4//!
5//! # RediSearch Query Syntax
6//!
7//! ```text
8//! @field:value              - Exact match
9//! @field:*value*            - Contains
10//! @field:[min max]          - Numeric range
11//! @tags:{value1|value2}     - Tag membership
12//! @field:prefix*            - Prefix match
13//! @field:%value%            - Fuzzy match (Levenshtein distance 1)
14//! query1 query2             - AND (implicit)
15//! query1 | query2           - OR
16//! -query                    - NOT
17//! (query1 query2)           - Grouping
18//! ```
19
20use super::query_builder::{FieldOperator, FieldQuery, Query, QueryNode, QueryValue};
21
22/// RediSearch query translator
23pub struct RediSearchTranslator;
24
25impl RediSearchTranslator {
26    /// Translate Query AST to RediSearch FT.SEARCH syntax
27    pub fn translate(query: &Query) -> String {
28        Self::translate_node(&query.root)
29    }
30
31    fn translate_node(node: &QueryNode) -> String {
32        match node {
33            QueryNode::Field(field_query) => Self::translate_field(field_query),
34            QueryNode::And(nodes) => {
35                let parts: Vec<String> = nodes.iter().map(Self::translate_node).collect();
36                if parts.len() == 1 {
37                    parts[0].clone()
38                } else {
39                    format!("({})", parts.join(" "))
40                }
41            }
42            QueryNode::Or(nodes) => {
43                let parts: Vec<String> = nodes.iter().map(Self::translate_node).collect();
44                if parts.len() == 1 {
45                    parts[0].clone()
46                } else {
47                    format!("({})", parts.join(" | "))
48                }
49            }
50            QueryNode::Not(inner) => {
51                format!("-({})", Self::translate_node(inner))
52            }
53        }
54    }
55
56    fn translate_field(field: &FieldQuery) -> String {
57        let field_name = Self::escape_field_name(&field.field);
58
59        match (&field.operator, &field.value) {
60            (FieldOperator::Equals, QueryValue::Text(text)) => {
61                // For multi-word text, use phrase matching with parentheses
62                // e.g., @name:(Alice Smith) which requires all terms to match
63                let escaped = Self::escape_special_chars(text);
64                if text.contains(' ') {
65                    format!("@{}:({})", field_name, escaped)
66                } else {
67                    format!("@{}:{}", field_name, escaped)
68                }
69            }
70            (FieldOperator::Equals, QueryValue::Numeric(num)) => {
71                format!("@{}:[{} {}]", field_name, num, num)
72            }
73            (FieldOperator::Equals, QueryValue::Boolean(b)) => {
74                format!("@{}:{}", field_name, if *b { "true" } else { "false" })
75            }
76            (FieldOperator::Contains, QueryValue::Text(text)) => {
77                format!("@{}:*{}*", field_name, Self::escape_special_chars(text))
78            }
79            (FieldOperator::Range, QueryValue::NumericRange { min, max }) => {
80                let min_str = min.map(|v| v.to_string()).unwrap_or_else(|| "-inf".to_string());
81                let max_str = max.map(|v| v.to_string()).unwrap_or_else(|| "+inf".to_string());
82                format!("@{}:[{} {}]", field_name, min_str, max_str)
83            }
84            (FieldOperator::In, QueryValue::Tags(tags)) => {
85                let tag_str = tags
86                    .iter()
87                    .map(|t| Self::escape_value(t))
88                    .collect::<Vec<_>>()
89                    .join("|");
90                format!("@{}:{{{}}}", field_name, tag_str)
91            }
92            (FieldOperator::Prefix, QueryValue::Text(text)) => {
93                format!("@{}:{}*", field_name, Self::escape_value(text))
94            }
95            (FieldOperator::Fuzzy, QueryValue::Text(text)) => {
96                format!("@{}:%{}%", field_name, Self::escape_value(text))
97            }
98            _ => {
99                // Fallback for unsupported combinations
100                format!("@{}:{:?}", field_name, field.value)
101            }
102        }
103    }
104
105    fn escape_field_name(field: &str) -> String {
106        // Field names with special chars need backtick escaping
107        if field.contains(|c: char| !c.is_alphanumeric() && c != '_') {
108            format!("`{}`", field)
109        } else {
110            field.to_string()
111        }
112    }
113
114    /// Escape special RediSearch characters but preserve spaces (for phrase matching).
115    fn escape_special_chars(value: &str) -> String {
116        let mut escaped = String::new();
117        for c in value.chars() {
118            match c {
119                // RediSearch special chars that need escaping (not spaces - used in phrases)
120                '@' | ':' | '|' | '(' | ')' | '[' | ']' | '{' | '}' | '*' | '%' | '-' | '+' => {
121                    escaped.push('\\');
122                    escaped.push(c);
123                }
124                _ => escaped.push(c),
125            }
126        }
127        escaped
128    }
129
130    /// Escape all special chars including spaces (for single-term matching).
131    fn escape_value(value: &str) -> String {
132        let mut escaped = String::new();
133        for c in value.chars() {
134            match c {
135                // RediSearch special chars that need escaping
136                '@' | ':' | '|' | '(' | ')' | '[' | ']' | '{' | '}' | '*' | '%' | '-' | '+' | ' ' => {
137                    escaped.push('\\');
138                    escaped.push(c);
139                }
140                _ => escaped.push(c),
141            }
142        }
143        escaped
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_simple_field_query() {
153        let query = Query::field_eq("name", "Alice");
154        let redis_query = RediSearchTranslator::translate(&query);
155        assert_eq!(redis_query, "@name:Alice");
156    }
157
158    #[test]
159    fn test_field_with_spaces() {
160        // Multi-word text queries use phrase matching with parentheses
161        let query = Query::field_eq("name", "Alice Smith");
162        let redis_query = RediSearchTranslator::translate(&query);
163        assert_eq!(redis_query, "@name:(Alice Smith)");
164    }
165
166    #[test]
167    fn test_numeric_range() {
168        let query = Query::numeric_range("age", Some(25.0), Some(40.0));
169        let redis_query = RediSearchTranslator::translate(&query);
170        assert_eq!(redis_query, "@age:[25 40]");
171    }
172
173    #[test]
174    fn test_numeric_range_unbounded_min() {
175        let query = Query::numeric_range("age", None, Some(40.0));
176        let redis_query = RediSearchTranslator::translate(&query);
177        assert_eq!(redis_query, "@age:[-inf 40]");
178    }
179
180    #[test]
181    fn test_numeric_range_unbounded_max() {
182        let query = Query::numeric_range("score", Some(100.0), None);
183        let redis_query = RediSearchTranslator::translate(&query);
184        assert_eq!(redis_query, "@score:[100 +inf]");
185    }
186
187    #[test]
188    fn test_tag_query() {
189        let query = Query::tags("tags", vec!["rust".to_string(), "database".to_string()]);
190        let redis_query = RediSearchTranslator::translate(&query);
191        assert_eq!(redis_query, "@tags:{rust|database}");
192    }
193
194    #[test]
195    fn test_and_query() {
196        let query = Query::field_eq("name", "Alice")
197            .and(Query::numeric_range("age", Some(25.0), Some(40.0)));
198        let redis_query = RediSearchTranslator::translate(&query);
199        assert_eq!(redis_query, "(@name:Alice @age:[25 40])");
200    }
201
202    #[test]
203    fn test_or_query() {
204        let query = Query::field_eq("status", "active")
205            .or(Query::field_eq("status", "pending"));
206        let redis_query = RediSearchTranslator::translate(&query);
207        assert_eq!(redis_query, "(@status:active | @status:pending)");
208    }
209
210    #[test]
211    fn test_not_query() {
212        let query = Query::field_eq("deleted", "true").negate();
213        let redis_query = RediSearchTranslator::translate(&query);
214        assert_eq!(redis_query, "-(@deleted:true)");
215    }
216
217    #[test]
218    fn test_contains_query() {
219        let query = Query::text_search("description", "database");
220        let redis_query = RediSearchTranslator::translate(&query);
221        assert_eq!(redis_query, "@description:*database*");
222    }
223
224    #[test]
225    fn test_prefix_query() {
226        let query = Query::prefix("email", "admin");
227        let redis_query = RediSearchTranslator::translate(&query);
228        assert_eq!(redis_query, "@email:admin*");
229    }
230
231    #[test]
232    fn test_fuzzy_query() {
233        let query = Query::fuzzy("name", "alice");
234        let redis_query = RediSearchTranslator::translate(&query);
235        assert_eq!(redis_query, "@name:%alice%");
236    }
237
238    #[test]
239    fn test_complex_query() {
240        // (name:Alice AND age:[25 40]) OR (name:Bob AND tags:{rust|database})
241        let alice_query = Query::field_eq("name", "Alice")
242            .and(Query::numeric_range("age", Some(25.0), Some(40.0)));
243
244        let bob_query = Query::field_eq("name", "Bob")
245            .and(Query::tags("tags", vec!["rust".to_string(), "database".to_string()]));
246
247        let query = alice_query.or(bob_query);
248        let redis_query = RediSearchTranslator::translate(&query);
249
250        assert_eq!(
251            redis_query,
252            "((@name:Alice @age:[25 40]) | (@name:Bob @tags:{rust|database}))"
253        );
254    }
255
256    #[test]
257    fn test_escape_special_chars() {
258        let query = Query::field_eq("email", "user@example.com");
259        let redis_query = RediSearchTranslator::translate(&query);
260        assert_eq!(redis_query, "@email:user\\@example.com");
261    }
262
263    #[test]
264    fn test_escape_colon() {
265        let query = Query::field_eq("time", "12:30");
266        let redis_query = RediSearchTranslator::translate(&query);
267        assert_eq!(redis_query, "@time:12\\:30");
268    }
269}