mod engine;
#[cfg(feature = "engine-sqlite")]
mod sqlite;
#[cfg(feature = "engine-turso")]
mod turso;
#[cfg(any(test, feature = "conformance"))]
pub(crate) mod conformance;
use std::cell::RefCell;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, MutexGuard};
use engine::{EngineConn, Target};
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Null,
Integer(i64),
Real(f64),
Text(String),
Blob(Vec<u8>),
}
impl From<i64> for Value {
fn from(v: i64) -> Self {
Value::Integer(v)
}
}
impl From<i32> for Value {
fn from(v: i32) -> Self {
Value::Integer(i64::from(v))
}
}
impl From<f64> for Value {
fn from(v: f64) -> Self {
Value::Real(v)
}
}
impl From<bool> for Value {
fn from(v: bool) -> Self {
Value::Integer(i64::from(v))
}
}
impl From<String> for Value {
fn from(v: String) -> Self {
Value::Text(v)
}
}
impl From<&str> for Value {
fn from(v: &str) -> Self {
Value::Text(v.to_string())
}
}
impl From<Vec<u8>> for Value {
fn from(v: Vec<u8>) -> Self {
Value::Blob(v)
}
}
impl<T: Into<Value>> From<Option<T>> for Value {
fn from(v: Option<T>) -> Self {
match v {
Some(v) => v.into(),
None => Value::Null,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Row {
columns: Vec<String>,
values: Vec<Value>,
}
impl Row {
#[allow(
dead_code,
reason = "first real caller lands with the sqlite-backend task"
)]
pub(crate) fn new(columns: Vec<String>, values: Vec<Value>) -> Self {
Self { columns, values }
}
pub fn get(&self, idx: usize) -> Option<&Value> {
self.values.get(idx)
}
pub fn get_named(&self, name: &str) -> Option<&Value> {
self.columns
.iter()
.position(|c| c == name)
.and_then(|i| self.values.get(i))
}
}
mod sealed {
pub trait Sealed {}
}
pub trait IntoParams: sealed::Sealed {
fn into_params(self) -> Vec<Value>;
}
impl sealed::Sealed for () {}
impl IntoParams for () {
fn into_params(self) -> Vec<Value> {
Vec::new()
}
}
impl<T: Into<Value> + Clone> sealed::Sealed for &[T] {}
impl<T: Into<Value> + Clone> IntoParams for &[T] {
fn into_params(self) -> Vec<Value> {
self.iter().cloned().map(Into::into).collect()
}
}
impl<T: Into<Value>, const N: usize> sealed::Sealed for [T; N] {}
impl<T: Into<Value>, const N: usize> IntoParams for [T; N] {
fn into_params(self) -> Vec<Value> {
self.into_iter().map(Into::into).collect()
}
}
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum DatabaseError {
#[error("database storage error: {0}")]
Storage(String),
#[error("SQL error: {message}")]
Sql {
message: String,
},
#[error("a synchronous database call ran from inside an async runtime worker")]
AsyncContext,
#[error("engine {0:?} is not available in this build")]
EngineUnavailable(Engine),
#[error("a database call re-entered a handle already locked by the calling thread")]
Reentrant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Engine {
#[cfg(feature = "engine-sqlite")]
Sqlite,
#[cfg(feature = "engine-turso")]
Turso,
}
#[derive(Debug, Clone, Default)]
pub struct OpenOptions {
engine: Option<Engine>,
}
impl OpenOptions {
pub fn new() -> Self {
Self::default()
}
pub fn engine(mut self, engine: Engine) -> Self {
self.engine = Some(engine);
self
}
}
pub struct Database {
conn: Mutex<Box<dyn EngineConn>>,
holder: AtomicU64,
}
impl Database {
fn from_conn(conn: Box<dyn EngineConn>) -> Self {
Self {
conn: Mutex::new(conn),
holder: AtomicU64::new(UNHELD),
}
}
fn lock_conn(&self) -> Result<ConnGuard<'_>, DatabaseError> {
let me = thread_token();
if self.holder.load(Ordering::Acquire) == me {
return Err(DatabaseError::Reentrant);
}
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
self.holder.store(me, Ordering::Release);
Ok(ConnGuard {
conn,
holder: &self.holder,
})
}
pub fn open(name: &str) -> Result<Self, DatabaseError> {
Self::open_with(name, OpenOptions::default())
}
pub fn open_in_memory() -> Result<Self, DatabaseError> {
let engine = default_engine()?;
let conn = engine::open_conn(engine, Target::Memory)?;
Ok(Self::from_conn(conn))
}
pub fn open_at(path: &Path) -> Result<Self, DatabaseError> {
let engine = default_engine()?;
let conn = engine::open_conn(engine, Target::Path(path.to_path_buf()))?;
Ok(Self::from_conn(conn))
}
pub fn open_with(name: &str, options: OpenOptions) -> Result<Self, DatabaseError> {
let path = resolve_db_path(name)?;
let engine = match options.engine {
Some(engine) => engine,
None => default_engine()?,
};
let conn = engine::open_conn(engine, Target::Path(path))?;
Ok(Self::from_conn(conn))
}
pub fn execute(&self, sql: &str, params: impl IntoParams) -> Result<u64, DatabaseError> {
let mut conn = self.lock_conn()?;
conn.conn().execute(sql, ¶ms.into_params())
}
pub fn query(&self, sql: &str, params: impl IntoParams) -> Result<Vec<Row>, DatabaseError> {
let mut conn = self.lock_conn()?;
conn.conn().query(sql, ¶ms.into_params())
}
pub fn transaction<T>(
&self,
f: impl FnOnce(&Transaction) -> Result<T, DatabaseError>,
) -> Result<T, DatabaseError> {
let mut locked = self.lock_conn()?;
locked.conn().execute("BEGIN", &[])?;
let mut guard = RollbackGuard::new(locked.conn());
let result = {
let txn = Transaction {
conn: RefCell::new(guard.conn()),
};
f(&txn)
};
let value = result?;
guard.conn().execute("COMMIT", &[])?;
guard.disarm();
Ok(value)
}
}
struct ConnGuard<'a> {
conn: MutexGuard<'a, Box<dyn EngineConn>>,
holder: &'a AtomicU64,
}
impl ConnGuard<'_> {
fn conn(&mut self) -> &mut dyn EngineConn {
&mut **self.conn
}
}
impl Drop for ConnGuard<'_> {
fn drop(&mut self) {
self.holder.store(UNHELD, Ordering::Release);
}
}
struct RollbackGuard<'a> {
conn: &'a mut dyn EngineConn,
armed: bool,
}
impl<'a> RollbackGuard<'a> {
fn new(conn: &'a mut dyn EngineConn) -> Self {
Self { conn, armed: true }
}
fn conn(&mut self) -> &mut dyn EngineConn {
&mut *self.conn
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for RollbackGuard<'_> {
fn drop(&mut self) {
if self.armed {
let _ = self.conn.execute("ROLLBACK", &[]);
}
}
}
const UNHELD: u64 = 0;
fn thread_token() -> u64 {
static NEXT: AtomicU64 = AtomicU64::new(UNHELD + 1);
thread_local! {
static TOKEN: u64 = NEXT.fetch_add(1, Ordering::Relaxed);
}
TOKEN.with(|token| *token)
}
pub struct Transaction<'a> {
conn: RefCell<&'a mut dyn EngineConn>,
}
impl Transaction<'_> {
pub fn execute(&self, sql: &str, params: impl IntoParams) -> Result<u64, DatabaseError> {
self.conn.borrow_mut().execute(sql, ¶ms.into_params())
}
pub fn query(&self, sql: &str, params: impl IntoParams) -> Result<Vec<Row>, DatabaseError> {
self.conn.borrow_mut().query(sql, ¶ms.into_params())
}
}
#[cfg(feature = "engine-sqlite")]
fn default_engine() -> Result<Engine, DatabaseError> {
Ok(Engine::Sqlite)
}
#[cfg(all(not(feature = "engine-sqlite"), feature = "engine-turso"))]
fn default_engine() -> Result<Engine, DatabaseError> {
Ok(Engine::Turso)
}
#[cfg(not(any(feature = "engine-sqlite", feature = "engine-turso")))]
fn default_engine() -> Result<Engine, DatabaseError> {
Err(DatabaseError::Storage(
"no SQL engine compiled into this build — enable the `engine-sqlite` or `engine-turso` \
feature"
.into(),
))
}
fn db_file_path(base: &Path, name: &str) -> Result<PathBuf, DatabaseError> {
validate_name(name)?;
Ok(base.join("databases").join(format!("{name}.db")))
}
fn validate_name(name: &str) -> Result<(), DatabaseError> {
if name.is_empty() {
return Err(DatabaseError::Storage(
"database name must not be empty".into(),
));
}
if name.contains('/') || name.contains('\\') {
return Err(DatabaseError::Storage(format!(
"database name {name:?} must not contain a path separator"
)));
}
Ok(())
}
fn resolve_db_path_from(
base: &Path,
legacy_base: Option<&Path>,
name: &str,
) -> Result<PathBuf, DatabaseError> {
let path = db_file_path(base, name)?;
if let Some(legacy_base) = legacy_base
&& !path.try_exists().unwrap_or(true)
{
let legacy_path = db_file_path(legacy_base, name)?;
if legacy_path.try_exists().unwrap_or(false) {
log::debug!(
"frust-database: {} not found, opening legacy {}",
path.display(),
legacy_path.display()
);
return Ok(legacy_path);
}
}
Ok(path)
}
#[cfg(target_os = "android")]
const UNRESOLVED_DATA_DIR: &str = "could not resolve the app data directory: the host shell has not installed the Android directories yet (Database::open must run after FrustSurfaceView.nativeInitPlatform, i.e. from app code, not from a static initializer)";
#[cfg(not(target_os = "android"))]
const UNRESOLVED_DATA_DIR: &str = "could not resolve a user data directory (HOME/APPDATA unset)";
fn resolve_db_path(name: &str) -> Result<PathBuf, DatabaseError> {
let base = frust_paths::data_dir()
.ok_or_else(|| DatabaseError::Storage(UNRESOLVED_DATA_DIR.into()))?;
let path = resolve_db_path_from(&base, frust_paths::legacy_data_dir().as_deref(), name)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| DatabaseError::Storage(format!("creating databases directory: {e}")))?;
}
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn value_from_conversions() {
assert_eq!(Value::from(42i64), Value::Integer(42));
assert_eq!(Value::from(7i32), Value::Integer(7));
assert_eq!(Value::from(3.5f64), Value::Real(3.5));
assert_eq!(Value::from(true), Value::Integer(1));
assert_eq!(Value::from(false), Value::Integer(0));
assert_eq!(Value::from("hi"), Value::Text("hi".to_string()));
assert_eq!(
Value::from(String::from("hi")),
Value::Text("hi".to_string())
);
assert_eq!(Value::from(vec![1u8, 2, 3]), Value::Blob(vec![1, 2, 3]));
assert_eq!(Value::from(None::<i64>), Value::Null);
assert_eq!(Value::from(Some(7i64)), Value::Integer(7));
}
#[test]
fn row_get_and_get_named() {
let row = Row::new(
vec!["id".to_string(), "name".to_string()],
vec![Value::Integer(1), Value::Text("a".to_string())],
);
assert_eq!(row.get(0), Some(&Value::Integer(1)));
assert_eq!(row.get(1), Some(&Value::Text("a".to_string())));
assert_eq!(row.get(2), None);
assert_eq!(row.get_named("name"), Some(&Value::Text("a".to_string())));
assert_eq!(row.get_named("missing"), None);
}
#[test]
fn into_params_shapes() {
assert_eq!(().into_params(), Vec::<Value>::new());
assert_eq!(
[1i64, 2i64].into_params(),
vec![Value::Integer(1), Value::Integer(2)]
);
let values = [Value::Text("a".to_string())];
assert_eq!(values.into_params(), vec![Value::Text("a".to_string())]);
let slice: &[i64] = &[3, 4];
assert_eq!(
slice.into_params(),
vec![Value::Integer(3), Value::Integer(4)]
);
}
#[test]
fn db_file_path_shape() {
let base = Path::new("/tmp/frust-database-test-base");
let path = db_file_path(base, "app").unwrap();
assert_eq!(path, base.join("databases").join("app.db"));
}
#[test]
fn db_file_path_rejects_path_separator() {
assert!(db_file_path(Path::new("/tmp/x"), "a/b").is_err());
assert!(db_file_path(Path::new("/tmp/x"), "a\\b").is_err());
}
#[test]
fn db_file_path_rejects_empty_name() {
assert!(db_file_path(Path::new("/tmp/x"), "").is_err());
}
fn scratch_dir(tag: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"frust-database-test-{}-{tag}-{n}",
std::process::id()
));
fs::create_dir(&dir).expect("scratch dir must not already exist (planted path?)");
dir
}
fn seed_db_file(base: &Path, name: &str) -> PathBuf {
let path = db_file_path(base, name).unwrap();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, b"placeholder").unwrap();
path
}
#[test]
fn resolve_db_path_from_reads_through_to_legacy_file_with_no_app_stem_dir() {
let new_base = scratch_dir("legacy-read-through-new");
let legacy_base = scratch_dir("legacy-read-through-legacy");
let legacy_path = seed_db_file(&legacy_base, "app");
let stem_dir = legacy_base.join(frust_paths::app_stem());
assert!(!stem_dir.exists(), "test fixture must have no app-stem dir");
assert_eq!(
resolve_db_path_from(&new_base, Some(&legacy_base), "app").unwrap(),
legacy_path,
);
let _ = fs::remove_dir_all(&new_base);
let _ = fs::remove_dir_all(&legacy_base);
}
#[test]
fn resolve_db_path_from_prefers_the_new_file_when_both_exist() {
let new_base = scratch_dir("both-new");
let legacy_base = scratch_dir("both-legacy");
let new_path = seed_db_file(&new_base, "app");
let legacy_path = seed_db_file(&legacy_base, "app");
assert_eq!(
resolve_db_path_from(&new_base, Some(&legacy_base), "app").unwrap(),
new_path,
);
assert!(legacy_path.exists(), "legacy file must be left untouched");
let _ = fs::remove_dir_all(&new_base);
let _ = fs::remove_dir_all(&legacy_base);
}
#[test]
#[cfg(unix)]
fn resolve_db_path_from_does_not_divert_when_new_path_is_unstatable() {
use std::os::unix::fs::PermissionsExt;
let new_base = scratch_dir("unstatable-new");
let locked_dir = new_base.join("locked");
fs::create_dir(&locked_dir).unwrap();
fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o000)).unwrap();
let legacy_base = scratch_dir("unstatable-legacy");
let legacy_path = seed_db_file(&legacy_base, "app");
let path = db_file_path(&locked_dir, "app").unwrap();
if path.try_exists().is_ok() {
fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o700)).unwrap();
let _ = fs::remove_dir_all(&new_base);
let _ = fs::remove_dir_all(&legacy_base);
return;
}
let resolved = resolve_db_path_from(&locked_dir, Some(&legacy_base), "app").unwrap();
assert_eq!(
resolved, path,
"an unstatable new path must not divert to the legacy file"
);
assert_ne!(resolved, legacy_path);
fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o700)).unwrap();
let _ = fs::remove_dir_all(&new_base);
let _ = fs::remove_dir_all(&legacy_base);
}
#[test]
fn resolve_db_path_from_uses_the_new_path_when_neither_exists() {
let new_base = scratch_dir("neither-new");
let legacy_base = scratch_dir("neither-legacy");
assert_eq!(
resolve_db_path_from(&new_base, Some(&legacy_base), "app").unwrap(),
db_file_path(&new_base, "app").unwrap(),
);
}
#[test]
fn resolve_db_path_from_without_a_legacy_base_uses_the_new_path() {
let new_base = scratch_dir("no-legacy");
assert_eq!(
resolve_db_path_from(&new_base, None, "app").unwrap(),
db_file_path(&new_base, "app").unwrap(),
);
}
#[test]
fn resolve_db_path_from_rejects_an_invalid_name() {
let new_base = scratch_dir("invalid-name");
assert!(resolve_db_path_from(&new_base, None, "a/b").is_err());
assert!(resolve_db_path_from(&new_base, None, "").is_err());
}
#[test]
#[cfg(feature = "engine-sqlite")]
fn default_engine_prefers_sqlite_when_compiled() {
assert_eq!(default_engine().unwrap(), Engine::Sqlite);
}
#[test]
#[cfg(all(not(feature = "engine-sqlite"), feature = "engine-turso"))]
fn default_engine_falls_back_to_turso() {
assert_eq!(default_engine().unwrap(), Engine::Turso);
}
#[test]
#[cfg(not(any(feature = "engine-sqlite", feature = "engine-turso")))]
fn default_engine_errors_when_nothing_compiled() {
assert!(default_engine().is_err());
}
#[test]
fn unresolved_data_dir_is_non_empty() {
assert!(!UNRESOLVED_DATA_DIR.is_empty());
}
#[test]
#[cfg(not(target_os = "android"))]
fn non_android_unresolved_data_dir_matches_legacy_wording() {
assert_eq!(
UNRESOLVED_DATA_DIR,
"could not resolve a user data directory (HOME/APPDATA unset)"
);
}
}