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