Skip to main content

mlua_batteries_sqlite/
sql.rs

1//! `std.sql` — SQLite (rusqlite WAL) bridge for Lua scripts.
2//!
3//! Provides:
4//! - `std.sql.query(sql, params?) -> rows`   rows = array of { col_name = value, ... }
5//! - `std.sql.exec(sql, params?)  -> { affected = N, last_id = M }`
6//! - `std.sql.null` — sentinel for SQL NULL on the Lua side
7//!
8//! rusqlite calls are executed inside `tokio::task::spawn_blocking` to avoid
9//! blocking the async runtime.  Lock acquisition is also inside spawn_blocking
10//! to prevent holding a Mutex guard across `.await` (await-holding-lock).
11//!
12//! # Wiring contract
13//!
14//! The host owns the [`rusqlite::Connection`] (file path / `busy_timeout` /
15//! `journal_mode` are host-side concerns, not this crate's) and its
16//! [`rusqlite::InterruptHandle`].  Pass them to [`register`] /
17//! [`register_with`] wrapped in `Arc<Mutex<_>>` / `Arc<_>`.  This crate
18//! does not open the database, does not read environment variables, and
19//! does not attempt to recover from a corrupt connection.
20//!
21//! Which `rusqlite` those types come from is decided by this crate's
22//! dependency — see [`crate::rusqlite`] to name it without a second
23//! dependency that could drift onto another `libsqlite3-sys` cluster.
24//!
25//! # Cancellation integration
26//!
27//! Every query/exec races against the enclosing `task.scope` /
28//! `task.with_timeout`'s
29//! [`CancelToken`](mlua_batteries::task::CancelToken) via
30//! [`mlua_batteries::task::effective_token`].  When the token fires we call
31//! `sqlite3_interrupt` so the blocking thread returns quickly and the
32//! Mutex guard is released.
33
34use std::sync::Arc;
35use std::time::Duration;
36
37use mlua::prelude::*;
38use mlua_batteries::json::{array_metatable, json_to_lua_preserving_null};
39use serde_json::Map;
40use tracing::warn;
41
42use crate::sqlite_backend::rusqlite::{
43    self,
44    types::{Value, ValueRef},
45    Connection, InterruptHandle,
46};
47
48// ---------------------------------------------------------------------------
49// Public API
50// ---------------------------------------------------------------------------
51
52/// Runtime configuration for the SQL/KV bridges.
53///
54/// Stored in `lua.app_data` by [`register_with`] and consulted by
55/// [`race_timeout`] for the per-query timeout.  Shared between `std.sql`
56/// and `std.kv` since both speak to a SQLite connection with identical
57/// timeout semantics.
58#[derive(Clone, Debug)]
59pub struct SqlConfig {
60    /// Per-query timeout.  `None` disables the timeout (the operation
61    /// runs until completion or until the enclosing task cancels).
62    pub query_timeout: Option<Duration>,
63}
64
65impl Default for SqlConfig {
66    fn default() -> Self {
67        Self {
68            query_timeout: Some(Duration::from_millis(5000)),
69        }
70    }
71}
72
73/// Register `std.sql` with default [`SqlConfig`].
74pub fn register(
75    lua: &Lua,
76    conn: Arc<std::sync::Mutex<Connection>>,
77    interrupt: Arc<InterruptHandle>,
78) -> LuaResult<()> {
79    register_with(lua, conn, interrupt, SqlConfig::default())
80}
81
82/// Register `std.sql` with caller-provided [`SqlConfig`].
83///
84/// The config is stored in `lua.app_data`.  `std.kv` (registered via
85/// [`crate::kv::register_with`]) shares the same `SqlConfig` slot, so
86/// calling either `register_with` after the other replaces the previous
87/// config — pass identical configs from the host or only set it once.
88pub fn register_with(
89    lua: &Lua,
90    conn: Arc<std::sync::Mutex<Connection>>,
91    interrupt: Arc<InterruptHandle>,
92    cfg: SqlConfig,
93) -> LuaResult<()> {
94    lua.set_app_data::<SqlConfig>(cfg);
95
96    let sql_tbl = lua.create_table()?;
97
98    // ── std.sql.null ──────────────────────────────────────────────────────
99    // Sentinel that represents SQL NULL on the Lua side (also used for JSON
100    // null in values returned from `sql` / `kv` / other bridges).
101    // `mlua::Value::NULL` is `LightUserData(null_ptr)`, and any equivalent
102    // LightUserData produced from `std::ptr::null_mut()` compares equal via
103    // Lua `==` (lightuserdata equality is pointer equality), so scripts can
104    // write `if row.col == std.sql.null then ... end`.
105    sql_tbl.set("null", LuaValue::NULL)?;
106
107    // ── std.sql.query ─────────────────────────────────────────────────────
108    {
109        let conn = Arc::clone(&conn);
110        let interrupt = Arc::clone(&interrupt);
111        sql_tbl.set(
112            "query",
113            lua.create_async_function(move |lua, (sql, params): (String, Option<LuaTable>)| {
114                let conn = Arc::clone(&conn);
115                let interrupt = Arc::clone(&interrupt);
116                let params_result = params
117                    .map(|t| lua_params_to_values(&t))
118                    .transpose()
119                    .map_err(LuaError::external);
120                async move {
121                    let params_vec = params_result?.unwrap_or_default();
122                    let fut = tokio::task::spawn_blocking(move || {
123                        let guard = lock_conn(&conn);
124                        run_query(&guard, &sql, &params_vec)
125                    });
126                    let timeout = sql_query_timeout(&lua);
127                    let rows = race_timeout(fut, timeout, &interrupt, "sql.query").await?;
128                    rows_to_lua(&lua, rows)
129                }
130            })?,
131        )?;
132    }
133
134    // ── std.sql.exec ──────────────────────────────────────────────────────
135    {
136        let conn = Arc::clone(&conn);
137        let interrupt = Arc::clone(&interrupt);
138        sql_tbl.set(
139            "exec",
140            lua.create_async_function(move |lua, (sql, params): (String, Option<LuaTable>)| {
141                let conn = Arc::clone(&conn);
142                let interrupt = Arc::clone(&interrupt);
143                let params_result = params
144                    .map(|t| lua_params_to_values(&t))
145                    .transpose()
146                    .map_err(LuaError::external);
147                async move {
148                    let params_vec = params_result?.unwrap_or_default();
149                    let fut = tokio::task::spawn_blocking(move || {
150                        let guard = lock_conn(&conn);
151                        run_exec(&guard, &sql, &params_vec)
152                    });
153                    let timeout = sql_query_timeout(&lua);
154                    let (affected, last_id) =
155                        race_timeout(fut, timeout, &interrupt, "sql.exec").await?;
156
157                    let result_tbl = lua.create_table()?;
158                    result_tbl.set("affected", affected as i64)?;
159                    result_tbl.set("last_id", last_id)?;
160                    Ok(LuaValue::Table(result_tbl))
161                }
162            })?,
163        )?;
164    }
165
166    let std_ns: LuaTable = lua.globals().get("std")?;
167    std_ns.set("sql", sql_tbl)?;
168    Ok(())
169}
170
171pub(crate) fn sql_query_timeout(lua: &Lua) -> Option<Duration> {
172    lua.app_data_ref::<SqlConfig>()
173        .map(|c| c.query_timeout)
174        .unwrap_or_else(|| SqlConfig::default().query_timeout)
175}
176
177// ---------------------------------------------------------------------------
178// Helpers shared with `std.kv` (re-exported under `pub(crate)`)
179// ---------------------------------------------------------------------------
180
181/// Lock the shared Connection mutex without panicking.
182///
183/// On `PoisonError` we log and recover via `into_inner()`. Poison here means a
184/// previous blocking thread panicked while holding the guard; for a local
185/// agent-runtime SQLite (single-process, embedded) the safest path is to log
186/// and keep serving rather than tear the host down.
187pub(crate) fn lock_conn(
188    conn: &std::sync::Mutex<Connection>,
189) -> std::sync::MutexGuard<'_, Connection> {
190    conn.lock().unwrap_or_else(|poisoned| {
191        warn!("sql conn mutex was poisoned; recovering via into_inner");
192        poisoned.into_inner()
193    })
194}
195
196/// Race an `spawn_blocking` SQL operation against (a) the enclosing task's
197/// cancel token and (b) the configured query timeout.
198///
199/// When either fires first we call `sqlite3_interrupt` via the stored handle
200/// so the blocking thread returns quickly, releases the Mutex guard, and
201/// frees the connection for subsequent calls.
202///
203/// # Threading model
204///
205/// The returned future is `!Send`: the cancel token held by
206/// `effective_token()` is `Rc<_>`, and the whole bridge surface is
207/// single-threaded by design.  Callers must `.await` this future on the
208/// same `LocalSet` that owns the VM; wrapping it in `tokio::spawn` will
209/// fail to compile.
210pub(crate) async fn race_timeout<T, F>(
211    fut: F,
212    timeout: Option<Duration>,
213    interrupt: &InterruptHandle,
214    op: &'static str,
215) -> LuaResult<T>
216where
217    F: std::future::Future<Output = Result<Result<T, String>, tokio::task::JoinError>>,
218{
219    let wait = async {
220        match timeout {
221            Some(d) => match tokio::time::timeout(d, fut).await {
222                Ok(j) => Ok(j),
223                Err(_) => Err(d),
224            },
225            None => Ok(fut.await),
226        }
227    };
228
229    let wait_result = match mlua_batteries::task::effective_token() {
230        Some(t) => tokio::select! {
231            biased;
232            _ = t.cancelled() => {
233                interrupt.interrupt();
234                warn!(op, "cancelled by enclosing task");
235                return Err(LuaError::external(format!(
236                    "task cancelled during {op}"
237                )));
238            }
239            r = wait => r,
240        },
241        None => wait.await,
242    };
243
244    let joined = match wait_result {
245        Ok(j) => j,
246        Err(d) => {
247            interrupt.interrupt();
248            warn!(op, timeout_ms = d.as_millis() as u64, "operation timeout");
249            return Err(LuaError::external(format!(
250                "{op} timeout ({}ms)",
251                d.as_millis()
252            )));
253        }
254    };
255
256    joined
257        .map_err(|e| {
258            warn!(op, error = %e, "spawn_blocking join error");
259            LuaError::external(format!("spawn_blocking: {e}"))
260        })?
261        .map_err(|e| {
262            warn!(op, error = %e, "execution error");
263            LuaError::external(e)
264        })
265}
266
267// ---------------------------------------------------------------------------
268// Param conversion: Lua → rusqlite
269// ---------------------------------------------------------------------------
270
271/// Convert a Lua array table to `Vec<rusqlite::types::Value>`.
272fn lua_params_to_values(tbl: &LuaTable) -> Result<Vec<Value>, String> {
273    let len = tbl.raw_len();
274    let mut result = Vec::with_capacity(len);
275    for i in 1..=len {
276        let v: LuaValue = tbl
277            .raw_get(i)
278            .map_err(|e| format!("params table access error: {e}"))?;
279        let sql_val = match v {
280            LuaValue::Nil => Value::Null,
281            LuaValue::Boolean(b) => Value::Integer(if b { 1 } else { 0 }),
282            LuaValue::Integer(n) => Value::Integer(n),
283            LuaValue::Number(f) => {
284                if !f.is_finite() {
285                    return Err(format!(
286                        "SQL param #{i} is non-finite ({f}); NaN and ±Inf are not supported"
287                    ));
288                }
289                Value::Real(f)
290            }
291            LuaValue::String(s) => Value::Text(
292                s.to_str()
293                    .map_err(|e| format!("param string encoding error: {e}"))?
294                    .to_string(),
295            ),
296            other => return Err(format!("unsupported SQL param type: {}", other.type_name())),
297        };
298        result.push(sql_val);
299    }
300    Ok(result)
301}
302
303// ---------------------------------------------------------------------------
304// Query/Exec execution
305// ---------------------------------------------------------------------------
306
307fn run_query(
308    conn: &Connection,
309    sql: &str,
310    params: &[Value],
311) -> Result<Vec<Map<String, serde_json::Value>>, String> {
312    let mut stmt = conn.prepare(sql).map_err(|e| format!("sql error: {e}"))?;
313
314    let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
315
316    let mut rows = stmt
317        .query(rusqlite::params_from_iter(params.iter()))
318        .map_err(|e| format!("sql error: {e}"))?;
319
320    let mut result = Vec::new();
321    while let Some(row) = rows.next().map_err(|e| format!("sql error: {e}"))? {
322        let mut map = serde_json::Map::new();
323        for (i, name) in col_names.iter().enumerate() {
324            let val = match row.get_ref(i).map_err(|e| format!("sql error: {e}"))? {
325                ValueRef::Null => serde_json::Value::Null,
326                ValueRef::Integer(n) => serde_json::Value::Number(n.into()),
327                ValueRef::Real(f) => serde_json::Number::from_f64(f)
328                    .map(serde_json::Value::Number)
329                    .ok_or_else(|| {
330                        format!(
331                            "non-finite REAL in column '{}' ({f}); \
332                             NaN / ±Inf cannot be represented in JSON/Lua",
333                            col_names[i]
334                        )
335                    })?,
336                ValueRef::Text(b) => {
337                    let s = std::str::from_utf8(b)
338                        .map_err(|e| format!("non-UTF-8 TEXT in column '{}': {e}", col_names[i]))?;
339                    serde_json::Value::String(s.to_string())
340                }
341                ValueRef::Blob(_) => return Err("blob columns not supported".to_string()),
342            };
343            map.insert(name.clone(), val);
344        }
345        result.push(map);
346    }
347
348    Ok(result)
349}
350
351fn run_exec(conn: &Connection, sql: &str, params: &[Value]) -> Result<(usize, i64), String> {
352    let affected = conn
353        .execute(sql, rusqlite::params_from_iter(params.iter()))
354        .map_err(|e| format!("sql error: {e}"))?;
355    let last_id = conn.last_insert_rowid();
356    Ok((affected, last_id))
357}
358
359// ---------------------------------------------------------------------------
360// Row → Lua conversion (NULL-preserving variant)
361// ---------------------------------------------------------------------------
362
363/// Convert a list of column-name→JSON-value maps into a Lua array table.
364///
365/// NULL columns arrive as `serde_json::Value::Null` and are translated by
366/// [`json_to_lua_preserving_null`] into the `LightUserData(null_ptr)` sentinel
367/// (exposed to Lua as `std.sql.null`), which keeps the column present in
368/// the row table.  This preserves the distinction between "column is NULL"
369/// and "column was not in the query".
370///
371/// A zero-row result carries the shared `__jsontype = "array"` metatable so
372/// that `json.encode(rows)` renders `[]` rather than `{}`.
373pub(crate) fn rows_to_lua(
374    lua: &Lua,
375    rows: Vec<Map<String, serde_json::Value>>,
376) -> LuaResult<LuaValue> {
377    let arr = lua.create_table()?;
378    let row_count = rows.len();
379    for (i, row_map) in rows.into_iter().enumerate() {
380        let row_tbl = lua.create_table()?;
381        for (col, val) in row_map {
382            let lua_val = json_to_lua_preserving_null(lua, val)?;
383            row_tbl.set(col.as_str(), lua_val)?;
384        }
385        arr.set(i + 1, row_tbl)?;
386    }
387    if row_count == 0 {
388        arr.set_metatable(Some(array_metatable(lua)?))?;
389    }
390    Ok(LuaValue::Table(arr))
391}