You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
516 lines
21 KiB
516 lines
21 KiB
use std::path::Path;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use mlua::prelude::LuaResult;
|
|
use mlua::{Lua, Table as LuaTable, UserData, UserDataMethods, Value as LuaValue};
|
|
use rusqlite::types::Value;
|
|
|
|
use super::utils::is_null;
|
|
|
|
const SQLITE_LUA: &str = include_str!("../../lua/stdlib/sqlite.lua");
|
|
|
|
/// Shared, lockable handle to a SQLite connection.
|
|
///
|
|
/// `Option` allows an explicit `close()` (set to `None`); any later use then
|
|
/// reports a clear "connection is closed" error. `Arc<Mutex<>>` is required
|
|
/// because mlua's async `UserData` must be `Send + Sync + 'static`, and it lets
|
|
/// the blocking SQLite work run on a `spawn_blocking` thread.
|
|
type ConnHandle = Arc<Mutex<Option<rusqlite::Connection>>>;
|
|
|
|
struct SqliteConn(ConnHandle);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Error type carried back across the `spawn_blocking` boundary. Unlike
|
|
/// `mlua::Error` it is trivially `Send + Sync`, so it travels cleanly between
|
|
/// threads before being surfaced to Lua via `mlua::Error::external`.
|
|
#[derive(Debug)]
|
|
enum DbError {
|
|
Closed,
|
|
Sqlite(rusqlite::Error),
|
|
Other(String),
|
|
}
|
|
|
|
impl std::fmt::Display for DbError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
DbError::Closed => write!(f, "sqlite: connection is closed"),
|
|
DbError::Sqlite(e) => write!(f, "sqlite: {e}"),
|
|
DbError::Other(s) => write!(f, "{s}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for DbError {}
|
|
|
|
impl From<rusqlite::Error> for DbError {
|
|
fn from(e: rusqlite::Error) -> Self {
|
|
DbError::Sqlite(e)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parameter binding
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Parameters bound to a statement, already converted to a `Send` form so they
|
|
/// can cross into the blocking thread.
|
|
enum BoundParams {
|
|
None,
|
|
/// Positional `?` parameters, in order.
|
|
Positional(Vec<Value>),
|
|
/// Named `:name` / `@name` / `$name` parameters (sigil stripped from the key).
|
|
Named(Vec<(String, Value)>),
|
|
}
|
|
|
|
/// Convert an optional Lua params table into `BoundParams`.
|
|
///
|
|
/// A table with any string key is treated as named; a table with only integer
|
|
/// keys is positional. Mixing the two is an error, as is a non-contiguous
|
|
/// positional array. Runs on the Lua side (before `spawn_blocking`) because it
|
|
/// touches non-`Send` Lua values.
|
|
fn convert_params(params: Option<LuaTable>) -> LuaResult<BoundParams> {
|
|
let table = match params {
|
|
None => return Ok(BoundParams::None),
|
|
Some(t) => t,
|
|
};
|
|
|
|
let mut positional: Vec<(i64, Value)> = Vec::new();
|
|
let mut named: Vec<(String, Value)> = Vec::new();
|
|
|
|
for pair in table.pairs::<LuaValue, LuaValue>() {
|
|
let (key, value) = pair?;
|
|
let value = lua_value_to_sqlite(value)?;
|
|
match key {
|
|
LuaValue::Integer(i) => positional.push((i, value)),
|
|
LuaValue::Number(n) if n.fract() == 0.0 => positional.push((n as i64, value)),
|
|
LuaValue::String(s) => named.push((s.to_str()?.to_owned(), value)),
|
|
other => {
|
|
return Err(mlua::Error::external(format!(
|
|
"sqlite: unsupported parameter key of type '{}'",
|
|
other.type_name()
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
match (positional.is_empty(), named.is_empty()) {
|
|
(true, true) => Ok(BoundParams::None),
|
|
(false, false) => Err(mlua::Error::external(
|
|
"sqlite: parameter table mixes positional (array) and named keys",
|
|
)),
|
|
(true, false) => Ok(BoundParams::Named(named)),
|
|
(false, true) => {
|
|
// Require a gapless 1..=n sequence so the `?` placeholders line up.
|
|
positional.sort_by_key(|(i, _)| *i);
|
|
for (offset, (index, _)) in positional.iter().enumerate() {
|
|
if *index != offset as i64 + 1 {
|
|
return Err(mlua::Error::external(
|
|
"sqlite: positional parameters must form a contiguous array starting at index 1",
|
|
));
|
|
}
|
|
}
|
|
Ok(BoundParams::Positional(
|
|
positional.into_iter().map(|(_, v)| v).collect(),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Map a Lua value to the SQLite value it binds as.
|
|
fn lua_value_to_sqlite(value: LuaValue) -> LuaResult<Value> {
|
|
// `utils.NULL` is the only practical way to bind a SQL NULL, since a literal
|
|
// `nil` cannot live inside a Lua table.
|
|
if is_null(&value) {
|
|
return Ok(Value::Null);
|
|
}
|
|
match value {
|
|
LuaValue::Nil => Ok(Value::Null),
|
|
LuaValue::Boolean(b) => Ok(Value::Integer(b as i64)),
|
|
LuaValue::Integer(i) => Ok(Value::Integer(i)),
|
|
LuaValue::Number(f) => Ok(Value::Real(f)),
|
|
// Lua strings are byte strings. Valid UTF-8 binds as TEXT; anything else
|
|
// (raw bytes, embedded NULs) binds as a BLOB so binary data round-trips
|
|
// instead of failing the UTF-8 conversion.
|
|
LuaValue::String(s) => {
|
|
let bytes = s.as_bytes();
|
|
match std::str::from_utf8(&bytes) {
|
|
Ok(text) => Ok(Value::Text(text.to_owned())),
|
|
Err(_) => Ok(Value::Blob(bytes.to_vec())),
|
|
}
|
|
}
|
|
other => Err(mlua::Error::external(format!(
|
|
"sqlite: cannot bind value of type '{}'",
|
|
other.type_name()
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Bind already-converted parameters onto a prepared statement.
|
|
fn bind_params(stmt: &mut rusqlite::CachedStatement, params: &BoundParams) -> Result<(), DbError> {
|
|
match params {
|
|
BoundParams::None => Ok(()),
|
|
BoundParams::Positional(values) => {
|
|
let expected = stmt.parameter_count();
|
|
if values.len() != expected {
|
|
return Err(DbError::Other(format!(
|
|
"sqlite: statement expects {expected} positional parameter(s) but {} were given",
|
|
values.len()
|
|
)));
|
|
}
|
|
for (offset, value) in values.iter().enumerate() {
|
|
stmt.raw_bind_parameter(offset + 1, value)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
BoundParams::Named(map) => {
|
|
for index in 1..=stmt.parameter_count() {
|
|
// `parameter_name` returns the placeholder with its sigil, e.g. ":id";
|
|
// `None` means an anonymous `?`, which can't be filled by name.
|
|
let name = stmt
|
|
.parameter_name(index)
|
|
.ok_or_else(|| {
|
|
DbError::Other(format!(
|
|
"sqlite: statement has a positional placeholder at position {index} but named parameters were given"
|
|
))
|
|
})?
|
|
.to_string();
|
|
let key = &name[1..]; // strip the leading sigil
|
|
let value = map
|
|
.iter()
|
|
.find(|(k, _)| k == key)
|
|
.map(|(_, v)| v)
|
|
.ok_or_else(|| {
|
|
DbError::Other(format!("sqlite: missing value for named parameter '{name}'"))
|
|
})?;
|
|
stmt.raw_bind_parameter(index, value)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SQLite value -> Lua
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn sqlite_value_to_lua(lua: &Lua, value: Value) -> LuaResult<LuaValue> {
|
|
Ok(match value {
|
|
Value::Null => LuaValue::Nil,
|
|
Value::Integer(i) => LuaValue::Integer(i),
|
|
Value::Real(f) => LuaValue::Number(f),
|
|
Value::Text(s) => LuaValue::String(lua.create_string(&s)?),
|
|
Value::Blob(b) => LuaValue::String(lua.create_string(&b)?),
|
|
})
|
|
}
|
|
|
|
/// One database row as (column name, value) pairs.
|
|
type Record = Vec<(String, Value)>;
|
|
|
|
fn record_to_lua(lua: &Lua, record: Record) -> LuaResult<LuaTable> {
|
|
let table = lua.create_table_with_capacity(0, record.len())?;
|
|
for (name, value) in record {
|
|
// A SQL NULL maps to Lua nil; setting a key to nil leaves it absent,
|
|
// which reads back as nil — exactly what we want.
|
|
if matches!(value, Value::Null) {
|
|
continue;
|
|
}
|
|
table.raw_set(name, sqlite_value_to_lua(lua, value)?)?;
|
|
}
|
|
Ok(table)
|
|
}
|
|
|
|
fn rows_to_lua(lua: &Lua, rows: Vec<Record>) -> LuaResult<LuaTable> {
|
|
let array = lua.create_table_with_capacity(rows.len(), 0)?;
|
|
for (i, record) in rows.into_iter().enumerate() {
|
|
array.raw_set(i + 1, record_to_lua(lua, record)?)?;
|
|
}
|
|
Ok(array)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Blocking workers (run on the tokio blocking pool)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Reject SQL that is empty/comment-only or contains more than one statement.
|
|
///
|
|
/// `execute`/`query`/`queryOne` compile a single statement via `prepare_cached`,
|
|
/// which silently ignores anything after the first — so `execute("A; B")` would
|
|
/// run only `A` and drop `B` with no error, and `execute("")` surfaces SQLite's
|
|
/// bare "not an error". `Batch` uses SQLite's own tokenizer to count statements
|
|
/// (skipping comments/whitespace), giving a clear message instead. Use
|
|
/// `executeBatch` to run multiple statements on purpose.
|
|
fn ensure_single_statement(conn: &rusqlite::Connection, sql: &str) -> Result<(), DbError> {
|
|
let mut batch = rusqlite::Batch::new(conn, sql);
|
|
let mut count = 0usize;
|
|
while batch.next()?.is_some() {
|
|
count += 1;
|
|
if count > 1 {
|
|
return Err(DbError::Other(
|
|
"sqlite: expected a single SQL statement (use executeBatch for multiple)".to_string(),
|
|
));
|
|
}
|
|
}
|
|
if count == 0 {
|
|
return Err(DbError::Other("sqlite: no SQL statement to run".to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Run an INSERT/UPDATE/DELETE-style statement, returning rows changed.
|
|
async fn run_execute(handle: ConnHandle, sql: String, params: BoundParams) -> Result<usize, DbError> {
|
|
tokio::task::spawn_blocking(move || -> Result<usize, DbError> {
|
|
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
|
|
let conn = guard.as_ref().ok_or(DbError::Closed)?;
|
|
ensure_single_statement(conn, &sql)?;
|
|
let mut stmt = conn.prepare_cached(&sql)?;
|
|
bind_params(&mut stmt, ¶ms)?;
|
|
Ok(stmt.raw_execute()?)
|
|
})
|
|
.await
|
|
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
|
|
}
|
|
|
|
/// Run every statement in `sql` in order, with no parameter binding.
|
|
async fn run_execute_batch(handle: ConnHandle, sql: String) -> Result<(), DbError> {
|
|
tokio::task::spawn_blocking(move || -> Result<(), DbError> {
|
|
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
|
|
let conn = guard.as_ref().ok_or(DbError::Closed)?;
|
|
conn.execute_batch(&sql)?;
|
|
Ok(())
|
|
})
|
|
.await
|
|
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
|
|
}
|
|
|
|
/// Run a SELECT-style statement, materializing every row.
|
|
async fn run_query(handle: ConnHandle, sql: String, params: BoundParams) -> Result<Vec<Record>, DbError> {
|
|
tokio::task::spawn_blocking(move || -> Result<Vec<Record>, DbError> {
|
|
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
|
|
let conn = guard.as_ref().ok_or(DbError::Closed)?;
|
|
ensure_single_statement(conn, &sql)?;
|
|
let mut stmt = conn.prepare_cached(&sql)?;
|
|
// Column names must be captured before the mutable borrows below.
|
|
let columns: Vec<String> = stmt.column_names().into_iter().map(String::from).collect();
|
|
bind_params(&mut stmt, ¶ms)?;
|
|
|
|
let mut out = Vec::new();
|
|
let mut rows = stmt.raw_query();
|
|
while let Some(row) = rows.next()? {
|
|
let mut record = Vec::with_capacity(columns.len());
|
|
for (i, name) in columns.iter().enumerate() {
|
|
record.push((name.clone(), row.get::<_, Value>(i)?));
|
|
}
|
|
out.push(record);
|
|
}
|
|
Ok(out)
|
|
})
|
|
.await
|
|
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UserData methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
impl UserData for SqliteConn {
|
|
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
|
|
// I/O-bound methods run on a blocking thread so SQLite never stalls the
|
|
// tokio event loop.
|
|
methods.add_async_method("execute", |_, this, (sql, params): (String, Option<LuaTable>)| {
|
|
let handle = this.0.clone();
|
|
let bound = convert_params(params);
|
|
async move {
|
|
let changed = run_execute(handle, sql, bound?).await.map_err(mlua::Error::external)?;
|
|
Ok(changed as i64)
|
|
}
|
|
});
|
|
|
|
methods.add_async_method("executeBatch", |_, this, sql: String| {
|
|
let handle = this.0.clone();
|
|
async move {
|
|
run_execute_batch(handle, sql).await.map_err(mlua::Error::external)?;
|
|
Ok(())
|
|
}
|
|
});
|
|
|
|
methods.add_async_method("query", |lua, this, (sql, params): (String, Option<LuaTable>)| {
|
|
let handle = this.0.clone();
|
|
let bound = convert_params(params);
|
|
async move {
|
|
let rows = run_query(handle, sql, bound?).await.map_err(mlua::Error::external)?;
|
|
rows_to_lua(&lua, rows)
|
|
}
|
|
});
|
|
|
|
methods.add_async_method("queryOne", |lua, this, (sql, params): (String, Option<LuaTable>)| {
|
|
let handle = this.0.clone();
|
|
let bound = convert_params(params);
|
|
async move {
|
|
let mut rows = run_query(handle, sql, bound?).await.map_err(mlua::Error::external)?;
|
|
if rows.is_empty() {
|
|
Ok(LuaValue::Nil)
|
|
} else {
|
|
Ok(LuaValue::Table(record_to_lua(&lua, rows.swap_remove(0))?))
|
|
}
|
|
}
|
|
});
|
|
|
|
// These only read in-memory connection state, but they still contend on
|
|
// the same mutex that a running query holds for its whole duration. Doing
|
|
// the lock on the blocking pool (not inline) keeps the tokio event loop
|
|
// responsive: a `close()` next to a long query no longer freezes every
|
|
// other coroutine and timer.
|
|
methods.add_async_method("lastInsertRowid", |_, this, ()| {
|
|
let handle = this.0.clone();
|
|
async move {
|
|
tokio::task::spawn_blocking(move || -> Result<i64, DbError> {
|
|
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
|
|
Ok(guard.as_ref().ok_or(DbError::Closed)?.last_insert_rowid())
|
|
})
|
|
.await
|
|
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
|
|
.map_err(mlua::Error::external)
|
|
}
|
|
});
|
|
|
|
methods.add_async_method("changes", |_, this, ()| {
|
|
let handle = this.0.clone();
|
|
async move {
|
|
tokio::task::spawn_blocking(move || -> Result<i64, DbError> {
|
|
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
|
|
Ok(guard.as_ref().ok_or(DbError::Closed)?.changes() as i64)
|
|
})
|
|
.await
|
|
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
|
|
.map_err(mlua::Error::external)
|
|
}
|
|
});
|
|
|
|
methods.add_async_method("close", |_, this, ()| {
|
|
let handle = this.0.clone();
|
|
async move {
|
|
// Dropping the connection releases the SQLite handle; later use errors.
|
|
// Done on the blocking pool so it waits behind an in-flight query
|
|
// there rather than stalling the event loop.
|
|
tokio::task::spawn_blocking(move || {
|
|
*handle.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
|
})
|
|
.await
|
|
.map_err(|e| mlua::Error::external(format!("sqlite: background task failed: {e}")))
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Path classification for the fs permission system
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Databases that live in memory (or in an anonymous temp file SQLite manages
|
|
/// itself): `""`, `":memory:"`, and their `file:` URI spellings. rusqlite's
|
|
/// default open flags include `SQLITE_OPEN_URI`, so URIs are live syntax.
|
|
fn is_memory_db(path: &str) -> bool {
|
|
if path.is_empty() || path == ":memory:" {
|
|
return true;
|
|
}
|
|
let Some(rest) = path.strip_prefix("file:") else {
|
|
return false;
|
|
};
|
|
let (file, query) = rest.split_once('?').unwrap_or((rest, ""));
|
|
file == ":memory:" || query.split('&').any(|kv| kv == "mode=memory")
|
|
}
|
|
|
|
/// The filesystem path of a file-backed database: strips the `file:` URI
|
|
/// scheme, authority and query, leaving what SQLite would open.
|
|
fn db_file_path(path: &str) -> &str {
|
|
let Some(rest) = path.strip_prefix("file:") else {
|
|
return path;
|
|
};
|
|
let rest = rest.split_once('?').map_or(rest, |(file, _query)| file);
|
|
// "file://localhost/p" and "file:///p" carry an (empty) authority;
|
|
// "file:/p" and "file:p" do not.
|
|
if let Some(after) = rest.strip_prefix("//") {
|
|
let (authority, p) = after.split_once('/').unwrap_or((after, ""));
|
|
match authority {
|
|
"" | "localhost" => return &rest[2 + authority.len()..],
|
|
_ => return p, // nonsensical host: best effort
|
|
}
|
|
}
|
|
rest
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Installation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub(super) fn install(lua: &Lua) -> LuaResult<()> {
|
|
let sqlite = lua.create_table()?;
|
|
|
|
sqlite.set(
|
|
"connect",
|
|
lua.create_async_function(|lua, path: String| async move {
|
|
// File-backed databases fall under the fs permission system.
|
|
// One combined read/write ask on the database's directory: SQLite
|
|
// opens read-write-create and drops WAL/journal files next to the
|
|
// file, so a read-only grant would be a lie. Memory-class
|
|
// databases touch no user-reachable file and stay exempt (they
|
|
// keep working even under --no-fs / --sandbox).
|
|
if !is_memory_db(&path) {
|
|
let (dir, _target) =
|
|
super::fs::write_target("sqlite.connect", Path::new(db_file_path(&path)))
|
|
.await?;
|
|
super::fs::ensure_access(&lua, "sqlite.connect", dir, super::fs::Wants::READ_WRITE)
|
|
.await?;
|
|
}
|
|
// Opening a file-backed database touches disk, so do it on the
|
|
// blocking pool rather than the tokio event loop.
|
|
let conn = tokio::task::spawn_blocking(move || rusqlite::Connection::open(&path))
|
|
.await
|
|
.map_err(|e| mlua::Error::external(format!("sqlite.connect: background task failed: {e}")))?
|
|
.map_err(|e| mlua::Error::external(format!("sqlite.connect: {e}")))?;
|
|
Ok(SqliteConn(Arc::new(Mutex::new(Some(conn)))))
|
|
})?,
|
|
)?;
|
|
|
|
lua.globals().set("sqlite", sqlite)?;
|
|
|
|
// The Lua layer adds the transaction helpers (begin/commit/rollback/
|
|
// transaction) onto the connection's shared method table. mlua hides the
|
|
// metatable from Lua (`__metatable` is set), so we reach the `__index`
|
|
// method table from Rust and hand it to the Lua chunk. Every connection of
|
|
// this type shares that one table, so the helpers apply to all of them. A
|
|
// closed (None) sample handle opens no database and is cheap to discard.
|
|
let init: mlua::Function = lua.load(SQLITE_LUA).set_name("@[stdlib/sqlite]").eval()?;
|
|
let sample = lua.create_userdata(SqliteConn(Arc::new(Mutex::new(None))))?;
|
|
let methods: LuaTable = sample.metatable()?.get("__index")?;
|
|
init.call::<()>(methods)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn memory_db_detection() {
|
|
for mem in ["", ":memory:", "file::memory:", "file::memory:?cache=shared", "file:x.db?mode=memory", "file:x.db?cache=shared&mode=memory"] {
|
|
assert!(is_memory_db(mem), "{mem:?} should be memory-class");
|
|
}
|
|
for disk in [":memory:x", "x.db", "/tmp/x.db", "file:x.db", "file:/tmp/x.db?cache=shared", "file:x.db?mode=memoryz"] {
|
|
assert!(!is_memory_db(disk), "{disk:?} should be file-backed");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn db_file_path_strips_uri_wrapping() {
|
|
assert_eq!(db_file_path("/tmp/x.db"), "/tmp/x.db");
|
|
assert_eq!(db_file_path("x.db"), "x.db");
|
|
assert_eq!(db_file_path("file:x.db"), "x.db");
|
|
assert_eq!(db_file_path("file:/tmp/x.db"), "/tmp/x.db");
|
|
assert_eq!(db_file_path("file:///tmp/x.db"), "/tmp/x.db");
|
|
assert_eq!(db_file_path("file://localhost/tmp/x.db"), "/tmp/x.db");
|
|
assert_eq!(db_file_path("file:/tmp/x.db?cache=shared&vfs=unix"), "/tmp/x.db");
|
|
}
|
|
}
|
|
|