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