makepad/libs/sqlite_query/tests/common/mod.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

194 lines
6.2 KiB
Rust

//! Shared test scaffolding: fixture databases built by the system `sqlite3`
//! CLI, and helpers that turn CLI output into our [`Value`] model so results
//! can be compared value-by-value rather than as formatted text.
//!
//! Every helper degrades gracefully when `sqlite3` is missing: tests call
//! [`have_sqlite3`] and skip.
#![allow(dead_code)]
use makepad_sqlite::Value;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// Tests in one binary run in parallel threads, and two `sqlite3` processes
/// opening the same WAL database at once can collide while one of them
/// rebuilds the shared-memory index ("database is locked"). CLI calls are
/// serialized here so the comparison harness never reports that as a diff.
fn cli_gate() -> &'static Mutex<()> {
static GATE: OnceLock<Mutex<()>> = OnceLock::new();
GATE.get_or_init(|| Mutex::new(()))
}
pub fn have_sqlite3() -> bool {
Command::new("sqlite3")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// A unique scratch directory that is removed when the guard drops.
pub struct Scratch {
pub dir: PathBuf,
}
impl Scratch {
pub fn new(tag: &str) -> Scratch {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"makepad-sqlite-{tag}-{}-{nonce}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("create scratch dir");
Scratch { dir }
}
pub fn path(&self, name: &str) -> PathBuf {
self.dir.join(name)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// Run SQL through the `sqlite3` CLI and return stdout. Panics on CLI failure
/// so a broken fixture is never mistaken for a passing test.
pub fn sqlite3(db: &Path, sql: &str) -> String {
let _guard = cli_gate().lock().unwrap_or_else(|e| e.into_inner());
let mut child = Command::new("sqlite3")
.arg(db)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn sqlite3");
child
.stdin
.take()
.expect("stdin")
.write_all(sql.as_bytes())
.expect("write sql");
let out = child.wait_with_output().expect("wait sqlite3");
assert!(
out.status.success(),
"sqlite3 failed:\n{}\nfor SQL:\n{sql}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
/// Query one column per row and return the parsed values (uses `quote()` so
/// blobs, NULLs and text survive the round trip).
pub fn sqlite3_column(db: &Path, sql: &str) -> Vec<Value> {
let out = sqlite3(db, &format!(".mode list\n.headers off\n{sql}\n"));
out.lines().map(parse_quoted).collect()
}
/// Parse one `quote()` rendering into a value.
pub fn parse_quoted(s: &str) -> Value {
let t = s.trim();
if t == "NULL" {
return Value::Null;
}
if (t.starts_with("X'") || t.starts_with("x'")) && t.ends_with('\'') {
let hex = &t[2..t.len() - 1];
let mut bytes = Vec::with_capacity(hex.len() / 2);
let hb = hex.as_bytes();
let mut i = 0;
while i + 1 < hb.len() {
bytes.push(u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(0));
i += 2;
}
return Value::Blob(bytes);
}
if t.starts_with('\'') && t.ends_with('\'') && t.len() >= 2 {
return Value::Text(t[1..t.len() - 1].replace("''", "'"));
}
if let Ok(i) = t.parse::<i64>() {
return Value::Integer(i);
}
if let Ok(f) = t.parse::<f64>() {
return Value::Real(f);
}
Value::Text(t.to_string())
}
/// Render a value the way `sqlite3`'s `quote()` does, for text comparisons.
pub fn quote(v: &Value) -> String {
match v {
Value::Null => "NULL".into(),
Value::Integer(i) => i.to_string(),
Value::Real(f) => {
let s = makepad_sqlite::value::format_real(*f);
if s.contains('.') || s.contains('e') || s.contains("Inf") {
s
} else {
format!("{s}.0")
}
}
Value::Text(t) => format!("'{}'", t.replace('\'', "''")),
Value::Blob(b) => {
let mut s = String::from("X'");
for byte in b {
s.push_str(&format!("{byte:02X}"));
}
s.push('\'');
s
}
}
}
/// Build a database with the CLI (fully checkpointed: no WAL left behind).
pub fn build_db(dir: &Path, name: &str, sql: &str) -> PathBuf {
let path = dir.join(name);
sqlite3(&path, sql);
path
}
/// Build a database whose newest rows live only in its `-wal` file: the CLI is
/// killed before it can checkpoint, exactly like a crashed writer.
pub fn build_wal_db(dir: &Path, name: &str, setup_sql: &str, wal_sql: &str) -> PathBuf {
let path = dir.join(name);
sqlite3(&path, &format!("PRAGMA journal_mode=WAL;\n{setup_sql}\n"));
let mut child = Command::new("sqlite3")
.arg(&path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn sqlite3");
let mut stdin = child.stdin.take().expect("stdin");
let stdout = child.stdout.take().expect("stdout");
stdin
.write_all(
format!("PRAGMA wal_autocheckpoint=0;\n{wal_sql}\nSELECT 'READY-MARKER';\n").as_bytes(),
)
.expect("write sql");
stdin.flush().expect("flush");
let mut reader = BufReader::new(stdout);
let mut saw_ready = false;
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 {
if line.contains("READY-MARKER") {
saw_ready = true;
break;
}
line.clear();
}
// SIGKILL: no checkpoint runs, so the frames stay in the -wal file.
let _ = child.kill();
let _ = child.wait();
assert!(saw_ready, "sqlite3 never reached the WAL marker");
path
}