use rustc_hash::FxHashMap;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
const VIEW_FILE: &str = "views.bin";
const VIEW_MAGIC: &[u8; 4] = b"BVIW";
const VIEW_VERSION: u16 = 1;
#[derive(Debug, Clone)]
pub struct ViewDef {
pub name: String,
pub query: String,
pub depends_on: Vec<String>,
pub dirty: bool,
}
pub struct ViewRegistry {
views: FxHashMap<String, ViewDef>,
deps: FxHashMap<String, Vec<String>>,
data_dir: PathBuf,
}
impl ViewRegistry {
pub fn new(data_dir: &Path) -> Self {
ViewRegistry {
views: FxHashMap::default(),
deps: FxHashMap::default(),
data_dir: data_dir.to_path_buf(),
}
}
pub fn open(data_dir: &Path) -> io::Result<Self> {
let path = data_dir.join(VIEW_FILE);
if !path.exists() {
return Ok(Self::new(data_dir));
}
let defs = read_view_file(&path)?;
let mut reg = Self::new(data_dir);
for def in defs {
reg.insert_def(def);
}
Ok(reg)
}
pub fn register(&mut self, def: ViewDef) -> io::Result<()> {
if self.views.contains_key(&def.name) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("view '{}' already exists", def.name),
));
}
self.insert_def(def);
self.persist()
}
pub fn unregister(&mut self, name: &str) -> io::Result<()> {
let def = self.views.remove(name).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, format!("view '{name}' not found"))
})?;
for table in &def.depends_on {
if let Some(list) = self.deps.get_mut(table) {
list.retain(|v| v != name);
if list.is_empty() {
self.deps.remove(table);
}
}
}
self.persist()
}
pub fn get(&self, name: &str) -> Option<&ViewDef> {
self.views.get(name)
}
#[inline]
pub fn is_view(&self, name: &str) -> bool {
self.views.contains_key(name)
}
pub fn mark_dirty(&mut self, view_name: &str) {
if let Some(def) = self.views.get_mut(view_name) {
def.dirty = true;
}
}
pub fn mark_clean(&mut self, view_name: &str) {
if let Some(def) = self.views.get_mut(view_name) {
def.dirty = false;
}
}
#[inline]
pub fn is_dirty(&self, view_name: &str) -> bool {
self.views.get(view_name).is_some_and(|d| d.dirty)
}
#[inline]
pub fn mark_dependents_dirty(&mut self, table: &str) {
let names: Option<Vec<String>> = self.deps.get(table).cloned();
if let Some(names) = names {
for name in &names {
if let Some(def) = self.views.get_mut(name.as_str()) {
def.dirty = true;
}
}
}
}
pub fn list_views(&self) -> Vec<&str> {
self.views.keys().map(|k| k.as_str()).collect()
}
fn insert_def(&mut self, def: ViewDef) {
let name = def.name.clone();
for table in &def.depends_on {
self.deps
.entry(table.clone())
.or_default()
.push(name.clone());
}
self.views.insert(name, def);
}
fn persist(&self) -> io::Result<()> {
let path = self.data_dir.join(VIEW_FILE);
let tmp = self.data_dir.join(format!("{VIEW_FILE}.tmp"));
let defs: Vec<&ViewDef> = self.views.values().collect();
write_view_file(&tmp, &defs)?;
fs::rename(&tmp, &path)?;
Ok(())
}
}
fn write_view_file(path: &Path, defs: &[&ViewDef]) -> io::Result<()> {
let mut buf: Vec<u8> = Vec::with_capacity(256);
buf.extend_from_slice(VIEW_MAGIC);
buf.extend_from_slice(&VIEW_VERSION.to_le_bytes());
buf.extend_from_slice(&(defs.len() as u32).to_le_bytes());
for def in defs {
let name = def.name.as_bytes();
buf.extend_from_slice(&(name.len() as u32).to_le_bytes());
buf.extend_from_slice(name);
let query = def.query.as_bytes();
buf.extend_from_slice(&(query.len() as u32).to_le_bytes());
buf.extend_from_slice(query);
buf.extend_from_slice(&(def.depends_on.len() as u16).to_le_bytes());
for dep in &def.depends_on {
let d = dep.as_bytes();
buf.extend_from_slice(&(d.len() as u32).to_le_bytes());
buf.extend_from_slice(d);
}
buf.push(if def.dirty { 1 } else { 0 });
}
let mut f = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
f.write_all(&buf)?;
f.sync_data()?;
Ok(())
}
fn read_view_file(path: &Path) -> io::Result<Vec<ViewDef>> {
let mut f = fs::File::open(path)?;
let mut buf = Vec::new();
f.read_to_end(&mut buf)?;
let mut pos = 0usize;
if buf.len() < 10 || &buf[0..4] != VIEW_MAGIC {
return Err(io::Error::new(io::ErrorKind::InvalidData, "bad view magic"));
}
pos += 4;
let version = u16::from_le_bytes(
buf[pos..pos + 2]
.try_into()
.expect("invariant: buf.len() >= 10 checked above covers the 2-byte version field"),
);
pos += 2;
if version != VIEW_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unsupported view version: {version}"),
));
}
let n_views = u32::from_le_bytes(
buf[pos..pos + 4]
.try_into()
.expect("invariant: buf.len() >= 10 checked above covers the 4-byte n_views field"),
) as usize;
pos += 4;
let mut defs = Vec::with_capacity(n_views);
for _ in 0..n_views {
let name = read_str(&buf, &mut pos)?;
let query = read_str(&buf, &mut pos)?;
let n_deps = read_u16(&buf, &mut pos)? as usize;
let mut depends_on = Vec::with_capacity(n_deps);
for _ in 0..n_deps {
depends_on.push(read_str(&buf, &mut pos)?);
}
let dirty = read_u8(&buf, &mut pos)? != 0;
defs.push(ViewDef {
name,
query,
depends_on,
dirty,
});
}
Ok(defs)
}
fn read_u8(buf: &[u8], pos: &mut usize) -> io::Result<u8> {
if *pos >= buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated view file",
));
}
let v = buf[*pos];
*pos += 1;
Ok(v)
}
fn read_u16(buf: &[u8], pos: &mut usize) -> io::Result<u16> {
if *pos + 2 > buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated view file",
));
}
let v = u16::from_le_bytes(
buf[*pos..*pos + 2]
.try_into()
.expect("invariant: bounds checked immediately above (*pos + 2 <= buf.len())"),
);
*pos += 2;
Ok(v)
}
fn read_str(buf: &[u8], pos: &mut usize) -> io::Result<String> {
if *pos + 4 > buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated view file",
));
}
let len = u32::from_le_bytes(
buf[*pos..*pos + 4]
.try_into()
.expect("invariant: bounds checked immediately above (*pos + 4 <= buf.len())"),
) as usize;
*pos += 4;
if *pos + len > buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated view file",
));
}
let s = std::str::from_utf8(&buf[*pos..*pos + len])
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8 in view file"))?
.to_string();
*pos += len;
Ok(s)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_registry(name: &str) -> ViewRegistry {
let dir = std::env::temp_dir().join(format!("powdb_view_{name}_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
ViewRegistry::new(&dir)
}
#[test]
fn test_read_view_file_garbage_errors_not_panic() {
let dir = std::env::temp_dir().join(format!("powdb_view_garbage_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cases: Vec<Vec<u8>> = vec![
vec![], b"BVIW".to_vec(), b"XXXX\x01\x00\x00\x00\x00\x00".to_vec(), {
let mut v = b"BVIW".to_vec();
v.extend_from_slice(&VIEW_VERSION.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
v
},
{
let mut v = b"BVIW".to_vec();
v.extend_from_slice(&VIEW_VERSION.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
v
},
];
for (i, bytes) in cases.iter().enumerate() {
let p = dir.join(format!("v{i}.bin"));
std::fs::write(&p, bytes).unwrap();
let result = read_view_file(&p);
assert!(
result.is_err(),
"expected Err for garbage view file case {i} ({bytes:?}), got Ok"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_register_and_lookup() {
let mut reg = temp_registry("basic");
reg.register(ViewDef {
name: "ActiveUsers".into(),
query: "User filter .active = true".into(),
depends_on: vec!["User".into()],
dirty: false,
})
.unwrap();
assert!(reg.is_view("ActiveUsers"));
assert!(!reg.is_view("User"));
let def = reg.get("ActiveUsers").unwrap();
assert_eq!(def.query, "User filter .active = true");
}
#[test]
fn test_dirty_tracking() {
let mut reg = temp_registry("dirty");
reg.register(ViewDef {
name: "V1".into(),
query: "T1".into(),
depends_on: vec!["T1".into()],
dirty: false,
})
.unwrap();
assert!(!reg.is_dirty("V1"));
reg.mark_dependents_dirty("T1");
assert!(reg.is_dirty("V1"));
reg.mark_clean("V1");
assert!(!reg.is_dirty("V1"));
}
#[test]
fn test_multi_dependency() {
let mut reg = temp_registry("multi");
reg.register(ViewDef {
name: "V1".into(),
query: "T1 inner join T2 on .id = .fk".into(),
depends_on: vec!["T1".into(), "T2".into()],
dirty: false,
})
.unwrap();
reg.mark_dependents_dirty("T2");
assert!(reg.is_dirty("V1"));
}
#[test]
fn test_unregister() {
let mut reg = temp_registry("unreg");
reg.register(ViewDef {
name: "V1".into(),
query: "T1".into(),
depends_on: vec!["T1".into()],
dirty: false,
})
.unwrap();
reg.unregister("V1").unwrap();
assert!(!reg.is_view("V1"));
reg.mark_dependents_dirty("T1");
}
#[test]
fn test_persist_and_reopen() {
let dir = std::env::temp_dir().join(format!("powdb_view_persist_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
{
let mut reg = ViewRegistry::new(&dir);
reg.register(ViewDef {
name: "V1".into(),
query: "User filter .active = true".into(),
depends_on: vec!["User".into()],
dirty: true,
})
.unwrap();
}
let reg = ViewRegistry::open(&dir).unwrap();
assert!(reg.is_view("V1"));
let def = reg.get("V1").unwrap();
assert_eq!(def.query, "User filter .active = true");
assert!(def.dirty);
assert_eq!(def.depends_on, vec!["User".to_string()]);
}
}