add sqlite module

repl
Ondřej Hruška 3 weeks ago
parent 1821a0f116
commit 8efdcab812
  1. 15
      .claude/settings.local.json
  2. 82
      Cargo.lock
  3. 1
      Cargo.toml
  4. 22
      README.md
  5. 258
      docs/sqlite.md
  6. 40
      lua/stdlib/sqlite.lua
  7. 111
      lua/test_sqlite.lua
  8. 2
      src/stdlib/mod.rs
  9. 352
      src/stdlib/sqlite.rs

@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(echo:*)",
"Bash(git ls-files:*)",
"Bash(cat:*)",
"Bash(cargo:*)",
"Bash(ls:*)",
"Bash(tree:*)",
"Bash(find:*)"
],
"deny": [],
"ask": []
}
}

82
Cargo.lock generated

@ -12,12 +12,25 @@ dependencies = [
"log", "log",
"mlua", "mlua",
"reqwest", "reqwest",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
"tokio", "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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@ -408,6 +421,18 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -520,12 +545,30 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 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]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@ -766,7 +809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.17.1",
] ]
[[package]] [[package]]
@ -888,6 +931,17 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" 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]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@ -1323,6 +1377,20 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@ -1857,6 +1925,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 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]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"

@ -11,6 +11,7 @@ log = "0.4"
clap = { version = "4.6.1", features = ["derive"] } clap = { version = "4.6.1", features = ["derive"] }
mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", "anyhow"]} mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", "anyhow"]}
reqwest = "0.13.4" reqwest = "0.13.4"
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150" serde_json = "1.0.150"
thiserror = "2.0.18" thiserror = "2.0.18"

@ -1,35 +1,35 @@
# `a` # `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 ```sh
a file.lua 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 ## 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 ## 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. - **Networking** — HTTP client, WebSocket, and MQTT (3.1.1 and 5).
- **SQLite** — a real embedded database one call away. - **SQLite** — access to embedded SQLite databases.
- **Filesystem**ergonomic read/write/glob/walk, without the ceremony of stock Lua's `io`. - **Filesystem** — read/write/glob/walk without stock Lua's `io` boilerplate.
- **Terminal UI** — interactive line and full-screen TUI in the spirit of `ncurses`, but without the awkwardness. - **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 ## How it works
- Embeds Lua 5.5 via `mlua`, on an async core ([`tokio`](https://tokio.rs/)) so IO-heavy scripts don't block. - 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. - 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. - A leading `#!` shebang line is skipped, so scripts can be made executable directly.
## Building ## Building

@ -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()
```

@ -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

@ -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.")

@ -1,6 +1,7 @@
mod logging; mod logging;
mod math; mod math;
mod os_ext; mod os_ext;
mod sqlite;
mod table; mod table;
pub(crate) mod utils; pub(crate) mod utils;
@ -10,5 +11,6 @@ pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
utils::install(lua)?; utils::install(lua)?;
os_ext::install(lua)?; os_ext::install(lua)?;
logging::install(lua)?; logging::install(lua)?;
sqlite::install(lua)?;
Ok(()) Ok(())
} }

@ -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<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)),
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<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)
// ---------------------------------------------------------------------------
/// 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)?;
let mut stmt = conn.prepare_cached(&sql)?;
bind_params(&mut stmt, &params)?;
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<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)?;
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, &params)?;
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("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, 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)
}
Loading…
Cancel
Save