Skip to main content

nodedb_lite/engine/fts/manager/
index.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Write side of [`FtsCollectionManager`]: whole-document, per-field, and
4//! schemaless-document indexing, removal, and the Origin surrogate map.
5
6use 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
16/// Every top-level string field of a document, joined by spaces: the text
17/// of the whole-document index.
18pub 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    // ── Schemaless documents ──────────────────────────────────────────────────
24
25    /// Index a schemaless document: its whole-document text under the bare
26    /// `"{collection}"` key and each top-level string field under
27    /// `"{collection}:{field}"`.
28    ///
29    /// Replaces whatever the document held before. The document is retracted
30    /// from every other field index of the collection, so a field it no longer
31    /// carries as a string stops matching. A field with an empty name has no
32    /// key of its own and is searchable through the whole-document index only.
33    ///
34    /// Returns the indexed `(field, text)` pairs sorted by field name, without
35    /// empty field names. The list is empty when the document holds no
36    /// indexable string field.
37    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    /// Remove a document from every index of `collection`: the
69    /// whole-document index and each per-field index.
70    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    // ── Whole-document index ──────────────────────────────────────────────────
78
79    /// Index `text` as a document's whole-document entry.
80    ///
81    /// The document is stored under the bare `"{collection}"` key.
82    /// Calling again with the same `doc_id` replaces the previous entry.
83    ///
84    /// Empty text is a removal, not a no-op: a document updated until it has
85    /// no indexable words must stop matching the words it used to contain.
86    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    /// Remove a document from the whole-document index.
96    pub fn remove_document(&self, collection: &str, doc_id: &str) -> Result<(), LiteError> {
97        self.retract(collection, &index_key(collection, ""), doc_id)
98    }
99
100    // ── Per-field index ───────────────────────────────────────────────────────
101
102    /// Index a single field value for a document.
103    ///
104    /// Key is `"{collection}:{field}"`. Calling again with the same `doc_id`
105    /// replaces the previous entry (upsert semantics).
106    ///
107    /// Empty text is a removal, not a no-op: clearing a field must stop the
108    /// document matching the words that field used to contain.
109    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    /// Remove a document's entry from the `field` index of a collection.
120    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    // ── Shared primitives ─────────────────────────────────────────────────────
130
131    /// Replace `doc_id`'s entry in the index under `key` with `text`,
132    /// creating the index on first use. Empty text removes the entry.
133    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    /// Remove `doc_id` from the index under `key`, if the index holds it.
181    ///
182    /// An index holds a document exactly when it records the document's
183    /// length, so absent documents skip the posting scan a removal costs.
184    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    // ── Origin-surrogate reverse map (for FtsIndexDoc / FtsDeleteDoc sync) ────
204
205    /// Register an association between an Origin global surrogate and the
206    /// Lite string `doc_id`. Called from the `FtsIndexDoc` execution arm so
207    /// `FtsDeleteDoc` can later resolve the Origin surrogate to a string doc_id
208    /// and call the proper single-doc removal instead of dropping the collection.
209    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    /// Remove a single document identified by its Origin-assigned surrogate.
215    ///
216    /// Returns the removed doc_id, or `None` if the surrogate has no known
217    /// Lite mapping (e.g. it was never indexed via this Lite instance).
218    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        // An update that strips every indexable word is a removal, not a no-op:
270        // the document must stop matching the words it used to contain.
271        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        // Register origin surrogate for doc2 (as if FtsIndexDoc was dispatched).
372        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}