use std::collections::{HashMap, HashSet};
use nodedb_fts::IndexScope;
use nodedb_fts::backend::FtsBackend;
use nodedb_types::Surrogate;
use nodedb_types::value::Value;
use super::registry::{FtsCollectionManager, fts_err, index_key};
use crate::error::LiteError;
pub fn whole_document_text(fields: &HashMap<String, Value>) -> String {
super::fields::joined_text(&super::fields::selected_texts(fields, None))
}
impl FtsCollectionManager {
pub fn index_document_fields(
&mut self,
collection: &str,
doc_id: &str,
fields: &HashMap<String, Value>,
) -> Result<Vec<(String, String)>, LiteError> {
let texts = super::fields::selected_texts(fields, self.declaration_for(collection));
let text = super::fields::joined_text(&texts);
self.index_document(collection, doc_id, &text)?;
let whole = index_key(collection, "");
let mut indexed: HashSet<String> = HashSet::new();
let mut pairs = Vec::with_capacity(texts.len());
for (field, value) in texts {
if field.is_empty() {
continue;
}
self.index_field(collection, field, doc_id, value)?;
indexed.insert(index_key(collection, field));
pairs.push((field.to_owned(), value.to_owned()));
}
let system = index_key(collection, "_geohash");
for key in self.collection_keys(collection) {
if key != whole && key != system && !indexed.contains(&key) {
self.retract(collection, &key, doc_id)?;
}
}
Ok(pairs)
}
pub fn remove_document_fields(&self, collection: &str, doc_id: &str) -> Result<(), LiteError> {
for key in self.collection_keys(collection) {
self.retract(collection, &key, doc_id)?;
}
Ok(())
}
pub fn index_document(
&mut self,
collection: &str,
doc_id: &str,
text: &str,
) -> Result<(), LiteError> {
self.index_text(collection, &index_key(collection, ""), doc_id, text)
}
pub fn remove_document(&self, collection: &str, doc_id: &str) -> Result<(), LiteError> {
self.retract(collection, &index_key(collection, ""), doc_id)
}
pub fn index_field(
&mut self,
collection: &str,
field: &str,
doc_id: &str,
text: &str,
) -> Result<(), LiteError> {
self.index_text(collection, &index_key(collection, field), doc_id, text)
}
pub fn remove_field(
&self,
collection: &str,
field: &str,
doc_id: &str,
) -> Result<(), LiteError> {
self.retract(collection, &index_key(collection, field), doc_id)
}
fn index_text(
&mut self,
collection: &str,
key: &str,
doc_id: &str,
text: &str,
) -> Result<(), LiteError> {
if text.is_empty() {
return self.retract(collection, key, doc_id);
}
let surrogate = self.surrogate_for(collection, doc_id)?;
if !self.indices.contains_key(key) {
let mut retained = super::retained::RetainedIndex::default();
retained.reserve_base(std::sync::Arc::clone(&self.governor), key)?;
let fresh = self.new_index_for(collection, key)?;
self.indices.insert(key.to_owned(), fresh);
self.retained.insert(key.to_owned(), retained);
}
let tokens = self
.indices
.get(key)
.ok_or_else(|| {
fts_err(
collection,
format!("index '{key}' vanished before analysis"),
)
})?
.analyze_for_collection(0, 0, key, text)
.map_err(|error| fts_err(collection, error))?;
self.retained
.get_mut(key)
.ok_or_else(|| fts_err(collection, format!("index lease '{key}' is absent")))?
.reserve_write(
std::sync::Arc::clone(&self.governor),
key,
surrogate,
&tokens,
)?;
self.retract(collection, key, doc_id)?;
let idx = self
.indices
.get(key)
.ok_or_else(|| fts_err(collection, format!("index '{key}' vanished mid-write")))?;
idx.index_analyzed_document(0, 0, key, surrogate, &tokens)
.map_err(|e| fts_err(collection, e))
}
fn retract(&self, collection: &str, key: &str, doc_id: &str) -> Result<(), LiteError> {
let Some(surrogate) = self.lookup_surrogate(doc_id) else {
return Ok(());
};
let Some(idx) = self.indices.get(key) else {
return Ok(());
};
let held = idx
.backend()
.read_doc_length(0, 0, IndexScope::document(key), surrogate)
.map_err(|e| fts_err(collection, e))?
.is_some();
if held {
idx.remove_document(0, 0, key, surrogate)
.map_err(|e| fts_err(collection, e))?;
}
Ok(())
}
pub fn register_origin_surrogate(&mut self, origin_surrogate: Surrogate, doc_id: &str) {
self.origin_surrogate_to_doc_id
.insert(origin_surrogate.0, doc_id.to_owned());
}
pub fn remove_by_origin_surrogate(
&mut self,
collection: &str,
origin_surrogate: Surrogate,
) -> Result<Option<String>, LiteError> {
let Some(doc_id) = self.origin_surrogate_to_doc_id.remove(&origin_surrogate.0) else {
tracing::debug!(
collection,
sur = origin_surrogate.0,
"FtsDeleteDoc: no Lite mapping for Origin surrogate — document was never indexed here"
);
return Ok(None);
};
self.remove_document(collection, &doc_id)?;
Ok(Some(doc_id))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use nodedb_types::Surrogate;
use nodedb_types::text_search::TextSearchParams;
use nodedb_types::value::Value;
use super::super::registry::test_governor;
use super::FtsCollectionManager;
fn doc(pairs: &[(&str, &str)]) -> HashMap<String, Value> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), Value::String((*v).to_owned())))
.collect()
}
fn hits(mgr: &FtsCollectionManager, field: &str, query: &str) -> Vec<String> {
mgr.search("col", field, query, 10, &TextSearchParams::default())
.expect("search must succeed")
.into_iter()
.map(|r| r.doc_id)
.collect()
}
#[test]
fn clearing_a_document_removes_it_from_the_index() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_document("col", "doc1", "the quick brown fox")
.expect("index update must succeed");
assert_eq!(hits(&mgr, "", "quick"), vec!["doc1"]);
mgr.index_document("col", "doc1", "")
.expect("index update must succeed");
assert!(
hits(&mgr, "", "quick").is_empty(),
"cleared document must not keep matching its prior terms"
);
}
#[test]
fn clearing_a_field_removes_it_from_the_field_index() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_field("col", "title", "doc1", "the quick brown fox")
.expect("index update must succeed");
assert!(
!mgr.indices
.get("col:title")
.expect("field index exists")
.memtable()
.is_empty(),
"field index must hold the document's postings"
);
mgr.index_field("col", "title", "doc1", "")
.expect("index update must succeed");
assert!(
mgr.indices
.get("col:title")
.expect("field index exists")
.memtable()
.is_empty(),
"cleared field must not keep its prior postings"
);
}
#[test]
fn schemaless_document_is_indexed_per_field_and_whole() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_document_fields("col", "d1", &doc(&[("title", "rust"), ("body", "python")]))
.expect("index update must succeed");
assert_eq!(hits(&mgr, "title", "rust"), vec!["d1"]);
assert!(hits(&mgr, "title", "python").is_empty());
assert_eq!(hits(&mgr, "body", "python"), vec!["d1"]);
assert_eq!(hits(&mgr, "", "python"), vec!["d1"]);
}
#[test]
fn rewriting_without_a_field_retracts_it_from_that_field_index() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_document_fields("col", "d1", &doc(&[("title", "rust"), ("body", "python")]))
.expect("index update must succeed");
mgr.index_document_fields("col", "d1", &doc(&[("title", "rust")]))
.expect("index update must succeed");
assert!(hits(&mgr, "body", "python").is_empty());
assert!(hits(&mgr, "", "python").is_empty());
assert_eq!(hits(&mgr, "title", "rust"), vec!["d1"]);
}
#[test]
fn removing_a_document_clears_every_index_of_the_collection() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_document_fields("col", "d1", &doc(&[("title", "rust"), ("body", "python")]))
.expect("index update must succeed");
mgr.remove_document_fields("col", "d1")
.expect("removal must succeed");
assert!(hits(&mgr, "title", "rust").is_empty());
assert!(hits(&mgr, "body", "python").is_empty());
assert!(hits(&mgr, "", "rust").is_empty());
}
#[test]
fn a_field_named_doc_is_its_own_index() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_document_fields("col", "d1", &doc(&[("_doc", "alpha"), ("title", "beta")]))
.expect("index update must succeed");
assert_eq!(hits(&mgr, "_doc", "alpha"), vec!["d1"]);
assert!(
hits(&mgr, "_doc", "beta").is_empty(),
"`_doc` holds only its own field"
);
assert_eq!(
hits(&mgr, "", "beta"),
vec!["d1"],
"the whole document holds both"
);
}
#[test]
fn fts_delete_doc_removes_only_targeted_doc() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_document("col", "doc1", "rust programming language")
.expect("index update must succeed");
mgr.index_document("col", "doc2", "rust is fast and safe")
.expect("index update must succeed");
mgr.index_document("col", "doc3", "python is also great")
.expect("index update must succeed");
mgr.register_origin_surrogate(Surrogate(42), "doc2");
let removed = mgr
.remove_by_origin_surrogate("col", Surrogate(42))
.expect("removal must succeed");
assert!(removed.is_some(), "doc2 must be found and removed");
let ids = hits(&mgr, "", "rust");
assert!(
ids.contains(&"doc1".to_owned()),
"doc1 must still be present"
);
assert!(!ids.contains(&"doc2".to_owned()), "doc2 must be removed");
}
#[test]
fn fts_delete_doc_unknown_surrogate_returns_none() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_document("col", "doc1", "hello world")
.expect("index update must succeed");
let removed = mgr
.remove_by_origin_surrogate("col", Surrogate(99))
.expect("removal must succeed");
assert!(removed.is_none(), "unknown surrogate must return None");
assert_eq!(hits(&mgr, "", "hello"), vec!["doc1"]);
}
}