Skip to main content

pylon_plugin/builtin/
search.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::Plugin;
5use pylon_auth::AuthContext;
6use serde_json::Value;
7
8/// A search index entry.
9#[derive(Debug, Clone)]
10struct IndexEntry {
11    entity: String,
12    row_id: String,
13    text: String,
14    data: Value,
15}
16
17/// Search configuration for an entity.
18pub struct SearchConfig {
19    /// Fields to index for full-text search.
20    pub fields: Vec<String>,
21}
22
23/// Full-text search plugin. Maintains an in-memory inverted index.
24pub struct SearchPlugin {
25    configs: HashMap<String, SearchConfig>,
26    index: Mutex<Vec<IndexEntry>>,
27}
28
29impl SearchPlugin {
30    pub fn new() -> Self {
31        Self {
32            configs: HashMap::new(),
33            index: Mutex::new(Vec::new()),
34        }
35    }
36
37    /// Register an entity and its searchable fields.
38    pub fn add(&mut self, entity: &str, fields: Vec<String>) {
39        self.configs
40            .insert(entity.to_string(), SearchConfig { fields });
41    }
42
43    /// Search across all indexed entities. Returns matching rows.
44    pub fn search(&self, query: &str) -> Vec<SearchResult> {
45        let query_lower = query.to_lowercase();
46        let terms: Vec<&str> = query_lower.split_whitespace().collect();
47
48        let index = self.index.lock().unwrap();
49        let results: Vec<SearchResult> = index
50            .iter()
51            .filter(|entry| {
52                let text_lower = entry.text.to_lowercase();
53                terms.iter().all(|term| text_lower.contains(term))
54            })
55            .map(|entry| SearchResult {
56                entity: entry.entity.clone(),
57                row_id: entry.row_id.clone(),
58                data: entry.data.clone(),
59            })
60            .collect();
61
62        results
63    }
64
65    fn index_row(&self, entity: &str, row_id: &str, data: &Value) {
66        if let Some(config) = self.configs.get(entity) {
67            let mut text_parts = Vec::new();
68            if let Some(obj) = data.as_object() {
69                for field in &config.fields {
70                    if let Some(val) = obj.get(field).and_then(|v| v.as_str()) {
71                        text_parts.push(val.to_string());
72                    }
73                }
74            }
75
76            if !text_parts.is_empty() {
77                let mut index = self.index.lock().unwrap();
78                // Remove existing entry for this row.
79                index.retain(|e| !(e.entity == entity && e.row_id == row_id));
80                // Add new entry.
81                index.push(IndexEntry {
82                    entity: entity.to_string(),
83                    row_id: row_id.to_string(),
84                    text: text_parts.join(" "),
85                    data: data.clone(),
86                });
87            }
88        }
89    }
90
91    fn remove_from_index(&self, entity: &str, row_id: &str) {
92        let mut index = self.index.lock().unwrap();
93        index.retain(|e| !(e.entity == entity && e.row_id == row_id));
94    }
95}
96
97#[derive(Debug, Clone)]
98pub struct SearchResult {
99    pub entity: String,
100    pub row_id: String,
101    pub data: Value,
102}
103
104impl Plugin for SearchPlugin {
105    fn name(&self) -> &str {
106        "search"
107    }
108
109    fn after_insert(&self, entity: &str, id: &str, data: &Value, _auth: &AuthContext) {
110        self.index_row(entity, id, data);
111    }
112
113    fn after_update(&self, entity: &str, id: &str, data: &Value, _auth: &AuthContext) {
114        self.index_row(entity, id, data);
115    }
116
117    fn after_delete(&self, entity: &str, id: &str, _auth: &AuthContext) {
118        self.remove_from_index(entity, id);
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn search_finds_matching_text() {
128        let mut plugin = SearchPlugin::new();
129        plugin.add("Todo", vec!["title".into()]);
130
131        plugin.after_insert(
132            "Todo",
133            "t1",
134            &serde_json::json!({"title": "Buy milk"}),
135            &AuthContext::anonymous(),
136        );
137        plugin.after_insert(
138            "Todo",
139            "t2",
140            &serde_json::json!({"title": "Buy bread"}),
141            &AuthContext::anonymous(),
142        );
143        plugin.after_insert(
144            "Todo",
145            "t3",
146            &serde_json::json!({"title": "Walk the dog"}),
147            &AuthContext::anonymous(),
148        );
149
150        let results = plugin.search("buy");
151        assert_eq!(results.len(), 2);
152
153        let results = plugin.search("milk");
154        assert_eq!(results.len(), 1);
155        assert_eq!(results[0].row_id, "t1");
156    }
157
158    #[test]
159    fn search_multiple_terms() {
160        let mut plugin = SearchPlugin::new();
161        plugin.add("Todo", vec!["title".into()]);
162
163        plugin.after_insert(
164            "Todo",
165            "t1",
166            &serde_json::json!({"title": "Buy organic milk"}),
167            &AuthContext::anonymous(),
168        );
169        plugin.after_insert(
170            "Todo",
171            "t2",
172            &serde_json::json!({"title": "Buy regular milk"}),
173            &AuthContext::anonymous(),
174        );
175
176        let results = plugin.search("organic milk");
177        assert_eq!(results.len(), 1);
178        assert_eq!(results[0].row_id, "t1");
179    }
180
181    #[test]
182    fn search_case_insensitive() {
183        let mut plugin = SearchPlugin::new();
184        plugin.add("Todo", vec!["title".into()]);
185
186        plugin.after_insert(
187            "Todo",
188            "t1",
189            &serde_json::json!({"title": "IMPORTANT TASK"}),
190            &AuthContext::anonymous(),
191        );
192
193        let results = plugin.search("important");
194        assert_eq!(results.len(), 1);
195    }
196
197    #[test]
198    fn search_updates_index_on_update() {
199        let mut plugin = SearchPlugin::new();
200        plugin.add("Todo", vec!["title".into()]);
201
202        plugin.after_insert(
203            "Todo",
204            "t1",
205            &serde_json::json!({"title": "Old title"}),
206            &AuthContext::anonymous(),
207        );
208        plugin.after_update(
209            "Todo",
210            "t1",
211            &serde_json::json!({"title": "New title"}),
212            &AuthContext::anonymous(),
213        );
214
215        let results = plugin.search("old");
216        assert_eq!(results.len(), 0);
217
218        let results = plugin.search("new");
219        assert_eq!(results.len(), 1);
220    }
221
222    #[test]
223    fn search_removes_on_delete() {
224        let mut plugin = SearchPlugin::new();
225        plugin.add("Todo", vec!["title".into()]);
226
227        plugin.after_insert(
228            "Todo",
229            "t1",
230            &serde_json::json!({"title": "Deletable"}),
231            &AuthContext::anonymous(),
232        );
233        plugin.after_delete("Todo", "t1", &AuthContext::anonymous());
234
235        let results = plugin.search("deletable");
236        assert_eq!(results.len(), 0);
237    }
238
239    #[test]
240    fn search_multiple_fields() {
241        let mut plugin = SearchPlugin::new();
242        plugin.add("User", vec!["displayName".into(), "email".into()]);
243
244        plugin.after_insert(
245            "User",
246            "u1",
247            &serde_json::json!({"displayName": "Alice", "email": "alice@test.com"}),
248            &AuthContext::anonymous(),
249        );
250
251        let results = plugin.search("alice");
252        assert_eq!(results.len(), 1);
253
254        let results = plugin.search("test.com");
255        assert_eq!(results.len(), 1);
256    }
257
258    #[test]
259    fn search_no_config_no_index() {
260        let plugin = SearchPlugin::new();
261        plugin.after_insert(
262            "Todo",
263            "t1",
264            &serde_json::json!({"title": "Test"}),
265            &AuthContext::anonymous(),
266        );
267        let results = plugin.search("test");
268        assert_eq!(results.len(), 0);
269    }
270
271    #[test]
272    fn search_empty_query() {
273        let mut plugin = SearchPlugin::new();
274        plugin.add("Todo", vec!["title".into()]);
275        plugin.after_insert(
276            "Todo",
277            "t1",
278            &serde_json::json!({"title": "Test"}),
279            &AuthContext::anonymous(),
280        );
281
282        let results = plugin.search("");
283        assert_eq!(results.len(), 1); // empty query matches all
284    }
285}