1use std::collections::{HashMap, HashSet};
7
8use nodedb_fts::IndexScope;
9use nodedb_fts::backend::FtsBackend;
10use nodedb_types::Surrogate;
11use nodedb_types::value::Value;
12
13use super::registry::{FtsCollectionManager, fts_err, index_key};
14use crate::error::LiteError;
15
16pub fn whole_document_text(fields: &HashMap<String, Value>) -> String {
19 super::fields::joined_text(&super::fields::selected_texts(fields, None))
20}
21
22impl FtsCollectionManager {
23 pub fn index_document_fields(
38 &mut self,
39 collection: &str,
40 doc_id: &str,
41 fields: &HashMap<String, Value>,
42 ) -> Result<Vec<(String, String)>, LiteError> {
43 let texts = super::fields::selected_texts(fields, self.declaration_for(collection));
44 let text = super::fields::joined_text(&texts);
45 self.index_document(collection, doc_id, &text)?;
46
47 let whole = index_key(collection, "");
48 let mut indexed: HashSet<String> = HashSet::new();
49 let mut pairs = Vec::with_capacity(texts.len());
50 for (field, value) in texts {
51 if field.is_empty() {
52 continue;
53 }
54 self.index_field(collection, field, doc_id, value)?;
55 indexed.insert(index_key(collection, field));
56 pairs.push((field.to_owned(), value.to_owned()));
57 }
58
59 let system = index_key(collection, "_geohash");
60 for key in self.collection_keys(collection) {
61 if key != whole && key != system && !indexed.contains(&key) {
62 self.retract(collection, &key, doc_id)?;
63 }
64 }
65 Ok(pairs)
66 }
67
68 pub fn remove_document_fields(&self, collection: &str, doc_id: &str) -> Result<(), LiteError> {
71 for key in self.collection_keys(collection) {
72 self.retract(collection, &key, doc_id)?;
73 }
74 Ok(())
75 }
76
77 pub fn index_document(
87 &mut self,
88 collection: &str,
89 doc_id: &str,
90 text: &str,
91 ) -> Result<(), LiteError> {
92 self.index_text(collection, &index_key(collection, ""), doc_id, text)
93 }
94
95 pub fn remove_document(&self, collection: &str, doc_id: &str) -> Result<(), LiteError> {
97 self.retract(collection, &index_key(collection, ""), doc_id)
98 }
99
100 pub fn index_field(
110 &mut self,
111 collection: &str,
112 field: &str,
113 doc_id: &str,
114 text: &str,
115 ) -> Result<(), LiteError> {
116 self.index_text(collection, &index_key(collection, field), doc_id, text)
117 }
118
119 pub fn remove_field(
121 &self,
122 collection: &str,
123 field: &str,
124 doc_id: &str,
125 ) -> Result<(), LiteError> {
126 self.retract(collection, &index_key(collection, field), doc_id)
127 }
128
129 fn index_text(
134 &mut self,
135 collection: &str,
136 key: &str,
137 doc_id: &str,
138 text: &str,
139 ) -> Result<(), LiteError> {
140 if text.is_empty() {
141 return self.retract(collection, key, doc_id);
142 }
143 let surrogate = self.surrogate_for(collection, doc_id)?;
144 if !self.indices.contains_key(key) {
145 let mut retained = super::retained::RetainedIndex::default();
146 retained.reserve_base(std::sync::Arc::clone(&self.governor), key)?;
147 let fresh = self.new_index_for(collection, key)?;
148 self.indices.insert(key.to_owned(), fresh);
149 self.retained.insert(key.to_owned(), retained);
150 }
151 let tokens = self
152 .indices
153 .get(key)
154 .ok_or_else(|| {
155 fts_err(
156 collection,
157 format!("index '{key}' vanished before analysis"),
158 )
159 })?
160 .analyze_for_collection(0, 0, key, text)
161 .map_err(|error| fts_err(collection, error))?;
162 self.retained
163 .get_mut(key)
164 .ok_or_else(|| fts_err(collection, format!("index lease '{key}' is absent")))?
165 .reserve_write(
166 std::sync::Arc::clone(&self.governor),
167 key,
168 surrogate,
169 &tokens,
170 )?;
171 self.retract(collection, key, doc_id)?;
172 let idx = self
173 .indices
174 .get(key)
175 .ok_or_else(|| fts_err(collection, format!("index '{key}' vanished mid-write")))?;
176 idx.index_analyzed_document(0, 0, key, surrogate, &tokens)
177 .map_err(|e| fts_err(collection, e))
178 }
179
180 fn retract(&self, collection: &str, key: &str, doc_id: &str) -> Result<(), LiteError> {
185 let Some(surrogate) = self.lookup_surrogate(doc_id) else {
186 return Ok(());
187 };
188 let Some(idx) = self.indices.get(key) else {
189 return Ok(());
190 };
191 let held = idx
192 .backend()
193 .read_doc_length(0, 0, IndexScope::document(key), surrogate)
194 .map_err(|e| fts_err(collection, e))?
195 .is_some();
196 if held {
197 idx.remove_document(0, 0, key, surrogate)
198 .map_err(|e| fts_err(collection, e))?;
199 }
200 Ok(())
201 }
202
203 pub fn register_origin_surrogate(&mut self, origin_surrogate: Surrogate, doc_id: &str) {
210 self.origin_surrogate_to_doc_id
211 .insert(origin_surrogate.0, doc_id.to_owned());
212 }
213
214 pub fn remove_by_origin_surrogate(
219 &mut self,
220 collection: &str,
221 origin_surrogate: Surrogate,
222 ) -> Result<Option<String>, LiteError> {
223 let Some(doc_id) = self.origin_surrogate_to_doc_id.remove(&origin_surrogate.0) else {
224 tracing::debug!(
225 collection,
226 sur = origin_surrogate.0,
227 "FtsDeleteDoc: no Lite mapping for Origin surrogate — document was never indexed here"
228 );
229 return Ok(None);
230 };
231 self.remove_document(collection, &doc_id)?;
232 Ok(Some(doc_id))
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use std::collections::HashMap;
239
240 use nodedb_types::Surrogate;
241 use nodedb_types::text_search::TextSearchParams;
242 use nodedb_types::value::Value;
243
244 use super::super::registry::test_governor;
245 use super::FtsCollectionManager;
246
247 fn doc(pairs: &[(&str, &str)]) -> HashMap<String, Value> {
248 pairs
249 .iter()
250 .map(|(k, v)| ((*k).to_owned(), Value::String((*v).to_owned())))
251 .collect()
252 }
253
254 fn hits(mgr: &FtsCollectionManager, field: &str, query: &str) -> Vec<String> {
255 mgr.search("col", field, query, 10, &TextSearchParams::default())
256 .expect("search must succeed")
257 .into_iter()
258 .map(|r| r.doc_id)
259 .collect()
260 }
261
262 #[test]
263 fn clearing_a_document_removes_it_from_the_index() {
264 let mut mgr = FtsCollectionManager::new(test_governor());
265 mgr.index_document("col", "doc1", "the quick brown fox")
266 .expect("index update must succeed");
267 assert_eq!(hits(&mgr, "", "quick"), vec!["doc1"]);
268
269 mgr.index_document("col", "doc1", "")
272 .expect("index update must succeed");
273 assert!(
274 hits(&mgr, "", "quick").is_empty(),
275 "cleared document must not keep matching its prior terms"
276 );
277 }
278
279 #[test]
280 fn clearing_a_field_removes_it_from_the_field_index() {
281 let mut mgr = FtsCollectionManager::new(test_governor());
282 mgr.index_field("col", "title", "doc1", "the quick brown fox")
283 .expect("index update must succeed");
284 assert!(
285 !mgr.indices
286 .get("col:title")
287 .expect("field index exists")
288 .memtable()
289 .is_empty(),
290 "field index must hold the document's postings"
291 );
292
293 mgr.index_field("col", "title", "doc1", "")
294 .expect("index update must succeed");
295 assert!(
296 mgr.indices
297 .get("col:title")
298 .expect("field index exists")
299 .memtable()
300 .is_empty(),
301 "cleared field must not keep its prior postings"
302 );
303 }
304
305 #[test]
306 fn schemaless_document_is_indexed_per_field_and_whole() {
307 let mut mgr = FtsCollectionManager::new(test_governor());
308 mgr.index_document_fields("col", "d1", &doc(&[("title", "rust"), ("body", "python")]))
309 .expect("index update must succeed");
310
311 assert_eq!(hits(&mgr, "title", "rust"), vec!["d1"]);
312 assert!(hits(&mgr, "title", "python").is_empty());
313 assert_eq!(hits(&mgr, "body", "python"), vec!["d1"]);
314 assert_eq!(hits(&mgr, "", "python"), vec!["d1"]);
315 }
316
317 #[test]
318 fn rewriting_without_a_field_retracts_it_from_that_field_index() {
319 let mut mgr = FtsCollectionManager::new(test_governor());
320 mgr.index_document_fields("col", "d1", &doc(&[("title", "rust"), ("body", "python")]))
321 .expect("index update must succeed");
322 mgr.index_document_fields("col", "d1", &doc(&[("title", "rust")]))
323 .expect("index update must succeed");
324
325 assert!(hits(&mgr, "body", "python").is_empty());
326 assert!(hits(&mgr, "", "python").is_empty());
327 assert_eq!(hits(&mgr, "title", "rust"), vec!["d1"]);
328 }
329
330 #[test]
331 fn removing_a_document_clears_every_index_of_the_collection() {
332 let mut mgr = FtsCollectionManager::new(test_governor());
333 mgr.index_document_fields("col", "d1", &doc(&[("title", "rust"), ("body", "python")]))
334 .expect("index update must succeed");
335 mgr.remove_document_fields("col", "d1")
336 .expect("removal must succeed");
337
338 assert!(hits(&mgr, "title", "rust").is_empty());
339 assert!(hits(&mgr, "body", "python").is_empty());
340 assert!(hits(&mgr, "", "rust").is_empty());
341 }
342
343 #[test]
344 fn a_field_named_doc_is_its_own_index() {
345 let mut mgr = FtsCollectionManager::new(test_governor());
346 mgr.index_document_fields("col", "d1", &doc(&[("_doc", "alpha"), ("title", "beta")]))
347 .expect("index update must succeed");
348
349 assert_eq!(hits(&mgr, "_doc", "alpha"), vec!["d1"]);
350 assert!(
351 hits(&mgr, "_doc", "beta").is_empty(),
352 "`_doc` holds only its own field"
353 );
354 assert_eq!(
355 hits(&mgr, "", "beta"),
356 vec!["d1"],
357 "the whole document holds both"
358 );
359 }
360
361 #[test]
362 fn fts_delete_doc_removes_only_targeted_doc() {
363 let mut mgr = FtsCollectionManager::new(test_governor());
364 mgr.index_document("col", "doc1", "rust programming language")
365 .expect("index update must succeed");
366 mgr.index_document("col", "doc2", "rust is fast and safe")
367 .expect("index update must succeed");
368 mgr.index_document("col", "doc3", "python is also great")
369 .expect("index update must succeed");
370
371 mgr.register_origin_surrogate(Surrogate(42), "doc2");
373
374 let removed = mgr
375 .remove_by_origin_surrogate("col", Surrogate(42))
376 .expect("removal must succeed");
377 assert!(removed.is_some(), "doc2 must be found and removed");
378
379 let ids = hits(&mgr, "", "rust");
380 assert!(
381 ids.contains(&"doc1".to_owned()),
382 "doc1 must still be present"
383 );
384 assert!(!ids.contains(&"doc2".to_owned()), "doc2 must be removed");
385 }
386
387 #[test]
388 fn fts_delete_doc_unknown_surrogate_returns_none() {
389 let mut mgr = FtsCollectionManager::new(test_governor());
390 mgr.index_document("col", "doc1", "hello world")
391 .expect("index update must succeed");
392
393 let removed = mgr
394 .remove_by_origin_surrogate("col", Surrogate(99))
395 .expect("removal must succeed");
396 assert!(removed.is_none(), "unknown surrogate must return None");
397 assert_eq!(hits(&mgr, "", "hello"), vec!["doc1"]);
398 }
399}