makepad/libs/sqlite_query/tests/fuzz_dml.rs
Admin 19c37a3df8 libs/sqlite_query: an SQLite engine of our own
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.
2026-08-23 00:43:20 +02:00

163 lines
6.7 KiB
Rust

//! Differential DML fuzzing: the same random sequence of INSERT / UPDATE /
//! DELETE / upsert statements is applied by this engine and by the system
//! `sqlite3` CLI to two copies of the same database. After every batch the two
//! files must contain exactly the same rows, and ours must still pass
//! `PRAGMA integrity_check` — run by SQLite, on the file we wrote.
mod common;
use common::*;
use makepad_sqlite::Connection;
use std::path::Path;
use std::time::Duration;
const SCHEMA: &str = "CREATE TABLE t(id INTEGER PRIMARY KEY, k TEXT UNIQUE, n INTEGER, b BLOB);\
CREATE INDEX t_by_n ON t(n);";
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
}
fn below(&mut self, n: u64) -> u64 {
self.next() % n.max(1)
}
}
fn gen_statement(rng: &mut Rng) -> String {
let id = rng.below(40) + 1;
let key = format!("k{}", rng.below(25));
let n = rng.below(100) as i64 - 20;
let blob = format!("x'{}'", "ab".repeat((rng.below(6) + 1) as usize));
match rng.below(12) {
0 => format!("INSERT OR IGNORE INTO t(id, k, n, b) VALUES ({id}, '{key}', {n}, {blob})"),
1 => format!("INSERT OR REPLACE INTO t(id, k, n, b) VALUES ({id}, '{key}', {n}, {blob})"),
2 => format!(
"INSERT INTO t(k, n) VALUES ('{key}', {n}) ON CONFLICT(k) DO UPDATE SET n = n + 1"
),
3 => format!(
"INSERT INTO t(k, n) VALUES ('{key}', {n}) ON CONFLICT(k) DO NOTHING"
),
4 | 5 => format!("INSERT OR IGNORE INTO t(k, n, b) VALUES ('{key}', {n}, {blob})"),
6 => format!("UPDATE t SET n = n + {n} WHERE id = {id}"),
7 => format!("UPDATE t SET b = {blob} WHERE n > {n}"),
8 => format!("UPDATE OR IGNORE t SET k = '{key}' WHERE id = {id}"),
9 => format!("DELETE FROM t WHERE id = {id}"),
10 => format!("DELETE FROM t WHERE n < {n}"),
_ => format!("DELETE FROM t WHERE k = '{key}'"),
}
}
/// Rows of `t` as the CLI renders them, so both files are compared identically.
fn dump(path: &Path) -> String {
sqlite3(
path,
".mode quote\n.headers off\n.separator |\nSELECT id, k, n, quote(b) FROM t ORDER BY id;\n",
)
}
fn apply_to_cli(path: &Path, sql: &str) -> bool {
let out = std::process::Command::new("sqlite3")
.arg(path)
.arg(sql)
.output()
.expect("sqlite3");
out.status.success()
}
#[test]
fn random_dml_matches_sqlite() {
if !have_sqlite3() {
return;
}
let scratch = Scratch::new("fuzz-dml");
let ours = scratch.path("ours.db");
let theirs = scratch.path("theirs.db");
{
let mut db = Connection::open(&ours, Duration::from_secs(5)).expect("open");
db.execute_batch(SCHEMA).expect("schema");
}
sqlite3(&theirs, SCHEMA);
let mut rng = Rng(0x1234_5678_9abc_def0);
let mut db = Connection::open(&ours, Duration::from_secs(5)).expect("open");
let mut applied = 0;
let mut skipped = 0;
for round in 0..300 {
let sql = gen_statement(&mut rng);
let our_result = db.execute(&sql, &[]);
let their_ok = apply_to_cli(&theirs, &sql);
match (&our_result, their_ok) {
(Ok(_), true) => applied += 1,
(Err(_), false) => skipped += 1,
(Ok(_), false) => panic!("round {round}: sqlite3 rejected {sql:?} but we accepted it"),
(Err(e), true) => {
panic!("round {round}: we rejected {sql:?} ({e}) but sqlite3 accepted it")
}
}
if round % 10 == 0 {
// Compare through the CLI so both sides are rendered the same way.
let a = dump(&ours);
let b = dump(&theirs);
assert_eq!(
a, b,
"round {round} diverged after {sql:?}\nours:\n{a}\ntheirs:\n{b}"
);
assert_eq!(
sqlite3(&ours, "PRAGMA integrity_check;\n").trim(),
"ok",
"round {round}: our file is damaged after {sql:?}"
);
}
}
assert_eq!(dump(&ours), dump(&theirs), "final state diverged");
assert!(applied > 100, "only {applied} statements applied");
let _ = skipped;
}
#[test]
fn upsert_and_replace_match_sqlite_exactly() {
if !have_sqlite3() {
return;
}
// The store's real upsert shapes, applied to both engines in order.
let scratch = Scratch::new("fuzz-upsert");
let ours = scratch.path("ours.db");
let theirs = scratch.path("theirs.db");
let schema = "CREATE TABLE asset_aliases(alias TEXT PRIMARY KEY, asset_id BLOB NOT NULL, head_revision BLOB NOT NULL, updated_ms INTEGER NOT NULL);\
CREATE INDEX asset_aliases_by_asset ON asset_aliases(asset_id, alias);";
{
let mut db = Connection::open(&ours, Duration::from_secs(5)).unwrap();
db.execute_batch(schema).unwrap();
}
sqlite3(&theirs, schema);
let statements = [
"INSERT INTO asset_aliases(alias, asset_id, head_revision, updated_ms) VALUES('a', x'01', x'aa', 1) ON CONFLICT(alias) DO UPDATE SET asset_id=x'01', head_revision=x'aa', updated_ms=1",
"INSERT INTO asset_aliases(alias, asset_id, head_revision, updated_ms) VALUES('a', x'02', x'bb', 2) ON CONFLICT(alias) DO UPDATE SET asset_id=x'02', head_revision=x'bb', updated_ms=2",
"INSERT INTO asset_aliases(alias, asset_id, head_revision, updated_ms) VALUES('b', x'03', x'cc', 3) ON CONFLICT(alias) DO UPDATE SET asset_id=x'03', head_revision=x'cc', updated_ms=3",
"DELETE FROM asset_aliases WHERE asset_id = x'02' AND head_revision = x'bb'",
"INSERT INTO asset_aliases(alias, asset_id, head_revision, updated_ms) VALUES('c', x'04', x'dd', 4)",
"INSERT OR IGNORE INTO asset_aliases(alias, asset_id, head_revision, updated_ms) VALUES('c', x'05', x'ee', 5)",
"INSERT OR REPLACE INTO asset_aliases(alias, asset_id, head_revision, updated_ms) VALUES('c', x'06', x'ff', 6)",
"UPDATE asset_aliases SET updated_ms = updated_ms + 10 WHERE alias >= 'b'",
"DELETE FROM asset_aliases WHERE alias = 'zzz'",
];
let mut db = Connection::open(&ours, Duration::from_secs(5)).unwrap();
for (i, sql) in statements.iter().enumerate() {
db.execute(sql, &[]).unwrap_or_else(|e| panic!("{sql}: {e}"));
assert!(apply_to_cli(&theirs, sql), "sqlite3 rejected {sql}");
let q = ".mode quote\n.headers off\n.separator |\nSELECT alias, quote(asset_id), quote(head_revision), updated_ms FROM asset_aliases ORDER BY alias;\n";
assert_eq!(
sqlite3(&ours, q),
sqlite3(&theirs, q),
"statement {i} diverged: {sql}"
);
}
assert_eq!(sqlite3(&ours, "PRAGMA integrity_check;\n").trim(), "ok");
}