diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1ab0f03 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(echo:*)", + "Bash(git ls-files:*)", + "Bash(cat:*)", + "Bash(cargo:*)", + "Bash(ls:*)", + "Bash(tree:*)", + "Bash(find:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 793e3f2..c2e04bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,12 +12,25 @@ dependencies = [ "log", "mlua", "reqwest", + "rusqlite", "serde", "serde_json", "thiserror", "tokio", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -408,6 +421,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -520,12 +545,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -766,7 +809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -888,6 +931,17 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.2" @@ -1323,6 +1377,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1857,6 +1925,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index a46127d..818b852 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ log = "0.4" clap = { version = "4.6.1", features = ["derive"] } mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", "anyhow"]} reqwest = "0.13.4" +rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" thiserror = "2.0.18" diff --git a/README.md b/README.md index aa5f39c..371faf8 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,35 @@ # `a` -A Swiss army knife for everyday hacks. `a` embeds a Lua interpreter and wraps it in a rich standard library and real IO access, so a small script can reach the network, a database, the filesystem, and the terminal without any setup. +`a` embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the network, a database, the filesystem, and the terminal without additional setup. ```sh a file.lua ``` -That's the whole interface. The script is the program. +The interface is a single command: `a` runs the script at the given path. ## What it is -`a` is not a Lua implementation; it embeds [Lua 5.5](https://www.lua.org/) (via [`mlua`](https://github.com/mlua-rs/mlua)) and adds the parts stock Lua leaves out. Plain Lua is small and pleasant but ships almost no standard library and no way to talk to the outside world without C modules and a build toolchain. `a` fills that in with a standard library built partly in Rust and partly in Lua, plus the IO access that makes scripts actually useful. +`a` is not a Lua implementation; it embeds [Lua 5.5](https://www.lua.org/) (via [`mlua`](https://github.com/mlua-rs/mlua)) and adds the parts stock Lua omits. Plain Lua ships almost no standard library and no way to reach the outside world without C modules and a build toolchain. `a` provides a standard library written partly in Rust and partly in Lua, together with IO access. -The result is a single binary you point at a `.lua` file. No package manager, no build step, no project scaffolding, just a script and the tools it needs to do real work. +The result is a single binary that runs a `.lua` file. There is no package manager, build step, or project scaffolding. ## The standard library -This is the point of `a`. Planned surface: +The standard library is the main purpose of `a`. Planned surface: -- **Networking** — HTTP client, WebSocket, and MQTT (3.1.1 and 5), with high-level APIs. -- **SQLite** — a real embedded database one call away. -- **Filesystem** — ergonomic read/write/glob/walk, without the ceremony of stock Lua's `io`. -- **Terminal UI** — interactive line and full-screen TUI in the spirit of `ncurses`, but without the awkwardness. +- **Networking** — HTTP client, WebSocket, and MQTT (3.1.1 and 5). +- **SQLite** — access to embedded SQLite databases. +- **Filesystem** — read/write/glob/walk without stock Lua's `io` boilerplate. +- **Terminal UI** — interactive line editing and full-screen TUI, comparable to `ncurses`. -Each should feel native: the common case is one obvious call, not a setup ritual. +The intent is for the common case to be a single call rather than a multi-step setup. ## How it works - Embeds Lua 5.5 via `mlua`, on an async core ([`tokio`](https://tokio.rs/)) so IO-heavy scripts don't block. - A single self-contained binary. No LuaRocks, no make, no virtualenv. -- Scripts run in a **sandboxed** environment modeled on [Luau's safe environment](https://luau.org/sandbox): `io`, `package`, and `debug` are not loaded; `os` is trimmed to its time functions; the bytecode/chunk-loading escape hatches (`load`, `loadfile`, `dofile`, `string.dump`) are removed. The library `a` provides is the sanctioned way to reach the outside world. +- Scripts run in a **sandboxed** environment modeled on [Luau's safe environment](https://luau.org/sandbox): `io`, `package`, and `debug` are not loaded; `os` is trimmed to its time functions; the bytecode/chunk-loading escape hatches (`load`, `loadfile`, `dofile`, `string.dump`) are removed. The library `a` provides is the supported way to reach the outside world. - A leading `#!` shebang line is skipped, so scripts can be made executable directly. ## Building diff --git a/docs/sqlite.md b/docs/sqlite.md new file mode 100644 index 0000000..7442ffb --- /dev/null +++ b/docs/sqlite.md @@ -0,0 +1,258 @@ +# `sqlite` + +The `sqlite` module provides access to [SQLite](https://sqlite.org/) databases. +SQLite is compiled into the binary, so no system library is required. The module +handles type conversion and parameter binding; it is not an ORM. You open a +connection and call methods on it. + +```lua +local con = sqlite.connect(":memory:") + +con:execute("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)") +con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Alice", 30}) + +for _, row in ipairs(con:query("SELECT * FROM people")) do + print(row.id, row.name, row.age) +end +``` + +## Connecting + +```lua +local con = sqlite.connect(path) +``` + +`path` is a filename (`"data.sqlite"`), created if it does not exist, or the special +string `":memory:"` for a private in-memory database that vanishes when closed. The +returned connection is an object you call methods on with `:` syntax. + +The connection closes automatically when it is garbage-collected. Call +[`con:close()`](#conclose) to release it eagerly. + +## Queries + +### `con:execute(sql [, params])` + +Run a statement that changes data (`INSERT`, `UPDATE`, `DELETE`, `CREATE`, …). +Returns the number of rows changed as an integer. + +```lua +local changed = con:execute("UPDATE people SET age = age + 1 WHERE name = ?", {"Alice"}) +-- changed == 1 +``` + +### `con:query(sql [, params])` + +Run a `SELECT` and return **all** matching rows as an array of tables. Each row is a +table keyed by column name. An empty result is an empty array (`#rows == 0`). + +```lua +local rows = con:query("SELECT id, name FROM people WHERE age > ?", {18}) +for _, row in ipairs(rows) do + print(row.id, row.name) +end +``` + +### `con:queryOne(sql [, params])` + +Like `query`, but returns the **first** row as a table, or `nil` if nothing matched. +Use it for lookups that return at most one row, such as a fetch by primary key. + +```lua +local row = con:queryOne("SELECT * FROM people WHERE id = ?", {1}) +if row then + print(row.name) +end +``` + +## Parameters + +Pass values as a `params` table rather than building SQL by string concatenation; +the values are then bound and escaped by SQLite. The table is bound either +**positionally** or **by name**, decided by its keys. + +### Positional + +A plain array binds to `?` placeholders in order. It must be a gapless array starting +at index 1. + +```lua +con:query("SELECT * FROM people WHERE age > ? AND name <> ?", {18, "Bob"}) +``` + +### Named + +A table with string keys binds to named placeholders. In the SQL the placeholder +carries a sigil (`:name`, `@name`, or `$name`); in the table the key is the bare name +without it. + +```lua +con:query("SELECT * FROM people WHERE age > :min", {min = 18}) +con:execute("INSERT INTO people (name, age) VALUES (:name, :age)", {name = "Carol", age = 25}) +``` + +### Rules + +- Omitting `params` (or passing `nil`) means the statement takes no parameters. +- A table may be **all positional** or **all named** — mixing the two is an error. +- Every named placeholder in the SQL must have a matching key, or the call errors. + +## Type mapping + +Values convert automatically in both directions. + +**Lua → SQLite** (binding parameters): + +| Lua | SQLite | +|--------------|-----------------| +| `nil` | `NULL` | +| `utils.NULL` | `NULL` | +| `boolean` | `INTEGER` (0/1) | +| integer | `INTEGER` | +| number | `REAL` | +| `string` | `TEXT` | + +**SQLite → Lua** (reading rows): + +| SQLite | Lua | +|-----------|----------------------| +| `NULL` | `nil` | +| `INTEGER` | integer | +| `REAL` | number | +| `TEXT` | `string` | +| `BLOB` | `string` (raw bytes) | + +Two consequences worth knowing: + +- **Booleans don't round-trip as booleans.** SQLite has no boolean type, so `true` + is stored as the integer `1` and reads back as `1`, not `true`. Compare against + `1`/`0`, or store a real `INTEGER` column and interpret it yourself. +- **`NULL` reads back as an absent key.** A column holding `NULL` simply isn't set on + the row table, so `row.col` is `nil` — exactly as if the key were present and `nil`. + To *bind* a SQL `NULL`, pass `utils.NULL`: a literal Lua `nil` cannot + live inside a table, so it can never reach a parameter slot. + +```lua +con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Dave", utils.NULL}) +local row = con:queryOne("SELECT age FROM people WHERE name = ?", {"Dave"}) +print(row.age) --> nil +``` + +## Metadata + +### `con:lastInsertRowid()` + +The rowid of the most recent successful `INSERT` on this connection, as an integer. +For a table with an `INTEGER PRIMARY KEY`, this is that key. + +```lua +con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Eve", 40}) +local id = con:lastInsertRowid() +``` + +### `con:changes()` + +The number of rows changed by the most recent `INSERT`/`UPDATE`/`DELETE`. This is the +same number `execute` returns, but you can query it separately at any time. + +## Transactions + +### `con:transaction(fn)` + +Run `fn` inside a transaction. If it returns normally the transaction is **committed**; +if it raises an error the transaction is **rolled back** and the original error is +re-raised unchanged. + +```lua +con:transaction(function() + con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Frank", 50}) + con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Grace", 60}) +end) +-- Both inserts committed together; if either had thrown, neither would persist. +``` + +### Manual control + +For flows that don't fit a single callback, drive the transaction yourself: + +```lua +con:begin() +local ok, err = pcall(function() + con:execute("DELETE FROM people WHERE age > ?", {100}) +end) +if ok then con:commit() else con:rollback() end +``` + +| Method | Effect | +|-------------------|---------------------------------| +| `con:begin()` | Start a transaction (`BEGIN`). | +| `con:commit()` | Commit it (`COMMIT`). | +| `con:rollback()` | Discard it (`ROLLBACK`). | + +You can mix both styles in the same program. + +## Closing + +### `con:close()` + +Release the connection and its underlying database handle. Any further call on the +connection raises an error. Closing is optional — a connection also closes when +garbage-collected — but it is the clean way to release a file lock promptly. + +```lua +con:close() +``` + +## Errors + +Failures raise Lua errors rather than returning error codes: a bad SQL statement, a +type that can't be bound, a parameter mismatch, or use of a closed connection all +`error()` out. Wrap calls in `pcall` or `utils.try` where you want to +handle failure rather than abort: + +```lua +local rows, err = utils.try(function() + return con:query("SELECT * FROM nonexistent") +end) +if not rows then + log.error("query failed: " .. tostring(err)) +end +``` + +## Notes + +- **Statements are cached.** Each query is prepared through SQLite's statement cache, + so repeating the same SQL reuses the compiled statement. There is no separate + prepared-statement API to manage. +- **I/O runs off the event loop.** `execute`, `query`, and `queryOne` run SQLite's + blocking work on a background thread, so a slow query does not stall other async + work (timers, `os.sleep`, networking) in the same script. + +## Full example + +```lua +local con = sqlite.connect(":memory:") + +con:execute([[ + CREATE TABLE tasks ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0 + ) +]]) + +con:transaction(function() + for _, title in ipairs({"write docs", "review code", "update changelog"}) do + con:execute("INSERT INTO tasks (title) VALUES (:title)", {title = title}) + end +end) + +con:execute("UPDATE tasks SET done = 1 WHERE title = ?", {"write docs"}) + +local pending = con:query("SELECT id, title FROM tasks WHERE done = 0 ORDER BY id") +for _, task in ipairs(pending) do + print(task.id, task.title) +end + +con:close() +``` diff --git a/lua/stdlib/sqlite.lua b/lua/stdlib/sqlite.lua new file mode 100644 index 0000000..dfc8f82 --- /dev/null +++ b/lua/stdlib/sqlite.lua @@ -0,0 +1,40 @@ +-- Lua-side of the sqlite module. +-- The connection methods execute/query/queryOne/lastInsertRowid/changes/close +-- are provided by Rust. This file adds the transaction helpers on top of the +-- same connection so users can write con:transaction(fn), con:begin(), etc. +-- +-- Rust passes in `Connection`: the shared method table that every connection's +-- metatable indexes into (mlua hides the metatable itself from Lua). + +return function(Connection) + --- Begin a transaction. + function Connection:begin() + self:execute("BEGIN") + end + + --- Commit the current transaction. + function Connection:commit() + self:execute("COMMIT") + end + + --- Roll back the current transaction. + function Connection:rollback() + self:execute("ROLLBACK") + end + + --- Run `fn` inside a transaction: commit on success, roll back and re-raise + --- on error. The original error is re-raised unchanged (level 0). + function Connection:transaction(fn) + if type(fn) ~= "function" then + error("sqlite: transaction requires a function argument (got " .. type(fn) .. ")", 2) + end + self:begin() + local ok, err = pcall(fn) + if ok then + self:commit() + else + self:rollback() + error(err, 0) + end + end +end diff --git a/lua/test_sqlite.lua b/lua/test_sqlite.lua new file mode 100644 index 0000000..2de1581 --- /dev/null +++ b/lua/test_sqlite.lua @@ -0,0 +1,111 @@ +-- Exercise of the sqlite stdlib module. + +local function assert_eq(actual, expected, msg) + if actual ~= expected then + error(string.format("%s: expected %s, got %s", + msg or "assertion failed", tostring(expected), tostring(actual)), 2) + end +end + +print("=== connect (in-memory) ===") +local con = sqlite.connect(":memory:") + +print("=== CREATE TABLE / INSERT ===") +con:execute([[ + CREATE TABLE people ( + id INTEGER PRIMARY KEY, + name TEXT, + age INTEGER, + score REAL, + vip INTEGER, + notes TEXT + ) +]]) + +-- Positional params, every supported type. +local changed = con:execute( + "INSERT INTO people (name, age, score, vip, notes) VALUES (?, ?, ?, ?, ?)", + {"Alice", 30, 9.5, true, "first"} +) +assert_eq(changed, 1, "execute returns rows changed") +assert_eq(con:changes(), 1, "changes() after insert") +local first_id = con:lastInsertRowid() +assert_eq(first_id, 1, "lastInsertRowid") + +-- Named params (mix of :name in SQL). +con:execute( + "INSERT INTO people (name, age, score, vip, notes) VALUES (:name, :age, :score, :vip, :notes)", + {name = "Bob", age = 42, score = 7.25, vip = false, notes = utils.NULL} +) +assert_eq(con:lastInsertRowid(), 2, "lastInsertRowid after second insert") + +print("=== SELECT (query) ===") +local rows = con:query("SELECT * FROM people ORDER BY id") +assert_eq(#rows, 2, "query returns 2 rows") +assert_eq(rows[1].name, "Alice", "row 1 name") +assert_eq(rows[1].age, 30, "row 1 age (integer round-trip)") +assert_eq(rows[1].score, 9.5, "row 1 score (real round-trip)") +assert_eq(rows[1].vip, 1, "row 1 vip (boolean stored as 1)") +assert_eq(rows[1].notes, "first", "row 1 notes (text round-trip)") +-- A SQL NULL comes back as nil (absent key). +assert_eq(rows[2].notes, nil, "row 2 notes is nil (NULL round-trip)") + +print("=== queryOne ===") +local bob = con:queryOne("SELECT * FROM people WHERE name = ?", {"Bob"}) +assert(bob ~= nil, "queryOne found Bob") +assert_eq(bob.age, 42, "queryOne Bob age") + +local missing = con:queryOne("SELECT * FROM people WHERE name = ?", {"Nobody"}) +assert_eq(missing, nil, "queryOne returns nil for no match") + +print("=== UPDATE / DELETE ===") +local n = con:execute("UPDATE people SET age = age + 1 WHERE name = :who", {who = "Alice"}) +assert_eq(n, 1, "update affected 1 row") +assert_eq(con:queryOne("SELECT age FROM people WHERE name = ?", {"Alice"}).age, 31, "age incremented") + +n = con:execute("DELETE FROM people WHERE name = ?", {"Bob"}) +assert_eq(n, 1, "delete affected 1 row") +assert_eq(#con:query("SELECT * FROM people"), 1, "one row left after delete") + +print("=== transaction(fn) — commit ===") +con:transaction(function() + con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Carol", 25}) + con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Dave", 28}) +end) +assert_eq(#con:query("SELECT * FROM people"), 3, "two rows committed") + +print("=== transaction(fn) — rollback on error ===") +local ok, err = pcall(function() + con:transaction(function() + con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Eve", 99}) + error("boom") + end) +end) +assert_eq(ok, false, "transaction propagated the error") +assert(tostring(err):find("boom"), "original error message preserved") +assert_eq(#con:query("SELECT * FROM people"), 3, "failed transaction rolled back") + +print("=== manual begin / commit ===") +con:begin() +con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Frank", 50}) +con:commit() +assert_eq(#con:query("SELECT * FROM people"), 4, "manual commit persisted") + +print("=== manual begin / rollback ===") +con:begin() +con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Grace", 60}) +con:rollback() +assert_eq(#con:query("SELECT * FROM people"), 4, "manual rollback discarded") + +print("=== mixed param table is rejected ===") +local mixed_ok = pcall(function() + con:query("SELECT 1 WHERE 1 = ?", {1, key = "x"}) +end) +assert_eq(mixed_ok, false, "mixed positional/named params error") + +print("=== close ===") +con:close() +local closed_ok = pcall(function() con:query("SELECT 1") end) +assert_eq(closed_ok, false, "use after close errors") + +print("\nAll sqlite checks passed.") diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 127fee8..c7a4402 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -1,6 +1,7 @@ mod logging; mod math; mod os_ext; +mod sqlite; mod table; pub(crate) mod utils; @@ -10,5 +11,6 @@ pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> { utils::install(lua)?; os_ext::install(lua)?; logging::install(lua)?; + sqlite::install(lua)?; Ok(()) } diff --git a/src/stdlib/sqlite.rs b/src/stdlib/sqlite.rs new file mode 100644 index 0000000..064ff22 --- /dev/null +++ b/src/stdlib/sqlite.rs @@ -0,0 +1,352 @@ +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>` 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>>; + +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 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), + /// 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) -> LuaResult { + 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::() { + 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 { + // `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)), + LuaValue::String(s) => Ok(Value::Text(s.to_str()?.to_owned())), + 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 { + 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 { + 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) -> LuaResult { + 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) +// --------------------------------------------------------------------------- + +/// Run an INSERT/UPDATE/DELETE-style statement, returning rows changed. +async fn run_execute(handle: ConnHandle, sql: String, params: BoundParams) -> Result { + tokio::task::spawn_blocking(move || -> Result { + let guard = handle.lock().unwrap_or_else(|e| e.into_inner()); + let conn = guard.as_ref().ok_or(DbError::Closed)?; + 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 a SELECT-style statement, materializing every row. +async fn run_query(handle: ConnHandle, sql: String, params: BoundParams) -> 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)?; + let mut stmt = conn.prepare_cached(&sql)?; + // Column names must be captured before the mutable borrows below. + let columns: Vec = 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>(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)| { + 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("query", |lua, this, (sql, params): (String, Option)| { + 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)| { + 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, so they stay synchronous. + methods.add_method("lastInsertRowid", |_, this, ()| { + let guard = this.0.lock().unwrap_or_else(|e| e.into_inner()); + let conn = guard.as_ref().ok_or_else(|| mlua::Error::external(DbError::Closed))?; + Ok(conn.last_insert_rowid()) + }); + + methods.add_method("changes", |_, this, ()| { + let guard = this.0.lock().unwrap_or_else(|e| e.into_inner()); + let conn = guard.as_ref().ok_or_else(|| mlua::Error::external(DbError::Closed))?; + Ok(conn.changes() as i64) + }); + + methods.add_method("close", |_, this, ()| { + // Dropping the connection releases the SQLite handle; later use errors. + *this.0.lock().unwrap_or_else(|e| e.into_inner()) = None; + Ok(()) + }); + } +} + +// --------------------------------------------------------------------------- +// Installation +// --------------------------------------------------------------------------- + +pub(super) fn install(lua: &Lua) -> LuaResult<()> { + let sqlite = lua.create_table()?; + + sqlite.set( + "connect", + lua.create_function(|_, path: String| { + let conn = rusqlite::Connection::open(&path).map_err(mlua::Error::external)?; + 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) +}