Skip to main content

nodedb_lite/engine/fts/
analyzer.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Per-collection text configuration for Lite's FTS indexes.
4//!
5//! Mirrors Origin's `TextOp::SetTextConfig`: the analyzer name and the default
6//! fuzzy-matching flag are persisted into each index's backend metadata via
7//! `FtsIndex::set_collection_analyzer` / `FtsIndex::set_collection_fuzzy`, so
8//! `analyze_for_collection` resolves the analyzer for every later tokenization
9//! of that collection's text — indexing and query-time scoring alike — and
10//! `FtsIndex::search` falls back to fuzzy matching for the collection even when
11//! the query did not ask for it.
12//!
13//! Lite shards one collection across several `FtsIndex` instances — a
14//! whole-document index keyed `"{collection}"` plus one per indexed field
15//! keyed `"{collection}:{field}"` — and passes that composite key as the
16//! `collection` argument to nodedb-fts. Each setting therefore has to be bound
17//! on every one of a collection's indexes under its own key, including indexes
18//! that do not exist yet: DDL normally runs before any document is written, so
19//! the values are also retained in `collection_analyzers` /
20//! `collection_fuzzy_defaults` and applied to each index at creation time.
21
22use super::manager::registry::fts_err;
23use super::manager::{FtsCollectionManager, resident_index};
24use crate::engine::fts::LiteFtsIndex;
25use crate::error::LiteError;
26
27impl FtsCollectionManager {
28    /// Bind `analyzer_name` to every index belonging to `collection`, and
29    /// retain it so indexes created later inherit the same analyzer.
30    ///
31    /// Unrecognized names fall back to the standard analyzer inside
32    /// nodedb-fts at resolve time, matching Origin's behavior. Fails when an
33    /// index cannot record the binding.
34    pub fn set_collection_analyzer(
35        &mut self,
36        collection: &str,
37        analyzer_name: &str,
38    ) -> Result<(), LiteError> {
39        self.collection_analyzers
40            .insert(collection.to_string(), analyzer_name.to_string());
41        for key in self.collection_keys(collection) {
42            if let Some(idx) = self.indices.get(&key) {
43                idx.set_collection_analyzer(0, 0, &key, analyzer_name)
44                    .map_err(|e| fts_err(collection, e))?;
45            }
46        }
47        Ok(())
48    }
49
50    /// Bind the default fuzzy-matching flag to every index belonging to
51    /// `collection`, and retain it so indexes created later inherit it.
52    /// Fails when an index cannot record the binding.
53    pub fn set_collection_fuzzy(&mut self, collection: &str, fuzzy: bool) -> Result<(), LiteError> {
54        self.collection_fuzzy_defaults
55            .insert(collection.to_string(), fuzzy);
56        for key in self.collection_keys(collection) {
57            if let Some(idx) = self.indices.get(&key) {
58                idx.set_collection_fuzzy(0, 0, &key, fuzzy)
59                    .map_err(|e| fts_err(collection, e))?;
60            }
61        }
62        Ok(())
63    }
64
65    /// Analyzer bound to `collection`, if any.
66    pub(crate) fn analyzer_for(&self, collection: &str) -> Option<&str> {
67        self.collection_analyzers
68            .get(collection)
69            .map(String::as_str)
70    }
71
72    /// Default fuzzy-matching flag bound to `collection`, if any.
73    pub(crate) fn fuzzy_for(&self, collection: &str) -> Option<bool> {
74        self.collection_fuzzy_defaults.get(collection).copied()
75    }
76
77    /// Create the index for `key` of `collection`, applying the collection's
78    /// bound text config.
79    ///
80    /// Used at every index-creation site so an analyzer or fuzzy default bound
81    /// before the first write is not silently lost for indexes materialized
82    /// afterwards. Fails when the new index cannot record the binding.
83    pub(crate) fn new_index_for(
84        &self,
85        collection: &str,
86        key: &str,
87    ) -> Result<LiteFtsIndex, LiteError> {
88        let idx = resident_index(std::sync::Arc::clone(&self.governor));
89        if let Some(name) = self.analyzer_for(collection) {
90            idx.set_collection_analyzer(0, 0, key, name)
91                .map_err(|e| fts_err(collection, e))?;
92        }
93        if let Some(fuzzy) = self.fuzzy_for(collection) {
94            idx.set_collection_fuzzy(0, 0, key, fuzzy)
95                .map_err(|e| fts_err(collection, e))?;
96        }
97        Ok(idx)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::FtsCollectionManager;
104    use crate::engine::fts::manager::test_governor;
105
106    const DOC_KEY: &str = "col";
107
108    /// Read back the analyzer and fuzzy default persisted on the whole-document
109    /// index of `col`.
110    fn doc_index_config(mgr: &FtsCollectionManager) -> (Option<String>, bool) {
111        let idx = mgr
112            .indices
113            .get(DOC_KEY)
114            .expect("whole-document index must exist");
115        (
116            idx.get_collection_analyzer(0, 0, DOC_KEY)
117                .expect("meta read must succeed"),
118            idx.get_collection_fuzzy(0, 0, DOC_KEY)
119                .expect("meta read must succeed"),
120        )
121    }
122
123    #[test]
124    fn setting_analyzer_leaves_fuzzy_default_unchanged() {
125        let mut mgr = FtsCollectionManager::new(test_governor());
126        mgr.index_document("col", "doc1", "the quick brown fox")
127            .expect("index update must succeed");
128
129        mgr.set_collection_fuzzy("col", true)
130            .expect("config binding must succeed");
131        mgr.set_collection_analyzer("col", "german")
132            .expect("config binding must succeed");
133
134        let (analyzer, fuzzy) = doc_index_config(&mgr);
135        assert_eq!(analyzer.as_deref(), Some("german"));
136        assert!(
137            fuzzy,
138            "binding the analyzer must not clear the fuzzy default"
139        );
140        assert_eq!(mgr.fuzzy_for("col"), Some(true));
141    }
142
143    #[test]
144    fn setting_fuzzy_default_leaves_analyzer_unchanged() {
145        let mut mgr = FtsCollectionManager::new(test_governor());
146        mgr.index_document("col", "doc1", "the quick brown fox")
147            .expect("index update must succeed");
148
149        mgr.set_collection_analyzer("col", "german")
150            .expect("config binding must succeed");
151        mgr.set_collection_fuzzy("col", true)
152            .expect("config binding must succeed");
153
154        let (analyzer, fuzzy) = doc_index_config(&mgr);
155        assert_eq!(
156            analyzer.as_deref(),
157            Some("german"),
158            "binding the fuzzy default must not clear the analyzer"
159        );
160        assert!(fuzzy);
161        assert_eq!(mgr.analyzer_for("col"), Some("german"));
162    }
163
164    #[test]
165    fn config_bound_before_any_index_is_inherited_by_later_indexes() {
166        let mut mgr = FtsCollectionManager::new(test_governor());
167        // DDL order: config first, documents afterwards — no index exists yet.
168        mgr.set_collection_analyzer("col", "german")
169            .expect("config binding must succeed");
170        mgr.set_collection_fuzzy("col", true)
171            .expect("config binding must succeed");
172        assert!(mgr.indices.is_empty());
173
174        mgr.index_document("col", "doc1", "der schnelle braune fuchs")
175            .expect("index update must succeed");
176
177        let (analyzer, fuzzy) = doc_index_config(&mgr);
178        assert_eq!(analyzer.as_deref(), Some("german"));
179        assert!(fuzzy);
180    }
181
182    #[test]
183    fn config_bound_before_any_index_is_inherited_by_later_field_indexes() {
184        let mut mgr = FtsCollectionManager::new(test_governor());
185        mgr.set_collection_analyzer("col", "german")
186            .expect("config binding must succeed");
187        mgr.set_collection_fuzzy("col", true)
188            .expect("config binding must succeed");
189
190        mgr.index_field("col", "title", "doc1", "der schnelle braune fuchs")
191            .expect("index update must succeed");
192
193        let key = "col:title";
194        let idx = mgr.indices.get(key).expect("field index must exist");
195        assert_eq!(
196            idx.get_collection_analyzer(0, 0, key)
197                .expect("meta read must succeed")
198                .as_deref(),
199            Some("german")
200        );
201        assert!(
202            idx.get_collection_fuzzy(0, 0, key)
203                .expect("meta read must succeed")
204        );
205    }
206
207    #[test]
208    fn binding_nothing_leaves_the_collection_at_its_defaults() {
209        // The `SetTextConfig { analyzer_name: None, fuzzy_default: None }`
210        // case: neither setter runs, so nothing is retained or persisted.
211        let mut mgr = FtsCollectionManager::new(test_governor());
212        mgr.index_document("col", "doc1", "the quick brown fox")
213            .expect("index update must succeed");
214
215        let (analyzer, fuzzy) = doc_index_config(&mgr);
216        assert_eq!(analyzer, None);
217        assert!(!fuzzy);
218        assert_eq!(mgr.analyzer_for("col"), None);
219        assert_eq!(mgr.fuzzy_for("col"), None);
220    }
221
222    #[test]
223    fn config_is_bound_to_the_exact_collection_name() {
224        let mut mgr = FtsCollectionManager::new(test_governor());
225        mgr.set_collection_analyzer("a:b", "german")
226            .expect("config binding must succeed");
227        mgr.set_collection_fuzzy("a:b", true)
228            .expect("config binding must succeed");
229
230        assert_eq!(mgr.analyzer_for("a:b"), Some("german"));
231        assert_eq!(mgr.fuzzy_for("a:b"), Some(true));
232        assert_eq!(mgr.analyzer_for("a"), None);
233        assert_eq!(mgr.fuzzy_for("a"), None);
234    }
235}