#![cfg(feature = "std")]
#![cfg(feature = "fts5")]
use graphitesql::Connection;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
fn tmp_path(tag: &str) -> String {
static SEQ: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"gsql-fts5-repl-{}-{}-{}.db",
tag,
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
let p = p.to_string_lossy().into_owned();
let _ = std::fs::remove_file(&p);
p
}
fn have_fts5_sqlite() -> bool {
let o = Command::new("sqlite3")
.arg(":memory:")
.arg("CREATE VIRTUAL TABLE t USING fts5(a); SELECT 1;")
.output();
matches!(o, Ok(o) if o.status.success())
}
fn sqlite_raw(path: &str, q: &str) -> String {
let o = Command::new("sqlite3").arg(path).arg(q).output().unwrap();
assert!(
o.status.success(),
"sqlite3 failed for {q:?}: {}",
String::from_utf8_lossy(&o.stderr)
);
String::from_utf8_lossy(&o.stdout).trim_end().to_string()
}
fn sqlite_quick_check_ok(path: &str) -> bool {
sqlite_raw(path, "PRAGMA quick_check;") == "ok"
}
fn sqlite_integrity_ok(path: &str) -> bool {
Command::new("sqlite3")
.arg(path)
.arg("INSERT INTO ft(ft) VALUES('integrity-check');")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn dump_shadows_sqlite(path: &str) -> String {
let data = sqlite_raw(path, "SELECT id, quote(block) FROM ft_data ORDER BY id;");
let idx = sqlite_raw(
path,
"SELECT segid, quote(term), pgno FROM ft_idx ORDER BY segid, term, pgno;",
);
let ds = sqlite_raw(path, "SELECT id, quote(sz) FROM ft_docsize ORDER BY id;");
format!("DATA\n{data}\nIDX\n{idx}\nDOCSIZE\n{ds}")
}
fn dump_shadows_graphite(c: &Connection) -> String {
let fmt = |r: graphitesql::QueryResult| -> String {
r.rows
.iter()
.map(|row| {
row.iter()
.map(|v| match v {
graphitesql::Value::Integer(i) => i.to_string(),
graphitesql::Value::Text(t) => String::from(t.as_str()),
graphitesql::Value::Null => String::new(),
other => format!("{other:?}"),
})
.collect::<Vec<_>>()
.join("|")
})
.collect::<Vec<_>>()
.join("\n")
};
let data = fmt(c
.query("SELECT id, quote(block) FROM ft_data ORDER BY id")
.unwrap());
let idx = fmt(c
.query("SELECT segid, quote(term), pgno FROM ft_idx ORDER BY segid, term, pgno")
.unwrap());
let ds = fmt(c
.query("SELECT id, quote(sz) FROM ft_docsize ORDER BY id")
.unwrap());
format!("DATA\n{data}\nIDX\n{idx}\nDOCSIZE\n{ds}")
}
fn graphite_match(c: &Connection, term: &str) -> Vec<i64> {
c.query(&format!(
"SELECT rowid FROM ft WHERE ft MATCH '{term}' ORDER BY rowid"
))
.unwrap()
.rows
.iter()
.map(|r| match &r[0] {
graphitesql::Value::Integer(i) => *i,
_ => -1,
})
.collect()
}
fn sqlite_match(path: &str, term: &str) -> Vec<i64> {
sqlite_raw(
path,
&format!("SELECT rowid FROM ft WHERE ft MATCH '{term}' ORDER BY rowid;"),
)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.parse().unwrap())
.collect()
}
fn assert_replace_valid(
tag: &str,
ddl: &str,
stmts: &[&str],
probe_terms: &[&str],
byte_exact: bool,
) {
let g = tmp_path(&format!("{tag}-g"));
let s = tmp_path(&format!("{tag}-s"));
let mut c = Connection::create(&g).unwrap();
c.execute(ddl).unwrap();
for st in stmts {
c.execute(st).unwrap();
}
let mut script = format!("{ddl};\n");
for st in stmts {
script.push_str(st);
script.push_str(";\n");
}
sqlite_raw(&s, &script);
let gic = c.query("PRAGMA integrity_check").unwrap();
let gic_ok = gic.rows.len() == 1
&& matches!(&gic.rows[0][0], graphitesql::Value::Text(t) if t.as_str() == "ok");
assert!(gic_ok, "graphite integrity_check not ok for {tag}: {gic:?}");
assert!(
sqlite_quick_check_ok(&g),
"sqlite quick_check rejected graphite's file for {tag}"
);
assert!(
sqlite_integrity_ok(&g),
"sqlite FTS5 integrity-check rejected graphite's file for {tag}"
);
for term in probe_terms {
assert_eq!(
graphite_match(&c, term),
sqlite_match(&s, term),
"MATCH '{term}' rows diverge for {tag}"
);
}
if byte_exact {
assert_eq!(
dump_shadows_graphite(&c),
dump_shadows_sqlite(&s),
"shadow-table bytes diverge for {tag}"
);
}
}
const DDL1: &str = "CREATE VIRTUAL TABLE ft USING fts5(x)";
#[test]
fn insert_or_replace_conflict_tombstones_old_terms() {
if !have_fts5_sqlite() {
return;
}
assert_replace_valid(
"ior-one",
DDL1,
&[
"INSERT INTO ft(rowid,x) VALUES(1,'hello')",
"INSERT OR REPLACE INTO ft(rowid,x) VALUES(1,'world')",
],
&["hello", "world"],
true,
);
}
#[test]
fn replace_into_conflict_tombstones_old_terms() {
if !have_fts5_sqlite() {
return;
}
assert_replace_valid(
"repl-one",
DDL1,
&[
"INSERT INTO ft(rowid,x) VALUES(1,'hello')",
"REPLACE INTO ft(rowid,x) VALUES(1,'world')",
],
&["hello", "world"],
true,
);
}
#[test]
fn repeated_replaces_same_rowid() {
if !have_fts5_sqlite() {
return;
}
assert_replace_valid(
"ior-repeat",
DDL1,
&[
"INSERT INTO ft(rowid,x) VALUES(1,'one')",
"INSERT OR REPLACE INTO ft(rowid,x) VALUES(1,'two')",
"INSERT OR REPLACE INTO ft(rowid,x) VALUES(1,'three')",
"INSERT OR REPLACE INTO ft(rowid,x) VALUES(1,'four')",
],
&["one", "two", "three", "four"],
true,
);
}
#[test]
fn replace_in_transaction() {
if !have_fts5_sqlite() {
return;
}
assert_replace_valid(
"ior-txn",
DDL1,
&[
"INSERT INTO ft(rowid,x) VALUES(1,'hello')",
"BEGIN",
"INSERT OR REPLACE INTO ft(rowid,x) VALUES(1,'world')",
"COMMIT",
],
&["hello", "world"],
true,
);
}
#[test]
fn replace_mixed_with_inserts_and_deletes() {
if !have_fts5_sqlite() {
return;
}
assert_replace_valid(
"ior-mixed",
DDL1,
&[
"INSERT INTO ft(rowid,x) VALUES(1,'aaa'),(2,'bbb'),(3,'ccc')",
"INSERT OR REPLACE INTO ft(rowid,x) VALUES(2,'zzz')",
"INSERT INTO ft(rowid,x) VALUES(4,'ddd')",
"DELETE FROM ft WHERE rowid=3",
],
&["aaa", "bbb", "ccc", "ddd", "zzz"],
true,
);
}
#[test]
fn multi_row_mixed_new_and_replace_one_statement() {
if !have_fts5_sqlite() {
return;
}
assert_replace_valid(
"ior-multirow",
DDL1,
&[
"INSERT INTO ft(rowid,x) VALUES(1,'alpha'),(2,'beta')",
"INSERT OR REPLACE INTO ft(rowid,x) VALUES(1,'gamma'),(3,'delta')",
],
&["alpha", "beta", "gamma", "delta"],
true,
);
}
#[test]
fn multi_column_replace() {
if !have_fts5_sqlite() {
return;
}
assert_replace_valid(
"ior-multicol",
"CREATE VIRTUAL TABLE ft USING fts5(x, y)",
&[
"INSERT INTO ft(rowid,x,y) VALUES(1,'foo','bar')",
"INSERT OR REPLACE INTO ft(rowid,x,y) VALUES(1,'baz','qux')",
],
&["foo", "bar", "baz", "qux", "y:qux"],
true,
);
}
#[test]
fn replace_to_null_document() {
if !have_fts5_sqlite() {
return;
}
assert_replace_valid(
"ior-null",
DDL1,
&[
"INSERT INTO ft(rowid,x) VALUES(1,'hello')",
"INSERT OR REPLACE INTO ft(rowid,x) VALUES(1,NULL)",
],
&["hello"],
true,
);
}
#[test]
fn non_conflicting_or_replace_is_a_plain_insert() {
if !have_fts5_sqlite() {
return;
}
assert_replace_valid(
"ior-nonconflict",
DDL1,
&[
"INSERT INTO ft(rowid,x) VALUES(1,'hello')",
"INSERT OR REPLACE INTO ft(rowid,x) VALUES(2,'world')",
],
&["hello", "world"],
true,
);
}
#[test]
fn prefix_index_replace_valid_not_byte_checked() {
if !have_fts5_sqlite() {
return;
}
assert_replace_valid(
"ior-prefix",
"CREATE VIRTUAL TABLE ft USING fts5(x, prefix='2 3')",
&[
"INSERT INTO ft(rowid,x) VALUES(1,'hello world')",
"INSERT OR REPLACE INTO ft(rowid,x) VALUES(1,'goodbye planet')",
],
&["hello", "world", "goodbye", "planet", "pla*", "wor*"],
false,
);
}