Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,14 @@ Databases must be registered on the Rust side with a stable key before they can
- Path validation module (`validate.rs`) with canonicalization at registration time.
- Parent directory auto-creation during registration validation for file paths.
- CI check that committed `api-iife.js` matches a fresh Rollup build.
- Scalar SQL functions written in Rust. Any query, from the frontend or from Rust, can call a registered function by name. Each database captures the registered set when it connects, so registration must precede the first `load`. See [Scalar Functions](README.md#scalar-functions) for the full contract. Aggregate functions, window functions, collations, virtual tables, and functions defined in JavaScript are out of scope.

#### Rust API

- **`Builder::register_function()`** and **`SetupRegistrar::register_function()`**: register a scalar SQL function. Use the registrar variant when the handler closes over the `app` instance. Use the builder variant otherwise. Both validate the name, the arity, the name-and-arity pair, and a collision with a SQLite built-in at the call. A bad registration produces an error there, not later when a connection opens.
- **`Builder::register_or_replace_function()`** and **`SetupRegistrar::register_or_replace_function()`**: the same registration, accepting a name and arity already registered. For a setup path that runs twice, where `register_function()` reports the repeat as an error.
- **`InvocationScope`**, the required **`ScalarFunction::invocation_scope`** field: `DirectOnly` confines a function to top-level SQL through `SQLITE_DIRECTONLY`, and `Schema` and `InnocuousSchema` let a view, a trigger, or an index call it. Pick `DirectOnly` unless a schema object must call the function: a handler reachable from the schema of a database this application did not write runs on any read through that object.
- **`sqlx_sqlite_conn_mgr::register_function()`** and **`register_or_replace_function()`** with **`ScalarFunction`**, **`InvocationScope`**, **`ScalarHandler`**, **`SqlValue`**, **`SqlValueRef`**, and **`FunctionError`**: the registry the plugin builds on, usable without Tauri. `SqliteDatabase::connect` snapshots the registered set, and both of the database's pools install that snapshot through `SqlitePoolOptions::after_connect`. This keeps a function present even on a connection the idle reaper replaced, and it keeps one database's connections uniform: a registration after a `connect` applies to the next database, never to part of an open one. Handlers receive borrowed `SqlValueRef` arguments and return an owned `SqlValue`, and `ScalarFunction::name` is an owned `String`, so a name built at runtime needs no leak. The types are re-exported from `sqlx_sqlite_toolkit`; the plugin re-exports the types and exposes registration through the builder and the registrar alone.
- **`sqlx_sqlite_toolkit::AttachedWriterGuard`** and **`DatabaseWrapper::acquire_writer_with_attached()`** — the attached-writer acquisition path that routes through the observer.
- **`sqlx_sqlite_conn_mgr::ObserverSlot`** (with `SqliteDatabase::observer_slot()`) — the database-scoped observation slot underlying the database-wide sharing described above. Populated only through `get_or_init()` / `get_or_init_with()`, both of which reuse rather than replace, so the slot cannot come to hold two different concrete types from safe code. `get_or_init_with()` additionally runs a caller-supplied merge callback under the same write lock that decided to reuse.
- **`sqlx_sqlite_observer::ObservableSqliteDatabase::acquire_writer_with_attached()`** and **`ObservableWriteGuard::detach_all()`**.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ SQLite database interface for Tauri applications using
* **Migration Support**: SQLx's migration framework
* **Resource Management**: Proper cleanup on application exit
* **Optional Change Notifications**: SQLite hooks for reactive change notifications
* **Scalar Functions in Rust**: register a Rust function and call it by name from
any query the plugin serves

## Architecture

Expand Down Expand Up @@ -941,6 +943,82 @@ async fn example<R: Runtime>(app: tauri::AppHandle<R>) -> tauri_plugin_sqlite::R
}
```

### Scalar Functions

Register a Rust function once and call it by name from any query the plugin serves. This
puts work that needs native code inside the query, so SQLite applies it while it scans.
The alternative reads every candidate row out of the database and applies the same logic
in the caller.

```rust
use std::sync::Arc;
use tauri_plugin_sqlite::{
Builder, FunctionError, InvocationScope, ScalarFunction, SqlValue, SqlValueRef,
};

Builder::new()
.register_function(ScalarFunction {
name: "normalize_for_search".into(),
arity: 1,
deterministic: true,
invocation_scope: InvocationScope::DirectOnly,
// SQLite's own lower() folds ASCII only, so Unicode case folding needs Rust.
handler: Arc::new(|args: &[SqlValueRef]| match &args[0] {
SqlValueRef::Text(text) => Ok(SqlValue::Text(text.to_lowercase())),
SqlValueRef::Null => Ok(SqlValue::Null),
_ => Err(FunctionError::new("normalize_for_search expects text")),
}),
})?
.build()?;
```

Any query can then call it, from the frontend or from Rust:

```sql
SELECT d.Title AS title
FROM Document d
WHERE normalize_for_search(d.Title) LIKE $1
```

Register a function inside `on_setup` instead when its handler needs the `app` instance:

```rust
Builder::new()
.on_setup(|app, reg| {
reg.register_database(MAIN_DB_KEY, app.path().app_data_dir()?.join("main.db"), None)?;
reg.register_function(ScalarFunction { /* ... */ })?;
Ok(())
})
.build()?;
```

The full contract lives in the `sqlx_sqlite_conn_mgr::functions` module documentation
(crates/sqlx-sqlite-conn-mgr/src/functions.rs). The short version:

* Register before the first `load` or `connect`. Each database captures the registered
set when it connects, for every connection it ever opens.
* `invocation_scope` decides where SQLite accepts a call.
`InvocationScope::DirectOnly` confines the function to top-level SQL, so SQLite
refuses a call from inside a view, a trigger, a CHECK constraint, an expression
index, or any other schema object. `InvocationScope::Schema` and
`InvocationScope::InnocuousSchema` accept a call from a schema object, which is what
a view over the function needs.
* Validation happens at the `register_function` call: an invalid name or arity, a name
a SQLite built-in already uses, a duplicate name and arity pair, and a function the
linked SQLite library refuses all fail there.
* `register_or_replace_function` accepts a name and arity already registered, for a
setup path that runs twice. The replacement reaches the databases that load after
it, and a database already loaded keeps the handler it captured.
* The handler receives borrowed arguments (`SqlValueRef`), including SQL NULL, and
returns an owned `SqlValue`. A returned `FunctionError` fails the statement with
error code `SQLITE_1` and the handler's message.
* A panicking handler produces an error naming the function and leaves the pool
usable, under `panic = "unwind"` (the Rust default). Under `panic = "abort"`, the
process aborts.

Aggregate functions, window functions, collations, virtual tables, and functions defined
in JavaScript are not supported.

### Basic Operations

```rust
Expand Down
4 changes: 4 additions & 0 deletions crates/sqlx-sqlite-conn-mgr/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ tokio = { version = "1.49.0", features = ["full"] }
tracing = { version = "0.1.44", default-features = false, features = ["std", "release_max_level_off"] }
serde = { version = "1.0.228", features = ["derive"] }
parking_lot = "0.12.3"
# Registers consumer-supplied scalar functions on every pooled connection.
# Pinned to the version sqlx-sqlite links: only one version of a `links`
# crate can exist in a dependency graph.
libsqlite3-sys = "0.35.0"

[dev-dependencies]
tempfile = "3.24.0"
54 changes: 54 additions & 0 deletions crates/sqlx-sqlite-conn-mgr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ project needing SQLx connection management.
* **WAL mode**: Enabled on first `acquire_writer()` call
* **Idle timeout**: Connections close after 30s inactivity (configurable)
* **No perpetual caching**: Zero minimum connections (prevents idle thread sprawl)
* **Scalar functions**: Either pool applies a registered Rust function to every
connection it opens, for every database this crate serves

Delegates to SQLx's `SqlitePoolOptions` and `SqliteConnectOptions` wherever
possible — minimal wrapper logic.
Expand Down Expand Up @@ -154,6 +156,36 @@ async fn example() -> Result<(), sqlx_sqlite_conn_mgr::Error> {
> circumvents the connection manager's policies and will result in
> unpredictable behavior, including potential deadlocks.

### Scalar Functions

`register_function` records a Rust function. Every database whose `connect` runs after the
registration keeps that function available on every connection either of its pools opens.

```rust
use std::sync::Arc;
use sqlx_sqlite_conn_mgr::{
FunctionError, InvocationScope, ScalarFunction, SqlValue, SqlValueRef, register_function,
};

register_function(ScalarFunction {
name: "normalize_for_search".into(),
arity: 1,
deterministic: true,
invocation_scope: InvocationScope::DirectOnly,
// SQLite's own lower() folds ASCII only, so Unicode case folding needs Rust.
handler: Arc::new(|args: &[SqlValueRef]| match &args[0] {
SqlValueRef::Text(text) => Ok(SqlValue::Text(text.to_lowercase())),
SqlValueRef::Null => Ok(SqlValue::Null),
_ => Err(FunctionError::new("normalize_for_search expects text")),
}),
})?;
```

The full contract (connection-scoped registration, connect-time snapshots, the invocation
scope, panic behavior, and the validation list) lives in the `functions` module
documentation (src/functions.rs). Aggregate functions, window
functions, collations, and virtual tables are not supported.

## API Reference

### `SqliteDatabase`
Expand All @@ -167,6 +199,28 @@ async fn example() -> Result<(), sqlx_sqlite_conn_mgr::Error> {
| `close()` | Close and remove from cache |
| `remove()` | Close and delete database files (.db, .db-wal, .db-shm) |

### `register_function`

| Function | Description |
| -------- | ----------- |
| `register_function(function)` | Register a `ScalarFunction` for every database that connects after the call |
| `register_or_replace_function(function)` | The same, replacing the function already registered under the name and arity |

Each of these fails at the call:

* an empty name
* a name over 255 bytes
* a name holding a NUL byte
* a negative arity
* a name a SQLite built-in already uses
* a name and arity pair already registered, unless the call is
`register_or_replace_function`
* a function the linked SQLite library refuses

The registry compares names case-insensitively. This matches how SQLite resolves them. The
call also registers the function on a temporary in-memory connection. A library that
refuses the function there returns an error naming the function and its reason.

### `WriteGuard`

RAII guard for exclusive write access. Derefs to `SqliteConnection`. Connection
Expand Down
18 changes: 18 additions & 0 deletions crates/sqlx-sqlite-conn-mgr/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use crate::Result;
use crate::config::SqliteDatabaseConfig;
use crate::error::Error;
use crate::functions;
use crate::observer_slot::ObserverSlot;
use crate::registry::{get_or_open_database, is_memory_database, uncache_database};
use crate::write_guard::WriteGuard;
Expand Down Expand Up @@ -180,12 +181,27 @@ impl SqliteDatabase {
.read_only(true)
.optimize_on_close(true, OPTIMIZE_ANALYSIS_LIMIT);

// The functions this database serves: the set registered before this connect.
// Both pools install this same snapshot on every connection they ever open, so
// one database never serves connections with differing function sets. A
// registration made after this call applies to the next database to connect.
let registered_functions = functions::snapshot();

// SQLite keeps a scalar function in per-connection state, so every connection
// either pool opens must have the whole snapshot. `after_connect` is the only
// hook that can do this. Both pools open connections lazily and drop them after
// the idle timeout, so a one-time pass over the connections open at startup will
// miss every connection opened later. sqlx runs this hook before it hands the
// connection to a caller. If the hook returns an error, sqlx discards the
// connection, so no caller ever receives a connection that lacks a registered
// function.
let read_pool = SqlitePoolOptions::new()
.max_connections(config.max_read_connections)
.min_connections(0)
.idle_timeout(Some(std::time::Duration::from_secs(
config.idle_timeout_secs,
)))
.after_connect(functions::install_hook(&registered_functions))
.connect_with(read_options)
.await?;

Expand Down Expand Up @@ -213,6 +229,8 @@ impl SqliteDatabase {
.idle_timeout(Some(std::time::Duration::from_secs(
config.idle_timeout_secs,
)))
// Registered scalar functions. See the read pool above for why this hook is here.
.after_connect(functions::install_hook(&registered_functions))
.after_release(|conn, _meta| {
Box::pin(async move {
match sqlx::query("ROLLBACK").execute(&mut *conn).await {
Expand Down
50 changes: 50 additions & 0 deletions crates/sqlx-sqlite-conn-mgr/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,56 @@ pub enum Error {
)]
DuplicateAttachedDatabase(String),

/// Invalid name for a scalar function. See `functions::validate_name` for the rule set
/// this message must match.
#[error(
"Invalid function name '{0}': must be non-empty, at most 255 bytes long, and contain no interior NUL byte"
)]
InvalidFunctionName(String),

/// Invalid argument count for a scalar function. In SQLite, a negative arity means a
/// variadic function. This crate does not offer that option. The upper bound is the
/// linked library's `SQLITE_MAX_FUNCTION_ARG`, enforced by the probe as
/// `Error::FunctionRefused`.
#[error("Invalid function arity {0}: must not be negative")]
InvalidFunctionArity(i32),

/// A scalar function with this name and argument count is already registered. This
/// crate compares names case-insensitively, to match how SQLite resolves them.
/// Without this check, a second registration replaces the first instead of the two
/// coexisting.
#[error("A function named '{name}' taking {arity} argument(s) is already registered")]
DuplicateFunction { name: String, arity: i32 },

/// The linked SQLite library already defines this name as a built-in with the same
/// argument count, or as a variadic built-in. SQLite silently replaces a built-in with
/// an application-defined function, so every statement on the connection that names the
/// built-in reaches the handler instead, including one inside an existing view, index,
/// or constraint. See `functions::shadows_builtin`.
#[error(
"A function named '{name}' taking {arity} argument(s) replaces a SQLite built-in function. Choose a name no built-in uses."
)]
ShadowsBuiltinFunction { name: String, arity: i32 },

/// The in-memory connection that checks a registration failed to open or query, so
/// this crate cannot tell whether the linked library accepts the function. The failure
/// belongs to the probe, not to the function. See `functions::probe`.
#[error(
"Cannot check whether SQLite accepts a function named '{name}': the probe failed: {reason}"
)]
ProbeConnectionFailed { name: String, reason: String },

/// The linked SQLite library refused the registration. SQLite validates the name, the
/// argument count, and the encoding flags. The library's own limits decide the
/// outcome: an arity above its `SQLITE_MAX_FUNCTION_ARG` fails here. The `reason`
/// field gives the message SQLite returned.
#[error("SQLite refused a function named '{name}' taking {arity} argument(s): {reason}")]
FunctionRefused {
name: String,
arity: i32,
reason: String,
},

/// Two attached-database specs used the same schema alias. Compared
/// case-insensitively, matching SQLite's own schema namespace - a spec named `"x"`
/// and one named `"X"` collide at `ATTACH` even though they compare unequal as
Expand Down
Loading
Loading