use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::ops::Bound::{Excluded, Included, Unbounded};
use crate::storage::unified::bitmap_index::BitmapIndexManager;
use crate::storage::unified::entity::EntityId;
use crate::storage::unified::hash_index::{HashIndexConfig, HashIndexManager};
use reddb_types::{value_to_canonical_key, CanonicalKey, CanonicalKeyFamily, Value};
enum CanonicalizedValue {
Exact(CanonicalKey),
Unsupported,
}
fn read_unpoisoned<'a, T>(lock: &'a RwLock<T>) -> RwLockReadGuard<'a, T> {
lock.read()
}
fn write_unpoisoned<'a, T>(lock: &'a RwLock<T>) -> RwLockWriteGuard<'a, T> {
lock.write()
}
const BTREE_ENTRY_OVERHEAD_BYTES: usize = 48;
fn canonical_key_bytes(key: &CanonicalKey) -> usize {
let heap = match key {
CanonicalKey::Text(_, text) => text.len(),
CanonicalKey::Bytes(_, bytes) => bytes.len(),
CanonicalKey::PairTextU64(_, text, _) => text.len(),
CanonicalKey::PairTextText(_, left, right) => left.len() + right.len(),
CanonicalKey::Null
| CanonicalKey::Boolean(_)
| CanonicalKey::Signed(_, _)
| CanonicalKey::Unsigned(_, _)
| CanonicalKey::Float(_)
| CanonicalKey::PairU32U8(_, _, _)
| CanonicalKey::PairU32U32(_, _, _)
| CanonicalKey::PairI32I32(_, _, _) => 0,
};
std::mem::size_of::<CanonicalKey>() + heap
}
fn btree_entry_bytes<'a>(
key_parts: impl Iterator<Item = &'a CanonicalKey>,
posted_ids: usize,
) -> usize {
key_parts.map(canonical_key_bytes).sum::<usize>()
+ posted_ids * std::mem::size_of::<EntityId>()
+ BTREE_ENTRY_OVERHEAD_BYTES
}
pub struct SortedColumnIndex {
entries: BTreeMap<CanonicalKey, Vec<EntityId>>,
entry_memory_bytes: usize,
range_family: Option<CanonicalKeyFamily>,
has_mixed_families: bool,
families: BTreeSet<CanonicalKeyFamily>,
}
impl SortedColumnIndex {
pub fn new() -> Self {
Self {
entries: BTreeMap::new(),
entry_memory_bytes: 0,
range_family: None,
has_mixed_families: false,
families: BTreeSet::new(),
}
}
pub fn insert(&mut self, key: CanonicalKey, entity_id: EntityId) {
self.families.insert(key.family());
match self.range_family {
Some(existing) if existing != key.family() => self.has_mixed_families = true,
None => self.range_family = Some(key.family()),
_ => {}
}
let entry = self.entries.entry(key);
if let std::collections::btree_map::Entry::Vacant(ref entry) = entry {
self.entry_memory_bytes += btree_entry_bytes(std::iter::once(entry.key()), 0);
}
self.entry_memory_bytes += std::mem::size_of::<EntityId>();
entry.or_default().push(entity_id);
}
fn range_enabled(&self, family: CanonicalKeyFamily) -> bool {
!self.has_mixed_families && self.range_family == Some(family)
}
pub fn supports_range_key(&self, key: &CanonicalKey) -> bool {
self.range_enabled(key.family())
}
pub fn supports_mixed_integral_ranges(&self) -> bool {
!self.families.is_empty()
&& self.families.iter().all(|family| {
matches!(
family,
CanonicalKeyFamily::Integer | CanonicalKeyFamily::UnsignedInteger
)
})
}
pub fn range(&self, low: CanonicalKey, high: CanonicalKey) -> Option<Vec<EntityId>> {
if !self.range_enabled(low.family()) || low.family() != high.family() {
return None;
}
if low > high {
return Some(Vec::new());
}
Some(self.collect_range(low..=high))
}
pub fn greater_than(&self, threshold: CanonicalKey) -> Option<Vec<EntityId>> {
if !self.range_enabled(threshold.family()) {
return None;
}
Some(self.collect_range((Excluded(threshold), Unbounded)))
}
pub fn greater_equal(&self, threshold: CanonicalKey) -> Option<Vec<EntityId>> {
if !self.range_enabled(threshold.family()) {
return None;
}
Some(self.collect_range((Included(threshold), Unbounded)))
}
pub fn less_than(&self, threshold: CanonicalKey) -> Option<Vec<EntityId>> {
if !self.range_enabled(threshold.family()) {
return None;
}
Some(self.collect_range((Unbounded, Excluded(threshold))))
}
pub fn less_equal(&self, threshold: CanonicalKey) -> Option<Vec<EntityId>> {
if !self.range_enabled(threshold.family()) {
return None;
}
Some(self.collect_range((Unbounded, Included(threshold))))
}
pub fn len(&self) -> usize {
self.entries.values().map(|v| v.len()).sum()
}
pub fn memory_bytes(&self) -> usize {
std::mem::size_of::<Self>() + self.entry_memory_bytes
}
pub fn range_limited(
&self,
low: CanonicalKey,
high: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
if !self.range_enabled(low.family()) || low.family() != high.family() {
return None;
}
if low > high {
return Some(Vec::new());
}
Some(self.collect_range_limited(low..=high, limit))
}
pub fn range_limited_same_family(
&self,
low: CanonicalKey,
high: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
if low.family() != high.family() {
return None;
}
if low > high {
return Some(Vec::new());
}
if !self.families.contains(&low.family()) {
return Some(Vec::new());
}
Some(self.collect_range_limited(low..=high, limit))
}
pub fn greater_than_limited(
&self,
threshold: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
if !self.range_enabled(threshold.family()) {
return None;
}
Some(self.collect_range_limited((Excluded(threshold), Unbounded), limit))
}
pub fn greater_equal_limited(
&self,
threshold: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
if !self.range_enabled(threshold.family()) {
return None;
}
Some(self.collect_range_limited((Included(threshold), Unbounded), limit))
}
pub fn less_than_limited(
&self,
threshold: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
if !self.range_enabled(threshold.family()) {
return None;
}
Some(self.collect_range_limited((Unbounded, Excluded(threshold)), limit))
}
pub fn less_equal_limited(
&self,
threshold: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
if !self.range_enabled(threshold.family()) {
return None;
}
Some(self.collect_range_limited((Unbounded, Included(threshold)), limit))
}
pub fn in_lookup_limited(
&self,
values: &[CanonicalKey],
limit: usize,
) -> Option<Vec<EntityId>> {
let mut sorted_vals = values.to_vec();
sorted_vals.sort_unstable();
sorted_vals.dedup();
let mut result = Vec::with_capacity(limit.min(sorted_vals.len() * 4));
'outer: for key in &sorted_vals {
if let Some(ids) = self.entries.get(key) {
for &id in ids {
result.push(id);
if result.len() >= limit {
break 'outer;
}
}
}
}
Some(result)
}
pub fn in_lookup_limited_filtered_by_set(
&self,
values: &[CanonicalKey],
filter_set: &std::collections::HashSet<u64>,
limit: usize,
) -> Option<Vec<EntityId>> {
let mut sorted_vals = values.to_vec();
sorted_vals.sort_unstable();
sorted_vals.dedup();
let mut result = Vec::with_capacity(limit.min(sorted_vals.len() * 4));
'outer: for key in &sorted_vals {
if let Some(ids) = self.entries.get(key) {
for &id in ids {
if filter_set.contains(&id.raw()) {
result.push(id);
if result.len() >= limit {
break 'outer;
}
}
}
}
}
Some(result)
}
pub fn range_lookup_values<R>(&self, range: R, limit: usize) -> Vec<CanonicalKey>
where
R: std::ops::RangeBounds<CanonicalKey>,
{
self.entries
.range(range)
.take(limit)
.map(|(key, _)| key.clone())
.collect()
}
pub fn in_lookup_values(&self, values: &[CanonicalKey], limit: usize) -> Vec<CanonicalKey> {
let mut sorted_vals = values.to_vec();
sorted_vals.sort_unstable();
sorted_vals.dedup();
let mut result = Vec::with_capacity(sorted_vals.len().min(limit));
for key in sorted_vals {
if self.entries.contains_key(&key) {
result.push(key);
if result.len() >= limit {
break;
}
}
}
result
}
fn collect_range<R>(&self, range: R) -> Vec<EntityId>
where
R: std::ops::RangeBounds<CanonicalKey>,
{
let mut result = Vec::new();
for ids in self.entries.range(range).map(|(_, ids)| ids) {
result.extend_from_slice(ids);
}
result
}
fn collect_range_limited<R>(&self, range: R, limit: usize) -> Vec<EntityId>
where
R: std::ops::RangeBounds<CanonicalKey>,
{
let mut result = Vec::with_capacity(limit.min(512));
'outer: for ids in self.entries.range(range).map(|(_, ids)| ids) {
for &id in ids {
result.push(id);
if result.len() >= limit {
break 'outer;
}
}
}
result
}
pub fn collect_range_filtered_by_set<R>(
&self,
range: R,
filter_set: &std::collections::HashSet<u64>,
limit: usize,
) -> Option<Vec<EntityId>>
where
R: std::ops::RangeBounds<CanonicalKey>,
{
if self.has_mixed_families {
return None;
}
let mut result = Vec::new();
'outer: for ids in self.entries.range(range).map(|(_, ids)| ids) {
for &id in ids {
if filter_set.contains(&id.raw()) {
result.push(id);
if result.len() >= limit {
break 'outer;
}
}
}
}
Some(result)
}
}
pub struct SortedCompositeIndex {
entries: BTreeMap<Vec<CanonicalKey>, Vec<EntityId>>,
entry_memory_bytes: usize,
}
impl SortedCompositeIndex {
pub fn new() -> Self {
Self {
entries: BTreeMap::new(),
entry_memory_bytes: 0,
}
}
pub fn insert(&mut self, key: Vec<CanonicalKey>, entity_id: EntityId) {
let entry = self.entries.entry(key);
if let std::collections::btree_map::Entry::Vacant(ref entry) = entry {
self.entry_memory_bytes += btree_entry_bytes(entry.key().iter(), 0);
}
self.entry_memory_bytes += std::mem::size_of::<EntityId>();
entry.or_default().push(entity_id);
}
pub fn len(&self) -> usize {
self.entries.values().map(|v| v.len()).sum()
}
fn insert_fields(
&mut self,
columns: &[String],
entity_id: EntityId,
fields: &[(String, Value)],
) {
let mut tuple = Vec::with_capacity(columns.len());
for column in columns {
let Some((_, value)) = fields.iter().find(|(name, _)| name == column) else {
return;
};
let CanonicalizedValue::Exact(key) = classify_sorted_value(value) else {
return;
};
tuple.push(key);
}
self.insert(tuple, entity_id);
}
pub fn memory_bytes(&self) -> usize {
std::mem::size_of::<Self>() + self.entry_memory_bytes
}
pub fn prefix_range(
&self,
prefix: &[CanonicalKey],
low: CanonicalKey,
high: CanonicalKey,
limit: usize,
) -> Vec<EntityId> {
if limit == 0 {
return Vec::new();
}
let mut low_key = prefix.to_vec();
low_key.push(low);
let mut high_key = prefix.to_vec();
high_key.push(high);
let mut out = Vec::with_capacity(limit.min(128));
for (_, ids) in self.entries.range(low_key..=high_key) {
for id in ids {
out.push(*id);
if out.len() >= limit {
return out;
}
}
}
out
}
pub fn prefix_eq(&self, prefix: &[CanonicalKey], limit: usize) -> Vec<EntityId> {
if limit == 0 || prefix.is_empty() {
return Vec::new();
}
let low = prefix.to_vec();
let mut out = Vec::with_capacity(limit.min(128));
for (k, ids) in self.entries.range(low.clone()..) {
if k.len() < prefix.len() || &k[..prefix.len()] != prefix {
break;
}
for id in ids {
out.push(*id);
if out.len() >= limit {
return out;
}
}
}
out
}
}
pub struct SortedIndexManager {
indices: RwLock<HashMap<(String, String), SortedColumnIndex>>,
composite: RwLock<HashMap<(String, Vec<String>), SortedCompositeIndex>>,
}
impl SortedIndexManager {
pub fn new() -> Self {
Self {
indices: RwLock::new(HashMap::new()),
composite: RwLock::new(HashMap::new()),
}
}
pub fn memory_bytes(&self) -> u64 {
let single: usize = read_unpoisoned(&self.indices)
.values()
.map(SortedColumnIndex::memory_bytes)
.sum();
let composite: usize = read_unpoisoned(&self.composite)
.values()
.map(SortedCompositeIndex::memory_bytes)
.sum();
(single + composite) as u64
}
pub fn build_composite(
&self,
collection: &str,
columns: &[String],
entities: &[(EntityId, Vec<(String, Value)>)],
) -> usize {
let mut index = SortedCompositeIndex::new();
let mut count = 0;
'entity: for (eid, fields) in entities {
let mut tuple: Vec<CanonicalKey> = Vec::with_capacity(columns.len());
for col in columns {
let found = fields.iter().find(|(name, _)| name == col);
let key = match found {
Some((_, val)) => match classify_sorted_value(val) {
CanonicalizedValue::Exact(k) => k,
CanonicalizedValue::Unsupported => continue 'entity,
},
None => continue 'entity,
};
tuple.push(key);
}
index.insert(tuple, *eid);
count += 1;
}
write_unpoisoned(&self.composite).insert((collection.to_string(), columns.to_vec()), index);
count
}
pub fn has_composite_index(&self, collection: &str, columns: &[String]) -> bool {
let key = (collection.to_string(), columns.to_vec());
read_unpoisoned(&self.composite).contains_key(&key)
}
pub fn composite_prefix_range_lookup(
&self,
collection: &str,
columns: &[String],
prefix: &[CanonicalKey],
low: CanonicalKey,
high: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
let guard = read_unpoisoned(&self.composite);
let idx = guard.get(&(collection.to_string(), columns.to_vec()))?;
Some(idx.prefix_range(prefix, low, high, limit))
}
pub fn composite_prefix_eq_lookup(
&self,
collection: &str,
columns: &[String],
prefix: &[CanonicalKey],
limit: usize,
) -> Option<Vec<EntityId>> {
let guard = read_unpoisoned(&self.composite);
let idx = guard.get(&(collection.to_string(), columns.to_vec()))?;
Some(idx.prefix_eq(prefix, limit))
}
pub fn composite_entity_update(
&self,
collection: &str,
columns: &[String],
entity_id: EntityId,
old_fields: &[(String, Value)],
new_fields: &[(String, Value)],
) {
let build_tuple = |fields: &[(String, Value)]| -> Option<Vec<CanonicalKey>> {
let mut tuple = Vec::with_capacity(columns.len());
for col in columns {
let val = fields
.iter()
.find(|(name, _)| name == col)
.map(|(_, v)| v)?;
match classify_sorted_value(val) {
CanonicalizedValue::Exact(k) => tuple.push(k),
CanonicalizedValue::Unsupported => return None,
}
}
Some(tuple)
};
let old_tuple = build_tuple(old_fields);
let new_tuple = build_tuple(new_fields);
if old_tuple == new_tuple {
return;
}
let mut guard = write_unpoisoned(&self.composite);
let idx = match guard.get_mut(&(collection.to_string(), columns.to_vec())) {
Some(i) => i,
None => return,
};
if let Some(old) = old_tuple {
if let Some(ids) = idx.entries.get_mut(&old) {
let before = ids.len();
ids.retain(|id| *id != entity_id);
idx.entry_memory_bytes -= (before - ids.len()) * std::mem::size_of::<EntityId>();
if ids.is_empty() {
idx.entry_memory_bytes -= btree_entry_bytes(old.iter(), 0);
idx.entries.remove(&old);
}
}
}
if let Some(new) = new_tuple {
idx.insert(new, entity_id);
}
}
pub fn composite_entity_insert(
&self,
collection: &str,
entity_id: EntityId,
fields: &[(String, Value)],
) {
let mut guard = write_unpoisoned(&self.composite);
for ((coll, cols), idx) in guard.iter_mut() {
if coll != collection {
continue;
}
idx.insert_fields(cols, entity_id, fields);
}
}
fn composite_insert_one(
&self,
collection: &str,
columns: &[String],
entity_id: EntityId,
fields: &[(String, Value)],
) {
let mut guard = self.composite.write();
if let Some((_, index)) = guard
.iter_mut()
.find(|((name, indexed_columns), _)| name == collection && indexed_columns == columns)
{
index.insert_fields(columns, entity_id, fields);
}
}
pub fn composite_insert_batch(
&self,
collection: &str,
columns: &[String],
rows: &[(EntityId, Vec<(String, Value)>)],
) {
let mut guard = write_unpoisoned(&self.composite);
let Some(idx) = guard.get_mut(&(collection.to_string(), columns.to_vec())) else {
return;
};
for (entity_id, fields) in rows {
idx.insert_fields(columns, *entity_id, fields);
}
}
pub fn build_index(
&self,
collection: &str,
column: &str,
entities: &[(EntityId, Vec<(String, Value)>)],
) -> usize {
let mut index = SortedColumnIndex::new();
let mut count = 0;
for (eid, fields) in entities {
for (col, val) in fields {
if col == column {
match classify_sorted_value(val) {
CanonicalizedValue::Exact(key) => {
index.insert(key, *eid);
count += 1;
}
CanonicalizedValue::Unsupported => {}
}
}
}
}
write_unpoisoned(&self.indices).insert((collection.to_string(), column.to_string()), index);
count
}
pub fn entry_count(&self, collection: &str, columns: &[String]) -> Option<usize> {
if columns.len() > 1 {
let guard = read_unpoisoned(&self.composite);
return guard
.get(&(collection.to_string(), columns.to_vec()))
.map(SortedCompositeIndex::len);
}
let column = columns.first()?;
let guard = read_unpoisoned(&self.indices);
guard
.get(&(collection.to_string(), column.clone()))
.map(SortedColumnIndex::len)
}
pub(crate) fn range_lookup(
&self,
collection: &str,
column: &str,
low: CanonicalKey,
high: CanonicalKey,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
match indices.get(&key) {
Some(index) => index.range(low, high),
None => None,
}
}
pub(crate) fn gt_lookup(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
match indices.get(&key) {
Some(index) => index.greater_than(threshold),
None => None,
}
}
pub(crate) fn ge_lookup(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
match indices.get(&key) {
Some(index) => index.greater_equal(threshold),
None => None,
}
}
pub(crate) fn lt_lookup(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
match indices.get(&key) {
Some(index) => index.less_than(threshold),
None => None,
}
}
pub(crate) fn le_lookup(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
match indices.get(&key) {
Some(index) => index.less_equal(threshold),
None => None,
}
}
pub(crate) fn range_lookup_limited(
&self,
collection: &str,
column: &str,
low: CanonicalKey,
high: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
indices.get(&key)?.range_limited(low, high, limit)
}
pub(crate) fn range_lookup_limited_same_family(
&self,
collection: &str,
column: &str,
low: CanonicalKey,
high: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
indices
.get(&key)?
.range_limited_same_family(low, high, limit)
}
pub(crate) fn gt_lookup_limited(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
indices.get(&key)?.greater_than_limited(threshold, limit)
}
pub(crate) fn ge_lookup_limited(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
indices.get(&key)?.greater_equal_limited(threshold, limit)
}
pub(crate) fn lt_lookup_limited(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
indices.get(&key)?.less_than_limited(threshold, limit)
}
pub(crate) fn le_lookup_limited(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
indices.get(&key)?.less_equal_limited(threshold, limit)
}
pub(crate) fn range_filtered_by_set(
&self,
collection: &str,
column: &str,
low: CanonicalKey,
high: CanonicalKey,
filter_set: &std::collections::HashSet<u64>,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
if low > high {
return Some(Vec::new());
}
let idx = indices.get(&key)?;
if !idx.supports_range_key(&low) || low.family() != high.family() {
return None;
}
idx.collect_range_filtered_by_set(low..=high, filter_set, limit)
}
pub(crate) fn gt_filtered_by_set(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
filter_set: &std::collections::HashSet<u64>,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
let idx = indices.get(&key)?;
if !idx.supports_range_key(&threshold) {
return None;
}
idx.collect_range_filtered_by_set((Excluded(threshold), Unbounded), filter_set, limit)
}
pub(crate) fn ge_filtered_by_set(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
filter_set: &std::collections::HashSet<u64>,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
let idx = indices.get(&key)?;
if !idx.supports_range_key(&threshold) {
return None;
}
idx.collect_range_filtered_by_set((Included(threshold), Unbounded), filter_set, limit)
}
pub(crate) fn lt_filtered_by_set(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
filter_set: &std::collections::HashSet<u64>,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
let idx = indices.get(&key)?;
if !idx.supports_range_key(&threshold) {
return None;
}
idx.collect_range_filtered_by_set((Unbounded, Excluded(threshold)), filter_set, limit)
}
pub(crate) fn le_filtered_by_set(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
filter_set: &std::collections::HashSet<u64>,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
let idx = indices.get(&key)?;
if !idx.supports_range_key(&threshold) {
return None;
}
idx.collect_range_filtered_by_set((Unbounded, Included(threshold)), filter_set, limit)
}
pub(crate) fn in_lookup_limited(
&self,
collection: &str,
column: &str,
values: &[CanonicalKey],
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
indices.get(&key)?.in_lookup_limited(values, limit)
}
pub(crate) fn in_lookup_limited_filtered_by_set(
&self,
collection: &str,
column: &str,
values: &[CanonicalKey],
filter_set: &std::collections::HashSet<u64>,
limit: usize,
) -> Option<Vec<EntityId>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
indices
.get(&key)?
.in_lookup_limited_filtered_by_set(values, filter_set, limit)
}
pub(crate) fn range_lookup_values(
&self,
collection: &str,
column: &str,
low: CanonicalKey,
high: CanonicalKey,
limit: usize,
) -> Option<Vec<CanonicalKey>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
let idx = indices.get(&key)?;
if !idx.supports_range_key(&low) || low.family() != high.family() {
return None;
}
Some(idx.range_lookup_values((Included(low), Included(high)), limit))
}
pub(crate) fn compare_lookup_values(
&self,
collection: &str,
column: &str,
threshold: CanonicalKey,
op: &reddb_rql::ast::CompareOp,
limit: usize,
) -> Option<Vec<CanonicalKey>> {
use reddb_rql::ast::CompareOp;
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
let idx = indices.get(&key)?;
if !idx.supports_range_key(&threshold) {
return None;
}
let values = match op {
CompareOp::Gt => idx.range_lookup_values((Excluded(threshold), Unbounded), limit),
CompareOp::Ge => idx.range_lookup_values((Included(threshold), Unbounded), limit),
CompareOp::Lt => idx.range_lookup_values((Unbounded, Excluded(threshold)), limit),
CompareOp::Le => idx.range_lookup_values((Unbounded, Included(threshold)), limit),
_ => return None,
};
Some(values)
}
pub(crate) fn in_lookup_values(
&self,
collection: &str,
column: &str,
values: &[CanonicalKey],
limit: usize,
) -> Option<Vec<CanonicalKey>> {
let indices = read_unpoisoned(&self.indices);
let key = (collection.to_string(), column.to_string());
Some(indices.get(&key)?.in_lookup_values(values, limit))
}
pub fn has_index(&self, collection: &str, column: &str) -> bool {
let indices = read_unpoisoned(&self.indices);
indices.contains_key(&(collection.to_string(), column.to_string()))
}
pub fn supports_mixed_integral_ranges(&self, collection: &str, column: &str) -> bool {
let indices = read_unpoisoned(&self.indices);
indices
.get(&(collection.to_string(), column.to_string()))
.is_some_and(SortedColumnIndex::supports_mixed_integral_ranges)
}
pub(crate) fn insert_one(
&self,
collection: &str,
column: &str,
value: &Value,
entity_id: EntityId,
) {
let mut indices = write_unpoisoned(&self.indices);
let k = (collection.to_string(), column.to_string());
if let Some(index) = indices.get_mut(&k) {
match classify_sorted_value(value) {
CanonicalizedValue::Exact(key) => index.insert(key, entity_id),
CanonicalizedValue::Unsupported => {}
}
}
}
pub(crate) fn delete_one(
&self,
collection: &str,
column: &str,
value: &Value,
entity_id: EntityId,
) {
let mut indices = write_unpoisoned(&self.indices);
let k = (collection.to_string(), column.to_string());
if let Some(index) = indices.get_mut(&k) {
let Some(key) = value_to_sorted_key(value) else {
return;
};
if let Some(bucket) = index.entries.get_mut(&key) {
let before = bucket.len();
bucket.retain(|id| *id != entity_id);
index.entry_memory_bytes -=
(before - bucket.len()) * std::mem::size_of::<EntityId>();
if bucket.is_empty() {
index.entry_memory_bytes -= btree_entry_bytes(std::iter::once(&key), 0);
index.entries.remove(&key);
}
}
}
}
}
fn classify_sorted_value(val: &Value) -> CanonicalizedValue {
match value_to_canonical_key(val) {
Some(key) => CanonicalizedValue::Exact(key),
None => CanonicalizedValue::Unsupported,
}
}
pub(crate) fn value_to_sorted_key(val: &Value) -> Option<CanonicalKey> {
match classify_sorted_value(val) {
CanonicalizedValue::Exact(key) => Some(key),
CanonicalizedValue::Unsupported => None,
}
}
pub(crate) fn sorted_key_to_value(key: CanonicalKey) -> Value {
key.into_value()
}
#[derive(Debug, Clone)]
pub struct RegisteredIndex {
pub name: String,
pub collection: String,
pub columns: Vec<String>,
pub method: IndexMethodKind,
pub unique: bool,
}
impl RegisteredIndex {
pub fn hash_lookup_name(&self) -> std::borrow::Cow<'_, str> {
match self.method {
IndexMethodKind::Hash => std::borrow::Cow::Borrowed(self.name.as_str()),
IndexMethodKind::BTree => std::borrow::Cow::Owned(format!("{}_hash", self.name)),
IndexMethodKind::Bitmap | IndexMethodKind::H3 { .. } => {
std::borrow::Cow::Borrowed(self.name.as_str())
}
}
}
fn composite_key_pocket(&self) -> Option<std::borrow::Cow<'_, str>> {
let has_pocket = match self.method {
IndexMethodKind::Hash => true,
IndexMethodKind::BTree => self.unique,
IndexMethodKind::Bitmap | IndexMethodKind::H3 { .. } => false,
};
(has_pocket && self.columns.len() > 1).then(|| self.hash_lookup_name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexMethodKind {
Hash,
Bitmap,
BTree,
H3 {
resolution: u8,
},
}
impl IndexMethodKind {
pub fn default_spatial() -> Self {
IndexMethodKind::H3 {
resolution: DEFAULT_H3_RESOLUTION,
}
}
}
pub(crate) const DEFAULT_H3_RESOLUTION: u8 = 9;
#[cfg(test)]
type MutationTestHook = std::sync::Arc<dyn Fn(&str, MutationTestPhase) + Send + Sync>;
#[cfg(test)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum MutationTestPhase {
StoragePublished,
BeforeEvents,
}
pub struct IndexStore {
pub hash: HashIndexManager,
pub bitmap: BitmapIndexManager,
pub sorted: SortedIndexManager,
registry: RwLock<HashMap<(String, String), RegisteredIndex>>,
topology: RwLock<HashMap<String, std::sync::Arc<RwLock<()>>>>,
#[cfg(test)]
pub(super) before_build: parking_lot::Mutex<Option<std::sync::Arc<dyn Fn() + Send + Sync>>>,
#[cfg(test)]
pub(super) mutation_hook: parking_lot::Mutex<Option<MutationTestHook>>,
}
impl IndexStore {
pub fn new() -> Self {
Self {
hash: HashIndexManager::new(),
bitmap: BitmapIndexManager::new(),
sorted: SortedIndexManager::new(),
registry: RwLock::new(HashMap::new()),
topology: RwLock::new(HashMap::new()),
#[cfg(test)]
before_build: parking_lot::Mutex::new(None),
#[cfg(test)]
mutation_hook: parking_lot::Mutex::new(None),
}
}
#[cfg(test)]
pub(crate) fn mutation_test_hook(&self, collection: &str, phase: MutationTestPhase) {
let hook = self.mutation_hook.lock().clone();
if let Some(hook) = hook {
hook(collection, phase);
}
}
pub(crate) fn collection_topology_lock(&self, collection: &str) -> std::sync::Arc<RwLock<()>> {
if let Some(lock) = self.topology.read().get(collection) {
return std::sync::Arc::clone(lock);
}
std::sync::Arc::clone(
self.topology
.write()
.entry(collection.to_string())
.or_insert_with(|| std::sync::Arc::new(RwLock::new(()))),
)
}
pub(crate) fn needs_auto_id_index(
&self,
collection: &str,
fields: &[(String, Value)],
enabled: bool,
) -> bool {
enabled
&& fields.iter().any(|(name, _)| name == "id")
&& !self.registry.read().values().any(|index| {
index.collection == collection
&& index.columns.len() == 1
&& index.columns[0] == "id"
})
}
pub fn memory_bytes(&self) -> u64 {
self.hash
.memory_bytes()
.saturating_add(self.bitmap.memory_bytes())
.saturating_add(self.sorted.memory_bytes())
}
pub(crate) fn estimate_insert_growth<'a>(
&self,
collection: &str,
rows: impl Iterator<Item = &'a [(String, Value)]> + Clone,
auto_index_id: bool,
) -> u64 {
let registry = self.registry.read();
let indexes = registry
.values()
.filter(|index| index.collection == collection);
let needs_auto_id = auto_index_id
&& rows
.clone()
.next()
.is_some_and(|fields| fields.iter().any(|(name, _)| name == "id"))
&& !indexes
.clone()
.any(|index| index.columns.len() == 1 && index.columns[0] == "id");
let mut growth = 0u64;
if needs_auto_id {
growth = std::mem::size_of::<crate::storage::unified::hash_index::HashIndex>() as u64;
for fields in rows.clone() {
if let Some(value) = index_field_value(fields, "id") {
growth = growth.saturating_add(estimate_index_entry_bytes(value.as_ref()));
}
}
}
for index in indexes {
for fields in rows.clone() {
growth = growth.saturating_add(estimate_registered_index_growth(index, fields));
}
}
growth
}
pub(crate) fn estimate_update_growth(
&self,
collection: &str,
before: &[(String, Value)],
entity: &crate::storage::UnifiedEntity,
versioned: bool,
) -> u64 {
let registry = self.registry.read();
let mut indexes = registry
.values()
.filter(|index| index.collection == collection)
.peekable();
if indexes.peek().is_none() {
return 0;
}
let crate::storage::EntityData::Row(row) = &entity.data else {
return 0;
};
let after: Vec<_> = row
.iter_fields()
.map(|(name, value)| (name.to_string(), value.clone()))
.collect();
indexes
.filter(|index| {
versioned
|| index.columns.iter().any(|column| {
index_field_value(before, column) != index_field_value(&after, column)
})
})
.map(|index| estimate_registered_index_growth(index, &after))
.fold(0, u64::saturating_add)
}
pub fn create_index(
&self,
name: &str,
collection: &str,
columns: &[String],
method: IndexMethodKind,
unique: bool,
entities: &[(EntityId, Vec<(String, Value)>)],
) -> Result<usize, String> {
#[cfg(test)]
if let Some(hook) = self.before_build.lock().clone() {
hook();
}
let col = columns.first().map(|s| s.as_str()).unwrap_or("");
if columns.len() > 1 && columns.iter().any(|column| column.contains('.')) {
return Err(
"document path indexes currently support a single indexed column".to_string(),
);
}
match method {
IndexMethodKind::Hash => {
self.hash
.create_index(&HashIndexConfig {
name: name.to_string(),
collection: collection.to_string(),
columns: columns.to_vec(),
unique,
})
.map_err(|e| e.to_string())?;
let mut count = 0;
for (entity_id, fields) in entities {
let key = if unique {
unique_index_key(columns, fields)
} else {
hash_index_key(columns, fields)
};
if let Some(key) = key {
self.hash
.insert(collection, name, key, *entity_id)
.map_err(|err| err.to_string())?;
count += 1;
}
}
Ok(count)
}
IndexMethodKind::Bitmap => {
self.bitmap.create_index(collection, col);
let mut count = 0;
for (entity_id, fields) in entities {
if let Some(value) = index_field_value(fields, col) {
let key = value_to_bytes(value.as_ref());
self.bitmap
.insert(collection, col, *entity_id, &key)
.map_err(|err| err.to_string())?;
count += 1;
}
}
Ok(count)
}
IndexMethodKind::H3 { resolution } => {
let cell_entities = derive_h3_cell_entities(entities, col, resolution);
let count = self.sorted.build_index(collection, col, &cell_entities);
Ok(count)
}
IndexMethodKind::BTree => {
if columns.len() > 1 {
let count = self.sorted.build_composite(collection, columns, entities);
if unique {
let pocket = format!("{name}_hash");
self.hash
.create_index(&HashIndexConfig {
name: pocket.clone(),
collection: collection.to_string(),
columns: columns.to_vec(),
unique: false,
})
.map_err(|err| err.to_string())?;
for (entity_id, fields) in entities {
if let Some(key) = hash_index_key(columns, fields) {
self.hash
.insert(collection, &pocket, key, *entity_id)
.map_err(|err| err.to_string())?;
}
}
}
return Ok(count);
}
let derived_entities = derive_index_entities_for_column(entities, col);
let count = self.sorted.build_index(collection, col, &derived_entities);
self.hash
.create_index(&HashIndexConfig {
name: format!("{name}_hash"),
collection: collection.to_string(),
columns: columns.to_vec(),
unique: false,
})
.map_err(|err| err.to_string())?;
for (entity_id, fields) in entities {
if let Some(value) = index_field_value(fields, col) {
let key = value_to_bytes(value.as_ref());
self.hash
.insert(collection, &format!("{name}_hash"), key, *entity_id)
.map_err(|err| err.to_string())?;
}
}
Ok(count)
}
}
}
pub fn drop_index(&self, name: &str, collection: &str) -> bool {
let mut registry = write_unpoisoned(&self.registry);
let key = (collection.to_string(), name.to_string());
if let Some(info) = registry.remove(&key) {
match info.method {
IndexMethodKind::Hash => self.hash.drop_index(collection, name),
IndexMethodKind::Bitmap => {
let col = info.columns.first().map(|s| s.as_str()).unwrap_or("");
self.bitmap.drop_index(collection, col)
}
IndexMethodKind::BTree => self.hash.drop_index(collection, &format!("{name}_hash")),
IndexMethodKind::H3 { .. } => false,
};
true
} else {
false
}
}
pub fn register(&self, info: RegisteredIndex) {
let mut registry = write_unpoisoned(&self.registry);
registry.insert((info.collection.clone(), info.name.clone()), info);
}
pub fn hash_lookup(
&self,
collection: &str,
index_name: &str,
key: &[u8],
) -> Result<Vec<EntityId>, String> {
self.hash
.lookup(collection, index_name, key)
.map_err(|err| err.to_string())
}
pub fn bitmap_lookup(
&self,
collection: &str,
column: &str,
value: &[u8],
) -> Result<Vec<EntityId>, String> {
self.bitmap
.lookup(collection, column, value)
.map_err(|err| err.to_string())
}
pub fn bitmap_count(
&self,
collection: &str,
column: &str,
value: &[u8],
) -> Result<u64, String> {
self.bitmap
.count(collection, column, value)
.map_err(|err| err.to_string())
}
pub fn find_index_for_column(&self, collection: &str, column: &str) -> Option<RegisteredIndex> {
let registry = read_unpoisoned(&self.registry);
registry
.values()
.find(|idx| {
idx.collection == collection
&& idx.columns.len() == 1
&& idx.columns.first().is_some_and(|c| c == column)
})
.cloned()
}
pub fn list_indices(&self, collection: &str) -> Vec<RegisteredIndex> {
let registry = read_unpoisoned(&self.registry);
registry
.values()
.filter(|idx| idx.collection == collection)
.cloned()
.collect()
}
pub(crate) fn unique_indexes(&self, collection: &str) -> Vec<RegisteredIndex> {
read_unpoisoned(&self.registry)
.values()
.filter(|index| is_enforced_unique(collection, index))
.cloned()
.collect()
}
pub(crate) fn has_unique_index(&self, collection: &str) -> bool {
read_unpoisoned(&self.registry)
.values()
.any(|index| is_enforced_unique(collection, index))
}
pub(crate) fn touches_unique_index(
&self,
collection: &str,
modified_columns: &[String],
) -> bool {
read_unpoisoned(&self.registry).values().any(|index| {
is_enforced_unique(collection, index)
&& index.columns.iter().any(|column| {
modified_columns
.iter()
.any(|modified| modified.eq_ignore_ascii_case(column))
})
})
}
pub fn entries_indexed(&self, index: &RegisteredIndex) -> u64 {
match index.method {
IndexMethodKind::Hash => self
.hash
.index_stats(&index.collection, &index.name)
.map(|stats| stats.total_entries as u64)
.unwrap_or(0),
IndexMethodKind::BTree | IndexMethodKind::H3 { .. } => {
self.sorted
.entry_count(&index.collection, &index.columns)
.unwrap_or(0) as u64
}
IndexMethodKind::Bitmap => index
.columns
.first()
.and_then(|column| self.bitmap.index_stats(&index.collection, column).ok())
.map(|stats| stats.entity_count as u64)
.unwrap_or(0),
}
}
pub fn indexed_columns_set(&self, collection: &str) -> std::collections::HashSet<String> {
let registry = read_unpoisoned(&self.registry);
let mut out = std::collections::HashSet::new();
for idx in registry.values() {
if idx.collection == collection {
for col in &idx.columns {
out.insert(col.clone());
}
}
}
out
}
pub fn indexed_columns_set_with_parents(
&self,
collection: &str,
) -> std::collections::HashSet<String> {
let registry = read_unpoisoned(&self.registry);
let mut out = std::collections::HashSet::new();
for idx in registry.values() {
if idx.collection == collection {
for col in &idx.columns {
out.insert(col.clone());
if col.contains('.') {
let mut prefix = String::new();
for segment in col.split('.') {
if !prefix.is_empty() {
out.insert(prefix.clone());
prefix.push('.');
}
prefix.push_str(segment);
}
}
}
}
}
out
}
pub fn index_entity_insert_batch(
&self,
collection: &str,
rows: &[(EntityId, Vec<(String, Value)>)],
) -> Result<(), String> {
if rows.is_empty() {
return Ok(());
}
let registry = self.registry.read();
let relevant: Vec<&RegisteredIndex> = registry
.values()
.filter(|idx| idx.collection == collection)
.collect();
if relevant.is_empty() {
return Ok(());
}
for idx in &relevant {
let composite_btree =
matches!(idx.method, IndexMethodKind::BTree) && idx.columns.len() > 1;
if composite_btree {
self.sorted
.composite_insert_batch(collection, &idx.columns, rows);
}
if let Some(pocket) = idx.composite_key_pocket() {
for (entity_id, fields) in rows {
if let Some(key) = hash_index_key(&idx.columns, fields) {
self.hash
.insert(collection, &pocket, key, *entity_id)
.map_err(|err| err.to_string())?;
}
}
continue;
}
if composite_btree {
continue;
}
let col = idx.columns.first().map(|s| s.as_str()).unwrap_or("");
let btree_hash_name =
matches!(idx.method, IndexMethodKind::BTree).then(|| format!("{}_hash", idx.name));
for (entity_id, fields) in rows {
if let Some(value) = index_field_value(fields, col) {
let key = value_to_bytes(value.as_ref());
match idx.method {
IndexMethodKind::Hash => {
if idx.unique && matches!(value.as_ref(), Value::Null) {
continue;
}
self.hash
.insert(collection, &idx.name, key, *entity_id)
.map_err(|err| err.to_string())?;
}
IndexMethodKind::Bitmap => {
self.bitmap
.insert(collection, col, *entity_id, &key)
.map_err(|err| err.to_string())?;
}
IndexMethodKind::BTree => {
if !self.sorted.has_index(collection, col) {
return Err(format!(
"sorted index for collection '{collection}' column '{col}' was not found"
));
}
self.sorted
.insert_one(collection, col, value.as_ref(), *entity_id);
let hash_name = btree_hash_name.as_deref().unwrap_or("");
self.hash
.insert(collection, hash_name, key, *entity_id)
.map_err(|err| err.to_string())?;
}
IndexMethodKind::H3 { resolution } => {
if let Some(cell) = h3_cell_value(value.as_ref(), resolution) {
self.sorted.insert_one(collection, col, &cell, *entity_id);
}
}
}
}
}
}
Ok(())
}
pub fn index_entity_insert(
&self,
collection: &str,
entity_id: EntityId,
fields: &[(String, Value)],
) -> Result<(), String> {
let registry = self.registry.read();
for idx in registry.values() {
if idx.collection != collection {
continue;
}
let composite_btree =
matches!(idx.method, IndexMethodKind::BTree) && idx.columns.len() > 1;
if composite_btree {
self.sorted
.composite_insert_one(collection, &idx.columns, entity_id, fields);
}
if let Some(pocket) = idx.composite_key_pocket() {
if let Some(key) = hash_index_key(&idx.columns, fields) {
self.hash
.insert(collection, &pocket, key, entity_id)
.map_err(|err| err.to_string())?;
}
continue;
}
if composite_btree {
continue;
}
let col = idx.columns.first().map(|s| s.as_str()).unwrap_or("");
if let Some(value) = index_field_value(fields, col) {
let key = value_to_bytes(value.as_ref());
match idx.method {
IndexMethodKind::Hash => {
if idx.unique && matches!(value.as_ref(), Value::Null) {
continue;
}
self.hash
.insert(collection, &idx.name, key, entity_id)
.map_err(|err| err.to_string())?;
}
IndexMethodKind::Bitmap => {
self.bitmap
.insert(collection, col, entity_id, &key)
.map_err(|err| err.to_string())?;
}
IndexMethodKind::BTree => {
if !self.sorted.has_index(collection, col) {
return Err(format!(
"sorted index for collection '{collection}' column '{col}' was not found"
));
}
self.sorted
.insert_one(collection, col, value.as_ref(), entity_id);
self.hash
.insert(collection, &format!("{}_hash", idx.name), key, entity_id)
.map_err(|err| err.to_string())?;
}
IndexMethodKind::H3 { resolution } => {
if !self.sorted.has_index(collection, col) {
return Err(format!(
"sorted index for collection '{collection}' column '{col}' was not found"
));
}
if let Some(cell) = h3_cell_value(value.as_ref(), resolution) {
self.sorted.insert_one(collection, col, &cell, entity_id);
}
}
}
}
}
Ok(())
}
pub fn index_entity_delete(
&self,
collection: &str,
entity_id: EntityId,
fields: &[(String, Value)],
) -> Result<(), String> {
let registry = self.registry.read();
for idx in registry.values() {
if idx.collection != collection {
continue;
}
let composite_btree =
matches!(idx.method, IndexMethodKind::BTree) && idx.columns.len() > 1;
if composite_btree {
self.sorted.composite_entity_update(
collection,
&idx.columns,
entity_id,
fields,
&[],
);
}
if let Some(pocket) = idx.composite_key_pocket() {
if let Some(key) = hash_index_key(&idx.columns, fields) {
let _ = self.hash.remove(collection, &pocket, &key, entity_id);
}
continue;
}
if composite_btree {
continue;
}
let col = idx.columns.first().map(|s| s.as_str()).unwrap_or("");
if let Some(value) = index_field_value(fields, col) {
let key = value_to_bytes(value.as_ref());
match idx.method {
IndexMethodKind::Hash => {
let _ = self.hash.remove(collection, &idx.name, &key, entity_id);
}
IndexMethodKind::Bitmap => {
let _ = self.bitmap.remove(collection, col, entity_id);
}
IndexMethodKind::BTree => {
self.sorted
.delete_one(collection, col, value.as_ref(), entity_id);
let _ = self.hash.remove(
collection,
&format!("{}_hash", idx.name),
&key,
entity_id,
);
}
IndexMethodKind::H3 { resolution } => {
if let Some(cell) = h3_cell_value(value.as_ref(), resolution) {
self.sorted.delete_one(collection, col, &cell, entity_id);
}
}
}
}
}
Ok(())
}
pub fn index_entity_update(
&self,
collection: &str,
entity_id: EntityId,
old_fields: &[(String, Value)],
new_fields: &[(String, Value)],
) -> Result<(), String> {
let indexed_cols: std::collections::HashSet<String> = {
let registry = self.registry.read();
let mut cols = std::collections::HashSet::new();
for idx in registry.values().filter(|idx| idx.collection == collection) {
for col in &idx.columns {
cols.insert(col.clone());
if col.contains('.') {
let mut prefix = String::new();
for segment in col.split('.') {
if !prefix.is_empty() {
cols.insert(prefix.clone());
prefix.push('.');
}
prefix.push_str(segment);
}
}
}
}
cols
};
if indexed_cols.is_empty() {
return Ok(());
}
let composite_pockets: Vec<(RegisteredIndex, String)> = self
.registry
.read()
.values()
.filter(|idx| idx.collection == collection)
.filter_map(|idx| {
let pocket = idx.composite_key_pocket()?.into_owned();
Some((idx.clone(), pocket))
})
.collect();
for (idx, pocket) in &composite_pockets {
let old_key = hash_index_key(&idx.columns, old_fields);
let new_key = hash_index_key(&idx.columns, new_fields);
if old_key == new_key {
continue;
}
if let Some(key) = old_key {
let _ = self.hash.remove(collection, pocket, &key, entity_id);
}
if let Some(key) = new_key {
self.hash
.insert(collection, pocket, key, entity_id)
.map_err(|err| err.to_string())?;
}
}
let damage = crate::application::entity::row_damage_vector(old_fields, new_fields);
for (col, old_value, new_value) in &damage.changed {
if !indexed_cols.contains(col) {
continue;
}
self.index_entity_delete(collection, entity_id, &[(col.clone(), old_value.clone())])?;
self.index_entity_insert(collection, entity_id, &[(col.clone(), new_value.clone())])?;
}
for (col, old_value) in &damage.removed {
if !indexed_cols.contains(col) {
continue;
}
self.index_entity_delete(collection, entity_id, &[(col.clone(), old_value.clone())])?;
}
for (col, new_value) in &damage.added {
if !indexed_cols.contains(col) {
continue;
}
self.index_entity_insert(collection, entity_id, &[(col.clone(), new_value.clone())])?;
}
for col in &indexed_cols {
if old_fields.iter().any(|(f, _)| f == col) || new_fields.iter().any(|(f, _)| f == col)
{
continue;
}
let old_value = index_field_value(old_fields, col);
let new_value = index_field_value(new_fields, col);
match (old_value, new_value) {
(Some(old), Some(new)) => {
if old.as_ref() != new.as_ref() {
self.index_entity_delete(
collection,
entity_id,
&[(col.clone(), old.into_owned())],
)?;
self.index_entity_insert(
collection,
entity_id,
&[(col.clone(), new.into_owned())],
)?;
}
}
(Some(old), None) => {
self.index_entity_delete(
collection,
entity_id,
&[(col.clone(), old.into_owned())],
)?;
}
(None, Some(new)) => {
self.index_entity_insert(
collection,
entity_id,
&[(col.clone(), new.into_owned())],
)?;
}
(None, None) => {}
}
}
Ok(())
}
}
impl Default for IndexStore {
fn default() -> Self {
Self::new()
}
}
use crate::storage::unified::index::incremental::{
IndexMethodKind as IncMethodKind, SecondaryIndexBackend, SecondaryIndexHandle,
};
impl From<IndexMethodKind> for IncMethodKind {
fn from(value: IndexMethodKind) -> Self {
match value {
IndexMethodKind::Hash => IncMethodKind::Hash,
IndexMethodKind::Bitmap => IncMethodKind::Bitmap,
IndexMethodKind::BTree => IncMethodKind::BTree,
IndexMethodKind::H3 { .. } => IncMethodKind::H3,
}
}
}
impl From<IncMethodKind> for IndexMethodKind {
fn from(value: IncMethodKind) -> Self {
match value {
IncMethodKind::Hash => IndexMethodKind::Hash,
IncMethodKind::Bitmap => IndexMethodKind::Bitmap,
IncMethodKind::BTree => IndexMethodKind::BTree,
IncMethodKind::H3 => IndexMethodKind::H3 {
resolution: DEFAULT_H3_RESOLUTION,
},
}
}
}
impl SecondaryIndexBackend for IndexStore {
fn insert(
&self,
idx: &SecondaryIndexHandle,
key: &[u8],
row_id: EntityId,
) -> Result<(), String> {
let collection = idx.collection.as_ref();
let Some(col) = idx.leading_column() else {
return Ok(());
};
match idx.method {
IncMethodKind::Hash => self
.hash
.insert(collection, idx.name.as_ref(), key.to_vec(), row_id)
.map_err(|e| e.to_string()),
IncMethodKind::Bitmap => self
.bitmap
.insert(collection, col, row_id, key)
.map_err(|e| e.to_string()),
IncMethodKind::BTree => {
if idx.columns.len() > 1 {
return Ok(());
}
let aux = format!("{}_hash", idx.name);
self.hash
.insert(collection, &aux, key.to_vec(), row_id)
.map_err(|e| e.to_string())
}
IncMethodKind::H3 => Ok(()),
}
}
fn remove(&self, idx: &SecondaryIndexHandle, key: &[u8], row_id: EntityId) {
let collection = idx.collection.as_ref();
let Some(col) = idx.leading_column() else {
return;
};
match idx.method {
IncMethodKind::Hash => {
let _ = self.hash.remove(collection, idx.name.as_ref(), key, row_id);
}
IncMethodKind::Bitmap => {
let _ = self.bitmap.remove(collection, col, row_id);
}
IncMethodKind::BTree => {
if idx.columns.len() > 1 {
return;
}
let aux = format!("{}_hash", idx.name);
let _ = self.hash.remove(collection, &aux, key, row_id);
}
IncMethodKind::H3 => {}
}
}
}
impl IndexStore {
pub fn resolve_index_field_value(
&self,
fields: &[(String, Value)],
column: &str,
) -> Option<Value> {
index_field_value(fields, column).map(Cow::into_owned)
}
pub fn augment_body_derived_index_fields(
&self,
fields: &[(String, Value)],
indexed_cols: &std::collections::HashSet<String>,
) -> Vec<(String, Value)> {
let mut out = fields.to_vec();
for col in indexed_cols {
if out.iter().any(|(existing, _)| existing == col) {
continue;
}
if let Some(value) = self.resolve_index_field_value(fields, col) {
out.push((col.clone(), value));
}
}
out
}
}
pub(crate) fn index_field_value<'a>(
fields: &'a [(String, Value)],
column: &str,
) -> Option<Cow<'a, Value>> {
if let Some((_, Value::Json(bytes))) = fields.iter().find(|(field, _)| field == "body") {
let segments = super::join_filter::parse_runtime_document_path(column);
if let Some((root, tail)) = segments.split_first() {
let root_value = crate::document_body::read_body_field(bytes, root).or_else(|| {
crate::json::from_slice::<crate::json::Value>(bytes)
.ok()?
.as_object()?
.get(root)
.and_then(|value| crate::application::entity::json_to_storage_value(value).ok())
});
if let Some(root_value) = root_value {
if tail.is_empty() {
return Some(Cow::Owned(root_value));
}
if let Some(value) =
super::join_filter::resolve_runtime_document_path_from_value(&root_value, tail)
{
return Some(Cow::Owned(value));
}
}
}
}
if let Some((_, value)) = fields.iter().find(|(field, _)| field == column) {
return Some(Cow::Borrowed(value));
}
if !column.contains('.') {
return None;
}
let segments = super::join_filter::parse_runtime_document_path(column);
let (root, tail) = segments.split_first()?;
if tail.is_empty() {
return None;
}
let (_, root_value) = fields.iter().find(|(field, _)| field == root)?;
super::join_filter::resolve_runtime_document_path_from_value(root_value, tail).map(Cow::Owned)
}
fn derive_index_entities_for_column(
entities: &[(EntityId, Vec<(String, Value)>)],
column: &str,
) -> Vec<(EntityId, Vec<(String, Value)>)> {
entities
.iter()
.filter_map(|(entity_id, fields)| {
index_field_value(fields, column)
.map(|value| (*entity_id, vec![(column.to_string(), value.into_owned())]))
})
.collect()
}
fn h3_cell_value(value: &Value, resolution: u8) -> Option<Value> {
let (lat, lon) = crate::geo::recognize_geo_value(value)?;
let cell = crate::geo::h3::lat_lng_to_cell(lat, lon, resolution);
if cell == 0 {
return None;
}
Some(Value::UnsignedInteger(cell))
}
fn derive_h3_cell_entities(
entities: &[(EntityId, Vec<(String, Value)>)],
column: &str,
resolution: u8,
) -> Vec<(EntityId, Vec<(String, Value)>)> {
entities
.iter()
.filter_map(|(entity_id, fields)| {
let value = index_field_value(fields, column)?;
let cell = h3_cell_value(value.as_ref(), resolution)?;
Some((*entity_id, vec![(column.to_string(), cell)]))
})
.collect()
}
const INDEX_GROWTH_ENTRY_BYTES: u64 = 96;
fn estimate_index_entry_bytes(value: &Value) -> u64 {
INDEX_GROWTH_ENTRY_BYTES.saturating_add(value_to_bytes_len(value))
}
fn estimate_registered_index_growth(index: &RegisteredIndex, fields: &[(String, Value)]) -> u64 {
let canonical_bytes = std::mem::size_of::<CanonicalKey>() as u64;
if matches!(index.method, IndexMethodKind::BTree | IndexMethodKind::Hash)
&& index.columns.len() > 1
{
return index
.columns
.iter()
.try_fold(INDEX_GROWTH_ENTRY_BYTES, |growth, column| {
let (_, value) = fields.iter().find(|(name, _)| name == column)?;
Some(
growth
.saturating_add(canonical_bytes)
.saturating_add(value_to_bytes_len(value).max(16)),
)
})
.unwrap_or(0);
}
let Some(column) = index.columns.first() else {
return 0;
};
let Some(value) = index_field_value(fields, column) else {
return 0;
};
match index.method {
IndexMethodKind::H3 { .. } => INDEX_GROWTH_ENTRY_BYTES
.saturating_add(canonical_bytes)
.saturating_add(16),
IndexMethodKind::Hash | IndexMethodKind::Bitmap => {
estimate_index_entry_bytes(value.as_ref())
}
IndexMethodKind::BTree => {
let key_bytes = value_to_bytes_len(value.as_ref());
INDEX_GROWTH_ENTRY_BYTES
.saturating_add(key_bytes)
.saturating_add(INDEX_GROWTH_ENTRY_BYTES)
.saturating_add(canonical_bytes)
.saturating_add(key_bytes.max(16))
}
}
}
fn value_to_bytes_len(value: &Value) -> u64 {
match value {
Value::Text(text) => text.len() as u64,
Value::Integer(_) | Value::UnsignedInteger(_) | Value::Float(_) => 8,
Value::Boolean(_) => 1,
_ => {
struct ByteCount(u64);
impl std::fmt::Write for ByteCount {
fn write_str(&mut self, text: &str) -> std::fmt::Result {
self.0 = self.0.saturating_add(text.len() as u64);
Ok(())
}
}
let mut count = ByteCount(0);
std::fmt::Write::write_fmt(&mut count, format_args!("{value:?}"))
.expect("invariant: counting formatted bytes cannot fail");
count.0
}
}
}
pub(crate) fn hash_index_key(columns: &[String], fields: &[(String, Value)]) -> Option<Vec<u8>> {
match columns {
[] => None,
[column] => index_field_value(fields, column).map(|value| value_to_bytes(value.as_ref())),
_ => {
let mut key = Vec::new();
for column in columns {
let value = index_field_value(fields, column)?;
if matches!(value.as_ref(), Value::Null) {
return None;
}
let bytes = value_to_bytes(value.as_ref());
key.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
key.extend_from_slice(&bytes);
}
Some(key)
}
}
}
pub(crate) fn unique_index_key(columns: &[String], fields: &[(String, Value)]) -> Option<Vec<u8>> {
if let [column] = columns {
if matches!(
index_field_value(fields, column).as_deref(),
Some(Value::Null)
) {
return None;
}
}
hash_index_key(columns, fields)
}
pub(crate) fn first_duplicate_unique_key(
columns: &[String],
rows: &[(EntityId, Vec<(String, Value)>)],
) -> Option<(EntityId, EntityId)> {
let mut seen = std::collections::HashMap::with_capacity(rows.len());
for (entity_id, fields) in rows {
let Some(key) = unique_index_key(columns, fields) else {
continue;
};
if let Some(earlier) = seen.insert(key, *entity_id) {
return Some((earlier, *entity_id));
}
}
None
}
fn is_enforced_unique(collection: &str, index: &RegisteredIndex) -> bool {
index.collection == collection
&& index.unique
&& matches!(index.method, IndexMethodKind::Hash | IndexMethodKind::BTree)
}
pub(crate) fn value_to_bytes(value: &Value) -> Vec<u8> {
match value {
Value::Text(s) => s.as_bytes().to_vec(),
Value::Integer(n) => n.to_le_bytes().to_vec(),
Value::UnsignedInteger(n) => n.to_le_bytes().to_vec(),
Value::Float(n) => n.to_le_bytes().to_vec(),
Value::Boolean(b) => vec![*b as u8],
_ => format!("{:?}", value).into_bytes(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(pairs: &[(&str, Value)]) -> Vec<(String, Value)> {
pairs
.iter()
.map(|(name, value)| ((*name).to_string(), value.clone()))
.collect()
}
#[test]
fn hash_index_key_single_column_keeps_the_historical_encoding() {
let columns = vec!["id".to_string()];
assert_eq!(
hash_index_key(&columns, &row(&[("id", Value::Integer(7))])),
Some(7i64.to_le_bytes().to_vec())
);
assert_eq!(
hash_index_key(&columns, &row(&[("name", Value::text("x"))])),
None
);
assert!(hash_index_key(&columns, &row(&[("id", Value::Null)])).is_some());
}
#[test]
fn hash_index_key_composite_covers_every_column_and_cannot_collide() {
let columns = vec!["a".to_string(), "b".to_string()];
let key = |a: &str, b: &str| {
hash_index_key(
&columns,
&row(&[("a", Value::text(a)), ("b", Value::text(b))]),
)
.expect("both columns present")
};
assert_ne!(
key("x", "1"),
key("x", "2"),
"the second column is part of the key"
);
assert_ne!(
key("ab", "c"),
key("a", "bc"),
"length prefixes keep tuples apart"
);
assert_eq!(key("x", "1"), key("x", "1"));
}
#[test]
fn hash_index_key_composite_has_no_key_for_null_or_absent_columns() {
let columns = vec!["a".to_string(), "b".to_string()];
let null_b = row(&[("a", Value::text("x")), ("b", Value::Null)]);
assert_eq!(hash_index_key(&columns, &null_b), None);
assert_eq!(
hash_index_key(&columns, &row(&[("a", Value::text("x"))])),
None
);
}
#[test]
fn unique_index_key_never_reserves_a_null_column() {
let single = vec!["id".to_string()];
assert_eq!(
unique_index_key(&single, &row(&[("id", Value::Null)])),
None,
"a NULL never equals another NULL"
);
assert_eq!(
unique_index_key(&single, &row(&[("id", Value::Integer(7))])),
hash_index_key(&single, &row(&[("id", Value::Integer(7))])),
"non-NULL keys keep the physical encoding"
);
let composite = vec!["a".to_string(), "b".to_string()];
let full = row(&[("a", Value::text("x")), ("b", Value::text("1"))]);
assert_eq!(
unique_index_key(&composite, &full),
hash_index_key(&composite, &full)
);
}
#[test]
fn first_duplicate_unique_key_reports_the_colliding_pair_and_skips_nulls() {
let single = vec!["id".to_string()];
let rows = vec![
(EntityId::new(1), row(&[("id", Value::Null)])),
(EntityId::new(2), row(&[("id", Value::Null)])),
(EntityId::new(3), row(&[("id", Value::Integer(5))])),
(EntityId::new(4), row(&[("id", Value::Integer(6))])),
(EntityId::new(5), row(&[("id", Value::Integer(5))])),
];
assert_eq!(
first_duplicate_unique_key(&single, &rows),
Some((EntityId::new(3), EntityId::new(5)))
);
assert_eq!(first_duplicate_unique_key(&single, &rows[..2]), None);
let composite = vec!["a".to_string(), "b".to_string()];
let tuples = vec![
(
EntityId::new(1),
row(&[("a", Value::text("x")), ("b", Value::text("1"))]),
),
(
EntityId::new(2),
row(&[("a", Value::text("x")), ("b", Value::text("2"))]),
),
];
assert_eq!(first_duplicate_unique_key(&composite, &tuples), None);
}
#[test]
fn rebuilding_a_unique_btree_index_over_existing_duplicates_never_refuses() {
for columns in [
vec!["id".to_string()],
vec!["id".to_string(), "grp".to_string()],
] {
let store = IndexStore::new();
let rows = vec![
(
EntityId::new(1),
row(&[("id", Value::Integer(1)), ("grp", Value::text("g"))]),
),
(
EntityId::new(2),
row(&[("id", Value::Integer(1)), ("grp", Value::text("g"))]),
),
];
store
.create_index("dup", "rows", &columns, IndexMethodKind::BTree, true, &rows)
.expect("rebuild keeps the duplicates instead of refusing");
let key = hash_index_key(&columns, &rows[0].1).expect("key");
assert_eq!(
store
.hash_lookup("rows", "dup_hash", &key)
.expect("pocket")
.len(),
2
);
}
}
#[test]
fn composite_unique_btree_pocket_follows_insert_update_delete_and_drop() {
let store = IndexStore::new();
let columns = vec!["a".to_string(), "b".to_string()];
store
.create_index("ab", "rows", &columns, IndexMethodKind::BTree, true, &[])
.expect("composite unique btree index");
store.register(RegisteredIndex {
name: "ab".to_string(),
collection: "rows".to_string(),
columns: columns.clone(),
method: IndexMethodKind::BTree,
unique: true,
});
let old = row(&[("a", Value::text("x")), ("b", Value::text("1"))]);
let new = row(&[("a", Value::text("x")), ("b", Value::text("2"))]);
let old_key = hash_index_key(&columns, &old).expect("old key");
let new_key = hash_index_key(&columns, &new).expect("new key");
let id = EntityId::new(7);
let lookup = |key: &[u8]| store.hash_lookup("rows", "ab_hash", key).expect("pocket");
store.index_entity_insert("rows", id, &old).expect("insert");
assert_eq!(lookup(&old_key), vec![id]);
store
.index_entity_update("rows", id, &old, &new)
.expect("update");
assert!(lookup(&old_key).is_empty(), "the old tuple is released");
assert_eq!(lookup(&new_key), vec![id]);
store.index_entity_delete("rows", id, &new).expect("delete");
assert!(lookup(&new_key).is_empty());
assert!(store.drop_index("ab", "rows"));
assert!(
store.hash_lookup("rows", "ab_hash", &new_key).is_err(),
"dropping the index drops its pocket so the name can be reused"
);
}
#[test]
fn composite_hash_index_is_rekeyed_from_the_full_rows_on_update_and_delete() {
let store = IndexStore::new();
let columns = vec!["a".to_string(), "b".to_string()];
store
.create_index("ab", "rows", &columns, IndexMethodKind::Hash, false, &[])
.expect("composite hash index");
store.register(RegisteredIndex {
name: "ab".to_string(),
collection: "rows".to_string(),
columns: columns.clone(),
method: IndexMethodKind::Hash,
unique: false,
});
let old = row(&[("a", Value::text("x")), ("b", Value::text("1"))]);
let new = row(&[("a", Value::text("x")), ("b", Value::text("2"))]);
let key = |fields: &[(String, Value)]| hash_index_key(&columns, fields).expect("key");
let id = EntityId::new(1);
store.index_entity_insert("rows", id, &old).expect("insert");
assert_eq!(
store.hash_lookup("rows", "ab", &key(&old)).expect("lookup"),
vec![id]
);
store
.index_entity_update("rows", id, &old, &new)
.expect("update");
assert!(store
.hash_lookup("rows", "ab", &key(&old))
.expect("lookup")
.is_empty());
assert_eq!(
store.hash_lookup("rows", "ab", &key(&new)).expect("lookup"),
vec![id]
);
store.index_entity_delete("rows", id, &new).expect("delete");
assert!(store
.hash_lookup("rows", "ab", &key(&new))
.expect("lookup")
.is_empty());
}
#[test]
fn row_index_single_insert_updates_each_composite_once() {
let store = IndexStore::new();
for (name, columns) in [
("first", vec!["a".to_string(), "b".to_string()]),
("second", vec!["b".to_string(), "a".to_string()]),
] {
store
.create_index(name, "rows", &columns, IndexMethodKind::BTree, false, &[])
.expect("composite index");
store.register(RegisteredIndex {
name: name.to_string(),
collection: "rows".to_string(),
columns,
method: IndexMethodKind::BTree,
unique: false,
});
}
store
.index_entity_insert(
"rows",
EntityId::new(1),
&[
("a".to_string(), Value::Integer(10)),
("b".to_string(), Value::Integer(20)),
],
)
.expect("insert");
for index in store.list_indices("rows") {
assert_eq!(
store.entries_indexed(&index),
1,
"one posting per composite index"
);
}
}
#[test]
fn row_index_estimates_cover_resident_growth_for_single_and_batch_writes() {
for batch_size in [1, 16] {
for (method, columns) in [
(IndexMethodKind::Hash, vec!["a".to_string()]),
(IndexMethodKind::Bitmap, vec!["a".to_string()]),
(IndexMethodKind::BTree, vec!["a".to_string()]),
(
IndexMethodKind::BTree,
vec!["a".to_string(), "b".to_string()],
),
(
IndexMethodKind::H3 { resolution: 9 },
vec!["geo".to_string()],
),
] {
let store = IndexStore::new();
for name in ["first", "second"] {
store
.create_index(name, "rows", &columns, method, false, &[])
.expect("index");
store.register(RegisteredIndex {
name: name.to_string(),
collection: "rows".to_string(),
columns: columns.clone(),
method,
unique: false,
});
}
let rows = (1..=batch_size)
.map(|id| {
(
EntityId::new(id),
vec![
(
"a".to_string(),
Value::text(format!("{id}-{}", "x".repeat(1024))),
),
("b".to_string(), Value::Integer(id as i64)),
("geo".to_string(), Value::GeoPoint(23_000_000, -46_000_000)),
],
)
})
.collect::<Vec<_>>();
let estimate = store.estimate_insert_growth(
"rows",
rows.iter().map(|(_, fields)| fields.as_slice()),
false,
);
let before = store.memory_bytes();
if batch_size == 1 {
store
.index_entity_insert("rows", rows[0].0, &rows[0].1)
.expect("single");
} else {
store
.index_entity_insert_batch("rows", &rows)
.expect("batch");
}
let growth = store.memory_bytes() - before;
assert!(growth > 0, "fixture must populate {method:?}");
assert!(
estimate >= growth,
"{method:?}: estimated {estimate}, resident delta {growth}"
);
}
}
}
#[test]
fn row_index_auto_estimate_includes_header_and_stops_after_registration() {
let store = IndexStore::new();
let fields = vec![("id".to_string(), Value::Integer(1))];
let rows = std::iter::once(fields.as_slice());
assert_eq!(store.estimate_insert_growth("rows", rows.clone(), false), 0);
let estimate = store.estimate_insert_growth("rows", rows.clone(), true);
let columns = vec!["id".to_string()];
store
.create_index(
"idx_id",
"rows",
&columns,
IndexMethodKind::Hash,
false,
&[],
)
.expect("auto index");
store.register(RegisteredIndex {
name: "idx_id".to_string(),
collection: "rows".to_string(),
columns,
method: IndexMethodKind::Hash,
unique: false,
});
store
.index_entity_insert("rows", EntityId::new(1), &fields)
.expect("single");
assert!(
estimate >= store.memory_bytes(),
"initial header and entry must fit"
);
assert_eq!(
store.estimate_insert_growth("rows", rows.clone(), true),
store.estimate_insert_growth("rows", rows, false),
"registered id is not charged twice"
);
assert_eq!(
store.estimate_insert_growth("rows", std::iter::once([].as_slice()), true),
0,
"missing fields do not create index entries"
);
}
#[test]
fn row_index_key_size_matches_the_existing_encoding() {
for value in [
Value::Null,
Value::Integer(-7),
Value::UnsignedInteger(u64::MAX),
Value::Float(1.25),
Value::Boolean(true),
Value::text("ação\n\""),
Value::Blob(vec![0, 127, 255]),
Value::RowRef("rows".to_string(), 42),
Value::Array(vec![Value::text("nested"), Value::Integer(3)]),
] {
assert_eq!(
value_to_bytes_len(&value),
value_to_bytes(&value).len() as u64
);
}
}
fn ids(values: &[EntityId]) -> Vec<u64> {
values.iter().map(|id| id.raw()).collect()
}
fn assert_memory_accounting(manager: &SortedIndexManager) {
let single = manager
.indices
.read()
.values()
.map(|index| {
std::mem::size_of::<SortedColumnIndex>()
+ index
.entries
.iter()
.map(|(key, ids)| btree_entry_bytes(std::iter::once(key), ids.len()))
.sum::<usize>()
})
.sum::<usize>();
let composite = manager
.composite
.read()
.values()
.map(|index| {
std::mem::size_of::<SortedCompositeIndex>()
+ index
.entries
.iter()
.map(|(key, ids)| btree_entry_bytes(key.iter(), ids.len()))
.sum::<usize>()
})
.sum::<usize>();
assert_eq!(manager.memory_bytes(), (single + composite) as u64);
}
#[test]
fn memory_accounting_tracks_sorted_updates_deletes_and_rebuilds() {
let manager = SortedIndexManager::new();
manager
.indices
.write()
.insert(("items".into(), "key".into()), SortedColumnIndex::new());
let columns = vec!["key".to_string(), "group".to_string()];
let mut entities = Vec::new();
for id in 0..128 {
let value = Value::text(format!("key-{}", id % 13));
manager.insert_one("items", "key", &value, EntityId::new(id));
manager.insert_one("items", "key", &value, EntityId::new(id));
entities.push((
EntityId::new(id),
vec![("key".into(), value), ("group".into(), Value::Integer(1))],
));
assert_memory_accounting(&manager);
}
manager.build_composite("items", &columns, &entities);
assert_memory_accounting(&manager);
for (id, fields) in &entities {
manager.delete_one("items", "key", &fields[0].1, *id);
manager.delete_one("items", "key", &fields[0].1, *id);
let updated = vec![
("key".into(), Value::text("updated")),
("group".into(), Value::Integer(2)),
];
manager.composite_entity_update("items", &columns, *id, fields, &updated);
assert_memory_accounting(&manager);
manager.composite_entity_update("items", &columns, *id, &updated, &[]);
assert_memory_accounting(&manager);
}
manager.build_composite("items", &columns, &entities);
assert_memory_accounting(&manager);
manager.composite.write().clear();
manager.indices.write().clear();
assert_eq!(manager.memory_bytes(), 0);
}
#[test]
fn h3_cell_value_encodes_geopoint_to_unsigned_cell() {
let paris = Value::GeoPoint(48_856_600, 2_352_200);
let cell = h3_cell_value(&paris, 9).expect("geopoint must encode to a cell");
match cell {
Value::UnsignedInteger(n) => {
assert_ne!(n, 0, "valid coordinate must not yield the 0 sentinel");
assert_eq!(
n,
crate::geo::h3::lat_lng_to_cell(48.8566, 2.3522, 9),
"index key must match the slice-1 encode of the same point"
);
}
other => panic!("expected UnsignedInteger cell, got {other:?}"),
}
let fine = h3_cell_value(&paris, 12).unwrap();
assert_ne!(
fine, cell,
"a finer resolution must produce a distinct cell"
);
}
#[test]
fn h3_cell_value_rejects_non_geopoint() {
assert!(h3_cell_value(&Value::Integer(5), 9).is_none());
assert!(h3_cell_value(&Value::text("48.8,2.3".to_string()), 9).is_none());
}
#[test]
fn h3_cell_value_uses_shared_geo_recognition_for_json_objects() {
let value = Value::Json(br#"{"latitude":48.8566,"longitude":2.3522}"#.to_vec());
let cell = h3_cell_value(&value, 9).expect("json object coordinate must encode");
assert_eq!(
cell,
Value::UnsignedInteger(crate::geo::h3::lat_lng_to_cell(48.8566, 2.3522, 9))
);
let geojson = Value::Json(br#"{"type":"Point","coordinates":[2.3522,48.8566]}"#.to_vec());
let cell = h3_cell_value(&geojson, 9).expect("GeoJSON Point must encode");
assert_eq!(
cell,
Value::UnsignedInteger(crate::geo::h3::lat_lng_to_cell(48.8566, 2.3522, 9))
);
}
#[test]
fn h3_cell_value_rejects_shared_geo_non_goals() {
for value in [
Value::Json(br#"{"lat":"48.8566","lon":"2.3522"}"#.to_vec()),
Value::Json(br#"{"type":"Polygon","coordinates":[[2.3522,48.8566]]}"#.to_vec()),
Value::Json(br#"{"type":"Point","coordinates":[2.3522]}"#.to_vec()),
Value::Json(br#"{"type":"Point","coordinates":[2.3522,48.8566,0]}"#.to_vec()),
Value::Json(br#"{"lat":48.8566}"#.to_vec()),
Value::Json(br#"{"lat":91.0,"lon":2.3522}"#.to_vec()),
Value::Json(br#"{"type":"Point","coordinates":[2.3522,91.0]}"#.to_vec()),
] {
assert_eq!(h3_cell_value(&value, 9), None, "{value:?}");
}
}
#[test]
fn derive_h3_cell_entities_skips_missing_and_non_geo_rows() {
let entities = vec![
(
EntityId::new(1),
vec![("loc".to_string(), Value::GeoPoint(48_856_600, 2_352_200))],
),
(
EntityId::new(2),
vec![("id".to_string(), Value::Integer(2))],
),
(
EntityId::new(3),
vec![("loc".to_string(), Value::Integer(0))],
),
];
let derived = derive_h3_cell_entities(&entities, "loc", 9);
assert_eq!(derived.len(), 1, "only the valid geo row is indexed");
assert_eq!(derived[0].0, EntityId::new(1));
assert!(matches!(
derived[0].1.first(),
Some((c, Value::UnsignedInteger(_))) if c == "loc"
));
}
#[test]
fn test_sorted_key_supports_text_ranges() {
let mut index = SortedColumnIndex::new();
index.insert(
value_to_sorted_key(&Value::text("alpha".to_string())).unwrap(),
EntityId::new(1),
);
index.insert(
value_to_sorted_key(&Value::text("bravo".to_string())).unwrap(),
EntityId::new(2),
);
index.insert(
value_to_sorted_key(&Value::text("charlie".to_string())).unwrap(),
EntityId::new(3),
);
assert_eq!(
ids(&index
.greater_than(value_to_sorted_key(&Value::text("alpha".to_string())).unwrap())
.unwrap()),
vec![2, 3]
);
assert_eq!(
ids(&index
.less_equal(value_to_sorted_key(&Value::text("alpha".to_string())).unwrap())
.unwrap()),
vec![1]
);
assert_eq!(
ids(&index
.range(
value_to_sorted_key(&Value::text("bravo".to_string())).unwrap(),
value_to_sorted_key(&Value::text("charlie".to_string())).unwrap(),
)
.unwrap()),
vec![2, 3]
);
}
#[test]
fn test_sorted_index_disables_range_lookup_when_mixed_families_are_present() {
let manager = SortedIndexManager::new();
let entities = vec![
(
EntityId::new(1),
vec![("score".to_string(), Value::Integer(10))],
),
(
EntityId::new(2),
vec![("score".to_string(), Value::Float(10.5))],
),
];
manager.build_index("numbers", "score", &entities);
assert_eq!(
manager.range_lookup(
"numbers",
"score",
value_to_sorted_key(&Value::Integer(0)).unwrap(),
value_to_sorted_key(&Value::Integer(20)).unwrap(),
),
None
);
}
#[test]
fn test_sorted_index_keeps_exact_in_lookup_when_mixed_families_are_present() {
let manager = SortedIndexManager::new();
let entities = vec![
(
EntityId::new(1),
vec![("mixed".to_string(), Value::Integer(10))],
),
(
EntityId::new(2),
vec![("mixed".to_string(), Value::text("ten".to_string()))],
),
];
manager.build_index("mixed_table", "mixed", &entities);
let matched = manager
.in_lookup_limited(
"mixed_table",
"mixed",
&[value_to_sorted_key(&Value::Integer(10)).unwrap()],
10,
)
.unwrap();
assert_eq!(ids(&matched), vec![1]);
}
#[test]
fn test_index_entity_insert_errors_when_registered_hash_index_is_missing() {
let store = IndexStore::new();
store.register(RegisteredIndex {
name: "idx_email".to_string(),
collection: "users".to_string(),
columns: vec!["email".to_string()],
method: IndexMethodKind::Hash,
unique: false,
});
let err = store
.index_entity_insert(
"users",
EntityId::new(1),
&[("email".to_string(), Value::text("a@b.com".to_string()))],
)
.expect_err("missing backing hash index should surface as an error");
assert!(err.contains("idx_email"));
assert!(err.contains("users"));
}
#[test]
fn test_index_entity_insert_batch_maintains_composite_btree() {
let store = IndexStore::new();
let columns = vec!["city".to_string(), "age".to_string()];
store
.create_index(
"idx_city_age",
"users",
&columns,
IndexMethodKind::BTree,
false,
&[],
)
.unwrap();
store.register(RegisteredIndex {
name: "idx_city_age".to_string(),
collection: "users".to_string(),
columns: columns.clone(),
method: IndexMethodKind::BTree,
unique: false,
});
let rows = vec![
(
EntityId::new(1),
vec![
("city".to_string(), Value::text("NYC".to_string())),
("age".to_string(), Value::Integer(30)),
],
),
(
EntityId::new(2),
vec![
("city".to_string(), Value::text("LA".to_string())),
("age".to_string(), Value::Integer(30)),
],
),
(
EntityId::new(3),
vec![
("city".to_string(), Value::text("NYC".to_string())),
("age".to_string(), Value::Integer(40)),
],
),
];
store.index_entity_insert_batch("users", &rows).unwrap();
let found = store
.sorted
.composite_prefix_range_lookup(
"users",
&columns,
&[value_to_sorted_key(&Value::text("NYC".to_string())).unwrap()],
value_to_sorted_key(&Value::Integer(20)).unwrap(),
value_to_sorted_key(&Value::Integer(45)).unwrap(),
10,
)
.unwrap();
assert_eq!(ids(&found), vec![1, 3]);
}
fn provision_hash_index(store: &IndexStore) {
store
.create_index(
"idx_email",
"users",
&["email".to_string()],
IndexMethodKind::Hash,
false,
&[],
)
.unwrap();
store.register(RegisteredIndex {
name: "idx_email".to_string(),
collection: "users".to_string(),
columns: vec!["email".to_string()],
method: IndexMethodKind::Hash,
unique: false,
});
}
#[test]
fn test_index_entity_delete_removes_row_from_hash_index() {
let store = IndexStore::new();
provision_hash_index(&store);
let id = EntityId::new(7);
let key = ("email".to_string(), Value::text("a@b.com".to_string()));
store
.index_entity_insert("users", id, &[key.clone()])
.unwrap();
assert_eq!(
ids(&store.hash_lookup("users", "idx_email", b"a@b.com").unwrap()),
vec![7]
);
store
.index_entity_delete("users", id, &[key.clone()])
.unwrap();
assert!(store
.hash_lookup("users", "idx_email", b"a@b.com")
.unwrap()
.is_empty());
}
#[test]
fn test_index_entity_delete_tolerates_missing_index() {
let store = IndexStore::new();
store.register(RegisteredIndex {
name: "idx_email".to_string(),
collection: "users".to_string(),
columns: vec!["email".to_string()],
method: IndexMethodKind::Hash,
unique: false,
});
store
.index_entity_delete(
"users",
EntityId::new(1),
&[("email".to_string(), Value::text("a@b.com".to_string()))],
)
.expect("delete must tolerate a missing backing index");
}
#[test]
fn test_index_entity_update_moves_row_to_new_key() {
let store = IndexStore::new();
provision_hash_index(&store);
let id = EntityId::new(11);
let old = ("email".to_string(), Value::text("old@x.com".to_string()));
let new = ("email".to_string(), Value::text("new@x.com".to_string()));
store
.index_entity_insert("users", id, &[old.clone()])
.unwrap();
store
.index_entity_update("users", id, &[old.clone()], &[new.clone()])
.unwrap();
assert!(store
.hash_lookup("users", "idx_email", b"old@x.com")
.unwrap()
.is_empty());
assert_eq!(
ids(&store
.hash_lookup("users", "idx_email", b"new@x.com")
.unwrap()),
vec![11]
);
}
#[test]
fn test_index_entity_update_skips_unchanged_columns() {
let store = IndexStore::new();
provision_hash_index(&store);
let id = EntityId::new(13);
let same = ("email".to_string(), Value::text("a@b.com".to_string()));
store
.index_entity_insert("users", id, &[same.clone()])
.unwrap();
store
.index_entity_update("users", id, &[same.clone()], &[same.clone()])
.unwrap();
assert_eq!(
ids(&store.hash_lookup("users", "idx_email", b"a@b.com").unwrap()),
vec![13]
);
}
#[test]
fn test_index_entity_update_indexes_newly_added_column() {
let store = IndexStore::new();
provision_hash_index(&store);
let id = EntityId::new(17);
let old: Vec<(String, Value)> = vec![]; let new = vec![("email".to_string(), Value::text("fresh@x.com".to_string()))];
store.index_entity_update("users", id, &old, &new).unwrap();
assert_eq!(
ids(&store
.hash_lookup("users", "idx_email", b"fresh@x.com")
.unwrap()),
vec![17]
);
}
#[test]
fn test_index_entity_update_removes_dropped_column() {
let store = IndexStore::new();
provision_hash_index(&store);
let id = EntityId::new(19);
let old = vec![("email".to_string(), Value::text("bye@x.com".to_string()))];
let new: Vec<(String, Value)> = vec![];
store.index_entity_insert("users", id, &old).unwrap();
assert_eq!(
ids(&store
.hash_lookup("users", "idx_email", b"bye@x.com")
.unwrap()),
vec![19]
);
store.index_entity_update("users", id, &old, &new).unwrap();
assert!(store
.hash_lookup("users", "idx_email", b"bye@x.com")
.unwrap()
.is_empty());
}
#[test]
fn test_index_entity_update_ignores_non_indexed_column_changes() {
let store = IndexStore::new();
provision_hash_index(&store); let id = EntityId::new(23);
let insert = vec![("email".to_string(), Value::text("a@b.com".to_string()))];
store.index_entity_insert("users", id, &insert).unwrap();
let old = vec![
("email".to_string(), Value::text("a@b.com".to_string())),
("age".to_string(), Value::Integer(30)),
];
let new = vec![
("email".to_string(), Value::text("a@b.com".to_string())),
("age".to_string(), Value::Integer(31)),
];
store.index_entity_update("users", id, &old, &new).unwrap();
assert_eq!(
ids(&store.hash_lookup("users", "idx_email", b"a@b.com").unwrap()),
vec![23]
);
}
}