use zova::{
restore_backup, BackupOptions, ColumnType, CompactOptions, Database, Error, OpenOptions,
RestoreOptions, SharedDatabase, Status, Step, VectorCollectionOptions, VectorElementType,
VectorMetric, VectorValues,
};
fn temp_path(name: &str) -> String {
let mut path = std::env::temp_dir();
path.push(format!(
"zova-rust-safe-{}-{}-{name}.zova",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&path);
path.to_str().unwrap().to_owned()
}
#[test]
fn create_open_exec_and_prepare_records() {
let path = temp_path("records");
{
let mut db = Database::create(&path).unwrap();
db.exec("create table records(id integer primary key, name text not null, payload blob)")
.unwrap();
let mut insert = db
.prepare("insert into records(name, payload) values (:name, :payload)")
.unwrap();
assert_eq!(insert.parameter_count().unwrap(), 2);
assert_eq!(insert.parameter_index(":name").unwrap(), Some(1));
assert_eq!(insert.parameter_index(":missing").unwrap(), None);
insert.bind_text(1, "alpha").unwrap();
insert.bind_blob(2, b"\0bytes").unwrap();
assert_eq!(insert.step().unwrap(), Step::Done);
drop(insert);
assert_eq!(db.last_insert_rowid().unwrap(), 1);
assert_eq!(db.changes().unwrap(), 1);
assert!(db.total_changes().unwrap() >= 1);
}
{
let mut db = Database::open(&path).unwrap();
let mut query = db
.prepare("select id, name, payload from records where name = ?1")
.unwrap();
query.bind_text(1, "alpha").unwrap();
assert_eq!(query.step().unwrap(), Step::Row);
assert_eq!(query.column_count().unwrap(), 3);
assert_eq!(query.column_name(0).unwrap(), "id");
assert_eq!(query.column_name(1).unwrap(), "name");
assert_eq!(query.column_name(2).unwrap(), "payload");
assert_eq!(query.column_type(0).unwrap(), ColumnType::Integer);
assert_eq!(query.column_i64(0).unwrap(), 1);
assert_eq!(query.column_text(1).unwrap(), Some("alpha".to_string()));
assert_eq!(query.column_blob(2).unwrap(), Some(b"\0bytes".to_vec()));
assert_eq!(query.step().unwrap(), Step::Done);
}
let _ = std::fs::remove_file(path);
}
#[test]
fn bundled_trgm_extension_sql_surface_works_after_reopen() {
let path = temp_path("trgm");
{
let mut db = Database::create(&path).unwrap();
install_trgm_fixture(&mut db);
}
{
let mut db = Database::open(&path).unwrap();
let mut create = db
.prepare("select zova_trgm_create_index('messages')")
.unwrap();
assert_eq!(create.step().unwrap(), Step::Row);
drop(create);
let mut put = db
.prepare("select zova_trgm_put('messages', ?1, 'record', 'messages', ?2, ?3)")
.unwrap();
put.bind_text(1, "message:123").unwrap();
put.bind_text(2, "123").unwrap();
put.bind_text(3, "attachment upload failed").unwrap();
assert_eq!(put.step().unwrap(), Step::Row);
drop(put);
let mut search = db
.prepare(
"select document_id, score from zova_trgm_search \
where index_name = 'messages' and query = ?1 and \"limit\" = 1 \
order by rank",
)
.unwrap();
search.bind_text(1, "attachement failed").unwrap();
assert_eq!(search.step().unwrap(), Step::Row);
assert_eq!(
search.column_text(0).unwrap(),
Some("message:123".to_string())
);
assert!(search.column_f64(1).unwrap() > 0.20);
assert_eq!(search.step().unwrap(), Step::Done);
}
let _ = std::fs::remove_file(path);
}
#[test]
fn extension_lifecycle_methods_manage_bundled_trgm() {
let path = temp_path("extensions");
{
let mut db = Database::create(&path).unwrap();
assert!(db.list_extensions().unwrap().is_empty());
let missing = db.install_extension("missing_ext").unwrap_err();
assert_eq!(missing.status(), Some(Status::ExtensionNotFound));
db.install_extension("trgm").unwrap();
let duplicate = db.install_extension("trgm").unwrap_err();
assert_eq!(duplicate.status(), Some(Status::ExtensionExists));
let info = db.extension_info("trgm").unwrap();
assert_eq!(info.name, "trgm");
assert_eq!(info.storage_prefix, "_zova_ext_trgm_");
assert_eq!(info.capabilities, "sql,trgm");
assert!(info.required);
assert!(info.installed_at_unix > 0);
assert!(info.manifest_json.contains("trgm"));
let list = db.list_extensions().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "trgm");
db.check_extension("trgm").unwrap();
db.check_extensions().unwrap();
let mut create = db
.prepare("select zova_trgm_create_index('messages')")
.unwrap();
assert_eq!(create.step().unwrap(), Step::Row);
drop(create);
db.drop_extension("trgm").unwrap();
let missing_info = db.extension_info("trgm").unwrap_err();
assert_eq!(missing_info.status(), Some(Status::ExtensionNotFound));
}
let _ = std::fs::remove_file(path);
}
#[test]
fn shared_extension_lifecycle_methods_lock_and_copy_diagnostics() {
let path = temp_path("shared-extensions");
let db = SharedDatabase::create(&path).unwrap();
db.install_extension("trgm").unwrap();
assert_eq!(db.list_extensions().unwrap()[0].name, "trgm");
db.check_extension("trgm").unwrap();
db.check_extensions().unwrap();
let err = db.install_extension("trgm").unwrap_err();
assert_eq!(err.status(), Some(Status::ExtensionExists));
db.exec("create table after_extension_error(id integer)")
.unwrap();
assert!(err.to_string().contains("ExtensionExists"));
db.transaction(|guard| {
let info = guard.extension_info("trgm")?;
assert_eq!(info.storage_prefix, "_zova_ext_trgm_");
guard.check_extension("trgm")?;
guard.check_extensions()?;
Ok(())
})
.unwrap();
db.drop_extension("trgm").unwrap();
assert!(db.list_extensions().unwrap().is_empty());
let _ = std::fs::remove_file(path);
}
fn install_trgm_fixture(db: &mut Database) {
db.exec(
"insert into _zova_extensions \
(name, version, storage_prefix, zova_abi_min, capabilities, required, installed_at_unix, manifest_json) \
values ('trgm', '0.1.0', '_zova_ext_trgm_', '0.21.0', 'sql,trgm', 1, 0, \
'{\"extension\":\"trgm\",\"version\":\"0.1.0\"}')",
)
.unwrap();
db.exec("create table _zova_ext_trgm_meta (key text primary key, value text not null)")
.unwrap();
db.exec("insert into _zova_ext_trgm_meta (key, value) values ('schema_version', '1'), ('extension_version', '0.1.0')")
.unwrap();
db.exec("create table _zova_ext_trgm_indexes (name text primary key, created_order integer not null unique)")
.unwrap();
db.exec(
"create table _zova_ext_trgm_documents (\
index_name text not null, document_id text not null, target_type text not null, \
target_namespace text, target_ref text, normalized_len integer not null, \
text_hash blob not null check (length(text_hash) = 32), term_count integer not null, \
updated_at_unix integer not null, primary key (index_name, document_id))",
)
.unwrap();
db.exec("create table _zova_ext_trgm_terms (index_name text not null, term blob not null check (length(term) = 3), document_count integer not null, primary key (index_name, term))")
.unwrap();
db.exec("create table _zova_ext_trgm_postings (index_name text not null, term blob not null check (length(term) = 3), document_id text not null, count integer not null, primary key (index_name, term, document_id))")
.unwrap();
}
#[test]
fn owned_statement_can_outlive_database_wrapper_and_preserve_statement_api() {
let path = temp_path("owned-statement");
let mut db = Database::create(&path).unwrap();
db.exec("create table records(id integer primary key, body text, payload blob)")
.unwrap();
let mut insert = db
.prepare_owned("insert into records(body, payload) values (:body, :payload)")
.unwrap();
assert_eq!(insert.parameter_count().unwrap(), 2);
assert_eq!(insert.parameter_index(":body").unwrap(), Some(1));
insert.bind_text(1, "owned").unwrap();
insert.bind_blob(2, b"bytes").unwrap();
assert_eq!(insert.step().unwrap(), Step::Done);
drop(db);
drop(insert);
let mut reopened = Database::open(&path).unwrap();
let mut query = reopened
.prepare_owned("select body as body_text, payload from records where id = ?1")
.unwrap();
query.bind_i64(1, 1).unwrap();
assert_eq!(query.step().unwrap(), Step::Row);
assert_eq!(query.column_name(0).unwrap(), "body_text");
assert_eq!(query.column_text(0).unwrap(), Some("owned".to_string()));
assert_eq!(query.column_blob(1).unwrap(), Some(b"bytes".to_vec()));
query.reset().unwrap();
query.clear_bindings().unwrap();
query.bind_i64(1, 1).unwrap();
assert_eq!(query.step().unwrap(), Step::Row);
assert_eq!(query.column_text(0).unwrap(), Some("owned".to_string()));
let _ = std::fs::remove_file(path);
}
#[test]
fn statements_round_trip_all_basic_types_and_nulls() {
let path = temp_path("types");
let mut db = Database::create(&path).unwrap();
db.exec(
"create table values_table(
i integer,
f real,
n text,
empty_text text,
text_value text,
empty_blob blob,
blob_value blob
)",
)
.unwrap();
let mut insert = db
.prepare("insert into values_table values (?1, ?2, ?3, ?4, ?5, ?6, ?7)")
.unwrap();
insert.bind_i64(1, -7).unwrap();
insert.bind_f64(2, 3.5).unwrap();
insert.bind_null(3).unwrap();
insert.bind_text(4, "").unwrap();
insert.bind_text(5, "hello").unwrap();
insert.bind_blob(6, b"").unwrap();
insert.bind_blob(7, &[1, 2, 3]).unwrap();
assert_eq!(insert.step().unwrap(), Step::Done);
drop(insert);
let mut query = db.prepare("select * from values_table").unwrap();
assert_eq!(query.step().unwrap(), Step::Row);
assert_eq!(query.column_i64(0).unwrap(), -7);
assert_eq!(query.column_f64(1).unwrap(), 3.5);
assert_eq!(query.column_type(2).unwrap(), ColumnType::Null);
assert_eq!(query.column_text(2).unwrap(), None);
assert_eq!(query.column_text(3).unwrap(), Some(String::new()));
assert_eq!(query.column_text(4).unwrap(), Some("hello".to_string()));
assert_eq!(query.column_blob(5).unwrap(), Some(Vec::new()));
assert_eq!(query.column_blob(6).unwrap(), Some(vec![1, 2, 3]));
let _ = std::fs::remove_file(path);
}
#[test]
fn reset_preserves_bindings_and_clear_bindings_removes_them() {
let path = temp_path("reset");
let mut db = Database::create(&path).unwrap();
let mut statement = db.prepare("select ?1").unwrap();
assert_eq!(
statement.bind_i64(0, 1).unwrap_err().status(),
Some(Status::InvalidArgument)
);
statement.bind_i64(1, 99).unwrap();
assert_eq!(statement.step().unwrap(), Step::Row);
assert_eq!(statement.column_i64(0).unwrap(), 99);
statement.reset().unwrap();
assert_eq!(statement.step().unwrap(), Step::Row);
assert_eq!(statement.column_i64(0).unwrap(), 99);
statement.reset().unwrap();
statement.clear_bindings().unwrap();
assert_eq!(statement.step().unwrap(), Step::Row);
assert_eq!(statement.column_type(0).unwrap(), ColumnType::Null);
let _ = std::fs::remove_file(path);
}
#[test]
fn transactions_commit_rollback_and_vacuum_work() {
let path = temp_path("transactions");
let mut db = Database::create(&path).unwrap();
db.exec("create table tx(id integer primary key, value text)")
.unwrap();
db.begin().unwrap();
db.exec("insert into tx(value) values ('commit')").unwrap();
db.commit().unwrap();
db.begin_immediate().unwrap();
db.exec("insert into tx(value) values ('rollback')")
.unwrap();
db.rollback().unwrap();
let mut count = db.prepare("select count(*) from tx").unwrap();
assert_eq!(count.step().unwrap(), Step::Row);
assert_eq!(count.column_i64(0).unwrap(), 1);
drop(count);
db.vacuum().unwrap();
let _ = std::fs::remove_file(path);
}
#[test]
fn savepoints_rollback_release_and_validate_names() {
let path = temp_path("savepoints");
let mut db = Database::create(&path).unwrap();
db.exec("create table tx(id integer primary key, value text)")
.unwrap();
db.begin_immediate().unwrap();
db.exec("insert into tx(value) values ('outer')").unwrap();
db.savepoint("sp_one").unwrap();
db.exec("insert into tx(value) values ('rolled back')")
.unwrap();
let object_error = db.put_object(b"blocked inside savepoint").unwrap_err();
assert_eq!(object_error.status(), Some(Status::ObjectTransactionActive));
db.create_vector_collection(
"temporary_vectors",
VectorCollectionOptions {
dimensions: 2,
metric: VectorMetric::L2,
element_type: VectorElementType::F32,
},
)
.unwrap();
db.put_vector("temporary_vectors", "v1", VectorValues::F32(&[1.0, 2.0]))
.unwrap();
db.rollback_to_savepoint("sp_one").unwrap();
db.release_savepoint("sp_one").unwrap();
assert!(!db.has_vector_collection("temporary_vectors").unwrap());
db.savepoint("sp_two").unwrap();
db.exec("insert into tx(value) values ('kept')").unwrap();
db.release_savepoint("sp_two").unwrap();
db.commit().unwrap();
let mut count = db
.prepare("select count(*) from tx where value != 'rolled back'")
.unwrap();
assert_eq!(count.step().unwrap(), Step::Row);
assert_eq!(count.column_i64(0).unwrap(), 2);
drop(count);
let invalid = db.savepoint("bad name").unwrap_err();
assert_eq!(invalid.status(), Some(Status::InvalidArgument));
let missing = db.release_savepoint("missing_sp").unwrap_err();
assert!(missing.to_string().contains("no such savepoint"));
let _ = std::fs::remove_file(path);
}
#[test]
fn scoped_savepoint_helper_releases_rolls_back_and_returns_values() {
let path = temp_path("scoped-savepoints");
let mut db = Database::create(&path).unwrap();
db.exec("create table tx(id integer primary key, value text)")
.unwrap();
db.begin_immediate().unwrap();
let value = db
.with_savepoint("sp_keep", |db| {
db.exec("insert into tx(value) values ('kept scoped')")?;
Ok(42)
})
.unwrap();
assert_eq!(value, 42);
let err = db
.with_savepoint("sp_fail", |db| {
db.exec("insert into tx(value) values ('rolled back scoped')")?;
db.exec("select * from missing_table")
})
.unwrap_err();
assert_eq!(err.status(), Some(Status::SqliteError));
let err = db
.with_savepoint("bad name", |db| {
db.exec("insert into tx(value) values ('not invoked')")
})
.unwrap_err();
assert_eq!(err.status(), Some(Status::InvalidArgument));
let err = db
.with_savepoint("sp_manual", |db| {
db.exec("insert into tx(value) values ('manual release kept')")?;
db.release_savepoint("sp_manual")
})
.unwrap_err();
assert!(err.to_string().contains("no such savepoint"));
db.commit().unwrap();
let mut count = db
.prepare("select count(*) from tx where value in ('kept scoped', 'manual release kept')")
.unwrap();
assert_eq!(count.step().unwrap(), Step::Row);
assert_eq!(count.column_i64(0).unwrap(), 2);
drop(count);
let mut rolled_back = db
.prepare(
"select count(*) from tx where value like '%rolled back%' or value = 'not invoked'",
)
.unwrap();
assert_eq!(rolled_back.step().unwrap(), Step::Row);
assert_eq!(rolled_back.column_i64(0).unwrap(), 0);
let _ = std::fs::remove_file(path);
}
#[test]
fn backup_compact_and_restore_preserve_records_objects_and_vectors() {
let path = temp_path("ops-source");
let backup_path = temp_path("ops-backup");
let compact_path = temp_path("ops-compact");
let restored_path = temp_path("ops-restored");
let no_verify_path = temp_path("ops-no-verify");
let payload = b"rust operational object bytes";
let object_id;
{
let mut db = Database::create(&path).unwrap();
db.exec("create table records(id integer primary key, body text not null)")
.unwrap();
db.exec("insert into records(body) values ('kept')")
.unwrap();
object_id = db.put_object(payload).unwrap();
db.create_vector_collection(
"chunks",
VectorCollectionOptions {
dimensions: 2,
metric: VectorMetric::L2,
element_type: VectorElementType::F32,
},
)
.unwrap();
db.put_vector("chunks", "near", VectorValues::F32(&[0.0, 0.0]))
.unwrap();
db.put_vector("chunks", "far", VectorValues::F32(&[10.0, 0.0]))
.unwrap();
db.backup_to(&backup_path, BackupOptions::default())
.unwrap();
db.compact_to(&compact_path, CompactOptions::default())
.unwrap();
db.backup_to(&no_verify_path, BackupOptions { verify: false })
.unwrap();
let err = db
.backup_to(&backup_path, BackupOptions::default())
.unwrap_err();
assert_eq!(err.status(), Some(Status::DestinationExists));
}
restore_backup(&backup_path, &restored_path, RestoreOptions::default()).unwrap();
for copy in [&backup_path, &compact_path, &restored_path, &no_verify_path] {
let mut db = Database::open(copy).unwrap();
let mut query = db.prepare("select body from records where id = 1").unwrap();
assert_eq!(query.step().unwrap(), Step::Row);
assert_eq!(query.column_text(0).unwrap(), Some("kept".to_string()));
drop(query);
assert_eq!(db.get_object(object_id).unwrap(), payload);
let results = db
.search_vectors("chunks", VectorValues::F32(&[0.0, 0.0]), 2)
.unwrap();
assert_eq!(results[0].id, "near");
let query_blob: Vec<u8> = [0.0_f32, 0.0]
.into_iter()
.flat_map(|value| value.to_le_bytes())
.collect();
let mut distance = db
.prepare("select zova_vector_distance('chunks', 'near', ?1)")
.unwrap();
distance.bind_blob(1, &query_blob).unwrap();
assert_eq!(distance.step().unwrap(), Step::Row);
assert_eq!(distance.column_f64(0).unwrap(), 0.0);
}
let mut db = Database::open(&path).unwrap();
let err = db
.compact_to("bad-destination.db", CompactOptions::default())
.unwrap_err();
assert_eq!(err.status(), Some(Status::NotZovaPath));
let err = restore_backup(
"bad-source.db",
&temp_path("bad-restore"),
RestoreOptions::default(),
)
.unwrap_err();
assert_eq!(err.status(), Some(Status::NotZovaPath));
}
#[test]
fn shared_database_backup_and_compact_work() {
let path = temp_path("shared-ops-source");
let backup_path = temp_path("shared-ops-backup");
let compact_path = temp_path("shared-ops-compact");
let db = SharedDatabase::create(&path).unwrap();
db.exec("create table records(id integer primary key, body text not null)")
.unwrap();
db.exec("insert into records(body) values ('shared')")
.unwrap();
let reader = db.clone();
let handle = std::thread::spawn(move || {
for _ in 0..8 {
reader
.with_exclusive(|database| {
let mut stmt =
database.prepare("select count(*) from records where body = 'shared'")?;
assert_eq!(stmt.step()?, Step::Row);
assert_eq!(stmt.column_i64(0)?, 1);
Ok(())
})
.unwrap();
}
});
db.backup_to(&backup_path, BackupOptions::default())
.unwrap();
db.compact_to(&compact_path, CompactOptions { verify: false })
.unwrap();
handle.join().unwrap();
for copy in [&backup_path, &compact_path] {
let mut opened = Database::open(copy).unwrap();
let mut stmt = opened.prepare("select body from records").unwrap();
assert_eq!(stmt.step().unwrap(), Step::Row);
assert_eq!(stmt.column_text(0).unwrap(), Some("shared".to_string()));
}
}
#[test]
fn read_only_open_and_busy_timeout_work() {
let path = temp_path("readonly");
{
let mut db = Database::create(&path).unwrap();
db.exec("create table notes(id integer primary key, body text not null)")
.unwrap();
db.exec("insert into notes(body) values ('kept')").unwrap();
}
let mut db = Database::open_with_options(
&path,
OpenOptions {
read_only: true,
busy_timeout_ms: 1,
},
)
.unwrap();
db.set_busy_timeout(0).unwrap();
db.set_busy_timeout(2).unwrap();
let mut query = db.prepare("select body from notes").unwrap();
assert_eq!(query.step().unwrap(), Step::Row);
assert_eq!(query.column_text(0).unwrap(), Some("kept".to_string()));
drop(query);
let err = db
.exec("insert into notes(body) values ('blocked')")
.unwrap_err();
assert_eq!(err.status(), Some(Status::ReadOnly));
let _ = std::fs::remove_file(path);
}
#[test]
fn errors_preserve_status_and_reject_bad_strings() {
let bad = Database::create("bad\0path.zova").unwrap_err();
assert!(matches!(bad, Error::InteriorNul { .. }));
let mut plain_path = std::env::temp_dir();
plain_path.push(format!(
"zova-rust-safe-{}-{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let path = plain_path.to_str().unwrap().to_owned();
let err = Database::create(&path).unwrap_err();
assert_eq!(err.status(), Some(Status::NotZovaPath));
let path = temp_path("sql");
let mut db = Database::create(&path).unwrap();
let err = db.exec("select * from no_such_table").unwrap_err();
assert_eq!(err.status(), Some(Status::SqliteError));
assert!(err.to_string().contains("no_such_table"));
db.exec("create table after_error(id integer)").unwrap();
assert_eq!(err.status(), Some(Status::SqliteError));
assert!(err.to_string().contains("no_such_table"));
let _ = std::fs::remove_file(path);
}