use crate::row::encode_row_into;
use crate::table::Table;
use crate::types::*;
use crate::wal::{Wal, WalRecordType, WalSyncMode};
use rustc_hash::FxHashMap;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use tracing::{info, warn};
fn validate_identifier(kind: &str, name: &str) -> io::Result<()> {
if name.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid {kind} name: must not be empty"),
));
}
let mut chars = name.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"invalid {kind} name '{name}': must start with a letter or underscore"
),
));
}
for ch in chars {
if !ch.is_ascii_alphanumeric() && ch != '_' {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"invalid {kind} name '{name}': must contain only letters, digits, and underscores"
),
));
}
}
Ok(())
}
fn validate_table_name(name: &str) -> io::Result<()> {
validate_identifier("table", name)
}
fn validate_column_name(name: &str) -> io::Result<()> {
validate_identifier("column", name)
}
const CATALOG_FILE: &str = "catalog.bin";
const CATALOG_MAGIC: &[u8; 4] = b"BCAT";
const CATALOG_VERSION: u16 = 2;
const WAL_FILE: &str = "wal.log";
const WAL_BATCH_SIZE: usize = 64;
pub struct Catalog {
tables: Vec<Table>,
name_to_slot: FxHashMap<String, usize>,
data_dir: PathBuf,
wal: Wal,
next_tx_id: u64,
checkpointed: bool,
}
impl Catalog {
pub fn create(data_dir: &Path) -> io::Result<Self> {
std::fs::create_dir_all(data_dir)?;
let wal_path = data_dir.join(WAL_FILE);
let wal = Wal::create(&wal_path, WAL_BATCH_SIZE)?;
let cat = Catalog {
tables: Vec::new(),
name_to_slot: FxHashMap::default(),
data_dir: data_dir.to_path_buf(),
wal,
next_tx_id: 1,
checkpointed: false,
};
cat.persist()?;
Ok(cat)
}
pub fn open(data_dir: &Path) -> io::Result<Self> {
let cat_path = data_dir.join(CATALOG_FILE);
if !cat_path.exists() {
return Err(io::Error::new(io::ErrorKind::NotFound, "no catalog file"));
}
let entries = read_catalog_file(&cat_path)?;
let mut tables: Vec<Table> = Vec::with_capacity(entries.len());
let mut name_to_slot =
FxHashMap::with_capacity_and_hasher(entries.len(), Default::default());
for CatalogEntry {
schema,
indexed_cols,
} in entries
{
let name = schema.table_name.clone();
let table = Table::open_with_indexes(schema, data_dir, &indexed_cols)?;
name_to_slot.insert(name, tables.len());
tables.push(table);
}
let wal_path = data_dir.join(WAL_FILE);
let wal = Wal::open(&wal_path, WAL_BATCH_SIZE)?;
let mut cat = Catalog {
tables,
name_to_slot,
data_dir: data_dir.to_path_buf(),
wal,
next_tx_id: 1,
checkpointed: false,
};
cat.replay_wal()?;
Ok(cat)
}
fn replay_wal(&mut self) -> io::Result<()> {
let records = self.wal.read_all()?;
if records.is_empty() {
return Ok(());
}
info!(count = records.len(), "replaying WAL records");
let mut replayed_inserts = 0usize;
let mut replayed_updates = 0usize;
let mut replayed_deletes = 0usize;
for rec in records {
match rec.record_type {
WalRecordType::Insert => {
if let Some((table_name, _rid, row_bytes)) = decode_wal_payload(&rec.data) {
if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
let tbl = &mut self.tables[slot];
let _ = tbl.heap.insert(&row_bytes)?;
replayed_inserts += 1;
}
}
}
WalRecordType::Update => {
if let Some((table_name, rid, row_bytes)) = decode_wal_payload(&rec.data) {
if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
let tbl = &mut self.tables[slot];
let _ = tbl.heap.update(rid, &row_bytes)?;
replayed_updates += 1;
}
}
}
WalRecordType::Delete => {
if let Some((table_name, rid, _)) = decode_wal_payload(&rec.data) {
if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
let tbl = &mut self.tables[slot];
let _ = tbl.heap.delete(rid);
replayed_deletes += 1;
}
}
}
WalRecordType::Commit | WalRecordType::Rollback => {
}
}
}
info!(
inserts = replayed_inserts,
updates = replayed_updates,
deletes = replayed_deletes,
"WAL replay complete"
);
for tbl in &mut self.tables {
tbl.heap.flush_all_dirty()?;
tbl.heap.flush()?;
tbl.rebuild_indexes_from_heap()?;
tbl.save_dirty_indexes()?;
}
self.wal.truncate()?;
Ok(())
}
pub fn checkpoint(&mut self) -> io::Result<()> {
for tbl in &mut self.tables {
tbl.heap.flush_all_dirty()?;
tbl.heap.flush()?;
tbl.save_dirty_indexes()?;
}
self.wal.flush()?;
self.wal.truncate()?;
self.checkpointed = true;
Ok(())
}
#[inline]
fn next_tx(&mut self) -> u64 {
let id = self.next_tx_id;
self.next_tx_id = self.next_tx_id.wrapping_add(1);
id
}
fn wal_log(
&mut self,
tx_id: u64,
record_type: WalRecordType,
table: &str,
rid: RowId,
row_bytes: &[u8],
) -> io::Result<()> {
if self.wal.is_off() {
return Ok(());
}
let payload = encode_wal_payload(table, rid, row_bytes);
self.wal.append(tx_id, record_type, &payload)
}
#[inline]
pub fn sync_wal(&mut self) -> io::Result<()> {
self.wal.flush()
}
pub fn set_wal_sync_mode(&mut self, mode: WalSyncMode) {
self.wal.set_sync_mode(mode);
}
pub fn create_table(&mut self, schema: Schema) -> io::Result<()> {
validate_table_name(&schema.table_name)?;
for col in &schema.columns {
validate_column_name(&col.name)?;
}
let name = schema.table_name.clone();
if self.name_to_slot.contains_key(&name) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("table '{name}' already exists"),
));
}
let table = Table::create(schema, &self.data_dir)?;
let slot = self.tables.len();
self.tables.push(table);
self.name_to_slot.insert(name, slot);
self.persist()?;
Ok(())
}
fn persist(&self) -> io::Result<()> {
let cat_path = self.data_dir.join(CATALOG_FILE);
let tmp_path = self.data_dir.join(format!("{CATALOG_FILE}.tmp"));
let entries: Vec<CatalogEntryRef<'_>> = self
.tables
.iter()
.map(|t| CatalogEntryRef {
schema: &t.schema,
indexed_cols: t.indexed_column_names(),
})
.collect();
write_catalog_file(&tmp_path, &entries)?;
fs::rename(&tmp_path, &cat_path)?;
Ok(())
}
#[inline]
pub fn table_slot(&self, name: &str) -> Option<usize> {
self.name_to_slot.get(name).copied()
}
#[inline]
pub fn table_by_slot(&self, slot: usize) -> &Table {
&self.tables[slot]
}
#[inline]
pub fn table_by_slot_mut(&mut self, slot: usize) -> &mut Table {
&mut self.tables[slot]
}
pub fn get_table(&self, name: &str) -> Option<&Table> {
let slot = *self.name_to_slot.get(name)?;
Some(&self.tables[slot])
}
pub fn get_table_mut(&mut self, name: &str) -> Option<&mut Table> {
let slot = *self.name_to_slot.get(name)?;
Some(&mut self.tables[slot])
}
#[inline]
fn by_name(&self, table: &str) -> io::Result<&Table> {
let slot = *self.name_to_slot.get(table).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})?;
Ok(&self.tables[slot])
}
#[inline]
fn by_name_mut(&mut self, table: &str) -> io::Result<&mut Table> {
let slot = *self.name_to_slot.get(table).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})?;
Ok(&mut self.tables[slot])
}
pub fn insert(&mut self, table: &str, values: &Row) -> io::Result<RowId> {
if self.wal.is_off() {
return self.by_name_mut(table)?.insert(values);
}
let tbl = self.by_name_mut(table)?;
let mut wal_bytes: Vec<u8> = Vec::new();
encode_row_into(&tbl.schema, values, &mut wal_bytes);
let tx_id = self.next_tx();
self.wal_log(
tx_id,
WalRecordType::Insert,
table,
RowId {
page_id: 0,
slot_index: 0,
},
&wal_bytes,
)?;
self.by_name_mut(table)?.insert(values)
}
pub fn get(&self, table: &str, rid: RowId) -> Option<Row> {
self.get_table(table)?.get(rid)
}
pub fn delete(&mut self, table: &str, rid: RowId) -> io::Result<()> {
if self.wal.is_off() {
return self.by_name_mut(table)?.delete(rid);
}
let tx_id = self.next_tx();
self.wal_log(tx_id, WalRecordType::Delete, table, rid, &[])?;
self.by_name_mut(table)?.delete(rid)
}
pub fn delete_many(&mut self, table: &str, rids: &[RowId]) -> io::Result<u64> {
if self.wal.is_off() {
return self.by_name_mut(table)?.delete_many(rids);
}
let tx_id = self.next_tx();
for &rid in rids {
let payload = encode_wal_payload(table, rid, &[]);
self.wal.append(tx_id, WalRecordType::Delete, &payload)?;
}
self.by_name_mut(table)?.delete_many(rids)
}
pub fn scan_delete_matching<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
where
P: FnMut(&[u8]) -> bool,
{
self.by_name_mut(table)?.scan_delete_matching(pred)
}
pub fn scan_delete_matching_logged<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
where
P: FnMut(&[u8]) -> bool,
{
if self.wal.is_off() {
return self.by_name_mut(table)?.scan_delete_matching(pred);
}
let slot = *self.name_to_slot.get(table).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})?;
let tx_id = self.next_tx();
let Catalog { tables, wal, .. } = self;
let tbl = &mut tables[slot];
let name_bytes = table.as_bytes();
let count = tbl.scan_delete_matching_with_hook(pred, |rid, row_bytes| {
let mut payload: Vec<u8> =
Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
payload.extend_from_slice(name_bytes);
payload.extend_from_slice(&rid.page_id.to_le_bytes());
payload.extend_from_slice(&rid.slot_index.to_le_bytes());
payload.extend_from_slice(&0u32.to_le_bytes());
let _ = wal.append(tx_id, WalRecordType::Delete, &payload);
})?;
Ok(count)
}
pub fn scan_patch_matching_logged<P, M>(
&mut self,
table: &str,
pred: P,
try_mutate: M,
) -> io::Result<(u64, Vec<RowId>)>
where
P: FnMut(&[u8]) -> bool,
M: FnMut(&mut [u8]) -> Option<u16>,
{
if self.wal.is_off() {
return self.by_name_mut(table)?.scan_patch_matching_with_hook(
pred,
try_mutate,
|_, _| {},
);
}
let slot = *self.name_to_slot.get(table).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})?;
let tx_id = self.next_tx();
let Catalog { tables, wal, .. } = self;
let tbl = &mut tables[slot];
let name_bytes = table.as_bytes();
let result = tbl.scan_patch_matching_with_hook(pred, try_mutate, |rid, row_bytes| {
let mut payload: Vec<u8> =
Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
payload.extend_from_slice(name_bytes);
payload.extend_from_slice(&rid.page_id.to_le_bytes());
payload.extend_from_slice(&rid.slot_index.to_le_bytes());
payload.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
payload.extend_from_slice(row_bytes);
let _ = wal.append(tx_id, WalRecordType::Update, &payload);
})?;
Ok(result)
}
pub fn update(&mut self, table: &str, rid: RowId, values: &Row) -> io::Result<RowId> {
if self.wal.is_off() {
return self.by_name_mut(table)?.update(rid, values);
}
let tbl = self.by_name_mut(table)?;
let mut wal_bytes: Vec<u8> = Vec::new();
encode_row_into(&tbl.schema, values, &mut wal_bytes);
let tx_id = self.next_tx();
self.wal_log(tx_id, WalRecordType::Update, table, rid, &wal_bytes)?;
self.by_name_mut(table)?.update(rid, values)
}
pub fn update_hinted(
&mut self,
table: &str,
rid: RowId,
values: &Row,
changed_col_indices: Option<&[usize]>,
) -> io::Result<RowId> {
if self.wal.is_off() {
return self
.by_name_mut(table)?
.update_hinted(rid, values, changed_col_indices);
}
let tbl = self.by_name_mut(table)?;
let mut wal_bytes: Vec<u8> = Vec::new();
encode_row_into(&tbl.schema, values, &mut wal_bytes);
let tx_id = self.next_tx();
self.wal_log(tx_id, WalRecordType::Update, table, rid, &wal_bytes)?;
self.by_name_mut(table)?
.update_hinted(rid, values, changed_col_indices)
}
#[inline]
pub fn with_row_bytes_mut<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
where
F: FnOnce(&mut [u8]),
{
self.by_name_mut(table)?.with_row_bytes_mut(rid, f)
}
#[inline]
pub fn update_row_bytes_logged<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
where
F: FnOnce(&mut [u8]),
{
let slot = *self.name_to_slot.get(table).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})?;
self.update_row_bytes_logged_by_slot(slot, rid, f)
}
#[inline]
pub fn update_row_bytes_logged_by_slot<F>(
&mut self,
slot: usize,
rid: RowId,
f: F,
) -> io::Result<bool>
where
F: FnOnce(&mut [u8]),
{
let tbl = &mut self.tables[slot];
let ok = tbl.with_row_bytes_mut(rid, f)?;
if !ok {
return Ok(false);
}
if self.wal.is_off() {
return Ok(true);
}
let new_bytes = match tbl.heap.get(rid) {
Some(b) => b,
None => return Ok(false),
};
let table_name = tbl.schema.table_name.clone();
let tx_id = self.next_tx();
self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
Ok(true)
}
#[inline]
pub fn patch_var_col_in_place(
&mut self,
table: &str,
rid: RowId,
col_idx: usize,
new_value: Option<&[u8]>,
) -> io::Result<bool> {
self.by_name_mut(table)?
.patch_var_col_in_place(rid, col_idx, new_value)
}
pub fn patch_var_col_logged(
&mut self,
table: &str,
rid: RowId,
col_idx: usize,
new_value: Option<&[u8]>,
) -> io::Result<bool> {
let slot = *self.name_to_slot.get(table).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})?;
let tbl = &mut self.tables[slot];
let ok = tbl.patch_var_col_in_place(rid, col_idx, new_value)?;
if !ok {
return Ok(false);
}
if self.wal.is_off() {
return Ok(true);
}
let new_bytes = match tbl.heap.get(rid) {
Some(b) => b,
None => return Ok(false),
};
let table_name = tbl.schema.table_name.clone();
let tx_id = self.next_tx();
self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
Ok(true)
}
pub fn scan(&self, table: &str) -> io::Result<impl Iterator<Item = (RowId, Row)> + '_> {
Ok(self.by_name(table)?.scan())
}
pub fn for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
where
F: FnMut(RowId, &[u8]),
{
self.by_name(table)?.for_each_row_raw(f);
Ok(())
}
pub fn try_for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
where
F: FnMut(RowId, &[u8]) -> std::ops::ControlFlow<()>,
{
self.by_name(table)?.try_for_each_row_raw(f);
Ok(())
}
pub fn create_index(&mut self, table: &str, column: &str) -> io::Result<()> {
let data_dir = self.data_dir.clone();
self.by_name_mut(table)?.create_index(column, &data_dir)?;
self.persist()
}
pub fn index_lookup(&self, table: &str, column: &str, key: &Value) -> io::Result<Option<Row>> {
Ok(self
.by_name(table)?
.index_lookup(column, key)
.map(|(_, row)| row))
}
pub fn list_tables(&self) -> Vec<&str> {
self.tables
.iter()
.map(|t| t.schema.table_name.as_str())
.collect()
}
pub fn schema(&self, table: &str) -> Option<&Schema> {
let slot = *self.name_to_slot.get(table)?;
Some(&self.tables[slot].schema)
}
pub fn drop_table(&mut self, name: &str) -> io::Result<()> {
validate_table_name(name)?;
let slot = *self.name_to_slot.get(name).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, format!("table '{name}' not found"))
})?;
let table = &self.tables[slot];
let heap_path = self
.data_dir
.join(format!("{}.heap", table.schema.table_name));
if heap_path.exists() {
fs::remove_file(&heap_path)?;
}
for col_name in table.indexed_column_names() {
let idx_path = self.data_dir.join(format!("{name}_{col_name}.idx"));
if idx_path.exists() {
let _ = fs::remove_file(&idx_path);
}
}
self.name_to_slot.remove(name);
let last = self.tables.len() - 1;
if slot != last {
let moved_name = self.tables[last].schema.table_name.clone();
self.tables.swap(slot, last);
self.name_to_slot.insert(moved_name, slot);
}
self.tables.pop();
self.persist()?;
Ok(())
}
pub fn alter_table_add_column(&mut self, table: &str, col: ColumnDef) -> io::Result<()> {
let data_dir = self.data_dir.clone();
let tbl = self.by_name_mut(table)?;
if tbl.schema.columns.iter().any(|c| c.name == col.name) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("column '{}' already exists in table '{table}'", col.name),
));
}
let old_schema = tbl.schema.clone();
let has_rows = tbl.heap.scan().next().is_some();
if has_rows && col.required {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"cannot add required column '{}' to non-empty table '{table}': \
no default value to backfill existing rows with",
col.name
),
));
}
tbl.schema.columns.push(col);
tbl.refresh_layout();
if has_rows {
let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
}
self.persist()?;
Ok(())
}
pub fn alter_table_drop_column(&mut self, table: &str, col_name: &str) -> io::Result<()> {
let data_dir = self.data_dir.clone();
let tbl = self.by_name_mut(table)?;
let idx = tbl
.schema
.columns
.iter()
.position(|c| c.name == col_name)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("column '{col_name}' not found in table '{table}'"),
)
})?;
let old_schema = tbl.schema.clone();
let has_rows = tbl.heap.scan().next().is_some();
tbl.schema.columns.remove(idx);
for (i, col) in tbl.schema.columns.iter_mut().enumerate() {
col.position = i as u16;
}
tbl.refresh_layout();
if has_rows {
let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
}
self.persist()?;
Ok(())
}
}
impl Drop for Catalog {
fn drop(&mut self) {
if let Err(e) = self.checkpoint() {
warn!(error = %e, "catalog drop checkpoint failed");
}
}
}
fn encode_wal_payload(table: &str, rid: RowId, row_bytes: &[u8]) -> Vec<u8> {
let name = table.as_bytes();
let mut out = Vec::with_capacity(4 + name.len() + 4 + 2 + 4 + row_bytes.len());
out.extend_from_slice(&(name.len() as u32).to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(&rid.page_id.to_le_bytes());
out.extend_from_slice(&rid.slot_index.to_le_bytes());
out.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
out.extend_from_slice(row_bytes);
out
}
fn decode_wal_payload(data: &[u8]) -> Option<(String, RowId, Vec<u8>)> {
let mut pos = 0usize;
if data.len() < 4 {
return None;
}
let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
pos += 4;
if pos + name_len > data.len() {
return None;
}
let name = std::str::from_utf8(&data[pos..pos + name_len])
.ok()?
.to_string();
pos += name_len;
if pos + 4 + 2 + 4 > data.len() {
return None;
}
let page_id = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
pos += 4;
let slot_index = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
pos += 2;
let row_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
pos += 4;
if pos + row_len > data.len() {
return None;
}
let row_bytes = data[pos..pos + row_len].to_vec();
Some((
name,
RowId {
page_id,
slot_index,
},
row_bytes,
))
}
pub(crate) struct CatalogEntry {
pub schema: Schema,
pub indexed_cols: Vec<String>,
}
pub(crate) struct CatalogEntryRef<'a> {
pub schema: &'a Schema,
pub indexed_cols: Vec<String>,
}
fn write_catalog_file(path: &Path, entries: &[CatalogEntryRef<'_>]) -> io::Result<()> {
let mut buf: Vec<u8> = Vec::with_capacity(64);
buf.extend_from_slice(CATALOG_MAGIC);
buf.extend_from_slice(&CATALOG_VERSION.to_le_bytes());
buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
for entry in entries {
let schema = entry.schema;
let name = schema.table_name.as_bytes();
buf.extend_from_slice(&(name.len() as u32).to_le_bytes());
buf.extend_from_slice(name);
buf.extend_from_slice(&(schema.columns.len() as u16).to_le_bytes());
for col in &schema.columns {
let cn = col.name.as_bytes();
buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
buf.extend_from_slice(cn);
buf.push(col.type_id as u8);
buf.push(if col.required { 1 } else { 0 });
buf.extend_from_slice(&col.position.to_le_bytes());
}
buf.extend_from_slice(&(entry.indexed_cols.len() as u16).to_le_bytes());
for col_name in &entry.indexed_cols {
let cn = col_name.as_bytes();
buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
buf.extend_from_slice(cn);
}
}
let mut f = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
f.write_all(&buf)?;
f.sync_data()?;
Ok(())
}
fn read_catalog_file(path: &Path) -> io::Result<Vec<CatalogEntry>> {
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] != CATALOG_MAGIC {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"bad catalog magic",
));
}
pos += 4;
let version = u16::from_le_bytes(buf[pos..pos + 2].try_into().unwrap());
pos += 2;
if version != CATALOG_VERSION && version != 1 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unsupported catalog version: {version}"),
));
}
let n_tables = u32::from_le_bytes(buf[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
let mut entries = Vec::with_capacity(n_tables);
for _ in 0..n_tables {
let name_len = read_u32(&buf, &mut pos)? as usize;
let table_name = read_string(&buf, &mut pos, name_len)?;
let n_cols = read_u16(&buf, &mut pos)? as usize;
let mut columns = Vec::with_capacity(n_cols);
for _ in 0..n_cols {
let cname_len = read_u32(&buf, &mut pos)? as usize;
let name = read_string(&buf, &mut pos, cname_len)?;
let type_id_raw = read_u8(&buf, &mut pos)?;
let type_id = type_id_from_u8(type_id_raw)?;
let required = read_u8(&buf, &mut pos)? != 0;
let position = read_u16(&buf, &mut pos)?;
columns.push(ColumnDef {
name,
type_id,
required,
position,
});
}
let indexed_cols = if version >= 2 {
let n = read_u16(&buf, &mut pos)? as usize;
let mut v = Vec::with_capacity(n);
for _ in 0..n {
let l = read_u32(&buf, &mut pos)? as usize;
v.push(read_string(&buf, &mut pos, l)?);
}
v
} else {
Vec::new()
};
entries.push(CatalogEntry {
schema: Schema {
table_name,
columns,
},
indexed_cols,
});
}
Ok(entries)
}
fn read_u8(buf: &[u8], pos: &mut usize) -> io::Result<u8> {
if *pos >= buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated catalog",
));
}
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 catalog",
));
}
let v = u16::from_le_bytes(buf[*pos..*pos + 2].try_into().unwrap());
*pos += 2;
Ok(v)
}
fn read_u32(buf: &[u8], pos: &mut usize) -> io::Result<u32> {
if *pos + 4 > buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated catalog",
));
}
let v = u32::from_le_bytes(buf[*pos..*pos + 4].try_into().unwrap());
*pos += 4;
Ok(v)
}
fn read_string(buf: &[u8], pos: &mut usize, len: usize) -> io::Result<String> {
if *pos + len > buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated catalog string",
));
}
let s = std::str::from_utf8(&buf[*pos..*pos + len])
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8 in catalog"))?
.to_string();
*pos += len;
Ok(s)
}
fn type_id_from_u8(v: u8) -> io::Result<TypeId> {
match v {
0 => Ok(TypeId::Empty),
1 => Ok(TypeId::Int),
2 => Ok(TypeId::Float),
3 => Ok(TypeId::Bool),
4 => Ok(TypeId::Str),
5 => Ok(TypeId::DateTime),
6 => Ok(TypeId::Uuid),
7 => Ok(TypeId::Bytes),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown type id: {v}"),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_catalog(name: &str) -> Catalog {
let dir = std::env::temp_dir().join(format!("powdb_cat_{name}_{}", std::process::id()));
Catalog::create(&dir).unwrap()
}
#[test]
fn test_create_table_and_insert() {
let mut cat = temp_catalog("basic");
let schema = Schema {
table_name: "users".into(),
columns: vec![
ColumnDef {
name: "name".into(),
type_id: TypeId::Str,
required: true,
position: 0,
},
ColumnDef {
name: "age".into(),
type_id: TypeId::Int,
required: false,
position: 1,
},
],
};
cat.create_table(schema).unwrap();
let row = vec![Value::Str("Alice".into()), Value::Int(30)];
let rid = cat.insert("users", &row).unwrap();
let result = cat.get("users", rid).unwrap();
assert_eq!(result[0], Value::Str("Alice".into()));
assert_eq!(result[1], Value::Int(30));
}
#[test]
fn test_scan_table() {
let mut cat = temp_catalog("scan");
let schema = Schema {
table_name: "items".into(),
columns: vec![
ColumnDef {
name: "name".into(),
type_id: TypeId::Str,
required: true,
position: 0,
},
ColumnDef {
name: "price".into(),
type_id: TypeId::Float,
required: true,
position: 1,
},
],
};
cat.create_table(schema).unwrap();
for i in 0..50 {
cat.insert(
"items",
&vec![
Value::Str(format!("item_{i}")),
Value::Float(i as f64 * 1.5),
],
)
.unwrap();
}
let rows: Vec<_> = cat.scan("items").unwrap().collect();
assert_eq!(rows.len(), 50);
}
#[test]
fn test_index_lookup() {
let mut cat = temp_catalog("idx");
let schema = Schema {
table_name: "users".into(),
columns: vec![
ColumnDef {
name: "email".into(),
type_id: TypeId::Str,
required: true,
position: 0,
},
ColumnDef {
name: "name".into(),
type_id: TypeId::Str,
required: true,
position: 1,
},
],
};
cat.create_table(schema).unwrap();
cat.create_index("users", "email").unwrap();
cat.insert(
"users",
&vec![
Value::Str("alice@example.com".into()),
Value::Str("Alice".into()),
],
)
.unwrap();
cat.insert(
"users",
&vec![
Value::Str("bob@example.com".into()),
Value::Str("Bob".into()),
],
)
.unwrap();
let result = cat
.index_lookup("users", "email", &Value::Str("bob@example.com".into()))
.unwrap();
assert!(result.is_some());
let row = result.unwrap();
assert_eq!(row[1], Value::Str("Bob".into()));
}
#[test]
fn test_delete_row() {
let mut cat = temp_catalog("delete");
let schema = Schema {
table_name: "t".into(),
columns: vec![ColumnDef {
name: "v".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
};
cat.create_table(schema).unwrap();
let r1 = cat.insert("t", &vec![Value::Int(1)]).unwrap();
let r2 = cat.insert("t", &vec![Value::Int(2)]).unwrap();
cat.delete("t", r1).unwrap();
assert!(cat.get("t", r1).is_none());
assert!(cat.get("t", r2).is_some());
}
#[test]
fn test_update_row() {
let mut cat = temp_catalog("update");
let schema = Schema {
table_name: "t".into(),
columns: vec![ColumnDef {
name: "v".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
};
cat.create_table(schema).unwrap();
let rid = cat.insert("t", &vec![Value::Int(1)]).unwrap();
let new_rid = cat.update("t", rid, &vec![Value::Int(99)]).unwrap();
let row = cat.get("t", new_rid).unwrap();
assert_eq!(row[0], Value::Int(99));
}
#[test]
fn test_persist_and_reopen() {
let dir = std::env::temp_dir().join(format!("powdb_cat_persist_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
{
let mut cat = Catalog::create(&dir).unwrap();
cat.create_table(Schema {
table_name: "users".into(),
columns: vec![
ColumnDef {
name: "name".into(),
type_id: TypeId::Str,
required: true,
position: 0,
},
ColumnDef {
name: "age".into(),
type_id: TypeId::Int,
required: false,
position: 1,
},
],
})
.unwrap();
cat.insert("users", &vec![Value::Str("Alice".into()), Value::Int(30)])
.unwrap();
cat.insert("users", &vec![Value::Str("Bob".into()), Value::Int(25)])
.unwrap();
}
let cat = Catalog::open(&dir).unwrap();
let schema = cat.schema("users").unwrap();
assert_eq!(schema.columns.len(), 2);
assert_eq!(schema.columns[0].name, "name");
assert_eq!(schema.columns[0].type_id, TypeId::Str);
assert_eq!(schema.columns[1].type_id, TypeId::Int);
let rows: Vec<_> = cat.scan("users").unwrap().collect();
assert_eq!(rows.len(), 2);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_open_missing_dir_errors() {
let dir = std::env::temp_dir().join(format!("powdb_cat_missing_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(Catalog::open(&dir).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_list_tables() {
let mut cat = temp_catalog("list");
cat.create_table(Schema {
table_name: "a".into(),
columns: vec![ColumnDef {
name: "x".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
})
.unwrap();
cat.create_table(Schema {
table_name: "b".into(),
columns: vec![ColumnDef {
name: "y".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
})
.unwrap();
let mut tables = cat.list_tables();
tables.sort();
assert_eq!(tables, vec!["a", "b"]);
}
#[test]
fn test_path_traversal_table_name_rejected() {
let mut cat = temp_catalog("path_trav");
let bad_names = vec![
"../etc/passwd",
"foo/bar",
"table\0name",
"",
"123starts_with_digit",
"has-dashes",
"has spaces",
"has.dots",
];
for name in bad_names {
let schema = Schema {
table_name: name.into(),
columns: vec![ColumnDef {
name: "x".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
};
let result = cat.create_table(schema);
assert!(result.is_err(), "expected error for table name '{name}'");
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
}
let good_names = vec!["users", "_private", "Table_123", "_"];
for name in good_names {
let schema = Schema {
table_name: name.into(),
columns: vec![ColumnDef {
name: "x".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
};
assert!(
cat.create_table(schema).is_ok(),
"expected ok for table name '{name}'"
);
}
}
#[test]
fn test_path_traversal_column_name_rejected() {
let mut cat = temp_catalog("col_path_trav");
let schema = Schema {
table_name: "valid_table".into(),
columns: vec![ColumnDef {
name: "../bad".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
};
let result = cat.create_table(schema);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn test_drop_table_validates_name() {
let mut cat = temp_catalog("drop_trav");
let result = cat.drop_table("../etc/passwd");
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
}
}