sync_engine/search/
index_manager.rs

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