use crate::error::{storage_err, TopoError};
use redb::{ReadableTable, Table, TableDefinition};
use smol_str::SmolStr;
use std::collections::HashMap;
pub(crate) const DICT: TableDefinition<&[u8], &str> = TableDefinition::new("dict");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DictKind {
Label = 0,
EdgeType = 1,
PropKey = 2,
Model = 3,
}
impl DictKind {
fn from_byte(b: u8) -> Option<Self> {
match b {
0 => Some(Self::Label),
1 => Some(Self::EdgeType),
2 => Some(Self::PropKey),
3 => Some(Self::Model),
_ => None,
}
}
}
fn key(kind: DictKind, id: u32) -> [u8; 5] {
let mut k = [0; 5];
k[0] = kind as u8;
k[1..].copy_from_slice(&id.to_be_bytes());
k
}
#[derive(Debug, Default)]
struct Map {
names: HashMap<SmolStr, u32>,
ids: HashMap<u32, SmolStr>,
next: u32,
}
#[derive(Debug, Default)]
pub(crate) struct Dicts {
labels: Map,
types: Map,
props: Map,
models: Map,
}
#[derive(Debug, Default)]
pub(crate) struct InternJournal {
dict_ids: Vec<(DictKind, u32)>,
pub(crate) scope_ids: Vec<u32>,
}
impl Dicts {
fn map(&self, k: DictKind) -> &Map {
match k {
DictKind::Label => &self.labels,
DictKind::EdgeType => &self.types,
DictKind::PropKey => &self.props,
DictKind::Model => &self.models,
}
}
fn map_mut(&mut self, k: DictKind) -> &mut Map {
match k {
DictKind::Label => &mut self.labels,
DictKind::EdgeType => &mut self.types,
DictKind::PropKey => &mut self.props,
DictKind::Model => &mut self.models,
}
}
pub(crate) fn load(tx: &redb::ReadTransaction) -> Result<Self, TopoError> {
match tx.open_table(DICT) {
Ok(t) => Self::load_from_table(&t),
Err(redb::TableError::TableDoesNotExist(_)) => Ok(Self::default()),
Err(e) => Err(storage_err(e)),
}
}
pub(crate) fn load_from_table(
t: &impl ReadableTable<&'static [u8], &'static str>,
) -> Result<Self, TopoError> {
let mut d = Self::default();
for x in t.iter().map_err(storage_err)? {
let (k, v) = x.map_err(storage_err)?;
let k = k.value();
if k.len() != 5 {
return Err(TopoError::Encoding("bad dict key length".into()));
}
let kind = DictKind::from_byte(k[0])
.ok_or_else(|| TopoError::Encoding("bad dict kind".into()))?;
let id = u32::from_be_bytes(k[1..].try_into().unwrap());
let name = SmolStr::new(v.value());
let m = d.map_mut(kind);
m.names.insert(name.clone(), id);
m.ids.insert(id, name);
m.next = m.next.max(
id.checked_add(1)
.ok_or_else(|| TopoError::Encoding("dict id exhausted".into()))?,
);
}
Ok(d)
}
pub(crate) fn intern(
&mut self,
t: &mut Table<'_, &'static [u8], &'static str>,
kind: DictKind,
s: &str,
journal: &mut InternJournal,
) -> Result<u32, TopoError> {
if let Some(id) = self.map(kind).names.get(s) {
return Ok(*id);
}
let m = self.map_mut(kind);
let id = m.next;
m.next = m
.next
.checked_add(1)
.ok_or_else(|| TopoError::Encoding("dict id exhausted".into()))?;
t.insert(key(kind, id).as_slice(), s).map_err(storage_err)?;
let n = SmolStr::new(s);
m.names.insert(n.clone(), id);
m.ids.insert(id, n);
journal.dict_ids.push((kind, id));
Ok(id)
}
pub(crate) fn revert(&mut self, journal: &InternJournal) {
for &(kind, id) in journal.dict_ids.iter().rev() {
let m = self.map_mut(kind);
if let Some(name) = m.ids.remove(&id) {
m.names.remove(&name);
}
m.next = id;
}
}
pub(crate) fn id_of(&self, kind: DictKind, value: &str) -> Option<u32> {
self.map(kind).names.get(value).copied()
}
pub(crate) fn resolve(&self, k: DictKind, id: u32) -> Result<SmolStr, TopoError> {
self.map(k)
.ids
.get(&id)
.cloned()
.ok_or_else(|| TopoError::Encoding(format!("unknown {k:?} dict id {id}")))
}
pub(crate) fn clear(&mut self) {
*self = Self::default();
}
#[cfg(test)]
fn total_len(&self) -> usize {
self.labels.names.len()
+ self.types.names.len()
+ self.props.names.len()
+ self.models.names.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use redb::Database;
#[test]
fn revert_restores_exact_pre_batch_counts_and_counters() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("x.redb")).unwrap();
let tx = db.begin_write().unwrap();
let mut t = tx.open_table(DICT).unwrap();
let mut dicts = Dicts::default();
let mut seed_journal = InternJournal::default();
dicts
.intern(&mut t, DictKind::Label, "Memory", &mut seed_journal)
.unwrap();
dicts
.intern(&mut t, DictKind::EdgeType, "RELATES_TO", &mut seed_journal)
.unwrap();
let pre_batch_total = dicts.total_len();
let pre_batch_label_next = dicts.labels.next;
let pre_batch_type_next = dicts.types.next;
assert_eq!(pre_batch_total, 2);
let mut journal = InternJournal::default();
let repeat_id = dicts
.intern(&mut t, DictKind::Label, "Memory", &mut journal)
.unwrap();
dicts
.intern(&mut t, DictKind::Label, "PhantomA", &mut journal)
.unwrap();
dicts
.intern(&mut t, DictKind::Label, "PhantomB", &mut journal)
.unwrap();
dicts
.intern(&mut t, DictKind::PropKey, "phantom_key", &mut journal)
.unwrap();
dicts
.intern(&mut t, DictKind::Model, "phantom-model", &mut journal)
.unwrap();
assert_eq!(
repeat_id,
dicts.id_of(DictKind::Label, "Memory").unwrap(),
"re-interning a known string must return its existing id"
);
assert_eq!(
dicts.total_len(),
pre_batch_total + 4,
"4 NEW entries (2 labels, 1 prop key, 1 model); the repeat must not grow the mirror"
);
dicts.revert(&journal);
assert_eq!(
dicts.total_len(),
pre_batch_total,
"revert must restore the EXACT pre-batch entry count, not merely shrink it"
);
assert_eq!(
dicts.id_of(DictKind::Label, "PhantomA"),
None,
"reverted label must be gone from the by-name map"
);
assert_eq!(
dicts.id_of(DictKind::Label, "Memory"),
Some(repeat_id),
"the idempotent re-intern's target must survive revert unharmed"
);
assert_eq!(
dicts.labels.next, pre_batch_label_next,
"the label id counter must roll back too, or the on-disk row that \
never saw these ids would be permanently skipped"
);
assert_eq!(dicts.types.next, pre_batch_type_next);
let mut journal2 = InternJournal::default();
let fresh_id = dicts
.intern(&mut t, DictKind::Label, "PhantomA", &mut journal2)
.unwrap();
assert_eq!(fresh_id, pre_batch_label_next);
}
#[test]
fn namespaces_are_independent_and_resolve_round_trips() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("x.redb")).unwrap();
let tx = db.begin_write().unwrap();
let mut t = tx.open_table(DICT).unwrap();
let mut dicts = Dicts::default();
let all = [
DictKind::Label,
DictKind::EdgeType,
DictKind::PropKey,
DictKind::Model,
];
let mut j = InternJournal::default();
for k in all {
dicts.intern(&mut t, k, "shared", &mut j).unwrap();
}
for k in all {
let id = dicts.id_of(k, "shared").expect("interned under this kind");
assert_eq!(
dicts.resolve(k, id).unwrap(),
SmolStr::new("shared"),
"round-trip must hold within kind {k:?}"
);
}
let mut j2 = InternJournal::default();
dicts
.intern(&mut t, DictKind::Label, "label_only", &mut j2)
.unwrap();
assert!(dicts.id_of(DictKind::Label, "label_only").is_some());
for k in [DictKind::EdgeType, DictKind::PropKey, DictKind::Model] {
assert_eq!(
dicts.id_of(k, "label_only"),
None,
"a Label-interned string must be invisible in the {k:?} namespace"
);
}
let before = dicts.total_len();
let existing = dicts.id_of(DictKind::Label, "shared").unwrap();
let mut j3 = InternJournal::default();
let again = dicts
.intern(&mut t, DictKind::Label, "shared", &mut j3)
.unwrap();
assert_eq!(existing, again, "re-intern must return the existing id");
assert_eq!(
dicts.total_len(),
before,
"an idempotent re-intern must not grow any namespace"
);
assert!(
dicts.resolve(DictKind::Label, 9_999).is_err(),
"unknown dict id must be a clean error"
);
}
}