sync_engine/search/
index_manager.rs

1//! Index Manager
2//!
3//! Manages RediSearch index lifecycle - creation, deletion, and schema management.
4//!
5//! # RediSearch Index Creation
6//!
7//! ```text
8//! FT.CREATE idx:users
9//!   ON JSON
10//!   PREFIX 1 crdt:users:
11//!   SCHEMA
12//!     $.name AS name TEXT
13//!     $.email AS email TEXT SORTABLE
14//!     $.age AS age NUMERIC SORTABLE
15//!     $.tags AS tags TAG
16//! ```
17
18use std::collections::HashMap;
19
20/// Search index definition
21#[derive(Debug, Clone)]
22pub struct SearchIndex {
23    /// Index name (will be prefixed with "idx:")
24    pub name: String,
25    /// Key prefix this index covers (e.g., "crdt:users:")
26    pub prefix: String,
27    /// Field definitions for the index
28    pub fields: Vec<SearchField>,
29}
30
31impl SearchIndex {
32    /// Create a new search index definition
33    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    /// Add a text field
42    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    /// Add a text field with custom JSON path
54    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    /// Add a sortable text field
66    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    /// Add a numeric field
78    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    /// Add a numeric field with custom JSON path
90    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    /// Add a sortable numeric field
102    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    /// Add a sortable numeric field with custom JSON path
114    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    /// Add a tag field
126    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    /// Add a tag field with custom JSON path
138    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    /// Add a geo field
150    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    /// Generate the FT.CREATE command arguments
162    pub fn to_ft_create_args(&self) -> Vec<String> {
163        self.to_ft_create_args_with_prefix(None)
164    }
165
166    /// Generate FT.CREATE args with optional global redis prefix
167    ///
168    /// The redis_prefix is prepended to both the index name and the key prefix
169    /// to match the actual key structure in Redis.
170    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/// Search field definition
192#[derive(Debug, Clone)]
193pub struct SearchField {
194    /// Field name (used in queries)
195    pub name: String,
196    /// JSON path (defaults to $.{name})
197    pub json_path: Option<String>,
198    /// Field type
199    pub field_type: SearchFieldType,
200    /// Whether the field is sortable
201    pub sortable: bool,
202    /// Whether to exclude from indexing (useful for SORTABLE-only fields)
203    pub no_index: bool,
204}
205
206impl SearchField {
207    fn to_schema_args(&self) -> Vec<String> {
208        // Default path: user data is stored under $.payload in our JSON wrapper
209        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/// Search field types supported by RediSearch
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum SearchFieldType {
236    /// Full-text searchable field
237    Text,
238    /// Numeric field (supports range queries)
239    Numeric,
240    /// Tag field (exact match, supports OR)
241    Tag,
242    /// Geographic field (latitude, longitude)
243    Geo,
244    /// Vector field (for similarity search)
245    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
260/// Index manager for RediSearch
261pub struct IndexManager {
262    /// Registered indexes by name
263    indexes: HashMap<String, SearchIndex>,
264}
265
266impl IndexManager {
267    /// Create a new index manager
268    pub fn new() -> Self {
269        Self {
270            indexes: HashMap::new(),
271        }
272    }
273
274    /// Register an index definition
275    pub fn register(&mut self, index: SearchIndex) {
276        self.indexes.insert(index.name.clone(), index);
277    }
278
279    /// Get a registered index by name
280    pub fn get(&self, name: &str) -> Option<&SearchIndex> {
281        self.indexes.get(name)
282    }
283
284    /// Get all registered indexes
285    pub fn all(&self) -> impl Iterator<Item = &SearchIndex> {
286        self.indexes.values()
287    }
288
289    /// Generate FT.CREATE arguments for an index
290    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    /// Find index by prefix match
295    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        // Default paths are $.payload.{name} since user data is stored under payload
326        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        // Check SORTABLE appears
339        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        // Verify the structure
405        // FT.CREATE idx:users ON JSON PREFIX 1 crdt:users: SCHEMA
406        //   $.payload.name AS name TEXT SORTABLE
407        //   $.payload.email AS email TEXT
408        //   $.payload.age AS age NUMERIC SORTABLE
409        //   $.payload.roles AS roles TAG
410        // Note: default paths use $.payload.{field} since user data is wrapped in payload
411
412        assert_eq!(args[0], "idx:users");
413        assert_eq!(args[6], "SCHEMA");
414
415        // Build the full command string for verification
416        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}