1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::Plugin;
5use pylon_auth::AuthContext;
6use serde_json::Value;
7
8#[derive(Debug, Clone)]
10struct IndexEntry {
11 entity: String,
12 row_id: String,
13 text: String,
14 data: Value,
15}
16
17pub struct SearchConfig {
19 pub fields: Vec<String>,
21}
22
23pub 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 pub fn add(&mut self, entity: &str, fields: Vec<String>) {
39 self.configs
40 .insert(entity.to_string(), SearchConfig { fields });
41 }
42
43 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 index.retain(|e| !(e.entity == entity && e.row_id == row_id));
80 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); }
285}