use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::engine::{self, Target};
use crate::{Database, DatabaseError, Engine, Value};
fn open(engine: Engine, target: Target) -> Result<Database, DatabaseError> {
engine::open_conn(engine, target).map(Database::from_conn)
}
fn scratch_dir(tag: &str, case: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"frust-database-conformance-{}-{tag}-{case}-{n}",
std::process::id()
))
}
fn must_open(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
target: Target,
) -> Database {
open(target).unwrap_or_else(|e| panic!("{tag}: failed to open a connection: {e}"))
}
fn must_exec(tag: &str, db: &Database, sql: &str, params: impl crate::IntoParams) -> u64 {
db.execute(sql, params)
.unwrap_or_else(|e| panic!("{tag}: execute {sql:?} failed: {e}"))
}
fn must_query(
tag: &str,
db: &Database,
sql: &str,
params: impl crate::IntoParams,
) -> Vec<crate::Row> {
db.query(sql, params)
.unwrap_or_else(|e| panic!("{tag}: query {sql:?} failed: {e}"))
}
pub(crate) fn run_conformance_suite(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
value_round_trip_all_classes(tag, open);
positional_param_binding(tag, open);
execute_rows_affected(tag, open);
query_column_names_and_order(tag, open);
transaction_commits_on_ok(tag, open);
transaction_rolls_back_on_err(tag, open);
transaction_no_nesting_surfaces_as_err(tag, open);
transaction_commit_failure_leaves_connection_clean(tag, open);
transaction_panic_leaves_connection_clean(tag, open);
transaction_reentrant_call_is_reported(tag, open);
syntax_error_is_sql(tag, open);
open_at_non_writable_dir_is_storage(tag, open);
multi_handle_same_file_reads_committed_writes(tag, open);
}
fn value_round_trip_all_classes(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
let db = must_open(tag, open, Target::Memory);
must_exec(
tag,
&db,
"CREATE TABLE t (id INTEGER PRIMARY KEY, v BLOB)",
(),
);
let cases: Vec<(&str, Value)> = vec![
("null", Value::Null),
("integer_zero", Value::Integer(0)),
("integer_min", Value::Integer(i64::MIN)),
("integer_max", Value::Integer(i64::MAX)),
("real", Value::Real(3.5)),
("real_negative", Value::Real(-1.25)),
("text_empty", Value::Text(String::new())),
("text", Value::Text("hello frust".to_string())),
("blob_empty", Value::Blob(Vec::new())),
("blob", Value::Blob(vec![0u8, 1, 2, 255])),
];
for (label, value) in cases {
must_exec(tag, &db, "DELETE FROM t", ());
must_exec(
tag,
&db,
"INSERT INTO t (id, v) VALUES (?1, ?2)",
[Value::Integer(1), value.clone()],
);
let rows = must_query(
tag,
&db,
"SELECT v FROM t WHERE id = ?1",
[Value::Integer(1)],
);
assert_eq!(
rows.len(),
1,
"{tag}/{label}: expected exactly one row back"
);
assert_eq!(
rows[0].get(0),
Some(&value),
"{tag}/{label}: round-trip mismatch"
);
}
}
fn positional_param_binding(tag: &str, open: &dyn Fn(Target) -> Result<Database, DatabaseError>) {
let db = must_open(tag, open, Target::Memory);
must_exec(tag, &db, "CREATE TABLE t (a INTEGER, b TEXT)", ());
must_exec(
tag,
&db,
"INSERT INTO t (a, b) VALUES (?1, ?2)",
[Value::Integer(7), Value::Text("seven".to_string())],
);
let rows = must_query(tag, &db, "SELECT a, b FROM t", ());
assert_eq!(rows.len(), 1, "{tag}: expected exactly one row back");
assert_eq!(rows[0].get(0), Some(&Value::Integer(7)), "{tag}: column a");
assert_eq!(
rows[0].get(1),
Some(&Value::Text("seven".to_string())),
"{tag}: column b"
);
}
fn param_count_mismatch_is_sql_error(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
let db = must_open(tag, open, Target::Memory);
must_exec(tag, &db, "CREATE TABLE t (a INTEGER, b INTEGER)", ());
let result = db.execute("INSERT INTO t (a, b) VALUES (?1, ?2)", [Value::Integer(1)]);
assert!(
matches!(result, Err(DatabaseError::Sql { .. })),
"{tag}: a param-count mismatch must surface as DatabaseError::Sql, got {result:?}"
);
}
fn execute_rows_affected(tag: &str, open: &dyn Fn(Target) -> Result<Database, DatabaseError>) {
let db = must_open(tag, open, Target::Memory);
must_exec(
tag,
&db,
"CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)",
(),
);
let inserted = must_exec(
tag,
&db,
"INSERT INTO t (id, v) VALUES (?1, ?2)",
[Value::Integer(1), Value::Integer(10)],
);
assert_eq!(
inserted, 1,
"{tag}: a single INSERT must report 1 row affected"
);
must_exec(
tag,
&db,
"INSERT INTO t (id, v) VALUES (?1, ?2)",
[Value::Integer(2), Value::Integer(10)],
);
must_exec(
tag,
&db,
"INSERT INTO t (id, v) VALUES (?1, ?2)",
[Value::Integer(3), Value::Integer(10)],
);
let updated = must_exec(
tag,
&db,
"UPDATE t SET v = ?1 WHERE v = ?2",
[Value::Integer(20), Value::Integer(10)],
);
assert_eq!(
updated, 3,
"{tag}: a multi-row UPDATE must report every row it touched"
);
let deleted = must_exec(tag, &db, "DELETE FROM t WHERE v = ?1", [Value::Integer(20)]);
assert_eq!(
deleted, 3,
"{tag}: a multi-row DELETE must report every row it touched"
);
}
fn query_column_names_and_order(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
let db = must_open(tag, open, Target::Memory);
must_exec(
tag,
&db,
"CREATE TABLE t (id INTEGER, name TEXT, score REAL)",
(),
);
must_exec(
tag,
&db,
"INSERT INTO t (id, name, score) VALUES (?1, ?2, ?3)",
[
Value::Integer(1),
Value::Text("alice".to_string()),
Value::Real(9.5),
],
);
let rows = must_query(tag, &db, "SELECT id, name, score FROM t", ());
assert_eq!(rows.len(), 1, "{tag}: expected exactly one row back");
let row = &rows[0];
assert_eq!(row.get(0), Some(&Value::Integer(1)), "{tag}: positional id");
assert_eq!(
row.get(1),
Some(&Value::Text("alice".to_string())),
"{tag}: positional name"
);
assert_eq!(
row.get(2),
Some(&Value::Real(9.5)),
"{tag}: positional score"
);
assert_eq!(
row.get_named("id"),
Some(&Value::Integer(1)),
"{tag}: named id"
);
assert_eq!(
row.get_named("name"),
Some(&Value::Text("alice".to_string())),
"{tag}: named name"
);
assert_eq!(
row.get_named("score"),
Some(&Value::Real(9.5)),
"{tag}: named score"
);
assert_eq!(row.get_named("missing"), None, "{tag}: unknown column name");
let reversed = must_query(tag, &db, "SELECT score, name, id FROM t", ());
assert_eq!(
reversed[0].get(0),
Some(&Value::Real(9.5)),
"{tag}: reversed[0]"
);
assert_eq!(
reversed[0].get(1),
Some(&Value::Text("alice".to_string())),
"{tag}: reversed[1]"
);
assert_eq!(
reversed[0].get(2),
Some(&Value::Integer(1)),
"{tag}: reversed[2]"
);
assert_eq!(
reversed[0].get_named("score"),
Some(&Value::Real(9.5)),
"{tag}: reversed named score"
);
}
fn transaction_commits_on_ok(tag: &str, open: &dyn Fn(Target) -> Result<Database, DatabaseError>) {
let db = must_open(tag, open, Target::Memory);
must_exec(tag, &db, "CREATE TABLE t (id INTEGER PRIMARY KEY)", ());
let result = db.transaction(|txn| {
txn.execute("INSERT INTO t (id) VALUES (?1)", [Value::Integer(1)])?;
Ok(())
});
assert!(
result.is_ok(),
"{tag}: a transaction whose closure returns Ok must itself return Ok, got {result:?}"
);
let rows = must_query(tag, &db, "SELECT id FROM t", ());
assert_eq!(
rows.len(),
1,
"{tag}: a committed transaction's write must be visible after it returns"
);
}
fn transaction_rolls_back_on_err(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
let db = must_open(tag, open, Target::Memory);
must_exec(tag, &db, "CREATE TABLE t (id INTEGER PRIMARY KEY)", ());
let result: Result<(), DatabaseError> = db.transaction(|txn| {
txn.execute("INSERT INTO t (id) VALUES (?1)", [Value::Integer(1)])?;
Err(DatabaseError::Sql {
message: "forced rollback".to_string(),
})
});
assert!(
result.is_err(),
"{tag}: a transaction whose closure returns Err must itself return Err"
);
let rows = must_query(tag, &db, "SELECT id FROM t", ());
assert!(
rows.is_empty(),
"{tag}: a rolled-back transaction's write must not be visible"
);
}
fn transaction_no_nesting_surfaces_as_err(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
let db = must_open(tag, open, Target::Memory);
must_exec(tag, &db, "CREATE TABLE t (id INTEGER PRIMARY KEY)", ());
let result = db.transaction(|txn| txn.execute("BEGIN", ()));
assert!(
matches!(result, Err(DatabaseError::Sql { .. })),
"{tag}: a nested BEGIN must surface as DatabaseError::Sql, got {result:?}"
);
let rows = must_query(tag, &db, "SELECT id FROM t", ());
assert!(
rows.is_empty(),
"{tag}: a failed nested-transaction attempt must leave no rows behind"
);
}
fn transaction_commit_failure_leaves_connection_clean(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
let db = must_open(tag, open, Target::Memory);
must_exec(tag, &db, "CREATE TABLE parent (id INTEGER PRIMARY KEY)", ());
must_exec(
tag,
&db,
"CREATE TABLE child (id INTEGER PRIMARY KEY, pid INTEGER REFERENCES parent(id) \
DEFERRABLE INITIALLY DEFERRED)",
(),
);
let result = db.transaction(|txn| {
let inserted = txn.execute(
"INSERT INTO child (id, pid) VALUES (?1, ?2)",
[Value::Integer(1), Value::Integer(999)],
);
assert!(
inserted.is_ok(),
"{tag}: the deferred FK violation must reach COMMIT, not be rejected at INSERT \
— got {inserted:?}"
);
Ok(())
});
assert!(
matches!(result, Err(DatabaseError::Sql { .. })),
"{tag}: a deferred-constraint COMMIT failure must surface as DatabaseError::Sql, \
got {result:?}"
);
let recovered = db
.transaction(|txn| txn.execute("INSERT INTO parent (id) VALUES (?1)", [Value::Integer(1)]));
assert!(
recovered.is_ok(),
"{tag}: the connection must be left clean after a failed COMMIT — the next \
transaction got {recovered:?}"
);
let rows = must_query(tag, &db, "SELECT id FROM child", ());
assert!(
rows.is_empty(),
"{tag}: a transaction whose COMMIT failed must have rolled its write back"
);
}
fn transaction_panic_leaves_connection_clean(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
let db = must_open(tag, open, Target::Memory);
must_exec(tag, &db, "CREATE TABLE t (id INTEGER PRIMARY KEY)", ());
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _: Result<(), DatabaseError> = db.transaction(|txn| {
txn.execute("INSERT INTO t (id) VALUES (?1)", [Value::Integer(1)])?;
panic!("{tag}: forced panic inside a transaction closure")
});
}));
assert!(
caught.is_err(),
"{tag}: the forced panic must actually unwind out of transaction()"
);
let recovered =
db.transaction(|txn| txn.execute("INSERT INTO t (id) VALUES (?1)", [Value::Integer(2)]));
assert!(
recovered.is_ok(),
"{tag}: the connection must be left clean after a panicking closure — the next \
transaction got {recovered:?}"
);
let rows = must_query(tag, &db, "SELECT id FROM t", ());
assert_eq!(
rows.len(),
1,
"{tag}: only the post-panic transaction's row may survive"
);
assert_eq!(
rows[0].get(0),
Some(&Value::Integer(2)),
"{tag}: the panicking transaction's write must have rolled back"
);
}
fn transaction_reentrant_call_is_reported(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
let db = must_open(tag, open, Target::Memory);
must_exec(tag, &db, "CREATE TABLE t (id INTEGER PRIMARY KEY)", ());
let db = &db;
let outer: Result<(), DatabaseError> = db.transaction(|_txn| {
let reentrant_execute = db.execute("SELECT 1", ());
assert!(
matches!(reentrant_execute, Err(DatabaseError::Reentrant)),
"{tag}: a reentrant execute must report Reentrant, got {reentrant_execute:?}"
);
let reentrant_query = db.query("SELECT 1", ());
assert!(
matches!(reentrant_query, Err(DatabaseError::Reentrant)),
"{tag}: a reentrant query must report Reentrant, got {reentrant_query:?}"
);
let reentrant_txn = db.transaction(|_| Ok::<(), DatabaseError>(()));
assert!(
matches!(reentrant_txn, Err(DatabaseError::Reentrant)),
"{tag}: a reentrant transaction must report Reentrant, got {reentrant_txn:?}"
);
Ok(())
});
assert!(
outer.is_ok(),
"{tag}: the outer transaction itself must still commit, got {outer:?}"
);
std::thread::scope(|scope| {
for id in 1..=4i64 {
scope.spawn(move || {
let result = db.transaction(|txn| {
txn.execute("INSERT INTO t (id) VALUES (?1)", [Value::Integer(id)])
});
assert!(
result.is_ok(),
"{tag}: a cross-thread call must queue on the lock, not be refused as \
reentrant — got {result:?}"
);
});
}
});
let rows = must_query(tag, db, "SELECT id FROM t", ());
assert_eq!(
rows.len(),
4,
"{tag}: every queued cross-thread transaction must have committed"
);
}
fn syntax_error_is_sql(tag: &str, open: &dyn Fn(Target) -> Result<Database, DatabaseError>) {
let db = must_open(tag, open, Target::Memory);
let result = db.execute("THIS IS NOT VALID SQL AT ALL", ());
match result {
Err(DatabaseError::Sql { message }) => {
assert!(!message.is_empty(), "{tag}: Sql error must carry a message");
}
other => panic!("{tag}: a syntax error must surface as DatabaseError::Sql, got {other:?}"),
}
}
#[cfg(unix)]
fn open_at_non_writable_dir_is_storage(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
use std::os::unix::fs::PermissionsExt;
let dir = scratch_dir(tag, "readonly-dir");
fs::create_dir_all(&dir).expect("create scratch dir");
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555))
.expect("chmod scratch dir read-only");
let probe_path = dir.join("probe");
let can_still_write = fs::File::create(&probe_path).is_ok();
let _ = fs::remove_file(&probe_path);
if can_still_write {
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o755));
let _ = fs::remove_dir_all(&dir);
return;
}
let result = open(Target::Path(dir.join("blocked.db")));
match &result {
Err(DatabaseError::Storage(_)) => {}
Err(other) => panic!(
"{tag}: opening in a non-writable directory must surface DatabaseError::Storage, got {other}"
),
Ok(_) => panic!("{tag}: opening in a non-writable directory must fail, but it succeeded"),
}
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o755));
let _ = fs::remove_dir_all(&dir);
}
#[cfg(not(unix))]
fn open_at_non_writable_dir_is_storage(
_tag: &str,
_open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
}
fn multi_handle_same_file_reads_committed_writes(
tag: &str,
open: &dyn Fn(Target) -> Result<Database, DatabaseError>,
) {
let dir = scratch_dir(tag, "multi-handle");
fs::create_dir_all(&dir).expect("create scratch dir");
let path = dir.join("shared.db");
let writer = must_open(tag, open, Target::Path(path.clone()));
must_exec(tag, &writer, "CREATE TABLE t (id INTEGER PRIMARY KEY)", ());
must_exec(
tag,
&writer,
"INSERT INTO t (id) VALUES (?1)",
[Value::Integer(1)],
);
let reader = must_open(tag, open, Target::Path(path));
let rows = must_query(tag, &reader, "SELECT id FROM t", ());
assert_eq!(
rows.len(),
1,
"{tag}: a second handle on the same file must see the first handle's committed write"
);
must_exec(
tag,
&writer,
"INSERT INTO t (id) VALUES (?1)",
[Value::Integer(2)],
);
let rows_after = must_query(tag, &reader, "SELECT id FROM t", ());
assert_eq!(
rows_after.len(),
2,
"{tag}: a second handle must see a later committed write too (WAL concurrent readers)"
);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(feature = "engine-sqlite")]
mod sqlite_conformance {
use super::{open, run_conformance_suite};
use crate::engine::Target;
use crate::{Database, DatabaseError, Engine, Value};
fn open_sqlite(target: Target) -> Result<Database, DatabaseError> {
open(Engine::Sqlite, target)
}
#[test]
fn conformance_suite() {
run_conformance_suite("sqlite", &open_sqlite);
}
#[test]
fn param_count_mismatch() {
super::param_count_mismatch_is_sql_error("sqlite", &open_sqlite);
}
#[test]
fn create_index() {
let db = open_sqlite(Target::Memory).expect("sqlite: failed to open");
db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)", ())
.expect("sqlite: CREATE TABLE failed");
db.execute("CREATE INDEX idx_name ON t (name)", ())
.expect("sqlite: CREATE INDEX failed");
db.execute(
"INSERT INTO t (id, name) VALUES (?1, ?2)",
[Value::Integer(1), Value::Text("alice".to_string())],
)
.expect("sqlite: INSERT failed");
let rows = db
.query(
"SELECT id FROM t WHERE name = ?1",
[Value::Text("alice".to_string())],
)
.expect("sqlite: indexed SELECT failed");
assert_eq!(
rows.len(),
1,
"sqlite: indexed lookup must return exactly one row"
);
assert_eq!(rows[0].get(0), Some(&Value::Integer(1)));
}
}
#[cfg(feature = "engine-turso")]
mod turso_conformance {
use super::{open, run_conformance_suite};
use crate::engine::Target;
use crate::{Database, DatabaseError, Engine};
fn open_turso(target: Target) -> Result<Database, DatabaseError> {
open(Engine::Turso, target)
}
#[test]
fn conformance_suite() {
run_conformance_suite("turso", &open_turso);
}
}
#[cfg(all(feature = "engine-sqlite", feature = "engine-turso"))]
mod cross_engine {
use std::fs;
use super::{must_exec, must_open, must_query, scratch_dir};
use crate::engine::Target;
use crate::{Database, Engine, Value};
fn seed_rows() -> Vec<(i64, Value)> {
vec![
(1, Value::Null),
(2, Value::Integer(-42)),
(3, Value::Real(2.5)),
(4, Value::Text("cross-engine".to_string())),
(5, Value::Blob(vec![9, 8, 7])),
]
}
fn assert_journal_mode_wal(tag: &str, db: &Database) {
let rows = must_query(tag, db, "PRAGMA journal_mode", ());
let mode = match rows.first().and_then(|row| row.get(0)) {
Some(Value::Text(s)) => s.to_lowercase(),
other => panic!("{tag}: unexpected journal_mode result: {other:?}"),
};
assert_eq!(mode, "wal", "{tag}: journal mode must stay WAL");
}
fn assert_seed_rows_present(tag: &str, db: &Database) {
for (id, value) in seed_rows() {
let rows = must_query(
tag,
db,
"SELECT v FROM t WHERE id = ?1",
[Value::Integer(id)],
);
assert_eq!(rows.len(), 1, "{tag}: expected exactly one row for id={id}");
assert_eq!(
rows[0].get(0),
Some(&value),
"{tag}: value mismatch for id={id}"
);
}
}
fn round_trip(
case: &str,
writer_tag: &str,
writer_engine: Engine,
reader_tag: &str,
reader_engine: Engine,
) {
let dir = scratch_dir("cross-engine", case);
fs::create_dir_all(&dir).expect("create scratch dir");
let path = dir.join("roundtrip.db");
{
let writer_open = |target: Target| super::open(writer_engine, target);
let db = must_open(writer_tag, &writer_open, Target::Path(path.clone()));
must_exec(
writer_tag,
&db,
"CREATE TABLE t (id INTEGER PRIMARY KEY, v BLOB)",
(),
);
for (id, value) in seed_rows() {
must_exec(
writer_tag,
&db,
"INSERT INTO t (id, v) VALUES (?1, ?2)",
[Value::Integer(id), value],
);
}
assert_journal_mode_wal(writer_tag, &db);
}
let reader_open = |target: Target| super::open(reader_engine, target);
let db = must_open(reader_tag, &reader_open, Target::Path(path));
assert_journal_mode_wal(reader_tag, &db);
assert_seed_rows_present(reader_tag, &db);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn sqlite_write_turso_read_round_trip() {
round_trip(
"sqlite-write-turso-read",
"sqlite-write",
Engine::Sqlite,
"turso-read",
Engine::Turso,
);
}
#[test]
fn turso_write_sqlite_read_round_trip() {
round_trip(
"turso-write-sqlite-read",
"turso-write",
Engine::Turso,
"sqlite-read",
Engine::Sqlite,
);
}
}