use noxu_db::{
Database, DatabaseConfig, DatabaseEntry, Environment, OperationStatus,
Transaction,
};
use crate::error::{PersistError, Result};
const CATALOG_FORMAT_VERSION: u16 = 1;
const RECORD_LEN: usize = 6;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CatalogEntry {
pub format_version: u16,
pub class_version: u16,
}
impl CatalogEntry {
fn encode(&self) -> [u8; RECORD_LEN] {
let mut out = [0u8; RECORD_LEN];
out[0..2].copy_from_slice(&self.format_version.to_be_bytes());
out[2..4].copy_from_slice(&self.class_version.to_be_bytes());
out
}
fn decode(bytes: &[u8]) -> Result<Self> {
if bytes.len() != RECORD_LEN {
return Err(PersistError::SerializationError(format!(
"catalog record has wrong length: {} (expected {})",
bytes.len(),
RECORD_LEN,
)));
}
let format_version = u16::from_be_bytes([bytes[0], bytes[1]]);
let class_version = u16::from_be_bytes([bytes[2], bytes[3]]);
Ok(Self { format_version, class_version })
}
}
pub fn catalog_db_name(store_name: &str) -> String {
format!("__noxu_persist_catalog__{}", store_name)
}
pub struct ClassCatalog {
db: Option<Database>,
}
impl ClassCatalog {
pub fn open(
env: &Environment,
store_name: &str,
allow_create: bool,
read_only: bool,
transactional: bool,
) -> Result<Self> {
let name = catalog_db_name(store_name);
let mut cfg = DatabaseConfig::new();
cfg.set_allow_create(allow_create);
cfg.set_read_only(read_only);
cfg.set_transactional(transactional);
match env.open_database(None, &name, &cfg) {
Ok(db) => Ok(Self { db: Some(db) }),
Err(e) if read_only => {
log::debug!(target: "noxu_persist::evolve",
"no on-disk catalog for store '{}' (read-only): {}",
store_name, e);
Ok(Self { db: None })
}
Err(e) => Err(PersistError::DatabaseError(e)),
}
}
pub fn get(
&self,
txn: Option<&Transaction>,
class_name: &str,
) -> Result<Option<CatalogEntry>> {
let Some(db) = &self.db else {
return Ok(None);
};
let key = DatabaseEntry::from_vec(class_name.as_bytes().to_vec());
let mut data = DatabaseEntry::new();
match db.get(txn, &key, &mut data)? {
OperationStatus::Success => {
let bytes = data.get_data().ok_or_else(|| {
PersistError::SerializationError(
"catalog entry has empty data".to_string(),
)
})?;
Ok(Some(CatalogEntry::decode(bytes)?))
}
_ => Ok(None),
}
}
pub fn put(
&self,
txn: Option<&Transaction>,
class_name: &str,
class_version: u16,
) -> Result<()> {
let db = self.db.as_ref().ok_or_else(|| {
PersistError::DatabaseError(
noxu_db::NoxuError::OperationNotAllowed(
"catalog database is not writable (read-only or absent)"
.to_string(),
),
)
})?;
let entry = CatalogEntry {
format_version: CATALOG_FORMAT_VERSION,
class_version,
};
let key = DatabaseEntry::from_vec(class_name.as_bytes().to_vec());
let val = DatabaseEntry::from_vec(entry.encode().to_vec());
db.put(txn, &key, &val)?;
Ok(())
}
pub fn remove(
&self,
txn: Option<&Transaction>,
class_name: &str,
) -> Result<bool> {
let Some(db) = &self.db else {
return Ok(false);
};
let key = DatabaseEntry::from_vec(class_name.as_bytes().to_vec());
match db.delete(txn, &key)? {
OperationStatus::Success => Ok(true),
_ => Ok(false),
}
}
pub fn close(&mut self) -> Result<()> {
if let Some(db) = self.db.take() {
db.close()?;
}
Ok(())
}
}
impl Drop for ClassCatalog {
fn drop(&mut self) {
let _ = self.close();
}
}
#[cfg(test)]
mod tests {
use super::*;
use noxu_db::EnvironmentConfig;
use tempfile::TempDir;
fn temp_env() -> (TempDir, Environment) {
let td = TempDir::new().unwrap();
let cfg = EnvironmentConfig::new(td.path().to_path_buf())
.with_allow_create(true);
(td, Environment::open(cfg).unwrap())
}
#[test]
fn round_trip_record() {
let entry = CatalogEntry { format_version: 1, class_version: 7 };
let dec = CatalogEntry::decode(&entry.encode()).unwrap();
assert_eq!(entry, dec);
}
#[test]
fn decode_wrong_length_errors() {
assert!(CatalogEntry::decode(&[1, 2, 3]).is_err());
}
#[test]
fn open_creates_catalog_database() {
let (_td, env) = temp_env();
let cat =
ClassCatalog::open(&env, "store", true, false, false).unwrap();
assert!(cat.db.is_some());
}
#[test]
fn put_get_round_trip() {
let (_td, env) = temp_env();
let cat = ClassCatalog::open(&env, "s", true, false, false).unwrap();
cat.put(None, "User", 3).unwrap();
let got = cat.get(None, "User").unwrap().unwrap();
assert_eq!(got.class_version, 3);
}
#[test]
fn get_missing_returns_none() {
let (_td, env) = temp_env();
let cat = ClassCatalog::open(&env, "s", true, false, false).unwrap();
assert!(cat.get(None, "DoesNotExist").unwrap().is_none());
}
#[test]
fn put_overwrites() {
let (_td, env) = temp_env();
let cat = ClassCatalog::open(&env, "s", true, false, false).unwrap();
cat.put(None, "X", 1).unwrap();
cat.put(None, "X", 2).unwrap();
assert_eq!(cat.get(None, "X").unwrap().unwrap().class_version, 2);
}
#[test]
fn remove_existing_returns_true() {
let (_td, env) = temp_env();
let cat = ClassCatalog::open(&env, "s", true, false, false).unwrap();
cat.put(None, "X", 1).unwrap();
assert!(cat.remove(None, "X").unwrap());
assert!(cat.get(None, "X").unwrap().is_none());
}
#[test]
fn remove_missing_returns_false() {
let (_td, env) = temp_env();
let cat = ClassCatalog::open(&env, "s", true, false, false).unwrap();
assert!(!cat.remove(None, "Nope").unwrap());
}
#[test]
fn catalog_db_name_is_distinct() {
assert_eq!(catalog_db_name("foo"), "__noxu_persist_catalog__foo");
assert_ne!(catalog_db_name("foo"), "foo_User");
}
}