1use std::collections::HashMap;
19
20#[derive(Debug, Clone)]
22pub struct SearchIndex {
23 pub name: String,
25 pub prefix: String,
27 pub fields: Vec<SearchField>,
29}
30
31impl SearchIndex {
32 pub fn new(name: impl Into<String>, prefix: impl Into<String>) -> Self {
34 Self {
35 name: name.into(),
36 prefix: prefix.into(),
37 fields: Vec::new(),
38 }
39 }
40
41 pub fn text(mut self, name: impl Into<String>) -> Self {
43 self.fields.push(SearchField {
44 name: name.into(),
45 json_path: None,
46 field_type: SearchFieldType::Text,
47 sortable: false,
48 no_index: false,
49 });
50 self
51 }
52
53 pub fn text_at(mut self, name: impl Into<String>, json_path: impl Into<String>) -> Self {
55 self.fields.push(SearchField {
56 name: name.into(),
57 json_path: Some(json_path.into()),
58 field_type: SearchFieldType::Text,
59 sortable: false,
60 no_index: false,
61 });
62 self
63 }
64
65 pub fn text_sortable(mut self, name: impl Into<String>) -> Self {
67 self.fields.push(SearchField {
68 name: name.into(),
69 json_path: None,
70 field_type: SearchFieldType::Text,
71 sortable: true,
72 no_index: false,
73 });
74 self
75 }
76
77 pub fn numeric(mut self, name: impl Into<String>) -> Self {
79 self.fields.push(SearchField {
80 name: name.into(),
81 json_path: None,
82 field_type: SearchFieldType::Numeric,
83 sortable: false,
84 no_index: false,
85 });
86 self
87 }
88
89 pub fn numeric_at(mut self, name: impl Into<String>, json_path: impl Into<String>) -> Self {
91 self.fields.push(SearchField {
92 name: name.into(),
93 json_path: Some(json_path.into()),
94 field_type: SearchFieldType::Numeric,
95 sortable: false,
96 no_index: false,
97 });
98 self
99 }
100
101 pub fn numeric_sortable(mut self, name: impl Into<String>) -> Self {
103 self.fields.push(SearchField {
104 name: name.into(),
105 json_path: None,
106 field_type: SearchFieldType::Numeric,
107 sortable: true,
108 no_index: false,
109 });
110 self
111 }
112
113 pub fn numeric_sortable_at(mut self, name: impl Into<String>, json_path: impl Into<String>) -> Self {
115 self.fields.push(SearchField {
116 name: name.into(),
117 json_path: Some(json_path.into()),
118 field_type: SearchFieldType::Numeric,
119 sortable: true,
120 no_index: false,
121 });
122 self
123 }
124
125 pub fn tag(mut self, name: impl Into<String>) -> Self {
127 self.fields.push(SearchField {
128 name: name.into(),
129 json_path: None,
130 field_type: SearchFieldType::Tag,
131 sortable: false,
132 no_index: false,
133 });
134 self
135 }
136
137 pub fn tag_at(mut self, name: impl Into<String>, json_path: impl Into<String>) -> Self {
139 self.fields.push(SearchField {
140 name: name.into(),
141 json_path: Some(json_path.into()),
142 field_type: SearchFieldType::Tag,
143 sortable: false,
144 no_index: false,
145 });
146 self
147 }
148
149 pub fn geo(mut self, name: impl Into<String>) -> Self {
151 self.fields.push(SearchField {
152 name: name.into(),
153 json_path: None,
154 field_type: SearchFieldType::Geo,
155 sortable: false,
156 no_index: false,
157 });
158 self
159 }
160
161 pub fn to_ft_create_args(&self) -> Vec<String> {
163 self.to_ft_create_args_with_prefix(None)
164 }
165
166 pub fn to_ft_create_args_with_prefix(&self, redis_prefix: Option<&str>) -> Vec<String> {
171 let prefix = redis_prefix.unwrap_or("");
172
173 let mut args = vec![
174 format!("{}idx:{}", prefix, self.name),
175 "ON".to_string(),
176 "JSON".to_string(),
177 "PREFIX".to_string(),
178 "1".to_string(),
179 format!("{}{}", prefix, self.prefix),
180 "SCHEMA".to_string(),
181 ];
182
183 for field in &self.fields {
184 args.extend(field.to_schema_args());
185 }
186
187 args
188 }
189}
190
191#[derive(Debug, Clone)]
193pub struct SearchField {
194 pub name: String,
196 pub json_path: Option<String>,
198 pub field_type: SearchFieldType,
200 pub sortable: bool,
202 pub no_index: bool,
204}
205
206impl SearchField {
207 fn to_schema_args(&self) -> Vec<String> {
208 let json_path = self
210 .json_path
211 .clone()
212 .unwrap_or_else(|| format!("$.payload.{}", self.name));
213
214 let mut args = vec![
215 json_path,
216 "AS".to_string(),
217 self.name.clone(),
218 self.field_type.to_string(),
219 ];
220
221 if self.sortable {
222 args.push("SORTABLE".to_string());
223 }
224
225 if self.no_index {
226 args.push("NOINDEX".to_string());
227 }
228
229 args
230 }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum SearchFieldType {
236 Text,
238 Numeric,
240 Tag,
242 Geo,
244 Vector,
246}
247
248impl std::fmt::Display for SearchFieldType {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 SearchFieldType::Text => write!(f, "TEXT"),
252 SearchFieldType::Numeric => write!(f, "NUMERIC"),
253 SearchFieldType::Tag => write!(f, "TAG"),
254 SearchFieldType::Geo => write!(f, "GEO"),
255 SearchFieldType::Vector => write!(f, "VECTOR"),
256 }
257 }
258}
259
260pub struct IndexManager {
262 indexes: HashMap<String, SearchIndex>,
264}
265
266impl IndexManager {
267 pub fn new() -> Self {
269 Self {
270 indexes: HashMap::new(),
271 }
272 }
273
274 pub fn register(&mut self, index: SearchIndex) {
276 self.indexes.insert(index.name.clone(), index);
277 }
278
279 pub fn get(&self, name: &str) -> Option<&SearchIndex> {
281 self.indexes.get(name)
282 }
283
284 pub fn all(&self) -> impl Iterator<Item = &SearchIndex> {
286 self.indexes.values()
287 }
288
289 pub fn ft_create_args(&self, name: &str) -> Option<Vec<String>> {
291 self.indexes.get(name).map(|idx| idx.to_ft_create_args())
292 }
293
294 pub fn find_by_prefix(&self, key: &str) -> Option<&SearchIndex> {
296 self.indexes.values().find(|idx| key.starts_with(&idx.prefix))
297 }
298}
299
300impl Default for IndexManager {
301 fn default() -> Self {
302 Self::new()
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn test_simple_index() {
312 let index = SearchIndex::new("users", "crdt:users:")
313 .text("name")
314 .text("email")
315 .numeric("age");
316
317 let args = index.to_ft_create_args();
318 assert_eq!(args[0], "idx:users");
319 assert_eq!(args[1], "ON");
320 assert_eq!(args[2], "JSON");
321 assert_eq!(args[3], "PREFIX");
322 assert_eq!(args[4], "1");
323 assert_eq!(args[5], "crdt:users:");
324 assert_eq!(args[6], "SCHEMA");
325 assert!(args.contains(&"$.payload.name".to_string()));
327 assert!(args.contains(&"name".to_string()));
328 assert!(args.contains(&"TEXT".to_string()));
329 }
330
331 #[test]
332 fn test_sortable_fields() {
333 let index = SearchIndex::new("users", "crdt:users:")
334 .text_sortable("name")
335 .numeric_sortable("age");
336
337 let args = index.to_ft_create_args();
338 let sortable_count = args.iter().filter(|a| *a == "SORTABLE").count();
340 assert_eq!(sortable_count, 2);
341 }
342
343 #[test]
344 fn test_tag_field() {
345 let index = SearchIndex::new("items", "crdt:items:").tag("tags");
346
347 let args = index.to_ft_create_args();
348 assert!(args.contains(&"TAG".to_string()));
349 }
350
351 #[test]
352 fn test_custom_json_path() {
353 let index =
354 SearchIndex::new("users", "crdt:users:").text_at("username", "$.profile.name");
355
356 let args = index.to_ft_create_args();
357 assert!(args.contains(&"$.profile.name".to_string()));
358 assert!(args.contains(&"username".to_string()));
359 }
360
361 #[test]
362 fn test_index_manager_register() {
363 let mut manager = IndexManager::new();
364
365 let index = SearchIndex::new("users", "crdt:users:")
366 .text("name")
367 .numeric("age");
368
369 manager.register(index);
370
371 assert!(manager.get("users").is_some());
372 assert!(manager.get("unknown").is_none());
373 }
374
375 #[test]
376 fn test_index_manager_find_by_prefix() {
377 let mut manager = IndexManager::new();
378
379 manager.register(SearchIndex::new("users", "crdt:users:"));
380 manager.register(SearchIndex::new("posts", "crdt:posts:"));
381
382 let found = manager.find_by_prefix("crdt:users:abc123");
383 assert!(found.is_some());
384 assert_eq!(found.unwrap().name, "users");
385
386 let found = manager.find_by_prefix("crdt:posts:xyz");
387 assert!(found.is_some());
388 assert_eq!(found.unwrap().name, "posts");
389
390 let not_found = manager.find_by_prefix("crdt:comments:1");
391 assert!(not_found.is_none());
392 }
393
394 #[test]
395 fn test_ft_create_full_command() {
396 let index = SearchIndex::new("users", "crdt:users:")
397 .text_sortable("name")
398 .text("email")
399 .numeric_sortable("age")
400 .tag("roles");
401
402 let args = index.to_ft_create_args();
403
404 assert_eq!(args[0], "idx:users");
413 assert_eq!(args[6], "SCHEMA");
414
415 let cmd = format!("FT.CREATE {}", args.join(" "));
417 assert!(cmd.contains("idx:users"));
418 assert!(cmd.contains("ON JSON"));
419 assert!(cmd.contains("PREFIX 1 crdt:users:"));
420 assert!(cmd.contains("$.payload.name AS name TEXT SORTABLE"));
421 assert!(cmd.contains("$.payload.email AS email TEXT"));
422 assert!(cmd.contains("$.payload.age AS age NUMERIC SORTABLE"));
423 assert!(cmd.contains("$.payload.roles AS roles TAG"));
424 }
425}