AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/sqlexec/src/catalog.rs

Latest change d971623579ac6cb425eea3ca33f24af5983c18b2 - feat(sqlexec): SQL executor over the storage B+tree (CREATE/INSERT/SELECT) by Ólafur Búi Ólafsson

//! Table schemas and their persistence in the B+tree.
//!
//! The catalog is just more rows in the same tree, stored under a reserved key
//! prefix that ordinary table data can never collide with (table names are SQL
//! identifiers and so never begin with a `\x00` byte). Each table's schema is
//! encoded once at `CREATE TABLE` time and read back on demand, so the catalog
//! survives reopening the database.

use akurai_sql::{ColumnDef, DataType};

use crate::error::ExecError;

/// Reserved prefix for catalog entries: `\x00cat:<table>`.
pub const CATALOG_PREFIX: &[u8] = b"\x00cat:";
/// Reserved prefix for per-table rowid counters: `\x00seq:<table>`.
pub const SEQ_PREFIX: &[u8] = b"\x00seq:";

/// A table's columns, in declaration order.
#[derive(Debug, Clone, PartialEq)]
pub struct Schema {
    pub table: String,
    pub columns: Vec<Column>,
}

/// One column: name, type, and nullability.
#[derive(Debug, Clone, PartialEq)]
pub struct Column {
    pub name: String,
    pub ty: DataType,
    pub not_null: bool,
}

impl Schema {
    /// Build a schema from a parsed `CREATE TABLE` column list.
    pub fn from_defs(table: &str, defs: &[ColumnDef]) -> Schema {
        Schema {
            table: table.to_string(),
            columns: defs
                .iter()
                .map(|d| Column {
                    name: d.name.clone(),
                    ty: d.ty,
                    not_null: d.not_null,
                })
                .collect(),
        }
    }

    /// Find a column's position by name (case-sensitive, as the parser emits).
    pub fn index_of(&self, name: &str) -> Option<usize> {
        self.columns.iter().position(|c| c.name == name)
    }

    /// The catalog key for this table.
    pub fn catalog_key(table: &str) -> Vec<u8> {
        let mut k = CATALOG_PREFIX.to_vec();
        k.extend_from_slice(table.as_bytes());
        k
    }

    /// The rowid-counter key for this table.
    pub fn seq_key(table: &str) -> Vec<u8> {
        let mut k = SEQ_PREFIX.to_vec();
        k.extend_from_slice(table.as_bytes());
        k
    }

    /// Serialize the schema to bytes for storage.
    pub fn encode(&self) -> Vec<u8> {
        let mut out = Vec::new();
        out.extend_from_slice(&(self.columns.len() as u32).to_be_bytes());
        for c in &self.columns {
            out.extend_from_slice(&(c.name.len() as u32).to_be_bytes());
            out.extend_from_slice(c.name.as_bytes());
            out.push(type_tag(c.ty));
            out.push(c.not_null as u8);
        }
        out
    }

    /// Decode a schema previously written by [`Schema::encode`].
    pub fn decode(table: &str, bytes: &[u8]) -> Result<Schema, ExecError> {
        let mut r = Reader { bytes, pos: 0 };
        let n = r.u32()? as usize;
        let mut columns = Vec::with_capacity(n);
        for _ in 0..n {
            let len = r.u32()? as usize;
            let name = r.string(len)?;
            let ty = tag_type(r.byte()?)?;
            let not_null = r.byte()? != 0;
            columns.push(Column { name, ty, not_null });
        }
        Ok(Schema {
            table: table.to_string(),
            columns,
        })
    }
}

/// Map a [`DataType`] to its on-disk tag byte.
pub fn type_tag(ty: DataType) -> u8 {
    match ty {
        DataType::Integer => 1,
        DataType::Text => 2,
        DataType::Real => 3,
    }
}

/// Inverse of [`type_tag`].
pub fn tag_type(tag: u8) -> Result<DataType, ExecError> {
    match tag {
        1 => Ok(DataType::Integer),
        2 => Ok(DataType::Text),
        3 => Ok(DataType::Real),
        _ => Err(ExecError::Corrupt("unknown column type tag")),
    }
}

/// A tiny cursor over a byte slice, returning [`ExecError::Corrupt`] on
/// underrun rather than panicking.
pub struct Reader<'a> {
    pub bytes: &'a [u8],
    pub pos: usize,
}

impl Reader<'_> {
    pub fn byte(&mut self) -> Result<u8, ExecError> {
        let b = *self
            .bytes
            .get(self.pos)
            .ok_or(ExecError::Corrupt("unexpected end of bytes"))?;
        self.pos += 1;
        Ok(b)
    }

    pub fn take(&mut self, n: usize) -> Result<&[u8], ExecError> {
        let end = self
            .pos
            .checked_add(n)
            .filter(|e| *e <= self.bytes.len())
            .ok_or(ExecError::Corrupt("unexpected end of bytes"))?;
        let slice = &self.bytes[self.pos..end];
        self.pos = end;
        Ok(slice)
    }

    pub fn u32(&mut self) -> Result<u32, ExecError> {
        let b = self.take(4)?;
        Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
    }

    pub fn u64(&mut self) -> Result<u64, ExecError> {
        let b = self.take(8)?;
        let mut a = [0u8; 8];
        a.copy_from_slice(b);
        Ok(u64::from_be_bytes(a))
    }

    pub fn string(&mut self, len: usize) -> Result<String, ExecError> {
        let b = self.take(len)?;
        String::from_utf8(b.to_vec()).map_err(|_| ExecError::Corrupt("invalid utf-8 in text"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn schema_round_trips() {
        let defs = vec![
            ColumnDef {
                name: "id".into(),
                ty: DataType::Integer,
                not_null: true,
            },
            ColumnDef {
                name: "name".into(),
                ty: DataType::Text,
                not_null: false,
            },
            ColumnDef {
                name: "score".into(),
                ty: DataType::Real,
                not_null: true,
            },
        ];
        let schema = Schema::from_defs("users", &defs);
        let bytes = schema.encode();
        let back = Schema::decode("users", &bytes).unwrap();
        assert_eq!(schema, back);
        assert_eq!(back.index_of("name"), Some(1));
        assert_eq!(back.index_of("nope"), None);
    }
}