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, ¶ms_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, ¶ms_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}