parent
1821a0f116
commit
8efdcab812
@ -0,0 +1,15 @@ |
|||||||
|
{ |
||||||
|
"permissions": { |
||||||
|
"allow": [ |
||||||
|
"Bash(echo:*)", |
||||||
|
"Bash(git ls-files:*)", |
||||||
|
"Bash(cat:*)", |
||||||
|
"Bash(cargo:*)", |
||||||
|
"Bash(ls:*)", |
||||||
|
"Bash(tree:*)", |
||||||
|
"Bash(find:*)" |
||||||
|
], |
||||||
|
"deny": [], |
||||||
|
"ask": [] |
||||||
|
} |
||||||
|
} |
||||||
@ -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.") |
||||||
@ -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, ¶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<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, ¶ms)?; |
||||||
|
|
||||||
|
let mut out = Vec::new(); |
||||||
|
let mut rows = stmt.raw_query(); |
||||||
|
while let Some(row) = rows.next()? { |
||||||
|
let mut record = Vec::with_capacity(columns.len()); |
||||||
|
for (i, name) in columns.iter().enumerate() { |
||||||
|
record.push((name.clone(), row.get::<_, Value>(i)?)); |
||||||
|
} |
||||||
|
out.push(record); |
||||||
|
} |
||||||
|
Ok(out) |
||||||
|
}) |
||||||
|
.await |
||||||
|
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}")))) |
||||||
|
} |
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UserData methods
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
impl UserData for SqliteConn { |
||||||
|
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) { |
||||||
|
// I/O-bound methods run on a blocking thread so SQLite never stalls the
|
||||||
|
// tokio event loop.
|
||||||
|
methods.add_async_method("execute", |_, this, (sql, params): (String, Option<LuaTable>)| { |
||||||
|
let handle = this.0.clone(); |
||||||
|
let bound = convert_params(params); |
||||||
|
async move { |
||||||
|
let changed = run_execute(handle, sql, bound?).await.map_err(mlua::Error::external)?; |
||||||
|
Ok(changed as i64) |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
methods.add_async_method("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…
Reference in new issue