A from-scratch, dependency-free SQLite implementation: file format reader and writer (b-tree read and write paths, pager, journal, WAL), a SQL lexer, parser and AST, a planner, and an executor — plus locking, integrity checking and a `sqlq` CLI. It exists because the asset store needs a database on every platform the app ships to, without a C toolchain in the build and without a system library whose version is somebody else's decision. The test suite is the argument: DML, DDL, concurrency, crash recovery, a query corpus and a DML fuzzer, all checked against real SQLite behaviour rather than against our own reading of the spec.
498 lines
17 KiB
Rust
498 lines
17 KiB
Rust
//! P0 acceptance: read files the system SQLite wrote — every page size, deep
|
|
//! b-trees, overflow chains, indexes and live WAL snapshots — and never panic
|
|
//! on a corrupt one.
|
|
|
|
mod common;
|
|
|
|
use common::*;
|
|
use makepad_sqlite::btree::{IndexCursor, TableCursor};
|
|
use makepad_sqlite::{Collation, Database, TextMode, Value};
|
|
use std::path::Path;
|
|
|
|
/// Scan a whole table with a cursor, materializing rows the way a query would.
|
|
fn scan_table(db: &mut Database, table: &str) -> Vec<(i64, Vec<Value>)> {
|
|
let root = db.schema().table(table).expect("table in schema").root_page;
|
|
let info = db.schema().table(table).unwrap().clone();
|
|
let (pager, _) = db.parts();
|
|
let mut cursor = TableCursor::new(root);
|
|
cursor.rewind(pager).expect("rewind");
|
|
let mut out = Vec::new();
|
|
while let Some(row) = cursor.next(pager).expect("scan") {
|
|
let vals = row.payload.values(pager, TextMode::Strict).expect("decode");
|
|
out.push((row.rowid, info.materialize(row.rowid, vals)));
|
|
}
|
|
out
|
|
}
|
|
|
|
const FIXTURE_SQL: &str = r#"
|
|
CREATE TABLE items(
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
kind TEXT,
|
|
score REAL,
|
|
payload BLOB,
|
|
flag INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE INDEX items_by_kind ON items(kind, id);
|
|
CREATE TABLE keyed(
|
|
k TEXT PRIMARY KEY,
|
|
v BLOB NOT NULL,
|
|
n INTEGER NOT NULL
|
|
);
|
|
WITH RECURSIVE seq(i) AS (VALUES(1) UNION ALL SELECT i+1 FROM seq WHERE i < 2000)
|
|
INSERT INTO items(id, name, kind, score, payload, flag)
|
|
SELECT i,
|
|
'name-' || i,
|
|
CASE i % 4 WHEN 0 THEN NULL ELSE 'kind-' || (i % 7) END,
|
|
CASE i % 3 WHEN 0 THEN NULL ELSE i * 1.5 END,
|
|
CASE WHEN i % 100 = 0 THEN randomblob(9000) ELSE randomblob(i % 40) END,
|
|
i % 2
|
|
FROM seq;
|
|
WITH RECURSIVE seq(i) AS (VALUES(1) UNION ALL SELECT i+1 FROM seq WHERE i < 500)
|
|
INSERT INTO keyed(k, v, n)
|
|
SELECT printf('key-%06d', i), randomblob(20), i FROM seq;
|
|
"#;
|
|
|
|
fn cli_rows(db: &Path, sql: &str) -> Vec<Vec<Value>> {
|
|
let out = sqlite3(db, &format!(".mode quote\n.headers off\n.separator |\n{sql}\n"));
|
|
out.lines()
|
|
.filter(|l| !l.trim().is_empty())
|
|
.map(|l| l.split('|').map(parse_quoted).collect())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn full_scan_matches_cli_across_page_sizes() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("scan");
|
|
for page_size in [512u32, 1024, 4096, 65536] {
|
|
let name = format!("p{page_size}.db");
|
|
let path = build_db(
|
|
&scratch.dir,
|
|
&name,
|
|
&format!("PRAGMA page_size={page_size};\n{FIXTURE_SQL}"),
|
|
);
|
|
let mut db = Database::open(&path).expect("open");
|
|
assert_eq!(db.pager().page_size() as u32, page_size);
|
|
|
|
for table in ["items", "keyed"] {
|
|
let ours = scan_table(&mut db, table);
|
|
let theirs = cli_rows(
|
|
&path,
|
|
&format!("SELECT * FROM {table} ORDER BY rowid;"),
|
|
);
|
|
assert_eq!(
|
|
ours.len(),
|
|
theirs.len(),
|
|
"row count for {table} at page size {page_size}"
|
|
);
|
|
for (i, ((_rowid, a), b)) in ours.iter().zip(theirs.iter()).enumerate() {
|
|
assert_eq!(a.len(), b.len(), "column count row {i} of {table}");
|
|
for (c, (x, y)) in a.iter().zip(b.iter()).enumerate() {
|
|
assert!(
|
|
x == y,
|
|
"row {i} col {c} of {table} at page size {page_size}: {} vs {}",
|
|
quote(x),
|
|
quote(y)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn btree_is_deep_and_has_overflow() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("deep");
|
|
let path = build_db(
|
|
&scratch.dir,
|
|
"deep.db",
|
|
&format!("PRAGMA page_size=512;\n{FIXTURE_SQL}"),
|
|
);
|
|
let mut db = Database::open(&path).expect("open");
|
|
// A 512-byte page fixture of 2000 rows must be more than two levels deep,
|
|
// and the 9000-byte blobs must live on overflow chains.
|
|
let root = db.schema().table("items").unwrap().root_page;
|
|
let (pager, _) = db.parts();
|
|
let mut cursor = TableCursor::new(root);
|
|
cursor.rewind(pager).unwrap();
|
|
let mut overflowing = 0;
|
|
let mut rows = 0;
|
|
while let Some(row) = cursor.next(pager).unwrap() {
|
|
if !row.payload.is_local() {
|
|
overflowing += 1;
|
|
let bytes = row.payload.read(pager).unwrap();
|
|
assert_eq!(bytes.len(), row.payload.total_size());
|
|
}
|
|
rows += 1;
|
|
}
|
|
assert_eq!(rows, 2000);
|
|
assert!(overflowing >= 20, "expected overflow rows, got {overflowing}");
|
|
assert!(pager.page_count() > 500);
|
|
}
|
|
|
|
#[test]
|
|
fn rowid_seek_finds_every_row() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("seek");
|
|
let path = build_db(
|
|
&scratch.dir,
|
|
"seek.db",
|
|
&format!("PRAGMA page_size=1024;\n{FIXTURE_SQL}"),
|
|
);
|
|
let mut db = Database::open(&path).expect("open");
|
|
let root = db.schema().table("items").unwrap().root_page;
|
|
let (pager, _) = db.parts();
|
|
let mut cursor = TableCursor::new(root);
|
|
for id in [1i64, 2, 999, 1000, 1001, 2000] {
|
|
let row = cursor.seek_exact(pager, id).unwrap();
|
|
assert!(row.is_some(), "rowid {id} not found");
|
|
assert_eq!(row.unwrap().rowid, id);
|
|
}
|
|
for id in [-5i64, 0, 2001, i64::MAX] {
|
|
assert!(cursor.seek_exact(pager, id).unwrap().is_none());
|
|
}
|
|
// seek_ge lands on the first row at or after the target
|
|
cursor.seek_ge(pager, 1500).unwrap();
|
|
assert_eq!(cursor.next(pager).unwrap().unwrap().rowid, 1500);
|
|
}
|
|
|
|
#[test]
|
|
fn index_seek_matches_table_contents() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("index");
|
|
let path = build_db(
|
|
&scratch.dir,
|
|
"index.db",
|
|
&format!("PRAGMA page_size=1024;\n{FIXTURE_SQL}"),
|
|
);
|
|
let mut db = Database::open(&path).expect("open");
|
|
|
|
// Automatic index on keyed(k): seek to a key and check the payload rowid
|
|
// points at the row the CLI reports.
|
|
let table = db.schema().table("keyed").unwrap().clone();
|
|
let idx = table
|
|
.indexes
|
|
.iter()
|
|
.find(|i| i.name.starts_with("sqlite_autoindex_keyed"))
|
|
.expect("auto index for keyed")
|
|
.clone();
|
|
let (pager, _) = db.parts();
|
|
let mut cursor = IndexCursor::new(idx.root_page);
|
|
let target = vec![Value::text("key-000250")];
|
|
cursor.seek_ge(pager, &target, &[Collation::Binary]).unwrap();
|
|
let entry = cursor.next(pager).unwrap().expect("entry");
|
|
let vals = entry.values(pager, TextMode::Strict).unwrap();
|
|
assert_eq!(vals[0].as_text(), Some("key-000250"));
|
|
let rowid = vals.last().unwrap().as_integer().expect("trailing rowid");
|
|
|
|
let mut table_cursor = TableCursor::new(table.root_page);
|
|
let row = table_cursor.seek_exact(pager, rowid).unwrap().expect("row");
|
|
let row_vals = row.payload.values(pager, TextMode::Strict).unwrap();
|
|
assert_eq!(row_vals[0].as_text(), Some("key-000250"));
|
|
|
|
// Walking the index from the start yields keys in sorted order.
|
|
let mut cursor = IndexCursor::new(idx.root_page);
|
|
cursor.rewind(pager).unwrap();
|
|
let mut prev: Option<String> = None;
|
|
let mut count = 0;
|
|
while let Some(entry) = cursor.next(pager).unwrap() {
|
|
let vals = entry.values(pager, TextMode::Strict).unwrap();
|
|
let k = vals[0].as_text().unwrap().to_string();
|
|
if let Some(p) = &prev {
|
|
assert!(p < &k, "index out of order: {p} then {k}");
|
|
}
|
|
prev = Some(k);
|
|
count += 1;
|
|
}
|
|
assert_eq!(count, 500);
|
|
|
|
// A two-column index with a prefix seek.
|
|
let items = db.schema().table("items").unwrap().clone();
|
|
let by_kind = items
|
|
.indexes
|
|
.iter()
|
|
.find(|i| i.name == "items_by_kind")
|
|
.expect("items_by_kind")
|
|
.clone();
|
|
let (pager, _) = db.parts();
|
|
let mut cursor = IndexCursor::new(by_kind.root_page);
|
|
cursor
|
|
.seek_ge(pager, &[Value::text("kind-3")], &[Collation::Binary])
|
|
.unwrap();
|
|
let mut seen = 0;
|
|
while let Some(entry) = cursor.next(pager).unwrap() {
|
|
let vals = entry.values(pager, TextMode::Strict).unwrap();
|
|
match vals[0].as_text() {
|
|
Some("kind-3") => seen += 1,
|
|
_ => break,
|
|
}
|
|
}
|
|
let cli = sqlite3(
|
|
&path,
|
|
".mode list\nSELECT count(*) FROM items WHERE kind = 'kind-3';\n",
|
|
);
|
|
assert_eq!(seen, cli.trim().parse::<i64>().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn wal_snapshot_is_read() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("wal");
|
|
let path = build_wal_db(
|
|
&scratch.dir,
|
|
"wal.db",
|
|
"CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT NOT NULL);
|
|
INSERT INTO t(v) VALUES('checkpointed');",
|
|
"BEGIN;
|
|
INSERT INTO t(v) VALUES('in-wal-1');
|
|
INSERT INTO t(v) VALUES('in-wal-2');
|
|
COMMIT;
|
|
INSERT INTO t(v) VALUES('in-wal-3');
|
|
CREATE TABLE later(x TEXT);
|
|
INSERT INTO later VALUES('after-schema-change');",
|
|
);
|
|
let wal = path.with_file_name("wal.db-wal");
|
|
assert!(wal.exists(), "fixture left no WAL behind");
|
|
assert!(std::fs::metadata(&wal).unwrap().len() > 0);
|
|
|
|
let mut db = Database::open(&path).expect("open");
|
|
assert!(db.pager().wal_frames() > 0, "no WAL frames were accepted");
|
|
let rows = scan_table(&mut db, "t");
|
|
let texts: Vec<&str> = rows
|
|
.iter()
|
|
.map(|(_, v)| v[1].as_text().unwrap_or(""))
|
|
.collect();
|
|
assert_eq!(
|
|
texts,
|
|
vec![
|
|
"checkpointed",
|
|
"in-wal-1",
|
|
"in-wal-2",
|
|
"in-wal-3",
|
|
]
|
|
);
|
|
// A table created inside the WAL is visible through the schema too.
|
|
let later = scan_table(&mut db, "later");
|
|
assert_eq!(later.len(), 1);
|
|
assert_eq!(later[0].1[0].as_text(), Some("after-schema-change"));
|
|
|
|
// ... and the CLI agrees, reading the same files.
|
|
let cli = sqlite3(&path, ".mode list\nSELECT count(*) FROM t;\n");
|
|
assert_eq!(cli.trim(), "4");
|
|
}
|
|
|
|
#[test]
|
|
fn wal_ignores_a_torn_tail() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("waltorn");
|
|
let path = build_wal_db(
|
|
&scratch.dir,
|
|
"torn.db",
|
|
"CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT);
|
|
INSERT INTO t(v) VALUES('base');",
|
|
"INSERT INTO t(v) VALUES('committed');",
|
|
);
|
|
let wal = path.with_file_name("torn.db-wal");
|
|
let before = {
|
|
let mut db = Database::open(&path).unwrap();
|
|
scan_table(&mut db, "t").len()
|
|
};
|
|
assert_eq!(before, 2);
|
|
|
|
// Append a frame-sized block of garbage: the checksum chain must reject it.
|
|
let mut bytes = std::fs::read(&wal).unwrap();
|
|
let page_size = 4096usize;
|
|
bytes.extend(std::iter::repeat(0xA5).take(24 + page_size));
|
|
std::fs::write(&wal, &bytes).unwrap();
|
|
|
|
let mut db = Database::open(&path).unwrap();
|
|
assert_eq!(scan_table(&mut db, "t").len(), before);
|
|
|
|
// Truncating mid-frame must not lose the committed snapshot either.
|
|
let mut bytes = std::fs::read(&wal).unwrap();
|
|
bytes.truncate(bytes.len() - 17);
|
|
std::fs::write(&wal, &bytes).unwrap();
|
|
let mut db = Database::open(&path).unwrap();
|
|
assert_eq!(scan_table(&mut db, "t").len(), before);
|
|
}
|
|
|
|
/// Deterministic xorshift so a failing corruption case can be replayed.
|
|
struct Rng(u64);
|
|
impl Rng {
|
|
fn next(&mut self) -> u64 {
|
|
let mut x = self.0;
|
|
x ^= x << 13;
|
|
x ^= x >> 7;
|
|
x ^= x << 17;
|
|
self.0 = x;
|
|
x
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn corrupt_bytes_never_panic() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("corrupt");
|
|
let path = build_db(
|
|
&scratch.dir,
|
|
"good.db",
|
|
&format!("PRAGMA page_size=1024;\n{FIXTURE_SQL}"),
|
|
);
|
|
let good = std::fs::read(&path).unwrap();
|
|
let mut rng = Rng(0x5eed_1234_9876_abcd);
|
|
let victim = scratch.path("victim.db");
|
|
let mut opened = 0;
|
|
for round in 0..200 {
|
|
let mut bytes = good.clone();
|
|
let flips = 1 + (rng.next() % 16) as usize;
|
|
for _ in 0..flips {
|
|
let at = (rng.next() as usize) % bytes.len();
|
|
bytes[at] ^= 1u8 << (rng.next() % 8);
|
|
}
|
|
std::fs::write(&victim, &bytes).unwrap();
|
|
// Any outcome is fine except a panic, which fails the test outright.
|
|
if let Ok(mut db) = Database::open(&victim) {
|
|
opened += 1;
|
|
let tables: Vec<String> = db
|
|
.schema()
|
|
.tables
|
|
.iter()
|
|
.map(|t| t.name.clone())
|
|
.collect();
|
|
for name in tables {
|
|
let Some(info) = db.schema().table(&name).cloned() else {
|
|
continue;
|
|
};
|
|
if info.root_page == 0 {
|
|
continue;
|
|
}
|
|
let (pager, _) = db.parts();
|
|
let mut cursor = TableCursor::new(info.root_page);
|
|
if cursor.rewind(pager).is_err() {
|
|
continue;
|
|
}
|
|
let mut budget = 100_000;
|
|
loop {
|
|
budget -= 1;
|
|
if budget == 0 {
|
|
panic!("cursor did not terminate on round {round}");
|
|
}
|
|
match cursor.next(pager) {
|
|
Ok(Some(row)) => {
|
|
let _ = row.payload.values(pager, TextMode::Strict);
|
|
}
|
|
Ok(None) => break,
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert!(opened > 0, "every corrupted file failed to open; test is vacuous");
|
|
}
|
|
|
|
#[test]
|
|
fn header_validation_rejects_junk() {
|
|
let scratch = Scratch::new("hdr");
|
|
let path = scratch.path("junk.db");
|
|
std::fs::write(&path, vec![0u8; 4096]).unwrap();
|
|
assert!(Database::open(&path).is_err());
|
|
std::fs::write(&path, b"not a database at all").unwrap();
|
|
assert!(Database::open(&path).is_err());
|
|
std::fs::write(&path, b"").unwrap();
|
|
assert!(Database::open(&path).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn live_catalog_copy_smoke() {
|
|
// Runs only where the asset-store copy exists; skipped elsewhere.
|
|
let copy = Path::new(
|
|
"/private/tmp/claude-501/-Users-admin-makepad-makepad/9ffb7a56-6354-42a8-8256-89ffed8580ec/scratchpad/store-copy/catalog.sqlite3",
|
|
);
|
|
if !copy.exists() || !have_sqlite3() {
|
|
return;
|
|
}
|
|
let mut db = Database::open(copy).expect("open catalog copy");
|
|
assert_eq!(db.user_version(), 8);
|
|
for t in &db.schema().tables {
|
|
assert!(t.unsupported.is_none(), "{} -> {:?}", t.name, t.unsupported);
|
|
}
|
|
for table in ["assets", "asset_aliases", "search_annotations", "blobs"] {
|
|
let ours = scan_table(&mut db, table).len();
|
|
let cli = sqlite3(copy, &format!(".mode list\nSELECT count(*) FROM {table};\n"));
|
|
assert_eq!(
|
|
ours.to_string(),
|
|
cli.trim(),
|
|
"row count mismatch for {table}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn integrity_check_passes_on_files_sqlite_wrote() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("integrity");
|
|
for page_size in [512u32, 4096] {
|
|
let path = build_db(
|
|
&scratch.dir,
|
|
&format!("i{page_size}.db"),
|
|
&format!("PRAGMA page_size={page_size};\n{FIXTURE_SQL}\nDELETE FROM items WHERE id % 7 = 0;\nDELETE FROM keyed WHERE n % 5 = 0;\n"),
|
|
);
|
|
let mut db = Database::open(&path).unwrap();
|
|
let (pager, schema) = db.parts();
|
|
let report = makepad_sqlite::integrity::check(pager, schema, true).unwrap();
|
|
assert!(
|
|
report.ok(),
|
|
"our checker found problems in a file sqlite wrote at page size {page_size}:\n{}",
|
|
report.problems.join("\n")
|
|
);
|
|
assert!(report.rows > 0 && report.index_entries > 0);
|
|
// and the CLI agrees the file is fine
|
|
let out = sqlite3(&path, "PRAGMA integrity_check;\n");
|
|
assert_eq!(out.trim(), "ok");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn integrity_check_catches_damage() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("damage");
|
|
let path = build_db(&scratch.dir, "d.db", &format!("PRAGMA page_size=1024;\n{FIXTURE_SQL}"));
|
|
let mut bytes = std::fs::read(&path).unwrap();
|
|
// Point the root of the first index at a table page.
|
|
let victim = scratch.path("damaged.db");
|
|
for at in [1024 * 3 + 8, 1024 * 5 + 12, 1024 * 9 + 20] {
|
|
if at < bytes.len() {
|
|
bytes[at] ^= 0x55;
|
|
}
|
|
}
|
|
std::fs::write(&victim, &bytes).unwrap();
|
|
let Ok(mut db) = Database::open(&victim) else {
|
|
return; // refusing to open is also a clean outcome
|
|
};
|
|
let (pager, schema) = db.parts();
|
|
match makepad_sqlite::integrity::check(pager, schema, true) {
|
|
Ok(report) => assert!(!report.ok(), "damage went unnoticed"),
|
|
Err(_) => {}
|
|
}
|
|
}
|