#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![deny(clippy::indexing_slicing)]
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![deny(clippy::panic)]
#![cfg_attr(
test,
allow(
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::unwrap_used
)
)]
use std::path::PathBuf;
use std::sync::Arc;
use inillucent_base::error::{corrupt, misuse};
use inillucent_base::ids::PageId;
use inillucent_base::limits::Limits;
use inillucent_base::DbResult;
use inillucent_storage::cursor::BTreeCursor;
use inillucent_storage::pager::Pager;
use inillucent_transaction::recovery::{open_database, DatabaseOptions};
use inillucent_tree::datum::{Datum, OwnedDatum};
use inillucent_value::record::{FieldSpan, KeyInfo, RecordRef};
use inillucent_value::Value;
use inillucent_vfs::path::DbPath;
use inillucent_vfs::{OsVfs, Vfs};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SchemaObject {
pub kind: String,
pub name: String,
pub table: String,
pub root: u32,
pub sql: String,
}
impl SchemaObject {
pub fn column_names(&self) -> DbResult<Vec<String>> {
let open = self
.sql
.find('(')
.ok_or_else(|| corrupt(format!("{} has no column list", self.name)))?;
let close = self
.sql
.rfind(')')
.ok_or_else(|| corrupt(format!("{} has no column list", self.name)))?;
if close <= open {
return Err(corrupt(format!(
"{}'s column list is inside out",
self.name
)));
}
let body = self.sql.get(open.saturating_add(1)..close).unwrap_or("");
let mut names = Vec::new();
let mut depth = 0i32;
let mut part = String::new();
for character in body.chars() {
match character {
'(' => {
depth = depth.saturating_add(1);
part.push(character);
}
')' => {
depth = depth.saturating_sub(1);
part.push(character);
}
',' if depth == 0 => {
push_column_name(&part, &mut names);
part.clear();
}
_ => part.push(character),
}
}
push_column_name(&part, &mut names);
if names.is_empty() {
return Err(corrupt(format!("{} declares no columns", self.name)));
}
Ok(names)
}
}
const TABLE_CONSTRAINTS: [&str; 6] = [
"primary",
"unique",
"check",
"foreign",
"constraint",
"exclude",
];
fn push_column_name(part: &str, names: &mut Vec<String>) {
let trimmed = part.trim();
let Some(first) = trimmed.split_whitespace().next() else {
return;
};
if TABLE_CONSTRAINTS
.iter()
.any(|keyword| first.eq_ignore_ascii_case(keyword))
{
return;
}
let cleaned = first.trim_matches(|c| c == '"' || c == '`' || c == '[' || c == ']');
if cleaned.is_empty() {
return;
}
names.push(cleaned.to_string());
}
pub struct SqliteFile {
pager: Pager,
limits: Limits,
}
impl SqliteFile {
pub fn open(path: PathBuf) -> DbResult<SqliteFile> {
let vfs: Arc<dyn Vfs> = Arc::new(OsVfs::new());
let options = DatabaseOptions {
writable: false,
..DatabaseOptions::default()
};
let mut pager = open_database(vfs, &DbPath::new(path), options)?;
pager.begin_read()?;
Ok(SqliteFile {
pager,
limits: Limits::default(),
})
}
pub fn page_size(&self) -> u32 {
self.pager.page_size().bytes()
}
pub fn page_count(&self) -> u32 {
self.pager.page_count()
}
pub fn catalog(
&mut self,
name: &[u8],
) -> DbResult<inillucent_catalog::snapshot::DatabaseCatalog> {
inillucent_catalog::load::load_database_catalog(&mut self.pager, name, 0)
}
pub fn schema(&mut self) -> DbResult<Vec<SchemaObject>> {
let root = PageId::from_persisted(1)?;
let mut cursor = BTreeCursor::table(root);
let mut payload: Vec<u8> = Vec::with_capacity(512);
let mut fields: Vec<FieldSpan> = Vec::with_capacity(8);
let mut out = Vec::new();
let encoding = self.pager.text_encoding();
let mut more = cursor.first(&mut self.pager)?;
while more {
cursor.payload_into(&mut self.pager, &self.limits, &mut payload)?;
let header_len = RecordRef::parse_into(&payload, &self.limits, &mut fields)?;
let record = RecordRef::with_fields(&payload, &fields, header_len, encoding);
out.push(SchemaObject {
kind: text_at(&record, 0)?,
name: text_at(&record, 1)?,
table: text_at(&record, 2)?,
root: u32::try_from(integer_at(&record, 3)?)
.map_err(|_| corrupt("a root page that is not a page number"))?,
sql: text_at(&record, 4)?,
});
more = cursor.next(&mut self.pager)?;
}
Ok(out)
}
pub fn object(&mut self, kind: &str, name: &str) -> DbResult<SchemaObject> {
self.schema()?
.into_iter()
.find(|object| object.kind == kind && object.name == name)
.ok_or_else(|| misuse(format!("no {kind} named {name} in this file")))
}
pub fn read_table(&mut self, root: u32, columns: usize) -> DbResult<Vec<Vec<OwnedDatum>>> {
let root = PageId::from_persisted(root)?;
let mut cursor = BTreeCursor::table(root);
let mut payload: Vec<u8> = Vec::with_capacity(512);
let mut fields: Vec<FieldSpan> = Vec::with_capacity(16);
let mut out = Vec::new();
let encoding = self.pager.text_encoding();
let mut more = cursor.first(&mut self.pager)?;
while more {
let rowid = cursor.rowid()?;
cursor.payload_into(&mut self.pager, &self.limits, &mut payload)?;
let header_len = RecordRef::parse_into(&payload, &self.limits, &mut fields)?;
let record = RecordRef::with_fields(&payload, &fields, header_len, encoding);
let mut row = Vec::with_capacity(columns.saturating_add(1));
row.push(OwnedDatum::Int(rowid));
for index in 0..columns {
row.push(owned_from_record(&record, index)?);
}
out.push(row);
more = cursor.next(&mut self.pager)?;
}
Ok(out)
}
pub fn read_index(&mut self, root: u32, columns: usize) -> DbResult<Vec<Vec<OwnedDatum>>> {
let root = PageId::from_persisted(root)?;
let mut cursor = BTreeCursor::index(root, KeyInfo::binary(columns));
let mut payload: Vec<u8> = Vec::with_capacity(512);
let mut fields: Vec<FieldSpan> = Vec::with_capacity(16);
let mut out = Vec::new();
let encoding = self.pager.text_encoding();
let mut more = cursor.first(&mut self.pager)?;
while more {
cursor.payload_into(&mut self.pager, &self.limits, &mut payload)?;
let header_len = RecordRef::parse_into(&payload, &self.limits, &mut fields)?;
let record = RecordRef::with_fields(&payload, &fields, header_len, encoding);
let mut row = Vec::with_capacity(columns);
for index in 0..columns {
row.push(owned_from_record(&record, index)?);
}
out.push(row);
more = cursor.next(&mut self.pager)?;
}
Ok(out)
}
}
fn owned_from_record(record: &RecordRef<'_>, index: usize) -> DbResult<OwnedDatum> {
Ok(match record.value(index)? {
Value::Null => OwnedDatum::Null,
Value::Integer(number) => OwnedDatum::Int(number),
Value::Real(number) => OwnedDatum::Real(number),
Value::Text(text) => OwnedDatum::Text(text.utf8_bytes().into_owned()),
Value::Blob(blob) => OwnedDatum::Blob(blob.raw().to_vec()),
})
}
fn text_at(record: &RecordRef<'_>, index: usize) -> DbResult<String> {
match record.value(index)? {
Value::Text(text) => Ok(String::from_utf8_lossy(&text.utf8_bytes()).into_owned()),
Value::Null => Ok(String::new()),
other => Err(corrupt(format!(
"expected text in schema field {index}, found {:?}",
other.storage_class()
))),
}
}
fn integer_at(record: &RecordRef<'_>, index: usize) -> DbResult<i64> {
match record.value(index)? {
Value::Integer(number) => Ok(number),
Value::Null => Ok(0),
other => Err(corrupt(format!(
"expected an integer in schema field {index}, found {:?}",
other.storage_class()
))),
}
}
pub fn borrow(row: &[OwnedDatum]) -> Vec<Datum<'_>> {
row.iter().map(OwnedDatum::borrow).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn object(sql: &str) -> SchemaObject {
SchemaObject {
kind: "table".to_string(),
name: "t".to_string(),
table: "t".to_string(),
root: 2,
sql: sql.to_string(),
}
}
#[test]
fn the_fixture_schema_parses() {
let names = object(
"CREATE TABLE main_table(id INTEGER PRIMARY KEY, key INTEGER NOT NULL, \
category INTEGER NOT NULL, label TEXT NOT NULL, payload BLOB)",
)
.column_names()
.unwrap();
assert_eq!(names, ["id", "key", "category", "label", "payload"]);
}
#[test]
fn table_constraints_are_not_columns() {
let names = object(
"CREATE TABLE t(a INTEGER, b TEXT, PRIMARY KEY (a, b), \
FOREIGN KEY (b) REFERENCES u(x), CHECK (a > 0))",
)
.column_names()
.unwrap();
assert_eq!(names, ["a", "b"]);
}
#[test]
fn parenthesised_types_stay_in_one_column() {
let names = object("CREATE TABLE t(a VARCHAR(20), b DECIMAL(10, 2), c INT)")
.column_names()
.unwrap();
assert_eq!(names, ["a", "b", "c"]);
}
#[test]
fn quoted_identifiers_are_unquoted() {
let names = object("CREATE TABLE t(\"a b\" INTEGER, `c` TEXT, [d] BLOB)")
.column_names()
.unwrap();
assert_eq!(names, ["a", "c", "d"]);
}
#[test]
fn a_missing_column_list_is_refused() {
assert!(object("CREATE TABLE t").column_names().is_err());
assert!(object("CREATE TABLE t)(").column_names().is_err());
assert!(object("CREATE TABLE t()").column_names().is_err());
}
}