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