#![cfg(feature = "std")]
use graphitesql::{Connection, Value};
use std::collections::BTreeMap;
use std::process::Command;
fn sqlite3_available() -> bool {
Command::new("sqlite3").arg("--version").output().is_ok()
}
fn graphite_check(c: &Connection) -> String {
match &c.query("PRAGMA integrity_check").unwrap().rows[0][0] {
Value::Text(s) => String::from(s.as_str()),
_ => "?".into(),
}
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn range(&mut self, lo: usize, hi: usize) -> usize {
lo + (self.next() as usize) % (hi - lo + 1)
}
fn pick(&mut self, choices: &[usize]) -> usize {
choices[(self.next() as usize) % choices.len()]
}
}
fn build(seed: u64, iters: usize, wal: bool) -> (String, BTreeMap<i64, String>) {
let mut rng = Rng(seed);
let mut sql = String::new();
if wal {
sql.push_str("PRAGMA journal_mode=WAL;\n");
}
sql.push_str("CREATE TABLE docs(id INTEGER PRIMARY KEY, t TEXT, b TEXT, tag TEXT);\n");
let mut committed: BTreeMap<i64, String> = BTreeMap::new();
let mut pending: BTreeMap<i64, String> = BTreeMap::new();
let mut live: std::collections::VecDeque<i64> = Default::default();
let mut live_at_begin = live.clone();
let mut nk: i64 = 1;
let mut open = false;
let tags = ['a', 'b', 'c', 'd', 'e'];
for it in 0..iters {
if it % 20 == 0 {
sql.push_str("BEGIN;\n");
pending = committed.clone();
live_at_begin = live.clone();
open = true;
}
for _ in 0..rng.range(1, 3) {
let blen = rng.pick(&[10, 100, 800]);
let body = format!("body {nk} {}", "z".repeat(blen));
let tag = tags[rng.range(0, 4)];
let title = format!("title {nk}");
sql.push_str(&format!(
"INSERT INTO docs VALUES({nk},'{title}','{body}','{tag}');\n"
));
pending.insert(nk, body);
live.push_back(nk);
nk += 1;
}
if live.len() > 40 {
for _ in 0..rng.range(1, 5) {
if let Some(old) = live.pop_front() {
sql.push_str(&format!("DELETE FROM docs WHERE id={old};\n"));
pending.remove(&old);
}
}
}
if it % 20 == 19 {
if it % 40 == 39 {
sql.push_str("ROLLBACK;\n");
live = live_at_begin.clone();
} else {
sql.push_str("COMMIT;\n");
committed = pending.clone();
}
open = false;
}
}
if open {
sql.push_str("COMMIT;\n");
committed = pending.clone();
}
(sql, committed)
}
fn scenario(name: &str, seed: u64, iters: usize, wal: bool) {
let dir = std::env::temp_dir();
let gpath = dir
.join(format!("gsql-rbchurn-{name}-{}.db", std::process::id()))
.to_string_lossy()
.into_owned();
let _ = std::fs::remove_file(&gpath);
let _ = std::fs::remove_file(format!("{gpath}-wal"));
let (sql, expected) = build(seed, iters, wal);
{
let mut c = Connection::create(&gpath).unwrap();
for stmt in sql.split_inclusive(';') {
let s = stmt.trim();
if s.is_empty() {
continue;
}
c.execute(s)
.unwrap_or_else(|e| panic!("{name}: statement `{s}` failed: {e}"));
}
assert_eq!(graphite_check(&c), "ok", "{name}: graphite integrity_check");
let res = c.query("SELECT id, b FROM docs ORDER BY id").unwrap();
let db_ids: std::collections::BTreeSet<i64> = res
.rows
.iter()
.map(|r| match &r[0] {
Value::Integer(i) => *i,
_ => -1,
})
.collect();
let exp_ids: std::collections::BTreeSet<i64> = expected.keys().copied().collect();
let only_db: Vec<_> = db_ids.difference(&exp_ids).collect();
let only_exp: Vec<_> = exp_ids.difference(&db_ids).collect();
assert_eq!(
res.rows.len(),
expected.len(),
"{name}: surviving row count (in DB not expected: {only_db:?}; in expected not DB: {only_exp:?})"
);
for (row, (id, body)) in res.rows.iter().zip(expected.iter()) {
let gid = match &row[0] {
Value::Integer(i) => *i,
other => panic!("{name}: id not integer: {other:?}"),
};
let gb = match &row[1] {
Value::Text(s) => String::from(s.as_str()),
other => panic!("{name}: b unexpected: {other:?}"),
};
assert_eq!(gid, *id, "{name}: id mismatch");
assert_eq!(&gb, body, "{name}: body mismatch for id={id}");
}
}
if sqlite3_available() {
let qc = Command::new("sqlite3")
.arg(&gpath)
.arg("PRAGMA quick_check;")
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&qc.stdout).trim(),
"ok",
"{name}: sqlite3 quick_check"
);
let spath = dir
.join(format!("sqlite-rbchurn-{name}-{}.db", std::process::id()))
.to_string_lossy()
.into_owned();
let _ = std::fs::remove_file(&spath);
let _ = std::fs::remove_file(format!("{spath}-wal"));
let load = Command::new("sqlite3")
.arg(&spath)
.arg(&sql)
.output()
.unwrap();
assert!(
load.status.success(),
"{name}: sqlite3 load failed: {}",
String::from_utf8_lossy(&load.stderr)
);
let g = Command::new("sqlite3")
.arg(&gpath)
.arg("SELECT id, b FROM docs ORDER BY id;")
.output()
.unwrap();
let s = Command::new("sqlite3")
.arg(&spath)
.arg("SELECT id, b FROM docs ORDER BY id;")
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&g.stdout),
String::from_utf8_lossy(&s.stdout),
"{name}: row set differs from sqlite3"
);
let _ = std::fs::remove_file(&spath);
let _ = std::fs::remove_file(format!("{spath}-wal"));
}
let _ = std::fs::remove_file(&gpath);
let _ = std::fs::remove_file(format!("{gpath}-wal"));
}
#[test]
fn rollback_after_growth_then_insert_seed11() {
scenario("seed11", 11, 42, false);
}
#[test]
fn rollback_churn_multiple_seeds() {
for seed in [1, 7, 23, 99] {
scenario(&format!("s{seed}"), seed, 60, false);
}
}
#[test]
fn rollback_churn_wal_mode() {
for seed in [11, 23] {
scenario(&format!("wal{seed}"), seed, 60, true);
}
}
#[test]
fn rolled_back_growth_leaves_no_trace() {
let dir = std::env::temp_dir();
let path = dir
.join(format!("gsql-rbtrace-{}.db", std::process::id()))
.to_string_lossy()
.into_owned();
let _ = std::fs::remove_file(&path);
let big = "q".repeat(4000); {
let mut c = Connection::create(&path).unwrap();
c.execute("CREATE TABLE t(id INTEGER PRIMARY KEY, b TEXT)")
.unwrap();
c.execute("BEGIN").unwrap();
for i in 1..=30 {
c.execute(&format!("INSERT INTO t VALUES({i},'{big}')"))
.unwrap();
}
for i in 1..=20 {
c.execute(&format!("DELETE FROM t WHERE id={i}")).unwrap();
}
c.execute("COMMIT").unwrap();
c.execute("BEGIN").unwrap();
for i in 100..=140 {
c.execute(&format!("INSERT INTO t VALUES({i},'{big}')"))
.unwrap();
}
c.execute("ROLLBACK").unwrap();
c.execute("BEGIN").unwrap();
for i in 200..=210 {
c.execute(&format!("INSERT INTO t VALUES({i},'{big}')"))
.unwrap();
}
c.execute("COMMIT").unwrap();
assert_eq!(graphite_check(&c), "ok", "integrity after rollback+commit");
let rolled = c
.query("SELECT count(*) FROM t WHERE id BETWEEN 100 AND 140")
.unwrap();
assert_eq!(
rolled.rows[0][0],
Value::Integer(0),
"rolled-back rows leaked"
);
let kept = c.query("SELECT count(*) FROM t WHERE id >= 200").unwrap();
assert_eq!(kept.rows[0][0], Value::Integer(11), "committed rows lost");
let base = c
.query("SELECT count(*) FROM t WHERE id BETWEEN 21 AND 30")
.unwrap();
assert_eq!(base.rows[0][0], Value::Integer(10), "baseline rows lost");
}
if sqlite3_available() {
let out = Command::new("sqlite3")
.arg(&path)
.arg("PRAGMA quick_check;")
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ok");
}
let _ = std::fs::remove_file(&path);
}