use crate::columns::ColumnStore;
use crate::idmap::IdMap;
use crate::interner::Interner;
use crate::types::Value;
use std::collections::{BTreeMap, BTreeSet};
pub fn tokenize(s: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
for ch in s.chars() {
if ch.is_alphanumeric() {
for lc in ch.to_lowercase() {
current.push(lc);
}
} else if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn value_tokens(v: &Value) -> Vec<String> {
match v {
Value::Str(s) => tokenize(s),
Value::List(items) => items
.iter()
.flat_map(|item| {
if let Value::Str(s) = item {
tokenize(s)
} else {
vec![]
}
})
.collect(),
_ => vec![],
}
}
#[derive(Debug, Clone)]
pub struct Term {
pub token: String,
pub prefix: bool,
}
pub fn parse_query(query: &str) -> Vec<Vec<Term>> {
let mut groups: Vec<Vec<Term>> = vec![vec![]];
for word in query.split_whitespace() {
match word.to_ascii_uppercase().as_str() {
"OR" => groups.push(vec![]),
"AND" => { }
_ => {
let (raw, prefix) = if let Some(stripped) = word.strip_suffix('*') {
(stripped, true)
} else {
(word, false)
};
let token: String = raw
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect();
if !token.is_empty() {
groups.last_mut().unwrap().push(Term { token, prefix });
}
}
}
}
groups.retain(|g| !g.is_empty());
groups
}
pub fn eval_query_terms(node_tokens: &BTreeSet<String>, groups: &[Vec<Term>]) -> bool {
if groups.is_empty() {
return false;
}
'outer: for group in groups {
for term in group {
let matched = if term.prefix {
node_tokens
.iter()
.any(|t| t.starts_with(term.token.as_str()))
} else {
node_tokens.contains(&term.token)
};
if !matched {
continue 'outer;
}
}
return true; }
false
}
pub fn eval_query(node_tokens: &BTreeSet<String>, query: &str) -> bool {
let groups = parse_query(query);
eval_query_terms(node_tokens, &groups)
}
#[derive(Debug, Default)]
pub struct FulltextIndex {
enabled: BTreeSet<(String, String)>,
postings: BTreeMap<String, BTreeMap<String, BTreeSet<u32>>>,
}
impl FulltextIndex {
pub fn new() -> Self {
Self::default()
}
pub fn is_enabled(&self, label: &str, field: &str) -> bool {
self.enabled
.contains(&(label.to_string(), field.to_string()))
}
pub fn has_label(&self, label: &str) -> bool {
self.enabled.iter().any(|(l, _)| l == label)
}
pub fn field_indexed(&self, field: &str) -> bool {
self.enabled.iter().any(|(_, f)| f == field)
}
pub fn field_indexed_by_other(&self, label: &str, field: &str) -> bool {
self.enabled.iter().any(|(l, f)| f == field && l != label)
}
pub fn enabled_pairs(&self) -> impl Iterator<Item = &(String, String)> {
self.enabled.iter()
}
pub fn enable(&mut self, label: &str, field: &str) -> bool {
self.enabled.insert((label.to_string(), field.to_string()))
}
pub fn disable(&mut self, label: &str, field: &str) -> bool {
let removed = self.enabled.remove(&(label.to_string(), field.to_string()));
if removed && !self.field_indexed(field) {
self.postings.remove(field);
}
removed
}
pub fn add_tokens(&mut self, node_id: u32, field: &str, value: &Value) {
let col = self.postings.entry(field.to_string()).or_default();
for tok in value_tokens(value) {
col.entry(tok).or_default().insert(node_id);
}
}
pub fn remove_node_field(&mut self, node_id: u32, field: &str) {
if let Some(col) = self.postings.get_mut(field) {
col.retain(|_, ids| {
ids.remove(&node_id);
!ids.is_empty()
});
if col.is_empty() {
self.postings.remove(field);
}
}
}
pub fn remove_node(&mut self, node_id: u32) {
for col in self.postings.values_mut() {
col.retain(|_, ids| {
ids.remove(&node_id);
!ids.is_empty()
});
}
self.postings.retain(|_, col| !col.is_empty());
}
pub fn search(&self, field: &str, query: &str) -> Vec<(u32, usize)> {
let Some(col) = self.postings.get(field) else {
return vec![];
};
let groups = parse_query(query);
if groups.is_empty() {
return vec![];
}
let mut counts: BTreeMap<u32, usize> = BTreeMap::new();
for group in &groups {
let matching = and_match(col, group);
for id in matching {
*counts.entry(id).or_insert(0) += 1;
}
}
let mut results: Vec<(u32, usize)> = counts.into_iter().collect();
results.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
results
}
pub fn rebuild_all(
&mut self,
ids: &IdMap,
labels: &[u32],
syms: &Interner,
props: &ColumnStore,
) {
if self.enabled.is_empty() {
return;
}
let enabled_vec: Vec<(String, String)> = self.enabled.iter().cloned().collect();
for (_, field) in &enabled_vec {
self.postings.remove(field);
}
let n = ids.len() as u32;
for id in 0..n {
let Some(&sym) = labels.get(id as usize) else {
continue;
};
if sym == u32::MAX {
continue; }
let Some(label) = syms.resolve(sym) else {
continue;
};
for (lbl, field) in &enabled_vec {
if lbl == label {
if let Some(value) = props.get(id, field) {
self.add_tokens(id, field, value);
}
}
}
}
}
}
fn and_match(col: &BTreeMap<String, BTreeSet<u32>>, terms: &[Term]) -> BTreeSet<u32> {
let mut result: Option<BTreeSet<u32>> = None;
for term in terms {
let matching: BTreeSet<u32> = if term.prefix {
col.iter()
.filter(|(k, _)| k.starts_with(term.token.as_str()))
.flat_map(|(_, ids)| ids.iter().copied())
.collect()
} else {
col.get(&term.token).cloned().unwrap_or_default()
};
result = Some(match result {
None => matching,
Some(prev) => prev.intersection(&matching).copied().collect(),
});
}
result.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn toks(s: &str) -> Vec<String> {
tokenize(s)
}
#[test]
fn tokenizer_basic() {
assert_eq!(toks("Hello, World!"), vec!["hello", "world"]);
assert_eq!(toks("rust-lang"), vec!["rust", "lang"]);
assert_eq!(toks("abc123"), vec!["abc123"]);
assert_eq!(toks(""), Vec::<String>::new());
}
#[test]
fn tokenizer_unicode() {
assert_eq!(toks("café"), vec!["café"]);
assert_eq!(toks("über alles"), vec!["über", "alles"]);
}
#[test]
fn parse_query_and() {
let g = parse_query("foo bar");
assert_eq!(g.len(), 1);
assert_eq!(g[0].len(), 2);
assert_eq!(g[0][0].token, "foo");
assert_eq!(g[0][1].token, "bar");
assert!(!g[0][0].prefix);
}
#[test]
fn parse_query_or() {
let g = parse_query("foo OR bar");
assert_eq!(g.len(), 2);
assert_eq!(g[0][0].token, "foo");
assert_eq!(g[1][0].token, "bar");
}
#[test]
fn parse_query_prefix() {
let g = parse_query("foo*");
assert_eq!(g.len(), 1);
assert!(g[0][0].prefix);
assert_eq!(g[0][0].token, "foo");
}
#[test]
fn parse_query_explicit_and_keyword() {
let g = parse_query("foo AND bar");
assert_eq!(g.len(), 1);
assert_eq!(g[0].len(), 2);
}
#[test]
fn parse_query_or_case_insensitive() {
let g = parse_query("a or b");
assert_eq!(g.len(), 2);
}
#[test]
fn eval_query_and_all_must_match() {
let toks: BTreeSet<_> = ["hello", "world"].iter().map(|s| s.to_string()).collect();
assert!(eval_query(&toks, "hello world"));
assert!(!eval_query(&toks, "hello rust"));
}
#[test]
fn eval_query_or_any_group_matches() {
let toks: BTreeSet<_> = ["hello"].iter().map(|s| s.to_string()).collect();
assert!(eval_query(&toks, "hello OR rust"));
assert!(eval_query(&toks, "nope OR hello"));
assert!(!eval_query(&toks, "nope OR missing"));
}
#[test]
fn eval_query_prefix() {
let toks: BTreeSet<_> = ["rustlang", "python"]
.iter()
.map(|s| s.to_string())
.collect();
assert!(eval_query(&toks, "rust*"));
assert!(!eval_query(&toks, "java*"));
}
#[test]
fn index_and_search_basic() {
let mut idx = FulltextIndex::new();
idx.enable("Person", "bio");
idx.add_tokens(0, "bio", &Value::Str("I love Rust and databases".into()));
idx.add_tokens(1, "bio", &Value::Str("Python developer here".into()));
let r = idx.search("bio", "rust");
assert_eq!(r.len(), 1);
assert_eq!(r[0].0, 0);
let r2 = idx.search("bio", "rust OR python");
assert_eq!(r2.len(), 2);
let r3 = idx.search("bio", "rust databases");
assert_eq!(r3.len(), 1);
assert_eq!(r3[0].0, 0);
let r4 = idx.search("bio", "rust AND python");
assert!(r4.is_empty()); }
#[test]
fn search_case_insensitive() {
let mut idx = FulltextIndex::new();
idx.enable("Doc", "body");
idx.add_tokens(0, "body", &Value::Str("Rust is great".into()));
assert_eq!(idx.search("body", "RUST").len(), 1);
assert_eq!(idx.search("body", "Rust").len(), 1);
assert_eq!(idx.search("body", "rust").len(), 1);
}
#[test]
fn remove_node_field_clears_tokens() {
let mut idx = FulltextIndex::new();
idx.enable("A", "f");
idx.add_tokens(0, "f", &Value::Str("hello world".into()));
idx.remove_node_field(0, "f");
assert!(idx.search("f", "hello").is_empty());
}
#[test]
fn remove_node_clears_all_fields() {
let mut idx = FulltextIndex::new();
idx.enable("A", "f");
idx.enable("A", "g");
idx.add_tokens(0, "f", &Value::Str("foo".into()));
idx.add_tokens(0, "g", &Value::Str("bar".into()));
idx.remove_node(0);
assert!(idx.search("f", "foo").is_empty());
assert!(idx.search("g", "bar").is_empty());
}
#[test]
fn unindexed_field_returns_empty() {
let idx = FulltextIndex::new();
assert!(idx.search("notindexed", "anything").is_empty());
}
#[test]
fn prefix_search() {
let mut idx = FulltextIndex::new();
idx.enable("Doc", "body");
idx.add_tokens(0, "body", &Value::Str("rustlang rusty".into()));
idx.add_tokens(1, "body", &Value::Str("python java".into()));
let r = idx.search("body", "rust*");
assert_eq!(r.len(), 1);
assert_eq!(r[0].0, 0);
let r2 = idx.search("body", "java* OR rust*");
assert_eq!(r2.len(), 2);
}
#[test]
fn ranking_by_match_count_desc() {
let mut idx = FulltextIndex::new();
idx.enable("A", "f");
idx.add_tokens(0, "f", &Value::Str("alpha beta".into()));
idx.add_tokens(1, "f", &Value::Str("beta".into()));
let r = idx.search("f", "alpha OR beta");
assert_eq!(r[0].0, 0); assert_eq!(r[0].1, 2);
assert_eq!(r[1].0, 1);
assert_eq!(r[1].1, 1);
}
#[test]
fn rebuild_all_restores_index() {
let mut ids = IdMap::new();
let mut syms = Interner::new();
let mut labels: Vec<u32> = Vec::new();
let mut props = ColumnStore::new();
let id0 = ids.get_or_insert("k0");
let sym = syms.intern("Person");
labels.resize(id0 as usize + 1, u32::MAX);
labels[id0 as usize] = sym;
props.set(id0, "bio", Value::Str("I love Rust".into()));
let mut idx = FulltextIndex::new();
idx.enable("Person", "bio");
assert!(idx.search("bio", "rust").is_empty());
idx.rebuild_all(&ids, &labels, &syms, &props);
let r = idx.search("bio", "rust");
assert_eq!(r.len(), 1);
}
#[test]
fn mid_token_star_is_stripped_to_exact() {
let toks = tokenize("ru*st");
assert_eq!(toks, vec!["ru".to_string(), "st".to_string()]);
let groups = parse_query("ru*st");
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 1);
assert!(!groups[0][0].prefix, "mid-token * must NOT set prefix flag");
assert_eq!(groups[0][0].token, "rust");
let mut idx = FulltextIndex::new();
idx.enable("T", "f");
idx.add_tokens(0, "f", &Value::Str("rust embedded".into()));
assert_eq!(idx.search("f", "ru*st").len(), 1);
assert_eq!(idx.search("f", "ru*").len(), 1);
assert_eq!(idx.search("f", "rust").len(), 1);
}
}