sync_engine/search/
redis_translator.rs1use super::query_builder::{FieldOperator, FieldQuery, Query, QueryNode, QueryValue};
24
25pub struct RediSearchTranslator;
27
28impl RediSearchTranslator {
29 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 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 format!("@{}:{:?}", field_name, field.value)
104 }
105 }
106 }
107
108 fn escape_field_name(field: &str) -> String {
109 if field.contains(|c: char| !c.is_alphanumeric() && c != '_') {
111 format!("`{}`", field)
112 } else {
113 field.to_string()
114 }
115 }
116
117 fn escape_special_chars(value: &str) -> String {
119 let mut escaped = String::new();
120 for c in value.chars() {
121 match c {
122 '@' | ':' | '|' | '(' | ')' | '[' | ']' | '{' | '}' | '*' | '%' | '-' | '+' => {
124 escaped.push('\\');
125 escaped.push(c);
126 }
127 _ => escaped.push(c),
128 }
129 }
130 escaped
131 }
132
133 fn escape_value(value: &str) -> String {
135 let mut escaped = String::new();
136 for c in value.chars() {
137 match c {
138 '@' | ':' | '|' | '(' | ')' | '[' | ']' | '{' | '}' | '*' | '%' | '-' | '+' | ' ' => {
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 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 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}