use std::collections::{HashMap, HashSet};
use crate::node_store::{NodeStore, Value as StoreValue};
const TOMBSTONE: u64 = u64::MAX;
const ABSENT: u64 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct TextIndexKey {
label_id: u32,
col_id: u32,
}
#[derive(Default)]
pub struct TextIndex {
entries: HashMap<TextIndexKey, Vec<(String, u32)>>,
loaded: HashSet<TextIndexKey>,
}
impl TextIndex {
pub fn new() -> Self {
TextIndex::default()
}
pub fn build_for(&mut self, store: &NodeStore, label_id: u32, col_id: u32) {
if col_id == 0 {
return;
}
let key = TextIndexKey { label_id, col_id };
if self.loaded.contains(&key) {
return;
}
self.loaded.insert(key);
let tombstone_slots: HashSet<u32> = match store.read_col_all(label_id, 0) {
Ok(col0) => col0
.iter()
.enumerate()
.filter_map(|(slot, &raw)| {
if raw == TOMBSTONE {
Some(slot as u32)
} else {
None
}
})
.collect(),
Err(_) => HashSet::new(),
};
let raw_vals = match store.read_col_all(label_id, col_id) {
Ok(v) => v,
Err(e) => {
eprintln!(
"WARN: TextIndex::build_for: failed to read col_{col_id} for label {label_id}: {e}; skipping"
);
return;
}
};
let bucket = self.entries.entry(key).or_default();
for (slot, raw) in raw_vals.into_iter().enumerate() {
let slot = slot as u32;
if raw == ABSENT || tombstone_slots.contains(&slot) {
continue;
}
if let StoreValue::Bytes(b) = store.decode_raw_value(raw) {
let s = String::from_utf8_lossy(&b).into_owned();
bucket.push((s, slot));
}
}
bucket.sort_unstable_by(|a, b| a.0.cmp(&b.0));
}
pub fn build(store: &NodeStore, label_ids: &[(u32, String)]) -> Self {
let mut idx = TextIndex::new();
for &(label_id, ref _label_name) in label_ids {
let col_ids = match store.col_ids_for_label(label_id) {
Ok(ids) => ids,
Err(e) => {
eprintln!(
"WARN: TextIndex::build: failed to list col_ids for label {label_id}: {e}; skipping"
);
continue;
}
};
let tombstone_slots: std::collections::HashSet<u32> =
match store.read_col_all(label_id, 0) {
Ok(col0) => col0
.iter()
.enumerate()
.filter_map(|(slot, &raw)| {
if raw == TOMBSTONE {
Some(slot as u32)
} else {
None
}
})
.collect(),
Err(_) => std::collections::HashSet::new(),
};
for col_id in col_ids {
let raw_vals = match store.read_col_all(label_id, col_id) {
Ok(v) => v,
Err(e) => {
eprintln!(
"WARN: TextIndex::build: failed to read col_{col_id} for label {label_id}: {e}; skipping"
);
continue;
}
};
let key = TextIndexKey { label_id, col_id };
let bucket = idx.entries.entry(key).or_default();
for (slot, raw) in raw_vals.into_iter().enumerate() {
let slot = slot as u32;
if raw == ABSENT || tombstone_slots.contains(&slot) {
continue;
}
if let StoreValue::Bytes(b) = store.decode_raw_value(raw) {
let s = String::from_utf8_lossy(&b).into_owned();
bucket.push((s, slot));
}
}
bucket.sort_unstable_by(|a, b| a.0.cmp(&b.0));
}
}
idx
}
pub fn is_indexed(&self, label_id: u32, col_id: u32) -> bool {
self.entries
.contains_key(&TextIndexKey { label_id, col_id })
}
pub fn insert(&mut self, label_id: u32, col_id: u32, slot: u32, decoded_string: String) {
let key = TextIndexKey { label_id, col_id };
let bucket = self.entries.entry(key).or_default();
let pos = bucket.partition_point(|(s, _)| s.as_str() < decoded_string.as_str());
bucket.insert(pos, (decoded_string, slot));
}
pub fn lookup_contains(&self, label_id: u32, col_id: u32, pattern: &str) -> Vec<u32> {
let key = TextIndexKey { label_id, col_id };
match self.entries.get(&key) {
None => vec![],
Some(bucket) => bucket
.iter()
.filter(|(s, _)| s.contains(pattern))
.map(|(_, slot)| *slot)
.collect(),
}
}
pub fn lookup_starts_with(&self, label_id: u32, col_id: u32, prefix: &str) -> Vec<u32> {
let key = TextIndexKey { label_id, col_id };
match self.entries.get(&key) {
None => vec![],
Some(bucket) => {
let start = bucket.partition_point(|(s, _)| s.as_str() < prefix);
bucket[start..]
.iter()
.take_while(|(s, _)| s.starts_with(prefix))
.map(|(_, slot)| *slot)
.collect()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_index_with_entries(entries: &[(&str, u32)]) -> TextIndex {
let mut idx = TextIndex::new();
let bucket = idx
.entries
.entry(TextIndexKey {
label_id: 1,
col_id: 1,
})
.or_default();
for (s, slot) in entries {
bucket.push((s.to_string(), *slot));
}
bucket.sort_unstable_by(|a, b| a.0.cmp(&b.0));
idx
}
#[test]
fn contains_basic() {
let idx = make_index_with_entries(&[
("Widget Pro", 0),
("Widget Lite", 1),
("Gadget Max", 2),
("Banana Phone", 3),
]);
let mut hits = idx.lookup_contains(1, 1, "Widget");
hits.sort();
assert_eq!(hits, vec![0, 1]);
}
#[test]
fn contains_no_match() {
let idx = make_index_with_entries(&[("hello", 0), ("world", 1)]);
assert!(idx.lookup_contains(1, 1, "xyz").is_empty());
}
#[test]
fn starts_with_basic() {
let idx = make_index_with_entries(&[
("Widget Pro", 0),
("Widget Lite", 1),
("Gadget Max", 2),
("Banana Phone", 3),
]);
let mut hits = idx.lookup_starts_with(1, 1, "Widget");
hits.sort();
assert_eq!(hits, vec![0, 1]);
}
#[test]
fn starts_with_no_match() {
let idx = make_index_with_entries(&[("hello", 0), ("world", 1)]);
assert!(idx.lookup_starts_with(1, 1, "xyz").is_empty());
}
#[test]
fn starts_with_prefix_boundary() {
let idx = make_index_with_entries(&[("aaa", 0), ("aab", 1), ("abc", 2), ("bcd", 3)]);
let mut hits = idx.lookup_starts_with(1, 1, "aa");
hits.sort();
assert_eq!(hits, vec![0, 1]);
}
#[test]
fn not_indexed_returns_empty() {
let idx = TextIndex::new();
assert!(idx.lookup_contains(99, 99, "x").is_empty());
assert!(idx.lookup_starts_with(99, 99, "x").is_empty());
}
#[test]
fn insert_maintains_sort_order() {
let mut idx = TextIndex::new();
idx.insert(1, 1, 2, "gamma".to_string());
idx.insert(1, 1, 0, "alpha".to_string());
idx.insert(1, 1, 1, "beta".to_string());
let hits = idx.lookup_starts_with(1, 1, "bet");
assert_eq!(hits, vec![1]);
assert!(idx.lookup_starts_with(1, 1, "delta").is_empty());
}
}