use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::sync::Arc;
use nodedb_fts::FtsIndex;
use nodedb_fts::FtsIndexError;
use nodedb_fts::backend::memory::MemoryBackend;
use nodedb_fts::lsm::memtable::MemtableConfig;
use nodedb_mem::MemoryGovernor;
use nodedb_types::Surrogate;
use crate::engine::fts::LiteFtsIndex;
use crate::error::LiteError;
pub struct FtsResult {
pub doc_id: String,
pub score: f32,
pub fuzzy: bool,
}
pub fn index_key(collection: &str, field: &str) -> String {
if field.is_empty() {
collection.to_owned()
} else {
format!("{collection}:{field}")
}
}
pub(crate) fn key_of_collection(key: &str, collection: &str) -> bool {
key.strip_prefix(collection)
.is_some_and(|rest| rest.is_empty() || rest.starts_with(':'))
}
pub(crate) fn resident_index(governor: Arc<MemoryGovernor>) -> LiteFtsIndex {
FtsIndex::with_memtable_config(
MemoryBackend::new(),
MemtableConfig {
max_postings: usize::MAX,
max_terms: usize::MAX,
},
governor,
)
}
pub(in crate::engine::fts) fn fts_err(collection: &str, e: impl Display) -> LiteError {
LiteError::FtsIndex {
collection: collection.to_owned(),
detail: e.to_string(),
}
}
pub(in crate::engine::fts) fn search_err<E: Display>(
collection: &str,
e: FtsIndexError<E>,
) -> LiteError {
match e {
FtsIndexError::InvalidQuery(q) => LiteError::FtsQueryInvalid {
collection: collection.to_owned(),
detail: q.to_string(),
},
FtsIndexError::BudgetExhausted(m) => LiteError::Backpressure {
detail: format!("full-text search on {collection}: {m}"),
},
other => read_err(collection, other),
}
}
pub(in crate::engine::fts) fn read_err(collection: &str, e: impl Display) -> LiteError {
LiteError::FtsSearch {
collection: collection.to_owned(),
detail: e.to_string(),
}
}
pub struct FtsCollectionManager {
pub(super) retained: HashMap<String, super::retained::RetainedIndex>,
pub(super) declarations:
std::collections::BTreeMap<String, crate::engine::fts::catalog::SearchDeclarationRecord>,
pub(in crate::engine::fts) indices: HashMap<String, FtsIndex<MemoryBackend>>,
id_to_surrogate: HashMap<String, u32>,
pub(super) surrogate_to_id: HashMap<u32, String>,
next_surrogate: u32,
pub(super) origin_surrogate_to_doc_id: HashMap<u32, String>,
pub(in crate::engine::fts) collection_analyzers: HashMap<String, String>,
pub(in crate::engine::fts) collection_fuzzy_defaults: HashMap<String, bool>,
pub(in crate::engine::fts) governor: Arc<MemoryGovernor>,
}
impl FtsCollectionManager {
pub fn new(governor: Arc<MemoryGovernor>) -> Self {
Self {
retained: HashMap::new(),
declarations: std::collections::BTreeMap::new(),
indices: HashMap::new(),
id_to_surrogate: HashMap::new(),
surrogate_to_id: HashMap::new(),
next_surrogate: 1,
origin_surrogate_to_doc_id: HashMap::new(),
collection_analyzers: HashMap::new(),
collection_fuzzy_defaults: HashMap::new(),
governor,
}
}
pub(super) fn surrogate_for(
&mut self,
collection: &str,
doc_id: &str,
) -> Result<Surrogate, LiteError> {
if let Some(&s) = self.id_to_surrogate.get(doc_id) {
return Ok(Surrogate(s));
}
let s = self.next_surrogate;
self.next_surrogate = s.checked_add(1).ok_or_else(|| {
fts_err(
collection,
format!(
"no surrogate left for document '{doc_id}': all u32 surrogates are assigned"
),
)
})?;
self.id_to_surrogate.insert(doc_id.to_owned(), s);
self.surrogate_to_id.insert(s, doc_id.to_owned());
Ok(Surrogate(s))
}
pub(super) fn lookup_surrogate(&self, doc_id: &str) -> Option<Surrogate> {
self.id_to_surrogate.get(doc_id).copied().map(Surrogate)
}
pub(super) fn doc_id_of(&self, collection: &str, s: Surrogate) -> Result<&str, LiteError> {
self.surrogate_to_id
.get(&s.0)
.map(String::as_str)
.ok_or_else(|| {
read_err(
collection,
format!("posting references surrogate {} with no document id", s.0),
)
})
}
pub fn is_empty(&self) -> bool {
self.indices.is_empty()
}
pub fn collection_count(&self) -> usize {
self.indices
.keys()
.map(|k| k.split(':').next().unwrap_or(k.as_str()))
.collect::<HashSet<_>>()
.len()
}
pub(in crate::engine::fts) fn collection_keys(&self, collection: &str) -> Vec<String> {
self.indices
.keys()
.filter(|k| key_of_collection(k, collection))
.cloned()
.collect()
}
pub fn has_text_index(&self, collection: &str) -> bool {
self.indices
.keys()
.any(|k| key_of_collection(k, collection))
}
pub fn drop_collection(&mut self, collection: &str) {
self.remove_collection_postings(collection);
}
pub(crate) fn checkpoint_data(
&self,
) -> (
&HashMap<String, FtsIndex<MemoryBackend>>,
&HashMap<String, u32>,
u32,
) {
(&self.indices, &self.id_to_surrogate, self.next_surrogate)
}
pub(crate) fn load_checkpoint(
&mut self,
indices: HashMap<String, FtsIndex<MemoryBackend>>,
id_to_surrogate: HashMap<String, u32>,
surrogate_to_id: HashMap<u32, String>,
next_surrogate: u32,
) {
self.retained = indices
.iter()
.map(|(key, index)| {
(
key.clone(),
super::retained::RetainedIndex::restored(
Arc::clone(&self.governor),
key,
index,
),
)
})
.collect();
self.indices = indices;
self.id_to_surrogate = id_to_surrogate;
self.surrogate_to_id = surrogate_to_id;
self.next_surrogate = next_surrogate;
}
}
#[cfg(test)]
pub(crate) fn test_governor() -> Arc<MemoryGovernor> {
use nodedb_mem::{EngineLimits, GovernorConfig};
let per_engine = usize::MAX / nodedb_mem::EngineId::ALL.len();
Arc::new(
MemoryGovernor::new(GovernorConfig {
global_ceiling: per_engine * nodedb_mem::EngineId::ALL.len(),
engine_limits: EngineLimits::uniform(per_engine),
})
.expect("test governor"),
)
}
#[cfg(test)]
mod tests {
use super::{index_key, key_of_collection};
#[test]
fn no_field_name_produces_the_whole_document_key() {
let whole = index_key("col", "");
assert_eq!(whole, "col");
for field in ["_doc", ":", "doc", "col"] {
assert_ne!(index_key("col", field), whole, "field {field:?}");
}
assert_eq!(index_key("col", "_doc"), "col:_doc");
}
#[test]
fn keys_are_owned_by_their_collection_only() {
assert!(key_of_collection("col", "col"));
assert!(key_of_collection("col:title", "col"));
assert!(!key_of_collection("column", "col"));
assert!(!key_of_collection("column:title", "col"));
}
}