Skip to content

Commit 4e26e9f

Browse files
committed
feat(fts): bind per-collection analyzers
Retain a collection → analyzer name map on FtsCollectionManager and add a `TextOp::SetAnalyzer` handler so indexes created after DDL binds an analyzer inherit it.
1 parent 0aff1dd commit 4e26e9f

4 files changed

Lines changed: 99 additions & 9 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! Per-collection analyzer binding for Lite's FTS indexes.
4+
//!
5+
//! Mirrors Origin's `TextOp::SetAnalyzer`: the analyzer name is persisted into
6+
//! each index's backend metadata via `FtsIndex::set_collection_analyzer`, so
7+
//! `analyze_for_collection` resolves it for every later tokenization of that
8+
//! collection's text — indexing and query-time scoring alike.
9+
//!
10+
//! Lite shards one collection across several `FtsIndex` instances — a
11+
//! whole-document index keyed `"{collection}:_doc"` plus one per indexed field
12+
//! keyed `"{collection}:{field}"` — and passes that composite key as the
13+
//! `collection` argument to nodedb-fts. The analyzer therefore has to be bound
14+
//! on every one of a collection's indexes under its own key, including indexes
15+
//! that do not exist yet: DDL normally sets the analyzer before any document is
16+
//! written, so the name is also retained in `collection_analyzers` and applied
17+
//! to each index at creation time.
18+
19+
use super::manager::FtsCollectionManager;
20+
use crate::engine::fts::{LiteFtsIndex, MemoryBackend};
21+
use nodedb_fts::FtsIndex;
22+
23+
impl FtsCollectionManager {
24+
/// Bind `analyzer_name` to every index belonging to `collection`, and
25+
/// retain it so indexes created later inherit the same analyzer.
26+
///
27+
/// Unrecognized names fall back to the standard analyzer inside
28+
/// nodedb-fts at resolve time, matching Origin's behavior.
29+
pub fn set_collection_analyzer(&mut self, collection: &str, analyzer_name: &str) {
30+
self.collection_analyzers
31+
.insert(collection.to_string(), analyzer_name.to_string());
32+
33+
let prefix = format!("{collection}:");
34+
for (key, idx) in self.indices.iter_mut() {
35+
if key.starts_with(&prefix) {
36+
let _ = idx.set_collection_analyzer(0, 0, key, analyzer_name);
37+
}
38+
}
39+
}
40+
41+
/// Analyzer bound to the collection owning `key`, if any.
42+
///
43+
/// `key` is the composite `"{collection}:{field}"` index key; the
44+
/// collection is the portion before the last `:`, so field names
45+
/// containing `:` do not split incorrectly.
46+
pub(crate) fn analyzer_for_key(&self, key: &str) -> Option<&str> {
47+
let collection = key.rsplit_once(':').map(|(c, _)| c)?;
48+
self.collection_analyzers
49+
.get(collection)
50+
.map(String::as_str)
51+
}
52+
53+
/// Create an index for `key`, applying the collection's bound analyzer.
54+
///
55+
/// Used at every index-creation site so an analyzer bound before the first
56+
/// write is not silently lost for indexes materialized afterwards.
57+
pub(crate) fn new_index_for(&self, key: &str) -> LiteFtsIndex {
58+
let idx = FtsIndex::new(MemoryBackend::new());
59+
if let Some(name) = self.analyzer_for_key(key) {
60+
let _ = idx.set_collection_analyzer(0, 0, key, name);
61+
}
62+
idx
63+
}
64+
}

nodedb-lite/src/engine/fts/manager.rs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub struct FtsResult {
3535
pub struct FtsCollectionManager {
3636
/// Key: `"{collection}:{field}"` → FTS index.
3737
/// Whole-document index uses key `"{collection}:_doc"`.
38-
indices: HashMap<String, FtsIndex<MemoryBackend>>,
38+
pub(super) indices: HashMap<String, FtsIndex<MemoryBackend>>,
3939
/// Forward map: original string doc_id → dense u32 surrogate.
4040
///
4141
/// Surrogates **must** be dense (0, 1, 2, …) because `nodedb_fts::Memtable`
@@ -55,6 +55,13 @@ pub struct FtsCollectionManager {
5555
/// Needed by `FtsDeleteDoc` to translate the Origin surrogate back to the
5656
/// Lite string doc_id without dropping the whole collection.
5757
origin_surrogate_to_doc_id: HashMap<u32, String>,
58+
/// Collection name → bound analyzer name, from `TextOp::SetAnalyzer`.
59+
///
60+
/// Retained so indexes created after the analyzer was bound inherit it —
61+
/// DDL normally binds the analyzer before the first document is written,
62+
/// when none of the collection's indexes exist yet. See
63+
/// `super::analyzer` for the binding logic.
64+
pub(super) collection_analyzers: HashMap<String, String>,
5865
}
5966

6067
impl FtsCollectionManager {
@@ -67,6 +74,7 @@ impl FtsCollectionManager {
6774
// rejected by FtsIndex::index_document with SurrogateOutOfRange.
6875
next_surrogate: 1,
6976
origin_surrogate_to_doc_id: HashMap::new(),
77+
collection_analyzers: HashMap::new(),
7078
}
7179
}
7280

@@ -111,10 +119,8 @@ impl FtsCollectionManager {
111119
}
112120
let surrogate = self.surrogate_for(doc_id);
113121
let key = format!("{collection}:_doc");
114-
let idx = self
115-
.indices
116-
.entry(key.clone())
117-
.or_insert_with(|| FtsIndex::new(MemoryBackend::new()));
122+
let fresh = self.new_index_for(&key);
123+
let idx = self.indices.entry(key.clone()).or_insert(fresh);
118124
// Remove old entry first (upsert semantics).
119125
let _ = idx.remove_document(0, 0, &key, surrogate);
120126
let _ = idx.index_document(0, 0, &key, surrogate, text);
@@ -415,10 +421,8 @@ impl FtsCollectionManager {
415421
}
416422
let surrogate = self.surrogate_for(doc_id);
417423
let key = format!("{collection}:{field}");
418-
let idx = self
419-
.indices
420-
.entry(key.clone())
421-
.or_insert_with(|| FtsIndex::new(MemoryBackend::new()));
424+
let fresh = self.new_index_for(&key);
425+
let idx = self.indices.entry(key.clone()).or_insert(fresh);
422426
let _ = idx.remove_document(0, 0, &key, surrogate);
423427
let _ = idx.index_document(0, 0, &key, surrogate, text);
424428
}

nodedb-lite/src/engine/fts/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod analyzer;
12
pub mod checkpoint;
23
pub mod manager;
34
pub mod search;

nodedb-lite/src/query/physical_visitor/text_op.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,5 +471,26 @@ pub(super) fn execute_text_op<'a, S: StorageEngine + 'a>(
471471
})
472472
}))
473473
}
474+
475+
TextOp::SetAnalyzer {
476+
collection,
477+
analyzer_name,
478+
} => {
479+
let collection = collection.clone();
480+
let analyzer_name = analyzer_name.clone();
481+
let fts_state = Arc::clone(&engine.fts_state);
482+
Ok(Box::pin(async move {
483+
let mut mgr = fts_state
484+
.manager
485+
.lock()
486+
.map_err(|_| LiteError::LockPoisoned)?;
487+
mgr.set_collection_analyzer(&collection, &analyzer_name);
488+
Ok(QueryResult {
489+
columns: vec![],
490+
rows: vec![],
491+
rows_affected: 0,
492+
})
493+
}))
494+
}
474495
}
475496
}

0 commit comments

Comments
 (0)