//! Query execution: the `Connection` API and the read-query executor.
//!
//! This layer ties the pieces together: parse SQL ([`crate::sql`]), resolve
//! names against the schema catalog ([`crate::schema`]), scan b-trees
//! ([`crate::btree`]), decode records ([`crate::format::record`]), and evaluate
//! expressions ([`eval`]) to produce result rows.
//!
//! It implements an *operational, iterator-style* executor rather than emitting
//! VDBE bytecode. The observable semantics (row order, type coercion, NULL
//! handling) follow SQLite; the bytecode representation the roadmap describes is
//! an internal-representation refactor we can layer in later without changing
//! results. The [`Connection`] reads (`query`) and writes (`execute`) over a
//! writable pager, an in-memory database, or — read-only — a WAL-mode database
//! (the `-wal` overlay is detected automatically).
pub mod datetime;
pub mod eval;
pub mod func;
mod integrity;
pub mod json;
mod stat4;
pub mod vdbe;
mod window;
use crate::btree::{
IndexCursor, TableCursor, clear_index, clear_table, create_index_root, create_table_root,
delete_table, free_tree, insert_index, insert_table, table_has_empty_leaf,
};
use crate::error::{Error, Result};
use crate::format::record::{decode_record, encode_record};
use crate::pager::{AutoVacuum, CheckpointMode, PageSource, WritePager};
use crate::schema::Schema;
use crate::sql::ast::*;
use crate::sql::{self};
use crate::value::Value;
use crate::vfs::{OpenFlags, Vfs};
use crate::vtab::{
ConstraintOp, DynVTabModule, IndexConstraint, IndexPlan, VTabChange, VTabRegistry, VTabStore,
};
use alloc::borrow::Cow;
use alloc::boxed::Box;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use eval::{ColumnInfo, EvalCtx, Params};
/// The result of a query: column labels and the materialized rows.
#[derive(Debug, Clone, PartialEq)]
pub struct QueryResult {
/// Result column labels, in order.
pub columns: Vec<String>,
/// Result rows, each with one value per column.
pub rows: Vec<Vec<Value>>,
}
/// The storage backing a connection: a writable pager, or a read-only page
/// source (e.g. a WAL-mode database opened read-only).
enum Backend {
Write(Box<WritePager>),
Read(Box<dyn PageSource>),
}
impl Backend {
fn source(&self) -> &dyn PageSource {
match self {
Backend::Write(w) => w.as_ref(),
Backend::Read(r) => r.as_ref(),
}
}
fn writer(&mut self) -> Result<&mut WritePager> {
match self {
Backend::Write(w) => Ok(w),
Backend::Read(_) => Err(Error::Error("database is read-only".into())),
}
}
fn wal_mode(&self) -> bool {
matches!(self, Backend::Write(w) if w.wal_mode())
}
}
/// A database connection. Supports reading (`query`) and writing (`execute`),
/// over a file or in memory.
///
/// # Threading model (roadmap C9d)
///
/// A `Connection` follows a **per-thread ("thread-confined") model**: it is used
/// by **one thread at a time**, and it is neither `Send` nor `Sync`. To use
/// graphite from a thread pool, give **each thread its own `Connection`** (open
/// the same file path from each worker — the built-in `StdVfs` coordinates
/// cross-`Connection` access to one file through a process-local lock manager and
/// a shared wal-index). Do not move a live `Connection` between threads or share
/// one behind a lock.
///
/// ## Why `Connection` is not `Send`
///
/// Making the *whole* `Connection` type `Send` was investigated for C9d. It is
/// blocked by state that is fundamentally single-threaded and cannot be converted
/// without an architectural refactor that is out of proportion to the payoff.
/// The exact, remaining blockers (as surfaced by `assert_send::<Connection>()`)
/// are:
///
/// 1. **`Box<dyn `[`File`](crate::vfs::File)`>`** in the pager. The default
/// `StdVfs` file handle ([`StdFile`](crate::vfs::std_file::StdFile)) *is*
/// `Send` (it is `Mutex`/`Arc`/atomics), but the always-available in-memory
/// VFS handle ([`MemoryFile`](crate::vfs::memory::MemoryFile)) is deliberately
/// `Rc`/`RefCell`-based and `!Send` (it backs `:memory:` and must work in
/// `no_std`/wasm with no atomics). Because a `Connection` stores the file as a
/// single erased `Box<dyn File>` — the same concrete type for both VFSs — the
/// type is `Send` only if **every** `File` impl is, which `MemoryFile` is not.
/// Making only the `StdVfs`-backed connection `Send` would require making
/// `Connection` generic over the file type (a large, pervasive refactor).
/// 2. **`Box<dyn `[`PageSource`]`>`** (the read-only
/// backend) — same erased-trait-object situation as (1).
/// 3. The **session recorder** (`RefCell<SessionState>`, shared with a
/// [`Session`](crate::session::Session) via reference counting):
/// `Send` would need `Arc<Mutex<…>>` (a `std`-only primitive), not the
/// single-threaded `Rc<RefCell<…>>` the per-thread model calls for.
/// 4. Registered **user functions/aggregates and virtual-table modules**
/// (`Box<dyn Fn …>` / `Box<dyn DynVTabModule>`): `Send` would require adding a
/// `+ Send` bound to those public trait objects, a breaking API change that
/// would forbid non-`Send` user closures — and that buys nothing while (1)/(2)
/// keep the type `!Send` regardless.
///
/// Net: the payoff (a `Send` `Connection`) is unreachable within a clean,
/// non-breaking change, so graphite ships the documented per-thread model above.
/// The page cache and page buffers use `Rc<Vec<u8>>` (single-threaded, cheap);
/// were the `File`/`PageSource` blocker ever removed (e.g. by making `Connection`
/// generic over the VFS), those `Rc`s would need to become `Arc` too.
///
/// The `!Send`-ness is deliberate and enforced: the following must **not**
/// compile (if it ever does, the per-thread model above has silently changed and
/// this decision should be revisited):
///
/// ```compile_fail
/// fn assert_send<T: Send>() {}
/// assert_send::<graphitesql::Connection>();
/// ```
pub struct Connection {
backend: Backend,
schema: Schema,
/// The `main` database's file path (empty for an in-memory database), as
/// reported by `PRAGMA database_list`.
main_file: String,
/// Attached databases (`ATTACH … AS name`), in attachment order, each with
/// its own backend and schema. The `main` database is the fields above; this
/// list holds everything attached after it.
attached: Vec<AttachedDb>,
/// The `temp` database (`CREATE TEMP …`), created lazily on first use and
/// invisible to other connections. Reported at seq 1 by `database_list`.
temp_db: Option<AttachedDb>,
/// True between `BEGIN` and `COMMIT`/`ROLLBACK`; suppresses autocommit.
in_tx: bool,
/// A stack of materialized `WITH` common table expressions in scope, innermost
/// last. Resolved by name during `FROM` scanning before the schema is
/// consulted; this is also how a recursive CTE sees its own working table.
cte_env: core::cell::RefCell<Vec<CteBinding>>,
/// A stack of enclosing query rows, innermost last. A correlated subquery
/// pushes its evaluation row here so its body can resolve outer columns.
outer_scope: core::cell::RefCell<Vec<OuterFrame>>,
/// Whether foreign-key constraints are enforced (`PRAGMA foreign_keys`).
/// Off by default, matching SQLite.
foreign_keys: bool,
/// Whether `LIKE` compares ASCII case-sensitively (`PRAGMA
/// case_sensitive_like`). Off by default (SQLite folds ASCII case in `LIKE`);
/// `GLOB` is always case-sensitive regardless of this flag.
case_sensitive_like: bool,
/// Whether the connection is in read-only mode (`PRAGMA query_only`). When on,
/// any statement that would write to a database — INSERT/UPDATE/DELETE, every
/// CREATE/DROP/ALTER, VACUUM, and ANALYZE — fails with `attempt to write a
/// readonly database`; reads and read-only transactions are unaffected. Off by
/// default.
query_only: bool,
/// Whether CHECK constraints are skipped on INSERT/UPDATE (`PRAGMA
/// ignore_check_constraints`). NOT NULL, UNIQUE, and foreign keys are
/// unaffected. Off by default, matching SQLite.
ignore_check_constraints: bool,
/// Re-entrancy depth of trigger firing.
trigger_depth: core::cell::Cell<usize>,
/// Nesting depth of foreign-key action application. Non-zero while a
/// cascade/set-null/set-default runs, so a session records those writes as
/// *indirect* (mirroring SQLite's preupdate-hook depth for FK actions).
fk_depth: core::cell::Cell<usize>,
/// Names of tables whose b-tree had rows removed by a cascading delete
/// (`delete_row_cascade`) during the current statement. Each such delete can
/// leave an empty non-root leaf that SQLite's balancer would merge away, but
/// the per-row cascade path can't compact eagerly without O(rows²) rebuilds.
/// The top-level DML that started the statement drains this set and compacts
/// each table once, exactly as it already compacts its own target table.
cascade_compact: core::cell::RefCell<alloc::collections::BTreeSet<String>>,
/// Set by an `OR FAIL` conflict before it raises: tells the statement-level
/// atomicity wrapper to keep the rows changed before the failure (rather than
/// rolling the statement back, which is the `OR ABORT` default).
stmt_keep_partial: core::cell::Cell<bool>,
/// Set by an `OR ROLLBACK` conflict before it raises: the surrounding
/// transaction must be unwound, not just the current statement.
stmt_rollback_tx: core::cell::Cell<bool>,
/// Set when a `BEFORE` trigger runs `SELECT RAISE(IGNORE)`: the row operation
/// that fired the trigger is silently abandoned (no error). The firing caller
/// reads and clears it.
raise_ignore: core::cell::Cell<bool>,
/// Whether triggers may fire other triggers (`PRAGMA recursive_triggers`).
/// Off by default, matching SQLite: triggers then fire only at the top level.
recursive_triggers: bool,
/// Rows projected by the most recent `RETURNING` clause, drained by
/// [`execute_returning`](Self::execute_returning). Populated as a side effect
/// of `INSERT`/`UPDATE`/`DELETE` execution when the statement has a
/// `RETURNING` list.
returning_rows: core::cell::RefCell<Vec<Vec<Value>>>,
/// Count of open savepoints. Like `in_tx`, a non-zero count suppresses
/// autocommit so changes accumulate until the outermost savepoint is released.
open_savepoints: usize,
/// The rowid of the most recently inserted row (`last_insert_rowid()`).
last_insert_rowid: core::cell::Cell<i64>,
/// Rows modified by the most recent INSERT/UPDATE/DELETE (`changes()`).
changes: core::cell::Cell<i64>,
/// Rows modified since the connection opened (`total_changes()`).
total_changes: core::cell::Cell<i64>,
/// During a cross-database view read, the database whose catalog unqualified
/// table names resolve against (so a view's body reads its own database's
/// tables). `Main` at all other times; nested subqueries inherit it. Set and
/// restored around [`scan_db_view`](Self::scan_db_view).
read_default: core::cell::Cell<DbRef>,
/// The database the in-flight top-level `INSERT`/`UPDATE`/`DELETE` writes to,
/// resolved *before* the write target is swapped into the active `main` slot
/// (a temp table shadows main; an attached target keeps its name). `Main` at
/// all other times. Read by [`dml_target_db`](Self::dml_target_db) so a
/// three-part column qualifier is validated against the target's real
/// database name, not the swap-relative one `unqualified_db` would report.
write_target: core::cell::Cell<DbRef>,
/// The database physically swapped into the active `main` slot for the
/// duration of a write to a non-main target (`Some(target)` only while that
/// swap is live — set *after* `swap_db`, cleared *before* the swap is undone,
/// so it is `None` during the pre-swap prematerialize window). Read by
/// [`resolve_db`](Self::resolve_db) to invert the qualifier→slot mapping for a
/// schema-qualified reference in the write's WHERE/SET (its subqueries): the
/// target's own name and `main` are physically exchanged by the swap, but the
/// name→slot lookup is not, so `main.t` / `aux.u` would otherwise resolve to
/// the wrong database.
swap_active: core::cell::Cell<Option<DbRef>>,
/// Virtual-table modules registered on this connection, keyed by the name
/// that follows `USING` in `CREATE VIRTUAL TABLE`. Seeded with the built-in
/// `series` module; a public registration API is roadmap D4.
vtab_registry: VTabRegistry,
/// State for `random()`/`randomblob()`, advanced one SplitMix64 step per
/// value. Seeded from the system clock under `std` (so each process run
/// differs, like SQLite reseeding from the OS) and from a fixed constant in
/// `no_std` builds (which have no entropy source) — non-determinism that no
/// differential test can observe either way.
rng_state: core::cell::Cell<u64>,
/// `PRAGMA cache_size` setting, round-tripped verbatim (a positive value is a
/// page count, a negative value is KiB; default −2000). graphite keeps every
/// page resident, so this is reported back but does not bound a real cache.
cache_size: core::cell::Cell<i64>,
/// `PRAGMA data_version` — sqlite's per-connection `SQLITE_FCNTL_DATA_VERSION`
/// counter. It stays constant for the life of a connection *unless another
/// connection commits* to the database, at which point it changes. Starts at
/// `1` (`dv_counter`); `dv_seen_cc` remembers the on-disk change counter this
/// connection has already accounted for (its own writes plus the last value
/// read), so a later read that finds a *different* change counter — a foreign
/// commit — bumps `dv_counter`. `None` until first observed.
dv_counter: core::cell::Cell<i64>,
dv_seen_cc: core::cell::Cell<Option<u32>>,
/// `PRAGMA analysis_limit` — the row sample cap `ANALYZE` would use (0 =
/// unlimited). graphite always analyzes fully, so this is advisory; it is
/// stored and reported back like sqlite (which clamps a negative value to 0).
analysis_limit: core::cell::Cell<i64>,
/// `PRAGMA busy_timeout` — the lock-wait timeout in ms (0 = no wait). graphite
/// has no cross-process lock manager, so this never blocks; it is stored and
/// reported back like sqlite (which clamps a negative value to 0).
busy_timeout: core::cell::Cell<i64>,
/// `PRAGMA journal_size_limit` — the cap (bytes) sqlite would shrink a
/// rollback/WAL journal back to (-1 = no limit, the default). graphite's
/// journal handling does not honor it, so it is advisory; it is stored and
/// reported back like sqlite, which clamps any negative value to -1.
journal_size_limit: core::cell::Cell<i64>,
/// `PRAGMA secure_delete` (0=off, 1=on, 2=fast), round-tripped like sqlite.
/// When non-zero, freed pages are zeroed (the pager honors it); a
/// per-connection runtime setting, not persisted in the file.
secure_delete: core::cell::Cell<i64>,
/// `PRAGMA automatic_index` (default on). graphite's planner never builds
/// transient automatic indexes, so the flag is inert; it is stored and
/// reported back like sqlite for drop-in compatibility.
automatic_index: core::cell::Cell<bool>,
/// `PRAGMA cell_size_check` (default off). graphite already validates btree
/// cells on every read, so the flag is inert; it is stored and reported back
/// like sqlite for drop-in compatibility.
cell_size_check: core::cell::Cell<bool>,
/// `PRAGMA synchronous` (0=OFF, 1=NORMAL, 2=FULL, 3=EXTRA; default FULL).
/// graphite has no fsync-policy knob, so this is advisory; it is stored and
/// reported back like sqlite.
synchronous: core::cell::Cell<i64>,
/// `PRAGMA temp_store` (0=DEFAULT, 1=FILE, 2=MEMORY). graphite holds temp data
/// in the pager regardless, so this is advisory; stored and reported back.
temp_store: core::cell::Cell<i64>,
/// `PRAGMA threads` — the max auxiliary sort threads. graphite is
/// single-threaded, so this is advisory; stored and reported back like sqlite.
threads: core::cell::Cell<i64>,
/// `PRAGMA soft_heap_limit` — an advisory memory cap (bytes, 0 = unlimited).
/// graphite does not bound its heap, so it is stored and reported back like
/// sqlite (which echoes the value on set). (`hard_heap_limit` is deliberately
/// left inert: sqlite *enforces* it — a small value OOMs — which graphite can't
/// replicate, so echoing it would diverge; it stays a reported-0 no-op.)
soft_heap_limit: core::cell::Cell<i64>,
/// `PRAGMA wal_autocheckpoint` — the WAL frame threshold that triggers an
/// automatic checkpoint (default 1000). Advisory here; stored and reported back.
wal_autocheckpoint: core::cell::Cell<i64>,
/// User-defined scalar functions registered via
/// [`register_function`](Self::register_function), keyed by lowercased name.
/// Built-in functions take precedence; these fill otherwise-unknown names.
functions: alloc::collections::BTreeMap<String, ScalarFunction>,
/// User-defined aggregate functions registered via
/// [`register_aggregate_function`](Self::register_aggregate_function), keyed by
/// lowercased name. Built-in aggregates take precedence.
aggregates: alloc::collections::BTreeMap<String, AggregateFactory>,
/// Per-query FTS5 state ([`Fts5QueryCtx`]: the MATCH query plus, when ranking
/// is referenced, the bm25 corpus), set by `run_core` while executing a
/// `SELECT … MATCH …` over an `fts5` table and read by the `rank`/`bm25()`/
/// `highlight()` special forms. `None` outside such a query.
#[cfg(feature = "fts5")]
fts5_rank: core::cell::RefCell<Option<Fts5QueryCtx>>,
/// Names of self-content `fts5` tables written (INSERT/UPDATE/DELETE) inside
/// the current explicit transaction (or open savepoint) whose segment index
/// has been left untouched and must be flushed at COMMIT / outermost RELEASE.
/// Mirrors SQLite, which accumulates a transaction's postings in an in-memory
/// hash and writes them as ONE level-0 segment at `xSync`/`xCommit` — so an
/// N-INSERT transaction appends one segment, not N. Cleared at commit (after
/// the flush) and on ROLLBACK (nothing was written to the index to undo). The
/// document rows themselves live in `<name>_content` (pager-managed, so a
/// ROLLBACK/ROLLBACK TO reverts them), and in-transaction `MATCH` reads them
/// directly (the stale index is bypassed while `in_tx`/`open_savepoints`).
/// Maps each dirtied table to whether it needs a full rebuild at flush time
/// (`true`) rather than an incremental append (`false`). A pure-insert
/// transaction appends one level-0 segment (byte-identical to sqlite); a
/// transaction that deleted or updated a previously-committed document sets
/// the flag, because the incremental appender compares rowid *sets* and cannot
/// see a same-rowid content change — so those flush as a single consolidated
/// rebuild from the live `<name>_content` instead (correct + integrity-clean,
/// though not byte-identical to sqlite's incremental tombstone segments).
#[cfg(feature = "fts5")]
fts5_txn_dirty: alloc::collections::BTreeMap<String, bool>,
/// Per-table ORDERED log of the writes made to each self-content `fts5` table
/// inside the current explicit transaction (keyed by table name). At the
/// commit-time flush this is replayed through SQLite's
/// `sqlite3Fts5IndexBeginWrite` flush-boundary logic to reproduce its level-0
/// segment structure byte-for-byte for the delete/update and out-of-order-rowid
/// shapes (a plain `BEGIN … COMMIT`). Populated alongside `fts5_txn_dirty`;
/// ignored for tables written under an open `SAVEPOINT` (see
/// `fts5_txn_sp_used`), which keep the consolidated legacy flush. Cleared with
/// the rest of the transaction state at commit / rollback.
#[cfg(feature = "fts5")]
fts5_txn_ops: alloc::collections::BTreeMap<String, Vec<Fts5TxnOp>>,
/// Self-content `fts5` tables that are SAVEPOINT-involved: written while a
/// `SAVEPOINT` was open, or reached at a savepoint-boundary flush. These mirror
/// SQLite's `xSavepoint`, which flushes the pending in-memory postings to disk
/// as a level-0 segment at each savepoint open (before the pager savepoint, so a
/// later `ROLLBACK TO` reverts only the segments written after it) and again at
/// `xSync`/commit. The per-table op-log (`fts5_txn_ops`) holds the ops since the
/// last flush; each boundary/commit flush replays it into batches and then
/// clears it. Cleared at commit / rollback.
#[cfg(feature = "fts5")]
fts5_txn_sp_used: alloc::collections::BTreeSet<String>,
/// SAVEPOINT-involved tables whose incremental batch flush DECLINED at some
/// boundary (a spanning doclist or all-empty tombstone batch the incremental
/// writer cannot reproduce). They fall back to a single consolidated rebuild
/// from the live `<name>_content` at the final commit flush — correct and
/// integrity-clean, though not byte-identical for that rare shape. Cleared at
/// commit / rollback.
#[cfg(feature = "fts5")]
fts5_txn_sp_bail: alloc::collections::BTreeSet<String>,
/// Whether `SELECT` execution tries the VDBE engine first, falling back
/// transparently to the tree-walker for any query shape it does not support.
/// **On by default** (Track B, B7b): the VDBE is the primary engine, parity-
/// validated across the full test suite and the differential corpus. Toggled
/// by [`set_use_vdbe`](Self::set_use_vdbe) — turn it off to force the
/// tree-walker. The result is identical either way; this only chooses which
/// engine produces it.
use_vdbe: core::cell::Cell<bool>,
/// The active change-tracking session's shared recorder, if a [`Session`]
/// has been created on this connection (roadmap D5). `None` when no session
/// is active, in which case the write-path hook
/// ([`record_session_change`](Self::record_session_change)) is a no-op.
/// Shared with the caller's [`Session`] via reference counting so DML pushes
/// changes into the session the caller holds.
session: core::cell::RefCell<
Option<alloc::rc::Rc<core::cell::RefCell<crate::session::SessionState>>>,
>,
/// The data-change notification callback, the equivalent of
/// `sqlite3_update_hook`: invoked once per inserted/updated/deleted row with
/// the operation, the (schema, table) it belongs to, and the rowid. `None`
/// when no hook is registered.
#[allow(clippy::type_complexity)]
update_hook: core::cell::RefCell<Option<Box<dyn FnMut(UpdateOp, &str, &str, i64)>>>,
/// The commit callback, the equivalent of `sqlite3_commit_hook`: invoked just
/// before a transaction (explicit `COMMIT`, an autocommit write, or the
/// finalizing release of an implicit transaction's outermost savepoint) is
/// committed. Returning a non-zero value converts the commit into a rollback
/// (and fires the [`rollback_hook`](Self::rollback_hook)). `None` when unset.
#[allow(clippy::type_complexity)]
commit_hook: core::cell::RefCell<Option<Box<dyn FnMut() -> i32>>>,
/// The rollback callback, the equivalent of `sqlite3_rollback_hook`: invoked
/// whenever a transaction rolls back (explicit `ROLLBACK`, or a commit vetoed
/// by the [`commit_hook`](Self::commit_hook)). `None` when unset.
#[allow(clippy::type_complexity)]
rollback_hook: core::cell::RefCell<Option<Box<dyn FnMut()>>>,
/// The authorizer callback, the equivalent of `sqlite3_set_authorizer`:
/// consulted while preparing a statement with the action code and up to two
/// action-specific string arguments (e.g. table and column). Returning
/// [`SQLITE_DENY`](AuthResult::Deny) rejects the statement; `SQLITE_OK` allows
/// it. `None` when unset.
#[allow(clippy::type_complexity)]
authorizer: core::cell::RefCell<
Option<Box<dyn FnMut(i32, Option<&str>, Option<&str>, Option<&str>, Option<&str>) -> i32>>,
>,
}
/// SQLite authorizer action codes (a subset covering the statement-level
/// operations graphitesql authorizes). Passed to the callback registered with
/// [`Connection::set_authorizer`].
#[allow(missing_docs)]
pub mod auth_action {
pub const CREATE_INDEX: i32 = 1;
pub const CREATE_TABLE: i32 = 2;
pub const CREATE_TEMP_INDEX: i32 = 3;
pub const CREATE_TEMP_TABLE: i32 = 4;
pub const CREATE_TEMP_TRIGGER: i32 = 5;
pub const CREATE_TEMP_VIEW: i32 = 6;
pub const CREATE_TRIGGER: i32 = 7;
pub const CREATE_VIEW: i32 = 8;
pub const DELETE: i32 = 9;
pub const DROP_INDEX: i32 = 10;
pub const DROP_TABLE: i32 = 11;
pub const DROP_TRIGGER: i32 = 16;
pub const DROP_VIEW: i32 = 17;
pub const INSERT: i32 = 18;
pub const PRAGMA: i32 = 19;
pub const READ: i32 = 20;
pub const SELECT: i32 = 21;
pub const TRANSACTION: i32 = 22;
pub const UPDATE: i32 = 23;
pub const ATTACH: i32 = 24;
pub const DETACH: i32 = 25;
pub const ALTER_TABLE: i32 = 26;
pub const REINDEX: i32 = 27;
pub const ANALYZE: i32 = 28;
pub const CREATE_VTABLE: i32 = 29;
pub const DROP_VTABLE: i32 = 30;
pub const FUNCTION: i32 = 31;
pub const SAVEPOINT: i32 = 32;
}
/// The result of an authorizer callback (`SQLITE_OK` / `SQLITE_DENY` /
/// `SQLITE_IGNORE`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthResult {
/// Allow the action (`SQLITE_OK`, 0).
Ok = 0,
/// Reject the whole statement with an authorization error (`SQLITE_DENY`, 1).
Deny = 1,
/// Disallow this action without failing the statement (`SQLITE_IGNORE`, 2).
/// graphitesql treats it like `Deny` for the statement-level actions it
/// authorizes (the read-column NULL substitution is not modeled).
Ignore = 2,
}
/// The kind of row change reported to an [update hook](Connection::register_update_hook).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateOp {
/// A row was inserted.
Insert,
/// A row was updated.
Update,
/// A row was deleted.
Delete,
}
/// A user-defined scalar function: it receives its evaluated argument values and
/// returns a result [`Value`] (or an error). Registered with
/// [`Connection::register_function`].
pub type ScalarFunction = Box<dyn Fn(&[Value]) -> Result<Value>>;
/// A user-defined aggregate's accumulator: `step` is called once per group row
/// with the evaluated argument values, then `finalize` produces the result.
/// A fresh accumulator is created (by the registered factory) for each group.
pub trait AggregateFunction {
/// Fold one row's argument values into the accumulator.
fn step(&mut self, args: &[Value]) -> Result<()>;
/// Produce the aggregate's value for the group.
fn finalize(&mut self) -> Result<Value>;
}
/// Builds a fresh [`AggregateFunction`] accumulator per group. Registered with
/// [`Connection::register_aggregate_function`].
pub type AggregateFactory = Box<dyn Fn() -> Box<dyn AggregateFunction>>;
/// One FTS5 incremental DELETE/UPDATE change: `(rowid, old fts5-column values,
/// new fts5-column values?)`. `None` new values ⇒ a pure delete; `Some(v)` ⇒ an
/// UPDATE (tombstone the old terms, insert the new). See
/// [`Executor::fts5_incremental_delete`].
#[cfg(feature = "fts5")]
type Fts5Change = (i64, Vec<Value>, Option<Vec<Value>>);
/// One recorded FTS5 write inside an explicit transaction, in execution order.
/// Replays SQLite's `sqlite3Fts5IndexBeginWrite` sequence so the commit-time flush
/// can reproduce its level-0 segment boundaries byte-for-byte (a rowid regression,
/// a same-rowid re-write, or a hash overflow flushes the pending postings as one
/// segment). Each variant carries the fts5 column values (declared order, no
/// leading rowid) the flush needs to (re)tokenize.
#[cfg(feature = "fts5")]
#[derive(Clone)]
enum Fts5TxnOp {
/// A new document (`INSERT`): insert postings for `values` under `rowid`.
Insert { rowid: i64, values: Vec<Value> },
/// A `DELETE`: tombstone `old_values`' terms for `rowid`.
Delete { rowid: i64, old_values: Vec<Value> },
/// An `UPDATE` (or a delete+reinsert collapsed by SQLite's hash): tombstone
/// `old_values`' terms and insert `new_values`' postings under `rowid`. Modeled
/// as SQLite does — a delete `BeginWrite` immediately followed by an insert
/// `BeginWrite` for the same rowid (which never flushes between them).
Update {
rowid: i64,
old_values: Vec<Value>,
new_values: Vec<Value>,
},
}
/// One document's contribution to a single flushed level-0 segment: tombstone the
/// `old_values` terms (when `Some`) and/or insert the `new_values` postings (when
/// `Some`) under `rowid`. A pure insert has `old_values = None`; a pure delete has
/// `new_values = None`; an update has both.
#[cfg(feature = "fts5")]
struct Fts5BatchEntry {
rowid: i64,
old_values: Option<Vec<Value>>,
new_values: Option<Vec<Value>>,
}
/// Initial seed for a connection's `random()` generator. Under `std` it mixes
/// the wall clock so repeated invocations of the binary produce different
/// sequences; `no_std` builds, lacking any entropy source, fall back to a fixed
/// constant (the SplitMix64 golden-ratio increment).
fn initial_rng_seed() -> u64 {
#[cfg(feature = "std")]
{
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
nanos ^ 0x9E37_79B9_7F4A_7C15
}
#[cfg(not(feature = "std"))]
{
0x9E37_79B9_7F4A_7C15
}
}
/// Which database an operation targets: `main`, the lazily-created `temp`
/// database, or an attached database by index.
#[derive(Clone, Copy, PartialEq, Eq)]
enum DbRef {
Main,
Temp,
Attached(usize),
}
/// An attached database (`ATTACH 'file' AS name`): its own storage and catalog.
struct AttachedDb {
/// The schema name given in `ATTACH … AS name`.
name: String,
/// The file path it was attached from (empty for an in-memory attachment).
file: String,
backend: Backend,
schema: Schema,
}
/// A materialized common table expression: a named, in-memory relation.
struct CteBinding {
name: String,
columns: Vec<ColumnInfo>,
rows: Vec<InputRow>,
}
/// A snapshot of an enclosing query's current row, for correlated subqueries.
struct OuterFrame {
columns: Vec<ColumnInfo>,
row: Vec<Value>,
rowid: Option<i64>,
}
/// The kind of data-change event, for trigger matching.
#[derive(Clone, Copy, PartialEq, Eq)]
enum TrigEvent {
Insert,
Update,
Delete,
}
impl Connection {
fn from_pager(db: WritePager) -> Result<Connection> {
let backend = Backend::Write(Box::new(db));
let schema = Schema::read(backend.source())?;
Ok(Connection {
backend,
schema,
main_file: String::new(),
attached: Vec::new(),
temp_db: None,
in_tx: false,
cte_env: core::cell::RefCell::new(Vec::new()),
outer_scope: core::cell::RefCell::new(Vec::new()),
foreign_keys: false,
case_sensitive_like: false,
query_only: false,
ignore_check_constraints: false,
trigger_depth: core::cell::Cell::new(0),
fk_depth: core::cell::Cell::new(0),
cascade_compact: core::cell::RefCell::new(alloc::collections::BTreeSet::new()),
stmt_keep_partial: core::cell::Cell::new(false),
stmt_rollback_tx: core::cell::Cell::new(false),
raise_ignore: core::cell::Cell::new(false),
recursive_triggers: false,
returning_rows: core::cell::RefCell::new(Vec::new()),
open_savepoints: 0,
last_insert_rowid: core::cell::Cell::new(0),
changes: core::cell::Cell::new(0),
total_changes: core::cell::Cell::new(0),
read_default: core::cell::Cell::new(DbRef::Main),
write_target: core::cell::Cell::new(DbRef::Main),
swap_active: core::cell::Cell::new(None),
vtab_registry: VTabRegistry::with_builtins(),
rng_state: core::cell::Cell::new(initial_rng_seed()),
cache_size: core::cell::Cell::new(-2000),
dv_counter: core::cell::Cell::new(1),
dv_seen_cc: core::cell::Cell::new(None),
analysis_limit: core::cell::Cell::new(0),
busy_timeout: core::cell::Cell::new(0),
journal_size_limit: core::cell::Cell::new(-1),
secure_delete: core::cell::Cell::new(0),
automatic_index: core::cell::Cell::new(true),
cell_size_check: core::cell::Cell::new(false),
synchronous: core::cell::Cell::new(2),
temp_store: core::cell::Cell::new(0),
threads: core::cell::Cell::new(0),
soft_heap_limit: core::cell::Cell::new(0),
wal_autocheckpoint: core::cell::Cell::new(1000),
functions: alloc::collections::BTreeMap::new(),
aggregates: alloc::collections::BTreeMap::new(),
#[cfg(feature = "fts5")]
fts5_rank: core::cell::RefCell::new(None),
#[cfg(feature = "fts5")]
fts5_txn_dirty: alloc::collections::BTreeMap::new(),
#[cfg(feature = "fts5")]
fts5_txn_ops: alloc::collections::BTreeMap::new(),
#[cfg(feature = "fts5")]
fts5_txn_sp_used: alloc::collections::BTreeSet::new(),
#[cfg(feature = "fts5")]
fts5_txn_sp_bail: alloc::collections::BTreeSet::new(),
use_vdbe: core::cell::Cell::new(true),
session: core::cell::RefCell::new(None),
update_hook: core::cell::RefCell::new(None),
commit_hook: core::cell::RefCell::new(None),
rollback_hook: core::cell::RefCell::new(None),
authorizer: core::cell::RefCell::new(None),
})
}
fn from_read_backend(backend: Box<dyn PageSource>) -> Result<Connection> {
let backend = Backend::Read(backend);
let schema = Schema::read(backend.source())?;
Ok(Connection {
backend,
schema,
main_file: String::new(),
attached: Vec::new(),
temp_db: None,
in_tx: false,
cte_env: core::cell::RefCell::new(Vec::new()),
outer_scope: core::cell::RefCell::new(Vec::new()),
foreign_keys: false,
case_sensitive_like: false,
query_only: false,
ignore_check_constraints: false,
trigger_depth: core::cell::Cell::new(0),
fk_depth: core::cell::Cell::new(0),
cascade_compact: core::cell::RefCell::new(alloc::collections::BTreeSet::new()),
stmt_keep_partial: core::cell::Cell::new(false),
stmt_rollback_tx: core::cell::Cell::new(false),
raise_ignore: core::cell::Cell::new(false),
recursive_triggers: false,
returning_rows: core::cell::RefCell::new(Vec::new()),
open_savepoints: 0,
last_insert_rowid: core::cell::Cell::new(0),
changes: core::cell::Cell::new(0),
total_changes: core::cell::Cell::new(0),
read_default: core::cell::Cell::new(DbRef::Main),
write_target: core::cell::Cell::new(DbRef::Main),
swap_active: core::cell::Cell::new(None),
vtab_registry: VTabRegistry::with_builtins(),
rng_state: core::cell::Cell::new(initial_rng_seed()),
cache_size: core::cell::Cell::new(-2000),
dv_counter: core::cell::Cell::new(1),
dv_seen_cc: core::cell::Cell::new(None),
analysis_limit: core::cell::Cell::new(0),
busy_timeout: core::cell::Cell::new(0),
journal_size_limit: core::cell::Cell::new(-1),
secure_delete: core::cell::Cell::new(0),
automatic_index: core::cell::Cell::new(true),
cell_size_check: core::cell::Cell::new(false),
synchronous: core::cell::Cell::new(2),
temp_store: core::cell::Cell::new(0),
threads: core::cell::Cell::new(0),
soft_heap_limit: core::cell::Cell::new(0),
wal_autocheckpoint: core::cell::Cell::new(1000),
functions: alloc::collections::BTreeMap::new(),
aggregates: alloc::collections::BTreeMap::new(),
#[cfg(feature = "fts5")]
fts5_rank: core::cell::RefCell::new(None),
#[cfg(feature = "fts5")]
fts5_txn_dirty: alloc::collections::BTreeMap::new(),
#[cfg(feature = "fts5")]
fts5_txn_ops: alloc::collections::BTreeMap::new(),
#[cfg(feature = "fts5")]
fts5_txn_sp_used: alloc::collections::BTreeSet::new(),
#[cfg(feature = "fts5")]
fts5_txn_sp_bail: alloc::collections::BTreeSet::new(),
use_vdbe: core::cell::Cell::new(true),
session: core::cell::RefCell::new(None),
update_hook: core::cell::RefCell::new(None),
commit_hook: core::cell::RefCell::new(None),
rollback_hook: core::cell::RefCell::new(None),
authorizer: core::cell::RefCell::new(None),
})
}
/// Open an existing database for reading and writing through `vfs`. Creates
/// (and recovers from) a `<path>-journal` companion file.
pub fn open_vfs(vfs: &dyn Vfs, path: &str) -> Result<Connection> {
let main = vfs.open(path, OpenFlags::READ_WRITE)?;
let journal = vfs.open(&journal_path(path), OpenFlags::READ_WRITE_CREATE)?;
let wal = vfs.open(&wal_path(path), OpenFlags::READ_WRITE_CREATE)?;
let mut c = Connection::from_pager(WritePager::open_wal(main, Some(journal), Some(wal))?)?;
c.main_file = path.to_string();
Ok(c)
}
/// Open an existing database read-only through `vfs`. If a `<path>-wal` file
/// is present, its committed frames are overlaid so WAL-mode databases read
/// correctly.
pub fn open_readonly_vfs(vfs: &dyn Vfs, path: &str) -> Result<Connection> {
let main = vfs.open(path, OpenFlags::READ_ONLY)?;
let wal_path = wal_path(path);
if vfs.exists(&wal_path)? {
let mut wal = vfs.open(&wal_path, OpenFlags::READ_ONLY)?;
let reader = crate::pager::WalReader::open(main, wal.as_mut())?;
let mut c = Connection::from_read_backend(Box::new(reader))?;
c.main_file = path.to_string();
return Ok(c);
}
let mut c = Connection::from_read_backend(Box::new(WritePager::open(main, None)?))?;
c.main_file = path.to_string();
Ok(c)
}
/// Create a new, empty database through `vfs`.
pub fn create_vfs(vfs: &dyn Vfs, path: &str, page_size: u32) -> Result<Connection> {
let main = vfs.open(path, OpenFlags::READ_WRITE_CREATE)?;
let journal = vfs.open(&journal_path(path), OpenFlags::READ_WRITE_CREATE)?;
let wal = vfs.open(&wal_path(path), OpenFlags::READ_WRITE_CREATE)?;
let mut db = WritePager::create_wal(main, Some(journal), Some(wal), page_size)?;
db.commit()?;
let mut c = Connection::from_pager(db)?;
c.main_file = path.to_string();
Ok(c)
}
/// Open an existing database file for reading and writing (requires `std`).
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
pub fn open(path: &str) -> Result<Connection> {
Connection::open_vfs(&crate::vfs::std_file::StdVfs::new(), path)
}
/// Open an existing database file read-only (requires `std`).
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
pub fn open_readonly(path: &str) -> Result<Connection> {
Connection::open_readonly_vfs(&crate::vfs::std_file::StdVfs::new(), path)
}
/// Create a new database file with the default 4096-byte page size (`std`).
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
pub fn create(path: &str) -> Result<Connection> {
Connection::create_vfs(&crate::vfs::std_file::StdVfs::new(), path, 4096)
}
/// Create a fresh in-memory database (`:memory:`), always available.
pub fn open_memory() -> Result<Connection> {
let vfs = crate::vfs::memory::MemoryVfs::new();
let main = vfs.open("main", OpenFlags::READ_WRITE_CREATE)?;
let mut db = WritePager::create(main, None, 4096)?;
db.commit()?;
Connection::from_pager(db)
}
/// Open a read-write in-memory database from a serialized database image —
/// the equivalent of `sqlite3_deserialize()`. `bytes` must be a complete
/// SQLite database file (such as one produced by
/// [`serialize`](Self::serialize) or written by `sqlite3`); the image is
/// copied into a private in-memory VFS and opened.
///
/// Always available (`no_std` too), so a database can be loaded from a byte
/// buffer without any filesystem.
///
/// # Errors
/// [`crate::error::Error`] if `bytes` is not a valid database image.
pub fn deserialize(bytes: &[u8]) -> Result<Connection> {
let vfs = crate::vfs::memory::MemoryVfs::new();
{
let mut main = vfs.open("main", OpenFlags::READ_WRITE_CREATE)?;
main.write_all_at(bytes, 0)?;
main.sync()?;
}
Connection::open_vfs(&vfs, "main")
}
/// Replace this connection's `main` database with the database image `bytes`
/// (a complete SQLite file, as produced by [`serialize`](Self::serialize) or
/// written by `sqlite3`), the primitive behind an online backup's destination
/// side. Registered callbacks (update / commit / rollback hooks), functions,
/// collations, and PRAGMA settings are preserved — only the stored data and its
/// schema change. The connection must not be inside an open transaction. The
/// restored image is held in memory (as with [`deserialize`](Self::deserialize));
/// persist it to a file afterward with [`serialize`](Self::serialize) if needed.
pub fn restore_from(&mut self, bytes: &[u8]) -> Result<()> {
if self.in_tx || self.open_savepoints > 0 {
return Err(Error::Error(
"cannot restore into a connection with an active transaction".into(),
));
}
let fresh = Connection::deserialize(bytes)?;
self.backend = fresh.backend;
self.schema = fresh.schema;
self.last_insert_rowid.set(0);
self.changes.set(0);
Ok(())
}
/// The schema catalog.
pub fn schema(&self) -> &Schema {
&self.schema
}
/// The rowid of the most recently inserted row on this connection, the
/// equivalent of `sqlite3_last_insert_rowid()` (and of the SQL
/// `last_insert_rowid()` function). Returns 0 if no row has ever been
/// inserted. A successful `INSERT` into a rowid table updates it; other
/// statements leave it unchanged.
pub fn last_insert_rowid(&self) -> i64 {
self.last_insert_rowid.get()
}
/// The number of rows modified, inserted, or deleted by the most recently
/// completed `INSERT`/`UPDATE`/`DELETE` statement — the equivalent of
/// `sqlite3_changes()` (and the SQL `changes()` function). Statements that
/// are not `INSERT`/`UPDATE`/`DELETE` leave it unchanged.
pub fn changes(&self) -> i64 {
self.changes.get()
}
/// The total number of rows modified, inserted, or deleted by
/// `INSERT`/`UPDATE`/`DELETE` statements since this connection was opened —
/// the equivalent of `sqlite3_total_changes()` (and the SQL
/// `total_changes()` function).
pub fn total_changes(&self) -> i64 {
self.total_changes.get()
}
/// Whether the connection is in autocommit mode — the equivalent of
/// `sqlite3_get_autocommit()`. Autocommit is on by default and is turned off
/// by a `BEGIN` (or an outermost `SAVEPOINT`) until the matching
/// `COMMIT`/`ROLLBACK` (or `RELEASE`) restores it.
pub fn is_autocommit(&self) -> bool {
!self.in_tx && self.open_savepoints == 0
}
/// Run a single `SELECT` and return all rows.
pub fn query(&self, sql: &str) -> Result<QueryResult> {
self.query_params(sql, &Params::default())
}
/// Run `sql` through the experimental VDBE engine instead of the tree-walker.
/// Supports constant projections and plain single-table scans
/// (`SELECT <exprs> FROM <table>` with no `WHERE`/joins/aggregates/`ORDER BY`);
/// returns `Unsupported` otherwise so callers can fall back to
/// [`query`](Self::query).
pub fn query_vdbe(&self, sql: &str) -> Result<QueryResult> {
let Statement::Select(sel) = sql::parse_one(sql)? else {
return Err(Error::Unsupported("query_vdbe expects SELECT"));
};
self.run_select_vdbe(&sel)
}
/// Enable or disable the VDBE engine for `SELECT` (Track B). When on (the
/// default), [`query`](Self::query) runs through the VDBE and falls back
/// transparently to the tree-walker for any query shape it does not handle;
/// turning it off forces the tree-walker. The result is identical either way.
pub fn set_use_vdbe(&self, on: bool) {
self.use_vdbe.set(on);
}
/// Compile a `SELECT` to a VDBE program *without running it*, gathering only
/// the schema (column names / qualifiers / affinities) it needs — no row
/// scan. Used by plain `EXPLAIN`. Covers the constant and single-table cases;
/// joins and other shapes return `Unsupported`.
fn compile_select_program(&self, sel: &Select) -> Result<vdbe::Program> {
let Some(from) = &sel.from else {
return vdbe::compile_const_select(sel);
};
if !from.joins.is_empty() {
return Err(Error::Unsupported(
"EXPLAIN: VDBE join programs not yet listed",
));
}
if from.first.subquery.is_some() || from.first.tvf_args.is_some() {
return Err(Error::Unsupported("EXPLAIN: only plain table sources"));
}
let meta = self.table_meta(&from.first.name, from.first.alias.as_deref())?;
let cols: Vec<String> = meta.columns.iter().map(|c| c.name.clone()).collect();
let qualifier = from
.first
.alias
.clone()
.unwrap_or_else(|| from.first.name.clone());
let tables: Vec<String> = meta.columns.iter().map(|_| qualifier.clone()).collect();
let affinities: Vec<eval::Affinity> = meta.columns.iter().map(|c| c.affinity).collect();
let collations: Vec<crate::value::Collation> =
meta.columns.iter().map(|c| c.collation).collect();
// A rowid table can carry `rowid`/`_rowid_`/`oid` references; expose the
// hidden rowid slot so EXPLAIN compiles the same program execution uses.
vdbe::compile_table_select(
sel,
&cols,
&tables,
&affinities,
&collations,
!meta.without_rowid,
)
}
/// Plain `EXPLAIN <select>` (Track B, B8): compile the query to graphite's
/// VDBE bytecode and return the program listing as `(addr, opcode, detail)`
/// rows. Returns `Unsupported` for a query shape the VDBE cannot compile.
fn explain_bytecode(&self, stmt: &Statement) -> Result<QueryResult> {
let Statement::Select(sel) = stmt else {
return Err(Error::Unsupported(
"EXPLAIN: only SELECT is compiled to bytecode",
));
};
let prog = self.compile_select_program(sel)?;
let rows = prog
.explain_rows()
.into_iter()
.map(|(addr, opcode, detail)| {
alloc::vec![
Value::Integer(addr as i64),
Value::Text(opcode.into()),
Value::Text(detail.into()),
]
})
.collect();
Ok(QueryResult {
columns: alloc::vec!["addr".into(), "opcode".into(), "detail".into()],
rows,
})
}
/// Rewrite `sel`'s top-level expressions, replacing every provably
/// non-correlated scalar or `EXISTS` subquery with the constant it evaluates
/// to. Returns `Some(rewritten)` when at least one subquery was folded, or
/// `None` when there was nothing to fold (the caller keeps the original).
///
/// Only the *top-level* expression positions are touched — a subquery that is
/// itself a `FROM` source is its own scope and is materialized separately. A
/// subquery is folded only when [`Self::vdbe_subquery_foldable`] proves it is
/// self-contained; everything else is left untouched, so the result is never
/// affected (the compiler simply falls back when an unfoldable subquery
/// remains).
fn fold_vdbe_subqueries(&self, sel: &Select) -> Option<Select> {
let mut changed = false;
let mut out = sel.clone();
for rc in &mut out.columns {
if let sql::ast::ResultColumn::Expr { expr, .. } = rc {
*expr = self.fold_subquery_expr(expr, &mut changed);
}
}
if let Some(w) = out.where_clause.take() {
out.where_clause = Some(self.fold_subquery_expr(&w, &mut changed));
}
if let Some(h) = out.having.take() {
out.having = Some(self.fold_subquery_expr(&h, &mut changed));
}
for g in &mut out.group_by {
*g = self.fold_subquery_expr(g, &mut changed);
}
for o in &mut out.order_by {
o.expr = self.fold_subquery_expr(&o.expr, &mut changed);
}
// A non-correlated scalar subquery in `LIMIT`/`OFFSET` folds to its
// constant, which the VDBE's `fold_const_int` then accepts (it otherwise
// bails on any non-constant LIMIT/OFFSET). Parity-safe: the value comes
// from running the subquery, and a non-integer fold still falls back.
if let Some(l) = out.limit.take() {
out.limit = Some(self.fold_subquery_expr(&l, &mut changed));
}
if let Some(o) = out.offset.take() {
out.offset = Some(self.fold_subquery_expr(&o, &mut changed));
}
if let Some(from) = &mut out.from {
for j in &mut from.joins {
if let Some(on) = j.on.take() {
j.on = Some(self.fold_subquery_expr(&on, &mut changed));
}
}
}
if changed { Some(out) } else { None }
}
/// Recursively rebuild `e`, folding any foldable scalar/`EXISTS` subquery into
/// a literal and otherwise descending into sub-expressions. A subquery that is
/// not foldable is left in place (so the VDBE compiler still falls back).
fn fold_subquery_expr(&self, e: &Expr, changed: &mut bool) -> Expr {
use sql::ast::Expr as E;
match e {
E::Subquery(sel2) => match self.eval_foldable_scalar(sel2) {
Some(v) => {
*changed = true;
E::Literal(value_to_literal(v))
}
None => e.clone(),
},
E::Exists { select, negated } => match self.eval_foldable_exists(select) {
Some(found) => {
*changed = true;
E::Literal(Literal::Integer((found ^ *negated) as i64))
}
None => e.clone(),
},
E::Unary { op, expr } => E::Unary {
op: *op,
expr: alloc::boxed::Box::new(self.fold_subquery_expr(expr, changed)),
},
E::Binary { op, left, right } => E::Binary {
op: *op,
left: alloc::boxed::Box::new(self.fold_subquery_expr(left, changed)),
right: alloc::boxed::Box::new(self.fold_subquery_expr(right, changed)),
},
E::IsNull { expr, negated } => E::IsNull {
expr: alloc::boxed::Box::new(self.fold_subquery_expr(expr, changed)),
negated: *negated,
},
E::InList {
expr,
list,
negated,
candidate_affinity,
} => E::InList {
expr: alloc::boxed::Box::new(self.fold_subquery_expr(expr, changed)),
list: list
.iter()
.map(|x| self.fold_subquery_expr(x, changed))
.collect(),
negated: *negated,
candidate_affinity: candidate_affinity.clone(),
},
E::Between {
expr,
low,
high,
negated,
} => E::Between {
expr: alloc::boxed::Box::new(self.fold_subquery_expr(expr, changed)),
low: alloc::boxed::Box::new(self.fold_subquery_expr(low, changed)),
high: alloc::boxed::Box::new(self.fold_subquery_expr(high, changed)),
negated: *negated,
},
E::Case {
operand,
when_then,
else_result,
} => E::Case {
operand: operand
.as_ref()
.map(|o| alloc::boxed::Box::new(self.fold_subquery_expr(o, changed))),
when_then: when_then
.iter()
.map(|(w, t)| {
(
self.fold_subquery_expr(w, changed),
self.fold_subquery_expr(t, changed),
)
})
.collect(),
else_result: else_result
.as_ref()
.map(|x| alloc::boxed::Box::new(self.fold_subquery_expr(x, changed))),
},
E::Cast { expr, type_name } => E::Cast {
expr: alloc::boxed::Box::new(self.fold_subquery_expr(expr, changed)),
type_name: type_name.clone(),
},
E::Paren(inner) => E::Paren(alloc::boxed::Box::new(
self.fold_subquery_expr(inner, changed),
)),
E::Collate { expr, collation } => E::Collate {
expr: alloc::boxed::Box::new(self.fold_subquery_expr(expr, changed)),
collation: collation.clone(),
},
E::RowValue(items) => E::RowValue(
items
.iter()
.map(|x| self.fold_subquery_expr(x, changed))
.collect(),
),
// A function call: fold within ordinary arguments and the `FILTER`
// predicate. A windowed call (`OVER (…)`) is left untouched (its frame
// exprs are not in the VDBE's grammar anyway).
E::Function {
name,
distinct,
args,
star,
filter,
order_by,
over,
..
} if over.is_none() => E::Function {
name: name.clone(),
distinct: *distinct,
args: args
.iter()
.map(|a| self.fold_subquery_expr(a, changed))
.collect(),
star: *star,
filter: filter
.as_ref()
.map(|f| alloc::boxed::Box::new(self.fold_subquery_expr(f, changed))),
order_by: order_by.clone(),
over: None,
span: Span::none(),
},
// `IN (SELECT …)`: fold to an `IN (list)` of the materialized candidate
// values when the subquery is self-contained (non-correlated). A
// *computed* candidate column carries NONE affinity / BINARY collation,
// so the list compares exactly like the original (no candidate affinity).
// A *bare-column* candidate instead contributes its column's affinity:
// SQLite compares under `combine(left_aff, col_aff)`, which a plain
// `IN (list)` would not reproduce — so the fold records that affinity in
// `candidate_affinity`, and the VDBE/eval feed it as the right-operand
// comparison affinity. (The candidate column's collation is irrelevant
// — `IN (SELECT)` uses the left operand's collation.) `None` leaves the
// `IN (SELECT)` in place.
E::InSelect {
expr,
select,
negated,
} => match self.eval_foldable_in_select(select) {
Some((values, candidate_affinity)) => {
*changed = true;
E::InList {
expr: alloc::boxed::Box::new(self.fold_subquery_expr(expr, changed)),
list: values
.into_iter()
.map(|v| E::Literal(value_to_literal(v)))
.collect(),
negated: *negated,
candidate_affinity,
}
}
None => e.clone(),
},
// Literals, parameters, columns, windowed calls: nothing to fold.
_ => e.clone(),
}
}
/// Evaluate a scalar subquery to its constant value when it is foldable, else
/// `None`. Foldable means [`Self::vdbe_subquery_foldable`] (self-contained) AND
/// the single result column is a *computed* expression, not a bare column
/// reference — so the resulting literal has the same NONE affinity / BINARY
/// collation the subquery operand would have had, making the substitution
/// exact for the enclosing comparison.
/// The *structural* half of [`Self::eval_foldable_scalar`]: whether a scalar
/// subquery would fold to a literal — self-contained (non-correlated), a single
/// *computed* result column, and (for a compound) every arm computed — WITHOUT
/// running it. Used to recognize `col = (subquery)` as a seekable equality for
/// EXPLAIN QUERY PLAN, which SQLite plans without evaluating the subquery.
fn scalar_subquery_folds_structurally(&self, sel2: &Select) -> bool {
self.vdbe_subquery_foldable(sel2)
&& sel2.columns.len() == 1
&& matches!(&sel2.columns[0],
sql::ast::ResultColumn::Expr { expr, .. } if !is_bare_column_expr(expr))
&& self.compound_arms_computed(sel2)
}
/// Whether an `IN (SELECT …)` candidate subquery is foldable to a value list
/// *without running it* — the structural half of [`Self::eval_foldable_in_select`]
/// (which additionally runs the body). Used to plan the `IN` seek in
/// `eqp_access` without evaluating the subquery, mirroring the executor fold so
/// the EQP and the seek agree. A bare-column candidate needs a resolvable single
/// origin (for its affinity); a computed candidate needs every compound arm
/// computed too.
fn in_select_folds_structurally(&self, sel2: &Select) -> bool {
if !self.vdbe_subquery_foldable(sel2) || sel2.columns.len() != 1 {
return false;
}
let sql::ast::ResultColumn::Expr { expr, .. } = &sel2.columns[0] else {
return false;
};
if is_bare_column_expr(expr) {
self.subquery_column_origins(sel2)
.is_some_and(|o| !o.is_empty())
} else {
self.compound_arms_computed(sel2)
}
}
fn eval_foldable_scalar(&self, sel2: &Select) -> Option<Value> {
if !self.scalar_subquery_folds_structurally(sel2) {
return None;
}
let r = self.run_select(sel2, &Params::default()).ok()?;
Some(
r.rows
.first()
.and_then(|row| row.first())
.cloned()
.unwrap_or(Value::Null),
)
}
/// Rewrite a `WHERE` clause so that a *structurally-foldable* non-correlated
/// scalar subquery used as a comparison operand (`col = (SELECT …)`, `col > (…)`)
/// is replaced by a non-NULL placeholder literal — WITHOUT running it. The seek
/// constraint collectors then recognize the comparison as seekable, so
/// `eqp_access` renders the `SEARCH` SQLite plans (SQLite plans the seek without
/// evaluating the subquery; the executor evaluates it via `fold_subquery_expr`).
/// Descends only the `AND`/`(…)` spine and the seekable comparison operators, so a
/// subquery elsewhere (an `OR`, a projection) never spuriously enables a seek.
/// Returns `None` when nothing changed. The placeholder value is irrelevant — the
/// plan renders `col=?`/`col>?` and only the constrained *column* is used.
fn placeholder_fold_seek_where(&self, e: &Expr) -> Option<Expr> {
let mut changed = false;
let out = self.placeholder_fold_where_inner(e, &mut changed);
changed.then_some(out)
}
fn placeholder_fold_where_inner(&self, e: &Expr, changed: &mut bool) -> Expr {
let subq_placeholder = |s: &Expr, changed: &mut bool| -> Expr {
match s {
Expr::Subquery(sel2) if self.scalar_subquery_folds_structurally(sel2) => {
*changed = true;
Expr::Literal(Literal::Integer(0))
}
other => other.clone(),
}
};
match e {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => Expr::Binary {
op: BinaryOp::And,
left: Box::new(self.placeholder_fold_where_inner(left, changed)),
right: Box::new(self.placeholder_fold_where_inner(right, changed)),
},
Expr::Paren(inner) => {
Expr::Paren(Box::new(self.placeholder_fold_where_inner(inner, changed)))
}
Expr::Binary { op, left, right }
if matches!(
op,
BinaryOp::Eq | BinaryOp::Lt | BinaryOp::LtEq | BinaryOp::Gt | BinaryOp::GtEq
) =>
{
Expr::Binary {
op: *op,
left: Box::new(subq_placeholder(left, changed)),
right: Box::new(subq_placeholder(right, changed)),
}
}
// A positive `col IN (<foldable SELECT>)` seeks the `col` index per
// candidate value; SQLite plans the `SEARCH` without evaluating the
// subquery, so replace the candidate set with a single non-NULL
// placeholder literal — the constraint collectors then recognize the `IN`
// seek. The executor mirrors this by folding the subquery to its real
// value list (`eval_foldable_in_select`) before its `try_index_in` seek.
Expr::InSelect {
expr,
select,
negated: false,
} if self.in_select_folds_structurally(select) => {
*changed = true;
Expr::InList {
expr: expr.clone(),
list: alloc::vec![Expr::Literal(Literal::Integer(0))],
negated: false,
candidate_affinity: None,
}
}
other => other.clone(),
}
}
/// True when every *compound arm* of `sel2` (the `UNION`/… operands after the
/// base) projects a single computed (non-bare-column) expression — so the
/// whole compound's result column carries NONE affinity, exactly like an
/// ordinary literal list. Trivially true for a non-compound body.
fn compound_arms_computed(&self, sel2: &Select) -> bool {
sel2.compound.iter().all(|(_, arm)| {
arm.columns.len() == 1
&& matches!(
&arm.columns[0],
sql::ast::ResultColumn::Expr { expr, .. } if !is_bare_column_expr(expr)
)
})
}
/// Materialize an `IN (SELECT …)` candidate set to its values, with the
/// candidate side's comparison affinity, when the subquery is self-contained
/// (non-correlated). Returns `(values, candidate_affinity)`:
///
/// - A *computed* candidate column carries NONE affinity / BINARY collation,
/// so `L IN (SELECT …)` compares exactly like `L IN (v1, v2, …)` (the
/// comparison takes `L`'s affinity in both forms — verified vs sqlite); the
/// returned affinity is `None`.
/// - A *bare-column* candidate contributes its column's affinity: SQLite uses
/// `combine(left_aff, col_aff)`, which a plain literal list lacks. The
/// column's affinity is returned as a canonical type name so the VDBE/eval
/// feed it as the element comparison's right-operand affinity.
///
/// **Collation:** the candidate column's collation is NOT consulted — SQLite
/// resolves `x IN (SELECT col)` under the LEFT operand's collation (the
/// candidate's collation never affects the result, verified vs sqlite), and
/// the folded `IN (list)` comparison already applies the left's collation.
/// `None` when not foldable, so the VDBE compiler simply falls back as before.
fn eval_foldable_in_select(&self, sel2: &Select) -> Option<(Vec<Value>, Option<String>)> {
if !self.vdbe_subquery_foldable(sel2) {
return None;
}
if sel2.columns.len() != 1 {
return None;
}
let sql::ast::ResultColumn::Expr { expr, .. } = &sel2.columns[0] else {
return None;
};
// A bare-column candidate must carry its column's affinity into the
// comparison; resolve the single output column's origin affinity (bail
// only when the origin is unresolvable). The candidate column's COLLATION
// is irrelevant: `x IN (SELECT col)` always uses the LEFT operand's
// collation — the candidate column's collation never affects the result
// (verified vs sqlite) — and the folded IN-list comparison already applies
// the left's collation.
let candidate_affinity = if is_bare_column_expr(expr) {
// A bare-column candidate over a compound body has no single resolvable
// origin (`subquery_column_origins` returns `None` for compounds), so
// this bails — only a single-source bare column carries its affinity.
let origins = self.subquery_column_origins(sel2)?;
let (aff, _coll) = origins.first().copied()?;
Some(affinity_type_name(aff))
} else {
// Computed base arm → NONE affinity; a compound must have every other
// arm computed too, else a bare-column arm's affinity would be lost.
if !self.compound_arms_computed(sel2) {
return None;
}
None
};
let r = self.run_select(sel2, &Params::default()).ok()?;
Some((
r.rows
.into_iter()
.map(|row| row.into_iter().next().unwrap_or(Value::Null))
.collect(),
candidate_affinity,
))
}
/// Evaluate `EXISTS (sel2)` to a constant truth value when `sel2` is
/// self-contained (non-correlated), else `None`.
fn eval_foldable_exists(&self, sel2: &Select) -> Option<bool> {
if !self.vdbe_subquery_foldable(sel2) {
return None;
}
let r = self.run_select(sel2, &Params::default()).ok()?;
Some(!r.rows.is_empty())
}
/// Conservatively decide whether `sel2` is self-contained — i.e. references no
/// column outside its own `FROM` sources (non-correlated), takes no bound
/// parameter, and contains no further nested subquery. Such a query yields the
/// same value evaluated in isolation as it would in any outer row, so its
/// result can be folded to a constant. Bails (returns `false`) on anything it
/// cannot prove: compound/CTE bodies, non-base-table sources, etc.
fn vdbe_subquery_foldable(&self, sel2: &Select) -> bool {
self.select_self_contained(sel2, &[], &[])
}
/// Conservatively decide whether `sel2` is self-contained relative to a
/// surrounding scope (`outer_quals`/`outer_cols`): every column reference
/// resolves to `sel2`'s own sources or that inherited scope, it takes no bound
/// parameter, and every nested subquery is itself self-contained against the
/// accumulated scope. With an empty inherited scope this proves a top-level
/// subquery non-correlated; a nested subquery is checked with its parent's
/// scope passed down, so a reference *into the parent* (correlation that stays
/// inside the folded unit) is fine while a reference further out is not. Bails
/// on compound/CTE bodies or any non-base-table source, whose column set it
/// can't enumerate.
fn select_self_contained(
&self,
sel2: &Select,
outer_quals: &[String],
outer_cols: &[String],
) -> bool {
if !sel2.ctes.is_empty() {
return false;
}
// Start from the inherited scope and add this body's own sources; every
// source must be a plain base table so the column set is known. A
// `FROM`-less body (`(SELECT 1)`) inherits only the outer scope.
let mut quals: Vec<String> = outer_quals.to_vec();
let mut cols: Vec<String> = outer_cols.to_vec();
if let Some(from) = &sel2.from {
let mut collect = |tr: &sql::ast::TableRef| -> bool {
if tr.subquery.is_some()
|| tr.tvf_args.is_some()
|| tr.schema.is_some()
|| tr.name.is_empty()
{
return false;
}
let Ok(meta) = self.table_meta(&tr.name, None) else {
return false;
};
quals.push(tr.name.clone());
if let Some(a) = &tr.alias {
quals.push(a.clone());
}
for c in &meta.columns {
cols.push(c.name.clone());
}
true
};
if !collect(&from.first) {
return false;
}
for j in &from.joins {
if !collect(&j.table) {
return false;
}
}
}
if !self.expr_positions_internal(sel2, &quals, &cols) {
return false;
}
// Every compound arm (`UNION`/`INTERSECT`/`EXCEPT` operand) has its own
// `FROM`, so each must be self-contained against the same surrounding
// scope on its own terms. `expr_positions_internal` above checked the base
// arm's expressions plus the whole query's `ORDER BY`/`LIMIT`.
sel2.compound
.iter()
.all(|(_, arm)| self.select_self_contained(arm, outer_quals, outer_cols))
}
/// True when every column reference in every top-level expression of `sel2`
/// resolves to one of `quals`/`cols` (the accumulated scope) and no expression
/// contains a parameter or a *correlated* nested subquery — see
/// [`Self::expr_internal`].
fn expr_positions_internal(&self, sel2: &Select, quals: &[String], cols: &[String]) -> bool {
let ok = |e: &Expr| self.expr_internal(e, quals, cols);
for rc in &sel2.columns {
if let sql::ast::ResultColumn::Expr { expr, .. } = rc
&& !ok(expr)
{
return false;
}
}
if let Some(w) = &sel2.where_clause
&& !ok(w)
{
return false;
}
if let Some(h) = &sel2.having
&& !ok(h)
{
return false;
}
if !sel2.group_by.iter().all(&ok) {
return false;
}
if !sel2.order_by.iter().all(|t| ok(&t.expr)) {
return false;
}
if let Some(from) = &sel2.from {
for j in &from.joins {
if let Some(on) = &j.on
&& !ok(on)
{
return false;
}
}
}
if let Some(l) = &sel2.limit
&& !ok(l)
{
return false;
}
if let Some(o) = &sel2.offset
&& !ok(o)
{
return false;
}
true
}
/// Does `e` reference only columns of `quals`/`cols` (the accumulated scope),
/// with no bound parameter and no *correlated* nested subquery? A nested
/// subquery is allowed when it is itself self-contained against the current
/// scope (its body may reach into `quals`/`cols`, but not further out): the
/// whole unit then folds to the same constant for every outer row, and the
/// tree-walker evaluates the nested subquery with full affinity semantics — no
/// value is lost. Conservative: a parameter, an out-of-scope column, or a
/// subquery that can't be proven self-contained makes it return `false`.
fn expr_internal(&self, e: &Expr, quals: &[String], cols: &[String]) -> bool {
let rec = |x: &Expr| self.expr_internal(x, quals, cols);
match e {
Expr::Literal(_) => true,
// A parameter would need the statement's bindings to evaluate; the fold
// runs with empty params, so bail and let the normal path handle it.
Expr::Parameter(_) => false,
// A nested subquery folds only when it stays inside the current scope.
Expr::Subquery(s) => self.select_self_contained(s, quals, cols),
Expr::Exists { select, .. } => self.select_self_contained(select, quals, cols),
Expr::InSelect { expr, select, .. } => {
rec(expr) && self.select_self_contained(select, quals, cols)
}
Expr::Column { table, column, .. } => match table {
Some(q) => quals.iter().any(|x| x.eq_ignore_ascii_case(q)),
None => {
cols.iter().any(|c| c.eq_ignore_ascii_case(column))
|| column.eq_ignore_ascii_case("rowid")
|| column.eq_ignore_ascii_case("_rowid_")
|| column.eq_ignore_ascii_case("oid")
}
},
Expr::Unary { expr, .. } => rec(expr),
Expr::Binary { left, right, .. } => rec(left) && rec(right),
Expr::IsNull { expr, .. } => rec(expr),
Expr::InList { expr, list, .. } => rec(expr) && list.iter().all(rec),
Expr::Between {
expr, low, high, ..
} => rec(expr) && rec(low) && rec(high),
Expr::Case {
operand,
when_then,
else_result,
} => {
operand.as_deref().map(rec).unwrap_or(true)
&& when_then.iter().all(|(w, t)| rec(w) && rec(t))
&& else_result.as_deref().map(rec).unwrap_or(true)
}
Expr::Cast { expr, .. } => rec(expr),
Expr::Paren(inner) => rec(inner),
Expr::Collate { expr, .. } => rec(expr),
Expr::RowValue(items) => items.iter().all(rec),
// A window function would not compile on the VDBE anyway; a non-windowed
// call is internal when its arguments and `FILTER` are.
Expr::Function {
args,
filter,
order_by,
over,
..
} => {
over.is_none()
&& args.iter().all(rec)
&& filter.as_deref().map(rec).unwrap_or(true)
&& order_by.iter().all(|t| rec(&t.expr))
}
}
}
/// Whether the tree-walker would emit this single-table query's rows in a
/// *secondary-index* order that a plain rowid scan does not — i.e. the chosen
/// seek spans more than one index key: a range bound (on the index's leading
/// column, or the column right after an all-equality/`IS NULL` prefix), a
/// multi-value `IN`, or a covering `IS NOT NULL`. SQLite walks the index for
/// these and so returns the rows in key order; the VDBE executes the query as
/// a rowid-order table scan, so without an `ORDER BY` to re-sort, its output
/// order would diverge. `run_select_vdbe` defers such queries to the
/// tree-walker (whose seek paths already walk the index in key order, matching
/// SQLite). Single-key seeks (`a=?`, `a IS NULL`, a one-element `IN`) keep
/// rowid order and stay on the VDBE. Conservative: any uncertainty (a CTE,
/// view, subquery, `NOT INDEXED`, missing metadata) returns `false`, leaving
/// the query on the VDBE — the row order only differs when an index is
/// genuinely walked.
/// True when the tree-walker would answer this no-`WHERE` query via a covering
/// secondary index (`covering_scan`), reading rows in index-key order that the
/// VDBE's rowid-order table scan cannot reproduce. Used to defer such queries
/// to the tree-walker so the observable row order matches SQLite. Only meaningful
/// with no `ORDER BY` (an explicit sort makes the order access-path-independent).
fn vdbe_covering_scan_reorders(&self, sel: &Select) -> bool {
let Some(from) = sel.from.as_ref() else {
return false;
};
let t = &from.first;
let Ok(meta) = self.table_meta(&t.name, t.alias.as_deref()) else {
return false;
};
self.covering_scan(sel, &meta, &eval::Params::default())
.is_some()
}
/// True when the tree-walker would scan a table WITHIN a join via a covering
/// secondary index (the outer driver, or a plain-scanned inner) — reading that
/// table in index-key order, which the VDBE's rowid-order table scan cannot
/// reproduce, so the join's output row order differs. Used to defer such joins
/// to the tree-walker (which owns the covering-order scan). Only meaningful with
/// no `ORDER BY` (an explicit sort makes the order access-path-independent).
/// Mirrors the tree-walker's covering-scan choice for the driver and each
/// plain-scanned inner (an equi-hash inner is DRIVER-ordered, so it is excluded,
/// matching the executor's `inner_is_equi_hash` gate).
fn vdbe_join_covering_reorders(&self, sel: &Select) -> bool {
let Some(from) = sel.from.as_ref() else {
return false;
};
if from.joins.is_empty() {
return false;
}
// The driver (`from.first`) is always scanned.
if let Ok(meta) = self.table_meta(&from.first.name, from.first.alias.as_deref())
&& self
.join_scan_covering_index(sel, from, &from.first, &meta)
.is_some()
{
return true;
}
// A plain-scanned inner (not an equi-hash, which is driver-ordered).
for join in &from.joins {
let inner_is_equi_hash = !join.natural
&& join.using.is_empty()
&& matches!(join.kind, JoinKind::Inner | JoinKind::Left)
&& join.on.as_ref().is_some_and(|on| {
// Approximate the executor's hash gate: detect the equi-join on
// the two-table DECLARED column layout (`join_equi_cols` resolves
// by position). Only exact for the first join, which is the
// common two-table shape; a later join over-scoping to plain-scan
// is safe (the guard only defers extra queries to the tree-walker).
match (
self.table_meta(&from.first.name, from.first.alias.as_deref()),
self.table_meta(&join.table.name, join.table.alias.as_deref()),
) {
(Ok(fm), Ok(jm)) => {
let mut cols = fm.columns;
let left_width = cols.len();
cols.extend(jm.columns);
join_equi_cols(on, &cols, left_width).is_some()
}
_ => false,
}
});
if inner_is_equi_hash {
continue;
}
if let Ok(meta) = self.table_meta(&join.table.name, join.table.alias.as_deref())
&& self
.join_scan_covering_index(sel, from, &join.table, &meta)
.is_some()
{
return true;
}
}
false
}
fn vdbe_seek_returns_index_order(&self, sel: &Select, params: &Params) -> Result<bool> {
let Some(from) = sel.from.as_ref() else {
return Ok(false);
};
if !from.joins.is_empty() {
return Ok(false);
}
let t = &from.first;
if t.subquery.is_some() || t.tvf_args.is_some() || t.schema.is_some() {
return Ok(false);
}
if matches!(t.index_hint, Some(IndexHint::NotIndexed)) {
return Ok(false);
}
let Some(where_expr) = sel.where_clause.as_ref() else {
return Ok(false);
};
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return Ok(false);
}
let Ok(meta) = self.table_meta(&t.name, t.alias.as_deref()) else {
return Ok(false);
};
let indexes = self.indexes_of(&t.name)?;
let plain = |idx: &&IndexMeta| idx.partial.is_none() && idx.key_exprs.is_none();
// The equality / `IS NULL` prefix that pins leading index columns to a
// single key value (those keep rowid order); a range on the column right
// after the prefix is the first multi-key span.
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
eqs.retain(|(_, v)| !matches!(v, Value::Null));
let mut isnull_cols: Vec<usize> = Vec::new();
collect_isnull_cols(where_expr, &meta.columns, &mut isnull_cols);
let pinned = |c: usize| eqs.iter().any(|(col, _)| *col == c) || isnull_cols.contains(&c);
// (1) A range after the pinned prefix of a plain secondary index, or an
// equality/`IS NULL` prefix that pins a *proper* non-empty prefix and
// leaves at least one trailing index column unconstrained. In both
// cases SQLite walks the index and orders the matched entries by that
// trailing column — an order the VDBE's rowid-order scan does not
// reproduce. A range on the rowid/IPK walks the table b-tree in rowid
// order instead, so it never counts; and a *fully*-pinned prefix
// (`k == cols.len()`) leaves only the implicit trailing rowid, whose
// order is rowid order, so an equality seek on a single-column index —
// or on every declared column of a composite one — stays on the VDBE.
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
for idx in indexes.iter().filter(plain) {
let mut k = 0;
while k < idx.cols.len() && pinned(idx.cols[k]) {
k += 1;
}
if let Some(&next) = idx.cols.get(k) {
// A range on the first unpinned column (not the rowid) spans keys.
if meta.ipk != Some(next) && ranges.contains_key(&next) {
return Ok(true);
}
// A non-empty equality/`IS NULL` prefix with a real trailing index
// column left over: that column (`next`) orders the equal-prefix
// entries, so the index walk diverges from rowid order.
if k >= 1 {
return Ok(true);
}
}
}
// A multi-value `IN` on a plain secondary index's leading column: SQLite
// seeks once per sorted value, walking the index in key order. The
// tree-walker reproduces that (a covering `IN` reads the whole index in
// order; a non-covering `in_seek_fetch` sorts its keys), so defer it. A
// rowid/IPK `IN` walks the table b-tree in rowid order — the same order the
// VDBE scan produces — so it stays. A single-value `IN` is one key (rowid
// order within it = the scan order), so it stays too; only `len >= 2` with
// no NULL key (a NULL makes the tree-walker decline to a plain scan) spans
// multiple keys.
let multi = |vals: &[Value]| vals.iter().filter(|v| !matches!(v, Value::Null)).count() >= 2;
// `col IN (…)` walked via a plain or a (pred-guaranteed) partial index.
if let Some((col, values)) = find_in_constraint(where_expr, &meta.columns, params)
&& multi(&values)
&& meta.ipk != Some(col)
&& indexes.iter().any(|idx| {
idx.key_exprs.is_none()
&& idx.cols.first() == Some(&col)
&& (idx.partial.is_none() || partial_pred_guaranteed(idx, where_expr))
})
{
return Ok(true);
}
// `<expr> IN (…)` walked via an expression index keyed by that expression.
for idx in &indexes {
let Some(exprs) = &idx.key_exprs else {
continue;
};
let [key_expr] = exprs.as_slice() else {
continue;
};
if !partial_pred_guaranteed(idx, where_expr) {
continue;
}
if let Some(values) = find_expr_in_values(key_expr, where_expr, params)
&& multi(&values)
{
return Ok(true);
}
}
// (2) A covering `IS NOT NULL` seek (spans every non-NULL key).
let mut isnotnull_cols: Vec<usize> = Vec::new();
collect_isnotnull_cols(where_expr, &meta.columns, &mut isnotnull_cols);
if !isnotnull_cols.is_empty()
&& self
.isnotnull_covering_index(
&meta,
&t.name,
sel,
where_expr,
&isnotnull_cols,
t.index_hint.as_ref(),
)?
.is_some()
{
return Ok(true);
}
Ok(false)
}
/// Rewrite a two-table `a RIGHT JOIN b ON …` into the equivalent
/// `b LEFT JOIN a ON …` (swap the first table with the joined one, flip the
/// kind to `LEFT`). The `ON` predicate references both tables by name, so it is
/// unchanged; the projection is unchanged (columns resolve by name). Used by
/// B1c to seek-drive the now-inner left table.
fn swap_right_join_to_left(sel: &Select) -> Select {
let mut s = sel.clone();
if let Some(from) = s.from.as_mut()
&& from.joins.len() == 1
{
let mut joined = from.joins.remove(0);
core::mem::swap(&mut from.first, &mut joined.table);
joined.kind = sql::ast::JoinKind::Left;
from.joins.push(joined);
}
s
}
/// B1c: run a two-table `FULL JOIN` on the VDBE by rewriting it to the
/// equivalent compound `(a LEFT JOIN b) UNION ALL (b WHERE NOT EXISTS a)` —
/// verified row-for-row (including order) against sqlite. The second arm scans
/// the right table with a correlated `NOT EXISTS` (which B5c-2 seek-drives) and
/// projects the left columns as NULL. Returns `Unsupported` for a shape that
/// can't be safely rewritten (a wildcard or non-null-rewritable projection, a
/// grouped/windowed/DISTINCT query, a non-base table, or a missing `ON`), so
/// the caller falls through to the materialized FULL path.
fn try_full_join_seek(&self, sel: &Select) -> Result<QueryResult> {
use sql::ast::{Expr, Join, JoinKind, ResultColumn};
let unsup = |m: &'static str| Error::Unsupported(m);
let from = sel.from.as_ref().ok_or(unsup("VDBE: full join seek"))?;
if from.joins.len() != 1
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| !sel.window_defs.is_empty()
|| sel.distinct
|| !sel.compound.is_empty()
{
return Err(unsup("VDBE: full join seek shape"));
}
let join = &from.joins[0];
if join.natural || !join.using.is_empty() {
return Err(unsup("VDBE: full join natural/using"));
}
let on = join.on.as_ref().ok_or(unsup("VDBE: full join needs ON"))?;
// Both sides must be base tables — needed to split a/b columns.
let a_meta = self
.table_meta(&from.first.name, from.first.alias.as_deref())
.map_err(|_| unsup("VDBE: full join non-base left"))?;
self.table_meta(&join.table.name, join.table.alias.as_deref())
.map_err(|_| unsup("VDBE: full join non-base right"))?;
let b_meta = self
.table_meta(&join.table.name, join.table.alias.as_deref())
.map_err(|_| unsup("VDBE: full join non-base right"))?;
let _ = &a_meta;
// The names by which the left table's columns can be qualified.
let mut a_quals = alloc::vec![from.first.name.clone()];
if let Some(al) = &from.first.alias {
a_quals.push(al.clone());
}
let b_cols: Vec<String> = b_meta.columns.iter().map(|c| c.name.clone()).collect();
// Arm 2 projects the left columns as NULL (unmatched right rows have no
// left side); a wildcard or a shape the rewriter can't handle defers.
let mut arm2_cols = Vec::with_capacity(sel.columns.len());
for rc in &sel.columns {
let ResultColumn::Expr {
expr,
alias,
source,
} = rc
else {
return Err(unsup("VDBE: full join wildcard projection"));
};
let e = null_out_a_columns(expr, &a_quals, &b_cols)
.ok_or(unsup("VDBE: full join projection not null-rewritable"))?;
arm2_cols.push(ResultColumn::Expr {
expr: e,
alias: alias.clone(),
source: source.clone(),
});
}
// Arm 2 keeps only right rows with no matching left row: `NOT EXISTS
// (SELECT 1 FROM a WHERE <on>)`, plus the (null-rewritten) original WHERE.
let exists_body = Select {
ctes: Vec::new(),
compound: Vec::new(),
distinct: false,
columns: alloc::vec![ResultColumn::Expr {
expr: Expr::Literal(sql::ast::Literal::Integer(1)),
alias: None,
source: None,
}],
from: Some(sql::ast::FromClause {
first: from.first.clone(),
joins: Vec::new(),
}),
where_clause: Some(on.clone()),
group_by: Vec::new(),
having: None,
window_defs: Vec::new(),
order_by: Vec::new(),
limit: None,
offset: None,
values_rows: 0,
};
let not_exists = Expr::Exists {
select: Box::new(exists_body),
negated: true,
};
let arm2_where = match &sel.where_clause {
Some(w) => {
let w2 = null_out_a_columns(w, &a_quals, &b_cols)
.ok_or(unsup("VDBE: full join where not null-rewritable"))?;
Expr::Binary {
op: sql::ast::BinaryOp::And,
left: Box::new(not_exists),
right: Box::new(w2),
}
}
None => not_exists,
};
let arm2 = Select {
ctes: Vec::new(),
compound: Vec::new(),
distinct: false,
columns: arm2_cols,
from: Some(sql::ast::FromClause {
first: join.table.clone(),
joins: Vec::new(),
}),
where_clause: Some(arm2_where),
group_by: Vec::new(),
having: None,
window_defs: Vec::new(),
order_by: Vec::new(),
limit: None,
offset: None,
values_rows: 0,
};
// Arm 1 is `a LEFT JOIN b` with the original projection and WHERE; the
// whole-query ORDER BY / LIMIT / OFFSET apply to the compound.
let mut arm1_from = from.clone();
arm1_from.joins[0] = Join {
kind: JoinKind::Left,
table: join.table.clone(),
on: Some(on.clone()),
natural: false,
using: Vec::new(),
};
let compound = Select {
ctes: Vec::new(),
compound: alloc::vec![(sql::ast::CompoundOp::UnionAll, arm2)],
distinct: false,
columns: sel.columns.clone(),
from: Some(arm1_from),
where_clause: sel.where_clause.clone(),
group_by: Vec::new(),
having: None,
window_defs: Vec::new(),
order_by: sel.order_by.clone(),
limit: sel.limit.clone(),
offset: sel.offset.clone(),
values_rows: 0,
};
self.run_select_vdbe(&compound)
}
/// Compile and run a parsed `SELECT` through the VDBE engine, or `Unsupported`
/// when its shape is outside the spike's grammar (so callers fall back).
fn run_select_vdbe(&self, sel: &Select) -> Result<QueryResult> {
// The VDBE resolves table names in the `main` schema only
// (`table_meta`). Whenever an attached or `temp` database is in scope, or
// a non-main database is the current resolution default, or a source is
// schema-qualified, defer to the tree-walker so the right schema is used.
if self.temp_db.is_some()
|| !self.attached.is_empty()
|| self.read_default.get() != DbRef::Main
{
return Err(Error::Unsupported("VDBE: non-main schema in scope"));
}
if let Some(f) = &sel.from {
// The attached/temp/default checks above guarantee a `main`-only
// context, so a `main.`-qualified source is unambiguous and equivalent
// to the bare name — strip the qualifier and route the equivalent
// query. Any *other* schema qualifier (a temp/attached name that can't
// resolve here) still defers to the tree-walker.
let mut has_main = false;
let mut has_other = false;
for s in
core::iter::once(&f.first.schema).chain(f.joins.iter().map(|j| &j.table.schema))
{
match s.as_deref() {
Some(n) if n.eq_ignore_ascii_case("main") => has_main = true,
Some(_) => has_other = true,
None => {}
}
}
if has_other {
return Err(Error::Unsupported("VDBE: schema-qualified source"));
}
if has_main {
let strip = |sch: &mut Option<String>| {
if sch
.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case("main"))
{
*sch = None;
}
};
let mut stripped = sel.clone();
if let Some(sf) = stripped.from.as_mut() {
strip(&mut sf.first.schema);
for j in &mut sf.joins {
strip(&mut j.table.schema);
}
}
return self.run_select_vdbe(&stripped);
}
}
// A three-part `schema.table.column` reference needs the qualifier validated
// against the source's actual database — the VDBE resolves by table/name
// only and would accept a wrong qualifier. Defer to the tree-walker, which
// reports `no such column: schema.table.column` on a mismatch.
if select_has_schema_qualified_column(sel) {
return Err(Error::Unsupported("VDBE: schema-qualified column"));
}
// A table-qualified rowid alias (`t.rowid`) over a join needs each base
// table's per-table rowid, which the VDBE join compiler does not model.
// Defer to the tree-walker, which contributes hidden per-table rowid
// columns. (A single-table `t.rowid` is fine and handled elsewhere.)
if let Some(f) = &sel.from
&& !f.joins.is_empty()
&& select_references_qualified_rowid(sel)
{
return Err(Error::Unsupported("VDBE: table-qualified rowid in a join"));
}
// Cost-based two-table rowid-inner swap: when a two-table equi-join would
// be reordered to drive from the second table (seeking `from.first` by its
// cheaper rowid), the observable row order changes. The VDBE join paths do
// not model that reorder — defer such shapes to the tree-walker, which
// owns the reorder (`two_table_rowid_inner_swap`). Only the *unordered*
// case is observable: with an explicit `ORDER BY` the drive direction is
// invisible (the row *set* is identical, and both paths sort it the same),
// so the VDBE may run those directly. The comma form (`FROM u,v WHERE
// u.x=v.p`) has its equality promoted to an `ON` only later in `run_core`,
// so promote a copy here first to catch it too.
// The nested-loop join order the VDBE compiler should use (empty = the
// identity, leftmost source outermost). A cost-based swap sets a non-identity
// permutation so the VDBE reproduces the tree-walker's driven row order.
let mut join_loop_order: Vec<usize> = Vec::new();
// A bare aggregate whose every aggregate is order-INDEPENDENT (count / sum /
// total / avg / min / max) yields the same value for *any* join drive order,
// so the VDBE's identity-order fold (`compile_aggregate_join`) is correct
// regardless of a cost-based swap or N-table reorder — the bails below need
// not fire (2-table *and* N-table). An order-sensitive or user-registered
// aggregate, or a GROUP BY (whose group emission order the reorder perturbs),
// is excluded and still defers.
let bare_order_indep_agg = self.has_aggregate(sel)
&& sel.group_by.is_empty()
&& sel.having.is_none()
&& !self.select_has_order_sensitive_aggregate(sel);
if !bare_order_indep_agg
&& sel.order_by.is_empty()
&& let Some(from) = &sel.from
{
let promo_tables = self.comma_join_table_columns(from);
let promoted;
let check_sel = match promote_comma_join_ons(sel, &promo_tables) {
Some(r) => {
promoted = r;
&promoted
}
None => sel,
};
if let Some(pf) = &check_sel.from
&& pf.joins.len() == 1
{
// Both the rowid and the single-column-UNIQUE index-inner swaps drive
// from the SECOND table, seeking `from.first`. The VDBE models the swap
// by nesting the second cursor outermost (`[1, 0]`) and scanning the
// materialized driver rowset in rowid / declaration order — so it
// reproduces the tree-walker's driven order only when the driver is
// ALSO scanned that way (NOT via a reordering covering index like
// `SCAN v USING COVERING INDEX iv`) and the shape is a plain projection
// (an aggregate / GROUP BY join's fold order — `group_concat` is
// order-sensitive — is not modelled here). Those excluded cases defer
// to the tree-walker.
let driver = &pf.joins[0].table;
let driver_reordered = self
.table_meta(&driver.name, driver.alias.as_deref())
.ok()
.is_some_and(|m| {
self.join_scan_covering_index(check_sel, pf, driver, &m)
.is_some()
});
let swap_runnable = !driver_reordered
&& sel.group_by.is_empty()
&& sel.having.is_none()
&& !self.has_aggregate(sel);
if self.two_table_rowid_inner_swap(pf).is_some() {
// A rowid join matches ≤1 inner row, so the emission order is
// exactly the driver's scan order.
if swap_runnable {
join_loop_order = alloc::vec![1, 0];
} else {
return Err(Error::Unsupported("VDBE: two-table rowid-inner swap"));
}
} else if let Some((_, _, idx)) = self.two_table_index_inner_swap(pf) {
// A single-column UNIQUE index also matches ≤1 inner row (like the
// rowid case) — safe to reorder. A composite or non-unique index
// can match several inner rows in index-key order, which the VDBE's
// scan + filter would not reproduce — defer those.
if idx.unique && idx.cols.len() == 1 && swap_runnable {
join_loop_order = alloc::vec![1, 0];
} else {
return Err(Error::Unsupported("VDBE: two-table index-inner swap"));
}
}
} else if let Some(pf) = &check_sel.from
&& let Some((reordered, _, perm, all_inners_single_match)) =
self.ntable_join_order(check_sel, pf)
{
// Cost-based N-table (≥3) reorder. The VDBE reproduces it by nesting
// the cursors in the placement permutation (`perm`), but only when
// every inner is a ≤1-match seek (its rowid IPK or a single-column
// UNIQUE index — so the combined row set and order are fixed by the
// driver's scan alone), the driver is scanned in rowid/declaration
// order (not a reordering covering index the materialized rowset can't
// reproduce), and the shape is a plain projection. Otherwise defer to
// the tree-walker, which owns the reorder.
let driver_reordered = self
.table_meta(&reordered.first.name, reordered.first.alias.as_deref())
.ok()
.is_some_and(|m| {
self.join_scan_covering_index(check_sel, pf, &reordered.first, &m)
.is_some()
});
if all_inners_single_match
&& !driver_reordered
&& sel.group_by.is_empty()
&& sel.having.is_none()
&& !self.has_aggregate(sel)
{
join_loop_order = perm;
} else {
return Err(Error::Unsupported("VDBE: N-table cost-based join order"));
}
}
}
// `PRAGMA case_sensitive_like = ON` makes the `LIKE` operator ASCII
// case-sensitive, but the VDBE's `Like` op always folds case. Defer to the
// tree-walker (which honors the flag via the `Subqueries` hook) whenever the
// pragma is set — it is off by default, so this costs nothing normally.
if self.case_sensitive_like {
return Err(Error::Unsupported("VDBE: case_sensitive_like set"));
}
// A compound query (UNION / UNION ALL / INTERSECT / EXCEPT) runs each
// constituent SELECT on the VDBE and combines the row-sets with the same
// set semantics the tree-walker uses (Track B, B5c-3).
if !sel.compound.is_empty() {
return self.run_compound_vdbe(sel);
}
// When the tree-walker satisfies `ORDER BY` via an index/rowid/seek scan,
// its tie/NULL order follows that (possibly reversed) scan; the VDBE
// sorter would emit a different — valid, but SQL-unspecified — tie order.
// Defer such queries to the tree-walker so the observable order matches.
if sel.from.is_some()
&& !sel.order_by.is_empty()
&& self
.order_satisfied_by_scan(sel, &eval::Params::default())
.is_some()
{
return Err(Error::Unsupported("VDBE: ORDER BY satisfied by a scan"));
}
// A secondary-index seek (range / multi-value IN / covering `IS NOT NULL`)
// returns rows in index-key order, which the VDBE's rowid-order table scan
// does not reproduce. With no `ORDER BY` to re-sort, defer to the
// tree-walker (which walks the index in key order, matching SQLite); an
// explicit `ORDER BY` makes the order independent of the access path, so
// the VDBE keeps those.
if sel.order_by.is_empty()
&& self.vdbe_seek_returns_index_order(sel, &eval::Params::default())?
{
return Err(Error::Unsupported("VDBE: secondary-index seek order"));
}
// With no `WHERE` and no `ORDER BY`, the tree-walker may answer a query by
// reading a covering secondary index (`covering_scan`) — rows arrive in
// index-key order, which the VDBE's rowid-order table scan does not
// reproduce. Defer those so the observable order matches SQLite. (An
// `ORDER BY` re-sorts the rows, making the order independent of the access
// path; `covering_scan` already declines when a scan satisfies the sort.)
if sel.order_by.is_empty() && self.vdbe_covering_scan_reorders(sel) {
return Err(Error::Unsupported("VDBE: covering-index scan order"));
}
// Likewise for a JOIN whose driver or plain-scanned inner reads a covering
// secondary index — the table is visited in index-key order (changing the
// join's output row order), which the VDBE's rowid-order scan cannot
// reproduce. Defer to the tree-walker (which owns the covering-order join
// scan). Observable only without an `ORDER BY`.
if sel.order_by.is_empty() && self.vdbe_join_covering_reorders(sel) {
return Err(Error::Unsupported("VDBE: join covering-index scan order"));
}
// A window-function query (Track B5c-4): scan the single base table on the
// VDBE (with `WHERE` applied and the rowid appended), then evaluate the
// windows, projection, DISTINCT, `ORDER BY` and `LIMIT`/`OFFSET` through the
// shared `finish_from_rows` tail — the same code the tree-walker runs.
if window::has_window(sel) {
// A scalar (or multi-arg min/max) function used with `OVER (…)` is not
// a window function — SQLite rejects it at prepare time. The VDBE window
// path bypasses `run_core`'s validation, so re-check here before the
// dispatch (else such a query would run and return rows silently).
{
let is_agg = |name: &str, n: usize, star: bool| {
func::is_aggregate_call(name, n, star)
|| self.aggregates.contains_key(&name.to_ascii_lowercase())
};
let is_known_scalar =
|name: &str, n: usize, star: bool| self.scalar_function_exists(name, n, star);
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
reject_invalid_window_function(expr, &is_agg, &is_known_scalar)?;
// A window nested inside an aggregate's argument
// (`sum(row_number() OVER ())`) is a misuse SQLite rejects
// at prepare time; this path bypasses `run_core` too. An
// aggregate/window call inside a non-windowed aggregate's
// `FILTER` predicate is the same kind of bypassed misuse.
reject_nested_aggregate_arg(expr)?;
reject_window_in_window(expr)?;
reject_aggregate_in_filter(expr, &is_agg)?;
}
}
for t in &sel.order_by {
reject_invalid_window_function(&t.expr, &is_agg, &is_known_scalar)?;
reject_nested_aggregate_arg(&t.expr)?;
reject_window_in_window(&t.expr)?;
reject_aggregate_in_filter(&t.expr, &is_agg)?;
}
// A named window (`WINDOW w AS (…)`) carries its spec separately,
// so a window function nested in its PARTITION BY / ORDER BY /
// frame is checked here rather than via the projection.
for (_, spec) in &sel.window_defs {
reject_window_in_windowspec(spec)?;
}
// A bad column in a window `PARTITION BY` / `ORDER BY` is a
// prepare-time `no such column` in SQLite, but this path bypasses
// `run_core`'s eager validators, so re-check it here (else the query
// would run and silently return rows over an empty/filtered input).
self.validate_window_over_columns(sel)?;
}
return self.run_window_vdbe(sel);
}
// Fold provably non-correlated scalar / `EXISTS` subqueries that appear in
// the top-level expressions to the constant they evaluate to, so the VDBE
// (which cannot open a cursor for a nested query) can run the rest. Only
// self-contained subqueries are folded; anything correlated, parameterized,
// or itself containing a nested subquery is left in place and the compiler
// falls back as before — so this only widens what the VDBE accepts, never
// changes a result.
let folded;
let sel = match self.fold_vdbe_subqueries(sel) {
Some(s) => {
folded = s;
&folded
}
None => sel,
};
// Resolve a positional `GROUP BY N` (a bare integer literal) to the N-th
// output column's expression — `GROUP BY 1` groups by the first result
// column, not the constant `1` (SQLite). The VDBE group compiler bails on
// an integer group key, so without this rewrite the query would always
// fall back. Only a clean, wildcard-free projection is resolved here: a
// leading `*`/`t.*` would make the ordinal count post-expansion columns
// (which the bare projection list cannot index), and an out-of-range
// ordinal must be *rejected* — both defer to the tree-walker, which
// validates and errors them exactly like SQLite.
let regrouped;
let sel = if sel.group_by.iter().any(|g| positional_int(g).is_some()) {
if sel
.columns
.iter()
.any(|c| matches!(c, ResultColumn::Wildcard | ResultColumn::TableWildcard(_)))
{
return Err(Error::Unsupported(
"VDBE: positional GROUP BY with wildcard projection",
));
}
let mut s = sel.clone();
for g in &mut s.group_by {
// A positional `GROUP BY N` — including the signed / parenthesized /
// `COLLATE`-wrapped forms SQLite folds (`GROUP BY +1`) — names the
// N-th output column.
if let Some(n) = positional_int(g) {
match usize::try_from(n)
.ok()
.filter(|&n| n >= 1)
.and_then(|n| sel.columns.get(n - 1))
{
Some(ResultColumn::Expr { expr, .. }) => *g = expr.clone(),
// Out of range (or names a wildcard): the tree-walker
// rejects/handles it.
_ => {
return Err(Error::Unsupported(
"VDBE: positional GROUP BY out of range",
));
}
}
}
}
regrouped = s;
®rouped
} else {
sel
};
// Constant SELECT (no FROM): compile and run directly.
let Some(from) = &sel.from else {
let prog = vdbe::compile_const_select(sel)?;
let rows = vdbe::run(&prog)?;
return Ok(QueryResult {
columns: prog.columns,
rows,
});
};
// Materialize the whole-query `WITH` into the CTE environment so a `FROM`
// reference naming a CTE can pull its already-materialized rows during
// scanning (correct even when the body reads a sibling CTE, is recursive, or
// shadows a base-table name — the tree-walker resolved all of that here). The
// guard restores the environment on every exit. Mirrors `run_select` /
// `run_compound_vdbe`; only the used CTEs (per `seeds`) are materialized.
let _cte_scope = CteEnvGuard {
env: &self.cte_env,
base: self.cte_env.borrow().len(),
};
if !sel.ctes.is_empty() {
let params = eval::Params::default();
let outer_cap = self.recursive_cte_outer_cap(sel, ¶ms);
let mut seeds = Vec::new();
collect_source_names(sel, &mut seeds);
self.push_ctes(&sel.ctes, ¶ms, outer_cap, Some(&seeds))?;
}
// Materialize a FROM source's column names and rows — a plain table, a safe
// subquery / in-scope CTE, or a table-valued function.
// (column names, owning-table qualifier, affinities, collations, rows, and
// the per-row rowids — `None` for a `WITHOUT ROWID` table, which has none).
type ScanOut = (
Vec<String>,
Vec<String>,
Vec<eval::Affinity>,
Vec<crate::value::Collation>,
Vec<Vec<Value>>,
Option<Vec<i64>>,
);
let scan_one = |tr: &sql::ast::TableRef| -> Result<ScanOut> {
// A table-valued function FROM source (`generate_series(…)`,
// `json_each` / `json_tree`, the table-valued `pragma_<name>(…)` form).
// `tvf_rows` produces the same columns and rows the tree-walker would, so
// the outer query sees them identically. Its *hidden* input columns
// (`json_each` / `json_tree`'s `json` / `root`) are dropped here — they are
// excluded from `*` / `tbl.*` expansion, and a query naming one explicitly
// simply fails to resolve on the VDBE and defers to the tree-walker. Both
// the column metadata and every row are projected through the visible-
// column mask. (A multi-source query containing a TVF defers earlier.)
if tr.tvf_args.is_some() || self.is_bare_tvf(tr) {
let series_cap = self.generate_series_scan_cap(sel);
let (cinfos, rows) =
self.tvf_rows_capped(tr, &eval::Params::default(), series_cap)?;
let visible: Vec<usize> = cinfos
.iter()
.enumerate()
.filter(|(_, ci)| !ci.hidden)
.map(|(i, _)| i)
.collect();
let columns = visible.iter().map(|&i| cinfos[i].name.clone()).collect();
let tables = visible.iter().map(|&i| cinfos[i].table.clone()).collect();
let affinities = visible.iter().map(|&i| cinfos[i].affinity).collect();
let collations = visible.iter().map(|&i| cinfos[i].collation).collect();
let rows = rows
.into_iter()
.map(|r| visible.iter().map(|&i| r[i].clone()).collect())
.collect();
return Ok((columns, tables, affinities, collations, rows, None));
}
// A derived source: an explicit `FROM` subquery, or a `FROM` reference
// naming an in-scope CTE — both materialized through the same
// conservative single-block constraints (a constant/`VALUES` body, or a
// single-block query over a single all-BINARY base table). A CTE
// reference's qualifier is its alias or its name, and an explicit
// `WITH name(cols…)` list renames the body's output columns. Anything
// else defers to the tree-walker.
let cte = if tr.subquery.is_none() && tr.tvf_args.is_none() && tr.schema.is_none() {
sel.ctes
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&tr.name))
} else {
None
};
// A `FROM` reference naming an in-scope CTE. The whole-query `WITH` was
// materialized into the CTE environment at the top of this function, so
// the rows are pulled straight from there — correct even when the body
// reads a *sibling* CTE, is recursive, or shadows a base-table name (the
// tree-walker resolved all of that during materialization, and the
// explicit `WITH name(cols…)` rename is already applied to the looked-up
// column names). The per-column affinity comes from the body's origins,
// CTE-scope-aware so a sibling reference resolves; a non-BINARY column,
// or a body whose origins don't resolve (join / compound / recursive),
// defers to the tree-walker.
if let Some(c) = cte {
let (cinfos, inrows) = self
.lookup_cte(&tr.name, tr.alias.as_deref())
.ok_or(Error::Unsupported("VDBE: CTE not in scope"))?;
let qualifier = tr.alias.clone().unwrap_or_else(|| tr.name.clone());
// A constant / `VALUES` CTE body carries no affinity (BINARY
// collation), exactly like a constant derived subquery; otherwise
// resolve each output column's affinity through the body.
let const_body = c.select.from.is_none()
&& c.select.compound.iter().all(|(_, s)| s.from.is_none());
let affinities: Vec<eval::Affinity> = if const_body {
cinfos
.iter()
.map(|_| eval::Affinity::from_type(None))
.collect()
} else {
let origins = self
.subquery_column_origins_in(&c.select, &sel.ctes)
.ok_or(Error::Unsupported("VDBE: complex CTE body"))?;
if origins.len() != cinfos.len() {
return Err(Error::Unsupported("VDBE: CTE column count mismatch"));
}
// Keep the conservative all-BINARY collation posture (the VDBE
// grouped / aggregate paths assume BINARY keys).
if origins
.iter()
.any(|(_, co)| *co != crate::value::Collation::default())
{
return Err(Error::Unsupported("VDBE: CTE over a non-BINARY column"));
}
origins.iter().map(|(a, _)| *a).collect()
};
let columns: Vec<String> = cinfos.iter().map(|ci| ci.name.clone()).collect();
let tables = columns.iter().map(|_| qualifier.clone()).collect();
let collations = columns
.iter()
.map(|_| crate::value::Collation::default())
.collect();
let rows = inrows.into_iter().map(|r| r.values).collect();
return Ok((columns, tables, affinities, collations, rows, None));
}
if let Some(sub) = &tr.subquery {
let sub = sub.as_ref();
let qualifier = tr.alias.clone().unwrap_or_default();
if tr.tvf_args.is_some() {
return Err(Error::Unsupported("VDBE: complex subquery source"));
}
// A constant / `VALUES` subquery — no base table in any compound arm
// (a top-level `VALUES (…),(…)` desugars to a `UNION ALL` of FROM-less
// constant cores). Its columns carry no affinity and BINARY collation,
// so materialize the rows directly and the outer query sees them
// exactly as the tree-walker does.
if sub.from.is_none() && sub.compound.iter().all(|(_, s)| s.from.is_none()) {
let result = self.run_select(sub, &eval::Params::default())?;
let columns = result.columns;
let tables = columns.iter().map(|_| qualifier.clone()).collect();
let affinities = columns
.iter()
.map(|_| eval::Affinity::from_type(None))
.collect();
let collations = columns
.iter()
.map(|_| crate::value::Collation::default())
.collect();
return Ok((columns, tables, affinities, collations, result.rows, None));
}
// Resolve each output column's `(affinity, collation)` through any
// depth of single-source derived tables (a base table or a nested
// subquery). `subquery_column_origins` returns `None` for a join /
// compound / view / CTE / TVF body — those defer to the tree-walker.
let origins = self
.subquery_column_origins(sub)
.ok_or(Error::Unsupported("VDBE: complex subquery source"))?;
// Keep the conservative all-BINARY collation posture (the VDBE
// grouped / aggregate paths assume BINARY group/agg keys); a
// non-BINARY derived column defers to the tree-walker.
if origins
.iter()
.any(|(_, c)| *c != crate::value::Collation::default())
{
return Err(Error::Unsupported(
"VDBE: subquery over a non-BINARY column",
));
}
let result = self.run_select(sub, &eval::Params::default())?;
if result.columns.len() != origins.len() {
return Err(Error::Unsupported("VDBE: subquery column count mismatch"));
}
let columns = result.columns;
let tables = columns.iter().map(|_| qualifier.clone()).collect();
let affinities = origins.iter().map(|(a, _)| *a).collect();
let collations = columns
.iter()
.map(|_| crate::value::Collation::default())
.collect();
return Ok((columns, tables, affinities, collations, result.rows, None));
}
// `NOT INDEXED` forces a full table scan — exactly what the VDBE does,
// yielding the same rows in the same (rowid) order — so it runs here.
// `INDEXED BY name` must be honoured or rejected (an unusable/missing
// index errors), which the VDBE cannot model, so it still defers to the
// tree-walker.
if matches!(tr.index_hint, Some(IndexHint::IndexedBy(_))) {
return Err(Error::Unsupported("VDBE: INDEXED BY hint"));
}
// A view `FROM` source: materialize it exactly as the tree-walker does
// (running its stored body), then expose the view's output columns — their
// `(affinity, collation)` come from `try_view`'s origin resolution, so an
// outer `WHERE` / `ORDER BY` over a view column coerces correctly. Keep the
// conservative all-BINARY posture: a view column carrying a non-BINARY
// collation defers (the VDBE grouped / aggregate paths assume BINARY keys).
// A view has no rowid, so a `rowid` reference over it resolves to nothing
// and the query defers (like a derived table).
if tr.schema.is_none() && self.is_view(&tr.name) {
let (cinfos, inrows) = self
.try_view(&tr.name, tr.alias.as_deref(), &eval::Params::default())?
.ok_or(Error::Unsupported("VDBE: view not found"))?;
if cinfos
.iter()
.any(|ci| ci.collation != crate::value::Collation::default())
{
return Err(Error::Unsupported("VDBE: view over a non-BINARY column"));
}
let columns = cinfos.iter().map(|ci| ci.name.clone()).collect();
let tables = cinfos.iter().map(|ci| ci.table.clone()).collect();
let affinities = cinfos.iter().map(|ci| ci.affinity).collect();
let collations = cinfos.iter().map(|ci| ci.collation).collect();
let rows = inrows.into_iter().map(|r| r.values).collect();
return Ok((columns, tables, affinities, collations, rows, None));
}
let meta = self.table_meta(&tr.name, tr.alias.as_deref())?;
let cols = meta.columns.iter().map(|c| c.name.clone()).collect();
let collations = meta.columns.iter().map(|c| c.collation).collect();
// The qualifier a `t.col` reference must use: the alias if present,
// else the table name.
let qualifier = tr.alias.clone().unwrap_or_else(|| tr.name.clone());
let tables = meta.columns.iter().map(|_| qualifier.clone()).collect();
let affinities = meta.columns.iter().map(|c| c.affinity).collect();
let (rows, rowids): (Vec<Vec<Value>>, Option<Vec<i64>>) = if meta.without_rowid {
(self.scan_without_rowid(&meta)?, None)
} else {
let scanned = self.scan_table(&meta)?;
let ids = scanned.iter().map(|(r, _)| *r).collect();
(scanned.into_iter().map(|(_, v)| v).collect(), Some(ids))
};
Ok((cols, tables, affinities, collations, rows, rowids))
};
// An aggregate or window function in a join `ON` predicate (or in the
// `WHERE` clause) is a misuse — there is no grouping context at the join /
// row-filter level. A join whose `ON` is never evaluated (e.g. an empty
// outer table) would otherwise run silently and return rows; defer to the
// tree-walker, which reports the proper "misuse of aggregate/window
// function" error at prepare time.
if !from.joins.is_empty() {
vdbe::reject_aggregate_or_window_in_predicates(sel)?;
}
// A table-valued function in a *join* runs only when every one of its
// arguments is a constant expression. A non-constant argument may correlate
// to another source's columns (`json_each(t.j)`, `generate_series(1, t.n)`),
// which `tvf_rows` — evaluating in a rowless context — can't honour, so such
// a TVF defers to the tree-walker. (A bare `pragma_x` / a literal-argument
// `pragma_x('t')` has only constant arguments, so it runs.)
if !from.joins.is_empty()
&& core::iter::once(&from.first)
.chain(from.joins.iter().map(|j| &j.table))
.any(|tr| {
(tr.tvf_args.is_some() || self.is_bare_tvf(tr))
&& !tr
.tvf_args
.as_deref()
.unwrap_or(&[])
.iter()
.all(is_const_offset_expr)
})
{
return Err(Error::Unsupported(
"VDBE: correlated table-valued function in a join",
));
}
// Outer / NATURAL / USING join(s) — anything beyond a plain INNER chain. A
// filtered cross-product can't model the NULL-extension of unmatched rows
// or the column coalescing, so build the joined rows by a real nested loop,
// processing each join left-to-right exactly like the tree-walker: for each
// accumulated left row emit a row per right match — matching on equality of
// the NATURAL/USING coalesce columns (each under the left column's
// collation) when present, else the `ON` predicate. A LEFT/FULL step also
// null-extends an unmatched left row; a RIGHT/FULL step appends each
// unmatched right row with a null left. Each NATURAL/USING column is then
// coalesced into its left position and the right duplicate dropped. Only the
// final WHERE is handed to the VDBE. Pure plain-INNER chains keep the
// cross-product path below.
if from.joins.iter().any(|j| {
matches!(
j.kind,
sql::ast::JoinKind::Left | sql::ast::JoinKind::Right | sql::ast::JoinKind::Full
) || j.natural
|| !j.using.is_empty()
}) {
// `t.*` over a coalesced (NATURAL/USING) join would need qualifier-aware
// expansion of the reduced column set; defer it. A plain outer join's
// `t.*` is fine (compile_table_select expands it by qualifier).
let has_coalesce = from.joins.iter().any(|j| j.natural || !j.using.is_empty());
if has_coalesce
&& sel
.columns
.iter()
.any(|rc| matches!(rc, sql::ast::ResultColumn::TableWildcard(_)))
{
return Err(Error::Unsupported("VDBE: table.* over NATURAL/USING join"));
}
// The VDBE path is param-less (explicit params were substituted
// upstream); evaluate each ON against an empty parameter set.
let on_params = eval::Params::default();
let first = scan_one(&from.first)?;
let mut names = first.0;
let mut tabs = first.1;
let mut affs = first.2;
let mut colls = first.3;
let mut rows: Vec<Vec<Value>> = first.4;
for j in &from.joins {
let src = scan_one(&j.table)?;
let lw = names.len();
let rw = src.0.len();
// Combined schema after adding this source (for ON resolution).
let mut n_names = names.clone();
n_names.extend(src.0.iter().cloned());
let mut n_tabs = tabs.clone();
n_tabs.extend(src.1.iter().cloned());
let mut n_affs = affs.clone();
n_affs.extend(src.2.iter().copied());
let mut n_colls = colls.clone();
n_colls.extend(src.3.iter().copied());
let cinfos: Vec<ColumnInfo> = (0..n_names.len())
.map(|i| ColumnInfo {
name: n_names[i].clone(),
table: n_tabs[i].clone(),
affinity: n_affs[i],
collation: n_colls[i],
schema: None,
hidden: false,
})
.collect();
// NATURAL/USING coalesce pairs (left index, right local index): the
// join matches on equality of these instead of an `ON`.
let pairs: Vec<(usize, usize)> = if j.natural {
src.0
.iter()
.enumerate()
.filter_map(|(rl, rn)| {
names
.iter()
.position(|n| n.eq_ignore_ascii_case(rn))
.map(|li| (li, rl))
})
.collect()
} else if !j.using.is_empty() {
let mut v = Vec::with_capacity(j.using.len());
for name in &j.using {
let li = names.iter().position(|n| n.eq_ignore_ascii_case(name));
let rl = src.0.iter().position(|n| n.eq_ignore_ascii_case(name));
match (li, rl) {
(Some(li), Some(rl)) => v.push((li, rl)),
_ => {
return Err(Error::Error(format!(
"cannot join using column {name} - column not present in both tables"
)));
}
}
}
v
} else {
Vec::new()
};
let keep_unmatched_left =
matches!(j.kind, sql::ast::JoinKind::Left | sql::ast::JoinKind::Full);
let keep_unmatched_right =
matches!(j.kind, sql::ast::JoinKind::Right | sql::ast::JoinKind::Full);
let mut matched_right = alloc::vec![false; src.4.len()];
let mut next: Vec<Vec<Value>> = Vec::new();
for a in &rows {
let mut any = false;
for (rj, b) in src.4.iter().enumerate() {
let mut row = a.clone();
row.extend(b.iter().cloned());
let keep = if !pairs.is_empty() {
pairs.iter().all(|&(li, rl)| {
// Apply each side's affinity (cross-type USING/
// NATURAL key: INTEGER 1 = TEXT '1'), as the
// tree-walker join does.
let (lv, rv) = eval::apply_comparison_affinity(
row[li].clone(),
Some(n_affs[li]),
row[lw + rl].clone(),
Some(n_affs[lw + rl]),
);
eval::truth(&eval::compare_op(
sql::ast::BinaryOp::Eq,
&lv,
&rv,
n_colls[li],
)) == Some(true)
})
} else {
match &j.on {
Some(p) => {
let ir = InputRow {
values: row.clone(),
rowid: None,
};
let ctx = ir.ctx(&cinfos, &on_params).with_subqueries(self);
eval::truth(&eval::eval(p, &ctx)?) == Some(true)
}
None => true,
}
};
if keep {
next.push(row);
matched_right[rj] = true;
any = true;
}
}
// LEFT/FULL: emit the left row with NULLs when nothing matched.
if keep_unmatched_left && !any {
let mut row = a.clone();
row.extend(core::iter::repeat_n(Value::Null, rw));
next.push(row);
}
}
// RIGHT/FULL: append each unmatched right row with a null left.
if keep_unmatched_right {
for (rj, b) in src.4.iter().enumerate() {
if !matched_right[rj] {
let mut row = alloc::vec![Value::Null; lw];
row.extend(b.iter().cloned());
next.push(row);
}
}
}
// Coalesce each NATURAL/USING column into its left position (taking
// the right value when the left is NULL, i.e. an outer-join row),
// then drop the right duplicates so it appears once.
if !pairs.is_empty() {
let mut drop: Vec<usize> = pairs.iter().map(|&(_, rl)| lw + rl).collect();
drop.sort_unstable();
drop.dedup();
for row in &mut next {
for &(li, rl) in &pairs {
if matches!(row[li], Value::Null) {
row[li] = row[lw + rl].clone();
}
}
for &d in drop.iter().rev() {
row.remove(d);
}
}
for &d in drop.iter().rev() {
n_names.remove(d);
n_tabs.remove(d);
n_affs.remove(d);
n_colls.remove(d);
}
}
rows = next;
names = n_names;
tabs = n_tabs;
affs = n_affs;
colls = n_colls;
}
// The VDBE resolves a bare column by name and would silently pick one
// side of an ambiguous reference; defer such a query to the tree-walker,
// which rejects it with "ambiguous column name" (reusing the exact same
// check over this join's resolved column list).
let join_cols: Vec<ColumnInfo> = (0..names.len())
.map(|i| ColumnInfo {
name: names[i].clone(),
table: tabs[i].clone(),
affinity: affs[i],
collation: colls[i],
schema: None,
hidden: false,
})
.collect();
if validate_unambiguous_columns(sel, &join_cols, &|t| t.into()).is_err() {
return Err(Error::Unsupported("VDBE: ambiguous column name"));
}
// The join is materialized into `rows` (a single cursor 0 over the
// combined columns), so a correlated scalar/EXISTS subquery can be
// re-evaluated per combined row through the callback (B5c-2 over any
// materialized join — inner/outer/NATURAL/USING alike); the combined
// schema is its outer scope.
let prog =
vdbe::compile_table_select_opts(sel, &names, &tabs, &affs, &colls, false, true)?;
let eval = LiveSubqueryEval {
conn: self,
columns: &join_cols,
rowid_index: None,
};
let result = vdbe::run_rows_multi_with_subqueries(&prog, &[&rows], &eval)?;
return Ok(QueryResult {
columns: prog.columns,
rows: result,
});
}
// Inner join(s) (B5a): an inner join is a filtered cross-product, so
// materialize `t1 × t2 × … × tN` (leftmost source outermost, matching the
// tree-walker's and sqlite's nested-loop row order), fold every `ON` into
// the `WHERE`, and reuse the single-cursor scan compiler. Every join must
// be a plain `INNER`/`CROSS`/comma join (no `NATURAL`/`USING`/outer).
if !from.joins.is_empty() {
// B5b-2 (live inner cursor): a two-table INNER or LEFT equi-join whose
// `ON` binds the inner table's INTEGER PRIMARY KEY (`… JOIN t ON o.x =
// t.<ipk>`) — or a single-column UNIQUE (BINARY) secondary index
// (`… JOIN t ON o.x = t.<uniq>`) — seeks the single matching inner row
// with a *live* b-tree cursor (`read_row`/`index_seek_fetch` →
// `TableCursor::seek`) instead of materializing and scanning the whole
// inner table. Only the outer table is scanned; each inner row is
// fetched by rowid or by the unique-index seek. Correctness rides the
// superset invariant: after the seek the full `ON` is re-evaluated
// against the assembled row, so every coercion corner (`= 2.5`,
// text/blob keys, `NULL`) is filtered exactly as the materialized
// cross-product would. A LEFT join null-pads the inner side on any
// non-match (both seek kinds are unique, so each outer row yields
// exactly one output row). Any shape outside this narrow window — or a
// projection the single-cursor compiler can't take — breaks out and
// falls through to the materialized join path below.
'seek: {
// The chain is a bounded left-deep sequence of unique seeks: the
// leftmost source is scanned, and every joined table is fetched by
// seeking its INTEGER PRIMARY KEY (== rowid) or a single-column
// UNIQUE index to the value of a column already assembled in the
// running prefix. A single join may be INNER (skip a miss) or LEFT
// (null-pad a miss); a 2+ chain must be all INNER (a LEFT anywhere
// in a chain has null-propagation the materialized path below still
// owns). NATURAL/USING always defer.
if from.joins.is_empty() || from.joins.len() > 3 {
break 'seek;
}
if from.joins.iter().any(|j| j.natural || !j.using.is_empty()) {
break 'seek;
}
let all_inner = from
.joins
.iter()
.all(|j| j.kind == sql::ast::JoinKind::Inner);
let single_left =
from.joins.len() == 1 && from.joins[0].kind == sql::ast::JoinKind::Left;
if !(all_inner || single_left) {
break 'seek;
}
// Only the single-LEFT case null-pads; every INNER seek drops a miss.
let is_left = single_left;
// A plain base table has a live rowid b-tree; a CTE/view/subquery/TVF
// has none.
let plain = |tr: &sql::ast::TableRef| -> bool {
tr.subquery.is_none()
&& tr.tvf_args.is_none()
&& tr.schema.is_none()
&& !self.is_bare_tvf(tr)
};
if !plain(&from.first) {
break 'seek;
}
// A plain (optionally parenthesized) unqualified-schema column ref.
fn col_ref(mut e: &sql::ast::Expr) -> Option<(Option<&str>, &str)> {
while let sql::ast::Expr::Paren(i) = e {
e = i;
}
match e {
sql::ast::Expr::Column {
schema: None,
table,
column,
..
} => Some((table.as_deref(), column.as_str())),
_ => None,
}
}
// Flatten the top-level `AND` conjuncts of an `ON` (parens stripped).
fn and_conjuncts<'a>(e: &'a sql::ast::Expr, out: &mut Vec<&'a sql::ast::Expr>) {
let mut e = e;
while let sql::ast::Expr::Paren(i) = e {
e = i;
}
if let sql::ast::Expr::Binary {
op: sql::ast::BinaryOp::And,
left,
right,
} = e
{
and_conjuncts(left, out);
and_conjuncts(right, out);
} else {
out.push(e);
}
}
// How each joined table's single matching row is fetched live: by
// its rowid (INTEGER PRIMARY KEY) or by seeking a single-column
// UNIQUE secondary index.
#[derive(Clone)]
enum SeekVia {
Rowid,
Index {
root: u32,
aff: eval::Affinity,
colls: Vec<crate::value::Collation>,
descs: Vec<bool>,
},
}
// Running combined schema + rows, seeded from the outer scan (the
// inner tables are never scanned — only seeked).
let (mut c_cols, mut c_tables, mut c_aff, mut c_coll, mut rows, _ids) =
scan_one(&from.first)?;
let on_params = eval::Params::default();
// Fold each join into the prefix: validate the inner table, resolve
// the ipk-seek key against the *current* prefix, seek per prefix row,
// and re-check the whole `ON` (superset invariant → exact subset).
for j in &from.joins {
if !plain(&j.table) {
break 'seek;
}
// A same-named CTE/view shadows a base table → not a rowid btree.
if self.is_view(&j.table.name)
|| sel
.ctes
.iter()
.any(|c| c.name.eq_ignore_ascii_case(&j.table.name))
{
break 'seek;
}
let inner_meta = match self.table_meta(&j.table.name, j.table.alias.as_deref())
{
Ok(m) => m,
Err(_) => break 'seek,
};
if inner_meta.without_rowid {
break 'seek;
}
let Some(on) = &j.on else { break 'seek };
let inner_qual = j
.table
.alias
.clone()
.unwrap_or_else(|| j.table.name.clone());
let i_cols: Vec<String> =
inner_meta.columns.iter().map(|c| c.name.clone()).collect();
let mut on_expr = on;
while let sql::ast::Expr::Paren(inner) = on_expr {
on_expr = inner;
}
// Candidate inner seek columns, each paired with how its single
// matching row is fetched:
// * the INTEGER PRIMARY KEY, seeked by rowid (`SeekVia::Rowid`);
// * any single-column UNIQUE, non-partial, plain-column index
// whose column *and* index collation are BINARY, seeked
// through the index (`SeekVia::Index`).
// Uniqueness keeps the "≤ 1 inner row per outer row" invariant
// the null-pad/re-check logic below relies on; the BINARY +
// equal-affinity guard (applied where the key is matched) makes
// the index seek return exactly the rows the re-checked `ON`
// accepts, so it never drops a true match.
let mut cands: Vec<(usize, SeekVia)> = Vec::new();
if let Some(ipk) = inner_meta.ipk {
cands.push((ipk, SeekVia::Rowid));
}
let inner_indexes = match self.indexes_of(&j.table.name) {
Ok(v) => v,
Err(_) => break 'seek,
};
for idx in &inner_indexes {
if idx.unique
&& idx.partial.is_none()
&& idx.key_exprs.is_none()
&& idx.cols.len() == 1
&& idx.collations.first() == Some(&crate::value::Collation::Binary)
{
let ic = idx.cols[0];
if Some(ic) != inner_meta.ipk
&& inner_meta.columns[ic].collation
== crate::value::Collation::Binary
{
cands.push((
ic,
SeekVia::Index {
root: idx.root,
aff: inner_meta.columns[ic].affinity,
colls: idx.collations.clone(),
descs: idx.seek_descs().to_vec(),
},
));
}
}
}
// Is `e` the inner column `ci`, named by its declared column name?
// (A bare `rowid`/`_rowid_`/`oid` alias defers — the assembled
// schema exposes each column under its declared name.) Qualified
// to the inner, or bare and not shadowing a prefix column.
let is_inner_col = |e: &sql::ast::Expr, ci: usize| -> bool {
let Some((q, name)) = col_ref(e) else {
return false;
};
if !name.eq_ignore_ascii_case(&i_cols[ci]) {
return false;
}
match q {
Some(q) => q.eq_ignore_ascii_case(&inner_qual),
None => !c_cols.iter().any(|c| c.eq_ignore_ascii_case(name)),
}
};
// Resolve `e` to a single column index in the current prefix
// (qualified to any prefix table, or bare-unambiguous and not
// also owned by this inner).
let prefix_col_index = |e: &sql::ast::Expr| -> Option<usize> {
let (q, name) = col_ref(e)?;
let matches: Vec<usize> = c_cols
.iter()
.enumerate()
.filter(|(i, c)| {
c.eq_ignore_ascii_case(name)
&& q.is_none_or(|q| c_tables[*i].eq_ignore_ascii_case(q))
})
.map(|(i, _)| i)
.collect();
if matches.len() != 1 {
return None;
}
if q.is_none() && i_cols.iter().any(|c| c.eq_ignore_ascii_case(name)) {
return None;
}
Some(matches[0])
};
// The seek key is a prefix column bound to one candidate inner
// column by a top-level `AND` conjunct `<inner col> = <prefix
// col>`. The rowid candidate is preferred (it comes first and is
// cheapest); an index candidate additionally requires equal
// affinity on the two sides so the index seek and the `ON` `=`
// agree exactly (the rowid seek coerces via `to_i64`, mirroring
// SQLite's rowid rule, so it needs no such guard).
let mut conjuncts: Vec<&sql::ast::Expr> = Vec::new();
and_conjuncts(on_expr, &mut conjuncts);
let resolved = cands.iter().find_map(|(ci, via)| {
conjuncts.iter().find_map(|c| {
let mut c = *c;
while let sql::ast::Expr::Paren(i) = c {
c = i;
}
let sql::ast::Expr::Binary {
op: sql::ast::BinaryOp::Eq,
left,
right,
} = c
else {
return None;
};
let (l, r) = (left.as_ref(), right.as_ref());
let kc = if is_inner_col(l, *ci) {
prefix_col_index(r)?
} else if is_inner_col(r, *ci) {
prefix_col_index(l)?
} else {
return None;
};
if let SeekVia::Index { aff, .. } = via {
// Equal affinity *and* a BINARY outer key column: the
// inner column and its index are already BINARY, so
// requiring the outer side BINARY too makes the `ON`
// `=` resolve to BINARY regardless of operand order,
// matching the index seek exactly. A NOCASE (or other
// non-BINARY) outer column would compare
// case-insensitively while the seek stays BINARY,
// dropping true matches — so it defers.
if c_aff[kc] != *aff
|| c_coll[kc] != crate::value::Collation::Binary
{
return None;
}
}
Some((kc, via.clone()))
})
});
let (kc, seek_via) = match resolved {
Some(v) => v,
None => break 'seek,
};
// Combined schema up to and including this inner — used to
// re-evaluate this join's `ON` against each assembled row.
let mut jc_cols = c_cols.clone();
jc_cols.extend(i_cols.iter().cloned());
let mut jc_tables = c_tables.clone();
jc_tables.extend(inner_meta.columns.iter().map(|_| inner_qual.clone()));
let mut jc_aff = c_aff.clone();
jc_aff.extend(inner_meta.columns.iter().map(|c| c.affinity));
let mut jc_coll = c_coll.clone();
jc_coll.extend(inner_meta.columns.iter().map(|c| c.collation));
let join_cols: Vec<ColumnInfo> = (0..jc_cols.len())
.map(|i| ColumnInfo {
name: jc_cols[i].clone(),
table: jc_tables[i].clone(),
affinity: jc_aff[i],
collation: jc_coll[i],
schema: None,
hidden: false,
})
.collect();
let null_inner: Vec<Value> = (0..i_cols.len()).map(|_| Value::Null).collect();
let mut next: Vec<Vec<Value>> = Vec::new();
for prow in &rows {
let push_unmatched = |out: &mut Vec<Vec<Value>>| {
if is_left {
let mut row = prow.clone();
row.extend(null_inner.iter().cloned());
out.push(row);
}
};
let kv = &prow[kc];
if matches!(kv, Value::Null) {
push_unmatched(&mut next);
continue;
}
let fetched = match &seek_via {
SeekVia::Rowid => self.read_row(&inner_meta, eval::to_i64(kv))?,
SeekVia::Index {
root,
aff,
colls,
descs,
} => {
let key = alloc::vec![aff.coerce(kv.clone())];
let hits = self
.index_seek_fetch(&inner_meta, *root, &key, colls, descs)?
.unwrap_or_default();
// A UNIQUE single-column index yields ≤ 1 row for a
// non-NULL key; more would mean the guard was wrong,
// so bail to the materialized path.
if hits.len() > 1 {
break 'seek;
}
hits.into_iter().next().map(|ir| ir.values)
}
};
let Some(ivals) = fetched else {
push_unmatched(&mut next);
continue;
};
let mut row = prow.clone();
row.extend(ivals.iter().cloned());
let ir = InputRow {
values: row.clone(),
rowid: None,
};
let ctx = ir.ctx(&join_cols, &on_params).with_subqueries(self);
if eval::truth(&eval::eval(on_expr, &ctx)?) == Some(true) {
next.push(row);
} else {
push_unmatched(&mut next);
}
}
// Commit the grown schema and the seeked rows to the prefix.
c_cols = jc_cols;
c_tables = jc_tables;
c_aff = jc_aff;
c_coll = jc_coll;
rows = next;
}
// Ambiguity check + projection over the final assembled schema.
let join_cols: Vec<ColumnInfo> = (0..c_cols.len())
.map(|i| ColumnInfo {
name: c_cols[i].clone(),
table: c_tables[i].clone(),
affinity: c_aff[i],
collation: c_coll[i],
schema: None,
hidden: false,
})
.collect();
if validate_unambiguous_columns(sel, &join_cols, &|t| t.into()).is_err() {
break 'seek;
}
let prog = match vdbe::compile_table_select(
sel, &c_cols, &c_tables, &c_aff, &c_coll, false,
) {
Ok(p) => p,
Err(Error::Unsupported(_)) => break 'seek,
Err(e) => return Err(e),
};
let result = vdbe::run_rows(&prog, &rows)?;
return Ok(QueryResult {
columns: prog.columns,
rows: result,
});
}
// A single two-table LEFT/RIGHT/FULL JOIN routes to the null-padding
// nested loop below; otherwise only plain INNER joins are handled here
// (NATURAL/USING fall back to the tree-walker).
let single =
from.joins.len() == 1 && !from.joins[0].natural && from.joins[0].using.is_empty();
let is_left_2 = single && from.joins[0].kind == sql::ast::JoinKind::Left;
let is_right_2 = single && from.joins[0].kind == sql::ast::JoinKind::Right;
let is_full_2 = single && from.joins[0].kind == sql::ast::JoinKind::Full;
// B1c: a two-table FULL join equals `(a LEFT JOIN b) UNION ALL (rows of
// b with no matching a, a-null-padded)` — verified row-for-row including
// order against sqlite. Both arms can seek (arm 1 via the LEFT seek
// path; arm 2 is a single-table scan of b with a correlated `NOT EXISTS`
// that B5c-2 runs on the VDBE), so the compound avoids materializing
// either table. Falls through to the materialized FULL path when the
// projection/predicate can't be safely null-rewritten or the compound
// can't run on the VDBE — this only *adds* seek coverage.
if is_full_2 {
match self.try_full_join_seek(sel) {
Ok(r) => return Ok(r),
Err(Error::Unsupported(_)) => {}
Err(e) => return Err(e),
}
}
// B1c: a two-table RIGHT join is the mirror of a LEFT join — the
// *right* table is preserved and the *left* is null-padded. Rewriting
// `a RIGHT JOIN b` to `b LEFT JOIN a` (same ON) lets the seek path
// drive the now-inner left table by rowid / unique index instead of
// materializing it, and is row-for-row identical. If the swapped LEFT
// join cannot run on the VDBE it falls through to the materialized RIGHT
// path, so this only ever *adds* seek coverage.
if is_right_2 {
let all_expr = sel
.columns
.iter()
.all(|c| matches!(c, sql::ast::ResultColumn::Expr { .. }));
// An explicit projection resolves output columns by name, so no
// reorder is needed. A bare `SELECT *` needs the combined
// `(right, left)` columns rotated back to `(left, right)` — the
// number of left columns comes from the schema (no materialization),
// and both sides must be base tables for the swap.
let bare_star =
matches!(sel.columns.as_slice(), [sql::ast::ResultColumn::Wildcard]);
let left_cols = if bare_star {
match (
self.table_meta(&from.first.name, from.first.alias.as_deref()),
self.table_meta(
&from.joins[0].table.name,
from.joins[0].table.alias.as_deref(),
),
) {
(Ok(a_meta), Ok(_)) => Some(a_meta.columns.len()),
_ => None,
}
} else {
None
};
if all_expr || left_cols.is_some() {
let swapped = Self::swap_right_join_to_left(sel);
match self.run_select_vdbe(&swapped) {
Ok(mut r) => {
// Rotate `(right ++ left)` back to `(left ++ right)`.
if let Some(n_a) = left_cols
&& r.columns.len() >= n_a
{
let n_b = r.columns.len() - n_a;
r.columns.rotate_left(n_b);
for row in &mut r.rows {
row.rotate_left(n_b);
}
}
return Ok(r);
}
Err(Error::Unsupported(_)) => {}
Err(e) => return Err(e),
}
}
}
// A left-deep chain of ≥ 2 LEFT/INNER joins (at least one LEFT, no
// NATURAL/USING, bounded depth) runs as one N-table null-padding nested
// loop (`compile_left_join_n`). A pure-INNER chain stays on the inner
// path below; RIGHT/FULL or NATURAL/USING anywhere falls back.
let left_inner_chain = from.joins.len() >= 2
&& from.joins.len() <= 4
&& from.joins.iter().all(|j| {
matches!(j.kind, sql::ast::JoinKind::Left | sql::ast::JoinKind::Inner)
&& !j.natural
&& j.using.is_empty()
});
let is_left_chain = left_inner_chain
&& from
.joins
.iter()
.any(|j| j.kind == sql::ast::JoinKind::Left);
if !is_left_2
&& !is_right_2
&& !is_full_2
&& !is_left_chain
&& from.joins.iter().any(|j| {
j.kind != sql::ast::JoinKind::Inner || j.natural || !j.using.is_empty()
})
{
return Err(Error::Unsupported("VDBE: only plain inner joins"));
}
// `t.*` over a join expands by qualifier inside `compile_table_select`.
// Scan every source (the first table, then each joined table) in
// declaration order.
let mut sources = alloc::vec![scan_one(&from.first)?];
for j in &from.joins {
sources.push(scan_one(&j.table)?);
}
// Combined schema = each source's columns concatenated in order. Shared
// bare names are allowed: a qualified `t.col` disambiguates them, and an
// ambiguous *bare* reference makes the compiler bail (→ tree-walker).
let mut combined: Vec<String> = Vec::new();
let mut combined_tables: Vec<String> = Vec::new();
let mut combined_aff: Vec<eval::Affinity> = Vec::new();
let mut combined_coll: Vec<crate::value::Collation> = Vec::new();
for (c, t, a, l, _, _) in &sources {
combined.extend(c.iter().cloned());
combined_tables.extend(t.iter().cloned());
combined_aff.extend(a.iter().copied());
combined_coll.extend(l.iter().copied());
}
// A left-deep chain of ≥ 2 LEFT/INNER joins (cursor 0 = the base table,
// each join bringing one more cursor in declaration order) runs as one
// N-table null-padding nested loop. Each join's ON gates matches at its
// own level (kept separate from WHERE, since a LEFT level's unmatched
// outer row must still be null-padded); WHERE filters the assembled row.
if is_left_chain {
let join_cols: Vec<ColumnInfo> = (0..combined.len())
.map(|i| ColumnInfo {
name: combined[i].clone(),
table: combined_tables[i].clone(),
affinity: combined_aff[i],
collation: combined_coll[i],
schema: None,
hidden: false,
})
.collect();
if validate_unambiguous_columns(sel, &join_cols, &|t| t.into()).is_err() {
return Err(Error::Unsupported("VDBE: ambiguous column name"));
}
// boundaries[i] = end of cursor i's columns in the combined row.
let mut boundaries = Vec::with_capacity(sources.len());
let mut acc = 0;
for src in &sources {
acc += src.0.len();
boundaries.push(acc);
}
let kinds: Vec<sql::ast::JoinKind> = from.joins.iter().map(|j| j.kind).collect();
let ons: Vec<Option<sql::ast::Expr>> =
from.joins.iter().map(|j| j.on.clone()).collect();
let prog = vdbe::compile_left_join_n(
sel,
&combined,
&combined_tables,
&combined_aff,
&combined_coll,
&boundaries,
&kinds,
&ons,
)?;
let rowsets: Vec<&[Vec<Value>]> = sources.iter().map(|s| s.4.as_slice()).collect();
let result = vdbe::run_rows_multi(&prog, &rowsets)?;
return Ok(QueryResult {
columns: prog.columns,
rows: result,
});
}
// A two-table LEFT/RIGHT JOIN: the ON predicate gates which inner rows
// match (an unmatched preserved-side row gets one null-padded output
// row), so it is NOT merged into WHERE — compile it via the
// null-padding nested loop. `compile_left_join2` always preserves
// cursor 0 and null-pads cursor 1, so order the cursors by which side
// is preserved: LEFT keeps the left table (declaration order [a, b]),
// RIGHT keeps the right table (so cursor 0 = b, cursor 1 = a). Column
// refs resolve by name regardless of cursor order. Any unsupported
// shape (or an ambiguous column) returns `Unsupported`, so the router
// falls back to the tree-walker (never the inner-join path, whose
// ON-into-WHERE merge would change outer-join semantics).
if is_left_2 || is_right_2 || is_full_2 {
// RIGHT preserves the right table, so it swaps the cursor order
// (cursor 0 = the preserved side); LEFT and FULL keep declaration
// order [a, b].
let (outer, inner) = if is_right_2 {
(1usize, 0usize)
} else {
(0usize, 1usize)
};
let mut oj_cols: Vec<String> = Vec::new();
let mut oj_tables: Vec<String> = Vec::new();
let mut oj_aff: Vec<eval::Affinity> = Vec::new();
let mut oj_coll: Vec<crate::value::Collation> = Vec::new();
for &si in &[outer, inner] {
let (c, t, a, l, _, _) = &sources[si];
oj_cols.extend(c.iter().cloned());
oj_tables.extend(t.iter().cloned());
oj_aff.extend(a.iter().copied());
oj_coll.extend(l.iter().copied());
}
let join_cols: Vec<ColumnInfo> = (0..oj_cols.len())
.map(|i| ColumnInfo {
name: oj_cols[i].clone(),
table: oj_tables[i].clone(),
affinity: oj_aff[i],
collation: oj_coll[i],
schema: None,
hidden: false,
})
.collect();
if validate_unambiguous_columns(sel, &join_cols, &|t| t.into()).is_err() {
return Err(Error::Unsupported("VDBE: ambiguous column name"));
}
let n_outer = sources[outer].0.len();
let on = &from.joins[0].on;
let prog = if is_full_2 {
vdbe::compile_full_join2(
sel, &oj_cols, &oj_tables, &oj_aff, &oj_coll, n_outer, on,
)?
} else {
vdbe::compile_left_join2(
sel, &oj_cols, &oj_tables, &oj_aff, &oj_coll, n_outer, on,
)?
};
let result = vdbe::run_rows_multi(&prog, &[&sources[outer].4, &sources[inner].4])?;
return Ok(QueryResult {
columns: prog.columns,
rows: result,
});
}
// Merge the existing WHERE with every join's ON predicate (AND).
let mut merged = sel.where_clause.clone();
for j in &from.joins {
if let Some(on) = &j.on {
merged = Some(match merged {
Some(w) => sql::ast::Expr::Binary {
op: sql::ast::BinaryOp::And,
left: alloc::boxed::Box::new(w),
right: alloc::boxed::Box::new(on.clone()),
},
None => on.clone(),
});
}
}
let mut joined = sel.clone();
joined.where_clause = merged;
// Defer an ambiguous-column query to the tree-walker, which rejects it
// with "ambiguous column name" (the same check over this join's combined
// column list). `compile_table_select` bails on some ambiguous bare refs
// but not all (e.g. one consumed only by GROUP BY), so check here too.
let join_cols: Vec<ColumnInfo> = (0..combined.len())
.map(|i| ColumnInfo {
name: combined[i].clone(),
table: combined_tables[i].clone(),
affinity: combined_aff[i],
collation: combined_coll[i],
schema: None,
hidden: false,
})
.collect();
if validate_unambiguous_columns(sel, &join_cols, &|t| t.into()).is_err() {
return Err(Error::Unsupported("VDBE: ambiguous column name"));
}
// B5b-1: a plain N-table inner join with a nested-loopable shape
// (projection + WHERE + constant LIMIT/OFFSET) runs as an N-deep
// nested loop over one cursor per table — no `t1 × … × tN`
// cross-product is materialized. The row order (each cursor advancing
// innermost-first, leftmost outermost) is identical, so the result
// matches the cross-product path. Any other shape bails below.
{
// Cumulative per-cursor column counts: boundaries[i] is the end of
// cursor i's columns in the combined row.
let mut boundaries = Vec::with_capacity(sources.len());
let mut acc = 0;
for src in &sources {
acc += src.0.len();
boundaries.push(acc);
}
// A bare-aggregate join (`count(*)`, `sum(a.x)`, … no GROUP BY)
// folds over the nested loop too, emitting one row — no
// cross-product is materialized. Same answer as the fallback.
if let Ok(prog) = vdbe::compile_aggregate_join(
&joined,
&combined,
&combined_tables,
&combined_aff,
&combined_coll,
&boundaries,
) {
let rowsets: Vec<&[Vec<Value>]> =
sources.iter().map(|s| s.4.as_slice()).collect();
let result = vdbe::run_rows_multi(&prog, &rowsets)?;
return Ok(QueryResult {
columns: prog.columns,
rows: result,
});
}
// A `GROUP BY` join (keys + aggregates, with optional HAVING /
// ORDER BY / LIMIT) folds each group over the nested loop and emits
// one row per group — again with no cross-product materialized.
if let Ok(prog) = vdbe::compile_group_join(
&joined,
&combined,
&combined_tables,
&combined_aff,
&combined_coll,
&boundaries,
true,
) {
let rowsets: Vec<&[Vec<Value>]> =
sources.iter().map(|s| s.4.as_slice()).collect();
// A group-key-correlated subquery in the projection runs against
// a synthetic per-group row over the combined columns; supply an
// evaluator over that combined schema when the program carries one.
let result = if prog.subqueries.is_empty() {
vdbe::run_rows_multi(&prog, &rowsets)?
} else {
let cols: Vec<ColumnInfo> = (0..combined.len())
.map(|i| ColumnInfo {
name: combined[i].clone(),
table: combined_tables[i].clone(),
affinity: combined_aff[i],
collation: combined_coll[i],
schema: None,
hidden: false,
})
.collect();
let eval = LiveSubqueryEval {
conn: self,
columns: &cols,
rowid_index: None,
};
vdbe::run_rows_multi_with_subqueries(&prog, &rowsets, &eval)?
};
return Ok(QueryResult {
columns: prog.columns,
rows: result,
});
}
if let Ok(prog) = vdbe::compile_join2(
&joined,
&combined,
&combined_tables,
&combined_aff,
&combined_coll,
&boundaries,
true,
&join_loop_order,
) {
let rowsets: Vec<&[Vec<Value>]> =
sources.iter().map(|s| s.4.as_slice()).collect();
// A correlated scalar/EXISTS subquery inside the join (B5c-2
// over joins) compiles to a callback op re-evaluated per outer
// row against the *combined* join row; the combined schema is
// its outer scope. Non-correlated joins never invoke the eval.
let join_cols: Vec<ColumnInfo> = (0..combined.len())
.map(|i| ColumnInfo {
name: combined[i].clone(),
table: combined_tables[i].clone(),
affinity: combined_aff[i],
collation: combined_coll[i],
schema: None,
hidden: false,
})
.collect();
let eval = LiveSubqueryEval {
conn: self,
columns: &join_cols,
rowid_index: None,
};
let result = vdbe::run_rows_multi_with_subqueries(&prog, &rowsets, &eval)?;
return Ok(QueryResult {
columns: prog.columns,
rows: result,
});
}
}
// N-way cross-product, leftmost source outermost.
let mut rows: Vec<Vec<Value>> = sources[0].4.clone();
for src in &sources[1..] {
let mut next = Vec::with_capacity(rows.len().saturating_mul(src.4.len()));
for a in &rows {
for b in &src.4 {
let mut row = a.clone();
row.extend(b.iter().cloned());
next.push(row);
}
}
rows = next;
}
let prog = vdbe::compile_table_select(
&joined,
&combined,
&combined_tables,
&combined_aff,
&combined_coll,
// rowid over a join is ambiguous across tables; not modeled here.
false,
)?;
let result = vdbe::run_rows(&prog, &rows)?;
return Ok(QueryResult {
columns: prog.columns,
rows: result,
});
}
// Single source. A plain rowid base table streams from a *live* b-tree
// cursor (B5b-2 / B8): the same `compile_table_select` program runs, but
// cursor 0's `Rewind`/`Column`/`Next` pull one decoded row at a time from a
// `TableCursor` instead of over a materialized row-set. This is purely a
// row-source swap — projection, `WHERE`, `ORDER BY`, `LIMIT`, `DISTINCT`,
// aggregate and `GROUP BY` handling are byte-identical to the materialized
// path. Anything not a plain rowid base table (a subquery / CTE / view /
// TVF source, a `WITHOUT ROWID` table, an index hint, a schema qualifier,
// or an unresolved `t.*` qualifier) returns `None` and takes the
// materialized path below unchanged.
if let Some(result) = self.try_live_single_scan(sel, from)? {
return Ok(result);
}
// Single source — a plain table or a derived table (`scan_one` materializes
// a safe FROM subquery, an in-scope CTE, or a table-valued function source).
let (col_names, col_tables, col_aff, col_coll, mut rows, rowids) = scan_one(&from.first)?;
// Append each row's rowid as a hidden trailing value so a `rowid`/`_rowid_`/
// `oid` reference resolves (a `WITHOUT ROWID` table has none → `rowids` is
// `None`, and such references fall back to the tree-walker, which errors).
let has_rowid = rowids.is_some();
if let Some(ids) = rowids {
for (row, id) in rows.iter_mut().zip(ids) {
row.push(Value::Integer(id));
}
}
// A `t.*` projection is only handled when its qualifier names this single
// table (by name or alias); any other qualifier falls back so the
// tree-walker can resolve or reject it.
for rc in &sel.columns {
if let sql::ast::ResultColumn::TableWildcard(q) = rc {
let matches = q.eq_ignore_ascii_case(&from.first.name)
|| from
.first
.alias
.as_deref()
.is_some_and(|a| q.eq_ignore_ascii_case(a));
if !matches {
return Err(Error::Unsupported("VDBE: unknown table.* qualifier"));
}
}
}
// Compile with `allow_correlated` so a correlated scalar/`EXISTS` subquery
// over this materialized source (a derived table / CTE / view / TVF /
// `WITHOUT ROWID` table — the shapes the live-scan path declines) runs on
// the VDBE too, re-evaluated per row (or per group, for a group-key
// correlated GROUP BY projection) through the `SubqueryEval` callback. A
// program without such a subquery leaves `subqueries` empty and takes the
// plain `run_rows` path unchanged.
let prog = vdbe::compile_table_select_opts(
sel,
&col_names,
&col_tables,
&col_aff,
&col_coll,
has_rowid,
true,
)?;
let result = if prog.subqueries.is_empty() {
vdbe::run_rows(&prog, &rows)?
} else {
let cols: Vec<ColumnInfo> = (0..col_names.len())
.map(|i| ColumnInfo {
name: col_names[i].clone(),
table: col_tables[i].clone(),
affinity: col_aff[i],
collation: col_coll[i],
schema: None,
hidden: false,
})
.collect();
// The rowid, when present, is the trailing value each row carries past
// the named columns (see the `has_rowid` append above).
let eval = LiveSubqueryEval {
conn: self,
columns: &cols,
rowid_index: has_rowid.then_some(col_names.len()),
};
vdbe::run_rows_multi_with_subqueries(&prog, &[&rows], &eval)?
};
Ok(QueryResult {
columns: prog.columns,
rows: result,
})
}
/// Attempt to run a single-source `SELECT … FROM <one rowid table> [WHERE …]`
/// through the VDBE over a *live* b-tree cursor (B5b-2 / B8), returning
/// `Ok(Some(result))` on success. Returns `Ok(None)` — deferring to the
/// materialized single-source path — for anything that is not a plain rowid
/// base table: a `FROM` subquery / in-scope CTE / view / table-valued function,
/// a `WITHOUT ROWID` table, an `INDEXED BY` / `NOT INDEXED` hint, a
/// schema-qualified source, or a `t.*` whose qualifier doesn't name this table.
///
/// The row source is the only thing that changes: the exact same
/// `compile_table_select` program runs, but cursor 0's `Rewind`/`Column`/`Next`
/// stream one decoded row at a time from [`LiveScanCursor`] rather than reading
/// a pre-materialized `Vec`. Every other stage (projection, `WHERE`,
/// `ORDER BY`, `LIMIT`/`OFFSET`, `DISTINCT`, aggregates, `GROUP BY`) is byte-
/// identical to [`run_rows`](vdbe::run_rows), so the result matches the
/// materialized path, the tree-walker, and SQLite.
fn try_live_single_scan(&self, sel: &Select, from: &FromClause) -> Result<Option<QueryResult>> {
let tr = &from.first;
// Only a plain named base table qualifies. A subquery / TVF / in-scope CTE
// / view / schema-qualified source takes the materialized path (which
// resolves each of those); mirror `scan_one`'s guards. A `NOT INDEXED`
// hint is fine — the live scan is a full scan — but `INDEXED BY name` must
// be honoured or rejected by the tree-walker, so it defers.
if tr.subquery.is_some()
|| tr.tvf_args.is_some()
|| tr.schema.is_some()
|| matches!(tr.index_hint, Some(IndexHint::IndexedBy(_)))
|| self.is_bare_tvf(tr)
{
return Ok(None);
}
if sel
.ctes
.iter()
.any(|c| c.name.eq_ignore_ascii_case(&tr.name))
{
return Ok(None);
}
if self.is_view(&tr.name) {
return Ok(None);
}
// Resolve the table; a missing/renamed table or one the VDBE can't model
// defers to the materialized path (which errors identically).
let meta = match self.table_meta(&tr.name, tr.alias.as_deref()) {
Ok(m) => m,
Err(_) => return Ok(None),
};
// The same per-column metadata `scan_one` derives for a base table.
let col_names: Vec<String> = meta.columns.iter().map(|c| c.name.clone()).collect();
let qualifier = tr.alias.clone().unwrap_or_else(|| tr.name.clone());
let col_tables: Vec<String> = meta.columns.iter().map(|_| qualifier.clone()).collect();
let col_aff: Vec<eval::Affinity> = meta.columns.iter().map(|c| c.affinity).collect();
let col_coll: Vec<crate::value::Collation> =
meta.columns.iter().map(|c| c.collation).collect();
// A rowid table carries a hidden trailing rowid so `rowid`/`_rowid_`/`oid`
// resolves (compiled into the program via `has_rowid`). A `WITHOUT ROWID`
// table has none — so `has_rowid` is false and any `rowid` reference makes
// `compile_table_select` bail (falling back to the materialized path, which
// errors identically); its rows are streamed in primary-key (b-tree) order,
// the same order the materialized scan and SQLite produce.
let has_rowid = !meta.without_rowid;
// A `t.*` projection is only handled when its qualifier names this table;
// any other qualifier defers so the tree-walker can resolve or reject it.
for rc in &sel.columns {
if let sql::ast::ResultColumn::TableWildcard(q) = rc {
let matches = q.eq_ignore_ascii_case(&tr.name)
|| tr
.alias
.as_deref()
.is_some_and(|a| q.eq_ignore_ascii_case(a));
if !matches {
return Ok(None);
}
}
}
// The live single-table scan supplies a `SubqueryEval` callback, so it opts
// into compiling a *correlated* scalar / `EXISTS` subquery to a callback op
// (B5c-2) that re-evaluates it per outer row through the tree-walker. A
// non-correlated subquery was already folded to a constant in
// `run_select_vdbe` before this point, so only the correlated (and other
// unfoldable) ones reach the callback — where the result matches the
// tree-walker exactly.
let prog = match vdbe::compile_table_select_opts(
sel,
&col_names,
&col_tables,
&col_aff,
&col_coll,
has_rowid,
true,
) {
Ok(p) => p,
// A shape `compile_table_select` can't emit falls back to the
// materialized path, which either handles it or defers identically.
Err(_) => return Ok(None),
};
let eval = LiveSubqueryEval {
conn: self,
columns: &meta.columns,
rowid_index: has_rowid.then_some(meta.columns.len()),
};
// A rowid table streams from a `TableCursor` (rowid b-tree); a WITHOUT
// ROWID table streams from an `IndexCursor` over its index-organized b-tree
// (primary-key order). Both implement `Cursor0Source`, so the same program
// and subquery callback run over either.
let rows = if meta.without_rowid {
let mut src = WithoutRowidLiveCursor::new(self, &meta);
vdbe::run_live_scan_with_subqueries(&prog, &mut src, &eval)?
} else {
let mut src = LiveScanCursor::new(self, &meta, has_rowid);
vdbe::run_live_scan_with_subqueries(&prog, &mut src, &eval)?
};
Ok(Some(QueryResult {
columns: prog.columns,
rows,
}))
}
/// Run a compound `SELECT` (`UNION` / `UNION ALL` / `INTERSECT` / `EXCEPT`)
/// on the VDBE (Track B, B5c-3). Each constituent SELECT is executed through
/// [`run_select_vdbe`](Self::run_select_vdbe); the set combination, the
/// post-dedup sort, and the overall `ORDER BY` / `LIMIT` / `OFFSET` reuse the
/// exact helpers the tree-walker uses ([`apply_compound`],
/// [`compound_order_limit`](Self::compound_order_limit)), so the result is
/// byte-identical. The whole-query `WITH` is threaded into every arm (each
/// resolves the CTEs through the CTE-source path). Returns `Unsupported` —
/// falling back to the tree-walker — if any arm is a shape the VDBE cannot
/// run, or carries its own nested compound (e.g. a multi-row `VALUES`, which
/// desugars to a nested `UNION ALL` chain).
fn run_compound_vdbe(&self, sel: &Select) -> Result<QueryResult> {
// Each arm must be a flat (non-compound, CTE-free) SELECT so the
// left-associative fold matches SQLite without recursing into operand
// tails (a multi-row `VALUES` operand keeps its rows in its own compound
// tail — defer those to the tree-walker).
if sel
.compound
.iter()
.any(|(_, c)| !c.compound.is_empty() || !c.ctes.is_empty())
{
return Err(Error::Unsupported("VDBE: nested compound arm"));
}
let params = eval::Params::default();
if sel.ctes.is_empty() {
return self.run_compound_vdbe_arms(sel, ¶ms);
}
// Materialize the whole-query `WITH` into the CTE environment (mirroring
// `run_select`) so the tree-walker collation scan below resolves the CTE
// sources; restore the environment on exit. Each VDBE arm additionally
// materializes the CTEs through the derived-source path (the outer CTEs
// are threaded into every operand in `run_compound_vdbe_arms`).
let base = self.cte_env.borrow().len();
let outer_cap = self.recursive_cte_outer_cap(sel, ¶ms);
let mut seeds = Vec::new();
collect_source_names(sel, &mut seeds);
let pushed = self.push_ctes(&sel.ctes, ¶ms, outer_cap, Some(&seeds));
let result = pushed.and_then(|()| self.run_compound_vdbe_arms(sel, ¶ms));
self.cte_env.borrow_mut().truncate(base);
result
}
/// The compound fold itself, assuming any whole-query `WITH` is already live
/// in the CTE environment (see [`run_compound_vdbe`](Self::run_compound_vdbe)).
fn run_compound_vdbe_arms(&self, sel: &Select, params: &Params) -> Result<QueryResult> {
// The first core, stripped of the compound tail and the whole-query
// ORDER BY / LIMIT / OFFSET.
let mut first = sel.clone();
first.compound = Vec::new();
first.order_by = Vec::new();
first.limit = None;
first.offset = None;
let mut result = self.run_select_vdbe(&first)?;
// Set comparison uses the left SELECT's per-column output collations.
let colls = {
let (cols, _) = self.scan_source(&first, params)?;
self.output_collations(&first, &cols, params)
};
for (op, operand) in &sel.compound {
// The whole-query `WITH` binds every arm. The first core already
// carries it (it is a clone of `sel`), but each operand parses with
// empty `ctes`, so thread the outer CTEs in before running the arm —
// each arm then materializes them through the CTE-source path. (A
// sibling-referencing or otherwise non-VDBE-able CTE makes the arm
// return `Unsupported`, falling the whole query back.)
let operand = if sel.ctes.is_empty() {
operand.clone()
} else {
let mut o = operand.clone();
o.ctes = sel.ctes.clone();
o
};
let r = self.run_select_vdbe(&operand)?;
// Every operand must project the same number of columns; SQLite names
// the operator at the mismatch.
if r.columns.len() != result.columns.len() {
let kw = match op {
CompoundOp::Union => "UNION",
CompoundOp::UnionAll => "UNION ALL",
CompoundOp::Intersect => "INTERSECT",
CompoundOp::Except => "EXCEPT",
};
return Err(Error::Error(alloc::format!(
"SELECTs to the left and right of {kw} do not have the same \
number of result columns"
)));
}
result.rows = apply_compound(*op, result.rows, r.rows, &colls);
}
// A dedup set operation (UNION / INTERSECT / EXCEPT) emits rows in sorted
// order in SQLite (its dedup is a sorter); with no explicit ORDER BY,
// sort the combined result by all output columns to match.
if sel.order_by.is_empty()
&& sel
.compound
.iter()
.any(|(op, _)| *op != CompoundOp::UnionAll)
{
result.rows.sort_by(|a, b| {
for (i, va) in a.iter().enumerate() {
let coll = colls.get(i).copied().unwrap_or_default();
let ord = crate::value::cmp_values_coll(va, &b[i], coll);
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
core::cmp::Ordering::Equal
});
}
self.compound_order_limit(&mut result, sel, params, &colls)?;
Ok(result)
}
/// Acquire the persistent read (`Shared`) lock on the main database's pager
/// when a read runs *inside an explicit transaction* (`BEGIN …` or an open
/// `SAVEPOINT`), matching SQLite's DEFERRED transaction semantics (ROADMAP
/// C9a): `BEGIN` alone takes no lock; the lock is taken at the **first read**
/// within the transaction and held until COMMIT/ROLLBACK, so a concurrent
/// writer's commit-time upgrade to `Exclusive` BUSYs until this reader ends.
///
/// A no-op for autocommit reads (no open transaction never blocks a writer)
/// and for a read-only backend (no pager to lock). Idempotent — safe to call
/// on every read; the pager only takes the lock once. Runs through `&self`:
/// the pager's lock state is interior-mutable.
fn ensure_read_txn_lock(&self) -> Result<()> {
if (self.in_tx || self.open_savepoints > 0)
&& let Backend::Write(w) = &self.backend
{
w.begin_read_txn()?;
}
Ok(())
}
/// Statement-boundary coherency hook (ROADMAP C8c-2): revalidate every
/// backend's read cache against the current on-disk change counter before a
/// read statement touches any page.
///
/// A pure read-only connection over a read-write file caches clean pages keyed
/// by the database change counter; another in-process `Connection` may commit
/// between statements and bump that counter. Calling `revalidate_cache` once
/// per statement drops the cache exactly when the file changed, so the reader
/// always sees the newest committed data while still reusing cached pages when
/// nothing changed. A no-op for write backends (a writer owns coherency through
/// its lock) and for snapshot sources. Runs through `&self` (the cache state is
/// interior-mutable). Covers the main database and every attached/temp one, so
/// a cross-database read is coherent too.
fn revalidate_read_caches(&self) {
self.backend.source().revalidate_cache();
if let Some(t) = &self.temp_db {
t.backend.source().revalidate_cache();
}
for d in &self.attached {
d.backend.source().revalidate_cache();
}
}
/// Like [`query`](Self::query) but with bound parameters.
pub fn query_params(&self, sql: &str, params: &Params) -> Result<QueryResult> {
let stmt = sql::parse_one(sql)?;
self.run_authorizer(&stmt)?;
// A bare autocommit `SELECT` takes a transient cross-process `Shared` lock for
// the duration of the read (ROADMAP C9b-3), so a foreign process mid-write
// can't be read torn. Acquired *before* revalidating the cache so the
// change-counter read is itself covered, and released at statement end. A
// no-op inside an explicit transaction (which holds its own lock) and for a
// read-only / in-memory backend.
let took_transient = if matches!(stmt, Statement::Select(_))
&& !self.in_tx
&& self.open_savepoints == 0
&& let Backend::Write(w) = &self.backend
{
w.begin_autocommit_read()?
} else {
false
};
// Statement boundary: drop any read cache that a foreign commit has made
// stale, so this statement sees the newest committed data (ROADMAP C8c-2).
self.revalidate_read_caches();
let result = match stmt {
Statement::Select(sel) => {
self.ensure_read_txn_lock()?;
self.run_select(&sel, params)
}
Statement::Pragma(p) => self.run_pragma(&p),
Statement::Explain { query_plan, stmt } => {
if query_plan {
self.explain_query_plan(&stmt, params)
} else {
self.explain_bytecode(&stmt)
}
}
_ => Err(Error::Unsupported(
"use execute() for non-SELECT statements",
)),
};
if took_transient && let Backend::Write(w) = &self.backend {
w.end_autocommit_read();
}
result
}
/// Evaluate the read-only `PRAGMA`s that return a result set.
fn run_pragma(&self, p: &Pragma) -> Result<QueryResult> {
let name = p.name.to_ascii_lowercase();
let header = self.backend.source().header();
let single = |col: &str, v: Value| QueryResult {
columns: alloc::vec![String::from(col)],
rows: alloc::vec![alloc::vec![v]],
};
match name.as_str() {
"page_size" => Ok(single("page_size", Value::Integer(header.page_size as i64))),
"page_count" => Ok(single(
"page_count",
Value::Integer(self.backend.source().page_count() as i64),
)),
"user_version" => Ok(single(
// Stored as a 32-bit value; SQLite reports it signed.
"user_version",
Value::Integer(header.user_version as i32 as i64),
)),
"schema_version" => Ok(single(
"schema_version",
Value::Integer(header.schema_cookie as i64),
)),
"encoding" => Ok(single(
"encoding",
Value::Text(
match header.text_encoding {
crate::format::TextEncoding::Utf8 => "UTF-8",
crate::format::TextEncoding::Utf16Le => "UTF-16le",
crate::format::TextEncoding::Utf16Be => "UTF-16be",
}
.into(),
),
)),
"freelist_count" => Ok(single(
"freelist_count",
Value::Integer(header.freelist_count as i64),
)),
// 0 = NONE, 1 = FULL, 2 = INCREMENTAL. Auto-vacuum is on when the
// header's largest-root-page field is non-zero; the incremental flag
// then distinguishes the two modes.
"auto_vacuum" => Ok(single(
"auto_vacuum",
Value::Integer(auto_vacuum_mode(header) as i64),
)),
"application_id" => Ok(single(
"application_id",
Value::Integer(header.application_id as i32 as i64),
)),
// `PRAGMA data_version` — sqlite's `SQLITE_FCNTL_DATA_VERSION`: a value
// that is stable for this connection but changes when *another*
// connection commits. We start at `1` and bump each time we observe
// the on-disk change counter differ from the value our own writes /
// last read left behind (see `dv_seen_cc`, updated after every
// `execute`). Same-connection writes never move it; a foreign commit
// does — matching sqlite's behaviour (the exact integer is arbitrary).
"data_version" => {
// Read the *live* file change counter from page 1 (header offset
// 24, big-endian) rather than the connection's cached header: in
// WAL mode a foreign commit lands as a page-1 frame that the
// shared wal-index overlays, so page(1) reflects it while the
// cached header does not. Fall back to the cached value if page 1
// is momentarily unreadable.
let cc = self
.backend
.source()
.page(1)
.ok()
.and_then(|pg| {
pg.data()
.get(24..28)
.map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
})
.unwrap_or_else(|| self.backend.source().header().change_counter);
match self.dv_seen_cc.get() {
Some(prev) if prev != cc => {
self.dv_counter.set(self.dv_counter.get().wrapping_add(1));
self.dv_seen_cc.set(Some(cc));
}
None => self.dv_seen_cc.set(Some(cc)),
_ => {}
}
Ok(single(
"data_version",
Value::Integer(self.dv_counter.get()),
))
}
"table_info" => self.pragma_table_info(p, false),
"table_xinfo" => self.pragma_table_info(p, true),
"index_list" => self.pragma_index_list(p),
"index_info" => self.pragma_index_info(p, false),
"index_xinfo" => self.pragma_index_info(p, true),
"database_list" => Ok(self.pragma_database_list()),
"table_list" => self.pragma_table_list(p),
// The collating sequences graphite implements (built-ins only; it
// registers no custom collations).
"collation_list" => Ok(QueryResult {
columns: alloc::vec!["seq".into(), "name".into()],
// SQLite lists the built-in collations `BINARY`, `NOCASE`, `RTRIM`
// in that order (seq 0..); graphite implements exactly these three.
rows: ["BINARY", "NOCASE", "RTRIM"]
.iter()
.enumerate()
.map(|(i, n)| alloc::vec![Value::Integer(i as i64), Value::Text((*n).into())])
.collect(),
}),
// `PRAGMA pragma_list` / `module_list` / `compile_options` —
// introspection over graphite's *own* registries (never a copy of a
// particular sqlite build's list). One `name` column each
// (`compile_options` names its column `compile_options`), rows in
// sqlite's alphabetical order.
"pragma_list" => Ok(QueryResult {
columns: alloc::vec![String::from("name")],
rows: PRAGMA_LIST
.iter()
.map(|n| alloc::vec![Value::Text((*n).into())])
.collect(),
}),
"module_list" => Ok(QueryResult {
columns: alloc::vec![String::from("name")],
rows: module_list_names()
.iter()
.map(|n| alloc::vec![Value::Text((*n).into())])
.collect(),
}),
"compile_options" => Ok(QueryResult {
columns: alloc::vec![String::from("compile_options")],
rows: compile_option_names()
.iter()
.map(|n| alloc::vec![Value::Text((*n).into())])
.collect(),
}),
// `PRAGMA function_list` — introspection over the SQL functions this
// build registers (graphite's own set, from `func::function_list()`),
// sorted by name like sqlite. Same six columns as sqlite:
// `name, builtin, type, enc, narg, flags`. `name`, `builtin` (always
// 1 — every graphite function is built in), `type` (`s`/`a`/`w`),
// `enc` (always `utf8` — graphite is UTF-8 only), and `narg` (the
// declared arity, `-1` for variadic) are reported faithfully. `flags`
// is a build/implementation-specific `FuncDef` bitmask graphite does
// not model, so it is reported as 0 rather than fabricated.
"function_list" => Ok(QueryResult {
columns: alloc::vec![
String::from("name"),
String::from("builtin"),
String::from("type"),
String::from("enc"),
String::from("narg"),
String::from("flags"),
],
rows: func::function_list()
.into_iter()
.map(|(name, kind, narg)| {
alloc::vec![
Value::Text(name.into()),
Value::Integer(1),
Value::Text(alloc::string::String::from(kind).into()),
Value::Text("utf8".into()),
Value::Integer(narg as i64),
Value::Integer(0),
]
})
.collect(),
}),
"foreign_key_list" => self.pragma_foreign_key_list(p),
"foreign_key_check" => self.pragma_foreign_key_check(p),
"integrity_check" | "quick_check" => self.pragma_integrity_check(p),
"foreign_keys" => Ok(single(
"foreign_keys",
Value::Integer(self.foreign_keys as i64),
)),
"recursive_triggers" => Ok(single(
"recursive_triggers",
Value::Integer(self.recursive_triggers as i64),
)),
"journal_mode" => {
// An in-memory database (empty main file) uses the `memory`
// journal, like sqlite; a file database defaults to `delete`.
let mode = if self.backend.wal_mode() {
"wal"
} else if self.main_file.is_empty() {
"memory"
} else {
"delete"
};
Ok(single("journal_mode", Value::Text(mode.into())))
}
// Read-only getters for tuning knobs graphite does not expose. It
// has no configurable page cache, durability mode, or lock manager
// beyond what it already implements, so each reports SQLite's fixed
// default — what an unconfigured connection observes. This keeps the
// shell drop-in for tools/ORMs that probe these on connect.
"cache_size" => Ok(single("cache_size", Value::Integer(self.cache_size.get()))),
// The reference sqlite build has memory-mapped I/O disabled
// (SQLITE_MAX_MMAP_SIZE = 0), so `PRAGMA mmap_size` yields no rows.
"mmap_size" => Ok(QueryResult {
columns: alloc::vec![String::from("mmap_size")],
rows: Vec::new(),
}),
"synchronous" => Ok(single(
"synchronous",
Value::Integer(self.synchronous.get()),
)),
"temp_store" => Ok(single("temp_store", Value::Integer(self.temp_store.get()))),
"threads" => Ok(single("threads", Value::Integer(self.threads.get()))),
"secure_delete" => Ok(single(
"secure_delete",
Value::Integer(self.secure_delete.get()),
)),
"read_uncommitted" => Ok(single("read_uncommitted", Value::Integer(0))),
// Inert in graphite (cells are validated on every read regardless),
// but stored and echoed so the round-trip matches sqlite.
"cell_size_check" => {
if let Some(e) = &p.value {
self.cell_size_check
.set(pragma_truth(e, &Params::default()));
}
Ok(single(
"cell_size_check",
Value::Integer(self.cell_size_check.get() as i64),
))
}
"checkpoint_fullfsync" => Ok(single("checkpoint_fullfsync", Value::Integer(0))),
"fullfsync" => Ok(single("fullfsync", Value::Integer(0))),
// `busy_timeout` round-trips the lock-wait timeout (graphite never
// blocks, so it is advisory). The set form clamps a negative value to 0
// and echoes it; the plain form reads it back — like sqlite. The result
// column is named "timeout".
"busy_timeout" => {
if let Some(e) = &p.value {
let v = eval::to_i64(&eval::eval(e, &EvalCtx::rowless(&Params::default()))?);
self.busy_timeout.set(v.max(0));
}
Ok(single("timeout", Value::Integer(self.busy_timeout.get())))
}
// `wal_checkpoint[(mode)]` returns one `(busy, log, checkpointed)`
// row. On a **non-WAL** database (rollback journal / memory) there is
// nothing to checkpoint and sqlite reports `0, -1, -1` — so does this
// read-only (`&self`) path. In **WAL** mode the checkpoint actually
// mutates (it backfills committed frames into the main file and can
// rewrite/truncate the `-wal`), which `query`'s `&self` borrow cannot
// do; route it to the mutating `execute`/`exec_pragma` path (which
// calls the real `checkpoint_mode`) via the same `use execute()`
// signal the executor uses for any other write-shaped statement.
"wal_checkpoint" => {
if self.backend.wal_mode() {
return Err(Error::Unsupported("use execute() for wal_checkpoint"));
}
Ok(QueryResult {
columns: alloc::vec![
String::from("busy"),
String::from("log"),
String::from("checkpointed"),
],
rows: alloc::vec![alloc::vec![
Value::Integer(0),
Value::Integer(-1),
Value::Integer(-1),
]],
})
}
"wal_autocheckpoint" => Ok(single(
"wal_autocheckpoint",
Value::Integer(self.wal_autocheckpoint.get()),
)),
"soft_heap_limit" => Ok(single(
"soft_heap_limit",
Value::Integer(self.soft_heap_limit.get()),
)),
// `journal_size_limit` stores/reports the journal-shrink cap. The set
// form clamps any negative value to -1 (the "no limit" sentinel) and
// echoes the result; the plain form reads it back — exactly like sqlite.
"journal_size_limit" => {
if let Some(e) = &p.value {
let v = eval::to_i64(&eval::eval(e, &EvalCtx::rowless(&Params::default()))?);
self.journal_size_limit.set(if v < 0 { -1 } else { v });
}
Ok(single(
"journal_size_limit",
Value::Integer(self.journal_size_limit.get()),
))
}
"max_page_count" => Ok(single("max_page_count", Value::Integer(4294967294))),
"locking_mode" => Ok(single("locking_mode", Value::Text("normal".into()))),
// Recognized boolean / legacy no-op pragmas: graphite does not act on
// them, but reports SQLite's fixed default so a probing tool/ORM sees a
// normal connection. `legacy_file_format` and `case_sensitive_like`
// (a setter-only spelling) yield no rows, as in SQLite.
"legacy_file_format" | "case_sensitive_like" => Ok(QueryResult {
columns: alloc::vec![name.clone()],
rows: Vec::new(),
}),
// `analysis_limit` stores/reports the ANALYZE sample cap. The set form
// (`PRAGMA analysis_limit = N`) clamps a negative N to 0 and echoes the
// resulting value, exactly like sqlite; the plain form reads it back.
"analysis_limit" => {
if let Some(e) = &p.value {
let v = eval::to_i64(&eval::eval(e, &EvalCtx::rowless(&Params::default()))?);
self.analysis_limit.set(v.max(0));
}
Ok(single(
"analysis_limit",
Value::Integer(self.analysis_limit.get()),
))
}
// `optimize` runs recommended maintenance; graphite keeps its stats
// current, so there is nothing to do and — like sqlite in its default,
// non-verbose mode — it returns no rows.
"optimize" => Ok(QueryResult {
columns: alloc::vec![name.clone()],
rows: Vec::new(),
}),
"short_column_names" => Ok(single(&name, Value::Integer(1))),
// Inert in graphite (it builds no transient automatic indexes), but
// stored and echoed so the round-trip matches sqlite (default on).
"automatic_index" => {
if let Some(e) = &p.value {
self.automatic_index
.set(pragma_truth(e, &Params::default()));
}
Ok(single(
"automatic_index",
Value::Integer(self.automatic_index.get() as i64),
))
}
// `query_only`/`ignore_check_constraints` reflect the live connection
// flags; the others below are accepted but inert (default `0`).
"query_only" => Ok(single(&name, Value::Integer(self.query_only as i64))),
"ignore_check_constraints" => Ok(single(
&name,
Value::Integer(self.ignore_check_constraints as i64),
)),
"legacy_alter_table"
| "count_changes"
| "full_column_names"
| "empty_result_callbacks"
| "defer_foreign_keys"
| "reverse_unordered_selects"
| "hard_heap_limit"
| "writable_schema" => Ok(single(&name, Value::Integer(0))),
// `incremental_vacuum` (bare or `(N)`) performs a write, so it cannot
// run on the read-only query path. Signal the caller to re-run it via
// execute() (the CLI retries on this message); the `= N` form already
// routes to execute() directly.
"incremental_vacuum" => Err(Error::Unsupported(
"PRAGMA incremental_vacuum modifies the database; use execute()",
)),
// An unrecognized pragma name is silently ignored by sqlite — it
// raises no error and returns no rows ("If the pragma name is not
// recognized ... no error is raised, the pragma is simply
// ignored"). The write path (`exec_pragma`) already no-ops unknown
// names; mirror that on the read path so `PRAGMA made_up` and
// `PRAGMA made_up(1)` return an empty result instead of erroring.
_ => Ok(QueryResult {
columns: alloc::vec![name.clone()],
rows: Vec::new(),
}),
}
}
/// `PRAGMA database_list` → `(seq, name, file)` for `main`, then each
/// attached database in attachment order. In-memory databases report an
/// empty file path, as in SQLite.
/// `PRAGMA table_list [(name)]`: one row per table/view across every
/// database — `(schema, name, type, ncol, wr, strict)` — plus each
/// database's synthetic schema table. Row order is unspecified in sqlite
/// (hash order); we emit database order, then catalog order within each.
fn pragma_table_list(&self, p: &Pragma) -> Result<QueryResult> {
use crate::schema::ObjectType;
let filter = match &p.value {
Some(Expr::Column { column, .. }) => Some(column.clone()),
Some(Expr::Literal(Literal::Str(s))) => Some(s.clone()),
_ => None,
};
let params = Params::default();
// (display name, which database, that database's schema-table name).
// `temp` is always listed here (matching sqlite) even before it exists —
// unlike `database_list`, which omits it until first use.
let mut dbs: Vec<(String, DbRef, &str)> = alloc::vec![
("main".into(), DbRef::Main, "sqlite_schema"),
("temp".into(), DbRef::Temp, "sqlite_temp_schema"),
];
for (i, d) in self.attached.iter().enumerate() {
dbs.push((d.name.clone(), DbRef::Attached(i), "sqlite_schema"));
}
let matches = |n: &str| filter.as_deref().is_none_or(|f| f.eq_ignore_ascii_case(n));
let mut rows: Vec<Vec<Value>> = Vec::new();
for (db_name, db, schema_tab) in &dbs {
// `temp` may be listed before it has been created (no user objects).
let objects: &[crate::schema::SchemaObject] =
if matches!(db, DbRef::Temp) && self.temp_db.is_none() {
&[]
} else {
self.db_parts(*db).0.objects()
};
for obj in objects {
let typ = match obj.obj_type {
ObjectType::Table => "table",
ObjectType::View => "view",
_ => continue,
};
if !matches(&obj.name) {
continue;
}
let (ncol, wr, strict) = self.table_list_dims(*db, obj, ¶ms);
rows.push(alloc::vec![
Value::Text(db_name.clone().into()),
Value::Text(obj.name.clone().into()),
Value::Text(typ.into()),
Value::Integer(ncol),
Value::Integer(wr),
Value::Integer(strict),
]);
}
// The database's own schema table (also matchable as `sqlite_master`).
if matches(schema_tab)
|| filter
.as_deref()
.is_some_and(|f| f.eq_ignore_ascii_case("sqlite_master"))
{
rows.push(alloc::vec![
Value::Text(db_name.clone().into()),
Value::Text((*schema_tab).into()),
Value::Text("table".into()),
Value::Integer(5),
Value::Integer(0),
Value::Integer(0),
]);
}
}
Ok(QueryResult {
columns: alloc::vec![
"schema".into(),
"name".into(),
"type".into(),
"ncol".into(),
"wr".into(),
"strict".into(),
],
rows,
})
}
/// `(ncol, wr, strict)` for one `table_list` row: a table's column count,
/// WITHOUT ROWID flag, and STRICT flag; a view's output-column count (its
/// `wr`/`strict` are always 0). Best-effort — an unreadable object yields 0s.
fn table_list_dims(
&self,
db: DbRef,
obj: &crate::schema::SchemaObject,
params: &Params,
) -> (i64, i64, i64) {
use crate::schema::ObjectType;
match obj.obj_type {
ObjectType::Table => {
let (schema, _) = self.db_parts(db);
match self.table_meta_in(schema, &obj.name, None) {
Ok(m) => (
m.columns.len() as i64,
m.without_rowid as i64,
m.strict_types.is_some() as i64,
),
Err(_) => (0, 0, 0),
}
}
ObjectType::View => {
let ncol = self
.scan_db_view(db, &obj.name, None, params)
.ok()
.flatten()
.map_or(0, |(c, _)| c.len() as i64);
(ncol, 0, 0)
}
_ => (0, 0, 0),
}
}
fn pragma_database_list(&self) -> QueryResult {
let mut rows = alloc::vec![alloc::vec![
Value::Integer(0),
Value::Text("main".into()),
Value::Text(self.main_file.clone().into()),
]];
// `temp` occupies seq 1 once it exists; attached databases begin at seq 2.
if self.temp_db.is_some() {
rows.push(alloc::vec![
Value::Integer(1),
Value::Text("temp".into()),
Value::Text(String::new().into()),
]);
}
for (i, db) in self.attached.iter().enumerate() {
rows.push(alloc::vec![
Value::Integer((i + 2) as i64),
Value::Text(db.name.clone().into()),
Value::Text(db.file.clone().into()),
]);
}
QueryResult {
columns: alloc::vec!["seq".into(), "name".into(), "file".into()],
rows,
}
}
/// The schema catalog an introspection `PRAGMA` targets: `p.schema` selects
/// `main` (the default), `temp`, or an attached database, matching SQLite's
/// `PRAGMA <db>.table_info(…)` form. An unknown database name errors
/// `unknown database <name>` (as SQLite does at prepare time).
fn pragma_db_schema(&self, p: &Pragma) -> Result<&Schema> {
match p.schema.as_deref() {
None => Ok(&self.schema),
Some(s) if s.eq_ignore_ascii_case("main") => Ok(&self.schema),
Some(s) if s.eq_ignore_ascii_case("temp") => self
.temp_db
.as_ref()
.map(|t| &t.schema)
.ok_or_else(|| Error::Error(alloc::format!("unknown database {s}"))),
Some(s) => self
.attached
.iter()
.find(|d| d.name.eq_ignore_ascii_case(s))
.map(|d| &d.schema)
.ok_or_else(|| Error::Error(alloc::format!("unknown database {s}"))),
}
}
/// `PRAGMA table_info(name)` → one row per column
/// `(cid, name, type, notnull, dflt_value, pk)`.
fn pragma_table_info(&self, p: &Pragma, extended: bool) -> Result<QueryResult> {
let sch = self.pragma_db_schema(p)?;
let table = match &p.value {
Some(Expr::Column { column, .. }) => column.clone(),
Some(Expr::Literal(Literal::Str(s))) => s.clone(),
// SQLite coerces a numeric argument to its text form; a bare
// `PRAGMA table_info` (or other non-name argument) names no table.
// Either way the lookup below finds nothing and returns an empty
// result rather than erroring, matching SQLite.
Some(Expr::Literal(Literal::Integer(n))) => n.to_string(),
_ => String::new(),
};
// The schema catalog is queryable but has no stored CREATE statement;
// report its fixed five columns, as SQLite does for `sqlite_master` /
// `sqlite_schema` (and their `sqlite_temp_*` aliases).
if matches!(
table.to_ascii_lowercase().as_str(),
"sqlite_master" | "sqlite_schema" | "sqlite_temp_master" | "sqlite_temp_schema"
) {
let cols = [
("type", "TEXT"),
("name", "TEXT"),
("tbl_name", "TEXT"),
("rootpage", "INT"),
("sql", "TEXT"),
];
let mut rows = Vec::new();
for (i, (name, ty)) in cols.iter().enumerate() {
let mut row = alloc::vec![
Value::Integer(i as i64),
Value::Text((*name).into()),
Value::Text((*ty).into()),
Value::Integer(0),
Value::Null,
Value::Integer(0),
];
if extended {
row.push(Value::Integer(0));
}
rows.push(row);
}
let columns = table_info_columns(extended);
return Ok(QueryResult { columns, rows });
}
// The eponymous read-only vtabs (`dbstat`, `sqlite_dbpage`) answer
// table_info with their fixed column shape, unless a real table of the
// name shadows them. Each entry is `(name, type, pk, hidden)`:
// `sqlite_dbpage.pgno` is PRIMARY KEY, and both carry trailing hidden
// columns that only `table_xinfo` (the extended form) reports.
if sch.table(&table).is_none() {
let lower = table.to_ascii_lowercase();
let fixed: &[(&str, &str, i64, bool)] = match lower.as_str() {
"sqlite_dbpage" => &[
("pgno", "INTEGER", 1, false),
("data", "BLOB", 0, false),
("schema", "", 0, true),
],
"dbstat" => &[
("name", "TEXT", 0, false),
("path", "TEXT", 0, false),
("pageno", "INTEGER", 0, false),
("pagetype", "TEXT", 0, false),
("ncell", "INTEGER", 0, false),
("payload", "INTEGER", 0, false),
("unused", "INTEGER", 0, false),
("mx_payload", "INTEGER", 0, false),
("pgoffset", "INTEGER", 0, false),
("pgsize", "INTEGER", 0, false),
("schema", "TEXT", 0, true),
("aggregate", "BOOLEAN", 0, true),
],
_ => &[],
};
if !fixed.is_empty() {
// Non-extended `table_info` omits hidden columns entirely; the
// `cid` is the position in the emitted sequence (hidden columns
// always trail, so visible indices are unaffected).
let rows = fixed
.iter()
.filter(|(_, _, _, hidden)| extended || !hidden)
.enumerate()
.map(|(i, (name, ty, pk, hidden))| {
let mut row = alloc::vec![
Value::Integer(i as i64),
Value::Text((*name).into()),
Value::Text((*ty).into()),
Value::Integer(0),
Value::Null,
Value::Integer(*pk),
];
if extended {
row.push(Value::Integer(*hidden as i64));
}
row
})
.collect();
return Ok(QueryResult {
columns: table_info_columns(extended),
rows,
});
}
}
// A VIEW also answers table_info: its columns with their resolved types
// (notnull/dflt/pk are always 0/empty for a view).
if let Some(vobj) = sch.objects().iter().find(|o| {
o.obj_type == crate::schema::ObjectType::View && o.name.eq_ignore_ascii_case(&table)
}) && let Some(sql) = &vobj.sql
&& let Statement::CreateView(cv) = sql::parse_one(sql)?
{
return self.view_table_info(&cv, &table, extended);
}
// A virtual table answers table_info with its module's declared columns
// and (optionally) their types; notnull / default / pk are 0/empty (the
// safe module interface carries no such info).
if self.is_virtual_table(&table) {
let (_, _, schema) = self.vtab_meta(&table)?;
let rows = schema
.columns
.iter()
.enumerate()
.map(|(i, name)| {
let ty = schema.types.get(i).cloned().unwrap_or_default();
let mut row = alloc::vec![
Value::Integer(i as i64),
Value::Text(name.clone().into()),
Value::Text(ty.into()),
Value::Integer(0),
Value::Null,
Value::Integer(0),
];
if extended {
row.push(Value::Integer(0));
}
row
})
.collect();
return Ok(QueryResult {
columns: table_info_columns(extended),
rows,
});
}
// `table_info` / `table_xinfo` of a non-existent table yields no rows (not
// an error), matching sqlite — both the `PRAGMA` form and the
// `pragma_table_info('x')` table-valued function.
let Some(obj) = sch.table(&table) else {
return Ok(QueryResult {
columns: table_info_columns(extended),
rows: Vec::new(),
});
};
let sql = obj.sql.as_deref().unwrap_or("");
let Statement::CreateTable(ct) = sql::parse_one(sql)? else {
return Err(Error::Corrupt("schema sql is not CREATE TABLE".into()));
};
// The `pk` column is the 1-based position of the column within the
// PRIMARY KEY (0 if not part of it) — so a composite `PRIMARY KEY(b,a)`
// reports b=1, a=2, matching SQLite. A single-column or INTEGER PK is 1.
let pk_positions = primary_key_positions(&ct);
let mut rows = Vec::new();
for (i, col) in ct.columns.iter().enumerate() {
// A generated column's storage kind (`Some(stored)`), or `None`.
let generated = col.constraints.iter().find_map(|c| match c {
ColumnConstraint::Generated { stored, .. } => Some(*stored),
_ => None,
});
// `table_info` hides generated columns; `table_xinfo` includes them
// with a `hidden` flag (2 = virtual, 3 = stored generated; 0 = normal).
if generated.is_some() && !extended {
continue;
}
let hidden = match generated {
None => 0,
Some(false) => 2,
Some(true) => 3,
};
// SQLite reports `notnull` from an explicit `NOT NULL` — and, in a
// WITHOUT ROWID table, every PRIMARY KEY column is *implicitly* NOT
// NULL and shown as notnull=1. (In a rowid table the PK may be NULL,
// even an INTEGER PRIMARY KEY, so those stay notnull=0.)
let notnull = col
.constraints
.iter()
.any(|c| matches!(c, ColumnConstraint::NotNull(_)))
|| (ct.without_rowid && pk_positions.contains(&i));
// `dflt_value` is the SQL text of the default expression (SQLite
// preserves the literal as written — e.g. a string keeps its quotes,
// `DEFAULT NULL` shows `NULL`), so reprint rather than evaluate it.
let dflt = col.constraints.iter().find_map(|c| match c {
// SQLite reproduces the default's verbatim source text here (`0x1F`,
// `-1.5e3`, `CURRENT_TIMESTAMP`, `1+1`), captured at parse time; fall
// back to re-printing the expression for a synthetic default.
ColumnConstraint::Default(e, text) => {
Some(text.clone().unwrap_or_else(|| sql::print::expr(e)))
}
_ => None,
});
let pk = pk_positions
.iter()
.position(|&pos| pos == i)
.map_or(0, |n| n as i64 + 1);
let mut row = alloc::vec![
Value::Integer(i as i64),
Value::Text(col.name.clone().into()),
Value::Text(
canonical_type_name(col.type_name.as_deref().unwrap_or_default()).into()
),
Value::Integer(notnull as i64),
dflt.map(|s| Value::Text(s.into())).unwrap_or(Value::Null),
Value::Integer(pk),
];
if extended {
row.push(Value::Integer(hidden));
}
rows.push(row);
}
Ok(QueryResult {
columns: table_info_columns(extended),
rows,
})
}
/// `table_info` for a VIEW: its output columns, each with the declared type
/// SQLite reports — a direct column reference takes its origin column's type
/// (an untyped origin shows `BLOB`), and any other expression shows an empty
/// type. notnull/dflt/pk are always 0/NULL/0.
fn view_table_info(
&self,
cv: &CreateView,
view_name: &str,
extended: bool,
) -> Result<QueryResult> {
// (name, declared type) per output column. Prefer the static resolver;
// fall back to running the view for names (with empty types) when the
// body is too complex to resolve column origins statically.
let mut cols: NamedColumns = match self.resolved_view_columns(&cv.select) {
Some(c) => c,
None => self
.view_columns(view_name, &Params::default())?
.into_iter()
.map(|c| (c.name, None))
.collect(),
};
// An explicit `CREATE VIEW v(x, y)` column list overrides the names.
if !cv.columns.is_empty() && cv.columns.len() == cols.len() {
for (slot, name) in cols.iter_mut().zip(&cv.columns) {
slot.0 = name.clone();
}
}
let rows = cols
.into_iter()
.enumerate()
.map(|(i, (name, ty))| {
let mut row = alloc::vec![
Value::Integer(i as i64),
Value::Text(name.into()),
Value::Text(ty.unwrap_or_default().into()),
Value::Integer(0),
Value::Null,
Value::Integer(0),
];
if extended {
row.push(Value::Integer(0));
}
row
})
.collect();
Ok(QueryResult {
columns: table_info_columns(extended),
rows,
})
}
/// Resolve a SELECT's output columns to `(name, declared-type)` pairs for
/// `view_table_info`, recursing through subqueries and views. Returns `None`
/// when a source cannot be resolved statically (a table-valued function, or a
/// wildcard over a NATURAL/USING join whose column coalescing isn't modelled),
/// so the caller can fall back to names-only.
fn resolved_view_columns(&self, select: &Select) -> Option<NamedColumns> {
// Resolve each FROM source to its labelled (name, type) columns.
let mut sources: Vec<(String, NamedColumns)> = Vec::new();
if let Some(fc) = &select.from {
let mut refs = alloc::vec![&fc.first];
let mut coalesced = false;
for j in &fc.joins {
refs.push(&j.table);
if j.natural || !j.using.is_empty() {
coalesced = true;
}
}
let has_wild = select
.columns
.iter()
.any(|c| matches!(c, ResultColumn::Wildcard | ResultColumn::TableWildcard(_)));
if coalesced && has_wild {
return None; // `*` over coalesced columns — don't guess.
}
for tref in refs {
let label = tref.alias.clone().unwrap_or_else(|| tref.name.clone());
sources.push((label, self.source_columns_of(tref)?));
}
}
let lookup = |table: Option<&str>, col: &str| -> Option<String> {
for (label, cols) in &sources {
if table.is_some_and(|t| !t.eq_ignore_ascii_case(label)) {
continue;
}
if let Some((_, ty)) = cols.iter().find(|(n, _)| n.eq_ignore_ascii_case(col)) {
return ty.clone();
}
}
None
};
let mut out = Vec::new();
for rc in &select.columns {
match rc {
ResultColumn::Wildcard => {
for (_, cols) in &sources {
out.extend(cols.iter().cloned());
}
}
ResultColumn::TableWildcard(t) => {
let (_, cols) = sources.iter().find(|(l, _)| l.eq_ignore_ascii_case(t))?;
out.extend(cols.iter().cloned());
}
ResultColumn::Expr {
expr,
alias,
source,
} => {
let name = result_column_label(expr, alias, source);
// Only a bare column reference carries a type through.
let ty = match expr {
Expr::Column { table, column, .. } => lookup(table.as_deref(), column),
_ => None,
};
out.push((name, ty));
}
}
}
Some(out)
}
/// The `(name, declared-type)` columns a FROM source contributes. A base
/// table's untyped columns report `BLOB` (as SQLite does for a view); views
/// and subqueries recurse; TVFs return `None` (unresolved).
fn source_columns_of(&self, tref: &TableRef) -> Option<NamedColumns> {
if tref.tvf_args.is_some() {
return None;
}
if let Some(sub) = &tref.subquery {
return self.resolved_view_columns(sub);
}
// A named source: a view recurses; otherwise a base table's columns.
if let Some(o) = self.schema.objects().iter().find(|o| {
o.obj_type == crate::schema::ObjectType::View && o.name.eq_ignore_ascii_case(&tref.name)
}) {
if let Some(Ok(Statement::CreateView(cv))) = o.sql.as_deref().map(sql::parse_one) {
return self.resolved_view_columns(&cv.select);
}
return None;
}
let obj = self.schema.table(&tref.name)?;
let Ok(Statement::CreateTable(ct)) = sql::parse_one(obj.sql.as_deref()?) else {
return None;
};
Some(
ct.columns
.iter()
.map(|c| {
// A direct reference to an untyped column shows `BLOB`.
let ty = c.type_name.clone().unwrap_or_else(|| String::from("BLOB"));
(c.name.clone(), Some(ty))
})
.collect(),
)
}
/// For each base-table / view source in `from` (its first table and every
/// joined table), the source's label (alias or name) paired with its column
/// names. Used to resolve *unqualified* columns in a comma join's `WHERE`
/// equality so it can be promoted to a join `ON` (see
/// [`promote_comma_join_ons`]). Sources whose columns cannot be resolved
/// (a TVF, or an unresolvable subquery) are simply omitted — an unqualified
/// column owned only by such a source then stays unresolved and declines.
fn comma_join_table_columns(&self, from: &FromClause) -> Vec<(String, Vec<String>)> {
let mut out = Vec::new();
for tref in core::iter::once(&from.first).chain(from.joins.iter().map(|j| &j.table)) {
if let Some(cols) = self.source_columns_of(tref) {
let label = tref.alias.clone().unwrap_or_else(|| tref.name.clone());
out.push((label, cols.into_iter().map(|(n, _)| n).collect()));
}
}
out
}
/// The single name argument of a `PRAGMA foo(name)` / `PRAGMA foo = name`,
/// or `None` for the bare argumentless form. SQLite coerces a numeric
/// argument to its text form (so `PRAGMA index_info(1)` looks up an object
/// literally named "1", which simply does not exist); a non-name argument
/// likewise names nothing.
fn pragma_arg_name(p: &Pragma) -> Option<String> {
match &p.value {
None => None,
Some(Expr::Column { column, .. }) => Some(column.clone()),
Some(Expr::Literal(Literal::Str(s))) => Some(s.clone()),
Some(Expr::Literal(Literal::Integer(n))) => Some(n.to_string()),
Some(_) => Some(String::new()),
}
}
/// `PRAGMA index_list(table)` → `(seq, name, unique, origin, partial)`, newest
/// index first (as SQLite lists them).
fn pragma_index_list(&self, p: &Pragma) -> Result<QueryResult> {
let sch = self.pragma_db_schema(p)?;
// A bare / non-name argument names no table → empty result (SQLite parity).
let table = Self::pragma_arg_name(p).unwrap_or_default();
let objs: Vec<_> = sch.indexes_on(&table).collect();
// To label an automatic index's origin `pk` vs `u`, find the PRIMARY KEY's
// column set. An INTEGER PRIMARY KEY is the rowid (no auto-index), so only
// a non-integer / composite PK yields a `pk`-origin auto-index. The set
// matches one of `collect_unique_sets`, which mirrors SQLite's auto-index
// numbering.
let pk_set: Vec<usize> = sch
.table(&table)
.and_then(|o| o.sql.as_deref())
.and_then(|sql| sql::parse_one(sql).ok())
.and_then(|st| match st {
Statement::CreateTable(ct) => {
let ipk = find_integer_primary_key(&ct);
let pk = primary_key_positions(&ct);
// A single integer-PK column is the rowid, not an auto-index;
// a table with no PK has no `pk`-origin auto-index either.
if pk.is_empty() || (pk.len() == 1 && Some(pk[0]) == ipk) {
None
} else {
Some(pk)
}
}
_ => None,
})
.unwrap_or_default();
let tmeta = self.table_meta_in(sch, &table, None).ok();
let mut rows = Vec::new();
for obj in objs.iter().rev() {
let (unique, origin, partial) = match &obj.sql {
Some(sql) => match sql::parse_one(sql) {
Ok(Statement::CreateIndex(ci)) => {
(ci.unique as i64, "c", ci.where_clause.is_some() as i64)
}
_ => (0, "c", 0),
},
None => {
// Automatic index: `pk` when its column set is the PRIMARY
// KEY's, otherwise a plain UNIQUE (`u`).
let cols = autoindex_number(&obj.name, &table)
.and_then(|n| tmeta.as_ref().and_then(|m| m.unique.get(n - 1)))
.map(|s| s.0.clone())
.unwrap_or_default();
let origin = if !pk_set.is_empty() && cols == pk_set {
"pk"
} else {
"u"
};
(1, origin, 0)
}
};
rows.push(alloc::vec![
Value::Integer(rows.len() as i64),
Value::Text(obj.name.clone().into()),
Value::Integer(unique),
Value::Text(origin.into()),
Value::Integer(partial),
]);
}
// A WITHOUT ROWID table's PRIMARY KEY is the table b-tree itself; SQLite
// still reports it as `sqlite_autoindex_<t>_1` (origin `pk`) — and, being
// auto-index #1 (the oldest), it comes *last* in this newest-first list.
// graphite keeps no separate index object for it, so synthesize the row.
if tmeta.as_ref().is_some_and(|m| m.without_rowid) && !pk_set.is_empty() {
rows.push(alloc::vec![
Value::Integer(rows.len() as i64),
Value::Text(alloc::format!("sqlite_autoindex_{table}_1").into()),
Value::Integer(1),
Value::Text("pk".into()),
Value::Integer(0),
]);
}
Ok(QueryResult {
columns: ["seq", "name", "unique", "origin", "partial"]
.iter()
.map(|s| String::from(*s))
.collect(),
rows,
})
}
/// `PRAGMA index_info(index)` → `(seqno, cid, name)` for each indexed column.
fn pragma_index_info(&self, p: &Pragma, extended: bool) -> Result<QueryResult> {
// A bare / non-name argument names no index → empty result (SQLite parity).
let index = Self::pragma_arg_name(p).unwrap_or_default();
let columns: Vec<String> = if extended {
["seqno", "cid", "name", "desc", "coll", "key"]
.iter()
.map(|s| String::from(*s))
.collect()
} else {
["seqno", "cid", "name"]
.iter()
.map(|s| String::from(*s))
.collect()
};
let sch = self.pragma_db_schema(p)?;
// SQLite reports an unknown index name as an empty result, not an error.
let Some(obj) = sch.index(&index) else {
// A WITHOUT ROWID table's PRIMARY KEY is the table b-tree itself,
// reported as `sqlite_autoindex_<t>_1` with no separate index object; its
// columns are the PK (key) columns, plus — for xinfo — the remaining
// table columns as trailing auxiliary (non-key) columns.
if let Some(result) = self.wr_pk_autoindex_info(sch, &index, extended, &columns)? {
return Ok(result);
}
return Ok(QueryResult {
columns,
rows: Vec::new(),
});
};
let tmeta = self.table_meta_in(sch, &obj.tbl_name, None)?;
// Per key column: (cid, name, descending, collation). A bare column takes
// its position + name; an EXPRESSION column is `cid = -2` with a NULL name,
// as SQLite reports (its collation defaults to BINARY unless COLLATE-d).
type Key = (i64, Option<String>, bool, crate::value::Collation);
let keys: Vec<Key> = match &obj.sql {
Some(sql) => match sql::parse_one(sql)? {
Statement::CreateIndex(ci) => ci
.columns
.iter()
.map(|term| {
let (inner, explicit) = match &term.expr {
Expr::Collate { expr, collation } => (
expr.as_ref(),
crate::value::resolve_collation_name(collation),
),
e => (e, None),
};
match inner {
Expr::Column { column, .. } => {
match tmeta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column))
{
Some(p) => (
p as i64,
Some(tmeta.columns[p].name.clone()),
term.descending,
explicit.unwrap_or(tmeta.columns[p].collation),
),
None => {
(-2, None, term.descending, explicit.unwrap_or_default())
}
}
}
_ => (-2, None, term.descending, explicit.unwrap_or_default()),
}
})
.collect(),
_ => Vec::new(),
},
None => autoindex_number(&obj.name, &obj.tbl_name)
.and_then(|n| tmeta.unique.get(n - 1))
.map(|s| s.0.clone())
.unwrap_or_default()
.into_iter()
.map(|cid| {
(
cid as i64,
Some(tmeta.columns[cid].name.clone()),
false,
tmeta.columns[cid].collation,
)
})
.collect(),
};
let coll_name = crate::value::collation_name;
let mut rows = Vec::new();
for (seqno, (cid, name, desc, coll)) in keys.iter().enumerate() {
let name_val = name.clone().map_or(Value::Null, |s| Value::Text(s.into()));
if extended {
rows.push(alloc::vec![
Value::Integer(seqno as i64),
Value::Integer(*cid),
name_val,
Value::Integer(*desc as i64),
Value::Text(coll_name(*coll).into()),
Value::Integer(1), // key column
]);
} else {
rows.push(alloc::vec![
Value::Integer(seqno as i64),
Value::Integer(*cid),
name_val
]);
}
}
// index_xinfo appends the index's implicit trailing auxiliary (non-key)
// columns: the rowid for an ordinary table, or the PRIMARY KEY columns (in
// key order, those not already index keys) for a WITHOUT ROWID table.
if extended {
if tmeta.without_rowid {
// A PK column already among the key columns is only a duplicate —
// and thus dropped from the trailing auxiliary list — when the
// collations also match (SQLite's `isDupColumn`). A PK column that
// overlaps a key column under a *different* collation is appended.
let mut seqno = keys.len();
for &pcid in &tmeta.storage_order[..tmeta.pk_len] {
let pk_coll = tmeta.columns[pcid].collation;
if keys
.iter()
.any(|(cid, _, _, coll)| *cid == pcid as i64 && *coll == pk_coll)
{
continue;
}
rows.push(alloc::vec![
Value::Integer(seqno as i64),
Value::Integer(pcid as i64),
Value::Text(tmeta.columns[pcid].name.clone().into()),
Value::Integer(0),
Value::Text(coll_name(tmeta.columns[pcid].collation).into()),
Value::Integer(0), // auxiliary, non-key
]);
seqno += 1;
}
} else {
rows.push(alloc::vec![
Value::Integer(keys.len() as i64),
Value::Integer(-1),
Value::Null,
Value::Integer(0),
Value::Text("BINARY".into()),
Value::Integer(0),
]);
}
}
Ok(QueryResult { columns, rows })
}
/// Synthesize `PRAGMA index_info` / `index_xinfo` for a `WITHOUT ROWID` table's
/// implicit PRIMARY KEY index (`sqlite_autoindex_<t>_1`), which has no separate
/// schema object because the table b-tree *is* that index. The key columns are
/// the PRIMARY KEY columns (in key order, honouring each `DESC`); for xinfo the
/// remaining table columns follow as trailing auxiliary (non-key) columns.
/// Returns `None` when `index` is not such an auto-index.
fn wr_pk_autoindex_info(
&self,
sch: &Schema,
index: &str,
extended: bool,
columns: &[String],
) -> Result<Option<QueryResult>> {
let Some(obj) = sch.objects().iter().find(|o| {
o.obj_type == crate::schema::ObjectType::Table
&& index.eq_ignore_ascii_case(&alloc::format!("sqlite_autoindex_{}_1", o.name))
}) else {
return Ok(None);
};
let m = self.table_meta_in(sch, &obj.name, None)?;
if !m.without_rowid || m.pk_len == 0 {
return Ok(None);
}
let coll_name = crate::value::collation_name;
let mut rows = Vec::new();
for (seqno, &cid) in m.storage_order[..m.pk_len].iter().enumerate() {
let desc = m.pk_descending.get(seqno).copied().unwrap_or(false);
if extended {
rows.push(alloc::vec![
Value::Integer(seqno as i64),
Value::Integer(cid as i64),
Value::Text(m.columns[cid].name.clone().into()),
Value::Integer(desc as i64),
Value::Text(coll_name(m.columns[cid].collation).into()),
Value::Integer(1), // key column
]);
} else {
rows.push(alloc::vec![
Value::Integer(seqno as i64),
Value::Integer(cid as i64),
Value::Text(m.columns[cid].name.clone().into()),
]);
}
}
// xinfo appends the non-PK columns as trailing auxiliary (non-key) columns.
if extended {
for (k, &cid) in m.storage_order[m.pk_len..].iter().enumerate() {
rows.push(alloc::vec![
Value::Integer((m.pk_len + k) as i64),
Value::Integer(cid as i64),
Value::Text(m.columns[cid].name.clone().into()),
Value::Integer(0),
Value::Text(coll_name(m.columns[cid].collation).into()),
Value::Integer(0), // auxiliary, non-key
]);
}
}
Ok(Some(QueryResult {
columns: columns.to_vec(),
rows,
}))
}
/// `PRAGMA foreign_key_list(table)` →
/// `(id, seq, table, from, to, on_update, on_delete, match)`.
fn pragma_foreign_key_list(&self, p: &Pragma) -> Result<QueryResult> {
let sch = self.pragma_db_schema(p)?;
// A bare / non-name argument names no table → empty result (SQLite parity).
let table = Self::pragma_arg_name(p).unwrap_or_default();
let columns: Vec<String> = [
"id",
"seq",
"table",
"from",
"to",
"on_update",
"on_delete",
"match",
]
.iter()
.map(|s| String::from(*s))
.collect();
// An unknown table — like a virtual table — yields an empty list, not an
// error, matching SQLite.
let Some(obj) = sch.table(&table) else {
return Ok(QueryResult {
columns,
rows: Vec::new(),
});
};
let Statement::CreateTable(ct) = sql::parse_one(obj.sql.as_deref().unwrap_or(""))? else {
// A virtual table (non-CREATE-TABLE schema) has no foreign keys.
return Ok(QueryResult {
columns,
rows: Vec::new(),
});
};
let action = |a: FkAction| -> &'static str {
match a {
FkAction::NoAction => "NO ACTION",
FkAction::Restrict => "RESTRICT",
FkAction::Cascade => "CASCADE",
FkAction::SetNull => "SET NULL",
FkAction::SetDefault => "SET DEFAULT",
}
};
// Collect (from-cols, fk) pairs from column-level and table-level FKs.
let mut fks: Vec<(Vec<String>, &ForeignKey)> = Vec::new();
for col in &ct.columns {
for c in &col.constraints {
if let ColumnConstraint::References(fk) = c {
fks.push((alloc::vec![col.name.clone()], fk));
}
}
}
for c in &ct.constraints {
if let TableConstraint::ForeignKey(fk) = c {
fks.push((fk.columns.clone(), fk));
}
}
let mut rows = Vec::new();
// SQLite numbers foreign keys from the last declared (id 0) backward, and
// lists them by id ascending — so iterate in reverse declaration order.
let n = fks.len();
for (i, (from_cols, fk)) in fks.iter().enumerate().rev() {
let id = (n - 1 - i) as i64;
for (seq, from) in from_cols.iter().enumerate() {
let to = fk.ref_columns.get(seq).cloned().unwrap_or_default();
rows.push(alloc::vec![
Value::Integer(id),
Value::Integer(seq as i64),
Value::Text(fk.ref_table.clone().into()),
Value::Text(from.clone().into()),
if to.is_empty() {
Value::Null
} else {
Value::Text(to.into())
},
Value::Text(action(fk.on_update).into()),
Value::Text(action(fk.on_delete).into()),
Value::Text("NONE".into()),
]);
}
}
Ok(QueryResult {
columns: [
"id",
"seq",
"table",
"from",
"to",
"on_update",
"on_delete",
"match",
]
.iter()
.map(|s| String::from(*s))
.collect(),
rows,
})
}
/// `PRAGMA foreign_key_check[(table)]` → one `(table, rowid, parent, fkid)`
/// row per child row that references a missing parent key. `fkid` matches the
/// `id` reported by `foreign_key_list`.
fn pragma_foreign_key_check(&self, p: &Pragma) -> Result<QueryResult> {
use crate::schema::ObjectType;
let tables: Vec<String> = match &p.value {
Some(_) => alloc::vec![Self::pragma_arg_name(p).unwrap_or_default()],
None => self
.schema
.objects()
.iter()
.filter(|o| o.obj_type == ObjectType::Table && !o.name.starts_with("sqlite_"))
.map(|o| o.name.clone())
.collect(),
};
let mut rows = Vec::new();
for table in &tables {
let meta = self.table_meta(table, None)?;
if meta.without_rowid {
continue; // rowid-less FK reporting not modeled yet
}
let fks = self.foreign_keys_of(table)?;
if fks.is_empty() {
continue;
}
// A structurally malformed FK aborts the whole check with a "foreign
// key mismatch", regardless of how many child rows exist.
for fk in &fks {
if self.fk_is_mismatch(fk)? {
return Err(Self::fk_mismatch_err(table, &fk.ref_table));
}
}
let n = fks.len();
for (rowid, values) in self.scan_table(&meta)? {
for (i, fk) in fks.iter().enumerate() {
let Some(key) = self.child_key_values(&meta, fk, &values) else {
continue; // a NULL key column => satisfied
};
if !self.parent_has_key(fk, &key)? {
rows.push(alloc::vec![
Value::Text(table.clone().into()),
Value::Integer(rowid),
Value::Text(fk.ref_table.clone().into()),
Value::Integer((n - 1 - i) as i64),
]);
}
}
}
}
Ok(QueryResult {
columns: ["table", "rowid", "parent", "fkid"]
.iter()
.map(|s| String::from(*s))
.collect(),
rows,
})
}
/// `PRAGMA integrity_check` / `quick_check`: whole-file page accounting
/// (every page reachable exactly once — see [`integrity::PageAccounting`]),
/// a structural walk of every b-tree, and a verification that each index
/// holds exactly the entries its table implies (honoring partial-index
/// predicates). Returns the single value `ok` when the database is
/// consistent, else one row per detected problem — capped at
/// `PRAGMA integrity_check(N)`'s limit (default 100, like sqlite's
/// `SQLITE_INTEGRITY_CHECK_ERROR_MAX`).
fn pragma_integrity_check(&self, p: &Pragma) -> Result<QueryResult> {
use crate::schema::ObjectType;
let single = |v: Value| QueryResult {
columns: alloc::vec![String::from("integrity_check")],
rows: alloc::vec![alloc::vec![v]],
};
// `PRAGMA integrity_check(N)` caps the report at N messages; a
// non-positive or non-integer argument keeps sqlite's default of 100.
let mut max_err: usize = 100;
if let Some(e) = &p.value
&& let Ok(v) = eval::eval(e, &EvalCtx::rowless(&Params::default()))
&& let Value::Integer(n) = v
&& n > 0
{
max_err = n as usize;
}
let tables: Vec<String> = self
.schema
.objects()
.iter()
// Skip virtual tables: they have no b-tree of their own (a persistent
// module's rows live in its `<name>_data` backing table, itself an
// ordinary table that is checked here).
.filter(|o| {
o.obj_type == ObjectType::Table
&& !o.name.starts_with("sqlite_")
&& !matches!(
o.sql.as_deref().map(sql::parse_one),
Some(Ok(Statement::CreateVirtualTable(_)))
)
})
.map(|o| o.name.clone())
.collect();
let mut problems = Vec::new();
let src = self.backend.source();
// Whole-file page accounting, the port of `sqlite3BtreeIntegrityCheck`'s
// aPgRef protocol: walk the freelist and every b-tree root — page 1 (the
// sqlite_schema tree) plus every object in the catalog, including the
// sqlite_* internal tables the logical checks below skip — through one
// shared reference bitmap, then sweep for pages never reached. This is
// what catches cross-tree damage: a page claimed by two trees, a live
// page also on the freelist, or an orphaned (leaked) page.
if src.page_count() > 0 {
let mut acct = integrity::PageAccounting::new(src, max_err);
let mut roots: Vec<(u32, String)> = alloc::vec![(1, String::from("sqlite_schema"))];
for o in self.schema.objects() {
if o.rootpage > 0 {
roots.push((o.rootpage, o.name.clone()));
}
}
acct.check_freelist(&mut problems);
acct.check_rootpage_header(roots.iter().map(|r| r.0).max().unwrap_or(0), &mut problems);
for (root, label) in &roots {
acct.check_tree(*root, label, &mut problems);
}
acct.check_never_used(&mut problems);
}
for table in &tables {
if problems.len() >= max_err {
break;
}
let meta = self.table_meta(table, None)?;
// The rows that physically exist, and how many each index should hold.
// (The structural walk of each b-tree already happened in the
// accounting pass above.) A tree too corrupt to scan is skipped —
// the accounting pass already reported its structural damage, and
// sqlite likewise keeps reporting what it found rather than abort.
let rows_scanned = if meta.without_rowid {
self.scan_without_rowid(&meta)
} else {
self.scan_table(&meta)
.map(|rs| rs.into_iter().map(|(_, v)| v).collect())
};
let rows: Vec<Vec<Value>> = match rows_scanned {
Ok(rows) => rows,
Err(_) => continue,
};
let no_params = Params::default();
for idx in self.indexes_of(table)? {
let expected = rows
.iter()
.filter_map(|r| self.row_in_index(&idx, &meta, r, None, &no_params).ok())
.filter(|&keep| keep)
.count();
// Count the index b-tree's entries (an unreadable index tree was
// already reported by the accounting pass; skip its count).
let mut cur = crate::btree::IndexCursor::new(self.backend.source(), idx.root);
let mut got = 0usize;
let mut unreadable = false;
loop {
match cur.next() {
Ok(Some(_)) => got += 1,
Ok(None) => break,
Err(_) => {
unreadable = true;
break;
}
}
}
if !unreadable && got != expected {
problems.push(alloc::format!("wrong # of entries in index {}", idx.name));
}
}
}
// FTS5 self-content tables: verify the inverted index still matches the
// documents in `%_content` (sqlite's `xIntegrity` → "malformed inverted
// index for FTS5 table …"). A no-op without the fts5 feature.
#[cfg(feature = "fts5")]
self.fts5_integrity_check(&mut problems)?;
// Honor the max-error cap for the logical checks above too (the
// accounting pass already stopped appending at the limit).
problems.truncate(max_err);
if problems.is_empty() {
Ok(single(Value::Text("ok".into())))
} else {
Ok(QueryResult {
columns: alloc::vec![String::from("integrity_check")],
rows: problems
.into_iter()
.map(|p| alloc::vec![Value::Text(p.into())])
.collect(),
})
}
}
/// The FTS5 arm of [`pragma_integrity_check`]: for every SELF-CONTENT `fts5`
/// virtual table, re-tokenize its `%_content` documents into the expected
/// `(term, rowid, per-column positions)` multiset and diff it against the
/// multiset DECODED from the on-disk inverted index. A mismatch — a stale or
/// wrong index that no longer matches the documents — or a structurally
/// impossible index (the structure record referencing an absent leaf page)
/// pushes `malformed inverted index for FTS5 table main.<name>`, matching
/// sqlite's `fts5IntegrityMethod`.
///
/// Deliberately CONSERVATIVE (zero false positives): index shapes the read-only
/// decoder cannot resolve with certainty — tombstone/update history, a term
/// spanning onto a doclist-index page, an external-content or contentless table
/// (no local documents to re-derive from) — are SKIPPED rather than reported.
/// See [`crate::fts5_index::scan_main_index`].
#[cfg(feature = "fts5")]
fn fts5_integrity_check(&self, problems: &mut Vec<String>) -> Result<()> {
use crate::fts5_index::{self, MainIndexScan, Posting};
use crate::schema::ObjectType;
use alloc::collections::BTreeMap;
// Canonicalize `(term -> postings)` into a multiset keyed by
// `(term, rowid) -> {non-empty column -> ascending positions}`. Dropping
// empty columns makes the index decode (which omits empty trailing columns)
// and the re-tokenization (which pads every posting to `ncols`) compare
// equal on the columns that actually carry the term.
//
// The value retained depends on the `detail=` mode, mirroring what the
// segment actually records: `full` keeps per-column positions; `columns`
// keeps only which columns carry the term (positions cleared on BOTH the
// index and content sides so they compare equal); `none` records neither
// column nor position, so the value is dropped to an empty map — the check
// then compares just the `(term, rowid)` set, which is all a detail=none
// segment stores.
fn canonical(
terms: &[(Vec<u8>, Vec<Posting>)],
detail: crate::fts5_index::Fts5Detail,
) -> BTreeMap<(Vec<u8>, i64), BTreeMap<usize, Vec<u32>>> {
use crate::fts5_index::Fts5Detail;
let mut m: BTreeMap<(Vec<u8>, i64), BTreeMap<usize, Vec<u32>>> = BTreeMap::new();
for (term, postings) in terms {
for p in postings {
let mut cols: BTreeMap<usize, Vec<u32>> = BTreeMap::new();
if detail != Fts5Detail::None {
for (c, positions) in p.cols.iter().enumerate() {
if !positions.is_empty() {
let kept = if detail == Fts5Detail::Columns {
Vec::new() // column presence only, positions cleared
} else {
positions.clone()
};
cols.insert(c, kept);
}
}
}
m.insert((term.clone(), p.rowid), cols);
}
}
m
}
let names: Vec<String> = self
.schema
.objects()
.iter()
.filter(|o| o.obj_type == ObjectType::Table)
.filter_map(|o| match o.sql.as_deref().map(sql::parse_one) {
Some(Ok(Statement::CreateVirtualTable(cvt)))
if cvt.module.eq_ignore_ascii_case("fts5") =>
{
Some(o.name.clone())
}
_ => None,
})
.collect();
for name in &names {
let (module, args, schema) = match self.vtab_meta(name) {
Ok(v) => v,
Err(_) => continue, // module not registered / connect failed: not ours
};
if !module.eq_ignore_ascii_case("fts5") {
continue;
}
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
// Only self-content tables carry a local `%_content` copy to re-derive
// the expected index from. External-content / contentless: skip.
if crate::vtab::fts5_external_content(&arg_refs).is_some()
|| crate::vtab::fts5_is_contentless(&arg_refs)
{
continue;
}
let data: Vec<(i64, Vec<u8>)> = match self.query(&format!(
"SELECT id, block FROM {}",
sql::print::ident(&format!("{name}_data"))
)) {
Ok(qr) => qr
.rows
.into_iter()
.filter_map(|r| {
let mut it = r.into_iter();
let id = eval::to_i64(&it.next()?);
let blk = match it.next() {
Some(Value::Blob(b)) => b,
Some(Value::Null) => Vec::new(),
_ => return None,
};
Some((id, blk))
})
.collect(),
Err(_) => continue, // no `%_data` backing table: nothing to check
};
let detail = crate::vtab::fts5_detail(&arg_refs);
match fts5_index::scan_main_index(&data, detail) {
MainIndexScan::Skip => continue,
MainIndexScan::Malformed => {
problems.push(format!(
"malformed inverted index for FTS5 table main.{name}"
));
}
MainIndexScan::Clean(index_terms) => {
let ncols = schema.columns.len();
let tok = crate::vtab::fts5_tok_config(&arg_refs);
let docs = match self.fts5_load_documents(name, &schema.columns, &arg_refs) {
Ok(d) => d,
Err(_) => continue,
};
let (content_terms, _totals, _sizes) =
self.fts5_tokenize_docs(&docs, ncols, tok);
if canonical(&index_terms, detail) != canonical(&content_terms, detail) {
problems.push(format!(
"malformed inverted index for FTS5 table main.{name}"
));
}
}
}
}
Ok(())
}
/// Execute a single non-`SELECT` statement, returning the number of rows
/// affected (0 for DDL and transaction control).
pub fn execute(&mut self, sql: &str) -> Result<usize> {
self.execute_params(sql, &Params::default())
}
/// Register a virtual-table [`module`](crate::vtab::VTabModule) under `name`,
/// the identifier used after `USING` in `CREATE VIRTUAL TABLE … USING <name>`.
/// A module implementing [`VTabModule::update`](crate::vtab::VTabModule::update)
/// makes its tables writable; the default leaves them read-only. Fails if a
/// module is already registered under that name (case-insensitively).
pub fn register_module(
&mut self,
name: &str,
module: impl DynVTabModule + 'static,
) -> Result<()> {
self.vtab_registry.register(name, Box::new(module))
}
/// Register a user-defined scalar function callable from SQL by `name`. `f`
/// receives the evaluated argument values and returns a result [`Value`]. A
/// built-in function of the same name takes precedence; registering an existing
/// user function replaces it. The callback should validate its own argument
/// count and types (returning an error otherwise), like SQLite's
/// `sqlite3_create_function` callbacks.
pub fn register_function(
&mut self,
name: &str,
f: impl Fn(&[Value]) -> Result<Value> + 'static,
) {
self.functions
.insert(name.to_ascii_lowercase(), Box::new(f));
}
/// Register a user-defined aggregate function callable from SQL by `name`.
/// `factory` builds a fresh [`AggregateFunction`] accumulator for each group;
/// the engine calls `step` once per group row (with the evaluated arguments)
/// then `finalize`. Built-in aggregates of the same name take precedence.
pub fn register_aggregate_function(
&mut self,
name: &str,
factory: impl Fn() -> Box<dyn AggregateFunction> + 'static,
) {
self.aggregates
.insert(name.to_ascii_lowercase(), Box::new(factory));
}
/// Register (or replace) a custom collating sequence callable as
/// `COLLATE <name>` in SQL — the equivalent of `sqlite3_create_collation`.
/// `cmp` compares two text values. Requires `std`.
///
/// The registry is process-global (shared across connections), so a name
/// registered here resolves in any connection; re-registering a name replaces
/// its function. A database whose schema declares a column/index
/// `COLLATE <name>` needs `<name>` registered before that schema is used.
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
pub fn register_collation<F>(&mut self, name: &str, cmp: F)
where
F: Fn(&str, &str) -> core::cmp::Ordering + Send + 'static,
{
crate::value::register_collation(name, cmp);
}
/// Register a data-change notification callback — the equivalent of
/// `sqlite3_update_hook`. `hook` is called once per row inserted, updated, or
/// deleted by a subsequent statement, with the operation, the schema name
/// (currently always `"main"`), the table name, and the rowid. Registering a
/// new hook replaces any previous one.
///
/// The callback must not modify the database (SQLite's rule); to keep this
/// safe, the hook is temporarily removed while it runs, so a change it
/// nonetheless triggers is not reported recursively.
pub fn register_update_hook<F>(&self, hook: F)
where
F: FnMut(UpdateOp, &str, &str, i64) + 'static,
{
*self.update_hook.borrow_mut() = Some(Box::new(hook));
}
/// Remove any callback set by [`register_update_hook`](Self::register_update_hook).
pub fn remove_update_hook(&self) {
*self.update_hook.borrow_mut() = None;
}
/// Register a commit callback — the equivalent of `sqlite3_commit_hook`. The
/// callback is invoked just before each transaction commits (an explicit
/// `COMMIT`, an autocommit write statement, or the finalizing release of an
/// implicit transaction's outermost savepoint). If it returns a non-zero
/// value the commit is converted into a rollback (and the rollback hook, if
/// any, fires). Registering a new hook replaces any previous one.
///
/// The callback must not modify the database (SQLite's rule); it is
/// temporarily removed while it runs, so a change it nonetheless attempts is
/// not reported to it recursively.
pub fn register_commit_hook<F>(&self, hook: F)
where
F: FnMut() -> i32 + 'static,
{
*self.commit_hook.borrow_mut() = Some(Box::new(hook));
}
/// Remove any callback set by [`register_commit_hook`](Self::register_commit_hook).
pub fn remove_commit_hook(&self) {
*self.commit_hook.borrow_mut() = None;
}
/// Register a rollback callback — the equivalent of `sqlite3_rollback_hook`.
/// The callback is invoked whenever a transaction rolls back (an explicit
/// `ROLLBACK`, or a commit vetoed by the commit hook). Registering a new hook
/// replaces any previous one. As with the other hooks it is removed while it
/// runs so it cannot recurse.
pub fn register_rollback_hook<F>(&self, hook: F)
where
F: FnMut() + 'static,
{
*self.rollback_hook.borrow_mut() = Some(Box::new(hook));
}
/// Remove any callback set by [`register_rollback_hook`](Self::register_rollback_hook).
pub fn remove_rollback_hook(&self) {
*self.rollback_hook.borrow_mut() = None;
}
/// Fire the commit hook (if any), returning `true` when it vetoed the commit
/// (returned non-zero). The hook is detached while it runs so it cannot
/// recurse into itself. A no-op returning `false` when no hook is set.
fn fire_commit_hook(&self) -> bool {
let taken = self.commit_hook.borrow_mut().take();
if let Some(mut hook) = taken {
let veto = hook() != 0;
// Restore unless the hook replaced itself while running.
let mut slot = self.commit_hook.borrow_mut();
if slot.is_none() {
*slot = Some(hook);
}
veto
} else {
false
}
}
/// Fire the rollback hook (if any). Detached while it runs so it cannot
/// recurse.
fn fire_rollback_hook(&self) {
let taken = self.rollback_hook.borrow_mut().take();
if let Some(mut hook) = taken {
hook();
let mut slot = self.rollback_hook.borrow_mut();
if slot.is_none() {
*slot = Some(hook);
}
}
}
/// Register an authorizer callback — the equivalent of
/// `sqlite3_set_authorizer`. While preparing each statement the callback is
/// consulted with an [action code](auth_action) and up to two action-specific
/// string arguments (e.g. a table and column, or a pragma name and value),
/// plus the database name and the triggering trigger name (currently always
/// `Some("main")` and `None`). Returning `SQLITE_DENY` (1) rejects the whole
/// statement with an authorization error; `SQLITE_OK` (0) allows it.
/// `SQLITE_IGNORE` (2) is treated like `SQLITE_DENY` for the statement-level
/// actions authorized here.
///
/// graphitesql authorizes the *statement-level* action of each statement
/// (`SELECT` / `INSERT` / `UPDATE` / `DELETE`, the `CREATE`/`DROP` family,
/// `ALTER`, `TRANSACTION`, `SAVEPOINT`, `PRAGMA`, `ATTACH`/`DETACH`,
/// `ANALYZE`, `REINDEX`) with its primary object name, plus a `READ` action
/// naming the table of a single-table `SELECT`. Per-column `READ` granularity
/// (and the `FUNCTION` code) is not modeled — enough to build a read-only or
/// per-table/operation sandbox, which is the common use.
///
/// The callback is detached while it runs so it cannot recurse.
pub fn set_authorizer<F>(&self, cb: F)
where
F: FnMut(i32, Option<&str>, Option<&str>, Option<&str>, Option<&str>) -> i32 + 'static,
{
*self.authorizer.borrow_mut() = Some(Box::new(cb));
}
/// Remove any callback set by [`set_authorizer`](Self::set_authorizer).
pub fn clear_authorizer(&self) {
*self.authorizer.borrow_mut() = None;
}
/// Consult the authorizer for one action; `Err` (not authorized) when it
/// returns a non-zero code (`DENY`/`IGNORE`). A no-op returning `Ok` when no
/// authorizer is set. Detaches the callback while it runs so it cannot recurse.
fn authorize(&self, action: i32, arg1: Option<&str>, arg2: Option<&str>) -> Result<()> {
let taken = self.authorizer.borrow_mut().take();
let Some(mut cb) = taken else { return Ok(()) };
let rc = cb(action, arg1, arg2, Some("main"), None);
let mut slot = self.authorizer.borrow_mut();
if slot.is_none() {
*slot = Some(cb);
}
drop(slot);
if rc == AuthResult::Ok as i32 {
Ok(())
} else {
Err(Error::Error("not authorized".into()))
}
}
/// Whether an authorizer is currently registered (a cheap pre-check so the
/// classification walk is skipped entirely on the common no-authorizer path).
fn has_authorizer(&self) -> bool {
self.authorizer.borrow().is_some()
}
/// Authorize a parsed statement before it runs, firing the authorizer for its
/// statement-level action(s). Returns the authorization error on a denial.
fn run_authorizer(&self, stmt: &Statement) -> Result<()> {
use auth_action as a;
if !self.has_authorizer() {
return Ok(());
}
match stmt {
Statement::Select(sel) => {
self.authorize(a::SELECT, None, None)?;
// A single-table SELECT also emits a `READ` for the table it reads
// (arg2 left empty — per-column granularity for joins/subqueries is
// not modeled), so a sandbox can deny reads of a specific table.
if let Some(from) = &sel.from
&& from.joins.is_empty()
&& from.first.subquery.is_none()
&& from.first.tvf_args.is_none()
&& !from.first.name.is_empty()
{
self.authorize(a::READ, Some(&from.first.name), Some(""))?;
}
}
Statement::Insert(ins) => self.authorize(a::INSERT, Some(&ins.table), None)?,
Statement::Delete(del) => self.authorize(a::DELETE, Some(&del.table), None)?,
Statement::Update(upd) => {
for (col, _) in &upd.assignments {
self.authorize(a::UPDATE, Some(&upd.table), Some(col))?;
}
}
Statement::CreateTable(ct) => self.authorize(a::CREATE_TABLE, Some(&ct.name), None)?,
Statement::CreateIndex(ci) => {
self.authorize(a::CREATE_INDEX, Some(&ci.name), Some(&ci.table))?
}
Statement::CreateView(cv) => self.authorize(a::CREATE_VIEW, Some(&cv.name), None)?,
Statement::CreateTrigger(ctr) => {
self.authorize(a::CREATE_TRIGGER, Some(&ctr.name), Some(&ctr.table))?
}
Statement::CreateVirtualTable(cvt) => {
self.authorize(a::CREATE_VTABLE, Some(&cvt.name), None)?
}
Statement::Drop(d) => {
let code = match d.kind {
sql::ast::DropKind::Table => a::DROP_TABLE,
sql::ast::DropKind::Index => a::DROP_INDEX,
sql::ast::DropKind::View => a::DROP_VIEW,
sql::ast::DropKind::Trigger => a::DROP_TRIGGER,
};
self.authorize(code, Some(&d.name), None)?;
}
Statement::Alter(al) => self.authorize(a::ALTER_TABLE, Some(&al.table), None)?,
Statement::Pragma(p) => self.authorize(a::PRAGMA, Some(&p.name), None)?,
Statement::Begin => self.authorize(a::TRANSACTION, Some("BEGIN"), None)?,
Statement::Commit => self.authorize(a::TRANSACTION, Some("COMMIT"), None)?,
Statement::Rollback => self.authorize(a::TRANSACTION, Some("ROLLBACK"), None)?,
Statement::Savepoint(name) => {
self.authorize(a::SAVEPOINT, Some("BEGIN"), Some(name))?
}
Statement::Release(name) => {
self.authorize(a::SAVEPOINT, Some("RELEASE"), Some(name))?
}
Statement::RollbackTo(name) => {
self.authorize(a::SAVEPOINT, Some("ROLLBACK"), Some(name))?
}
Statement::Attach { .. } => self.authorize(a::ATTACH, None, None)?,
Statement::Detach(name) => self.authorize(a::DETACH, Some(name), None)?,
Statement::Analyze(_) => self.authorize(a::ANALYZE, None, None)?,
Statement::Reindex { .. } => self.authorize(a::REINDEX, None, None)?,
// EXPLAIN / VACUUM and any other statement are not separately authorized.
_ => {}
}
Ok(())
}
/// Fire the update hook (if any) for one row change. Takes the callback out
/// for the duration of the call so a reentrant change does not double-borrow
/// or recurse; restores it afterward unless the callback replaced it.
fn fire_update_hook(&self, op: UpdateOp, table: &str, rowid: i64) {
let taken = self.update_hook.borrow_mut().take();
if let Some(mut cb) = taken {
cb(op, "main", table, rowid);
let mut slot = self.update_hook.borrow_mut();
if slot.is_none() {
*slot = Some(cb);
}
}
}
/// Execute a `;`-separated script of one or more statements, like SQLite's
/// `sqlite3_exec`. Each statement runs in order through the normal
/// single-statement path (so per-statement `CREATE` text is preserved and
/// each autocommits unless the script opens its own transaction); execution
/// stops at the first error. `;` inside string literals, `--`/`/* */`
/// comments, and `BEGIN…END` / `CASE…END` blocks does not split a statement.
/// A `SELECT` runs and its rows are discarded (as `sqlite3_exec` does without
/// a callback). [`execute`](Self::execute) stays single-statement.
pub fn execute_batch(&mut self, sql: &str) -> Result<()> {
for stmt in split_sql_script(sql) {
if matches!(sql::parse_one(stmt), Ok(Statement::Select(_))) {
self.query(stmt)?;
} else {
self.execute_params(stmt, &Params::default())?;
}
}
Ok(())
}
/// Like [`execute`](Self::execute) but with bound parameters.
pub fn execute_params(&mut self, sql: &str, params: &Params) -> Result<usize> {
let r = self.execute_params_inner(sql, params);
// Record the on-disk change counter this connection's own statement left
// behind, so `PRAGMA data_version` does not mistake our own writes for a
// foreign modification (sqlite's `SQLITE_FCNTL_DATA_VERSION` only tracks
// *other* connections' commits). A read-only/in-memory backend has a
// stable counter, so this is a cheap no-op there.
let cc = self.backend.source().header().change_counter;
self.dv_seen_cc.set(Some(cc));
r
}
fn execute_params_inner(&mut self, sql: &str, params: &Params) -> Result<usize> {
let stmt = sql::parse_one(sql)?;
self.run_authorizer(&stmt)?;
// Statement boundary: like the read path (`query_params`), drop any read
// cache a foreign commit has made stale and refresh the durable page
// bound before this statement touches pages — SQLite re-checks the file
// version on every transaction start, reads and writes alike
// (`pagerSharedLock`). A no-op mid-transaction (the write lock owns
// coherency then).
self.revalidate_read_caches();
// Transaction control is handled directly (no autocommit around it).
match &stmt {
Statement::Begin => {
if self.in_tx {
return Err(Error::Error(
"cannot start a transaction within a transaction".into(),
));
}
self.in_tx = true;
return Ok(0);
}
Statement::Commit => {
if !self.in_tx && self.open_savepoints == 0 {
return Err(Error::Error(
"cannot commit - no transaction is active".into(),
));
}
// Deferred foreign keys are verified here. On violation the
// transaction stays open (SQLite leaves it active so the caller
// can repair the data and COMMIT again) — nothing is committed.
self.check_deferred_fks()?;
// Flush the transaction's accumulated fts5 postings as ONE segment
// per table (SQLite's commit-time `xSync`/`xCommit`), part of this
// same durable transaction. A no-op when no fts5 table was written.
#[cfg(feature = "fts5")]
self.fts5_flush_txn(true)?;
// The commit hook fires just before committing a *write*
// transaction; a non-zero return converts the COMMIT to a
// ROLLBACK (SQLite's `sqlite3_commit_hook` semantics).
if self.backend.writer()?.resident_dirty_pages() > 0 && self.fire_commit_hook() {
self.backend.writer()?.rollback();
self.rollback_attached()?;
self.in_tx = false;
self.open_savepoints = 0;
self.schema = Schema::read(self.backend.source())?;
self.fire_rollback_hook();
return Ok(0);
}
self.backend.writer()?.commit()?;
// Cross-database transaction: commit the temp + attached
// databases alongside main (a clean pager commit is a no-op).
self.commit_attached()?;
self.in_tx = false;
self.open_savepoints = 0;
return Ok(0);
}
Statement::Savepoint(name) => {
// SQLite's fts5 `xSavepoint` flushes the pending in-memory postings
// to disk *before* the savepoint opens, so each pre-savepoint batch
// becomes its own level-0 segment and a later `ROLLBACK TO` (which
// reverts only writes made after this point) leaves it intact. Do
// the same for insert-only tables; the appended segment is written
// before the savepoint marker, so it survives a rollback to it.
#[cfg(feature = "fts5")]
self.fts5_flush_txn(false)?;
self.backend.writer()?.savepoint(name);
self.savepoint_attached(name)?;
self.open_savepoints += 1;
return Ok(0);
}
Statement::Release(name) => {
self.backend.writer()?.release_savepoint(name)?;
self.release_attached(name)?;
self.open_savepoints = self.backend.writer()?.savepoint_depth();
// Releasing the outermost savepoint of an implicit transaction
// finalizes it — verify deferred foreign keys first, then fire the
// commit hook (a veto converts the finalizing commit to a rollback).
if self.open_savepoints == 0 && !self.in_tx {
self.check_deferred_fks()?;
// Releasing the outermost savepoint finalizes the implicit
// transaction — flush the accumulated fts5 postings first.
#[cfg(feature = "fts5")]
self.fts5_flush_txn(true)?;
if self.backend.writer()?.resident_dirty_pages() > 0 && self.fire_commit_hook()
{
self.backend.writer()?.rollback();
self.rollback_attached()?;
self.schema = Schema::read(self.backend.source())?;
self.fire_rollback_hook();
} else {
self.backend.writer()?.commit()?;
self.commit_attached()?;
self.schema = Schema::read(self.backend.source())?;
}
}
return Ok(0);
}
Statement::RollbackTo(name) => {
self.backend.writer()?.rollback_to_savepoint(name)?;
self.rollback_to_attached(name)?;
self.open_savepoints = self.backend.writer()?.savepoint_depth();
// Discard the fts5 pending postings made since the last flush; the
// pager reverts any on-disk segments written after this savepoint.
#[cfg(feature = "fts5")]
self.fts5_rollback_to_txn();
// The schema may have reverted to the savepoint's state.
self.schema = Schema::read(self.backend.source())?;
return Ok(0);
}
Statement::Rollback => {
if !self.in_tx && self.open_savepoints == 0 {
return Err(Error::Error(
"cannot rollback - no transaction is active".into(),
));
}
self.backend.writer()?.rollback();
// Cross-database transaction: roll back the temp + attached
// databases too, discarding their staged changes.
self.rollback_attached()?;
self.in_tx = false;
self.open_savepoints = 0;
// Nothing was written to the fts5 index during the transaction, so
// there is nothing to undo — just drop the pending flush set.
#[cfg(feature = "fts5")]
self.fts5_discard_txn();
self.schema = Schema::read(self.backend.source())?;
// The rollback hook fires whenever a transaction is rolled back.
self.fire_rollback_hook();
return Ok(0);
}
_ => {}
}
// A DDL/DML statement targeting a non-main database (`… aux.t`,
// `CREATE TEMP …`, or an unqualified name that a temp table shadows) runs
// against that database: a single write touches exactly one database, so
// we make it the active `main` for the duration (swapping back
// afterwards, even on error). Cross-database *joins* are handled
// separately in the read path.
let target = self.target_db(&stmt)?;
if target == DbRef::Temp {
self.ensure_temp()?;
}
// A known schema qualifier on the statement's target (`UPDATE main.nope`,
// `DROP VIEW aux.gone`) must survive into a missing-object error — the
// deep lookup only knows the bare name. Capture it before `stmt` moves.
let missing_qual: Option<(String, String)> = match &stmt {
Statement::Insert(s) => s.schema.clone().map(|q| (q, s.table.clone())),
Statement::Update(s) => s.schema.clone().map(|q| (q, s.table.clone())),
Statement::Delete(s) => s.schema.clone().map(|q| (q, s.table.clone())),
Statement::Alter(a) => a.schema.clone().map(|q| (q, a.table.clone())),
Statement::Drop(s) => s.schema.clone().map(|q| (q, s.name.clone())),
_ => None,
};
// Record the target's real database for the duration of the write, so a
// three-part column qualifier in its WHERE/SET is validated against the
// right name even after the target is swapped into the active `main` slot.
let prev_write = self.write_target.replace(target);
let r = match target {
DbRef::Main => self.exec_parsed(stmt, sql, params),
other => {
// `INSERT INTO <non-main>.t SELECT … FROM s`: the SELECT's
// unqualified names resolve in the normal (main-first) order, not
// the target database. Materialize the source rows here, before
// swapping to the target — but only if they resolve in this
// (original) context; otherwise leave it unchanged so the swapped
// path still handles a source that lives in the target db.
let stmt = self.prematerialize_insert_source(stmt, params);
self.swap_db(other);
// Mark the swap live so `resolve_db` inverts the swapped pair for a
// schema-qualified reference in the write's WHERE/SET (its
// subqueries). Set only now — not during the prematerialize above,
// which runs unswapped.
let prev_swap = self.swap_active.replace(Some(other));
let r = self.exec_parsed(stmt, sql, params);
self.swap_active.set(prev_swap);
self.swap_db(other);
r
}
};
self.write_target.set(prev_write);
match missing_qual {
Some((q, name)) => r.map_err(|e| Self::qualify_missing(Some(&q), &name, e)),
None => r,
}
}
/// For an `INSERT` whose source reads the original database (a `SELECT`, or a
/// `VALUES` row with a subquery), evaluate it in the current (pre-swap) context
/// and replace the source with literal rows, so a later swap to the target
/// database does not re-resolve those table names there — matching SQLite's
/// main-first resolution for a cross-database `INSERT INTO aux.t SELECT … FROM
/// main_table` (or `… VALUES ((SELECT … FROM main_table))`). If it does not
/// resolve here — e.g. the source lives only in the target db — the statement
/// is returned unchanged so the swapped-context path handles it; the read is
/// side-effect-free, so the discarded attempt is safe. A plain literal `VALUES`
/// is left untouched (it needs no resolution).
fn prematerialize_insert_source(&self, stmt: Statement, params: &Params) -> Statement {
if let Statement::Insert(mut ins) = stmt {
// A leading `WITH` must be in scope while the source resolves in this
// (pre-swap) context — the CTE body reads the original database, so it
// has to be materialized here, not after the swap to the target.
let base = self.cte_env.borrow().len();
let pushed = if ins.ctes.is_empty() {
true
} else {
let seeds = insert_cte_seeds(&ins);
self.push_ctes(&ins.ctes, params, None, Some(&seeds))
.is_ok()
};
if !pushed {
self.cte_env.borrow_mut().truncate(base);
return Statement::Insert(ins);
}
match &ins.source {
InsertSource::Select(sel) => {
if let Ok(result) = self.run_select(sel, params) {
let rows: Vec<Vec<Expr>> = result
.rows
.into_iter()
.map(|row| row.into_iter().map(value_to_literal_expr).collect())
.collect();
ins.source = InsertSource::Values(rows);
}
}
// A `VALUES` row with a subquery (`VALUES ((SELECT … FROM m))`):
// evaluate every expression here so the subquery resolves
// main-first. Untouched when none has a subquery (plain literals).
InsertSource::Values(rows) if rows.iter().flatten().any(expr_has_subquery) => {
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let mut out = Vec::with_capacity(rows.len());
let materialized = rows.iter().try_for_each(|row| {
let mut r = Vec::with_capacity(row.len());
for e in row {
r.push(value_to_literal_expr(eval::eval(e, &ctx)?));
}
out.push(r);
Ok::<(), Error>(())
});
if materialized.is_ok() {
ins.source = InsertSource::Values(out);
}
}
_ => {}
}
return Statement::Insert(ins);
}
stmt
}
/// The database a DDL/DML statement targets: an explicit `schema.` qualifier
/// (including `CREATE TEMP …` → `Temp`), else — for DML/`DROP` — the temp
/// database when it shadows the unqualified name, else `main`.
fn target_db(&self, stmt: &Statement) -> Result<DbRef> {
let resolved = |s: Option<&str>, name: &str| -> Result<DbRef> {
match s {
Some(_) => self.resolve_db(s),
None => Ok(self.unqualified_db(name)),
}
};
match stmt {
// CREATE never temp-shadows: a bare `CREATE TABLE t` goes to main.
Statement::CreateTable(s) => self.resolve_db(s.schema.as_deref()),
Statement::Insert(s) => {
self.resolve_db_or_missing(s.schema.as_deref(), &s.table, "table")
}
Statement::Update(s) => {
self.resolve_db_or_missing(s.schema.as_deref(), &s.table, "table")
}
Statement::Delete(s) => {
self.resolve_db_or_missing(s.schema.as_deref(), &s.table, "table")
}
Statement::Drop(s) => {
let noun = match s.kind {
DropKind::Table => "table",
DropKind::Index => "index",
DropKind::View => "view",
DropKind::Trigger => "trigger",
};
self.resolve_db_or_missing(s.schema.as_deref(), &s.name, noun)
}
Statement::Alter(a) => {
self.resolve_db_or_missing(a.schema.as_deref(), &a.table, "table")
}
// The index lives in the schema named on the index (or, unqualified,
// wherever its table lives — so a temp table's index goes to temp).
Statement::CreateIndex(ci) => resolved(ci.schema.as_deref(), &ci.table),
// A view lives in the schema named on it (`CREATE TEMP VIEW` → temp);
// an unqualified `CREATE VIEW` stays in main.
Statement::CreateView(cv) => self.resolve_db(cv.schema.as_deref()),
// A trigger lives in the schema named on it (or, unqualified,
// wherever the table it fires on lives).
Statement::CreateTrigger(ct) => resolved(ct.schema.as_deref(), &ct.table),
// A virtual table lives in the schema named on it; bare → main.
Statement::CreateVirtualTable(cvt) => self.resolve_db(cvt.schema.as_deref()),
_ => Ok(DbRef::Main),
}
}
/// Commit pending changes in the temp + attached databases, refreshing each
/// catalog from its committed image. Part of a cross-database transaction
/// commit; a clean pager commit is a no-op.
fn commit_attached(&mut self) -> Result<()> {
if let Some(t) = &mut self.temp_db {
t.backend.writer()?.commit()?;
t.schema = Schema::read(t.backend.source())?;
}
for d in &mut self.attached {
d.backend.writer()?.commit()?;
d.schema = Schema::read(d.backend.source())?;
}
Ok(())
}
/// Roll back staged changes in the temp + attached databases and reload each
/// catalog. Part of a cross-database transaction rollback.
fn rollback_attached(&mut self) -> Result<()> {
if let Some(t) = &mut self.temp_db {
t.backend.writer()?.rollback();
t.schema = Schema::read(t.backend.source())?;
}
for d in &mut self.attached {
d.backend.writer()?.rollback();
d.schema = Schema::read(d.backend.source())?;
}
Ok(())
}
/// Open a savepoint in the temp + attached databases too, so a later
/// `ROLLBACK TO`/`RELEASE` reaches their staged changes.
fn savepoint_attached(&mut self, name: &str) -> Result<()> {
if let Some(t) = &mut self.temp_db {
t.backend.writer()?.savepoint(name);
}
for d in &mut self.attached {
d.backend.writer()?.savepoint(name);
}
Ok(())
}
/// Release a savepoint in the temp + attached databases. A database attached
/// after the savepoint was opened has no such savepoint; that is not an error
/// here (it simply had nothing staged at that point).
fn release_attached(&mut self, name: &str) -> Result<()> {
if let Some(t) = &mut self.temp_db {
let _ = t.backend.writer()?.release_savepoint(name);
}
for d in &mut self.attached {
let _ = d.backend.writer()?.release_savepoint(name);
}
Ok(())
}
/// Roll the temp + attached databases back to a savepoint, reloading the
/// catalog of each that actually had it (see [`release_attached`]).
fn rollback_to_attached(&mut self, name: &str) -> Result<()> {
if let Some(t) = &mut self.temp_db {
let did = t.backend.writer()?.rollback_to_savepoint(name).is_ok();
if did {
t.schema = Schema::read(t.backend.source())?;
}
}
for d in &mut self.attached {
let did = d.backend.writer()?.rollback_to_savepoint(name).is_ok();
if did {
d.schema = Schema::read(d.backend.source())?;
}
}
Ok(())
}
/// Make `db` the active `main` (or swap it back) by exchanging the backend
/// and schema. Used around a write to a non-main database.
fn swap_db(&mut self, db: DbRef) {
match db {
DbRef::Main => {}
DbRef::Temp => {
let t = self.temp_db.as_mut().expect("temp db exists");
core::mem::swap(&mut self.backend, &mut t.backend);
core::mem::swap(&mut self.schema, &mut t.schema);
}
DbRef::Attached(i) => self.swap_attached(i),
}
}
fn swap_attached(&mut self, i: usize) {
core::mem::swap(&mut self.backend, &mut self.attached[i].backend);
core::mem::swap(&mut self.schema, &mut self.attached[i].schema);
}
/// Execute a parsed non-transaction-control statement on the active database.
fn exec_parsed(&mut self, stmt: Statement, sql: &str, params: &Params) -> Result<usize> {
// `PRAGMA query_only = ON` makes the connection read-only: any statement
// that would open a write transaction (DML, every CREATE/DROP/ALTER,
// VACUUM, ANALYZE) fails here before it runs, while reads, PRAGMAs, and
// read-only transaction control pass through. This is the single write
// chokepoint — DML reaches `run_dml_atomic` from below, and both the
// main-target and swapped (temp/attached) paths call `exec_parsed`.
if self.query_only && statement_writes_db(&stmt) {
return Err(Error::Error("attempt to write a readonly database".into()));
}
// `changes()`/`total_changes()` track only INSERT/UPDATE/DELETE.
let is_dml = matches!(
stmt,
Statement::Insert(_) | Statement::Update(_) | Statement::Delete(_)
);
// Writes to an `auto_vacuum` database are now supported: the write-side
// pager maintains the pointer-map pages on commit (see
// `WritePager::rebuild_ptrmap`), so the C6a guard that used to refuse
// such writes has been lifted. auto_vacuum=NONE databases take the
// unchanged plain write path.
// An INSERT/UPDATE/DELETE is atomic: if it fails partway (a constraint
// violation, a trigger `RAISE(ABORT)`, …) the rows it already changed are
// undone, leaving the database as if the statement never ran — unless the
// failing conflict policy was `OR FAIL`, which keeps the partial change.
// We realise this with an internal savepoint snapshotting the writer
// overlay(s) before the statement and rolling back to it on an
// abort-class error. (A no-op for DDL, which doesn't set `is_dml`.)
if is_dml {
self.stmt_keep_partial.set(false);
self.stmt_rollback_tx.set(false);
return self.run_dml_atomic(stmt, params);
}
// A user-created object may not borrow the reserved `sqlite_` prefix.
match &stmt {
Statement::CreateTable(ct) => reject_reserved_name(&ct.name)?,
Statement::CreateIndex(ci) => reject_reserved_name(&ci.name)?,
Statement::CreateView(cv) => reject_reserved_name(&cv.name)?,
Statement::CreateTrigger(ct) => reject_reserved_name(&ct.name)?,
Statement::CreateVirtualTable(cvt) => reject_reserved_name(&cvt.name)?,
Statement::Alter(a) => {
if let AlterAction::RenameTable(new) = &a.action {
reject_reserved_name(new)?;
}
}
_ => {}
}
let affected = match stmt {
Statement::CreateTable(ct) => {
self.exec_create_table(&ct, ddl_text(sql))?;
0
}
Statement::Insert(_) | Statement::Delete(_) | Statement::Update(_) => unreachable!(),
Statement::CreateIndex(ci) => {
self.exec_create_index(&ci, ddl_text(sql))?;
0
}
Statement::CreateView(cv) => {
self.exec_create_view(&cv, ddl_text(sql))?;
0
}
Statement::CreateTrigger(ct) => {
self.exec_create_trigger(&ct, ddl_text(sql))?;
0
}
Statement::CreateVirtualTable(cvt) => {
self.exec_create_virtual_table(&cvt, ddl_text(sql))?;
0
}
Statement::Drop(d) => {
self.exec_drop(&d)?;
0
}
Statement::Alter(a) => {
self.exec_alter(&a)?;
0
}
Statement::Pragma(p) => {
self.exec_pragma(&p, params)?;
0
}
Statement::Vacuum { schema, into } => {
// A named database must exist (main/temp/an attached schema);
// sqlite errors "unknown database <name>" otherwise. VACUUM itself
// operates on the whole connection regardless of the named schema.
if let Some(name) = schema {
let name = name.as_str();
let known = name.eq_ignore_ascii_case("main")
|| name.eq_ignore_ascii_case("temp")
|| self
.attached
.iter()
.any(|a| a.name.eq_ignore_ascii_case(name));
if !known {
return Err(Error::Error(format!("unknown database {name}")));
}
}
self.exec_vacuum(into.as_deref())?;
0
}
// Indexes are kept current on every write, so REINDEX is a no-op — but
// a named target must identify a collation, table, or index, else
// sqlite errors "unable to identify the object to be reindexed".
Statement::Reindex { schema, name } => {
// A `schema.` qualifier is validated ahead of the object lookup:
// sqlite rejects an unknown database with `unknown database <x>`.
if let Some(db) = &schema {
self.resolve_db(Some(db.as_str()))
.map_err(|_| Error::Error(format!("unknown database {db}")))?;
}
if let Some(name) = name {
let name = name.as_str();
// A bare target may name a collation; a `schema.`-qualified one
// may only be a table or index (a collation is not per-database).
let known = (schema.is_none()
&& crate::value::resolve_collation_name(name).is_some())
|| self.schema.table(name).is_some()
|| self.schema.index(name).is_some();
if !known {
return Err(Error::Error(
"unable to identify the object to be reindexed".into(),
));
}
}
0
}
Statement::Analyze(target) => {
self.exec_analyze(target.as_deref())?;
0
}
Statement::Attach { file, name } => {
self.exec_attach(&file, &name, params)?;
0
}
Statement::Detach(name) => {
self.exec_detach(&name)?;
0
}
Statement::Select(_) => return Err(Error::Unsupported("use query() for SELECT")),
Statement::Explain { .. } => return Err(Error::Unsupported("use query() for EXPLAIN")),
Statement::Begin
| Statement::Commit
| Statement::Rollback
| Statement::Savepoint(_)
| Statement::Release(_)
| Statement::RollbackTo(_) => unreachable!(),
};
if !self.in_tx && self.open_savepoints == 0 {
// Autocommit: this statement is its own transaction. Fire the commit
// hook when it wrote changes; a veto converts the implicit commit into
// a rollback (the statement's changes are discarded).
if self.backend.writer()?.resident_dirty_pages() > 0 && self.fire_commit_hook() {
self.backend.writer()?.rollback();
self.schema = Schema::read(self.backend.source())?;
self.fire_rollback_hook();
} else {
self.backend.writer()?.commit()?;
// Refresh the catalog from the committed image.
self.schema = Schema::read(self.backend.source())?;
}
}
Ok(affected)
}
/// Execute one INSERT/UPDATE/DELETE under an internal savepoint so it is
/// atomic: on an abort-class failure (a constraint violation, a trigger
/// `RAISE(ABORT)`, …) the writer overlay(s) are rolled back to the
/// pre-statement snapshot, so no partial change survives. `OR FAIL` keeps the
/// rows changed before the failure; `OR ROLLBACK` unwinds the whole
/// transaction.
/// Build the constraint error for a conflict under conflict policy `oc`,
/// arming the statement-atomicity flags so `run_dml_atomic` keeps partial
/// changes (`OR FAIL`) or unwinds the transaction (`OR ROLLBACK`).
fn conflict_error(&self, oc: OnConflict, msg: &str) -> Error {
match oc {
OnConflict::Fail => self.stmt_keep_partial.set(true),
OnConflict::Rollback => self.stmt_rollback_tx.set(true),
_ => {}
}
Error::Constraint(String::from(msg))
}
/// Resolve `NOT NULL` violations for an INSERT/UPDATE row under its conflict
/// mode, mutating `values` as needed. For each `NOT NULL` column that is NULL,
/// the effective action is the statement's `OR <action>` (when it wrote one)
/// else the column's declared `ON CONFLICT` action: `REPLACE` substitutes the
/// column's DEFAULT (erroring if there is none, like SQLite), `IGNORE` skips
/// the whole row (returns `Ok(false)`), and `ABORT`/`FAIL`/`ROLLBACK` error
/// with the action's rollback semantics. `Ok(true)` means the row may proceed.
fn resolve_not_null(
&self,
meta: &TableMeta,
values: &mut [Value],
stmt_oc: OnConflict,
stmt_explicit: bool,
params: &Params,
) -> Result<bool> {
for (i, slot) in values.iter_mut().enumerate() {
if !matches!(slot, Value::Null) {
continue;
}
let Some(col_oc) = meta.not_null[i] else {
continue;
};
let oc = if stmt_explicit { stmt_oc } else { col_oc };
let fail = || {
let msg = format!(
"NOT NULL constraint failed: {}.{}",
meta.columns[i].table, meta.columns[i].name
);
self.conflict_error(oc, &msg)
};
match oc {
OnConflict::Ignore => return Ok(false),
OnConflict::Replace => {
// Substitute the column's DEFAULT; a missing or NULL default
// leaves the violation, which then errors.
let v = match &meta.defaults[i] {
Some(e) => eval::eval(e, &EvalCtx::rowless(params)).unwrap_or(Value::Null),
None => Value::Null,
};
if matches!(v, Value::Null) {
return Err(fail());
}
*slot = v;
}
_ => return Err(fail()),
}
}
Ok(true)
}
fn run_dml_atomic(&mut self, stmt: Statement, params: &Params) -> Result<usize> {
const SP: &str = "\u{0}graphite_stmt";
self.backend.writer()?.savepoint(SP);
self.savepoint_attached(SP)?;
let result = match stmt {
Statement::Insert(ins) => self.exec_insert(&ins, params),
Statement::Delete(del) => self.exec_delete(&del, params),
Statement::Update(upd) => self.exec_update(&upd, params),
_ => unreachable!("run_dml_atomic only handles DML"),
};
match result {
Ok(affected) => {
let _ = self.backend.writer()?.release_savepoint(SP);
let _ = self.release_attached(SP);
self.changes.set(affected as i64);
self.total_changes
.set(self.total_changes.get() + affected as i64);
if !self.in_tx && self.open_savepoints == 0 {
// Autocommit write: fire the commit hook (a veto rolls the
// statement's changes back instead of committing).
if self.backend.writer()?.resident_dirty_pages() > 0 && self.fire_commit_hook()
{
self.backend.writer()?.rollback();
self.schema = Schema::read(self.backend.source())?;
self.fire_rollback_hook();
} else {
self.backend.writer()?.commit()?;
self.schema = Schema::read(self.backend.source())?;
}
}
Ok(affected)
}
Err(e) => {
if self.stmt_rollback_tx.get() {
// `OR ROLLBACK`: discard the entire (implicit or explicit)
// transaction's staged changes.
self.backend.writer()?.rollback();
self.rollback_attached()?;
self.in_tx = false;
self.open_savepoints = 0;
self.schema = Schema::read(self.backend.source())?;
} else if self.stmt_keep_partial.get() {
// `OR FAIL`: keep what was changed before the failure.
let _ = self.backend.writer()?.release_savepoint(SP);
let _ = self.release_attached(SP);
if !self.in_tx && self.open_savepoints == 0 {
self.backend.writer()?.commit()?;
self.schema = Schema::read(self.backend.source())?;
}
} else {
// `OR ABORT` (the default): undo just this statement.
let _ = self.backend.writer()?.rollback_to_savepoint(SP);
let _ = self.backend.writer()?.release_savepoint(SP);
let _ = self.rollback_to_attached(SP);
let _ = self.release_attached(SP);
if !self.in_tx && self.open_savepoints == 0 {
// Outside a transaction the rolled-back statement leaves
// nothing to commit; drop any other staged state too.
self.backend.writer()?.rollback();
self.rollback_attached()?;
self.schema = Schema::read(self.backend.source())?;
}
}
Err(e)
}
}
}
/// Execute an `INSERT`/`UPDATE`/`DELETE` with a `RETURNING` clause, returning
/// the projected rows as a [`QueryResult`]. Without a `RETURNING` list the
/// result has no columns and no rows (the statement still runs for its
/// effects). Errors on `SELECT`/DDL — use [`query`](Self::query) or
/// [`execute`](Self::execute) for those.
pub fn execute_returning(&mut self, sql: &str, params: &Params) -> Result<QueryResult> {
let stmt = sql::parse_one(sql)?;
self.run_authorizer(&stmt)?;
let returning: &[ResultColumn] = match &stmt {
Statement::Insert(i) => &i.returning,
Statement::Update(u) => &u.returning,
Statement::Delete(d) => &d.returning,
_ => {
return Err(Error::Unsupported(
"execute_returning expects INSERT/UPDATE/DELETE",
));
}
};
if returning.is_empty() {
self.execute_params(sql, params)?;
return Ok(QueryResult {
columns: Vec::new(),
rows: Vec::new(),
});
}
let table = match &stmt {
Statement::Insert(i) => &i.table,
Statement::Update(u) => &u.table,
Statement::Delete(d) => &d.table,
_ => unreachable!(),
};
let meta = self.table_meta(table, None)?;
let columns = returning_labels(returning, &meta.columns);
self.returning_rows.borrow_mut().clear();
self.execute_params(sql, params)?;
let rows = core::mem::take(&mut *self.returning_rows.borrow_mut());
Ok(QueryResult { columns, rows })
}
/// `ATTACH <expr> AS <name>`: open another database under `name`. An empty
/// or `:memory:` path creates a fresh in-memory database; a real file path is
/// not yet supported (track piece C5).
fn exec_attach(&mut self, file: &Expr, name: &str, params: &Params) -> Result<()> {
let path = {
let ctx = EvalCtx::rowless(params).with_subqueries(self);
eval::to_text(&eval::eval(file, &ctx)?)
};
if name.eq_ignore_ascii_case("main")
|| name.eq_ignore_ascii_case("temp")
|| self
.attached
.iter()
.any(|d| d.name.eq_ignore_ascii_case(name))
{
return Err(Error::Error(alloc::format!(
"database {name} is already in use"
)));
}
let (backend, file) = if path.is_empty() || path.eq_ignore_ascii_case(":memory:") {
// A fresh in-memory database (same pattern as `open_memory`).
let vfs = crate::vfs::memory::MemoryVfs::new();
let f = vfs.open(name, OpenFlags::READ_WRITE_CREATE)?;
let mut db = WritePager::create(f, None, 4096)?;
db.commit()?;
(Backend::Write(Box::new(db)), String::new())
} else {
(self.open_attached_file(&path)?, path)
};
let schema = Schema::read(backend.source())?;
self.attached.push(AttachedDb {
name: name.to_string(),
file,
backend,
schema,
});
Ok(())
}
/// Open (or create, if absent/empty) a real file as an attached database's
/// backend. Requires the `std` file VFS.
#[cfg(feature = "std")]
fn open_attached_file(&self, path: &str) -> Result<Backend> {
let vfs = crate::vfs::std_file::StdVfs::new();
let main = vfs.open(path, OpenFlags::READ_WRITE_CREATE)?;
let journal = vfs.open(&journal_path(path), OpenFlags::READ_WRITE_CREATE)?;
// Rollback-journal (non-WAL) mode: commits land directly in the main
// file, so the attached database is immediately readable by sqlite3
// without needing a WAL checkpoint when the connection closes.
let db = if main.size()? == 0 {
let mut db = WritePager::create(main, Some(journal), 4096)?;
db.commit()?;
db
} else {
WritePager::open(main, Some(journal))?
};
Ok(Backend::Write(Box::new(db)))
}
#[cfg(not(feature = "std"))]
fn open_attached_file(&self, _path: &str) -> Result<Backend> {
Err(Error::Unsupported("ATTACH of a file database requires std"))
}
/// `DETACH <name>`: close an attached database. `main`/`temp` cannot be
/// detached; an unknown name is an error.
fn exec_detach(&mut self, name: &str) -> Result<()> {
if name.eq_ignore_ascii_case("main") || name.eq_ignore_ascii_case("temp") {
return Err(Error::Error(alloc::format!(
"cannot detach database {name}"
)));
}
match self
.attached
.iter()
.position(|d| d.name.eq_ignore_ascii_case(name))
{
Some(i) => {
self.attached.remove(i);
Ok(())
}
None => Err(Error::Error(alloc::format!("no such database: {name}"))),
}
}
/// `VACUUM`: rebuild the database into a fresh, compact image (no free pages,
/// defragmented b-trees) and replace the file. Implemented by replaying the
/// stored `CREATE` statements and re-inserting all rows into a throwaway
/// in-memory database, then copying its pages over. A no-op for read-only
/// backends.
fn exec_vacuum(&mut self, into: Option<&Expr>) -> Result<()> {
use crate::schema::ObjectType;
// In-place VACUUM on a read-only backend is a no-op; `VACUUM … INTO`
// only reads the source, so it proceeds regardless of the backend.
if into.is_none() && !matches!(self.backend, Backend::Write(_)) {
return Ok(());
}
// Flush any WAL frames into the main image first (in-place rewrite only).
if into.is_none() && self.backend.wal_mode() {
self.backend.writer()?.checkpoint()?;
}
let user_version = self.backend.source().header().user_version;
// Snapshot the catalog: (type, name, sql), preserving creation order.
let objs: Vec<(ObjectType, String, Option<String>)> = self
.schema
.objects()
.iter()
.map(|o| (o.obj_type, o.name.clone(), o.sql.clone()))
.collect();
let quote = |n: &str| alloc::format!("\"{}\"", n.replace('"', "\"\""));
// Virtual tables and their `<name>_data` backing tables need special care:
// recreating the `CREATE VIRTUAL TABLE` already creates the backing table,
// so the backing table must not be created (or its rows copied) separately
// — a persistent vtab's rows are repopulated by re-inserting through the
// vtab itself, and a computed (non-persistent) vtab has no rows to copy.
let is_vtab = |sql: &Option<String>| {
matches!(
sql.as_deref().map(sql::parse_one),
Some(Ok(Statement::CreateVirtualTable(_)))
)
};
let vtab_names: alloc::collections::BTreeSet<String> = objs
.iter()
.filter(|(ty, _, sql)| *ty == ObjectType::Table && is_vtab(sql))
.map(|(_, n, _)| n.clone())
.collect();
let table_names: alloc::collections::BTreeSet<String> = objs
.iter()
.filter(|(ty, _, _)| *ty == ObjectType::Table)
.map(|(_, n, _)| n.clone())
.collect();
let is_backing = |name: &str| {
[
"_data", "_node", "_rowid", "_parent", "_content", "_docsize", "_config", "_idx",
"_gpost",
]
.iter()
.any(|sfx| {
name.strip_suffix(sfx)
.is_some_and(|p| vtab_names.contains(p))
})
};
// A vtab is persistent (has rows to copy through it) iff a backing table
// exists — the generic `_data` or an R-Tree's `_node`.
let persistent_vtab = |name: &str| {
vtab_names.contains(name)
&& (table_names.contains(&alloc::format!("{name}_data"))
|| table_names.contains(&alloc::format!("{name}_node")))
};
// Build a compact copy in a throwaway in-memory database.
let mut tmp = Connection::open_memory()?;
// 1. Tables (this also recreates their automatic indexes). Skip a vtab's
// backing table — its `CREATE VIRTUAL TABLE` recreates it.
for (ty, name, sql) in &objs {
if *ty == ObjectType::Table
&& !is_backing(name)
&& let Some(s) = sql
{
tmp.execute(s)?;
}
}
// 2. Explicit secondary indexes (auto-indexes have no SQL).
for (ty, _, sql) in &objs {
if *ty == ObjectType::Index
&& let Some(s) = sql
{
tmp.execute(s)?;
}
}
// 3. Re-insert every table's rows (before triggers exist, so none fire).
// Skip a vtab's backing table (repopulated through the vtab) and a
// computed vtab (no rows); a persistent vtab is copied via the vtab,
// whose INSERTs rewrite the backing table.
for (ty, name, _) in &objs {
if *ty != ObjectType::Table
|| is_backing(name)
|| (vtab_names.contains(name) && !persistent_vtab(name))
{
continue;
}
let result = self.query(&alloc::format!("SELECT * FROM {}", quote(name)))?;
let ncols = result.columns.len();
if ncols == 0 {
continue;
}
let placeholders = (1..=ncols)
.map(|i| alloc::format!("?{i}"))
.collect::<Vec<_>>()
.join(",");
let stmt = alloc::format!("INSERT INTO {} VALUES ({placeholders})", quote(name));
for row in result.rows {
let params = Params {
positional: row,
named: Vec::new(),
};
tmp.execute_params(&stmt, ¶ms)?;
}
}
// 4. Views, then 5. triggers (last, so loading data didn't fire them).
for (ty, _, sql) in &objs {
if *ty == ObjectType::View
&& let Some(s) = sql
{
tmp.execute(s)?;
}
}
for (ty, _, sql) in &objs {
if *ty == ObjectType::Trigger
&& let Some(s) = sql
{
tmp.execute(s)?;
}
}
// Snapshot the compact image's pages.
let count = tmp.backend.source().page_count();
let mut image = Vec::with_capacity(count as usize);
for n in 1..=count {
image.push(tmp.backend.source().page(n)?.data().to_vec());
}
// `VACUUM … INTO <file>`: write the image to a new database file.
if let Some(expr) = into {
return self.vacuum_write_into(expr, image);
}
// Plain `VACUUM`: copy the compact image's pages over the current file.
self.backend.writer()?.replace_image(image)?;
// Preserve user_version across the rebuild.
if user_version != 0 {
self.backend.writer()?.header_mut().user_version = user_version;
// Re-stamp page 1 via a commit.
let mut page1 = self.backend.writer()?.read_page(1)?;
self.backend.writer()?.header().write_to(&mut page1)?;
self.backend.writer()?.write_page(1, page1)?;
self.backend.writer()?.commit()?;
}
self.schema = Schema::read(self.backend.source())?;
Ok(())
}
/// Write a freshly-built compact page `image` to a NEW database file for
/// `VACUUM … INTO <file>`. The target path comes from evaluating `expr`; it
/// must not already exist (matching SQLite). `std`-only — creating a file
/// needs the OS VFS.
#[cfg(feature = "std")]
fn vacuum_write_into(&self, expr: &Expr, image: Vec<Vec<u8>>) -> Result<()> {
let params = Params::default();
let path = match eval::eval(expr, &EvalCtx::rowless(¶ms))? {
Value::Null => return Err(Error::Error("VACUUM INTO target is NULL".into())),
Value::Text(s) => String::from(s.as_str()),
other => eval::to_text(&other),
};
// SQLite requires the target to be empty: an existing *non-empty* file is
// rejected (it opens it as a database — a valid one is `output file already
// exists`, anything else `file is not a database`), but an existing *empty*
// (0-byte) file is written into. Both messages carry no path.
match std::fs::metadata(&path) {
Ok(meta) if meta.len() > 0 => {
let mut hdr = [0u8; 16];
let looks_like_db = std::fs::File::open(&path)
.and_then(|mut f| std::io::Read::read_exact(&mut f, &mut hdr).map(|()| hdr))
.is_ok_and(|h| &h == b"SQLite format 3\0");
return Err(Error::Error(if looks_like_db {
"output file already exists".into()
} else {
"file is not a database".into()
}));
}
// An existing empty file: remove it so the fresh create starts clean.
Ok(_) => {
let _ = std::fs::remove_file(&path);
}
Err(_) => {} // does not exist — the normal path
}
let user_version = self.backend.source().header().user_version;
let mut dst = Connection::create(&path)?;
dst.backend.writer()?.replace_image(image)?;
if user_version != 0 {
dst.backend.writer()?.header_mut().user_version = user_version;
}
// Stamp page 1 (the header, incl. user_version) and flush to disk.
let mut page1 = dst.backend.writer()?.read_page(1)?;
dst.backend.writer()?.header().write_to(&mut page1)?;
dst.backend.writer()?.write_page(1, page1)?;
dst.backend.writer()?.commit()?;
Ok(())
}
/// Without `std` there is no file VFS to create the target, so `VACUUM …
/// INTO` is unsupported (the in-place form still works).
#[cfg(not(feature = "std"))]
fn vacuum_write_into(&self, _expr: &Expr, _image: Vec<Vec<u8>>) -> Result<()> {
Err(Error::Error(
"VACUUM INTO requires the std feature (file I/O)".into(),
))
}
/// `ANALYZE`: gather index selectivity statistics into the `sqlite_stat1`
/// table. The `stat` string for an index is `nRow avgEq1 avgEq2 …`, where
/// `avgEqK = (nRow + dK/2) / dK` and `dK` is the number of distinct values of
/// the index's leftmost `K` columns — the same integers SQLite records. A
/// table with no index gets a single `(tbl, NULL, nRow)` row.
fn exec_analyze(&mut self, target: Option<&str>) -> Result<()> {
use crate::schema::ObjectType;
// Which user tables to (re)analyze.
let analyze: Vec<String> = match target {
None => self
.schema
.objects()
.iter()
.filter(|o| o.obj_type == ObjectType::Table && !o.name.starts_with("sqlite_"))
.map(|o| o.name.clone())
.collect(),
Some(name) => {
if let Some(t) = self.schema.table(name) {
alloc::vec![t.name.clone()]
} else if let Some(ix) = self.schema.index(name) {
alloc::vec![ix.tbl_name.clone()]
} else if name.eq_ignore_ascii_case("main") {
// `ANALYZE <database>` analyzes that schema; for `main` that is
// every main user table (the no-argument form's behavior).
self.schema
.objects()
.iter()
.filter(|o| {
o.obj_type == ObjectType::Table && !o.name.starts_with("sqlite_")
})
.map(|o| o.name.clone())
.collect()
} else if is_main_schema_table(name)
|| name.eq_ignore_ascii_case("temp")
|| self
.attached
.iter()
.any(|a| a.name.eq_ignore_ascii_case(name))
{
// A valid schema table or attached/temp database: graphite keeps
// stats only for main, so this is a no-op — but not an error,
// matching sqlite (which only errors on a genuinely unknown name).
Vec::new()
} else {
return Err(Error::Error(format!("no such table: {name}")));
}
}
};
// Compute the new stat rows up front (read-only phase).
let mut new_rows: Vec<(String, Option<String>, String)> = Vec::new();
// Accumulated `sqlite_stat4` rows: (tbl, idx, neq, nlt, ndlt, sample-bytes).
let mut stat4_rows: Vec<(String, String, String, String, String, Vec<u8>)> = Vec::new();
for tname in &analyze {
let meta = self.table_meta(tname, None)?;
// Keep rowids for rowid tables — the STAT4 `sample` records reference the
// rowid, and the sample columns include it as the trailing entry.
let (rows, rowids): (Vec<Vec<Value>>, Vec<i64>) = if meta.without_rowid {
(self.scan_without_rowid(&meta)?, Vec::new())
} else {
let (rid, vals): (Vec<i64>, Vec<Vec<Value>>) =
self.scan_table(&meta)?.into_iter().unzip();
(vals, rid)
};
let n = rows.len();
let indexes = self.indexes_of(tname)?;
// A local helper to push one index's STAT4 samples.
let mut push_stat4 = |idx_name: &str, samples: Vec<crate::exec::stat4::Stat4Sample>| {
for s in samples {
stat4_rows.push((
tname.clone(),
idx_name.to_string(),
crate::exec::stat4::Stat4Sample::stat_string(&s.neq),
crate::exec::stat4::Stat4Sample::stat_string(&s.nlt),
crate::exec::stat4::Stat4Sample::stat_string(&s.ndlt),
s.sample,
));
}
};
if indexes.is_empty() && !meta.without_rowid {
if n > 0 {
new_rows.push((tname.clone(), None, alloc::format!("{n}")));
}
} else if n > 0 {
for idx in &indexes {
let stat = index_stat_string(&idx.cols, &idx.collations, &rows);
new_rows.push((tname.clone(), Some(idx.name.clone()), stat));
// STAT4 samples for this index. Expression indexes are skipped
// (SQLite records their column values via a different path).
if idx.key_exprs.is_none() {
let samples = if meta.without_rowid {
self.stat4_for_wr_index(&meta, idx, &rows)
} else {
self.stat4_for_rowid_index(idx, &rows, &rowids)
};
push_stat4(&idx.name, samples);
}
}
// The WITHOUT ROWID primary-key index is stored as the table b-tree
// (no `CREATE INDEX` object), so it is not in `indexes` above. SQLite
// records it (last in the index list) in both sqlite_stat1 and
// sqlite_stat4 under the *table* name; do the same.
if meta.without_rowid
&& let Some((stat, samples)) = self.stat4_for_wr_pk(&meta, &rows)
{
new_rows.push((tname.clone(), Some(tname.clone()), stat));
push_stat4(tname, samples);
}
}
}
// Ensure the sqlite_stat1 catalog table exists.
if self.schema.table("sqlite_stat1").is_none() {
const STAT1_SQL: &str = "CREATE TABLE sqlite_stat1(tbl,idx,stat)";
let Statement::CreateTable(ct) = sql::parse_one(STAT1_SQL)? else {
unreachable!()
};
self.exec_create_table(&ct, STAT1_SQL)?;
}
let stat_root = self.schema.table("sqlite_stat1").unwrap().rootpage;
// Replace existing rows for the analyzed tables.
let stat_meta = self.table_meta("sqlite_stat1", None)?;
let victims: Vec<i64> = self
.scan_table(&stat_meta)?
.into_iter()
.filter(
|(_, vals)| matches!(&vals[0], Value::Text(t) if analyze.iter().any(|a| a == t)),
)
.map(|(rid, _)| rid)
.collect();
for rid in victims {
delete_table(self.backend.writer()?, stat_root, rid)?;
}
let base = self.next_rowid(stat_root)?;
for (i, (tbl, idx, stat)) in new_rows.into_iter().enumerate() {
let rec = encode_record(&[
Value::Text(tbl.into()),
idx.map_or(Value::Null, |s| Value::Text(s.into())),
Value::Text(stat.into()),
]);
insert_table(self.backend.writer()?, stat_root, base + i as i64, &rec)?;
}
// ---- sqlite_stat4 ---------------------------------------------------
// A STAT4-enabled sqlite writes both sqlite_stat1 and sqlite_stat4. Its
// `openStatTable` creates *both* catalog tables whenever `ANALYZE`
// processes any table (even when the result is empty), so mirror that:
// create/keep sqlite_stat4 whenever there is a table to analyze or the
// table already exists, then replace the analyzed tables' rows.
if !analyze.is_empty() || self.schema.table("sqlite_stat4").is_some() {
if self.schema.table("sqlite_stat4").is_none() {
const STAT4_SQL: &str = "CREATE TABLE sqlite_stat4(tbl,idx,neq,nlt,ndlt,sample)";
let Statement::CreateTable(ct) = sql::parse_one(STAT4_SQL)? else {
unreachable!()
};
self.exec_create_table(&ct, STAT4_SQL)?;
}
let stat4_root = self.schema.table("sqlite_stat4").unwrap().rootpage;
let stat4_meta = self.table_meta("sqlite_stat4", None)?;
let victims: Vec<i64> = self
.scan_table(&stat4_meta)?
.into_iter()
.filter(
|(_, vals)| matches!(&vals[0], Value::Text(t) if analyze.iter().any(|a| a == t)),
)
.map(|(rid, _)| rid)
.collect();
for rid in victims {
delete_table(self.backend.writer()?, stat4_root, rid)?;
}
let base4 = self.next_rowid(stat4_root)?;
for (i, (tbl, idx, neq, nlt, ndlt, sample)) in stat4_rows.into_iter().enumerate() {
let rec = encode_record(&[
Value::Text(tbl.into()),
Value::Text(idx.into()),
Value::Text(neq.into()),
Value::Text(nlt.into()),
Value::Text(ndlt.into()),
Value::Blob(sample),
]);
insert_table(self.backend.writer()?, stat4_root, base4 + i as i64, &rec)?;
}
}
self.schema = Schema::read(self.backend.source())?;
Ok(())
}
/// Build the `sqlite_stat4` samples for one plain-column index on a *rowid*
/// table, mirroring SQLite's STAT4 accumulator. `rows` are the table's rows in
/// arbitrary order with `rowids` aligned; the index entries are formed, sorted
/// into index-storage order (key columns honouring collation and `DESC`, then
/// rowid ascending), and fed to [`stat4::collect_samples`].
fn stat4_for_rowid_index(
&self,
idx: &IndexMeta,
rows: &[Vec<Value>],
rowids: &[i64],
) -> Vec<crate::exec::stat4::Stat4Sample> {
use crate::exec::stat4::Stat4Entry;
let n_key = idx.cols.len();
// Sample columns = key columns followed by the trailing rowid.
let n_col = n_key + 1;
// Distinct-test columns: a UNIQUE index whose key columns are all NOT NULL
// is distinct on the key alone (`nKeyCol-1`); otherwise all but the
// trailing rowid (`nCol-1`).
let uniq_not_null = idx.unique && self.stat4_key_cols_not_null(idx).unwrap_or(false);
let n_col_test = if uniq_not_null {
n_key.saturating_sub(1)
} else {
n_col - 1
};
let mut entries: Vec<Stat4Entry> = rows
.iter()
.zip(rowids.iter())
.map(|(r, &rid)| {
let mut sample: Vec<Value> = idx.cols.iter().map(|&c| r[c].clone()).collect();
sample.push(Value::Integer(rid));
Stat4Entry { sample }
})
.collect();
// Sort into index-storage order: key columns (collation + DESC), then rowid
// ascending as the final tiebreak.
let colls = idx.collations.clone();
let descs = idx.descending.clone();
entries.sort_by(|a, b| {
for i in 0..n_key {
let coll = colls.get(i).copied().unwrap_or_default();
let mut ord = crate::value::cmp_values_coll(&a.sample[i], &b.sample[i], coll);
if descs.get(i).copied().unwrap_or(false) {
ord = ord.reverse();
}
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
// Trailing rowid, always ascending.
crate::value::cmp_values(&a.sample[n_key], &b.sample[n_key])
});
// Comparison used for the distinct (`iChng`) test: leftmost `len` key
// columns under their collations (NULL == NULL). DESC does not affect
// equality.
let colls2 = idx.collations.clone();
crate::exec::stat4::collect_samples(&entries, n_col, n_col_test, move |a, b, len| {
for i in 0..len {
let coll = colls2.get(i).copied().unwrap_or_default();
let ord = crate::value::cmp_values_coll(&a[i], &b[i], coll);
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
core::cmp::Ordering::Equal
})
}
/// Whether every key column of `idx` is declared `NOT NULL` in its table.
fn stat4_key_cols_not_null(&self, idx: &IndexMeta) -> Option<bool> {
let obj = self
.schema
.objects()
.iter()
.find(|o| o.name == idx.name && o.obj_type == crate::schema::ObjectType::Index)?;
let meta = self.table_meta(&obj.tbl_name, None).ok()?;
Some(idx.cols.iter().all(|&c| meta.not_null[c].is_some()))
}
/// The primary-key column positions of a WITHOUT ROWID table, in key order.
fn wr_pk_cols(meta: &TableMeta) -> &[usize] {
&meta.storage_order[..meta.pk_len]
}
/// Build the STAT4 samples for a secondary index on a WITHOUT ROWID table. The
/// index's full stored columns are its declared key columns followed by any
/// primary-key columns not already present (SQLite's `pIdx->nColumn`). The
/// `sample` record holds those column values in that order.
fn stat4_for_wr_index(
&self,
meta: &TableMeta,
idx: &IndexMeta,
rows: &[Vec<Value>],
) -> Vec<crate::exec::stat4::Stat4Sample> {
use crate::exec::stat4::Stat4Entry;
let pk = Self::wr_pk_cols(meta);
// Full column list: key columns, then the trailing PK columns not already
// in the key (SQLite's collation-aware `isDupColumn` dedup — the same
// shape the on-disk index records use, see `wr_trailing_pk`).
let (trailing_pk, trailing_colls, trailing_descs) =
wr_trailing_pk(&idx.cols, &idx.collations, pk, meta);
let mut full_cols: Vec<usize> = idx.cols.clone();
full_cols.extend_from_slice(&trailing_pk);
let n_key = idx.cols.len();
let n_col = full_cols.len();
// Per full-column collation and DESC (only key columns carry a declared
// direction; the appended PK columns inherit the PK's direction, but they
// are only ever a tiebreak here and their bytes are plain either way).
let mut colls: Vec<crate::value::Collation> = idx.collations.clone();
colls.extend(trailing_colls);
let mut descs: Vec<bool> = idx.descending.clone();
descs.extend(trailing_descs);
// Distinct-test columns: a UNIQUE index all-NOT-NULL is unique on the key
// alone; otherwise all but the last stored column.
let uniq_not_null = idx.unique && idx.cols.iter().all(|&c| meta.not_null[c].is_some());
let n_col_test = if uniq_not_null {
n_key.saturating_sub(1)
} else {
n_col - 1
};
let mut entries: Vec<Stat4Entry> = rows
.iter()
.map(|r| {
let sample: Vec<Value> = full_cols.iter().map(|&c| r[c].clone()).collect();
Stat4Entry { sample }
})
.collect();
Self::sort_and_collect(&mut entries, n_col, n_col_test, &colls, &descs)
}
/// Build the STAT4 samples (and the sqlite_stat1 `stat` string) for the
/// primary-key index of a WITHOUT ROWID table, recorded under the table name.
fn stat4_for_wr_pk(
&self,
meta: &TableMeta,
rows: &[Vec<Value>],
) -> Option<(String, Vec<crate::exec::stat4::Stat4Sample>)> {
use crate::exec::stat4::Stat4Entry;
let pk = Self::wr_pk_cols(meta);
if pk.is_empty() {
return None;
}
let n_col = pk.len();
let colls: Vec<crate::value::Collation> =
pk.iter().map(|&c| meta.columns[c].collation).collect();
let descs: Vec<bool> = meta.pk_descending.clone();
// The PK index is unique on the whole key and its columns are NOT NULL, so
// nColTest = nKeyCol - 1.
let n_col_test = n_col.saturating_sub(1);
let stat = index_stat_string(pk, &colls, rows);
let mut entries: Vec<Stat4Entry> = rows
.iter()
.map(|r| {
let sample: Vec<Value> = pk.iter().map(|&c| r[c].clone()).collect();
Stat4Entry { sample }
})
.collect();
let samples = Self::sort_and_collect(&mut entries, n_col, n_col_test, &colls, &descs);
Some((stat, samples))
}
/// Sort `entries` into index-storage order (the `n_col` stored columns
/// honouring per-column collation and `DESC`) and run the STAT4 accumulator.
/// Shared by the WITHOUT ROWID index and primary-key paths.
fn sort_and_collect(
entries: &mut [crate::exec::stat4::Stat4Entry],
n_col: usize,
n_col_test: usize,
colls: &[crate::value::Collation],
descs: &[bool],
) -> Vec<crate::exec::stat4::Stat4Sample> {
entries.sort_by(|a, b| {
for i in 0..n_col {
let coll = colls.get(i).copied().unwrap_or_default();
let mut ord = crate::value::cmp_values_coll(&a.sample[i], &b.sample[i], coll);
// Only the leading key columns carry a meaningful DESC; the appended
// tiebreak PK columns also honour their direction.
if descs.get(i).copied().unwrap_or(false) {
ord = ord.reverse();
}
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
core::cmp::Ordering::Equal
});
let colls2 = colls.to_vec();
crate::exec::stat4::collect_samples(entries, n_col, n_col_test, move |a, b, len| {
for i in 0..len {
let coll = colls2.get(i).copied().unwrap_or_default();
let ord = crate::value::cmp_values_coll(&a[i], &b[i], coll);
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
core::cmp::Ordering::Equal
})
}
// ---- DDL / DML ----------------------------------------------------------
/// SQLite keeps tables, views and indexes in a single namespace (triggers
/// are separate). A `CREATE TABLE`/`CREATE VIEW`/`CREATE VIRTUAL TABLE` whose
/// name is already taken there fails with a message naming the *existing*
/// object's kind — `table X already exists`, `view X already exists`, or
/// `there is already an index named X`. Returns `None` when the name is free
/// (or held only by a trigger, which does not conflict with a table/view).
fn table_namespace_conflict(&self, name: &str) -> Option<Error> {
use crate::schema::ObjectType;
let obj = self.schema.objects().iter().find(|o| {
o.name == name
&& matches!(
o.obj_type,
ObjectType::Table | ObjectType::View | ObjectType::Index
)
})?;
Some(match obj.obj_type {
ObjectType::View => Error::Error(format!("view {name} already exists")),
ObjectType::Index => Error::Error(format!("there is already an index named {name}")),
// Table (and, defensively, any other kind) uses the table wording.
_ => Error::Error(format!("table {name} already exists")),
})
}
/// SQLite resolves every scalar function call inside a CHECK or
/// generated-column expression at CREATE time, rejecting an unknown function
/// (`no such function: NAME`) or a wrong argument count (`wrong number of
/// arguments to function NAME()`) before the table is created — graphite only
/// noticed at row-evaluation time. Dry-resolve each call by invoking the
/// scalar evaluator with NULL stand-in arguments: the count is preserved (so
/// the arity guard fires) and an unknown name reaches the `no such function`
/// arm, while NULL operands keep the call from doing any real work. Only those
/// two resolution errors are surfaced; any other error (a builtin that rejects
/// NULL, etc.) is expected here and ignored. The RNG is snapshotted and
/// restored so a non-deterministic call (`random()` is legal in a CHECK)
/// leaves no observable side effect. Column resolution is validated separately
/// (and first), matching sqlite for the common single-fault expression.
fn reject_unresolved_functions(&self, e: &Expr) -> Result<()> {
let params = Params::default();
let ctx = EvalCtx::rowless(¶ms).with_subqueries(self);
let saved_rng = self.rng_state.get();
let mut err: Option<Error> = None;
window::visit(e, &mut |n| {
if err.is_some() {
return;
}
if let Expr::Function {
name,
args,
star,
over,
span,
..
} = n
{
// Window calls and aggregate calls are handled by their own
// dedicated checks/wordings; only plain scalar positions resolve
// here. A *registered* aggregate (UDAF) is not a `func::eval_scalar`
// builtin, so it must be excluded explicitly or the dry-resolve
// below would mistake it for an unknown name.
let lname = name.to_ascii_lowercase();
if over.is_some()
|| func::is_aggregate_call(name, args.len(), *star)
|| self.aggregates.contains_key(&lname)
{
return;
}
// The `MATCH` operator and the FTS5 auxiliary functions resolve
// against the *structure* of their arguments (a column/table
// reference, the current row's score), not just their count — the
// NULL stand-ins below would defeat that and make them look like an
// unknown name. They are validated in the virtual-table path
// instead, so skip them here.
if matches!(lname.as_str(), "match" | "bm25" | "highlight" | "snippet") {
return;
}
let null_args: Vec<Expr> = core::iter::repeat_with(|| Expr::Literal(Literal::Null))
.take(args.len())
.collect();
if let Err(Error::Error(m)) = func::eval_scalar(name, &null_args, *star, &ctx)
&& (m.starts_with("no such function: ")
|| m.starts_with("wrong number of arguments to function "))
{
// Carry the call's byte offset so the shell carets the exact
// function even when the same name appears earlier in a valid
// call (`abs(a), abs(a,a)`); synthetic calls (`Span::none()`)
// have none and fall back to the message-text search.
err = Some(match span.0 {
Some((start, _)) => Error::ErrorAt(m, start as usize),
None => Error::Error(m),
});
}
}
});
self.rng_state.set(saved_rng);
err.map_or(Ok(()), Err)
}
/// Whether `name` resolves to a built-in or registered *scalar* function,
/// regardless of argument count. Used to choose between SQLite's two prepare-
/// time errors for a function carrying `OVER (…)` that is neither a window
/// function nor an aggregate: an unknown name is `no such function: NAME`
/// (checked first), while a *known* scalar misused as a window is `NAME() may
/// not be used as a window function`. Dry-resolves with NULL stand-in
/// arguments exactly like `reject_unresolved_functions`, treating only the
/// `no such function` outcome as "does not exist" (a wrong-arity error still
/// means the name is known). The RNG is snapshotted and restored so a
/// non-deterministic builtin leaves no observable side effect.
fn scalar_function_exists(&self, name: &str, nargs: usize, star: bool) -> bool {
let params = Params::default();
let ctx = EvalCtx::rowless(¶ms).with_subqueries(self);
let null_args: Vec<Expr> = core::iter::repeat_with(|| Expr::Literal(Literal::Null))
.take(nargs)
.collect();
let saved_rng = self.rng_state.get();
let r = func::eval_scalar(name, &null_args, star, &ctx);
self.rng_state.set(saved_rng);
!matches!(&r, Err(Error::Error(m)) if m.starts_with("no such function: "))
}
fn exec_create_table(&mut self, ct: &CreateTable, sql_text: &str) -> Result<()> {
if let Some(select) = &ct.as_select {
return self.exec_create_table_as_select(ct, select);
}
if let Some(e) = self.table_namespace_conflict(&ct.name) {
if ct.if_not_exists {
return Ok(());
}
return Err(e);
}
// SQLite applies these per-column checks as it parses (adds) each column,
// left to right, ahead of the end-of-table validation — so they outrank
// even the STRICT missing/unknown-datatype check below. They interleave
// positionally: an earlier column's violation wins over a later column's,
// but within a single column the duplicate name is caught first, then the
// structural generated-column rules (no second `AS`, no `DEFAULT`, not
// part of the PRIMARY KEY), then the `COLLATE` sequence.
let table_pk_cols: Vec<&str> = ct
.constraints
.iter()
.filter_map(|tc| match tc {
TableConstraint::PrimaryKey(cols, _) => Some(cols),
_ => None,
})
.flatten()
.map(|(n, _)| n.as_str())
.collect();
// SQLite processes PRIMARY KEY declarations sequentially (column-level
// PKs precede table-level ones in source order), so the *first* declared
// PRIMARY KEY decides which error a conflict reports: a generated first
// PK yields "generated columns cannot be part of the PRIMARY KEY", while
// a non-generated first PK followed by any second PK yields "table has
// more than one primary key" (caught at end-of-table below). Only fire
// the generated-PK error when that first PK is itself generated.
let is_generated_col = |name: &str| {
ct.columns.iter().any(|c| {
c.name.eq_ignore_ascii_case(name)
&& c.constraints
.iter()
.any(|k| matches!(k, ColumnConstraint::Generated { .. }))
})
};
let first_pk_is_generated = if let Some(i) = ct.columns.iter().position(|c| {
c.constraints
.iter()
.any(|k| matches!(k, ColumnConstraint::PrimaryKey { .. }))
}) {
ct.columns[i]
.constraints
.iter()
.any(|k| matches!(k, ColumnConstraint::Generated { .. }))
} else if let Some(cols) = ct.constraints.iter().find_map(|tc| match tc {
TableConstraint::PrimaryKey(cols, _) => Some(cols),
_ => None,
}) {
cols.iter().any(|(name, _)| is_generated_col(name))
} else {
false
};
for (i, c) in ct.columns.iter().enumerate() {
if ct.columns[..i]
.iter()
.any(|p| p.name.eq_ignore_ascii_case(&c.name))
{
return Err(Error::Error(alloc::format!(
"duplicate column name: {}",
c.name
)));
}
let generated = c
.constraints
.iter()
.filter(|k| matches!(k, ColumnConstraint::Generated { .. }))
.count();
if generated > 1 {
return Err(Error::Error(alloc::format!(
"error in generated column \"{}\"",
c.name
)));
}
if generated == 1 {
if c.constraints
.iter()
.any(|k| matches!(k, ColumnConstraint::Default(..)))
{
return Err(Error::Error(
"cannot use DEFAULT on a generated column".into(),
));
}
let in_primary_key = c
.constraints
.iter()
.any(|k| matches!(k, ColumnConstraint::PrimaryKey { .. }))
|| table_pk_cols
.iter()
.any(|p| p.eq_ignore_ascii_case(&c.name));
if in_primary_key && first_pk_is_generated {
return Err(Error::Error(
"generated columns cannot be part of the PRIMARY KEY".into(),
));
}
}
for k in &c.constraints {
if let ColumnConstraint::Collate(name) = k
&& crate::value::resolve_collation_name(name).is_none()
{
return Err(Error::Error(format!("no such collation sequence: {name}")));
}
}
}
// STRICT tables restrict column types to the six rigid types; reject any
// other (or missing) declared type at CREATE, like SQLite.
if ct.strict {
for c in &ct.columns {
if strict_column_type(c.type_name.as_deref()).is_none() {
return Err(match &c.type_name {
Some(t) => Error::Error(format!(
"unknown datatype for {}.{}: \"{t}\"",
ct.name, c.name
)),
None => {
Error::Error(format!("missing datatype for {}.{}", ct.name, c.name))
}
});
}
}
}
// A table must have at least one non-generated (real) column. SQLite
// reports this right after the per-column parse checks above and before
// it resolves any CHECK / generated expression or flags an unknown table
// option, so it outranks "no such column", aggregate-misuse,
// subquery-prohibited and "unknown table option" errors.
if !ct.columns.is_empty()
&& ct.columns.iter().all(|c| {
c.constraints
.iter()
.any(|k| matches!(k, ColumnConstraint::Generated { .. }))
})
{
return Err(Error::Error(
"must have at least one non-generated column".into(),
));
}
// An unrecognized table option (`CREATE TABLE t(a) FOO`) is surfaced
// here, *after* the STRICT datatype check above — matching SQLite's
// order, where e.g. `CREATE TABLE t(a) STRICT, FOO` reports the missing
// datatype on `a` rather than the bad option.
if let Some(opt) = &ct.bad_table_option {
return Err(Error::Error(format!("unknown table option: {opt}")));
}
// SQLite forbids subqueries in CHECK constraints and generated columns.
for c in &ct.columns {
for k in &c.constraints {
match k {
ColumnConstraint::Check(e, _) if expr_has_subquery(e) => {
return Err(Error::Error(
"subqueries prohibited in CHECK constraints".into(),
));
}
ColumnConstraint::Generated { expr, .. } if expr_has_subquery(expr) => {
return Err(Error::Error(
"subqueries prohibited in generated columns".into(),
));
}
ColumnConstraint::Generated { expr, .. } if expr_is_nondeterministic(expr) => {
return Err(Error::Error(
"non-deterministic functions prohibited in generated columns".into(),
));
}
_ => {}
}
}
}
// SQLite rejects an aggregate function in a CHECK or generated-column
// expression at CREATE ("misuse of aggregate function NAME()").
for c in &ct.columns {
for k in &c.constraints {
let agg = match k {
ColumnConstraint::Check(e, _) | ColumnConstraint::Generated { expr: e, .. } => {
first_aggregate_call_name(e)
}
_ => None,
};
if let Some(name) = agg {
return Err(Error::Error(format!(
"misuse of aggregate function {name}()"
)));
}
}
}
for tc in &ct.constraints {
if let TableConstraint::Check(e, _) = tc {
if expr_has_subquery(e) {
return Err(Error::Error(
"subqueries prohibited in CHECK constraints".into(),
));
}
if let Some(name) = first_aggregate_call_name(e) {
return Err(Error::Error(format!(
"misuse of aggregate function {name}()"
)));
}
}
}
// A CHECK / generated-column expression may reference only the table's own
// columns, like SQLite (which rejects an unknown column at CREATE). A
// generated column additionally may not reference the rowid; a CHECK may.
let known: Vec<String> = ct.columns.iter().map(|c| c.name.clone()).collect();
for c in &ct.columns {
for k in &c.constraints {
let bad = match k {
ColumnConstraint::Check(e, _) => {
unknown_column_ref(e, &known, true, Some(&ct.name))
}
ColumnConstraint::Generated { expr, .. } => {
unknown_column_ref(expr, &known, false, Some(&ct.name))
}
_ => None,
};
if let Some(col) = bad {
return Err(Error::Error(format!("no such column: {col}")));
}
// Every scalar function the expression calls must exist with a
// valid argument count, like sqlite (which resolves them at CREATE).
match k {
ColumnConstraint::Check(e, _) | ColumnConstraint::Generated { expr: e, .. } => {
self.reject_unresolved_functions(e)?;
}
_ => {}
}
// A generated column may *reference* its table's columns but not via
// a `table.col` qualifier; SQLite rejects the dotted form even though
// it resolves. (A CHECK accepts the same dotted reference.)
if let ColumnConstraint::Generated { expr, .. } = k
&& has_resolved_dotted_ref(expr, &known, false, &ct.name)
{
return Err(Error::Error(
"the \".\" operator prohibited in generated columns".into(),
));
}
// A column `DEFAULT` must be constant: SQLite allows literals,
// `CURRENT_*`, and (deterministic or not) function calls, but not a
// reference to any column. Reject at CREATE like sqlite.
if let ColumnConstraint::Default(e, _) = k
&& unknown_column_ref(e, &[], false, None).is_some()
{
return Err(Error::Error(format!(
"default value of column [{}] is not constant",
c.name
)));
}
// A column-level FOREIGN KEY references exactly its own column, so
// it may name at most one parent column. SQLite rejects more at
// CREATE with this specific message.
if let ColumnConstraint::References(fk) = k
&& fk.ref_columns.len() > 1
{
return Err(Error::Error(format!(
"foreign key on {} should reference only one column of table {}",
c.name, fk.ref_table
)));
}
}
}
// A cycle among the table's generated columns is rejected at CREATE,
// like SQLite (which validates this before any row is inserted).
if let Some(col) = generated_column_loop(&ct.columns) {
return Err(Error::Error(format!("generated column loop on \"{col}\"")));
}
for tc in &ct.constraints {
if let TableConstraint::Check(e, _) = tc {
if let Some(col) = unknown_column_ref(e, &known, true, Some(&ct.name)) {
return Err(Error::Error(format!("no such column: {col}")));
}
self.reject_unresolved_functions(e)?;
}
// A table-level FOREIGN KEY's *local* columns must each be a declared
// column (a generated column counts; `rowid` does not), as SQLite
// rejects at CREATE. The referenced parent table/columns are not
// checked here — SQLite resolves those lazily.
if let TableConstraint::ForeignKey(fk) = tc {
for col in &fk.columns {
if !known.iter().any(|k| k.eq_ignore_ascii_case(col)) {
return Err(Error::Error(format!(
"unknown column \"{col}\" in foreign key definition"
)));
}
}
// The number of child columns must match the number of explicitly
// named parent columns (an empty parent list defers to the parent's
// PRIMARY KEY, resolved lazily). SQLite rejects a mismatch at CREATE.
if !fk.ref_columns.is_empty() && fk.ref_columns.len() != fk.columns.len() {
return Err(Error::Error(
"number of columns in foreign key does not match the number of \
columns in the referenced table"
.into(),
));
}
}
}
// At most one PRIMARY KEY (column-level + table-level).
let pk_count = ct
.columns
.iter()
.flat_map(|c| &c.constraints)
.filter(|k| matches!(k, ColumnConstraint::PrimaryKey { .. }))
.count()
+ ct.constraints
.iter()
.filter(|tc| matches!(tc, TableConstraint::PrimaryKey(..)))
.count();
if pk_count > 1 {
return Err(Error::Error(alloc::format!(
"table \"{}\" has more than one primary key",
ct.name
)));
}
// Table-level PRIMARY KEY/UNIQUE column lists must name real columns.
for tc in &ct.constraints {
let names: Vec<&str> = match tc {
TableConstraint::PrimaryKey(cols, _) => {
cols.iter().map(|(n, _)| n.as_str()).collect()
}
TableConstraint::Unique(cols, _) => cols.iter().map(|(n, _)| n.as_str()).collect(),
_ => continue,
};
for name in names {
if !ct.columns.iter().any(|c| c.name.eq_ignore_ascii_case(name)) {
return Err(Error::Error(alloc::format!("no such column: {name}")));
}
}
}
// AUTOINCREMENT is only valid on a rowid `INTEGER PRIMARY KEY` column.
let ipk = find_integer_primary_key(ct);
let has_autoinc = |i: usize| {
ct.columns[i].constraints.iter().any(|k| {
matches!(
k,
ColumnConstraint::PrimaryKey {
autoincrement: true,
..
}
)
})
};
if (0..ct.columns.len()).any(has_autoinc) {
if ct.without_rowid {
return Err(Error::Error(
"AUTOINCREMENT not allowed on WITHOUT ROWID tables".into(),
));
}
if !(0..ct.columns.len()).any(|i| has_autoinc(i) && Some(i) == ipk) {
return Err(Error::Error(
"AUTOINCREMENT is only allowed on an INTEGER PRIMARY KEY".into(),
));
}
}
// A WITHOUT ROWID table is stored as a PK-clustered index b-tree; an
// ordinary table uses a rowid table b-tree.
let root = if ct.without_rowid {
// A WITHOUT ROWID table must have a PRIMARY KEY (it is the b-tree key).
if primary_key_positions(ct).is_empty() {
return Err(Error::Error(format!(
"PRIMARY KEY missing on table {}",
ct.name
)));
}
create_index_root(self.backend.writer()?)?
} else {
create_table_root(self.backend.writer()?)?
};
let next = self.next_rowid(crate::schema::SCHEMA_ROOT_PAGE)?;
let row = encode_record(&[
Value::Text("table".into()),
Value::Text(ct.name.clone().into()),
Value::Text(ct.name.clone().into()),
Value::Integer(root as i64),
Value::Text(canonical_schema_sql("CREATE TABLE ", sql_text).into()),
]);
insert_table(
self.backend.writer()?,
crate::schema::SCHEMA_ROOT_PAGE,
next,
&row,
)?;
// Create the automatic indexes SQLite implies for UNIQUE / non-rowid
// PRIMARY KEY constraints, so the file is a valid SQLite database (it
// otherwise reports "wrong # of entries in index sqlite_autoindex_*").
// For a WITHOUT ROWID table the PRIMARY KEY *is* the table (no separate
// b-tree), but it still consumes its `sqlite_autoindex_<t>_<n>` slot.
let ipk = if ct.without_rowid {
None
} else {
find_integer_primary_key(ct)
};
let unique = collect_unique_sets(ct, ipk);
let pk = if ct.without_rowid {
primary_key_positions(ct)
} else {
Vec::new()
};
let mut schema_rowid = next + 1;
for (n, (set, _, _)) in unique.iter().enumerate() {
// The clustered PRIMARY KEY of a WITHOUT ROWID table gets no b-tree.
if ct.without_rowid && *set == pk {
continue;
}
let idx_root = create_index_root(self.backend.writer()?)?;
let idx_row = encode_record(&[
Value::Text("index".into()),
Value::Text(alloc::format!("sqlite_autoindex_{}_{}", ct.name, n + 1).into()),
Value::Text(ct.name.clone().into()),
Value::Integer(idx_root as i64),
Value::Null, // automatic indexes carry no CREATE SQL
]);
insert_table(
self.backend.writer()?,
crate::schema::SCHEMA_ROOT_PAGE,
schema_rowid,
&idx_row,
)?;
schema_rowid += 1;
}
// An `AUTOINCREMENT` table requires the `sqlite_sequence` catalog, which
// SQLite creates (empty) the first time such a table is created.
let is_autoinc = ipk.is_some_and(|i| {
ct.columns[i].constraints.iter().any(|k| {
matches!(
k,
ColumnConstraint::PrimaryKey {
autoincrement: true,
..
}
)
})
});
if is_autoinc && self.schema.table("sqlite_sequence").is_none() {
const SEQ_SQL: &str = "CREATE TABLE sqlite_sequence(name,seq)";
let Statement::CreateTable(seq_ct) = sql::parse_one(SEQ_SQL)? else {
unreachable!()
};
self.exec_create_table(&seq_ct, SEQ_SQL)?;
}
let cookie = self
.backend
.writer()?
.header()
.schema_cookie
.wrapping_add(1);
self.backend.writer()?.header_mut().schema_cookie = cookie;
// Make the new table visible to subsequent statements in this tx.
self.schema = Schema::read(self.backend.source())?;
Ok(())
}
/// `CREATE TABLE name AS SELECT …`: create a table whose columns are the
/// query's output labels (no declared types/constraints), then populate it
/// with the query's rows.
fn exec_create_table_as_select(&mut self, ct: &CreateTable, select: &Select) -> Result<()> {
if let Some(e) = self.table_namespace_conflict(&ct.name) {
if ct.if_not_exists {
return Ok(());
}
return Err(e);
}
let result = self.run_select(select, &Params::default())?;
// SQLite auto-renames duplicate output column names in CTAS — the second
// `a` becomes `a:1`, the third `a:2`, etc. — rather than erroring like an
// explicit `CREATE TABLE` column list. Names compare case-insensitively.
let mut counts: alloc::collections::BTreeMap<String, usize> =
alloc::collections::BTreeMap::new();
let deduped: Vec<String> = result
.columns
.iter()
.map(|c| {
let n = counts.entry(c.to_ascii_lowercase()).or_insert(0);
let name = if *n == 0 {
c.clone()
} else {
alloc::format!("{c}:{n}")
};
*n += 1;
name
})
.collect();
// Each new column inherits a declared TYPE from the query's output: SQLite
// uses the affinity of a direct column reference (through aliases/views),
// rendered as its canonical short name (INTEGER→`INT`, TEXT→`TEXT`,
// REAL→`REAL`, NUMERIC→`NUM`, BLOB/none→no type); a computed expression or
// literal gets no type. This also gives the new table the right affinity.
// A compound query's per-column affinity is combined across arms by a
// fiddly internal rule, so only a plain (non-compound) SELECT propagates
// types; a compound leaves them blank (as before).
let ctas_params = Params::default();
let types: Vec<String> = if select.compound.is_empty() {
match self.scan_source(select, &ctas_params) {
Ok((src_cols, _)) => {
let ctx = row_ctx(&[], &src_cols, None, &ctas_params).with_subqueries(self);
let mut affs: Vec<Option<eval::Affinity>> = Vec::new();
for col in &select.columns {
match col {
ResultColumn::Expr { expr, .. } => {
affs.push(eval::expr_affinity(expr, &ctx));
}
ResultColumn::Wildcard => affs.extend(
src_cols
.iter()
.filter(|c| !c.hidden)
.map(|c| Some(c.affinity)),
),
ResultColumn::TableWildcard(t) => affs.extend(
src_cols
.iter()
.filter(|c| !c.hidden && c.table.eq_ignore_ascii_case(t))
.map(|c| Some(c.affinity)),
),
}
}
affs.iter()
.map(|a| match a {
Some(eval::Affinity::Integer) => "INT",
Some(eval::Affinity::Text) => "TEXT",
Some(eval::Affinity::Real) => "REAL",
Some(eval::Affinity::Numeric) => "NUM",
Some(eval::Affinity::Blob) | None => "",
})
.map(String::from)
.collect()
}
Err(_) => Vec::new(),
}
} else {
Vec::new()
};
// Build and create the resolved table. SQLite stores the CTAS schema with
// `identPut` quoting (bare when safe), a space before a non-empty type, and
// no spaces after the commas — and lays it out on one line for up to five
// columns, but one column per indented line (with a trailing newline before
// the closing paren) for six or more. Mirror both to stay byte-identical.
let coldefs: Vec<String> = deduped
.iter()
.enumerate()
.map(|(i, c)| {
let ty = types.get(i).map(String::as_str).unwrap_or("");
if ty.is_empty() {
crate::sql::print::ident_smart(c)
} else {
format!("{} {ty}", crate::sql::print::ident_smart(c))
}
})
.collect();
let cols = if coldefs.len() > 5 {
format!("\n {}\n", coldefs.join(",\n "))
} else {
coldefs.join(",")
};
let create_sql = format!(
"CREATE TABLE {}({cols})",
crate::sql::print::ident_smart(&ct.name)
);
let Statement::CreateTable(syn) = sql::parse_one(&create_sql)? else {
return Err(Error::Corrupt("generated CTAS schema is invalid".into()));
};
self.exec_create_table(&syn, &create_sql)?;
// Populate it with the query's rows via the normal insert path.
if !result.rows.is_empty() {
let value_rows: Vec<Vec<Expr>> = result
.rows
.into_iter()
.map(|row| {
row.into_iter()
.map(|v| Expr::Literal(value_to_literal(v)))
.collect()
})
.collect();
let ins = Insert {
ctes: Vec::new(),
table: ct.name.clone(),
schema: None,
columns: Vec::new(),
source: InsertSource::Values(value_rows),
on_conflict: OnConflict::Abort,
// A VACUUM re-insert of already-valid rows keeps the plain default.
on_conflict_explicit: true,
upsert: Vec::new(),
returning: Vec::new(),
};
self.exec_insert(&ins, &Params::default())?;
}
Ok(())
}
/// Handle a settable `PRAGMA` (currently only `foreign_keys`). Unknown
/// pragmas are accepted as no-ops, matching SQLite's leniency.
fn exec_pragma(&mut self, p: &Pragma, params: &Params) -> Result<()> {
if p.name.eq_ignore_ascii_case("foreign_keys") {
if let Some(e) = &p.value {
self.foreign_keys = pragma_truth(e, params);
}
} else if p.name.eq_ignore_ascii_case("recursive_triggers") {
if let Some(e) = &p.value {
self.recursive_triggers = pragma_truth(e, params);
}
} else if p.name.eq_ignore_ascii_case("case_sensitive_like") {
// `ON` makes the LIKE operator (and the `like()` function) compare
// ASCII case-sensitively; the get form returns no rows (handled in the
// read path), so this is a write-only toggle, like SQLite.
if let Some(e) = &p.value {
self.case_sensitive_like = pragma_truth(e, params);
}
} else if p.name.eq_ignore_ascii_case("query_only") {
// `ON` puts the connection in read-only mode: any write statement then
// fails with `attempt to write a readonly database` (gated in
// `exec_parsed`). The get form reads the live flag back (read path).
if let Some(e) = &p.value {
self.query_only = pragma_truth(e, params);
}
} else if p.name.eq_ignore_ascii_case("ignore_check_constraints") {
// `ON` makes INSERT/UPDATE skip CHECK enforcement; the get form reads
// the live flag back (read path).
if let Some(e) = &p.value {
self.ignore_check_constraints = pragma_truth(e, params);
}
} else if p.name.eq_ignore_ascii_case("automatic_index") {
// Inert (graphite builds no automatic indexes); stored so a later
// `PRAGMA automatic_index` reads the value back, like sqlite.
if let Some(e) = &p.value {
self.automatic_index.set(pragma_truth(e, params));
}
} else if p.name.eq_ignore_ascii_case("cell_size_check") {
// Inert (graphite validates cells on every read); stored so a later
// `PRAGMA cell_size_check` reads the value back, like sqlite.
if let Some(e) = &p.value {
self.cell_size_check.set(pragma_truth(e, params));
}
} else if p.name.eq_ignore_ascii_case("cache_size") {
// Round-trip the value verbatim (graphite keeps all pages resident, so
// it changes nothing) — `PRAGMA cache_size` then reports it back.
if let Some(e) = &p.value {
self.cache_size
.set(eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?));
}
} else if p.name.eq_ignore_ascii_case("analysis_limit") {
// The ANALYZE sample cap (advisory here); store it, clamping a negative
// value to 0 like sqlite, so a later `PRAGMA analysis_limit` reads back.
if let Some(e) = &p.value {
let v = eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?);
self.analysis_limit.set(v.max(0));
}
} else if p.name.eq_ignore_ascii_case("busy_timeout") {
// Advisory (graphite never blocks on a lock); store it, clamping a
// negative value to 0, so a later `PRAGMA busy_timeout` reads it back.
if let Some(e) = &p.value {
let v = eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?);
self.busy_timeout.set(v.max(0));
}
} else if p.name.eq_ignore_ascii_case("journal_size_limit") {
// Advisory (graphite does not honor the cap); store it, clamping a
// negative value to -1 (the "no limit" sentinel), so a later
// `PRAGMA journal_size_limit` reads it back like sqlite.
if let Some(e) = &p.value {
let v = eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?);
self.journal_size_limit.set(if v < 0 { -1 } else { v });
}
} else if p.name.eq_ignore_ascii_case("synchronous") {
// Advisory (graphite has no fsync-policy knob); store the level so a
// later `PRAGMA synchronous` reads it back. Accepts the keyword form
// (OFF/NORMAL/FULL/EXTRA) or a number 0..3, like sqlite.
if let Some(e) = &p.value {
let v = match pragma_text(e).to_ascii_lowercase().as_str() {
"off" => 0,
"normal" => 1,
"full" => 2,
"extra" => 3,
_ => eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?),
};
self.synchronous.set(v);
}
} else if p.name.eq_ignore_ascii_case("temp_store") {
// Advisory (graphite keeps temp data in the pager); store the mode.
// Accepts DEFAULT/FILE/MEMORY or a number 0..2, like sqlite.
if let Some(e) = &p.value {
let v = match pragma_text(e).to_ascii_lowercase().as_str() {
"default" => 0,
"file" => 1,
"memory" => 2,
_ => eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?),
};
self.temp_store.set(v);
}
} else if p.name.eq_ignore_ascii_case("threads") {
// Advisory (graphite is single-threaded); store the value so a later
// `PRAGMA threads` reads it back, clamping a negative value to 0.
if let Some(e) = &p.value {
let v = eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?);
self.threads.set(v.max(0));
}
} else if p.name.eq_ignore_ascii_case("soft_heap_limit") {
// Advisory (graphite does not bound its heap); store so a later
// `PRAGMA soft_heap_limit` reads it back. A negative value clamps to 0.
if let Some(e) = &p.value {
let v = eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?);
self.soft_heap_limit.set(v.max(0));
}
} else if p.name.eq_ignore_ascii_case("wal_autocheckpoint") {
// Advisory (graphite has no WAL auto-checkpointer); store the threshold.
if let Some(e) = &p.value {
let v = eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?);
self.wal_autocheckpoint.set(v.max(0));
}
} else if p.name.eq_ignore_ascii_case("secure_delete") {
// sqlite maps the argument to 0 (off), 2 (the `fast` keyword only), or
// 1 (any other true / non-zero value). The pager zeroes freed pages
// when the setting is non-zero.
if let Some(e) = &p.value {
let v = match pragma_text(e).to_ascii_lowercase().as_str() {
"fast" => 2,
_ if pragma_truth(e, params) => 1,
_ => 0,
};
self.secure_delete.set(v);
if let Backend::Write(w) = &mut self.backend {
w.set_secure_delete(v != 0);
}
}
} else if p.name.eq_ignore_ascii_case("journal_mode") {
if let Some(e) = &p.value
&& pragma_text(e).eq_ignore_ascii_case("wal")
{
self.backend.writer()?.set_wal_mode()?;
}
// Other modes (delete/truncate/persist/memory/off) keep the
// rollback-journal path; switching back out of WAL is a no-op.
} else if p.name.eq_ignore_ascii_case("wal_checkpoint") {
// `PRAGMA wal_checkpoint(mode)` — the optional argument selects the
// checkpoint mode (`pragma.c`: PASSIVE/FULL/RESTART/TRUNCATE, default
// PASSIVE), mapped by `CheckpointMode::from_name`. A non-WAL database
// has nothing to checkpoint (the pager returns the `(0, -1, -1)`
// triple, discarded here). The `(busy, log, checkpointed)` row is
// produced by the read path (`run_pragma`) for a non-WAL database; in
// WAL mode the side effect runs here.
let mode = p
.value
.as_ref()
.map(|e| CheckpointMode::from_name(&pragma_text(e)))
.unwrap_or(CheckpointMode::Passive);
self.backend.writer()?.checkpoint_mode(mode)?;
} else if p.name.eq_ignore_ascii_case("user_version") {
if let Some(e) = &p.value {
let v = pragma_header_int(e, params)?;
self.backend.writer()?.header_mut().user_version = v;
}
} else if p.name.eq_ignore_ascii_case("application_id") {
if let Some(e) = &p.value {
let v = pragma_header_int(e, params)?;
self.backend.writer()?.header_mut().application_id = v;
}
} else if p.name.eq_ignore_ascii_case("auto_vacuum") {
if let Some(e) = &p.value {
// Accept the symbolic and numeric spellings.
let mode = match pragma_text(e).to_ascii_lowercase().as_str() {
"none" => 0,
"full" => 1,
"incremental" => 2,
_ => eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?),
};
// SQLite only honours a change of auto-vacuum mode on an *empty*
// database (before any table is created); afterwards it is a
// no-op until the next VACUUM. graphite mirrors that: on an empty
// database we stamp the header into the requested mode and the
// pager maintains pointer-map pages from then on; on a non-empty
// database the pragma is silently ignored.
let target = match mode {
0 => AutoVacuum::None,
1 => AutoVacuum::Full,
2 => AutoVacuum::Incremental,
_ => return Err(Error::Error(format!("invalid auto_vacuum mode {mode}"))),
};
self.backend.writer()?.set_auto_vacuum_if_empty(target)?;
}
} else if p.name.eq_ignore_ascii_case("incremental_vacuum") {
// `PRAGMA incremental_vacuum` (or `= N` / `(N)`): reclaim up to N free
// pages off the end of an `auto_vacuum=INCREMENTAL` database. With no
// argument (or N <= 0) reclaim as many as possible. The pager makes it
// a no-op for NONE/FULL, mirroring SQLite. The reclamation is staged
// like any other write; the caller's normal commit (the implicit
// auto-commit when not in a transaction, or an explicit COMMIT) flushes
// the now-smaller file to disk.
let n = match &p.value {
Some(e) => eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?),
None => 0,
};
self.backend.writer()?.incremental_vacuum(n)?;
}
Ok(())
}
/// The foreign keys declared by `table`, with child columns resolved and
/// parent columns defaulted to the parent's primary key when omitted.
fn foreign_keys_of(&self, table: &str) -> Result<Vec<ForeignKey>> {
let Some(obj) = self.schema.table(table) else {
return Ok(Vec::new());
};
let Some(sql) = &obj.sql else {
return Ok(Vec::new());
};
let Statement::CreateTable(ct) = sql::parse_one(sql)? else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for col in &ct.columns {
for c in &col.constraints {
if let ColumnConstraint::References(fk) = c {
out.push(self.resolve_fk(fk)?);
}
}
}
for c in &ct.constraints {
if let TableConstraint::ForeignKey(fk) = c {
out.push(self.resolve_fk(fk)?);
}
}
Ok(out)
}
/// Fill in a foreign key's parent columns from the parent's primary key when
/// the `REFERENCES` clause omitted them.
fn resolve_fk(&self, fk: &ForeignKey) -> Result<ForeignKey> {
let mut fk = fk.clone();
if fk.ref_columns.is_empty() {
fk.ref_columns = self.primary_key_columns(&fk.ref_table)?;
}
Ok(fk)
}
/// SQLite's `foreign key mismatch - "<child>" referencing "<parent>"` error.
fn fk_mismatch_err(child: &str, parent: &str) -> Error {
Error::Error(format!(
"foreign key mismatch - \"{child}\" referencing \"{parent}\""
))
}
/// Whether `fk` (declared on the child table) is *structurally* malformed —
/// SQLite's "foreign key mismatch". An FK is well-formed only when its
/// referenced columns (explicit, or the parent's PRIMARY KEY when omitted)
/// exist, match the child column count, and are collectively covered by the
/// parent's PRIMARY KEY or a non-partial UNIQUE index whose column set is
/// exactly the referenced set. A missing *parent table* is NOT a mismatch
/// (SQLite surfaces those as ordinary row violations / `no such table`), so
/// that case is left to the row-level paths.
fn fk_is_mismatch(&self, fk: &ForeignKey) -> Result<bool> {
if self.schema.table(&fk.ref_table).is_none() {
return Ok(false);
}
let pmeta = self.table_meta(&fk.ref_table, None)?;
let ref_cols = if fk.ref_columns.is_empty() {
self.primary_key_columns(&fk.ref_table)?
} else {
fk.ref_columns.clone()
};
// The referenced set must be non-empty (a parent with no PRIMARY KEY and
// no explicit columns is a mismatch) and match the child column count.
if ref_cols.is_empty() || ref_cols.len() != fk.columns.len() {
return Ok(true);
}
// Every referenced column must exist in the parent.
if !ref_cols.iter().all(|c| {
pmeta
.columns
.iter()
.any(|pc| pc.name.eq_ignore_ascii_case(c))
}) {
return Ok(true);
}
// The referenced columns must form a unique key: the parent's PRIMARY
// KEY, or a non-partial UNIQUE index whose column set is exactly the
// referenced set (order-independent, as SQLite compares as sets).
let same_set = |a: &[String], b: &[String]| {
a.len() == b.len()
&& a.iter()
.all(|x| b.iter().any(|y| y.eq_ignore_ascii_case(x)))
};
let pk = self.primary_key_columns(&fk.ref_table)?;
if !pk.is_empty() && same_set(&pk, &ref_cols) {
return Ok(false);
}
for idx in self.indexes_of(&fk.ref_table)? {
if idx.unique && idx.partial.is_none() {
let names: Vec<String> = idx
.cols
.iter()
.map(|&p| pmeta.columns[p].name.clone())
.collect();
if same_set(&names, &ref_cols) {
return Ok(false);
}
}
}
Ok(true)
}
/// The primary-key column names of `table` (the INTEGER PRIMARY KEY, or a
/// declared PRIMARY KEY constraint).
fn primary_key_columns(&self, table: &str) -> Result<Vec<String>> {
let Some(obj) = self.schema.table(table) else {
return Err(Error::Error(format!("no such table: {table}")));
};
let sql = obj.sql.as_deref().unwrap_or("");
let Statement::CreateTable(ct) = sql::parse_one(sql)? else {
return Ok(Vec::new());
};
for col in &ct.columns {
if col
.constraints
.iter()
.any(|c| matches!(c, ColumnConstraint::PrimaryKey { .. }))
{
return Ok(alloc::vec![col.name.clone()]);
}
}
for c in &ct.constraints {
if let TableConstraint::PrimaryKey(cols, _) = c {
return Ok(cols.iter().map(|(n, _)| n.clone()).collect());
}
}
Ok(Vec::new())
}
/// Verify, for a row being inserted/updated into `table`, that every foreign
/// key it declares points at an existing parent row. NULL key columns are
/// skipped (MATCH SIMPLE).
fn check_fk_child(&self, table: &str, meta: &TableMeta, values: &[Value]) -> Result<()> {
if !self.foreign_keys {
return Ok(());
}
for fk in self.foreign_keys_of(table)? {
// A structurally malformed FK (bad parent columns / arity / not a
// unique key) is a "foreign key mismatch", reported before any
// deferred handling or row lookup.
if self.fk_is_mismatch(&fk)? {
return Err(Self::fk_mismatch_err(table, &fk.ref_table));
}
// A `DEFERRABLE INITIALLY DEFERRED` key is checked at COMMIT, not now
// — but only inside an explicit transaction. In autocommit the
// statement *is* the transaction, so its implicit commit is immediate.
if fk.initially_deferred && self.in_tx {
continue;
}
let key = match self.child_key_values(meta, &fk, values) {
Some(k) => k,
None => continue, // a NULL column => constraint satisfied
};
if !self.parent_has_key(&fk, &key)? {
return Err(Error::Constraint("FOREIGN KEY constraint failed".into()));
}
}
Ok(())
}
/// Verify every `DEFERRABLE INITIALLY DEFERRED` foreign key across all tables
/// — run at `COMMIT` to catch a constraint that was temporarily violated
/// inside the transaction and never repaired.
fn check_deferred_fks(&self) -> Result<()> {
if !self.foreign_keys {
return Ok(());
}
for obj in self.schema.objects() {
if obj.obj_type != crate::schema::ObjectType::Table {
continue;
}
let fks: Vec<ForeignKey> = self
.foreign_keys_of(&obj.name)?
.into_iter()
.filter(|fk| fk.initially_deferred)
.collect();
if fks.is_empty() {
continue;
}
let meta = self.table_meta(&obj.name, None)?;
// The child may be WITHOUT ROWID — scan by storage kind.
for row in self.scan_rows(&meta)? {
for fk in &fks {
if let Some(key) = self.child_key_values(&meta, fk, &row)
&& !self.parent_has_key(fk, &key)?
{
return Err(Error::Constraint("FOREIGN KEY constraint failed".into()));
}
}
}
}
Ok(())
}
/// The child key values for `fk` from a child row, or `None` if any is NULL.
fn child_key_values(
&self,
meta: &TableMeta,
fk: &ForeignKey,
values: &[Value],
) -> Option<Vec<Value>> {
let mut key = Vec::with_capacity(fk.columns.len());
for cname in &fk.columns {
let pos = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(cname))?;
let v = values.get(pos)?;
if matches!(v, Value::Null) {
return None;
}
key.push(v.clone());
}
Some(key)
}
/// Whether the parent table of `fk` has a row whose referenced columns equal
/// `key`.
fn parent_has_key(&self, fk: &ForeignKey, key: &[Value]) -> Result<bool> {
let pmeta = self.table_meta(&fk.ref_table, None)?;
let positions = self.column_positions(&pmeta, &fk.ref_columns)?;
// The parent may be WITHOUT ROWID — scan by storage kind.
for row in self.scan_rows(&pmeta)? {
if positions.iter().zip(key).all(|(&p, k)| {
// SQLite compares under the *parent* key column's affinity and
// collation: a text child '1' matches an INTEGER parent key 1 (and
// 'x' cannot), and a NOCASE parent key matches case-insensitively.
let (pv, kv) = eval::apply_comparison_affinity(
row[p].clone(),
Some(pmeta.columns[p].affinity),
k.clone(),
None,
);
crate::value::cmp_values_coll(&pv, &kv, pmeta.columns[p].collation)
== core::cmp::Ordering::Equal
}) {
return Ok(true);
}
}
Ok(false)
}
/// Column positions in `meta` for the given names.
fn column_positions(&self, meta: &TableMeta, names: &[String]) -> Result<Vec<usize>> {
names
.iter()
.map(|n| {
meta.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(n))
.ok_or_else(|| Error::Error(format!("no such column: {n}")))
})
.collect()
}
/// Enforce referential actions when a parent row changes. `old_key` is the
/// parent row's referenced-column values before the change; `new_key` is the
/// values after (for `UPDATE`), or `None` for `DELETE`.
fn enforce_parent_change(
&mut self,
parent_table: &str,
old_vals: &[Value],
new_vals: Option<&[Value]>,
params: &Params,
) -> Result<()> {
if !self.foreign_keys {
return Ok(());
}
// Find every (child table, fk) that references this parent.
let table_names: Vec<String> = self
.schema
.objects()
.iter()
.filter(|o| o.obj_type == crate::schema::ObjectType::Table)
.map(|o| o.name.clone())
.collect();
let mut referencing: Vec<(String, ForeignKey)> = Vec::new();
for name in table_names {
for fk in self.foreign_keys_of(&name)? {
if fk.ref_table.eq_ignore_ascii_case(parent_table) {
referencing.push((name.clone(), fk));
}
}
}
if referencing.is_empty() {
return Ok(());
}
let pmeta = self.table_meta(parent_table, None)?;
for (child_table, fk) in referencing {
let ppos = self.column_positions(&pmeta, &fk.ref_columns)?;
let old_key: Vec<Value> = ppos.iter().map(|&p| old_vals[p].clone()).collect();
// A NULL parent key can't be referenced.
if old_key.iter().any(|v| matches!(v, Value::Null)) {
continue;
}
let is_delete = new_vals.is_none();
let action = if is_delete {
fk.on_delete
} else {
fk.on_update
};
// A deferred FK's NO ACTION orphan check waits for COMMIT (inside an
// explicit transaction); RESTRICT and the data-changing actions
// (CASCADE / SET NULL / SET DEFAULT) always run now.
if action == FkAction::NoAction && fk.initially_deferred && self.in_tx {
continue;
}
// If this is an UPDATE that didn't change the referenced key, skip.
if let Some(nv) = new_vals {
let new_key: Vec<Value> = ppos.iter().map(|&p| nv[p].clone()).collect();
if new_key
.iter()
.zip(&old_key)
.all(|(a, b)| eval::compare(a, b) == core::cmp::Ordering::Equal)
{
continue;
}
}
self.apply_fk_action(&child_table, &fk, &old_key, new_vals, &ppos, action, params)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn apply_fk_action(
&mut self,
child_table: &str,
fk: &ForeignKey,
old_key: &[Value],
new_parent: Option<&[Value]>,
parent_pos: &[usize],
action: FkAction,
params: &Params,
) -> Result<()> {
// Mark writes made by this FK action as indirect for any active session
// (SQLite's preupdate depth is non-zero inside FK-action sub-programs).
self.fk_depth.set(self.fk_depth.get() + 1);
let r = self.apply_fk_action_inner(
child_table,
fk,
old_key,
new_parent,
parent_pos,
action,
params,
);
self.fk_depth.set(self.fk_depth.get() - 1);
r
}
#[allow(clippy::too_many_arguments)]
fn apply_fk_action_inner(
&mut self,
child_table: &str,
fk: &ForeignKey,
old_key: &[Value],
new_parent: Option<&[Value]>,
parent_pos: &[usize],
action: FkAction,
params: &Params,
) -> Result<()> {
let cmeta = self.table_meta(child_table, None)?;
let cpos = self.column_positions(&cmeta, &fk.columns)?;
// The parent key columns' affinities — applied to the child value when
// matching, the same rule as the child→parent existence check
// (`parent_has_key`): a text child '1' matches an INTEGER parent key 1.
let pmeta = self.table_meta(&fk.ref_table, None)?;
// Whether a child row's FK columns equal `old_key` under the parent key
// columns' affinity/collation (the same rule `parent_has_key` uses).
let row_matches = |row: &[Value]| -> bool {
cpos.iter()
.zip(old_key)
.zip(parent_pos)
.all(|((&cp, k), &pp)| {
let (pv, kv) = eval::apply_comparison_affinity(
k.clone(),
Some(pmeta.columns[pp].affinity),
row[cp].clone(),
None,
);
crate::value::cmp_values_coll(&pv, &kv, pmeta.columns[pp].collation)
== core::cmp::Ordering::Equal
})
};
// A WITHOUT ROWID child is index-organized (no rowid): identify its rows
// by scan position and apply changes with a whole-table rewrite (the same
// primitive its native DELETE/UPDATE use), never by rowid.
if cmeta.without_rowid {
let rows = self.scan_without_rowid(&cmeta)?;
let matched: Vec<usize> = rows
.iter()
.enumerate()
.filter(|(_, r)| row_matches(r))
.map(|(i, _)| i)
.collect();
if matched.is_empty() {
return Ok(());
}
return self.apply_fk_action_wr(
&cmeta,
&pmeta,
child_table,
fk,
old_key,
new_parent,
parent_pos,
&cpos,
action,
&rows,
&matched,
params,
);
}
// Find child rowids whose key matches old_key.
let mut matches: Vec<i64> = Vec::new();
for (rowid, row) in self.scan_table(&cmeta)? {
if row_matches(&row) {
matches.push(rowid);
}
}
if matches.is_empty() {
return Ok(());
}
match action {
FkAction::NoAction | FkAction::Restrict => {
Err(Error::Constraint("FOREIGN KEY constraint failed".into()))
}
FkAction::Cascade if new_parent.is_none() => {
// DELETE CASCADE: delete the matching child rows (recursively).
for rowid in matches {
self.delete_row_cascade(child_table, &cmeta, rowid, params)?;
}
Ok(())
}
FkAction::Cascade => {
// UPDATE CASCADE: set child key columns to the new parent key.
let new_parent = new_parent.unwrap();
let new_key: Vec<Value> =
parent_pos.iter().map(|&p| new_parent[p].clone()).collect();
for rowid in matches {
self.update_child_key(&cmeta, child_table, rowid, &cpos, &new_key)?;
}
Ok(())
}
FkAction::SetNull => {
let nulls = alloc::vec![Value::Null; cpos.len()];
for rowid in matches {
self.update_child_key(&cmeta, child_table, rowid, &cpos, &nulls)?;
}
Ok(())
}
FkAction::SetDefault => {
let defaults = self.fk_set_default_values(
&cmeta, &pmeta, fk, old_key, new_parent, parent_pos, &cpos, params,
)?;
for rowid in matches {
self.update_child_key(&cmeta, child_table, rowid, &cpos, &defaults)?;
}
Ok(())
}
}
}
/// Compute the SET DEFAULT replacement key for `fk`'s child columns and
/// verify (as SQLite does) that it still references an existing parent —
/// unless a NULL makes the key MATCH SIMPLE-satisfied. Shared by the rowid
/// and WITHOUT ROWID child paths.
#[allow(clippy::too_many_arguments)]
fn fk_set_default_values(
&self,
cmeta: &TableMeta,
pmeta: &TableMeta,
fk: &ForeignKey,
old_key: &[Value],
new_parent: Option<&[Value]>,
parent_pos: &[usize],
cpos: &[usize],
params: &Params,
) -> Result<Vec<Value>> {
let defaults: Vec<Value> = cpos
.iter()
.map(|&p| match &cmeta.defaults[p] {
Some(e) => eval::eval(e, &EvalCtx::rowless(params)).unwrap_or(Value::Null),
None => Value::Null,
})
.collect();
// SQLite re-checks the FK after applying the default value: unless the
// default key contains a NULL (MATCH SIMPLE ⇒ satisfied), it must
// reference a parent that still exists *after* this change, else the
// child dangles and the statement fails.
if !defaults.iter().any(|v| matches!(v, Value::Null)) {
// Compare two FK keys under the parent columns' affinity / collation,
// the same rule the child→parent match uses.
let key_eq = |a: &[Value], b: &[Value]| -> bool {
a.iter().zip(b).zip(parent_pos).all(|((av, bv), &pp)| {
let (x, y) = eval::apply_comparison_affinity(
av.clone(),
Some(pmeta.columns[pp].affinity),
bv.clone(),
None,
);
crate::value::cmp_values_coll(&x, &y, pmeta.columns[pp].collation)
== core::cmp::Ordering::Equal
})
};
let valid = match new_parent {
// UPDATE: the parent whose key was `old_key` now has the new key.
// The default is valid if it names that new key, or a *different*
// unchanged parent (`parent_has_key` still sees the pre-write
// table, so exclude the row being changed).
Some(np) => {
let new_key: Vec<Value> = parent_pos.iter().map(|&p| np[p].clone()).collect();
key_eq(&defaults, &new_key)
|| (self.parent_has_key(fk, &defaults)? && !key_eq(&defaults, old_key))
}
// DELETE: the parent row is already removed (exec_delete_inner
// reorders the delete ahead of this), so a plain existence check
// reflects the post-delete state.
None => self.parent_has_key(fk, &defaults)?,
};
if !valid {
return Err(Error::Constraint("FOREIGN KEY constraint failed".into()));
}
}
Ok(defaults)
}
/// Apply a foreign-key action to a WITHOUT ROWID (index-organized) child.
/// Rows are identified by their scan position (`matched` indexes into
/// `rows`); the change is committed with a whole-table rewrite via
/// [`rewrite_without_rowid`](Self::rewrite_without_rowid) +
/// [`rebuild_wr_indexes`](Self::rebuild_wr_indexes) — the same primitives the
/// table's native DELETE/UPDATE use. `compact_table` is rowid-only and must
/// never run here.
#[allow(clippy::too_many_arguments)]
fn apply_fk_action_wr(
&mut self,
cmeta: &TableMeta,
pmeta: &TableMeta,
child_table: &str,
fk: &ForeignKey,
old_key: &[Value],
new_parent: Option<&[Value]>,
parent_pos: &[usize],
cpos: &[usize],
action: FkAction,
rows: &[Vec<Value>],
matched: &[usize],
params: &Params,
) -> Result<()> {
match action {
FkAction::NoAction | FkAction::Restrict => {
Err(Error::Constraint("FOREIGN KEY constraint failed".into()))
}
FkAction::Cascade if new_parent.is_none() => {
// DELETE CASCADE: cascade each matched row to its own dependents
// first (this may recurse — under a self-referential FK, back into
// this same table), then drop the matched rows.
let victims: Vec<Vec<Value>> = matched.iter().map(|&i| rows[i].clone()).collect();
for row in &victims {
self.enforce_parent_change(child_table, row, None, params)?;
if self.fk_depth.get() > 0 {
self.record_session_change(
child_table,
cmeta,
crate::session::ChangeOp::Delete,
0,
Some(row),
None,
);
}
}
// Re-scan live: a recursive cascade may have already rewritten this
// table. Keep every row that is not one of the victims.
let live = self.scan_without_rowid(cmeta)?;
let kept = live.into_iter().filter(|r| !victims.contains(r));
self.rewrite_without_rowid(cmeta, kept)?;
self.rebuild_wr_indexes(cmeta, child_table)?;
Ok(())
}
_ => {
// The data-changing actions all rewrite the matched rows' FK
// columns to a new key: UPDATE CASCADE → the new parent key;
// SET NULL → NULLs; SET DEFAULT → the columns' DEFAULTs.
let new_vals: Vec<Value> = match action {
FkAction::Cascade => {
let np = new_parent.expect("UPDATE action has a new parent");
parent_pos.iter().map(|&p| np[p].clone()).collect()
}
FkAction::SetNull => alloc::vec![Value::Null; cpos.len()],
FkAction::SetDefault => self.fk_set_default_values(
cmeta, pmeta, fk, old_key, new_parent, parent_pos, cpos, params,
)?,
FkAction::NoAction | FkAction::Restrict => unreachable!(),
};
let mut out = rows.to_vec();
for &i in matched {
let original = rows[i].clone();
for (&p, v) in cpos.iter().zip(&new_vals) {
out[i][p] = v.clone();
}
if self.fk_depth.get() > 0 {
self.record_session_change(
child_table,
cmeta,
crate::session::ChangeOp::Update,
0,
Some(&original),
Some(&out[i]),
);
}
}
// A change to a FK column that is part of the PK re-clusters the
// b-tree; the whole-table rewrite handles that transparently.
self.rewrite_without_rowid(cmeta, out.into_iter())?;
self.rebuild_wr_indexes(cmeta, child_table)?;
Ok(())
}
}
}
/// Delete one child row by rowid, first cascading to its own children.
fn delete_row_cascade(
&mut self,
table: &str,
meta: &TableMeta,
rowid: i64,
params: &Params,
) -> Result<()> {
// Read the row so its own dependents can be enforced.
let old = self.read_row(meta, rowid)?;
if let Some(old) = &old {
self.enforce_parent_change(table, old, None, params)?;
}
delete_table(self.backend.writer()?, meta.root, rowid)?;
// This delete can leave an empty leaf in the child b-tree; the top-level
// DML compacts it once the statement finishes (per-row compaction here
// would be O(rows²)).
self.cascade_compact.borrow_mut().insert(table.to_string());
let indexes = self.indexes_of(table)?;
if !indexes.is_empty() {
self.rebuild_indexes(meta, &indexes)?;
}
// Record the FK-action delete for an active session (indirect, since
// `fk_depth > 0` here). Skipped for the non-FK caller (INSERT OR
// REPLACE), whose conflict-delete is recorded on its own path.
if self.fk_depth.get() > 0
&& let Some(old) = &old
{
self.record_session_change(
table,
meta,
crate::session::ChangeOp::Delete,
rowid,
Some(old.as_slice()),
None,
);
}
Ok(())
}
/// Set specific columns of a child row (by position) to new values.
fn update_child_key(
&mut self,
meta: &TableMeta,
table: &str,
rowid: i64,
positions: &[usize],
new_vals: &[Value],
) -> Result<()> {
let Some(mut row) = self.read_row(meta, rowid)? else {
return Ok(());
};
// Snapshot the pre-update row for session recording (FK context only).
let old_row = if self.fk_depth.get() > 0 {
Some(row.clone())
} else {
None
};
for (&p, v) in positions.iter().zip(new_vals) {
row[p] = v.clone();
}
// Re-encode and rewrite the row (rowid unchanged here).
let mut stored = row.clone();
if let Some(ipk) = meta.ipk {
stored[ipk] = Value::Null;
}
let record = encode_record(&stored);
insert_table(self.backend.writer()?, meta.root, rowid, &record)?;
let indexes = self.indexes_of(table)?;
if !indexes.is_empty() {
self.rebuild_indexes(meta, &indexes)?;
}
// Record the FK-action update (SET NULL / SET DEFAULT / cascade key
// change) for an active session (indirect, since `fk_depth > 0`).
if let Some(old_row) = &old_row {
self.record_session_change(
table,
meta,
crate::session::ChangeOp::Update,
rowid,
Some(old_row.as_slice()),
Some(row.as_slice()),
);
}
Ok(())
}
/// Read a single row's full column values by rowid (IPK filled in), or None.
fn read_row(&self, meta: &TableMeta, rowid: i64) -> Result<Option<Vec<Value>>> {
let encoding = self.backend.source().header().text_encoding;
let mut cur = TableCursor::new(self.backend.source(), meta.root);
if cur.seek(rowid)? {
let values = self.decode_full_row(meta, rowid, &cur.payload()?, encoding)?;
Ok(Some(values))
} else {
Ok(None)
}
}
/// Serialize the entire database into a byte vector holding a complete,
/// valid SQLite database file — the equivalent of `sqlite3_serialize()`.
///
/// Every page of the current committed database (WAL frames included, since
/// the read is WAL-aware) is read in order and concatenated, so the result
/// is byte-for-byte a database file that `sqlite3` opens with
/// `PRAGMA integrity_check = ok` and identical contents. This backs the
/// shell's `.backup`/`.save` commands and lets a caller snapshot a
/// `:memory:` database.
///
/// The read/write format version bytes on page 1 are normalized to the
/// rollback-journal value, so a database currently in WAL mode serializes to
/// a self-contained image that needs no companion `-wal` file.
pub fn serialize(&self) -> Result<Vec<u8>> {
let src = self.backend.source();
let n = src.page_count();
let page_size = src.header().page_size as usize;
let mut out = Vec::with_capacity(n as usize * page_size);
for i in 1..=n {
out.extend_from_slice(src.page(i)?.data());
}
// Normalize the file-format read/write version bytes (offsets 18/19) to
// 1 (rollback journal), so a WAL-mode database yields a standalone image.
if out.len() >= 20 {
if out[18] == 2 {
out[18] = 1;
}
if out[19] == 2 {
out[19] = 1;
}
}
Ok(out)
}
/// Create a change-tracking [`Session`](crate::Session) on this connection
/// (roadmap D5). Call [`Session::attach`](crate::Session::attach) to begin
/// recording, run some `INSERT`/`UPDATE`/`DELETE`, then
/// [`Session::changeset`](Self::session_changeset) — reached through the
/// connection — to obtain the SQLite-compatible changeset blob.
///
/// Only one session is active at a time; creating a new one replaces any
/// previous session's recorder on this connection.
pub fn create_session(&self) -> crate::session::Session {
let state = alloc::rc::Rc::new(core::cell::RefCell::new(
crate::session::SessionState::default(),
));
*self.session.borrow_mut() = Some(state.clone());
crate::session::Session::new(state)
}
/// Produce the changeset blob for `session`, reading the current values of
/// changed rows live from the database (so coalesced inserts/updates carry
/// their final values). Mirrors `sqlite3session_changeset`.
///
/// The blob is byte-compatible with SQLite's session extension for the
/// supported table shape (a rowid table with a single `INTEGER PRIMARY KEY`).
pub fn session_changeset(&self, session: &crate::session::Session) -> Result<Vec<u8>> {
// Snapshot the recorded changes, then serialize with a live row reader.
// `serialize` calls back into the closure by table name + the row's
// primary-key column values, and expects the row's current full column
// values (visible columns, declared order) or `None` if it is gone.
let state = session.state.borrow();
// The closure reads live rows; the first error it hits is captured here
// and surfaced after serialization.
let mut err: Option<Error> = None;
let bytes = crate::session::serialize(&state, |table, pk| {
match self.session_read_row_by_pk(table, pk) {
Ok(row) => row,
Err(e) => {
err.get_or_insert(e);
None
}
}
});
if let Some(e) = err {
return Err(e);
}
Ok(bytes)
}
/// Produce the **patchset** blob for `session`, reading the current values of
/// changed rows live from the database. Mirrors `sqlite3session_patchset`.
///
/// A patchset is the [`session_changeset`](Self::session_changeset) format
/// with the old, non-primary-key values omitted: a `DELETE` record carries
/// only the primary-key columns, and an `UPDATE` record carries only the
/// primary-key columns plus the changed new values (no `old.*` record). The
/// blob is byte-compatible with SQLite's session extension for every
/// supported table shape (single `INTEGER PRIMARY KEY`, single non-integer
/// PK, composite PK, and `WITHOUT ROWID`).
///
/// A patchset produced here can be applied with
/// [`changeset_apply`](Self::changeset_apply), which accepts both formats.
pub fn session_patchset(&self, session: &crate::session::Session) -> Result<Vec<u8>> {
let state = session.state.borrow();
let mut err: Option<Error> = None;
let bytes = crate::session::serialize_patchset(&state, |table, pk| {
match self.session_read_row_by_pk(table, pk) {
Ok(row) => row,
Err(e) => {
err.get_or_insert(e);
None
}
}
});
if let Some(e) = err {
return Err(e);
}
Ok(bytes)
}
/// Read the current full row (visible columns, declared order) of `table`
/// whose primary-key columns equal `pk` (the PK column values, in column
/// order). Returns `None` if no such row exists. Used by
/// [`session_changeset`](Self::session_changeset) to re-read the live value
/// of a recorded row at changeset time.
///
/// This runs a `SELECT <cols> FROM t WHERE pk1 IS ?1 AND …` through the
/// normal query engine, so it works uniformly for every supported PK shape
/// (single/ composite/ non-integer/ WITHOUT ROWID) — mirroring SQLite's
/// `sessionSelectStmt`, which likewise selects by the primary-key columns.
fn session_read_row_by_pk(&self, table: &str, pk: &[Value]) -> Result<Option<Vec<Value>>> {
let meta = self.table_meta(table, None)?;
let Some((_, pk_positions)) = self.session_pk_layout(table, &meta)? else {
// No declared PK — not a recorded shape; treat as "row not found".
return Ok(None);
};
if pk_positions.len() != pk.len() {
return Ok(None);
}
let quote = |n: &str| alloc::format!("\"{}\"", n.replace('"', "\"\""));
let visible: Vec<&ColumnInfo> = meta.columns.iter().filter(|c| !c.hidden).collect();
let cols_sql: Vec<String> = visible.iter().map(|c| quote(&c.name)).collect();
let mut wheres: Vec<String> = Vec::with_capacity(pk.len());
let mut positional: Vec<Value> = Vec::with_capacity(pk.len());
for (pos, val) in pk_positions.iter().zip(pk) {
if *pos >= visible.len() {
return Ok(None);
}
positional.push(val.clone());
wheres.push(alloc::format!(
"{} IS ?{}",
quote(&visible[*pos].name),
positional.len()
));
}
let sql = alloc::format!(
"SELECT {} FROM {} WHERE {}",
cols_sql.join(","),
quote(table),
wheres.join(" AND ")
);
let params = Params {
positional,
named: Vec::new(),
};
let res = self.query_params(&sql, ¶ms)?;
Ok(res.rows.into_iter().next())
}
/// The session primary-key layout for `table`: per-visible-column PK flags
/// (SQLite's `abPK`) and the PK column positions in column order. Returns
/// `None` if the table has no declared primary key (the session module does
/// not record such tables under its default configuration).
///
/// For a single `INTEGER PRIMARY KEY` this is just that column; for a
/// composite / non-integer / `WITHOUT ROWID` key it is every primary-key
/// column, in declared order — exactly the columns SQLite flags as PK.
fn session_pk_layout(
&self,
table: &str,
meta: &TableMeta,
) -> Result<Option<(Vec<u8>, Vec<usize>)>> {
let visible_ncol = meta.columns.iter().filter(|c| !c.hidden).count();
// A single INTEGER PRIMARY KEY (rowid alias) is recorded directly: it is
// the sole PK column, so its `abPK` byte is 1.
if let Some(ipk) = meta.ipk {
if ipk >= visible_ncol {
return Ok(None);
}
let flags: Vec<u8> = (0..visible_ncol).map(|i| u8::from(i == ipk)).collect();
return Ok(Some((flags, alloc::vec![ipk])));
}
// Otherwise re-derive the PK columns from the table's DDL. This covers a
// composite PK, a non-integer single PK, and a WITHOUT ROWID table.
let obj = match self.schema.table(table) {
Some(o) => o,
None => return Ok(None),
};
let Some(sql) = obj.sql.as_ref() else {
return Ok(None);
};
let Statement::CreateTable(ct) = sql::parse_one(sql)? else {
return Ok(None);
};
// `primary_key_positions` returns the PK columns in PRIMARY-KEY-clause
// order. That order is the 1-based PK ordinal SQLite's `abPK`/table_xinfo
// `pk` reports (so `PRIMARY KEY(b, a)` gives b→1, a→2). Build the per-
// column ordinal byte array from it, keeping non-PK columns at 0.
let pk_decl = primary_key_positions(&ct);
if pk_decl.is_empty() {
return Ok(None);
}
let mut flags: Vec<u8> = alloc::vec![0u8; visible_ncol];
for (ordinal, &pos) in pk_decl.iter().enumerate() {
if pos < visible_ncol {
flags[pos] = (ordinal + 1) as u8;
}
}
// The session hashes and stores primary-key *values* in column order (it
// iterates columns 0..nCol and picks out the PK-flagged ones), regardless
// of the PRIMARY KEY clause order. So the value positions are the flagged
// columns in ascending column order.
let pk_positions: Vec<usize> = (0..visible_ncol).filter(|i| flags[*i] != 0).collect();
if pk_positions.is_empty() {
return Ok(None);
}
Ok(Some((flags, pk_positions)))
}
/// Apply a changeset **or patchset** blob (as produced by
/// [`session_changeset`](Self::session_changeset),
/// [`session_patchset`](Self::session_patchset), or SQLite's session
/// extension) to this connection's database, reproducing
/// `sqlite3changeset_apply`'s default behaviour (roadmap D5). SQLite's apply
/// accepts both formats, and so does this: a patchset's `DELETE`/`UPDATE`
/// records (which omit the old, non-PK values) match their target row by
/// primary key only.
///
/// Each `INSERT`/`UPDATE`/`DELETE` record is applied to the matching table.
/// The default conflict dispositions are honoured:
///
/// * A `DELETE`/`UPDATE` whose target row is missing (`NOTFOUND`) or whose
/// recorded `old.*` values no longer match the live row (`DATA`) is
/// **omitted** (silently skipped).
/// * An `INSERT` whose primary key already exists, or any change that hits a
/// constraint (`CONFLICT`/`CONSTRAINT`), **aborts** the whole apply: every
/// change made so far is rolled back and an error is returned.
///
/// The entire apply runs inside a savepoint, so an abort leaves the database
/// exactly as it was before the call.
///
/// # Scope (first slice)
///
/// Mirrors the generation side: rowid tables whose primary key is a single
/// `INTEGER PRIMARY KEY` column. A table named in the changeset that is
/// absent, of a different column count, or has a mismatched primary-key
/// layout is treated as a schema mismatch and its changes are skipped (as
/// SQLite does). Values of every storage class are supported.
pub fn changeset_apply(&mut self, changeset: &[u8]) -> Result<()> {
// The default conflict handler reproduces sqlite's default xConflict:
// omit a DATA/NOTFOUND (missing / mismatched DELETE-UPDATE target),
// abort a CONFLICT/CONSTRAINT (a colliding INSERT or a constraint hit).
self.changeset_apply_with(changeset, |kind| match kind {
crate::session::ConflictType::Data | crate::session::ConflictType::NotFound => {
crate::session::ConflictAction::Omit
}
crate::session::ConflictType::Conflict | crate::session::ConflictType::Constraint => {
crate::session::ConflictAction::Abort
}
})
}
/// Apply a changeset **or patchset** blob like
/// [`changeset_apply`](Self::changeset_apply), but drive conflict resolution
/// through the caller-supplied handler `on_conflict` — the equivalent of the
/// `xConflict` callback of SQLite's `sqlite3changeset_apply`.
///
/// For every change that cannot be applied cleanly the handler is called with
/// the [`ConflictType`](crate::ConflictType) and returns a
/// [`ConflictAction`](crate::ConflictAction):
///
/// * [`Omit`](crate::ConflictAction::Omit) — skip this change, keep applying.
/// * [`Replace`](crate::ConflictAction::Replace) — force the change through:
/// a [`Conflict`](crate::ConflictType::Conflict) `INSERT` deletes the
/// colliding row then inserts; a [`Data`](crate::ConflictType::Data)
/// `UPDATE`/`DELETE` is re-matched by primary key alone. `Replace` on a
/// [`NotFound`](crate::ConflictType::NotFound) or
/// [`Constraint`](crate::ConflictType::Constraint) conflict (where SQLite
/// does not permit it) is treated as `Abort`.
/// * [`Abort`](crate::ConflictAction::Abort) — roll back every change made so
/// far and return an error.
///
/// The whole apply runs inside a savepoint, so an abort restores the database
/// to its state before the call. Supports the same table shapes as
/// [`changeset_apply`](Self::changeset_apply) (any declared primary key). A
/// `Replace` on an `INSERT` removes the primary-key-colliding row; a row that
/// collides only on a *secondary* `UNIQUE` index surfaces as a separate
/// `Constraint` conflict rather than being replaced.
pub fn changeset_apply_with(
&mut self,
changeset: &[u8],
mut on_conflict: impl FnMut(crate::session::ConflictType) -> crate::session::ConflictAction,
) -> Result<()> {
let tables = crate::session::parse_changeset(changeset)?;
if tables.is_empty() {
return Ok(());
}
const SP: &str = "graphite_changeset_apply";
// Wrap the whole apply in a savepoint so an abort (a CONFLICT/CONSTRAINT
// under the default disposition, or a handler-requested Abort) rolls back
// every change applied so far, exactly like sqlite's ROLLBACK-TO + RELEASE.
self.execute_params(&alloc::format!("SAVEPOINT \"{SP}\""), &Params::default())?;
let mut rebase = None;
let result = self.changeset_apply_inner(&tables, &mut on_conflict, &mut rebase);
match result {
Ok(()) => {
self.execute_params(&alloc::format!("RELEASE \"{SP}\""), &Params::default())?;
Ok(())
}
Err(e) => {
let _ = self
.execute_params(&alloc::format!("ROLLBACK TO \"{SP}\""), &Params::default());
let _ =
self.execute_params(&alloc::format!("RELEASE \"{SP}\""), &Params::default());
Err(e)
}
}
}
/// Apply a changeset like [`changeset_apply_with`](Self::changeset_apply_with)
/// and, in addition, capture and return a **rebase** blob describing how each
/// conflict was resolved. The blob configures a [`Rebaser`](crate::Rebaser)
/// so a *local* changeset can be rebased onto these just-applied (remote)
/// changes. Mirrors `sqlite3changeset_apply_v2`'s rebase output (roadmap D5).
///
/// On a handler-requested `Abort` (or a constraint the handler aborts on) the
/// apply rolls back and returns the error, and no rebase blob is produced.
///
/// # Errors
/// As [`changeset_apply_with`](Self::changeset_apply_with).
pub fn changeset_apply_rebase(
&mut self,
changeset: &[u8],
mut on_conflict: impl FnMut(crate::session::ConflictType) -> crate::session::ConflictAction,
) -> Result<Vec<u8>> {
let tables = crate::session::parse_changeset(changeset)?;
if tables.is_empty() {
return Ok(Vec::new());
}
const SP: &str = "graphite_changeset_apply";
self.execute_params(&alloc::format!("SAVEPOINT \"{SP}\""), &Params::default())?;
let mut rebase = Some(Vec::new());
let result = self.changeset_apply_inner(&tables, &mut on_conflict, &mut rebase);
match result {
Ok(()) => {
self.execute_params(&alloc::format!("RELEASE \"{SP}\""), &Params::default())?;
Ok(crate::session::serialize_rebase(
&rebase.unwrap_or_default(),
))
}
Err(e) => {
let _ = self
.execute_params(&alloc::format!("ROLLBACK TO \"{SP}\""), &Params::default());
let _ =
self.execute_params(&alloc::format!("RELEASE \"{SP}\""), &Params::default());
Err(e)
}
}
}
/// The body of [`changeset_apply`](Self::changeset_apply), run inside the
/// caller's savepoint. Returns `Err` to signal an abort (the caller rolls
/// back).
fn changeset_apply_inner(
&mut self,
tables: &[crate::session::TableChangeset],
on_conflict: &mut dyn FnMut(crate::session::ConflictType) -> crate::session::ConflictAction,
rebase: &mut Option<Vec<crate::session::RebaseEntry>>,
) -> Result<()> {
for tbl in tables {
// Resolve the target table; a missing table is a schema mismatch
// (skip the whole table's changes), matching sqlite's xFilter=NULL
// + "no such table" log-and-continue.
let meta = match self.table_meta(&tbl.name, None) {
Ok(m) => m,
Err(_) => continue,
};
// Resolve the table's primary-key layout (single/composite/non-int/
// WITHOUT ROWID). A table with no declared primary key is skipped.
let Some((expected_pk, _)) = self.session_pk_layout(&tbl.name, &meta)? else {
continue;
};
let cols: Vec<&ColumnInfo> = meta.columns.iter().filter(|c| !c.hidden).collect();
// The changeset header's column count and PK-flag layout must match
// the live table, otherwise skip (schema mismatch), matching sqlite.
if cols.len() != tbl.ncol || expected_pk != tbl.pk_flags {
continue;
}
let col_names: Vec<String> = cols.iter().map(|c| c.name.clone()).collect();
for change in &tbl.changes {
self.apply_one_change(
&tbl.name,
&col_names,
&tbl.pk_flags,
change,
on_conflict,
rebase,
)?;
}
}
Ok(())
}
/// Apply one parsed change record, resolving any conflict through
/// `on_conflict` (see [`changeset_apply_with`](Self::changeset_apply_with)).
/// Returns `Err` only when the change must abort the whole apply.
#[allow(clippy::too_many_arguments)]
fn apply_one_change(
&mut self,
table: &str,
col_names: &[String],
pk_flags: &[u8],
change: &crate::session::ChangeRecord,
on_conflict: &mut dyn FnMut(crate::session::ConflictType) -> crate::session::ConflictAction,
rebase: &mut Option<Vec<crate::session::RebaseEntry>>,
) -> Result<()> {
use crate::session::{ChangeOp, ConflictAction as CA, ConflictType as CT};
let quote = |n: &str| alloc::format!("\"{}\"", n.replace('"', "\"\""));
let qtable = quote(table);
let ncol = col_names.len();
let is_pk = |i: usize| pk_flags.get(i).copied().unwrap_or(0) != 0;
// Capture a rebase record for a conflict resolved OMIT/REPLACE (a no-op
// unless `changeset_apply_rebase` is collecting). Values follow SQLite's
// `sessionRebaseAdd`: old for a DELETE or an UPDATE's PK columns, else new.
let mut capture = |action: CA| {
let Some(entries) = rebase.as_mut() else {
return;
};
if !matches!(action, CA::Omit | CA::Replace) {
return;
}
let mut values = Vec::with_capacity(ncol);
for i in 0..ncol {
let use_old =
change.op == ChangeOp::Delete || (change.op == ChangeOp::Update && is_pk(i));
let src = if use_old { &change.old } else { &change.new };
values.push(src.get(i).cloned().flatten());
}
entries.push(crate::session::RebaseEntry {
table: table.to_string(),
ncol,
pk_flags: pk_flags.to_vec(),
op: change.op,
replace: matches!(action, CA::Replace),
values,
});
};
// The error returned when the handler aborts a conflict that carries no
// underlying engine error (a handler-requested Abort, or a Replace where
// sqlite does not permit one). Mirrors `sqlite3changeset_apply`'s
// `SQLITE_ABORT`.
let abort_err = || Error::Constraint(String::from("changeset apply aborted by conflict"));
// A PK-only WHERE clause built from a change's PK columns, matching the
// row by primary key alone (used to detect DATA vs NOTFOUND, and to
// force a Replace through).
let pk_where = |source: &[Option<Value>]| -> (String, Vec<Value>) {
let mut wheres: Vec<String> = Vec::new();
let mut positional: Vec<Value> = Vec::new();
for (i, name) in col_names.iter().enumerate() {
if is_pk(i) {
let v = source.get(i).and_then(|o| o.clone()).unwrap_or(Value::Null);
positional.push(v);
wheres.push(alloc::format!("{} IS ?{}", quote(name), positional.len()));
}
}
(wheres.join(" AND "), positional)
};
match change.op {
ChangeOp::Insert => {
let cols_sql: Vec<String> = col_names.iter().map(|c| quote(c)).collect();
let placeholders: Vec<String> =
(1..=ncol).map(|i| alloc::format!("?{i}")).collect();
let sql = alloc::format!(
"INSERT INTO {qtable}({}) VALUES({})",
cols_sql.join(","),
placeholders.join(",")
);
let positional: Vec<Value> = change
.new
.iter()
.map(|v| v.clone().unwrap_or(Value::Null))
.collect();
let params = Params {
positional,
named: Vec::new(),
};
match self.execute_params(&sql, ¶ms) {
Ok(_) => Ok(()),
// Only a constraint violation is a changeset conflict; any
// other error propagates unchanged.
Err(e @ Error::Constraint(_)) => {
// Classify: a colliding primary key is CONFLICT, any
// other constraint (secondary UNIQUE / NOT NULL / CHECK)
// is CONSTRAINT.
let pk_exists =
self.session_pk_row_exists(table, col_names, pk_flags, &change.new)?;
let kind = if pk_exists {
CT::Conflict
} else {
CT::Constraint
};
let action = on_conflict(kind);
capture(action);
match action {
CA::Omit => Ok(()),
CA::Abort => Err(e),
CA::Replace if kind == CT::Conflict => {
// Delete the primary-key-colliding row, then
// retry the insert.
let (w, wp) = pk_where(&change.new);
let _ = self.execute_params(
&alloc::format!("DELETE FROM {qtable} WHERE {w}"),
&Params {
positional: wp,
named: Vec::new(),
},
)?;
match self.execute_params(&sql, ¶ms) {
Ok(_) => Ok(()),
// A row that also collides on a secondary
// UNIQUE index is a separate CONSTRAINT.
Err(e2 @ Error::Constraint(_)) => {
let a2 = on_conflict(CT::Constraint);
capture(a2);
match a2 {
CA::Omit => Ok(()),
_ => Err(e2),
}
}
Err(e2) => Err(e2),
}
}
// Replace is not permitted for a CONSTRAINT conflict;
// treat it as Abort (as sqlite does).
CA::Replace => Err(e),
}
}
Err(e) => Err(e),
}
}
ChangeOp::Delete => {
// Full match: primary key + every present old non-PK value.
let mut wheres: Vec<String> = Vec::new();
let mut positional: Vec<Value> = Vec::new();
for (i, name) in col_names.iter().enumerate() {
let old = &change.old[i];
if is_pk(i) {
let v = old.clone().unwrap_or(Value::Null);
positional.push(v);
wheres.push(alloc::format!("{} IS ?{}", quote(name), positional.len()));
} else if let Some(v) = old {
positional.push(v.clone());
wheres.push(alloc::format!("{} IS ?{}", quote(name), positional.len()));
}
}
let sql = alloc::format!("DELETE FROM {qtable} WHERE {}", wheres.join(" AND "));
let n = self.execute_params(
&sql,
&Params {
positional,
named: Vec::new(),
},
)?;
if n >= 1 {
return Ok(());
}
// 0 rows changed: DATA (the PK row exists but old.* differ) or
// NOTFOUND (no row with that PK).
let kind = if self.session_pk_row_exists(table, col_names, pk_flags, &change.old)? {
CT::Data
} else {
CT::NotFound
};
let action = on_conflict(kind);
capture(action);
match action {
CA::Omit => Ok(()),
CA::Replace if kind == CT::Data => {
// Force the delete through, matched by primary key alone.
let (w, wp) = pk_where(&change.old);
let _ = self.execute_params(
&alloc::format!("DELETE FROM {qtable} WHERE {w}"),
&Params {
positional: wp,
named: Vec::new(),
},
)?;
Ok(())
}
// Abort, or Replace on a NOTFOUND (which sqlite forbids).
_ => Err(abort_err()),
}
}
ChangeOp::Update => {
let mut sets: Vec<String> = Vec::new();
let mut wheres: Vec<String> = Vec::new();
let mut positional: Vec<Value> = Vec::new();
for (i, name) in col_names.iter().enumerate() {
if !is_pk(i)
&& let Some(v) = &change.new[i]
{
positional.push(v.clone());
sets.push(alloc::format!("{}=?{}", quote(name), positional.len()));
}
}
if sets.is_empty() {
// No column actually changes; nothing to apply.
return Ok(());
}
for (i, name) in col_names.iter().enumerate() {
let old = &change.old[i];
if is_pk(i) {
let v = old.clone().unwrap_or(Value::Null);
positional.push(v);
wheres.push(alloc::format!("{} IS ?{}", quote(name), positional.len()));
} else if let Some(v) = old {
positional.push(v.clone());
wheres.push(alloc::format!("{} IS ?{}", quote(name), positional.len()));
}
}
let sql = alloc::format!(
"UPDATE {qtable} SET {} WHERE {}",
sets.join(","),
wheres.join(" AND ")
);
let n = self.execute_params(
&sql,
&Params {
positional,
named: Vec::new(),
},
)?;
if n >= 1 {
return Ok(());
}
// 0 rows changed: DATA or NOTFOUND, exactly like DELETE.
let kind = if self.session_pk_row_exists(table, col_names, pk_flags, &change.old)? {
CT::Data
} else {
CT::NotFound
};
let action = on_conflict(kind);
capture(action);
match action {
CA::Omit => Ok(()),
CA::Replace if kind == CT::Data => {
// Force the update through, matched by primary key alone.
let mut sets: Vec<String> = Vec::new();
let mut positional: Vec<Value> = Vec::new();
for (i, name) in col_names.iter().enumerate() {
if !is_pk(i)
&& let Some(v) = &change.new[i]
{
positional.push(v.clone());
sets.push(alloc::format!("{}=?{}", quote(name), positional.len()));
}
}
let (w, wp) = pk_where(&change.old);
positional.extend(wp);
// Renumber the WHERE placeholders to follow the SET ones.
let mut idx = sets.len();
let w = w
.split(" AND ")
.map(|term| {
idx += 1;
// term is `"col" IS ?K`; rewrite the placeholder.
let cut = term.rfind('?').unwrap_or(term.len());
alloc::format!("{}?{}", &term[..cut], idx)
})
.collect::<Vec<_>>()
.join(" AND ");
let sql =
alloc::format!("UPDATE {qtable} SET {} WHERE {}", sets.join(","), w);
match self.execute_params(
&sql,
&Params {
positional,
named: Vec::new(),
},
) {
Ok(_) => Ok(()),
Err(e @ Error::Constraint(_)) => {
let a2 = on_conflict(CT::Constraint);
capture(a2);
match a2 {
CA::Omit => Ok(()),
_ => Err(e),
}
}
Err(e) => Err(e),
}
}
_ => Err(abort_err()),
}
}
}
}
/// Whether a row whose primary-key columns equal `source`'s PK-flagged
/// values exists in `table`. Used by [`apply_one_change`](Self::apply_one_change)
/// to distinguish a `DATA` conflict (row present, values differ) from a
/// `NOTFOUND` conflict, and a primary-key `CONFLICT` from a secondary
/// `CONSTRAINT`.
fn session_pk_row_exists(
&self,
table: &str,
col_names: &[String],
pk_flags: &[u8],
source: &[Option<Value>],
) -> Result<bool> {
let quote = |n: &str| alloc::format!("\"{}\"", n.replace('"', "\"\""));
let mut wheres: Vec<String> = Vec::new();
let mut positional: Vec<Value> = Vec::new();
for (i, name) in col_names.iter().enumerate() {
if pk_flags.get(i).copied().unwrap_or(0) != 0 {
let v = source.get(i).and_then(|o| o.clone()).unwrap_or(Value::Null);
positional.push(v);
wheres.push(alloc::format!("{} IS ?{}", quote(name), positional.len()));
}
}
if wheres.is_empty() {
return Ok(false);
}
let sql = alloc::format!(
"SELECT 1 FROM {} WHERE {} LIMIT 1",
quote(table),
wheres.join(" AND ")
);
let res = self.query_params(
&sql,
&Params {
positional,
named: Vec::new(),
},
)?;
Ok(!res.rows.is_empty())
}
/// Write-path hook: when a session is active and `table` has a declared
/// primary key, record the row operation. A no-op when no session is active
/// (the common case) or the table has no primary key (an implicit-rowid
/// table — which SQLite's session module also skips by default).
///
/// `rowid` fills the `INTEGER PRIMARY KEY` slot for a rowid table (whose PK
/// value is the rowid); it is ignored for a non-integer / composite /
/// `WITHOUT ROWID` key. `old_row` / `new_row` are the row's full visible
/// column values (declared order) before / after the change:
///
/// * INSERT: `new_row` = the inserted row; `old_row` = `None`.
/// * DELETE: `old_row` = the removed row; `new_row` = `None`.
/// * UPDATE: `old_row` and `new_row` = the pre- and post-update rows.
///
/// An UPDATE is recorded exactly as SQLite's pre-update hook does it — as a
/// change keyed by the *old* primary key (op = UPDATE) plus one keyed by the
/// *new* primary key (op = INSERT). When the PK is unchanged both key the
/// same row and the second call coalesces away, leaving a plain UPDATE; when
/// the PK changes they key different rows, yielding a DELETE of the old key
/// (its live row is gone) and an INSERT of the new key.
fn record_session_change(
&self,
table: &str,
meta: &TableMeta,
op: crate::session::ChangeOp,
rowid: i64,
old_row: Option<&[Value]>,
new_row: Option<&[Value]>,
) {
// The update hook fires for every row change, independent of whether a
// session is recording. (record_session_change is called at every DML
// row-change site, so it is the natural universal notification point.)
if self.update_hook.borrow().is_some() {
let uop = match op {
crate::session::ChangeOp::Insert => UpdateOp::Insert,
crate::session::ChangeOp::Update => UpdateOp::Update,
crate::session::ChangeOp::Delete => UpdateOp::Delete,
};
self.fire_update_hook(uop, table, rowid);
}
if self.session.borrow().is_none() {
return;
}
// Resolve the primary-key layout (per-column flags + PK positions).
let Ok(Some((pk_flags, pk_positions))) = self.session_pk_layout(table, meta) else {
return;
};
let ncol = pk_flags.len();
// Extract this row's primary-key values (column order). For an INTEGER
// PRIMARY KEY column (a rowid alias) use the row's own value when it is a
// concrete integer (so an UPDATE that changes the rowid keys correctly on
// both the old and new value); fall back to the passed `rowid` when the
// stored slot is NULL (e.g. an auto-assigned INSERT).
let pk_values = |row: &[Value]| -> Option<Vec<Value>> {
if row.len() < ncol {
return None;
}
let mut out = Vec::with_capacity(pk_positions.len());
for &p in &pk_positions {
let v = if Some(p) == meta.ipk {
match &row[p] {
Value::Integer(i) => Value::Integer(*i),
_ => Value::Integer(rowid),
}
} else {
row[p].clone()
};
out.push(v);
}
Some(out)
};
// A change made while a trigger or FK action is running is *indirect*
// (SQLite's preupdate depth > 0); the session's own indirect mode is
// folded in inside `SessionState::record`.
let indirect = self.trigger_depth.get() > 0 || self.fk_depth.get() > 0;
match op {
crate::session::ChangeOp::Insert => {
let Some(row) = new_row else { return };
let Some(pk) = pk_values(row) else { return };
// SQLite stores only the PK in an insert's original record; the
// live row is re-read at changeset time. `old` here is unused.
let old = alloc::vec![Value::Null; ncol];
self.session
.borrow()
.as_ref()
.unwrap()
.borrow_mut()
.record(table, ncol, &pk_flags, op, pk, old, indirect);
}
crate::session::ChangeOp::Delete => {
let Some(row) = old_row else { return };
if row.len() < ncol {
return;
}
let Some(pk) = pk_values(row) else { return };
self.session.borrow().as_ref().unwrap().borrow_mut().record(
table,
ncol,
&pk_flags,
op,
pk,
row[..ncol].to_vec(),
indirect,
);
}
crate::session::ChangeOp::Update => {
let (Some(oldr), Some(newr)) = (old_row, new_row) else {
return;
};
if oldr.len() < ncol || newr.len() < ncol {
return;
}
let (Some(old_pk), Some(new_pk)) = (pk_values(oldr), pk_values(newr)) else {
return;
};
{
let cell = self.session.borrow();
let state = cell.as_ref().unwrap();
// Change keyed by the OLD primary key (op = UPDATE).
state.borrow_mut().record(
table,
ncol,
&pk_flags,
crate::session::ChangeOp::Update,
old_pk,
oldr[..ncol].to_vec(),
indirect,
);
// Change keyed by the NEW primary key (op = INSERT). If the
// PK did not change this coalesces into the UPDATE above.
state.borrow_mut().record(
table,
ncol,
&pk_flags,
crate::session::ChangeOp::Insert,
new_pk,
alloc::vec![Value::Null; ncol],
indirect,
);
}
}
}
}
/// Store a `CREATE TRIGGER` in `sqlite_schema` (type `trigger`, no b-tree).
fn exec_create_trigger(&mut self, ct: &CreateTrigger, sql_text: &str) -> Result<()> {
// A schema-qualified `CREATE TRIGGER aux.tr …` stores its SQL bare-named.
let stripped;
let sql_text = match ct.schema.as_deref() {
Some(s) => {
stripped = strip_schema_qualifier(sql_text, s)?;
stripped.as_str()
}
None => sql_text,
};
if self
.schema
.objects()
.iter()
.any(|o| o.name.eq_ignore_ascii_case(&ct.name))
{
if ct.if_not_exists {
return Ok(());
}
return Err(Error::Error(format!("trigger {} already exists", ct.name)));
}
// SQLite refuses to attach a trigger to a system table. The schema tables
// (sqlite_master / sqlite_schema / sqlite_temp_master) always count; any
// other `sqlite_`-prefixed table counts only when it physically exists.
// This outranks the missing-table, timing-mismatch, and body-qualifier
// checks below but is itself outranked by the duplicate-name check above.
if ct
.table
.get(..7)
.is_some_and(|p| p.eq_ignore_ascii_case("sqlite_"))
{
let always = ["sqlite_master", "sqlite_schema", "sqlite_temp_master"]
.iter()
.any(|n| ct.table.eq_ignore_ascii_case(n));
let exists = self.schema.table(&ct.table).is_some()
|| self
.temp_db
.as_ref()
.is_some_and(|t| t.schema.table(&ct.table).is_some());
if always || exists {
return Err(Error::Error("cannot create trigger on system table".into()));
}
}
// The target may be a table or (for INSTEAD OF triggers) a view. A temp
// trigger may fire on a main table, so when the temp database is the active
// schema also consult the swapped-out catalog (which then holds main).
let table_in_other = self
.temp_db
.as_ref()
.is_some_and(|t| t.schema.table(&ct.table).is_some());
if self.schema.table(&ct.table).is_none() && !table_in_other && !self.is_view(&ct.table) {
// SQLite schema-qualifies the missing table in CREATE TRIGGER/INDEX
// (the object's target schema, `main` by default).
return Err(Error::Error(format!(
"no such table: {}.{}",
ct.schema.as_deref().unwrap_or("main"),
ct.table
)));
}
// `INSTEAD OF` triggers may only attach to a view, and `BEFORE`/`AFTER`
// triggers only to a real table — sqlite rejects the mismatch at CREATE.
let target_is_view = self.is_view(&ct.table);
match ct.timing {
TriggerTiming::InsteadOf if !target_is_view => {
return Err(Error::Error(format!(
"cannot create INSTEAD OF trigger on table: {}",
ct.table
)));
}
TriggerTiming::Before | TriggerTiming::After if target_is_view => {
let kind = if ct.timing == TriggerTiming::Before {
"BEFORE"
} else {
"AFTER"
};
return Err(Error::Error(format!(
"cannot create {kind} trigger on view: {}",
ct.table
)));
}
_ => {}
}
// SQLite parses a trigger's body steps only after resolving its target, so
// the dup-name / missing-table / system-table / timing-mismatch errors
// above all outrank any body-step grammar error. The parser records the
// first such body violation (in source order) rather than throwing it, so
// it surfaces here — last. This covers a disallowed leading keyword
// (`near "PRAGMA"`), a `WITH`-prefixed body DML (`near "INSERT"`), a
// schema-qualified DML target (the body runs in the trigger's own
// database), a body `UPDATE`/`DELETE` row-limit extension or `RETURNING`
// (`near "ORDER"`/`near "RETURNING"`), and a body `INSERT … RETURNING`
// (`cannot use RETURNING in a trigger`).
if let Some(msg) = &ct.body_error {
return Err(Error::Error(msg.clone()));
}
let next = self.next_rowid(crate::schema::SCHEMA_ROOT_PAGE)?;
let row = encode_record(&[
Value::Text("trigger".into()),
Value::Text(ct.name.clone().into()),
Value::Text(ct.table.clone().into()),
Value::Integer(0),
Value::Text(canonical_schema_sql("CREATE TRIGGER ", sql_text).into()),
]);
insert_table(
self.backend.writer()?,
crate::schema::SCHEMA_ROOT_PAGE,
next,
&row,
)?;
let cookie = self
.backend
.writer()?
.header()
.schema_cookie
.wrapping_add(1);
self.backend.writer()?.header_mut().schema_cookie = cookie;
self.schema = Schema::read(self.backend.source())?;
Ok(())
}
/// Triggers on `table` matching `kind`/`timing`, parsed from their schema SQL.
fn triggers_for(
&self,
table: &str,
kind: TrigEvent,
timing: TriggerTiming,
) -> Result<Vec<CreateTrigger>> {
let mut out = Vec::new();
// The active schema plus the temp catalog: a temp trigger fires on writes
// to its (possibly main) table, and a main trigger fires even while a temp
// database is swapped in. `swap_db` exchanges `self.schema` with the temp
// db's, so these two catalogs are always exactly {main, temp}. Tag each
// trigger with the database it lives in: a body's missing-table error is
// schema-qualified by the trigger's own schema (`main.nope`), except for a
// temp trigger, whose names resolve cross-schema and stay bare.
let active = self.db_label(self.write_target.get());
let other = if active.eq_ignore_ascii_case("temp") {
"main"
} else {
"temp"
};
self.collect_triggers(
self.schema.objects(),
table,
kind,
timing,
&active,
&mut out,
);
if let Some(t) = &self.temp_db {
self.collect_triggers(t.schema.objects(), table, kind, timing, other, &mut out);
}
// SQLite keeps a per-table trigger list that prepends on creation, so
// triggers of the same event/timing fire in REVERSE creation order
// (most-recently-created first). `objects()` is in creation order, so
// reverse to match.
out.reverse();
Ok(out)
}
/// Append triggers from `objects` matching `table`/`kind`/`timing` to `out`.
fn collect_triggers(
&self,
objects: &[crate::schema::SchemaObject],
table: &str,
kind: TrigEvent,
timing: TriggerTiming,
schema: &str,
out: &mut Vec<CreateTrigger>,
) {
for obj in objects {
if obj.obj_type != crate::schema::ObjectType::Trigger
|| !obj.tbl_name.eq_ignore_ascii_case(table)
{
continue;
}
let Some(sql) = &obj.sql else { continue };
let Ok(Statement::CreateTrigger(mut ct)) = sql::parse_one(sql) else {
continue;
};
// The stored SQL is bare-named; record the catalog it came from so a
// body's missing-table error can name the trigger's schema.
ct.schema = Some(schema.into());
let event_ok = matches!(
(&ct.event, kind),
(TriggerEvent::Insert, TrigEvent::Insert)
| (TriggerEvent::Delete, TrigEvent::Delete)
| (TriggerEvent::Update(_), TrigEvent::Update)
);
if ct.timing == timing && event_ok {
out.push(ct);
}
}
}
/// Fire row triggers for one row change. `old`/`new` carry the affected row's
/// values and rowid before/after the change. Non-recursive: triggers fire
/// only at the top level (matching `recursive_triggers = OFF`).
#[allow(clippy::too_many_arguments)]
fn fire_triggers(
&mut self,
table: &str,
kind: TrigEvent,
timing: TriggerTiming,
columns: &[ColumnInfo],
old: Option<(&[Value], i64)>,
new: Option<(&[Value], i64)>,
params: &Params,
changed_cols: Option<&[String]>,
) -> Result<bool> {
// Non-recursive by default; with PRAGMA recursive_triggers a trigger may
// fire others, bounded to avoid runaway recursion (SQLite caps at 1000).
let depth = self.trigger_depth.get();
let limit = if self.recursive_triggers { 1000 } else { 1 };
if depth >= limit {
return if self.recursive_triggers {
Err(Error::Error("too many levels of trigger recursion".into()))
} else {
Ok(false)
};
}
let mut trigs = self.triggers_for(table, kind, timing)?;
// An `UPDATE OF col, …` trigger fires only when one of its named columns
// appears in the UPDATE's SET list (SQLite semantics).
if let Some(changed) = changed_cols {
trigs.retain(|t| match &t.event {
TriggerEvent::Update(cols) if !cols.is_empty() => cols
.iter()
.any(|c| changed.iter().any(|ch| ch.eq_ignore_ascii_case(c))),
_ => true,
});
}
if trigs.is_empty() {
return Ok(false);
}
self.trigger_depth.set(depth + 1);
let base = self.outer_scope.borrow().len();
if let Some((vals, rid)) = old {
self.push_row_frame("old", columns, vals, rid);
}
if let Some((vals, rid)) = new {
self.push_row_frame("new", columns, vals, rid);
}
let result = self.run_trigger_bodies(&trigs, params);
self.outer_scope.borrow_mut().truncate(base);
self.trigger_depth.set(depth);
// A `RAISE(IGNORE)` inside an AFTER trigger stops that trigger program but
// has no effect on the row operation, which already completed — every row
// of the firing statement is still processed. Clear the flag so it does not
// leak into the NEXT row's BEFORE-trigger check (which would otherwise skip
// that row) or a later statement. A BEFORE / INSTEAD OF `RAISE(IGNORE)`
// must keep the flag set so the caller abandons the row operation.
if matches!(timing, TriggerTiming::After) {
self.raise_ignore.set(false);
}
result.map(|()| true)
}
fn push_row_frame(&self, label: &str, columns: &[ColumnInfo], values: &[Value], rowid: i64) {
let columns = columns
.iter()
.map(|c| ColumnInfo {
name: c.name.clone(),
table: String::from(label),
affinity: c.affinity,
collation: c.collation,
schema: None,
hidden: false,
})
.collect();
self.outer_scope.borrow_mut().push(OuterFrame {
columns,
row: values.to_vec(),
rowid: Some(rowid),
});
}
/// Whether `name` is a view in main (or a temp view, which shadows main).
fn is_view(&self, name: &str) -> bool {
self.temp_has_view(name)
|| self.schema.objects().iter().any(|o| {
o.obj_type == crate::schema::ObjectType::View && o.name.eq_ignore_ascii_case(name)
})
}
/// Whether the temp database holds a view named `name`.
fn temp_has_view(&self, name: &str) -> bool {
self.temp_db.as_ref().is_some_and(|t| {
t.schema.objects().iter().any(|o| {
o.obj_type == crate::schema::ObjectType::View && o.name.eq_ignore_ascii_case(name)
})
})
}
/// The output columns of a view (labeled with the view name).
fn view_columns(&self, name: &str, params: &Params) -> Result<Vec<ColumnInfo>> {
match self.try_view(name, None, params)? {
Some((cols, _)) => Ok(cols),
None => Err(Error::Error(format!("no such view: {name}"))),
}
}
/// `INSERT` into a view: fire its `INSTEAD OF INSERT` triggers (per row), or
/// error if none exist.
fn exec_view_insert(
&mut self,
ins: &Insert,
rows: &[Vec<Expr>],
params: &Params,
) -> Result<usize> {
let cols = self.view_columns(&ins.table, params)?;
if self
.triggers_for(&ins.table, TrigEvent::Insert, TriggerTiming::InsteadOf)?
.is_empty()
{
return Err(Error::Error(format!(
"cannot modify {} because it is a view",
ins.table
)));
}
let target: Vec<usize> = if ins.columns.is_empty() {
(0..cols.len()).collect()
} else {
ins.columns
.iter()
.map(|name| {
cols.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
.ok_or_else(|| Error::Error(format!("no such column: {name}")))
})
.collect::<Result<_>>()?
};
let mut affected = 0;
for row_exprs in rows {
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let mut new = alloc::vec![Value::Null; cols.len()];
for (i, e) in row_exprs.iter().enumerate() {
new[target[i]] = eval::eval(e, &ctx)?;
}
self.fire_triggers(
&ins.table,
TrigEvent::Insert,
TriggerTiming::InsteadOf,
&cols,
None,
Some((&new, 0)),
params,
None,
)?;
if self.raise_ignore.replace(false) {
continue;
}
affected += 1;
}
Ok(affected)
}
/// `DELETE` from a view: fire `INSTEAD OF DELETE` triggers for each row that
/// the view yields and the `WHERE` selects.
fn exec_view_delete(&mut self, del: &Delete, params: &Params) -> Result<usize> {
let (cols, rows) = self
.try_view(&del.table, None, params)?
.ok_or_else(|| Error::Error(format!("no such view: {}", del.table)))?;
if self
.triggers_for(&del.table, TrigEvent::Delete, TriggerTiming::InsteadOf)?
.is_empty()
{
return Err(Error::Error(format!(
"cannot modify {} because it is a view",
del.table
)));
}
let mut affected = 0;
for row in rows {
if let Some(p) = &del.where_clause {
let ctx = row_ctx(&row.values, &cols, None, params).with_subqueries(self);
if eval::truth(&eval::eval(p, &ctx)?) != Some(true) {
continue;
}
}
self.fire_triggers(
&del.table,
TrigEvent::Delete,
TriggerTiming::InsteadOf,
&cols,
Some((&row.values, 0)),
None,
params,
None,
)?;
if self.raise_ignore.replace(false) {
continue;
}
affected += 1;
}
Ok(affected)
}
/// `UPDATE` a view: fire `INSTEAD OF UPDATE` triggers with OLD/NEW for each
/// selected row.
/// Apply `SET (cols) = (SELECT …)` row-value-subquery assignments for one
/// target row: run each subquery once against `ctx` (the caller's original-row
/// context, so it is a correlated, simultaneous read) and write its first
/// row's columns into `target` at the positions named by the assignment's
/// column list (no row → NULLs; a column-count mismatch errors). `meta`, when
/// given, rejects assigning to a generated column.
fn apply_row_subquery_assignments(
&self,
row_assignments: &[(Vec<String>, Box<Select>)],
cols: &[ColumnInfo],
meta: Option<&TableMeta>,
ctx: &EvalCtx,
target: &mut [Value],
) -> Result<()> {
for (targets, select) in row_assignments {
let mut positions = Vec::with_capacity(targets.len());
for c in targets {
let pos = cols
.iter()
.position(|mc| mc.name.eq_ignore_ascii_case(c))
.ok_or_else(|| Error::Error(format!("no such column: {c}")))?;
if meta.is_some_and(|m| m.is_generated(pos)) {
return Err(Error::Error(format!(
"cannot UPDATE generated column \"{c}\""
)));
}
positions.push(pos);
}
let produced = eval::Subqueries::rows(self, select, ctx)?;
let first = produced.into_iter().next();
if let Some(r) = &first
&& r.len() != positions.len()
{
return Err(Error::Error(format!(
"{} columns assigned {} values",
positions.len(),
r.len()
)));
}
for (i, &pos) in positions.iter().enumerate() {
target[pos] = first.as_ref().map_or(Value::Null, |r| r[i].clone());
}
}
Ok(())
}
fn exec_view_update(&mut self, upd: &Update, params: &Params) -> Result<usize> {
let (cols, rows) = self
.try_view(&upd.table, None, params)?
.ok_or_else(|| Error::Error(format!("no such view: {}", upd.table)))?;
if self
.triggers_for(&upd.table, TrigEvent::Update, TriggerTiming::InsteadOf)?
.is_empty()
{
return Err(Error::Error(format!(
"cannot modify {} because it is a view",
upd.table
)));
}
let mut changed: Vec<String> = upd.assignments.iter().map(|(c, _)| c.clone()).collect();
for (rcols, _) in &upd.row_assignments {
changed.extend(rcols.iter().cloned());
}
let mut affected = 0;
for row in rows {
let old = row.values.clone();
if let Some(p) = &upd.where_clause {
let ctx = row_ctx(&old, &cols, None, params).with_subqueries(self);
if eval::truth(&eval::eval(p, &ctx)?) != Some(true) {
continue;
}
}
let mut new = old.clone();
for (col, expr) in &upd.assignments {
let pos = cols
.iter()
.position(|c| c.name.eq_ignore_ascii_case(col))
.ok_or_else(|| Error::Error(format!("no such column: {col}")))?;
// Simultaneous assignment: evaluate against the original row.
let ctx = row_ctx(&old, &cols, None, params).with_subqueries(self);
new[pos] = eval::eval(expr, &ctx)?;
}
if !upd.row_assignments.is_empty() {
let ctx = row_ctx(&old, &cols, None, params).with_subqueries(self);
self.apply_row_subquery_assignments(
&upd.row_assignments,
&cols,
None,
&ctx,
&mut new,
)?;
}
self.fire_triggers(
&upd.table,
TrigEvent::Update,
TriggerTiming::InsteadOf,
&cols,
Some((&old, 0)),
Some((&new, 0)),
params,
Some(&changed),
)?;
if self.raise_ignore.replace(false) {
continue;
}
affected += 1;
}
Ok(affected)
}
fn run_trigger_bodies(&mut self, trigs: &[CreateTrigger], params: &Params) -> Result<()> {
for trig in trigs {
if let Some(when) = &trig.when {
let fires = {
let ctx = EvalCtx::rowless(params).with_subqueries(self);
eval::truth(&eval::eval(when, &ctx)?) == Some(true)
};
if !fires {
continue;
}
}
let schema = trig.schema.as_deref();
for stmt in &trig.body {
match stmt {
Statement::Insert(ins) => {
self.exec_insert(ins, params)
.map_err(|e| qualify_trigger_missing_table(e, schema))?;
}
Statement::Update(u) => {
self.exec_update(u, params)
.map_err(|e| qualify_trigger_missing_table(e, schema))?;
}
Statement::Delete(d) => {
self.exec_delete(d, params)
.map_err(|e| qualify_trigger_missing_table(e, schema))?;
}
// A `SELECT` in a trigger body is side-effect free *except* for
// a `RAISE(…)`, which aborts or ignores the firing operation.
Statement::Select(sel) => {
self.run_trigger_select(sel, params)?;
// `RAISE(IGNORE)` abandons the row: stop running the rest of
// this (and later) trigger program(s).
if self.raise_ignore.get() {
return Ok(());
}
}
_ => return Err(Error::Unsupported("statement type in trigger body")),
}
}
}
Ok(())
}
/// Evaluate a trigger-body `SELECT` for a `RAISE(…)` call. A bare
/// `SELECT RAISE(…)` (optionally wrapped in a single `CASE`) is the standard
/// form; we evaluate each projected expression so any `RAISE` that the row
/// reaches takes effect. `RAISE(ABORT|FAIL|ROLLBACK, msg)` raises a constraint
/// error (arming the statement-atomicity flags); `RAISE(IGNORE)` sets
/// `raise_ignore` so the firing row operation is silently skipped.
fn run_trigger_select(&self, sel: &Select, params: &Params) -> Result<()> {
// A bare `SELECT RAISE(…) [WHERE cond]` (no FROM) reaches the RAISE only
// for the single row that passes WHERE — `SELECT RAISE(IGNORE) WHERE
// NEW.a<0` must NOT raise when the condition is false. Evaluate the WHERE
// in the trigger's row context (NEW/OLD via the subquery runner) and skip
// the projection when it is not true. (A trigger-body SELECT with a FROM
// is not a RAISE form handled here; leave it to the projection scan.)
if sel.from.is_none() {
let ctx = EvalCtx::rowless(params).with_subqueries(self);
// SQLite compiles the whole trigger program when the firing statement
// is prepared, so a body `SELECT`'s name / function / arity errors
// surface *before* any row is processed — ahead of the `WHERE` filter
// and any sibling `RAISE(…)`. graphite runs the body step by step, so
// resolve the FROM-less SELECT's projections up front by evaluating
// them (the value is discarded; the statement's atomicity rolls back
// any earlier body side-effect if this throws). A `RAISE(…)`-bearing
// projection keeps its dedicated path below — `eval` has no RAISE
// handling, and SQLite resolves the rest of the row first anyway.
for col in &sel.columns {
if let ResultColumn::Expr { expr, .. } = col
&& !trigger_select_skip_eval(expr)
{
let _ = eval::eval(expr, &ctx)?;
}
}
if let Some(w) = &sel.where_clause
&& eval::truth(&eval::eval(w, &ctx)?) != Some(true)
{
return Ok(());
}
}
for col in &sel.columns {
if let ResultColumn::Expr { expr, .. } = col {
self.eval_raise_expr(expr, params)?;
if self.raise_ignore.get() {
return Ok(());
}
}
}
Ok(())
}
/// Evaluate `expr` looking for a `RAISE(…)` that the row reaches: a direct
/// `RAISE(…)` call, or one selected by a `CASE` branch. Other expressions are
/// side-effect free here and are skipped.
fn eval_raise_expr(&self, expr: &Expr, params: &Params) -> Result<()> {
match expr {
Expr::Function { name, args, .. } if name.eq_ignore_ascii_case("raise") => {
self.fire_raise(args, params)
}
Expr::Paren(inner) => self.eval_raise_expr(inner, params),
Expr::Case {
operand,
when_then,
else_result,
} => {
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let base = match operand {
Some(op) => Some(eval::eval(op, &ctx)?),
None => None,
};
for (when, then) in when_then {
let hit = match &base {
// `CASE x WHEN v …`: the branch fires when x == v.
Some(b) => {
let w = eval::eval(when, &ctx)?;
crate::value::cmp_values(b, &w) == core::cmp::Ordering::Equal
}
// `CASE WHEN cond …`: the branch fires when cond is true.
None => eval::truth(&eval::eval(when, &ctx)?) == Some(true),
};
if hit {
return self.eval_raise_expr(then, params);
}
}
if let Some(e) = else_result {
return self.eval_raise_expr(e, params);
}
Ok(())
}
_ => Ok(()),
}
}
/// Apply a parsed `RAISE(action[, msg])`. `action` is the lower-cased keyword
/// stored as the first argument; `msg` (when present) is the second.
fn fire_raise(&self, args: &[Expr], params: &Params) -> Result<()> {
let action = match args.first() {
Some(Expr::Literal(Literal::Str(s))) => s.as_str(),
_ => return Err(Error::Error("malformed RAISE()".into())),
};
if action == "ignore" {
self.raise_ignore.set(true);
return Ok(());
}
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let msg = match args.get(1) {
Some(e) => match eval::eval(e, &ctx)? {
Value::Null => String::new(),
Value::Text(s) => s.as_str().to_string(),
Value::Integer(i) => {
let mut s = String::new();
let _ = core::fmt::write(&mut s, format_args!("{i}"));
s
}
Value::Real(r) => eval::format_real(r),
Value::Blob(_) => String::new(),
},
None => String::new(),
};
match action {
"fail" => self.stmt_keep_partial.set(true),
"rollback" => self.stmt_rollback_tx.set(true),
_ => {} // "abort" — the default statement rollback
}
Err(Error::Constraint(msg))
}
/// The AUTOINCREMENT high-water mark stored for `table` in `sqlite_sequence`,
/// or `None` if that catalog or row is absent.
fn sequence_value(&self, table: &str) -> Result<Option<i64>> {
if self.schema.table("sqlite_sequence").is_none() {
return Ok(None);
}
let meta = self.table_meta("sqlite_sequence", None)?;
for (_, vals) in self.scan_table(&meta)? {
if matches!(&vals[0], Value::Text(t) if t == table) {
return Ok(Some(eval::to_i64(&vals[1])));
}
}
Ok(None)
}
/// Persist the AUTOINCREMENT high-water mark `seq` for `table` into
/// `sqlite_sequence` — updating the existing row in place (same rowid) or
/// inserting a new one — like SQLite. A no-op if the catalog is absent.
fn set_sequence(&mut self, table: &str, seq: i64) -> Result<()> {
let Some(seq_obj) = self.schema.table("sqlite_sequence") else {
return Ok(());
};
let root = seq_obj.rootpage;
let meta = self.table_meta("sqlite_sequence", None)?;
let existing: Option<i64> = self
.scan_table(&meta)?
.into_iter()
.find(|(_, v)| matches!(&v[0], Value::Text(t) if t == table))
.map(|(rid, _)| rid);
let rec = encode_record(&[Value::Text(table.into()), Value::Integer(seq)]);
let rid = match existing {
Some(rid) => {
delete_table(self.backend.writer()?, root, rid)?;
rid
}
None => self.next_rowid(root)?,
};
insert_table(self.backend.writer()?, root, rid, &rec)?;
Ok(())
}
fn exec_insert(&mut self, ins: &Insert, params: &Params) -> Result<usize> {
// A leading `WITH` makes its CTEs visible to the source — the inserted
// SELECT or a subquery inside a VALUES expression. Push them for the
// duration of the statement, then restore the scope (mirrors the
// UPDATE/DELETE WITH paths).
if ins.ctes.is_empty() {
return self.exec_insert_inner(ins, params);
}
let base = self.cte_env.borrow().len();
let seeds = insert_cte_seeds(ins);
let pushed = self.push_ctes(&ins.ctes, params, None, Some(&seeds));
let result = pushed.and_then(|()| self.exec_insert_inner(ins, params));
self.cte_env.borrow_mut().truncate(base);
result
}
fn exec_insert_inner(&mut self, ins: &Insert, params: &Params) -> Result<usize> {
reject_schema_write(&ins.table)?;
// A virtual table routes INSERT to its module's `update` (xUpdate); only
// the `VALUES`/`SELECT` source needs materializing first.
if self.is_virtual_table(&ins.table) {
let rows: Vec<Vec<Expr>> = match &ins.source {
InsertSource::Values(rows) => rows.clone(),
InsertSource::DefaultValues => alloc::vec![Vec::new()],
InsertSource::Select(sel) => self
.run_select(sel, params)?
.rows
.into_iter()
.map(|row| row.into_iter().map(value_to_literal_expr).collect())
.collect(),
};
return self.exec_vtab_insert(ins, &rows, params);
}
// `INSERT … SELECT` is evaluated to a snapshot of value rows first (so
// `INSERT INTO t SELECT … FROM t` reads the pre-insert state), then each
// row flows through the normal VALUES path as literal expressions.
// A multi-row `INSERT … VALUES (…),(…)` must have rows of equal arity.
// SQLite rejects a mismatch up front ("all VALUES must have the same
// number of terms"); validate before any row is written so a short row
// never half-completes the insert.
if let InsertSource::Values(rows) = &ins.source
&& let Some(first) = rows.first()
&& rows.iter().any(|r| r.len() != first.len())
{
return Err(Error::Error(
"all VALUES must have the same number of terms".into(),
));
}
let (rows, is_default_values) = match &ins.source {
InsertSource::Values(rows) => (rows.clone(), false),
InsertSource::DefaultValues => (alloc::vec![Vec::new()], true),
InsertSource::Select(sel) => {
let result = self.run_select(sel, params)?;
let rows = result
.rows
.into_iter()
.map(|row| row.into_iter().map(value_to_literal_expr).collect())
.collect();
(rows, false)
}
};
if self.is_view(&ins.table) {
return self.exec_view_insert(ins, &rows, params);
}
let meta = self.table_meta(&ins.table, None)?;
// An `ON CONFLICT … DO …` clause may reference only the target table's
// columns (the conflict target and its `WHERE`) plus the `excluded`
// pseudo-table (in a `DO UPDATE`). Reject an unknown column up front, in
// sqlite's resolution order, rather than silently ignoring it.
let upsert_target_db = self.dml_target_db(ins.schema.as_deref(), &ins.table);
validate_upsert_columns(&meta, &ins.table, &upsert_target_db, &ins.upsert)?;
self.validate_upsert_conflict_targets(&meta, &ins.table, &ins.upsert)?;
if meta.without_rowid {
return self.exec_insert_without_rowid(ins, &meta, &rows, is_default_values, params);
}
let n_cols = meta.columns.len();
// Sentinel target position meaning "the rowid pseudo-column" (a table
// with no INTEGER PRIMARY KEY to alias it) — handled below in the value
// loop and rowid determination rather than written into a real column.
const ROWID_TARGET: usize = usize::MAX;
// Map the provided column list (or all columns) to table positions.
let target: Vec<usize> = if ins.columns.is_empty() {
// A bare `INSERT … VALUES`/`SELECT` (no column list) targets the
// NON-GENERATED columns, in order — SQLite excludes generated columns
// from the implicit list (they are always computed), so the value
// count must match the non-generated columns and an `INSERT INTO t
// VALUES(…)` works on a table that has generated columns.
(0..n_cols).filter(|&i| !meta.is_generated(i)).collect()
} else {
let mut t = Vec::new();
for name in &ins.columns {
match meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
{
Some(pos) => t.push(pos),
// `rowid`/`_rowid_`/`oid` name the rowid (when no real column
// shadows them). An INTEGER PRIMARY KEY *is* the rowid, so
// target that column and reuse its coercion/auto-fill path;
// otherwise mark the synthetic rowid target handled below.
None if is_rowid_alias(name) => t.push(meta.ipk.unwrap_or(ROWID_TARGET)),
None => {
return Err(Error::Error(format!(
"table {} has no column named {name}",
ins.table
)));
}
}
}
t
};
let indexes = self.indexes_of(&ins.table)?;
let mut next_auto = self.next_rowid(meta.root)?;
// AUTOINCREMENT never reuses a rowid at or below the persisted high-water
// mark, so seed the counter past it (a deleted maximum is not recycled).
if meta.autoincrement
&& let Some(seq) = self.sequence_value(&ins.table)?
{
next_auto = next_auto.max(seq + 1);
}
let mut affected = 0;
let mut replaced = false;
for row_exprs in &rows {
// Every supplied row must match the target column count (DEFAULT
// VALUES is the one exception — it supplies an empty row meaning
// "all defaults").
if !is_default_values && row_exprs.len() != target.len() {
return Err(insert_count_mismatch(
&ins.table,
!ins.columns.is_empty(),
target.len(),
row_exprs.len(),
));
}
// Start every column at its DEFAULT (or NULL), then apply provided.
// Subqueries are attached so INSERT … VALUES can use scalar subqueries
// and trigger bodies can read NEW/OLD via the outer scope.
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let mut values: Vec<Value> = meta
.defaults
.iter()
.map(|d| match d {
Some(e) => eval::eval(e, &ctx),
None => Ok(Value::Null),
})
.collect::<Result<_>>()?;
let mut explicit_rowid: Option<Value> = None;
for (i, e) in row_exprs.iter().enumerate() {
if target[i] == ROWID_TARGET {
explicit_rowid = Some(eval::eval(e, &ctx)?);
continue;
}
if meta.is_generated(target[i]) {
return Err(Error::Error(format!(
"cannot INSERT into generated column \"{}\"",
meta.columns[target[i]].name
)));
}
values[target[i]] = eval::eval(e, &ctx)?;
}
// INTEGER affinity then an integer check, matching the IPK path: '5'
// and 5.0 become 5, NULL means "auto", and 1.5/'x'/a blob mismatch.
let explicit_rowid: Option<i64> = match explicit_rowid {
Some(v) => match eval::Affinity::Integer.coerce(v) {
Value::Null => None,
Value::Integer(i) => Some(i),
_ => return Err(Error::Error("datatype mismatch".into())),
},
None => None,
};
apply_column_affinity(&meta, &mut values);
self.materialize_generated(&meta, &mut values, params)?;
// Determine the rowid (explicit INTEGER PRIMARY KEY value or auto).
// `rowid_auto` records whether it was auto-assigned — a BEFORE INSERT
// trigger runs before the auto-assignment, so it must see -1 there.
let mut rowid_auto = false;
let rowid = match meta.ipk {
Some(ipk) if !matches!(values[ipk], Value::Null) => {
// An INTEGER PRIMARY KEY *is* the rowid, so the supplied value
// must be an integer. Column affinity has already coerced an
// integer-valued real or numeric text (2.0, '5', '5.0') to
// Integer; anything still non-integer (1.5, 'x', a blob) is a
// datatype mismatch in SQLite, not a silent `to_i64` coercion.
let r = match &values[ipk] {
Value::Integer(i) => *i,
_ => return Err(Error::Error("datatype mismatch".into())),
};
// Advance the auto counter past an explicit rowid, saturating
// so a row at `i64::MAX` does not overflow.
next_auto = next_auto.max(r.saturating_add(1));
r
}
// An explicit rowid supplied via the `rowid` pseudo-column (a
// table with no INTEGER PRIMARY KEY to alias it).
_ if explicit_rowid.is_some() => {
let r = explicit_rowid.unwrap();
next_auto = next_auto.max(r.saturating_add(1));
r
}
_ => {
rowid_auto = true;
// The candidate is the largest rowid currently in the table + 1.
// For a plain rowid table that is the live b-tree maximum, read
// fresh so it reflects explicit rowids inserted earlier in this
// same multi-row statement — including negative ones (an
// explicit `-1` into an otherwise-empty table makes the next auto
// rowid `0`, not `1`). `next_auto`'s monotonic floor is correct
// only for AUTOINCREMENT, which never reuses a rowid at or below
// its persisted high-water mark.
let cand = if meta.autoincrement {
next_auto
} else {
self.next_rowid(meta.root)?
};
let r = self.auto_rowid(meta.root, meta.autoincrement, cand)?;
// Advance sequentially when we stayed in range; if `auto_rowid`
// left the exhausted range (a random pick below the candidate),
// keep the counter saturated so each further row re-enters that
// path instead of trusting a stale sequential value.
next_auto = if r >= next_auto {
r.saturating_add(1)
} else {
i64::MAX
};
r
}
};
// Capture column values (with the IPK = rowid) for index keys, then
// NULL the IPK column in the stored record (it aliases the rowid).
if let Some(ipk) = meta.ipk {
values[ipk] = Value::Integer(rowid);
}
// SQLite fires a BEFORE INSERT trigger *before* any constraint or
// conflict handling (insert.c: the trigger program runs ahead of the
// NOT NULL/type/CHECK checks, the uniqueness/PK resolution, and the FK
// checks). So an `INSERT OR REPLACE` has not yet deleted the
// conflicting row when the trigger runs — the trigger still observes
// it — and a row later skipped by `OR IGNORE` (for a NOT NULL/UNIQUE
// violation) has already run its BEFORE trigger's side effects.
// `NEW.<rowid>` reads -1 when the rowid will be auto-assigned; an
// explicit rowid is visible as itself.
let (before_values, before_rowid) = if rowid_auto {
let mut bv = values.clone();
if let Some(ipk) = meta.ipk {
bv[ipk] = Value::Integer(-1);
}
(bv, -1)
} else {
(values.clone(), rowid)
};
self.fire_triggers(
&ins.table,
TrigEvent::Insert,
TriggerTiming::Before,
&meta.columns,
None,
Some((&before_values, before_rowid)),
params,
None,
)?;
// A `BEFORE INSERT` trigger's `RAISE(IGNORE)` abandons just this row.
if self.raise_ignore.replace(false) {
continue;
}
// NOT NULL / STRICT-type / CHECK constraints. `INSERT OR IGNORE`
// skips a row that violates any of these (rather than failing the
// statement); every other conflict policy lets the error propagate.
{
// NOT NULL honors the column's (or statement's) ON CONFLICT action;
// a skipped row (IGNORE) drops out here, a REPLACE substitutes the
// column default into `values`.
if !self.resolve_not_null(
&meta,
&mut values,
ins.on_conflict,
ins.on_conflict_explicit,
params,
)? {
continue;
}
let r = self
.check_strict_types(&meta, &values)
.and_then(|()| self.check_constraints(&meta, &values, Some(rowid), params));
match r {
Ok(()) => {}
Err(Error::Constraint(_)) if ins.on_conflict == OnConflict::Ignore => continue,
Err(Error::Constraint(m)) => {
return Err(self.conflict_error(ins.on_conflict, &m));
}
Err(e) => return Err(e),
}
}
// Resolve UNIQUE / PRIMARY KEY (incl. rowid) conflicts.
let (conflicts, constraint_oc) =
self.find_conflicts(&ins.table, &meta, rowid, &values, None, params)?;
// A statement-level `OR <action>` overrides the constraint's declared
// `ON CONFLICT <action>`; a plain `INSERT` uses the constraint's action.
let effective_oc = if ins.on_conflict_explicit {
ins.on_conflict
} else {
constraint_oc
};
if !conflicts.is_empty() {
// An `ON CONFLICT … DO …` upsert clause intercepts the conflict,
// but only when the conflict is on the index it targets (a bare
// `ON CONFLICT` with no target matches any unique conflict). A
// conflict on a *different* index is a hard error, exactly as in
// SQLite.
let mut matched = None;
for up in &ins.upsert {
if let Some(target_row) =
self.upsert_target_row(&meta, up, &conflicts, &values, rowid, params)?
{
matched = Some((up, target_row));
break;
}
}
if let Some((up, target_row)) = matched {
match &up.action {
UpsertAction::Nothing => continue, // skip the conflicting row
UpsertAction::Update {
assignments,
where_clause,
} => {
if self.upsert_do_update(
&ins.table,
&meta,
target_row,
&values,
assignments,
where_clause.as_ref(),
&ins.returning,
params,
)? {
affected += 1;
replaced = true; // index entries changed; rebuild
}
continue;
}
}
}
match effective_oc {
oc @ (OnConflict::Abort | OnConflict::Fail | OnConflict::Rollback) => {
let m = self.unique_violation_message(
&ins.table, &meta, rowid, &values, None, params,
);
return Err(self.conflict_error(oc, &m));
}
OnConflict::Ignore => continue, // skip this row
OnConflict::Replace => {
// Deleting the conflicting rows to make room fires their FK
// `ON DELETE` actions (CASCADE / SET NULL / …) via
// `delete_row_cascade`, exactly like sqlite — but NOT DELETE
// triggers (sqlite gates those on `recursive_triggers`, off
// by default, and `delete_row_cascade` fires none).
for cr in conflicts {
// Record the replace-delete as a session change (its
// old values), matching SQLite's preupdate DELETE: a
// same-PK REPLACE then coalesces DELETE+INSERT into an
// UPDATE; a different-PK (UNIQUE) conflict yields a
// DELETE of that row plus this INSERT.
if self.session.borrow().is_some()
&& let Ok(Some(old)) = self.read_row(&meta, cr)
{
self.record_session_change(
&ins.table,
&meta,
crate::session::ChangeOp::Delete,
cr,
Some(&old),
None,
);
}
self.delete_row_cascade(&ins.table, &meta, cr, params)?;
}
replaced = true;
}
}
}
let index_values = values.clone();
// The child-side foreign-key check runs only for a row that is actually
// inserted — AFTER a UNIQUE/PK conflict was resolved (an `OR IGNORE` /
// upsert `DO NOTHING` skip, or an `OR REPLACE` that first deleted the
// conflicting rows) and after a BEFORE trigger's `RAISE(IGNORE)`. SQLite
// checks uniqueness before the FK, so a row skipped by `OR IGNORE` never
// trips the FK; checking it up front reported a spurious violation.
self.check_fk_child(&ins.table, &meta, &index_values)?;
let record = self.encode_table_record(&meta, &index_values);
insert_table(self.backend.writer()?, meta.root, rowid, &record)?;
self.record_session_change(
&ins.table,
&meta,
crate::session::ChangeOp::Insert,
rowid,
None,
Some(&index_values),
);
// `last_insert_rowid()` tracks the most recent insert (a later insert
// from an AFTER trigger overwrites this, matching SQLite).
self.last_insert_rowid.set(rowid);
for idx in &indexes {
if !self.row_in_index(idx, &meta, &index_values, Some(rowid), params)? {
continue; // partial index excludes this row
}
let key = self.index_key_bytes(idx, &meta, &index_values, rowid, params)?;
insert_index(
self.backend.writer()?,
idx.root,
&key,
&idx.collations,
idx.seek_descs(),
)?;
}
self.fire_triggers(
&ins.table,
TrigEvent::Insert,
TriggerTiming::After,
&meta.columns,
None,
Some((&index_values, rowid)),
params,
None,
)?;
if !ins.returning.is_empty() {
self.collect_returning(&ins.returning, &meta, &index_values, Some(rowid), params)?;
}
affected += 1;
}
// Persist the AUTOINCREMENT high-water mark: `next_auto - 1` is the largest
// rowid assigned or seen this statement. Only advance `sqlite_sequence`
// (never lower it), matching SQLite.
if meta.autoincrement && affected > 0 {
let high = next_auto - 1;
if high > self.sequence_value(&ins.table)?.unwrap_or(i64::MIN) {
self.set_sequence(&ins.table, high)?;
}
}
// REPLACE removed rows whose index entries were maintained incrementally;
// rebuild from the final table state to be safe. The delete of each
// conflicting row can leave an empty non-root leaf in the table b-tree
// (SQLite's balancer would merge it); compact it away first, exactly as
// the UPDATE and DELETE paths do, or the file is left malformed.
if replaced {
self.compact_table(&meta)?;
self.rebuild_indexes(&meta, &indexes)?;
}
// An OR REPLACE conflict-delete may have cascaded into child tables.
self.drain_cascade_compact()?;
Ok(affected)
}
/// Apply an `ON CONFLICT … DO UPDATE` action to the existing conflicting
/// row `existing_rowid`. `proposed` is the row the `INSERT` would have added,
/// exposed to the `SET`/`WHERE` expressions as the `excluded` pseudo-table.
/// Returns whether a row was actually updated (the optional `WHERE` can veto).
#[allow(clippy::too_many_arguments)]
fn upsert_do_update(
&mut self,
table: &str,
meta: &TableMeta,
existing_rowid: i64,
proposed: &[Value],
assignments: &[(String, Expr)],
where_clause: Option<&Expr>,
returning: &[ResultColumn],
params: &Params,
) -> Result<bool> {
let Some(old_row) = self.read_row(meta, existing_rowid)? else {
return Ok(false);
};
let changed: Vec<String> = assignments.iter().map(|(c, _)| c.clone()).collect();
// Column scope for the SET/WHERE expressions: the target table's columns,
// then the same columns again under the `excluded` table label.
let mut cols: Vec<ColumnInfo> = meta.columns.clone();
cols.extend(meta.columns.iter().map(|c| ColumnInfo {
name: c.name.clone(),
table: String::from("excluded"),
affinity: c.affinity,
collation: c.collation,
schema: None,
hidden: false,
}));
// Evaluate the DO UPDATE WHERE and SET right-hand sides against the
// combined (existing row + excluded) scope, then drop the borrow.
let mut values = old_row.clone();
{
let mut combined = old_row.clone();
combined.extend_from_slice(proposed);
let ctx = EvalCtx {
row: &combined,
columns: &cols,
rowid: Some(existing_rowid),
params,
anon_counter: core::cell::Cell::new(0),
subqueries: None,
}
.with_subqueries(self);
if let Some(w) = where_clause
&& eval::truth(&eval::eval(w, &ctx)?) != Some(true)
{
return Ok(false);
}
for (col, e) in assignments {
let pos = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(col))
.ok_or_else(|| Error::Error(format!("no such column: {col}")))?;
if meta.is_generated(pos) {
return Err(Error::Error(format!(
"cannot UPDATE generated column \"{col}\""
)));
}
values[pos] = eval::eval(e, &ctx)?;
}
}
apply_column_affinity(meta, &mut values);
self.materialize_generated(meta, &mut values, params)?;
// An UPDATE of the INTEGER PRIMARY KEY (the rowid) must leave it an
// integer: NULL or a non-integer value (after affinity) is a datatype
// mismatch in SQLite — checked before NOT NULL, which would otherwise
// mis-report a `SET ipk = NULL`.
if let Some(ipk) = meta.ipk
&& !matches!(values[ipk], Value::Integer(_))
{
return Err(Error::Error("datatype mismatch".into()));
}
check_not_null(meta, &values)?;
self.check_strict_types(meta, &values)?;
self.check_constraints(meta, &values, Some(existing_rowid), params)?;
self.check_fk_child(table, meta, &values)?;
if self.foreign_keys {
self.enforce_parent_change(table, &old_row, Some(&values), params)?;
}
let new_rowid = match meta.ipk {
Some(ipk) => eval::to_i64(&values[ipk]),
None => existing_rowid,
};
self.fire_triggers(
table,
TrigEvent::Update,
TriggerTiming::Before,
&meta.columns,
Some((&old_row, existing_rowid)),
Some((&values, new_rowid)),
params,
Some(&changed),
)?;
if !self
.find_conflicts(
table,
meta,
new_rowid,
&values,
Some(existing_rowid),
params,
)?
.0
.is_empty()
{
return Err(Error::Constraint(self.unique_violation_message(
table,
meta,
new_rowid,
&values,
Some(existing_rowid),
params,
)));
}
let new_full = values.clone();
let record = self.encode_table_record(meta, &new_full);
delete_table(self.backend.writer()?, meta.root, existing_rowid)?;
insert_table(self.backend.writer()?, meta.root, new_rowid, &record)?;
self.record_session_change(
table,
meta,
crate::session::ChangeOp::Update,
existing_rowid,
Some(&old_row),
Some(&new_full),
);
self.fire_triggers(
table,
TrigEvent::Update,
TriggerTiming::After,
&meta.columns,
Some((&old_row, existing_rowid)),
Some((&new_full, new_rowid)),
params,
Some(&changed),
)?;
if !returning.is_empty() {
self.collect_returning(returning, meta, &new_full, Some(new_rowid), params)?;
}
Ok(true)
}
/// Project a `RETURNING` row from `values` (a full table row) and stash it in
/// [`returning_rows`](Self::returning_rows) for `execute_returning` to drain.
fn collect_returning(
&self,
returning: &[ResultColumn],
meta: &TableMeta,
values: &[Value],
rowid: Option<i64>,
params: &Params,
) -> Result<()> {
let ctx = row_ctx(values, &meta.columns, rowid, params).with_subqueries(self);
let mut out = Vec::new();
for col in returning {
project_column(col, &meta.columns, &ctx, &mut out)?;
}
self.returning_rows.borrow_mut().push(out);
Ok(())
}
/// Load `ANALYZE` statistics, mapping each index name to its parsed `stat`
/// integers (`[nRow, avgEq1, avgEq2, …]`). Empty when the database has not
/// been analyzed. Used by the cost-based index chooser.
fn stat1_map(&self) -> alloc::collections::BTreeMap<String, Vec<u64>> {
let mut map = alloc::collections::BTreeMap::new();
if self.schema.table("sqlite_stat1").is_none() {
return map;
}
let Ok(meta) = self.table_meta("sqlite_stat1", None) else {
return map;
};
let Ok(rows) = self.scan_table(&meta) else {
return map;
};
for (_, vals) in rows {
if let (Some(Value::Text(idx)), Some(Value::Text(stat))) = (vals.get(1), vals.get(2)) {
let nums: Vec<u64> = stat
.split_whitespace()
.filter_map(|t| t.parse().ok())
.collect();
if !nums.is_empty() {
map.insert(idx.as_str().to_string(), nums);
}
}
}
map
}
/// Load the `sqlite_stat4` samples for one index (by name), in storage
/// order, decoding each `sample` record and parsing its `neq`/`nlt`/`ndlt`
/// integer lists. Returns `(samples, n_sample_col)` or `None` when there are
/// no stat4 rows for the index (or the table is absent/unreadable). Used by
/// the cost-based index chooser to refine an equality selectivity estimate.
fn stat4_samples(
&self,
idx_name: &str,
) -> Option<(Vec<crate::exec::stat4::LoadedSample>, usize)> {
self.schema.table("sqlite_stat4")?;
let meta = self.table_meta("sqlite_stat4", None).ok()?;
let rows = self.scan_table(&meta).ok()?;
let encoding = self.backend.source().header().text_encoding;
let parse_list = |s: &str| -> Vec<u64> {
s.split_whitespace()
.filter_map(|t| t.parse().ok())
.collect()
};
let mut out: Vec<crate::exec::stat4::LoadedSample> = Vec::new();
let mut n_sample_col = 0usize;
// sqlite_stat4 columns: (tbl, idx, neq, nlt, ndlt, sample).
for (_, vals) in rows {
let Some(Value::Text(name)) = vals.get(1) else {
continue;
};
if name != idx_name {
continue;
}
let (Some(Value::Text(neq)), Some(Value::Text(nlt)), Some(Value::Text(ndlt))) =
(vals.get(2), vals.get(3), vals.get(4))
else {
continue;
};
let sample_bytes = match vals.get(5) {
Some(Value::Blob(b)) => b.as_slice(),
_ => continue,
};
let Ok(sample) = crate::format::record::decode_record(sample_bytes, encoding) else {
continue;
};
let n_eq = parse_list(neq);
let n_lt = parse_list(nlt);
let n_dlt = parse_list(ndlt);
if n_eq.is_empty() || n_lt.len() != n_eq.len() || n_dlt.len() != n_eq.len() {
continue;
}
n_sample_col = n_sample_col.max(sample.len().max(n_eq.len()));
out.push(crate::exec::stat4::LoadedSample {
n_lt,
n_eq,
n_dlt,
sample,
});
}
if out.is_empty() {
return None;
}
Some((out, n_sample_col))
}
/// Rowids of existing rows that conflict with a candidate row on the rowid
/// or any UNIQUE/PRIMARY KEY column set (NULLs are considered distinct).
/// The existing rows that could possibly collide with `values` on the rowid or
/// a UNIQUE / PRIMARY KEY constraint — found by **seeking** the rowid and each
/// unique index (O(log n) each), never by scanning the whole table. The result
/// may be a superset (a partial-index or NULL-key subtlety); [`find_conflicts`]
/// re-confirms every candidate with the exact per-constraint comparison, so an
/// extra candidate is harmless. Every index on the rowid INSERT path is
/// maintained incrementally as rows are inserted, so a mid-statement seek sees
/// the rows already added this statement.
fn conflict_candidates(
&self,
table: &str,
meta: &TableMeta,
rowid: i64,
values: &[Value],
params: &Params,
) -> Result<Vec<(i64, Vec<Value>)>> {
let mut ids: alloc::collections::BTreeSet<i64> = alloc::collections::BTreeSet::new();
// A rowid / INTEGER PRIMARY KEY collision: seek the table b-tree by rowid.
if self.read_row(meta, rowid)?.is_some() {
ids.insert(rowid);
}
// Every UNIQUE index — the automatic indexes of the inline UNIQUE / PRIMARY
// KEY sets plus standalone `CREATE UNIQUE INDEX`es — seeked by this row's
// key. A NULL key term or an excluding partial predicate can't collide.
let src = self.backend.source();
for idx in self.indexes_of(table)?.iter().filter(|i| i.unique) {
if !self.row_in_index(idx, meta, values, Some(rowid), params)? {
continue;
}
let key = self.index_key_values(idx, meta, values, rowid, params)?;
if key.iter().any(|v| matches!(v, Value::Null)) {
continue;
}
for er in crate::btree::index_seek_rowids(
src,
idx.root,
&key,
&idx.collations,
idx.seek_descs(),
)? {
ids.insert(er);
}
}
let mut out = Vec::with_capacity(ids.len());
for er in ids {
if let Some(ev) = self.read_row(meta, er)? {
out.push((er, ev));
}
}
Ok(out)
}
fn find_conflicts(
&self,
table: &str,
meta: &TableMeta,
rowid: i64,
values: &[Value],
exclude: Option<i64>,
params: &Params,
) -> Result<(Vec<i64>, OnConflict)> {
// Unique standalone indexes (named `CREATE UNIQUE INDEX`, incl. partial
// and expression indexes) are not represented in `meta.unique` — those
// sets come only from inline CREATE TABLE constraints (whose automatic
// indexes we therefore skip here). Precompute each such index's key
// values for the new row; a NULL key term or an excluding partial
// predicate means the new row can't collide on that index.
let uniq_idx: Vec<(IndexMeta, Vec<Value>)> = self
.indexes_of(table)?
.into_iter()
.filter(|i| i.unique && autoindex_number(&i.name, table).is_none())
.filter_map(|i| {
if !self
.row_in_index(&i, meta, values, Some(rowid), params)
.unwrap_or(false)
{
return None;
}
let key = self
.index_key_values(&i, meta, values, rowid, params)
.ok()?;
if key.iter().any(|v| matches!(v, Value::Null)) {
return None; // a NULL makes the key distinct
}
Some((i, key))
})
.collect();
let mut out = Vec::new();
// The declared `ON CONFLICT` action of the first inline UNIQUE/PRIMARY KEY
// set the new row collides on (used when the statement has no `OR <action>`).
let mut action: Option<OnConflict> = None;
// Only the handful of rows that could possibly collide (found by seeking
// the rowid + each unique index), NOT the whole table — this is what keeps
// INSERT O(n·log n) instead of O(n²). Each candidate is re-confirmed below.
for (er, ev) in self.conflict_candidates(table, meta, rowid, values, params)? {
if Some(er) == exclude {
continue;
}
if er == rowid {
out.push(er);
continue;
}
let mut conflicted = false;
for (set, set_oc, _) in &meta.unique {
let new_tuple: Vec<&Value> = set.iter().map(|&i| &values[i]).collect();
if new_tuple.iter().any(|v| matches!(v, Value::Null)) {
continue; // a NULL makes the key distinct
}
let conflict = set.iter().zip(&new_tuple).all(|(&i, nv)| {
crate::value::cmp_values_coll(&ev[i], nv, meta.columns[i].collation)
== core::cmp::Ordering::Equal
});
if conflict {
out.push(er);
action.get_or_insert(*set_oc);
conflicted = true;
break;
}
}
if conflicted {
continue;
}
// Then the unique standalone/partial/expression indexes.
for (idx, new_key) in &uniq_idx {
if !self.row_in_index(idx, meta, &ev, Some(er), params)? {
continue; // existing row not in this partial index
}
let ex_key = self.index_key_values(idx, meta, &ev, er, params)?;
let conflict = ex_key.len() == new_key.len()
&& ex_key
.iter()
.zip(new_key)
.zip(&idx.collations)
.all(|((a, b), &coll)| {
crate::value::cmp_values_coll(a, b, coll) == core::cmp::Ordering::Equal
});
if conflict {
out.push(er);
break;
}
}
}
Ok((out, action.unwrap_or(OnConflict::Abort)))
}
/// SQLite's UNIQUE-violation message for the *first* unique constraint the new
/// row collides on: `UNIQUE constraint failed: t.a[, t.b]` (or `: index 'name'`
/// for an expression index). Checks the rowid/INTEGER PRIMARY KEY, then inline
/// `UNIQUE`/`PRIMARY KEY` sets, then standalone unique indexes — falling back to
/// the bare message if none can be pinpointed. Runs only on the (cold) error
/// path, so the extra table scans are immaterial.
fn unique_violation_message(
&self,
table: &str,
meta: &TableMeta,
rowid: i64,
values: &[Value],
exclude: Option<i64>,
params: &Params,
) -> String {
let bare = String::from("UNIQUE constraint failed");
let qualify = |cols: &[usize]| {
cols.iter()
.map(|&i| alloc::format!("{}.{}", meta.columns[i].table, meta.columns[i].name))
.collect::<Vec<_>>()
.join(", ")
};
let rows = match self.scan_table(meta) {
Ok(r) => r,
Err(_) => return bare,
};
// A rowid / INTEGER PRIMARY KEY collision.
if let Some(ipk) = meta.ipk
&& rows
.iter()
.any(|(er, _)| *er == rowid && Some(*er) != exclude)
{
return alloc::format!("UNIQUE constraint failed: {}", qualify(&[ipk]));
}
// Inline UNIQUE / PRIMARY KEY constraint sets, in declaration order.
for (set, _, _) in &meta.unique {
if set.iter().any(|&i| matches!(values[i], Value::Null)) {
continue;
}
let hit = rows.iter().any(|(er, ev)| {
Some(*er) != exclude
&& set.iter().all(|&i| {
crate::value::cmp_values_coll(&ev[i], &values[i], meta.columns[i].collation)
== core::cmp::Ordering::Equal
})
});
if hit {
return alloc::format!("UNIQUE constraint failed: {}", qualify(set));
}
}
// Standalone unique indexes (a `CREATE UNIQUE INDEX`; the inline sets'
// automatic indexes are already covered above and skipped here).
if let Ok(idxs) = self.indexes_of(table) {
for idx in idxs
.iter()
.filter(|i| i.unique && autoindex_number(&i.name, table).is_none())
{
if !self
.row_in_index(idx, meta, values, Some(rowid), params)
.unwrap_or(false)
{
continue;
}
let Ok(new_key) = self.index_key_values(idx, meta, values, rowid, params) else {
continue;
};
if new_key.iter().any(|v| matches!(v, Value::Null)) {
continue;
}
let hit = rows.iter().any(|(er, ev)| {
Some(*er) != exclude
&& self
.row_in_index(idx, meta, ev, Some(*er), params)
.unwrap_or(false)
&& self
.index_key_values(idx, meta, ev, *er, params)
.map(|ek| {
ek.iter().zip(&new_key).enumerate().all(|(k, (a, b))| {
crate::value::cmp_values_coll(a, b, idx.collations[k])
== core::cmp::Ordering::Equal
})
})
.unwrap_or(false)
});
if hit {
let detail = if idx.key_exprs.is_some() {
alloc::format!("index '{}'", idx.name)
} else {
qualify(&idx.cols)
};
return alloc::format!("UNIQUE constraint failed: {detail}");
}
}
}
bare
}
/// Reject an `ON CONFLICT (target…)` whose target columns do not name an
/// actual PRIMARY KEY / UNIQUE constraint or unique index, exactly as sqlite
/// does before the INSERT runs (`ON CONFLICT clause does not match any
/// PRIMARY KEY or UNIQUE constraint`). A bare `ON CONFLICT` with no target
/// (already validated for column existence) absorbs any unique conflict and
/// is always accepted.
///
/// The target matches when its column set equals — order-independently — a
/// unique candidate's column set: the INTEGER PRIMARY KEY, an inline
/// PRIMARY KEY / UNIQUE constraint (covers WITHOUT ROWID and composite PKs),
/// or a unique standalone index. A *partial* unique index only matches when
/// the conflict target itself carries a `WHERE` (sqlite requires the
/// predicates to correspond); a full constraint matches regardless of the
/// target `WHERE`. The exact partial-index predicate text is not compared —
/// a target `WHERE` that differs from the index's is leniently accepted.
fn validate_upsert_conflict_targets(
&self,
meta: &TableMeta,
table: &str,
upserts: &[Upsert],
) -> Result<()> {
// Resolve a target column name to a column index (or the rowid alias's
// INTEGER PRIMARY KEY). Returns `None` for an unresolvable name, in which
// case we skip the check rather than risk a false rejection.
let resolve = |name: &str| -> Option<usize> {
if let Some(p) = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
{
return Some(p);
}
if eval::is_rowid_alias(name) {
return meta.ipk; // matches the IPK; `None` if there is no IPK
}
None
};
// Unique candidates as sorted column-index sets, each tagged `partial`.
let mut candidates: Vec<(Vec<usize>, bool)> = Vec::new();
if let Some(ipk) = meta.ipk {
candidates.push((alloc::vec![ipk], false));
}
for (set, _, _) in &meta.unique {
let mut s = set.clone();
s.sort_unstable();
candidates.push((s, false));
}
for idx in self.indexes_of(table)? {
if !idx.unique || idx.cols.is_empty() {
continue; // non-unique, or an expression index (no plain columns)
}
let mut s = idx.cols.clone();
s.sort_unstable();
candidates.push((s, idx.partial.is_some()));
}
for up in upserts {
if up.target.is_empty() {
continue; // bare ON CONFLICT — matches any unique conflict
}
let Some(mut tset) = up
.target
.iter()
.map(|n| resolve(n))
.collect::<Option<Vec<usize>>>()
else {
continue; // unresolvable target column — leave it to runtime
};
tset.sort_unstable();
let has_where = up.target_where.is_some();
// A full candidate matches regardless of the target WHERE; a partial
// one matches only when the target itself carries a WHERE.
let matched = candidates
.iter()
.any(|(cset, partial)| *cset == tset && (!*partial || has_where));
if !matched {
return Err(Error::Error(
"ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint".into(),
));
}
}
Ok(())
}
/// Does an `ON CONFLICT (target…) DO …` upsert clause apply to the conflict
/// that just occurred? A bare `ON CONFLICT` (no target) absorbs any unique
/// conflict. A targeted clause applies only when the proposed row actually
/// collides with a conflicting row on the **target** columns — a conflict on
/// a different unique index is a hard error, exactly as SQLite behaves.
#[allow(clippy::too_many_arguments)]
/// If `up`'s `ON CONFLICT` target matches one of the `conflicts`, return the
/// rowid of the conflicting row *on that target* — the row the `DO UPDATE`
/// must edit. When the inserted row collides on several unique constraints,
/// this is not necessarily `conflicts[0]`: a bare (untargeted) clause updates
/// the first conflict, but `ON CONFLICT(cols)` must update the row that shares
/// those exact columns. Returns `None` when the target does not match.
fn upsert_target_row(
&self,
meta: &TableMeta,
up: &Upsert,
conflicts: &[i64],
values: &[Value],
rowid: i64,
params: &Params,
) -> Result<Option<i64>> {
if up.target.is_empty() {
return Ok(conflicts.first().copied());
}
// Resolve the target column names to column indices.
let target_cols: Vec<usize> = up
.target
.iter()
.map(|name| {
meta.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
.ok_or_else(|| Error::Error(format!("no such column: {name}")))
})
.collect::<Result<_>>()?;
// The target names the rowid / INTEGER PRIMARY KEY: it matches when a
// conflicting row shares the candidate rowid — which is that row's rowid.
if let Some(ipk) = meta.ipk
&& target_cols == [ipk]
{
return Ok(conflicts.contains(&rowid).then_some(rowid));
}
// The conflict matches the target only if the proposed row equals some
// conflicting row on every target column (NULLs never match — a NULL key
// is distinct, so it could not have produced this conflict). That row's
// rowid is the one to update.
for &er in conflicts {
let Some(existing) = self.read_row(meta, er)? else {
continue;
};
let collide = target_cols.iter().all(|&c| {
!matches!(values[c], Value::Null)
&& crate::value::cmp_values_coll(
&existing[c],
&values[c],
meta.columns[c].collation,
) == core::cmp::Ordering::Equal
});
if collide {
return Ok(Some(er));
}
}
let _ = params;
Ok(None)
}
/// The key values for a row under `idx` (excluding the trailing rowid): the
/// indexed column values, or the evaluated key expressions for an expression
/// index. Used for uniqueness comparison (collation applied by the caller).
fn index_key_values(
&self,
idx: &IndexMeta,
meta: &TableMeta,
values: &[Value],
rowid: i64,
params: &Params,
) -> Result<Vec<Value>> {
match &idx.key_exprs {
None => Ok(idx.cols.iter().map(|&c| values[c].clone()).collect()),
Some(exprs) => {
let ctx = row_ctx(values, &meta.columns, Some(rowid), params).with_subqueries(self);
exprs.iter().map(|e| eval::eval(e, &ctx)).collect()
}
}
}
/// Whether rows `a` and `b` collide on any unique *standalone* index of
/// `table` (plain or partial — expression indexes are rejected on WITHOUT
/// ROWID tables). Complements [`unique_match`], which covers only the inline
/// PRIMARY KEY / UNIQUE constraints; used by the WITHOUT ROWID write paths.
fn wr_index_collision(
&self,
table: &str,
meta: &TableMeta,
a: &[Value],
b: &[Value],
params: &Params,
) -> Result<bool> {
for idx in self
.indexes_of(table)?
.iter()
.filter(|i| i.unique && autoindex_number(&i.name, table).is_none())
{
if !self.row_in_index(idx, meta, a, None, params)?
|| !self.row_in_index(idx, meta, b, None, params)?
{
continue;
}
let ka = self.index_key_values(idx, meta, a, 0, params)?;
if ka.iter().any(|v| matches!(v, Value::Null)) {
continue; // a NULL makes the key distinct
}
let kb = self.index_key_values(idx, meta, b, 0, params)?;
let eq = ka.len() == kb.len()
&& ka.iter().zip(&kb).zip(&idx.collations).all(|((x, y), &c)| {
crate::value::cmp_values_coll(x, y, c) == core::cmp::Ordering::Equal
});
if eq {
return Ok(true);
}
}
Ok(false)
}
/// The `UNIQUE constraint failed: …` message for two colliding WITHOUT ROWID
/// rows. Tries the inline `UNIQUE`/`PRIMARY KEY` sets first (like
/// `wr_unique_message`), then the standalone unique indexes — naming the
/// matching index's columns (`t.b, t.c`), or `index '<name>'` for an
/// expression index — so a secondary-index conflict no longer degrades to the
/// bare message. Falls back to the bare message only if nothing matches.
fn wr_conflict_message(
&self,
table: &str,
meta: &TableMeta,
a: &[Value],
b: &[Value],
params: &Params,
) -> Result<String> {
let inline = wr_unique_message(meta, a, b);
if inline != "UNIQUE constraint failed" {
return Ok(inline);
}
for idx in self
.indexes_of(table)?
.iter()
.filter(|i| i.unique && autoindex_number(&i.name, table).is_none())
{
if !self.row_in_index(idx, meta, a, None, params)?
|| !self.row_in_index(idx, meta, b, None, params)?
{
continue;
}
let ka = self.index_key_values(idx, meta, a, 0, params)?;
if ka.iter().any(|v| matches!(v, Value::Null)) {
continue;
}
let kb = self.index_key_values(idx, meta, b, 0, params)?;
let eq = ka.len() == kb.len()
&& ka.iter().zip(&kb).zip(&idx.collations).all(|((x, y), &c)| {
crate::value::cmp_values_coll(x, y, c) == core::cmp::Ordering::Equal
});
if eq {
return Ok(if idx.key_exprs.is_some() {
alloc::format!("UNIQUE constraint failed: index '{}'", idx.name)
} else {
let cols = idx
.cols
.iter()
.map(|&i| {
alloc::format!("{}.{}", meta.columns[i].table, meta.columns[i].name)
})
.collect::<Vec<_>>()
.join(", ");
alloc::format!("UNIQUE constraint failed: {cols}")
});
}
}
Ok(String::from("UNIQUE constraint failed"))
}
fn exec_delete(&mut self, del: &Delete, params: &Params) -> Result<usize> {
// A leading `WITH` makes its CTEs visible to the WHERE subqueries; push
// them for the duration of the statement, then restore the scope.
if del.ctes.is_empty() {
return self.exec_delete_inner(del, params);
}
let base = self.cte_env.borrow().len();
let seeds = delete_cte_seeds(del);
let pushed = self.push_ctes(&del.ctes, params, None, Some(&seeds));
let result = pushed.and_then(|()| self.exec_delete_inner(del, params));
self.cte_env.borrow_mut().truncate(base);
result
}
fn exec_delete_inner(&mut self, del: &Delete, params: &Params) -> Result<usize> {
reject_schema_write(&del.table)?;
// Resolve a target-table alias (`DELETE FROM t AS x …`) up front so every
// downstream path sees alias-qualified `WHERE`/`ORDER BY` references
// rewritten to the real table name (and a now-hidden real-name reference
// rejected). `RETURNING` is left untouched.
let aliased;
let del = if del.alias.is_some() {
let mut d = del.clone();
// A base-table target's columns refine the alias check (a missing
// `x.col` is rejected by name); a view/vtab target passes `None`.
let cols = if !self.is_virtual_table(&d.table) && !self.is_view(&d.table) {
Some(self.table_meta(&d.table, None)?.columns)
} else {
None
};
resolve_delete_alias(&mut d, cols.as_deref())?;
aliased = d;
&aliased
} else {
del
};
if self.is_dbpage_write_target(del.schema.as_deref(), &del.table) {
// SQLite's `sqlite_dbpage` xUpdate rejects a delete outright.
return Err(Error::Error("cannot delete".into()));
}
if self.is_virtual_table(&del.table) {
return self.exec_vtab_delete(del, params);
}
if self.is_view(&del.table) {
return self.exec_view_delete(del, params);
}
let meta = self.table_meta(&del.table, None)?;
reject_order_by_without_limit(&del.order_by, del.limit.as_ref(), "DELETE")?;
self.validate_index_hint(&del.table, del.index_hint.as_ref())?;
// Resolve the WHERE columns eagerly (top-level only — a trigger body's
// DML may bind a bare name to NEW/OLD), so a bogus column errors even when
// the table has no rows, matching sqlite. See `validate_dml_refs`.
if self.outer_scope.borrow().is_empty() {
// `ORDER BY` (with the LIMIT extension) takes no alias and names only
// the target's columns, so it resolves exactly like `WHERE`.
let mut refs: Vec<&Expr> = Vec::new();
if let Some(w) = &del.where_clause {
refs.push(w);
}
refs.extend(del.order_by.iter().map(|o| &o.expr));
let returning = returning_exprs(&del.returning);
if !refs.is_empty() || !returning.is_empty() {
let target_db = self.dml_target_db(del.schema.as_deref(), &del.table);
self.validate_dml_refs(&del.table, &target_db, &meta.columns, &refs, &returning)?;
}
}
if meta.without_rowid {
return self.exec_delete_without_rowid(del, &meta, params);
}
let indexes = self.indexes_of(&del.table)?;
let mut victims = self.matching_rowids(&meta, del.where_clause.as_ref(), params)?;
if !del.order_by.is_empty() || del.limit.is_some() || del.offset.is_some() {
victims = self.order_limit_rowids(
&meta,
victims,
&del.order_by,
del.limit.as_ref(),
del.offset.as_ref(),
params,
)?;
}
let mut deleted = 0;
for rowid in &victims {
let old = self.read_row(&meta, *rowid)?;
if let Some(old) = &old {
self.fire_triggers(
&del.table,
TrigEvent::Delete,
TriggerTiming::Before,
&meta.columns,
Some((old, *rowid)),
None,
params,
None,
)?;
// A `BEFORE DELETE` trigger's `RAISE(IGNORE)` spares this row.
if self.raise_ignore.replace(false) {
continue;
}
if !del.returning.is_empty() {
self.collect_returning(&del.returning, &meta, old, Some(*rowid), params)?;
}
}
// Remove the parent row first, THEN enforce referential actions on the
// children. SQLite defers the child-side FK check to statement end, so
// an action that resolves to the just-deleted key (e.g. `ON DELETE SET
// DEFAULT` whose default names this very row) must see it already gone
// and fail — enforcing before the delete would wrongly find the parent
// still present. The action still matches children by the saved `old`
// key, so removing the parent first does not affect which rows it hits.
delete_table(self.backend.writer()?, meta.root, *rowid)?;
if let Some(old) = &old {
self.record_session_change(
&del.table,
&meta,
crate::session::ChangeOp::Delete,
*rowid,
Some(old),
None,
);
}
if self.foreign_keys
&& let Some(old) = &old
{
self.enforce_parent_change(&del.table, old, None, params)?;
}
deleted += 1;
if let Some(old) = &old {
self.fire_triggers(
&del.table,
TrigEvent::Delete,
TriggerTiming::After,
&meta.columns,
Some((old, *rowid)),
None,
params,
None,
)?;
}
}
if deleted > 0 {
self.compact_table(&meta)?;
self.rebuild_indexes(&meta, &indexes)?;
}
// A cascading delete may have emptied leaves in child tables too.
self.drain_cascade_compact()?;
Ok(deleted)
}
/// Reclaim empty/underfull table b-tree pages left by deletes: if the table
/// has any empty leaf page, rebuild the b-tree compactly in place (root page
/// number preserved), freeing the slack to the freelist. This is graphitesql's
/// page-merging-on-delete — using the well-tested insert path rather than
/// in-place sibling rebalancing — and keeps the tree balanced and compact.
fn compact_table(&mut self, meta: &TableMeta) -> Result<()> {
if !table_has_empty_leaf(self.backend.source(), meta.root)? {
return Ok(());
}
// Collect every surviving (rowid, raw payload) in key order.
let mut rows: Vec<(i64, Vec<u8>)> = Vec::new();
{
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut ok = cur.first()?;
while ok {
rows.push((cur.rowid()?, cur.payload()?));
ok = cur.next()?;
}
}
let w = self.backend.writer()?;
clear_table(w, meta.root)?;
for (rowid, payload) in &rows {
insert_table(w, meta.root, *rowid, payload)?;
}
Ok(())
}
/// Compact every table whose b-tree had rows removed by a cascading delete
/// during the statement just finished (see `cascade_compact`). Called from
/// each top-level DML tail; a no-op when nothing cascaded.
fn drain_cascade_compact(&mut self) -> Result<()> {
if self.cascade_compact.borrow().is_empty() {
return Ok(());
}
let tables: Vec<String> = self.cascade_compact.borrow_mut().iter().cloned().collect();
self.cascade_compact.borrow_mut().clear();
for name in tables {
// The table may have been dropped by a later cascade; skip if gone.
// Never compact a WITHOUT ROWID table: `compact_table` walks a rowid
// `TableCursor` (misreads an index-organized b-tree). Its clustered
// b-tree is maintained by the WR rewrite path, so it never needs it —
// and never enters this set — but guard defensively.
if let Ok(meta) = self.table_meta(&name, None)
&& !meta.without_rowid
{
self.compact_table(&meta)?;
}
}
Ok(())
}
fn exec_update(&mut self, upd: &Update, params: &Params) -> Result<usize> {
// A leading `WITH` exposes its CTEs to the SET/WHERE/FROM subqueries.
if upd.ctes.is_empty() {
return self.exec_update_inner(upd, params);
}
let base = self.cte_env.borrow().len();
let seeds = update_cte_seeds(upd);
let pushed = self.push_ctes(&upd.ctes, params, None, Some(&seeds));
let result = pushed.and_then(|()| self.exec_update_inner(upd, params));
self.cte_env.borrow_mut().truncate(base);
result
}
fn exec_update_inner(&mut self, upd: &Update, params: &Params) -> Result<usize> {
reject_schema_write(&upd.table)?;
// Resolve a target-table alias (`UPDATE t AS x …`) up front so every
// downstream path (rowid, view, vtab, WITHOUT ROWID) sees alias-qualified
// `SET`/`WHERE`/`ORDER BY` references rewritten to the real table name
// (and a now-hidden real-name reference rejected). `RETURNING` is left
// untouched — SQLite resolves it against the real table name, not the alias.
let aliased;
let upd = if upd.alias.is_some() {
let mut u = upd.clone();
// A base-table target's columns refine the alias check (a missing
// `x.col` is rejected by name); a view/vtab target passes `None`.
let cols = if !self.is_virtual_table(&u.table) && !self.is_view(&u.table) {
Some(self.table_meta(&u.table, None)?.columns)
} else {
None
};
resolve_update_alias(&mut u, cols.as_deref())?;
aliased = u;
&aliased
} else {
upd
};
if self.is_dbpage_write_target(upd.schema.as_deref(), &upd.table) {
return self.exec_dbpage_update(upd, params);
}
if self.is_virtual_table(&upd.table) {
return self.exec_vtab_update(upd, params);
}
if self.is_view(&upd.table) {
return self.exec_view_update(upd, params);
}
let meta = self.table_meta(&upd.table, None)?;
reject_order_by_without_limit(&upd.order_by, upd.limit.as_ref(), "UPDATE")?;
self.validate_index_hint(&upd.table, upd.index_hint.as_ref())?;
// Validate the SET-target columns up front: sqlite rejects an unknown
// assignment column at prepare time, even when the table has no rows.
// graphite otherwise resolves them lazily in the per-row loop and so
// silently accepted a bogus column on an empty table.
for col in upd
.assignments
.iter()
.map(|(c, _)| c)
.chain(upd.row_assignments.iter().flat_map(|(cs, _)| cs))
{
if !meta
.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(col))
{
return Err(Error::Error(alloc::format!("no such column: {col}")));
}
}
// Resolve the WHERE and SET-value columns eagerly too (no `FROM`, so every
// reference resolves to the target; top-level only, like DELETE). A bogus
// column then errors over an empty table, matching sqlite. `FROM` puts
// other tables in scope, so that shape is left to lazy resolution.
if upd.from.is_none() && self.outer_scope.borrow().is_empty() {
let mut refs: Vec<&Expr> = upd.assignments.iter().map(|(_, e)| e).collect();
if let Some(w) = &upd.where_clause {
refs.push(w);
}
// `ORDER BY` (with the LIMIT extension) names only the target's
// columns — no alias scope — so it resolves like `WHERE`.
refs.extend(upd.order_by.iter().map(|o| &o.expr));
let returning = returning_exprs(&upd.returning);
let target_db = self.dml_target_db(upd.schema.as_deref(), &upd.table);
self.validate_dml_refs(&upd.table, &target_db, &meta.columns, &refs, &returning)?;
}
if meta.without_rowid {
return self.exec_update_without_rowid(upd, &meta, params);
}
let indexes = self.indexes_of(&upd.table)?;
// Columns named in the SET list — drives `UPDATE OF col,…` trigger firing.
let mut changed: Vec<String> = upd.assignments.iter().map(|(c, _)| c.clone()).collect();
for (rcols, _) in &upd.row_assignments {
changed.extend(rcols.iter().cloned());
}
// UPDATE … FROM: materialize the extra tables once. Each target row is
// joined to the first FROM-row combination satisfying WHERE, and that
// row's columns are visible to SET/WHERE. Without FROM, `from_rows` is
// empty and the target is matched against WHERE directly.
let from_data = match &upd.from {
Some(fc) => {
// A from-only synthetic SELECT to reuse the join scanner. Its WHERE
// stays empty (the UPDATE's WHERE references the target too and is
// applied per target row below), so the scan is a plain superset.
// The `*` projection is essential: it marks every source column as
// needed, so `scan_source`'s covering-index optimization does not
// read from a narrow index (e.g. a PRIMARY KEY autoindex) that omits
// the columns the SET/WHERE expressions reference.
let synth = Select {
ctes: Vec::new(),
compound: Vec::new(),
distinct: false,
columns: alloc::vec![ResultColumn::Wildcard],
from: Some(fc.clone()),
where_clause: None,
group_by: Vec::new(),
having: None,
window_defs: Vec::new(),
order_by: Vec::new(),
limit: None,
offset: None,
values_rows: 0,
};
let (cols, rows) = self.scan_source(&synth, params)?;
Some((cols, rows.into_iter().map(|r| r.values).collect::<Vec<_>>()))
}
None => None,
};
let combined_columns: Vec<ColumnInfo> = match &from_data {
Some((cols, _)) => meta.columns.iter().chain(cols).cloned().collect(),
None => Vec::new(),
};
// Collect (rowid, current values, matched FROM row) for matching rows.
let mut targets: Vec<(i64, Vec<Value>, Option<Vec<Value>>)> = Vec::new();
{
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let encoding = self.backend.source().header().text_encoding;
let mut ok = cur.first()?;
while ok {
let rowid = cur.rowid()?;
let values = self.decode_full_row(&meta, rowid, &cur.payload()?, encoding)?;
match &from_data {
// UPDATE … FROM: find the first joined row passing WHERE.
Some((_, from_rows)) => {
let mut matched = None;
for fr in from_rows {
let mut combined = values.clone();
combined.extend_from_slice(fr);
let ok = match &upd.where_clause {
Some(p) => {
let ctx =
row_ctx(&combined, &combined_columns, Some(rowid), params)
.with_subqueries(self);
eval::truth(&eval::eval(p, &ctx)?) == Some(true)
}
None => true,
};
if ok {
matched = Some(fr.clone());
break;
}
}
if let Some(fr) = matched {
targets.push((rowid, values, Some(fr)));
}
}
None => {
let matches = match &upd.where_clause {
Some(p) => {
let ctx = row_ctx(&values, &meta.columns, Some(rowid), params)
.with_subqueries(self);
eval::truth(&eval::eval(p, &ctx)?) == Some(true)
}
None => true,
};
if matches {
targets.push((rowid, values, None));
}
}
}
ok = cur.next()?;
}
}
// `ORDER BY … LIMIT …` selects which matching rows to update.
if !upd.order_by.is_empty() || upd.limit.is_some() || upd.offset.is_some() {
let rowids: Vec<i64> = targets.iter().map(|(r, _, _)| *r).collect();
let kept = self.order_limit_rowids(
&meta,
rowids,
&upd.order_by,
upd.limit.as_ref(),
upd.offset.as_ref(),
params,
)?;
// Reorder/filter `targets` to the kept rowids, preserving kept order.
let mut by_id: alloc::collections::BTreeMap<i64, (Vec<Value>, Option<Vec<Value>>)> =
targets.into_iter().map(|(r, v, f)| (r, (v, f))).collect();
targets = kept
.into_iter()
.filter_map(|r| by_id.remove(&r).map(|(v, f)| (r, v, f)))
.collect();
}
// Evaluate every target row's SET assignments against the table as it is
// BEFORE any write, so a subquery in a SET expression sees a consistent
// snapshot — `UPDATE t SET b=(SELECT sum(b) FROM t)` uses the original sum
// for every row, exactly like sqlite — rather than observing rows updated
// earlier in the same statement. Writes happen in the second pass below.
let mut prepared: Vec<(i64, Vec<Value>, Vec<Value>)> = Vec::with_capacity(targets.len());
for (rowid, mut values, matched_from) in targets {
let old_row = values.clone();
for (col, expr) in &upd.assignments {
let pos = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(col))
.ok_or_else(|| Error::Error(format!("no such column: {col}")))?;
if meta.is_generated(pos) {
return Err(Error::Error(format!(
"cannot UPDATE generated column \"{col}\""
)));
}
// SQLite evaluates every SET expression against the ORIGINAL row
// (assignments are simultaneous): `SET a=b, b=a` swaps. Evaluate
// against `old_row`, not the progressively-mutated `values`.
let new = match &matched_from {
Some(fr) => {
let mut combined = old_row.clone();
combined.extend_from_slice(fr);
let ctx = row_ctx(&combined, &combined_columns, Some(rowid), params)
.with_subqueries(self);
eval::eval(expr, &ctx)?
}
None => {
let ctx = row_ctx(&old_row, &meta.columns, Some(rowid), params)
.with_subqueries(self);
eval::eval(expr, &ctx)?
}
};
values[pos] = new;
}
if !upd.row_assignments.is_empty() {
// Build the same (possibly FROM-combined) original-row context the
// per-expr assignments used, then run each row-value subquery.
let combined_row;
let (ctx_row, ctx_cols): (&[Value], &[ColumnInfo]) = match &matched_from {
Some(fr) => {
let mut c = old_row.clone();
c.extend_from_slice(fr);
combined_row = c;
(&combined_row, &combined_columns)
}
None => (&old_row, &meta.columns),
};
let ctx = row_ctx(ctx_row, ctx_cols, Some(rowid), params).with_subqueries(self);
self.apply_row_subquery_assignments(
&upd.row_assignments,
&meta.columns,
Some(&meta),
&ctx,
&mut values,
)?;
}
apply_column_affinity(&meta, &mut values);
self.materialize_generated(&meta, &mut values, params)?;
prepared.push((rowid, old_row, values));
}
// An AFTER UPDATE trigger firing for an *earlier* row may modify a *later*
// row of this same statement (e.g. `AFTER UPDATE … BEGIN UPDATE t SET
// b=b+1 WHERE a=NEW.a; END`). Those edits to columns this UPDATE does not
// itself SET must survive the later row's write, which would otherwise
// overlay its pass-1 snapshot. When such a trigger exists, re-read each row
// just before writing and merge, exactly as the BEFORE-trigger case does. A
// BEFORE UPDATE trigger fires for every row, so `before_fired` already
// covers its cross-row edits.
let has_after_update_trigger = !self
.triggers_for(&upd.table, TrigEvent::Update, TriggerTiming::After)?
.is_empty();
let mut affected = 0;
for (rowid, old_row, mut values) in prepared {
// An UPDATE of the INTEGER PRIMARY KEY (the rowid) must leave it an
// integer; NULL or a non-integer (after affinity) is a datatype
// mismatch — a hard error checked before NOT NULL (which would else
// mis-report `SET ipk = NULL`) and not skipped by UPDATE OR IGNORE.
if let Some(ipk) = meta.ipk
&& !matches!(values[ipk], Value::Integer(_))
{
return Err(Error::Error("datatype mismatch".into()));
}
// NOT NULL / CHECK / STRICT-type constraints. `UPDATE OR IGNORE` skips
// a row that violates one rather than failing the statement.
{
if !self.resolve_not_null(
&meta,
&mut values,
upd.on_conflict,
upd.on_conflict_explicit,
params,
)? {
continue;
}
let r = self
.check_strict_types(&meta, &values)
.and_then(|()| self.check_constraints(&meta, &values, Some(rowid), params));
match r {
Ok(()) => {}
Err(Error::Constraint(_)) if upd.on_conflict == OnConflict::Ignore => continue,
Err(Error::Constraint(m)) => {
return Err(self.conflict_error(upd.on_conflict, &m));
}
Err(e) => return Err(e),
}
}
// Foreign keys: this row as a child must still point at a parent, and
// as a parent it must propagate referenced-key changes to children.
self.check_fk_child(&upd.table, &meta, &values)?;
if self.foreign_keys {
self.enforce_parent_change(&upd.table, &old_row, Some(&values), params)?;
}
// New rowid if the IPK column was changed, else unchanged.
let new_rowid = match meta.ipk {
Some(ipk) => eval::to_i64(&values[ipk]),
None => rowid,
};
let before_fired = self.fire_triggers(
&upd.table,
TrigEvent::Update,
TriggerTiming::Before,
&meta.columns,
Some((&old_row, rowid)),
Some((&values, new_rowid)),
params,
Some(&changed),
)?;
// A `BEFORE UPDATE` trigger's `RAISE(IGNORE)` leaves this row alone.
if self.raise_ignore.replace(false) {
continue;
}
// A BEFORE UPDATE trigger may have modified this very row via a nested
// `UPDATE` (e.g. `UPDATE t SET b = NEW.a WHERE id = OLD.id`). SQLite
// keeps such changes to columns the main UPDATE does not itself SET,
// then overlays the SET assignments (already computed from the original
// row) on top. Re-read the row and merge: a SET column (and the rowid)
// keeps its computed value; every other column takes the possibly
// trigger-modified current value, after which generated columns are
// recomputed. Runs when a BEFORE trigger touched this row, or when an
// AFTER UPDATE trigger exists (a prior row's firing may have edited this
// one); the trigger-free path is unchanged (an untouched row is a no-op).
if (before_fired || has_after_update_trigger)
&& let Some(current) = self.read_row(&meta, rowid)?
{
for (i, col) in meta.columns.iter().enumerate() {
let is_set = changed.iter().any(|c| c.eq_ignore_ascii_case(&col.name));
if !is_set && meta.ipk != Some(i) {
values[i] = current[i].clone();
}
}
self.materialize_generated(&meta, &mut values, params)?;
}
// UNIQUE/PK conflict against any other row. `UPDATE OR IGNORE` skips
// this row; `UPDATE OR REPLACE` deletes the conflicting rows first.
let (conflicts, constraint_oc) =
self.find_conflicts(&upd.table, &meta, new_rowid, &values, Some(rowid), params)?;
let effective_oc = if upd.on_conflict_explicit {
upd.on_conflict
} else {
constraint_oc
};
if !conflicts.is_empty() {
match effective_oc {
OnConflict::Ignore => continue,
OnConflict::Replace => {
for cr in conflicts {
delete_table(self.backend.writer()?, meta.root, cr)?;
}
}
oc @ (OnConflict::Abort | OnConflict::Fail | OnConflict::Rollback) => {
let m = self.unique_violation_message(
&upd.table,
&meta,
new_rowid,
&values,
Some(rowid),
params,
);
return Err(self.conflict_error(oc, &m));
}
}
}
let new_full = values.clone();
let record = self.encode_table_record(&meta, &new_full);
delete_table(self.backend.writer()?, meta.root, rowid)?;
insert_table(self.backend.writer()?, meta.root, new_rowid, &record)?;
self.record_session_change(
&upd.table,
&meta,
crate::session::ChangeOp::Update,
rowid,
Some(&old_row),
Some(&new_full),
);
self.fire_triggers(
&upd.table,
TrigEvent::Update,
TriggerTiming::After,
&meta.columns,
Some((&old_row, rowid)),
Some((&new_full, new_rowid)),
params,
Some(&changed),
)?;
if !upd.returning.is_empty() {
self.collect_returning(&upd.returning, &meta, &new_full, Some(new_rowid), params)?;
}
affected += 1;
}
if affected > 0 {
self.compact_table(&meta)?;
self.rebuild_indexes(&meta, &indexes)?;
}
// An UPDATE OR REPLACE / FK action may have cascaded deletes into
// child tables; compact any that were left with empty leaves.
self.drain_cascade_compact()?;
Ok(affected)
}
// ---- index DDL & maintenance --------------------------------------------
fn exec_create_index(&mut self, ci: &CreateIndex, sql_text: &str) -> Result<()> {
if self.schema.index(&ci.name).is_some() {
if ci.if_not_exists {
return Ok(());
}
return Err(Error::Error(format!("index {} already exists", ci.name)));
}
// The index name also shares the table/view namespace; SQLite words that
// collision differently from a duplicate index ("there is already a table
// named X", and it says "table" even when X is a view).
if self.schema.objects().iter().any(|o| {
o.name == ci.name
&& matches!(
o.obj_type,
crate::schema::ObjectType::Table | crate::schema::ObjectType::View
)
}) {
return Err(Error::Error(format!(
"there is already a table named {}",
ci.name
)));
}
if self.is_virtual_table(&ci.table) {
return Err(Error::Error("virtual tables may not be indexed".into()));
}
self.reject_internal_table_ddl(&ci.table, "indexed")?;
// A missing index target is schema-qualified by SQLite (`main` default),
// unlike the bare "no such table" of a DML/SELECT reference.
if self.schema.table(&ci.table).is_none() {
return Err(Error::Error(format!(
"no such table: {}.{}",
ci.schema.as_deref().unwrap_or("main"),
ci.table
)));
}
let tmeta = self.table_meta(&ci.table, None)?;
// SQLite resolves the index *key* expressions fully, left to right, before
// it looks at the partial-index predicate at all — so a fault in any key
// outranks any fault in the WHERE clause. Within one key the precedence is:
// an unknown column, then an unknown function, then a non-deterministic
// function (`… prohibited in index expressions`), then aggregate- and then
// window-function misuse, then a dotted reference, then an unknown collation.
let known: Vec<String> = tmeta.columns.iter().map(|c| c.name.clone()).collect();
for term in &ci.columns {
// A `table.col` qualifier naming the indexed table resolves but is
// rejected as a dotted reference; the collation lives on the outer term.
let key = match &term.expr {
Expr::Collate { expr, .. } => expr.as_ref(),
other => other,
};
if let Some(col) = unknown_column_ref(key, &known, false, Some(&ci.table)) {
return Err(Error::Error(format!("no such column: {col}")));
}
self.reject_unresolved_functions(key)?;
if expr_is_nondeterministic(key) {
return Err(Error::Error(
"non-deterministic functions prohibited in index expressions".into(),
));
}
if let Some(name) = first_aggregate_call_name(key) {
return Err(Error::Error(format!(
"misuse of aggregate function {name}()"
)));
}
if let Some(name) = first_window_call_name(key) {
return Err(Error::Error(format!("misuse of window function {name}()")));
}
if has_resolved_dotted_ref(key, &known, false, &ci.table) {
return Err(Error::Error(
"the \".\" operator prohibited in index expressions".into(),
));
}
if let Some(name) = unknown_collation(&term.expr) {
return Err(Error::Error(format!("no such collation sequence: {name}")));
}
}
// The partial-index predicate (`CREATE INDEX … WHERE p`) is validated after
// every key, in SQLite's order: a subquery first, then an unknown column
// (rowid is allowed here, unlike a key), then an unknown function, then a
// non-deterministic function (its own `… partial index WHERE clauses`
// wording), then aggregate- and then window-function misuse.
if let Some(p) = &ci.where_clause {
if expr_has_subquery(p) {
return Err(Error::Error(
"subqueries prohibited in partial index WHERE clauses".into(),
));
}
if let Some(col) = unknown_column_ref(p, &known, true, Some(&ci.table)) {
return Err(Error::Error(format!("no such column: {col}")));
}
self.reject_unresolved_functions(p)?;
if expr_is_nondeterministic(p) {
return Err(Error::Error(
"non-deterministic functions prohibited in partial index WHERE clauses".into(),
));
}
if let Some(name) = first_aggregate_call_name(p) {
return Err(Error::Error(format!(
"misuse of aggregate function {name}()"
)));
}
if let Some(name) = first_window_call_name(p) {
return Err(Error::Error(format!("misuse of window function {name}()")));
}
}
let (cols, key_exprs, colls) = self.index_key_spec(&tmeta, ci)?;
// Per-column DESC flags for this build. Must match what later seeks/inserts
// pass (`IndexMeta::seek_descs`): a plain column index has trustworthy
// directions; expression indexes build (and seek) all-ascending.
let descs: Vec<bool> = if key_exprs.is_none() {
ci.columns.iter().map(|t| t.descending).collect()
} else {
Vec::new()
};
if key_exprs.is_some() && tmeta.without_rowid {
return Err(Error::Unsupported(
"expression indexes on WITHOUT ROWID tables",
));
}
let schema_next = self.next_rowid(crate::schema::SCHEMA_ROOT_PAGE)?;
// A partial index (`CREATE INDEX … WHERE p`) only stores rows for which
// the predicate holds; evaluate it up front (before the writer borrow).
let no_params = Params::default();
let keep_row = |values: &[Value], rowid: Option<i64>| -> Result<bool> {
match &ci.where_clause {
None => Ok(true),
Some(p) => {
let ctx =
row_ctx(values, &tmeta.columns, rowid, &no_params).with_subqueries(self);
Ok(eval::truth(&eval::eval(p, &ctx)?) == Some(true))
}
}
};
// WITHOUT ROWID secondary indexes are keyed by (indexed cols, PK cols)
// instead of (indexed cols, rowid).
// A UNIQUE index over rows that already collide is rejected at build time
// (`UNIQUE constraint failed: t.a[, t.b]`, or `index '<name>'` for an
// expression index) — matching SQLite, which scans the existing rows when
// it builds the index rather than silently admitting a duplicate key.
let uniq_msg = || -> String {
if key_exprs.is_some() {
alloc::format!("UNIQUE constraint failed: index '{}'", ci.name)
} else {
let detail = cols
.iter()
.map(|&i| {
alloc::format!("{}.{}", tmeta.columns[i].table, tmeta.columns[i].name)
})
.collect::<Vec<_>>()
.join(", ");
alloc::format!("UNIQUE constraint failed: {detail}")
}
};
let root = if tmeta.without_rowid {
let rows = self.scan_without_rowid(&tmeta)?;
let keep: Vec<bool> = rows
.iter()
.map(|row| keep_row(row, None))
.collect::<Result<_>>()?;
// (Expression indexes on WITHOUT ROWID tables are rejected above, so
// the uniqueness key here is always plain column values.)
if ci.unique {
let tuples: Vec<Vec<Value>> = rows
.iter()
.zip(&keep)
.filter(|&(_, &k)| k)
.map(|(row, _)| cols.iter().map(|&c| row[c].clone()).collect())
.collect();
if unique_index_conflict(&tuples, &colls) {
return Err(Error::Constraint(uniq_msg()));
}
}
let pk_cols = tmeta.storage_order[..tmeta.pk_len].to_vec();
// SQLite dedups PK columns already in the index key (same collation);
// the appended trailing PK carries the PK's own collation and DESC.
let (trailing_pk, trailing_colls, trailing_descs) =
wr_trailing_pk(&cols, &colls, &pk_cols, &tmeta);
let mut key_colls = colls.clone();
key_colls.extend(trailing_colls);
let mut key_descs = if descs.iter().any(|&d| d) {
descs.clone()
} else {
Vec::new()
};
wr_extend_descs(&mut key_descs, &colls, &trailing_descs);
let w = self.backend.writer()?;
let root = create_index_root(w)?;
for (row, &k) in rows.iter().zip(&keep) {
if k {
insert_index(
w,
root,
&wr_index_key(
&cols,
&trailing_pk,
&realify_columns_for_storage(&tmeta, row),
),
&key_colls,
&key_descs,
)?;
}
}
root
} else {
let rows = self.scan_table(&tmeta)?;
// Precompute the key bytes of every included row (column values, or
// evaluated expressions for an expression index) before the writer
// borrow. For a UNIQUE index, also collect the key values (rowid
// excluded) so existing duplicates can be rejected before any write.
let mut keys: Vec<Vec<u8>> = Vec::new();
let mut uniq: Vec<Vec<Value>> = Vec::new();
for (rowid, values) in &rows {
if !keep_row(values, Some(*rowid))? {
continue;
}
keys.push(match &key_exprs {
None => {
if ci.unique {
uniq.push(cols.iter().map(|&c| values[c].clone()).collect());
}
index_key(&cols, &realify_columns_for_storage(&tmeta, values), *rowid)
}
Some(exprs) => {
let ctx = row_ctx(values, &tmeta.columns, Some(*rowid), &no_params)
.with_subqueries(self);
let k: Vec<Value> = exprs
.iter()
.map(|e| eval::eval(e, &ctx))
.collect::<Result<_>>()?;
if ci.unique {
uniq.push(k.clone());
}
let mut k = k;
k.push(Value::Integer(*rowid));
encode_record(&k)
}
});
}
if ci.unique && unique_index_conflict(&uniq, &colls) {
return Err(Error::Constraint(uniq_msg()));
}
let w = self.backend.writer()?;
let root = create_index_root(w)?;
for key in &keys {
insert_index(w, root, key, &colls, &descs)?;
}
root
};
let w = self.backend.writer()?;
let schema_row = encode_record(&[
Value::Text("index".into()),
Value::Text(ci.name.clone().into()),
Value::Text(ci.table.clone().into()),
Value::Integer(root as i64),
Value::Text(
canonical_schema_sql(
if ci.unique {
"CREATE UNIQUE INDEX "
} else {
"CREATE INDEX "
},
sql_text,
)
.into(),
),
]);
insert_table(w, crate::schema::SCHEMA_ROOT_PAGE, schema_next, &schema_row)?;
let cookie = w.header().schema_cookie.wrapping_add(1);
w.header_mut().schema_cookie = cookie;
self.schema = Schema::read(self.backend.source())?;
Ok(())
}
fn exec_create_view(&mut self, cv: &CreateView, sql_text: &str) -> Result<()> {
// A schema-qualified `CREATE VIEW aux.v …` stores its SQL bare-named.
let stripped;
let sql_text = match cv.schema.as_deref() {
Some(s) => {
stripped = strip_schema_qualifier(sql_text, s)?;
stripped.as_str()
}
None => sql_text,
};
if let Some(e) = self.table_namespace_conflict(&cv.name) {
if cv.if_not_exists {
return Ok(());
}
return Err(e);
}
let next = self.next_rowid(crate::schema::SCHEMA_ROOT_PAGE)?;
let row = encode_record(&[
Value::Text("view".into()),
Value::Text(cv.name.clone().into()),
Value::Text(cv.name.clone().into()),
Value::Integer(0), // views have no b-tree root
Value::Text(canonical_schema_sql("CREATE VIEW ", sql_text).into()),
]);
insert_table(
self.backend.writer()?,
crate::schema::SCHEMA_ROOT_PAGE,
next,
&row,
)?;
let cookie = self
.backend
.writer()?
.header()
.schema_cookie
.wrapping_add(1);
self.backend.writer()?.header_mut().schema_cookie = cookie;
self.schema = Schema::read(self.backend.source())?;
Ok(())
}
/// Execute `CREATE VIRTUAL TABLE … USING module(args)`: look the module up in
/// the registry, validate the arguments by connecting (so a bad CREATE fails
/// now, not at first query), and persist a `sqlite_schema` row with
/// `type='table'`, `rootpage=0`, and `sql` = the original CREATE text.
fn exec_create_virtual_table(
&mut self,
cvt: &CreateVirtualTable,
sql_text: &str,
) -> Result<()> {
// A schema-qualified `CREATE VIRTUAL TABLE aux.v …` stores its SQL
// bare-named, like CREATE TABLE/VIEW.
let stripped;
let sql_text = match cvt.schema.as_deref() {
Some(s) => {
stripped = strip_schema_qualifier(sql_text, s)?;
stripped.as_str()
}
None => sql_text,
};
if let Some(e) = self.table_namespace_conflict(&cvt.name) {
if cvt.if_not_exists {
return Ok(());
}
return Err(e);
}
// The module must be registered, and must accept these arguments.
let module = self
.vtab_registry
.get(&cvt.module)
.ok_or_else(|| Error::Error(format!("no such module: {}", cvt.module)))?;
let arg_refs: Vec<&str> = cvt.args.iter().map(String::as_str).collect();
let schema = module.dyn_connect(&arg_refs)?;
let persistent = module.dyn_persistent();
let cols = schema.columns;
// Every R-Tree — with or without auxiliary (`+col`) columns — uses
// SQLite's byte-compatible node format (`_node`/`_rowid`/`_parent`) so its
// file round-trips through sqlite3; aux columns persist in `_rowid`'s
// `a0..aN` (see `rtree_create_storage`). All other persistent modules keep
// the generic `<name>_data` backing table.
let rtree_n_coord = (cvt.module.eq_ignore_ascii_case("rtree")
|| cvt.module.eq_ignore_ascii_case("rtree_i32"))
.then(|| crate::vtab::RTreeModule::n_coords(&arg_refs))
.filter(|n| cols.len() > *n);
#[cfg(feature = "fts5")]
let is_fts5 = cvt.module.eq_ignore_ascii_case("fts5");
#[cfg(not(feature = "fts5"))]
let is_fts5 = false;
let is_geopoly = cvt.module.eq_ignore_ascii_case("geopoly");
if is_geopoly {
// geopoly's `_rowid` carries one aux column per user argument (plus
// `a0` for the `_shape` BLOB); the node/parent shadows use the
// byte-compatible R-Tree format.
self.geopoly_create_storage(&cvt.name, cvt.args.len())?;
} else if let Some(n_coord) = rtree_n_coord {
let integer = cvt.module.eq_ignore_ascii_case("rtree_i32");
let n_aux = cols.len() - 1 - n_coord;
self.rtree_create_storage(&cvt.name, n_coord, integer, n_aux)?;
} else if is_fts5 {
// FTS5 uses sqlite's shadow tables (so the file round-trips through
// stock sqlite), not the generic `<name>_data` store. An external-
// content table (`content='<tbl>'`) keeps no `_content` copy — its
// documents live in the named content table.
#[cfg(feature = "fts5")]
{
let no_local = crate::vtab::fts5_no_local_content(&arg_refs);
self.fts5_create_storage(&cvt.name, cols.len(), no_local)?;
}
} else if persistent {
let coldefs = cols
.iter()
.map(|c| sql::print::ident(c))
.collect::<Vec<_>>()
.join(", ");
let backing_sql = format!(
"CREATE TABLE {}({coldefs})",
sql::print::ident(&format!("{}_data", cvt.name))
);
let Statement::CreateTable(ct) = sql::parse_one(&backing_sql)? else {
unreachable!("constructed a CREATE TABLE");
};
self.exec_create_table(&ct, &backing_sql)?;
}
let next = self.next_rowid(crate::schema::SCHEMA_ROOT_PAGE)?;
let row = encode_record(&[
Value::Text("table".into()),
Value::Text(cvt.name.clone().into()),
Value::Text(cvt.name.clone().into()),
Value::Integer(0), // virtual tables have no b-tree root
Value::Text(sql_text.into()),
]);
insert_table(
self.backend.writer()?,
crate::schema::SCHEMA_ROOT_PAGE,
next,
&row,
)?;
let cookie = self
.backend
.writer()?
.header()
.schema_cookie
.wrapping_add(1);
self.backend.writer()?.header_mut().schema_cookie = cookie;
self.schema = Schema::read(self.backend.source())?;
Ok(())
}
/// Whether the named object is a virtual table (a `type='table'` schema row
/// whose stored SQL is a `CREATE VIRTUAL TABLE`). Such a table has no b-tree
/// (`rootpage = 0`) and is scanned through its registered module instead.
fn is_virtual_table(&self, name: &str) -> bool {
self.schema
.objects()
.iter()
.filter(|o| {
o.obj_type == crate::schema::ObjectType::Table && o.name.eq_ignore_ascii_case(name)
})
.any(|o| {
matches!(
o.sql.as_deref().map(sql::parse_one),
Some(Ok(Statement::CreateVirtualTable(_)))
)
})
}
/// The module name, `USING` arguments, and declared column names of a virtual
/// table — by reparsing its stored `CREATE VIRTUAL TABLE` and asking the
/// module to `connect`. Used by the write path.
fn vtab_meta(&self, name: &str) -> Result<(String, Vec<String>, crate::vtab::VTabSchema)> {
use crate::schema::ObjectType;
let obj = self
.schema
.objects()
.iter()
.find(|o| o.obj_type == ObjectType::Table && o.name.eq_ignore_ascii_case(name))
.ok_or_else(|| Error::Error(format!("no such table: {name}")))?;
let Some(Ok(Statement::CreateVirtualTable(cvt))) = obj.sql.as_deref().map(sql::parse_one)
else {
return Err(Error::Error(format!("{name} is not a virtual table")));
};
let module = self
.vtab_registry
.get(&cvt.module)
.ok_or_else(|| Error::Error(format!("no such module: {}", cvt.module)))?;
let arg_refs: Vec<&str> = cvt.args.iter().map(String::as_str).collect();
let schema = module.dyn_connect(&arg_refs)?;
Ok((cvt.module.clone(), cvt.args.clone(), schema))
}
/// `INSERT` into a virtual table: evaluate each row's values into the module's
/// declared column order and hand them to its
/// [`update`](crate::vtab::VTabModule::update) (SQLite's `xUpdate` insert).
/// A read-only module's default `update` rejects the write.
/// Run `f` with the named module taken out of the registry and a [`VTabStore`]
/// over its `<table>_data` backing table, re-registering the module afterward.
/// Taking the module out lets the store hold `&mut Connection` without aliasing
/// the borrowed module. Callers do all read-only work (evaluating values,
/// scanning rows) *before* this, then only persist inside `f`.
fn with_vtab_store<F>(
&mut self,
module_name: &str,
args: &[String],
table: &str,
f: F,
) -> Result<usize>
where
F: FnOnce(&dyn DynVTabModule, &mut dyn VTabStore, &[&str]) -> Result<usize>,
{
let module = self
.vtab_registry
.unregister(module_name)
.ok_or_else(|| Error::Error(format!("no such module: {module_name}")))?;
// FTS5 keeps its documents in `<name>_content` (sqlite's layout); every
// other persistent module uses the generic `<name>_data` store.
let backing = if module_name.eq_ignore_ascii_case("fts5") {
format!("{table}_content")
} else {
format!("{table}_data")
};
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let result = {
let mut store = ExecVTabStore {
conn: self,
backing: &backing,
ipk_prefix: module_name.eq_ignore_ascii_case("fts5"),
};
f(&*module, &mut store, &arg_refs)
};
self.vtab_registry.register(module_name, module)?;
// The store services deletes/updates by removing rows from the backing
// b-tree one at a time (`ExecVTabStore::delete`/`put` → `delete_table`),
// which can leave an emptied leaf page in place. A non-root leaf with
// zero cells is a *malformed* sqlite b-tree (sqlite's `integrity_check`
// rejects it), so reclaim any such slack now — the same
// page-merge-on-delete compaction the ordinary DELETE path performs —
// keeping the backing `_content`/`_data` file a valid sqlite database.
if result.is_ok()
&& let Ok(meta) = self.table_meta(&backing, None)
{
self.compact_table(&meta)?;
}
result
}
fn exec_vtab_insert(
&mut self,
ins: &Insert,
rows: &[Vec<Expr>],
params: &Params,
) -> Result<usize> {
if !ins.upsert.is_empty() || !ins.returning.is_empty() {
return Err(Error::Unsupported("UPSERT / RETURNING on a virtual table"));
}
let (module_name, args, schema) = self.vtab_meta(&ins.table)?;
let col_names = schema.columns;
let ncols = col_names.len();
// FTS5 exposes a hidden column named after the table that accepts special
// commands: `INSERT INTO t(t) VALUES('rebuild'|'optimize')` issues a
// maintenance command rather than inserting a row. `rebuild` scans the
// content source and rebuilds the inverted index; `optimize` is a no-op
// (graphite already writes a single compacted segment). Other commands fall
// through to the usual column resolution (and its "no such column" error),
// matching SQLite, which rejects `delete`/`delete-all` on a content table.
//
// The `'delete'`/`'delete-all'` commands take the same special-column form
// with extra columns: `INSERT INTO t(t, rowid, <cols…>) VALUES('delete', …)`
// removes a document's postings (contentless/external only); `INSERT INTO
// t(t) VALUES('delete-all')` clears the whole index. Both are handled in
// `fts5_special_command` when the first column is the table-named command
// column.
#[cfg(feature = "fts5")]
if module_name.eq_ignore_ascii_case("fts5")
&& !ins.columns.is_empty()
&& ins.columns[0].eq_ignore_ascii_case(&ins.table)
{
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if let Some(n) = self.fts5_special_command(ins, rows, params, &arg_refs)? {
return Ok(n);
}
}
// A direct write to an external-content or contentless fts5 table adds the
// supplied document's tokens to the index (SQLite's trigger contract), with
// no `_content` copy. Route it through the incremental posting path rather
// than the self-content bulk rebuild.
#[cfg(feature = "fts5")]
let fts5_no_local = module_name.eq_ignore_ascii_case("fts5") && {
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
crate::vtab::fts5_no_local_content(&arg_refs)
};
// Map the (possibly explicit) column list onto declared column positions.
// `None` marks a `rowid`/`_rowid_`/`oid` term (a vtab's hidden rowid),
// whose value becomes the inserted row's explicit rowid.
let target: Vec<Option<usize>> = if ins.columns.is_empty() {
(0..ncols).map(Some).collect()
} else {
ins.columns
.iter()
.map(
|name| match col_names.iter().position(|c| c.eq_ignore_ascii_case(name)) {
Some(p) => Ok(Some(p)),
None if matches!(
name.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) =>
{
Ok(None)
}
None => Err(Error::Error(format!("no such column: {name}"))),
},
)
.collect::<Result<_>>()?
};
// Evaluate every row up front (a read-only borrow of self), then persist.
let mut changes: Vec<(Option<i64>, Vec<Value>)> = Vec::with_capacity(rows.len());
for row in rows {
if row.len() != target.len() {
return Err(Error::Error(format!(
"{} values for {} columns",
row.len(),
target.len()
)));
}
let mut values = alloc::vec![Value::Null; ncols];
let mut rowid = None;
for (j, expr) in row.iter().enumerate() {
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let v = eval::eval(expr, &ctx)?;
match target[j] {
Some(col) => values[col] = v,
None => rowid = Some(eval::to_i64(&v)),
}
}
changes.push((rowid, values));
}
// External-content / contentless: apply each document's tokens to the
// private posting state and rebuild the index once. No `_content` write and
// no bulk rebuild; a document with no explicit rowid gets the next id
// (max-existing + 1, from `_docsize`), matching SQLite. Inserting the same
// rowid again is purely additive (union of terms; per-term positions from
// the latest insert), also matching SQLite — no UNIQUE conflict.
#[cfg(feature = "fts5")]
if fts5_no_local {
let docsize_meta = self.table_meta(&format!("{}_docsize", ins.table), None)?;
let mut next_auto = self
.scan_table(&docsize_meta)?
.iter()
.map(|(r, _)| *r)
.max()
.unwrap_or(0)
+ 1;
let mut n = 0;
for (rowid, values) in &changes {
let rid = rowid.unwrap_or_else(|| {
let r = next_auto;
next_auto += 1;
r
});
if rid >= next_auto {
next_auto = rid + 1;
}
self.fts5_gpost_apply(&ins.table, rid, values, false)?;
n += 1;
}
self.fts5_rebuild_from_gpost(&ins.table)?;
return Ok(n);
}
let on_conflict = ins.on_conflict;
let table = ins.table.clone();
let id_col = col_names
.first()
.cloned()
.unwrap_or_else(|| String::from("rowid"));
// geopoly: parse each `_shape`, index its bounding box in the node tree,
// and store the polygon BLOB + user columns as `_rowid` aux columns.
if module_name.eq_ignore_ascii_case("geopoly") {
let mut existing: alloc::collections::BTreeSet<i64> =
self.geopoly_read_aux(&table)?.keys().copied().collect();
let mut next_auto = existing.iter().max().copied().unwrap_or(0) + 1;
let mut inserts: Vec<(RtreeCell, Vec<Value>)> = Vec::new();
let mut n = 0;
for (rowid, values) in &changes {
let rid = rowid.unwrap_or_else(|| {
let r = next_auto;
next_auto += 1;
r
});
if rid >= next_auto {
next_auto = rid + 1;
}
if existing.contains(&rid) {
match on_conflict {
OnConflict::Replace => {}
OnConflict::Ignore => continue,
_ => {
return Err(Error::Constraint(format!(
"UNIQUE constraint failed: {table}.rowid"
)));
}
}
}
existing.insert(rid);
inserts.retain(|(c, _)| c.key != rid); // OR REPLACE within this batch
inserts.push(geopoly_row_cell(rid, values)?);
n += 1;
}
self.geopoly_apply(&table, inserts, &[])?;
return Ok(n);
}
// R-Tree: store in SQLite's byte-compatible node tree. Auxiliary (`+col`)
// values ride in `_rowid`'s `a0..aN`; a no-aux R-Tree uses the plain
// `_rowid(rowid,nodeno)` layout and the no-aux write path unchanged.
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let rtree_nc = (module_name.eq_ignore_ascii_case("rtree")
|| module_name.eq_ignore_ascii_case("rtree_i32"))
.then(|| crate::vtab::RTreeModule::n_coords(&arg_refs))
.filter(|n| ncols > *n);
if let Some(n_coord) = rtree_nc {
let integer = module_name.eq_ignore_ascii_case("rtree_i32");
let n_aux = ncols - 1 - n_coord;
let mut existing: alloc::collections::BTreeSet<i64> = self
.rtree_entries(&table, n_coord, integer)?
.iter()
.map(|c| c.key)
.collect();
let mut next_auto = existing.iter().max().copied().unwrap_or(0) + 1;
let mut cells: Vec<(RtreeCell, Vec<Value>)> = Vec::new();
let mut n = 0;
for (rowid, values) in &changes {
let rid = rowid
.or(match values.first() {
Some(Value::Integer(i)) => Some(*i),
_ => None,
})
.unwrap_or_else(|| {
let r = next_auto;
next_auto += 1;
r
});
// sqlite's `rtreeUpdate` validates the coordinate pairs *before*
// the rowid-uniqueness check, so a row that violates both reports
// the coordinate error. The coordinate violation is a
// `SQLITE_CONSTRAINT` subject to the statement's conflict mode:
// `OR IGNORE` skips the row, everything else (including
// `OR REPLACE`, which only resolves the rowid conflict) errors.
let cell = match rtree_cell_from_values(
rid, values, n_coord, integer, &table, &arg_refs,
) {
Ok(c) => c,
Err(_) if matches!(on_conflict, OnConflict::Ignore) => continue,
Err(e) => return Err(e),
};
if existing.contains(&rid) {
match on_conflict {
OnConflict::Replace => {}
OnConflict::Ignore => continue,
_ => {
return Err(Error::Constraint(format!(
"UNIQUE constraint failed: {table}.{id_col}"
)));
}
}
}
existing.insert(rid);
// The aux tuple is the trailing columns after id + coordinates,
// stored verbatim (rtree.c applies no affinity to aux values).
let aux: Vec<Value> = values.get(1 + n_coord..).unwrap_or(&[]).to_vec();
cells.retain(|(c, _)| c.key != rid); // OR REPLACE within this batch
cells.push((cell, aux));
n += 1;
}
if n_aux == 0 {
let cells = cells.into_iter().map(|(c, _)| c).collect();
self.rtree_apply(&table, n_coord, integer, cells, &[])?;
} else {
self.rtree_apply_aux(&table, n_coord, integer, cells, &[])?;
}
return Ok(n);
}
// Record each new self-content fts5 document (assigned rowid + column
// values) when inside an explicit transaction, so the commit-time flush can
// reproduce SQLite's incremental level-0 segment boundaries (out-of-order
// rowids flush multiple segments). Captured inside the store closure because
// an auto-assigned rowid is only known once `dyn_update` runs.
#[cfg(feature = "fts5")]
let record_fts5_ops = module_name.eq_ignore_ascii_case("fts5")
&& !fts5_no_local
&& (self.in_tx || self.open_savepoints > 0);
#[cfg(feature = "fts5")]
let mut fts5_inserted: Vec<(i64, Vec<Value>)> = Vec::new();
// An `INSERT OR REPLACE` / `REPLACE` that lands on an EXISTING fts5 rowid is
// a delete-of-old + insert-of-new — exactly like an `UPDATE`. The old
// document's terms must be tombstoned or the inverted index keeps stale
// postings and sqlite rejects the file ("malformed inverted index"). Capture
// the current documents up front so the conflict branch can pair each
// replaced rowid with its old column values; only pay this cost for an
// `OR REPLACE` on a self-content fts5 table (where a conflict can occur).
#[cfg(feature = "fts5")]
let fts5_self_content = module_name.eq_ignore_ascii_case("fts5") && !fts5_no_local;
#[cfg(feature = "fts5")]
let fts5_replace_mode = fts5_self_content && matches!(on_conflict, OnConflict::Replace);
#[cfg(feature = "fts5")]
let fts5_old_docs: alloc::collections::BTreeMap<i64, Vec<Value>> = if fts5_replace_mode {
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
self.fts5_load_documents(&ins.table, &col_names, &arg_refs)?
.into_iter()
.collect()
} else {
alloc::collections::BTreeMap::new()
};
// Replace-mode write log in execution order: each entry is
// `(rowid, old_values?, new_values)` — `None` old for a fresh insert,
// `Some(old)` for a replaced rowid.
#[cfg(feature = "fts5")]
let mut fts5_repl_ops: Vec<(i64, Option<Vec<Value>>, Vec<Value>)> = Vec::new();
let inserted = self.with_vtab_store(
&module_name,
&args,
&ins.table,
|module, store, arg_refs| {
// An explicit rowid that already exists is a UNIQUE conflict on the
// implicit rowid — error (or skip/replace per `OR IGNORE`/`REPLACE`),
// matching sqlite, rather than silently overwriting the row. Only a
// store-backed (persistent) vtab is checked here; a non-persistent
// module (no `<name>_data` table → `rows()` errors) manages its own.
let mut existing: alloc::collections::BTreeSet<i64> = store
.rows()
.map(|rows| rows.iter().map(|(r, _)| *r).collect())
.unwrap_or_default();
// The effective rowid is the explicit `rowid` term, or — for a
// module with a rowid-alias column (rtree's `id`) — that column's
// value when not NULL.
let rowid_col = module.dyn_rowid_column();
let mut n = 0;
for (rowid, values) in &changes {
let effective = rowid.or_else(|| {
let v = values.get(rowid_col?)?;
(!matches!(v, Value::Null)).then(|| eval::to_i64(v))
});
#[cfg(feature = "fts5")]
let mut replaced_old: Option<Vec<Value>> = None;
if let Some(id) = effective
&& existing.contains(&id)
{
match on_conflict {
OnConflict::Replace => {
// Pair this replaced rowid with the old document's
// column values so its terms can be tombstoned. An
// absent/all-NULL old doc yields no terms.
#[cfg(feature = "fts5")]
if fts5_replace_mode {
replaced_old =
Some(fts5_old_docs.get(&id).cloned().unwrap_or_default());
}
}
OnConflict::Ignore => continue,
_ => {
return Err(Error::Constraint(format!(
"UNIQUE constraint failed: {table}.{id_col}"
)));
}
}
}
let assigned = module.dyn_update(
arg_refs,
VTabChange::Insert {
rowid: *rowid,
values,
},
store,
)?;
existing.insert(assigned);
#[cfg(feature = "fts5")]
if fts5_replace_mode {
fts5_repl_ops.push((assigned, replaced_old, values.clone()));
} else if record_fts5_ops {
fts5_inserted.push((assigned, values.clone()));
}
n += 1;
}
Ok(n)
},
)?;
// Log the transaction's fts5 inserts in execution order for the flush.
#[cfg(feature = "fts5")]
if record_fts5_ops && !fts5_replace_mode {
if self.open_savepoints > 0 {
self.fts5_txn_sp_used.insert(ins.table.clone());
}
let log = self.fts5_txn_ops.entry(ins.table.clone()).or_default();
for (rowid, values) in fts5_inserted {
log.push(Fts5TxnOp::Insert { rowid, values });
}
}
// Replace-mode fts5: an `OR REPLACE` that hit an existing rowid is a
// delete-of-old + insert-of-new. In AUTOCOMMIT the whole statement is one
// transaction, so append ONE tombstone/mixed segment (byte-identical to
// sqlite's INSERT OR REPLACE, which flushes its hash once) via the same
// incremental-delete path the UPDATE case uses; fresh rows in the same
// statement contribute insert-only postings to that segment. Inside an
// explicit transaction, record each write in execution order (a replaced
// rowid as an Update op, a fresh row as an Insert op) and mark the table so
// the commit-time flush tombstones the old terms. When nothing actually
// conflicted, fall through to the normal insert append.
#[cfg(feature = "fts5")]
if fts5_replace_mode {
let has_replace = fts5_repl_ops.iter().any(|(_, old, _)| old.is_some());
if !self.in_tx && self.open_savepoints == 0 {
if has_replace {
let inc: Vec<Fts5Change> = fts5_repl_ops
.iter()
.map(|(rid, old, new)| {
(*rid, old.clone().unwrap_or_default(), Some(new.clone()))
})
.collect();
if !self.fts5_incremental_delete(&ins.table, &inc)? {
self.fts5_rebuild_index(&ins.table)?;
}
return Ok(inserted);
}
// No conflict: the normal incremental append below handles the docs.
} else {
if self.open_savepoints > 0 {
self.fts5_txn_sp_used.insert(ins.table.clone());
}
if has_replace {
self.fts5_txn_dirty.insert(ins.table.clone(), true);
} else {
self.fts5_txn_dirty
.entry(ins.table.clone())
.or_insert(false);
}
let log = self.fts5_txn_ops.entry(ins.table.clone()).or_default();
for (rowid, old, new) in fts5_repl_ops {
match old {
Some(old_values) => log.push(Fts5TxnOp::Update {
rowid,
old_values,
new_values: new,
}),
None => log.push(Fts5TxnOp::Insert { rowid, values: new }),
}
}
}
}
self.fts5_maybe_rebuild(&module_name, &ins.table)?;
Ok(inserted)
}
/// `DELETE` from a virtual table: scan it for rows matching the `WHERE`, then
/// call the module's [`update`](crate::vtab::VTabModule::update) with
/// [`VTabChange::Delete`] for each (over a materialized snapshot, so deleting
/// during iteration is safe).
fn exec_vtab_delete(&mut self, del: &Delete, params: &Params) -> Result<usize> {
if !del.returning.is_empty() {
return Err(Error::Unsupported("RETURNING on a virtual table"));
}
let (module_name, args, _) = self.vtab_meta(&del.table)?;
// Contentless fts5 rejects DELETE (it keeps no text to identify postings);
// external content allows it (old text is read from the content table).
#[cfg(feature = "fts5")]
if module_name.eq_ignore_ascii_case("fts5") {
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if crate::vtab::fts5_is_contentless(&arg_refs) {
return Err(Error::Error(format!(
"cannot DELETE from contentless fts5 table: {}",
del.table
)));
}
}
let (columns, rows) = self
.try_virtual_table(&del.table, None, None)?
.ok_or_else(|| Error::Error(format!("{} is not a virtual table", del.table)))?;
// Collect the matching rows first (read-only), then persist. External-content
// deletes need the OLD column values (to subtract the right postings), so keep
// them alongside each victim rowid.
let mut victims: Vec<i64> = Vec::new();
#[cfg(feature = "fts5")]
let mut victim_vals: Vec<Vec<Value>> = Vec::new();
for r in &rows {
if let Some(pred) = &del.where_clause {
let ctx = r.ctx(&columns, params).with_subqueries(self);
if eval::truth(&eval::eval(pred, &ctx)?) != Some(true) {
continue;
}
}
victims.push(
r.rowid
.ok_or_else(|| Error::Error("virtual-table row has no rowid".into()))?,
);
#[cfg(feature = "fts5")]
victim_vals.push(r.values.clone());
}
// External-content fts5: subtract each victim's (content-table) postings from
// the private posting state, then rebuild. No `_content`/content-table write.
#[cfg(feature = "fts5")]
if module_name.eq_ignore_ascii_case("fts5") {
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if crate::vtab::fts5_external_content(&arg_refs).is_some() {
for (rid, vals) in victims.iter().zip(victim_vals.iter()) {
self.fts5_gpost_apply(&del.table, *rid, vals, true)?;
}
self.fts5_rebuild_from_gpost(&del.table)?;
return Ok(victims.len());
}
}
// geopoly: rebuild the node tree (and `_rowid` aux) without the victims.
if module_name.eq_ignore_ascii_case("geopoly") {
self.geopoly_apply(&del.table, Vec::new(), &victims)?;
return Ok(victims.len());
}
// R-Tree: rebuild the node tree (and, for an aux-column R-Tree, `_rowid`'s
// aux) without the victims.
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let rtree_nc = (module_name.eq_ignore_ascii_case("rtree")
|| module_name.eq_ignore_ascii_case("rtree_i32"))
.then(|| crate::vtab::RTreeModule::n_coords(&arg_refs))
.filter(|n| columns.len() > *n);
if let Some(n_coord) = rtree_nc {
let integer = module_name.eq_ignore_ascii_case("rtree_i32");
if columns.len() == 1 + n_coord {
self.rtree_apply(&del.table, n_coord, integer, Vec::new(), &victims)?;
} else {
self.rtree_apply_aux(&del.table, n_coord, integer, Vec::new(), &victims)?;
}
return Ok(victims.len());
}
let deleted = self.with_vtab_store(
&module_name,
&args,
&del.table,
|module, store, arg_refs| {
for rowid in &victims {
module.dyn_update(arg_refs, VTabChange::Delete { rowid: *rowid }, store)?;
}
Ok(victims.len())
},
)?;
// Self-content fts5 in AUTOCOMMIT: service the delete incrementally by
// appending one tombstone segment (byte-identical to sqlite), falling back
// to the bulk rebuild otherwise. `victim_vals` are the old fts5-column
// values (leading rowid dropped by try_virtual_table for fts5).
#[cfg(feature = "fts5")]
if module_name.eq_ignore_ascii_case("fts5")
&& !victims.is_empty()
&& !self.in_tx
&& self.open_savepoints == 0
{
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if !crate::vtab::fts5_no_local_content(&arg_refs) {
let changes: Vec<(i64, Vec<Value>, Option<Vec<Value>>)> = victims
.iter()
.zip(victim_vals.iter())
.map(|(rid, vals)| (*rid, vals.clone(), None))
.collect();
if self.fts5_incremental_delete(&del.table, &changes)? {
return Ok(deleted);
}
}
}
// Inside a transaction, record each deleted document (rowid + old fts5
// column values) in execution order so the commit-time flush can emit a
// byte-identical tombstone segment. Also set the rebuild flag as a safety
// net for the savepoint (legacy) flush path (`fts5_txn_sp_used`).
#[cfg(feature = "fts5")]
if module_name.eq_ignore_ascii_case("fts5")
&& !victims.is_empty()
&& (self.in_tx || self.open_savepoints > 0)
{
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if !crate::vtab::fts5_no_local_content(&arg_refs) {
self.fts5_txn_dirty.insert(del.table.clone(), true);
if self.open_savepoints > 0 {
self.fts5_txn_sp_used.insert(del.table.clone());
}
let log = self.fts5_txn_ops.entry(del.table.clone()).or_default();
for (rowid, old_values) in victims.iter().zip(victim_vals.iter()) {
log.push(Fts5TxnOp::Delete {
rowid: *rowid,
old_values: old_values.clone(),
});
}
}
}
self.fts5_maybe_rebuild(&module_name, &del.table)?;
Ok(deleted)
}
/// `UPDATE` of a virtual table: scan for rows matching the `WHERE`, evaluate
/// the `SET` assignments against each, and call the module's
/// [`update`](crate::vtab::VTabModule::update) with [`VTabChange::Update`].
fn exec_vtab_update(&mut self, upd: &Update, params: &Params) -> Result<usize> {
if !upd.returning.is_empty() {
return Err(Error::Unsupported("RETURNING on a virtual table"));
}
if !upd.row_assignments.is_empty() {
return Err(Error::Unsupported(
"UPDATE SET (…) = (SELECT …) on a virtual table",
));
}
if upd.from.is_some() {
return Err(Error::Unsupported("UPDATE … FROM on a virtual table"));
}
let (module_name, args, schema) = self.vtab_meta(&upd.table)?;
// Contentless fts5 rejects UPDATE (no stored text to identify the old
// postings); external content allows it (old text from the content table).
#[cfg(feature = "fts5")]
if module_name.eq_ignore_ascii_case("fts5") {
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if crate::vtab::fts5_is_contentless(&arg_refs) {
return Err(Error::Error(format!(
"cannot UPDATE contentless fts5 table: {}",
upd.table
)));
}
}
let col_names = schema.columns;
// Resolve each SET target to a declared column position.
let assigns: Vec<(usize, &Expr)> = upd
.assignments
.iter()
.map(|(name, value)| {
col_names
.iter()
.position(|c| c.eq_ignore_ascii_case(name))
.map(|pos| (pos, value))
.ok_or_else(|| Error::Error(format!("no such column: {name}")))
})
.collect::<Result<_>>()?;
let (columns, rows) = self
.try_virtual_table(&upd.table, None, None)?
.ok_or_else(|| Error::Error(format!("{} is not a virtual table", upd.table)))?;
// Compute the new (rowid, values) for each matching row first, then persist.
let mut changes: Vec<(i64, Vec<Value>)> = Vec::new();
// Old (pre-update) column values per change, for fts5 external-content
// posting subtraction (the old text lives in the content table).
#[cfg(feature = "fts5")]
let mut old_vals: Vec<Vec<Value>> = Vec::new();
for r in &rows {
let ctx = r.ctx(&columns, params).with_subqueries(self);
if let Some(pred) = &upd.where_clause
&& eval::truth(&eval::eval(pred, &ctx)?) != Some(true)
{
continue;
}
// Every SET RHS evaluates against the original row (simultaneous).
let mut values = r.values.clone();
for (pos, expr) in &assigns {
values[*pos] = eval::eval(expr, &ctx)?;
}
let rowid = r
.rowid
.ok_or_else(|| Error::Error("virtual-table row has no rowid".into()))?;
#[cfg(feature = "fts5")]
old_vals.push(r.values.clone());
changes.push((rowid, values));
}
// External-content fts5: UPDATE = subtract old postings (from the content
// table's old text) + add the new SET text under the (possibly changed)
// rowid, then rebuild. The content table itself is not modified — the caller
// keeps it in sync, exactly as SQLite's trigger contract requires.
#[cfg(feature = "fts5")]
if module_name.eq_ignore_ascii_case("fts5") {
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if crate::vtab::fts5_external_content(&arg_refs).is_some() {
for ((old_rowid, new_values), old_values) in changes.iter().zip(old_vals.iter()) {
self.fts5_gpost_apply(&upd.table, *old_rowid, old_values, true)?;
self.fts5_gpost_apply(&upd.table, *old_rowid, new_values, false)?;
}
self.fts5_rebuild_from_gpost(&upd.table)?;
return Ok(changes.len());
}
}
// geopoly: rebuild the node tree + `_rowid` aux (re-parse each new
// `_shape` for its bbox and normalized BLOB; the rowid is stable).
if module_name.eq_ignore_ascii_case("geopoly") {
let mut deletes = Vec::with_capacity(changes.len());
let mut inserts = Vec::with_capacity(changes.len());
for (rowid, values) in &changes {
deletes.push(*rowid);
inserts.push(geopoly_row_cell(*rowid, values)?);
}
self.geopoly_apply(&upd.table, inserts, &deletes)?;
return Ok(changes.len());
}
// R-Tree: rebuild the node tree (delete old + insert new; the `id` column
// may move the rowid). An aux-column R-Tree re-stores each new row's aux
// values in `_rowid`.
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let rtree_nc = (module_name.eq_ignore_ascii_case("rtree")
|| module_name.eq_ignore_ascii_case("rtree_i32"))
.then(|| crate::vtab::RTreeModule::n_coords(&arg_refs))
.filter(|n| columns.len() > *n);
if let Some(n_coord) = rtree_nc {
let integer = module_name.eq_ignore_ascii_case("rtree_i32");
let n_aux = columns.len() - 1 - n_coord;
let mut deletes = Vec::with_capacity(changes.len());
let mut inserts: Vec<(RtreeCell, Vec<Value>)> = Vec::with_capacity(changes.len());
for (old_rowid, values) in &changes {
deletes.push(*old_rowid);
let new_rid = match values.first() {
Some(Value::Null) | None => *old_rowid,
Some(v) => eval::to_i64(v),
};
let cell = rtree_cell_from_values(
new_rid, values, n_coord, integer, &upd.table, &arg_refs,
)?;
let aux: Vec<Value> = values.get(1 + n_coord..).unwrap_or(&[]).to_vec();
inserts.push((cell, aux));
}
if n_aux == 0 {
let inserts = inserts.into_iter().map(|(c, _)| c).collect();
self.rtree_apply(&upd.table, n_coord, integer, inserts, &deletes)?;
} else {
self.rtree_apply_aux(&upd.table, n_coord, integer, inserts, &deletes)?;
}
return Ok(changes.len());
}
let updated = self.with_vtab_store(
&module_name,
&args,
&upd.table,
|module, store, arg_refs| {
for (rowid, values) in &changes {
module.dyn_update(
arg_refs,
VTabChange::Update {
rowid: *rowid,
new_rowid: *rowid,
values,
},
store,
)?;
}
Ok(changes.len())
},
)?;
// Self-content fts5 in AUTOCOMMIT: service the UPDATE incrementally as a
// delete-then-insert of each row in one appended segment (tombstones for
// the old terms + insert postings for the new), byte-identical to sqlite.
#[cfg(feature = "fts5")]
if module_name.eq_ignore_ascii_case("fts5")
&& !changes.is_empty()
&& !self.in_tx
&& self.open_savepoints == 0
{
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if !crate::vtab::fts5_no_local_content(&arg_refs) {
let inc: Vec<(i64, Vec<Value>, Option<Vec<Value>>)> = changes
.iter()
.zip(old_vals.iter())
.map(|((rid, newv), oldv)| (*rid, oldv.clone(), Some(newv.clone())))
.collect();
if self.fts5_incremental_delete(&upd.table, &inc)? {
return Ok(updated);
}
}
}
// Inside a transaction, record each updated document (rowid + old and new
// fts5 column values) in execution order — SQLite writes an UPDATE as a
// delete of the old terms plus an insert of the new terms under the same
// rowid, which the commit-time flush reproduces as a byte-identical
// tombstone+insert segment. The rebuild flag remains as the savepoint
// (legacy) flush fallback (`fts5_txn_sp_used`).
#[cfg(feature = "fts5")]
if module_name.eq_ignore_ascii_case("fts5")
&& !changes.is_empty()
&& (self.in_tx || self.open_savepoints > 0)
{
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if !crate::vtab::fts5_no_local_content(&arg_refs) {
self.fts5_txn_dirty.insert(upd.table.clone(), true);
if self.open_savepoints > 0 {
self.fts5_txn_sp_used.insert(upd.table.clone());
}
let log = self.fts5_txn_ops.entry(upd.table.clone()).or_default();
for ((rowid, new_values), old_values) in changes.iter().zip(old_vals.iter()) {
log.push(Fts5TxnOp::Update {
rowid: *rowid,
old_values: old_values.clone(),
new_values: new_values.clone(),
});
}
}
}
self.fts5_maybe_rebuild(&module_name, &upd.table)?;
Ok(updated)
}
/// D2b-2: try to answer a `MATCH` over the FTS5 table `name` from its segment
/// index instead of scanning every `_content` document. Returns
/// `Some(rows)` (the matching `_content` rows, leading `id` column dropped,
/// in ascending rowid order — exactly the scan's order) for the shapes proven
/// to give identical results: a TABLE-WIDE, SINGLE BARE-TERM query
/// (`tbl MATCH 'word'`) — matched in any column — a COLUMN-SCOPED single
/// bare term (`tbl MATCH 'col : word'`) — matched only in the named column — and
/// a TWO-TERM PHRASE, table-wide (`tbl MATCH '"a b"'`) or column-scoped
/// (`tbl MATCH 'col : "a b"'`) — the two tokens at adjacent positions in some /
/// the named column — all over a fully-indexed table whose `_data` holds a
/// single height-0 segment. Returns `None` (the caller falls back to the
/// document scan) for every other case — a `col MATCH …` operand, an `UNINDEXED`
/// column, a phrase of ≠2 terms, a prefix/anchor inside the phrase, a `NEAR`
/// group that is not exactly two single-token bare operands, multiple column
/// filters, a multi-segment or interior/doclist-index index, or no `MATCH` at
/// all.
///
/// Correctness: for a lone bare term over a fully-indexed table the scan's
/// per-row predicate ([`crate::vtab::fts5_query_matches`]) is true iff the
/// token appears in some column — exactly the term's index doclist; for a
/// `col:word` filter it is true iff the token appears in that one column —
/// exactly the postings whose per-column position list for that column is
/// non-empty. run_core re-applies the full WHERE to whatever this returns, so
/// the result is a superset and never wrong; the rowid-ascending order matches
/// the scan.
/// Resolve an fts5 `MATCH` query to the set of matching rowids from the segment
/// index (`_data`), or `Ok(None)` when the query shape isn't index-routable (the
/// caller then falls back to the document scan). This is the routing core shared
/// by `fts5_try_index_match` (which fetches the matched rows) and the contentless
/// `MATCH` re-check (which tests rowid membership, since a contentless row keeps
/// no text to re-tokenize).
///
/// Routable shapes: a table-wide or column-scoped single bare term (`'word'` /
/// `'col : word'`), a K-term phrase (`'"t0 t1 …"'` / `'col : "…"'`), a two-term
/// `NEAR`, an N-operand bare-term boolean tree (`a AND b`, `(a OR b) NOT c`, …),
/// and a single prefix term (`'word*'` / `'col : word*'`).
#[cfg(feature = "fts5")]
fn fts5_index_match_rowids(
&self,
name: &str,
arg_refs: &[&str],
query: &str,
) -> Result<Option<Vec<i64>>> {
let tok = crate::vtab::fts5_tok_config(arg_refs);
// The index reader below decodes full-detail poslists. A detail=none/columns
// segment stores a different (positionless) doclist, so index-routing a MATCH
// there is not correct — return `Ok(None)` to fall back to the `%_content`
// document scan, which re-tokenizes each row and is always right for a
// self-content table (the only shape reaching this routing).
if tok.detail != crate::fts5_index::Fts5Detail::Full {
return Ok(None);
}
enum Routed {
AnyColumn(Vec<u8>),
InColumn(Vec<u8>, usize),
Phrase(Vec<Vec<u8>>),
PhraseInColumn(Vec<Vec<u8>>, usize),
Near(Vec<u8>, Vec<u8>, u32),
BoolTree(crate::vtab::Fts5BoolTree),
PrefixAnyColumn(Vec<u8>),
PrefixInColumn(Vec<u8>, usize),
}
// Resolve a column NAME to its position; a non-column name matches nothing —
// `Ok(None)` there yields the same empty set as the scan.
let resolve_col = |col: &str| -> Result<Option<usize>> {
Ok(self
.vtab_meta(name)?
.2
.columns
.iter()
.position(|c| c.eq_ignore_ascii_case(col)))
};
let routed = if let Some(t) = crate::vtab::fts5_single_bare_term(query, tok) {
Routed::AnyColumn(t)
} else if let Some((col, t)) = crate::vtab::fts5_single_bare_term_column(query, tok) {
match resolve_col(&col)? {
Some(ci) => Routed::InColumn(t, ci),
None => return Ok(None),
}
} else if let Some(terms) = crate::vtab::fts5_phrase_terms(query, tok) {
Routed::Phrase(terms)
} else if let Some((col, terms)) = crate::vtab::fts5_phrase_terms_column(query, tok) {
match resolve_col(&col)? {
Some(ci) => Routed::PhraseInColumn(terms, ci),
None => return Ok(None),
}
} else if let Some((a, b, n)) = crate::vtab::fts5_two_term_near(query, tok) {
Routed::Near(a, b, n as u32)
} else if let Some(tree) = crate::vtab::fts5_bare_term_bool_tree(query, tok) {
Routed::BoolTree(tree)
} else if let Some(p) = crate::vtab::fts5_single_prefix_term(query, tok) {
Routed::PrefixAnyColumn(p)
} else if let Some((col, p)) = crate::vtab::fts5_single_prefix_term_column(query, tok) {
match resolve_col(&col)? {
Some(ci) => Routed::PrefixInColumn(p, ci),
None => return Ok(None),
}
} else {
return Ok(None);
};
let dmeta = self.table_meta(&format!("{name}_data"), None)?;
let data: Vec<(i64, Vec<u8>)> = self
.scan_table(&dmeta)?
.into_iter()
.filter_map(|(rowid, mut values)| match values.drain(..).nth(1) {
Some(Value::Blob(b)) => Some((rowid, b)),
_ => None,
})
.collect();
// An empty index (only the averages [id 1] and structure [id 10] rows, no
// leaf pages) matches nothing — the leaf reader would return `None` for the
// absent leaves, which the caller would misread as "unservable". Short-circuit
// to an empty match set for any routable query.
let has_leaves = data.iter().any(|(id, _)| {
*id != crate::fts5_index::AVERAGES_ROWID && *id != crate::fts5_index::STRUCTURE_ROWID
});
if !has_leaves {
return Ok(Some(Vec::new()));
}
let rowids_opt = match &routed {
Routed::AnyColumn(term) => crate::fts5_index::lookup_term_rowids(&data, term),
Routed::InColumn(term, ci) => {
crate::fts5_index::lookup_term_rowids_in_column(&data, term, *ci)
}
Routed::Phrase(terms) => {
let refs: Vec<&[u8]> = terms.iter().map(Vec::as_slice).collect();
crate::fts5_index::lookup_phrase_rowids_k(&data, &refs)
}
Routed::PhraseInColumn(terms, ci) => {
let refs: Vec<&[u8]> = terms.iter().map(Vec::as_slice).collect();
crate::fts5_index::lookup_phrase_rowids_in_column_k(&data, &refs, *ci)
}
Routed::Near(a, b, n) => crate::fts5_index::lookup_near_rowids(&data, a, b, *n),
Routed::BoolTree(tree) => crate::fts5_index::lookup_bool_tree_rowids(&data, tree),
Routed::PrefixAnyColumn(p) => crate::fts5_index::lookup_prefix_rowids(&data, p),
Routed::PrefixInColumn(p, ci) => {
crate::fts5_index::lookup_prefix_rowids_in_column(&data, p, *ci)
}
};
Ok(rowids_opt)
}
#[cfg(feature = "fts5")]
fn fts5_try_index_match(
&self,
name: &str,
alias: Option<&str>,
arg_refs: &[&str],
pushdown: Option<(&Select, &Params)>,
) -> Result<Option<Vec<InputRow>>> {
let (sel, params) = match pushdown {
Some(p) => p,
None => return Ok(None),
};
let where_expr = match sel.where_clause.as_ref() {
Some(e) => e,
None => return Ok(None),
};
// The query must be a MATCH whose operand names the TABLE (a table-wide
// search) — its name or its FROM alias — not a single `col MATCH …` (which
// scopes to one column and so does not equal the term's any-column
// doclist).
let (query, operand) = match self.fts5_match_query(where_expr, params) {
Some(qo) => qo,
None => return Ok(None),
};
let names_table = operand.eq_ignore_ascii_case(name)
|| alias.is_some_and(|a| operand.eq_ignore_ascii_case(a));
if !names_table {
return Ok(None);
}
// Only fully-indexed tables: an `UNINDEXED` column is stored but excluded
// from the scan's any-column match, while graphite indexes every column —
// so the doclist would over-match. Leave those on the scan.
let indexed = crate::vtab::fts5_indexed_columns(arg_refs);
let ncols = self.vtab_meta(name)?.2.columns.len();
if indexed.len() != ncols {
return Ok(None);
}
let rowids = match self.fts5_index_match_rowids(name, arg_refs, &query)? {
Some(r) => r,
None => return Ok(None),
};
// Fetch exactly the matching document rows, by rowid, ascending (the
// doclist is already ascending). For external content, the fts5 column
// values come from the content table by rowid; otherwise from `_content`.
let ncols = self.vtab_meta(name)?.2.columns.len();
let mut rows = Vec::with_capacity(rowids.len());
for rid in rowids {
if let Some(values) = self.fts5_fetch_doc(name, arg_refs, ncols, rid)? {
rows.push(InputRow {
values,
rowid: Some(rid),
});
}
// A doclist rowid with no content row is a stale index; SQLite raises
// "missing row" only when a COLUMN value is retrieved (handled in
// `fts5_fetch_doc`). Here (rowid-only routes) omitting it is a valid
// superset — the scan wouldn't have produced it either.
}
Ok(Some(rows))
}
/// Fetch one fts5 document's column values (in declared order, `ncols` long)
/// by rowid — from the external content table when `content='<tbl>'`, else from
/// this table's `<name>_content` shadow. Returns `Ok(None)` when the rowid is
/// absent from the content source (a stale index entry).
#[cfg(feature = "fts5")]
fn fts5_fetch_doc(
&self,
name: &str,
arg_refs: &[&str],
ncols: usize,
rowid: i64,
) -> Result<Option<Vec<Value>>> {
let encoding = self.backend.source().header().text_encoding;
// Contentless (`content=''`): no stored text — every indexed column reads
// back as NULL. The rowid came from the doclist, so the row exists.
if crate::vtab::fts5_is_contentless(arg_refs) {
return Ok(Some(alloc::vec![Value::Null; ncols]));
}
if let Some((content, rowid_col)) = crate::vtab::fts5_external_content(arg_refs) {
let cmeta = self
.table_meta(&content, None)
.map_err(|_| Error::Error(format!("no such table: main.{content}")))?;
let columns = &self.vtab_meta(name)?.2.columns;
let col_pos: Vec<usize> = columns
.iter()
.map(|c| {
cmeta
.columns
.iter()
.position(|cc| cc.name.eq_ignore_ascii_case(c))
.ok_or_else(|| Error::Error(format!("no such column: T.{c}")))
})
.collect::<Result<_>>()?;
let use_rowid = matches!(
rowid_col.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) || cmeta
.ipk
.is_some_and(|i| cmeta.columns[i].name.eq_ignore_ascii_case(&rowid_col));
if use_rowid {
// The content_rowid IS the content table's rowid — a direct seek.
let mut cur = TableCursor::new(self.backend.source(), cmeta.root);
if cur.seek(rowid)? {
let values = self.decode_full_row(&cmeta, rowid, &cur.payload()?, encoding)?;
return Ok(Some(col_pos.iter().map(|&p| values[p].clone()).collect()));
}
return Ok(None);
}
// A non-rowid `content_rowid` column: scan for the row whose value
// matches (rare; external content normally aliases the rowid).
let rid_pos = cmeta
.columns
.iter()
.position(|cc| cc.name.eq_ignore_ascii_case(&rowid_col))
.ok_or_else(|| Error::Error(format!("no such column: T.{rowid_col}")))?;
for (_, values) in self.scan_table(&cmeta)? {
if eval::to_i64(&values[rid_pos]) == rowid {
return Ok(Some(col_pos.iter().map(|&p| values[p].clone()).collect()));
}
}
return Ok(None);
}
// Self-content: seek the `<name>_content` shadow and drop the leading id.
let cmeta = self.table_meta(&format!("{name}_content"), None)?;
let mut cur = TableCursor::new(self.backend.source(), cmeta.root);
if cur.seek(rowid)? {
let mut values = self.decode_full_row(&cmeta, rowid, &cur.payload()?, encoding)?;
if !values.is_empty() {
values.remove(0);
}
values.truncate(ncols);
return Ok(Some(values));
}
Ok(None)
}
/// Produce the columns and rows of a virtual table used as a `FROM` source:
/// reparse its stored `CREATE VIRTUAL TABLE`, look the module up in the
/// registry, `connect` for its column schema, then `open` a cursor and drain
/// it. Returns `Ok(None)` when `name` is not a virtual table.
///
/// `pushdown`, when given as `Some((sel, params))`, lets the module restrict
/// what it produces from the query's `WHERE` (constraint pushdown via
/// [`best_index`](crate::vtab::VTabModule::best_index) /
/// [`filter`](crate::vtab::VTabModule::filter)). The plan is always a superset:
/// the caller's `run_core` re-applies the full `WHERE`, so even a partially
/// consumed or ignored constraint stays correct.
fn try_virtual_table(
&self,
name: &str,
alias: Option<&str>,
pushdown: Option<(&Select, &Params)>,
) -> Result<Option<(Vec<ColumnInfo>, Vec<InputRow>)>> {
use crate::schema::ObjectType;
let obj = match self
.schema
.objects()
.iter()
.find(|o| o.obj_type == ObjectType::Table && o.name.eq_ignore_ascii_case(name))
{
Some(o) => o,
None => return Ok(None),
};
let sql = match obj.sql.as_deref() {
Some(s) => s,
None => return Ok(None),
};
let cvt = match sql::parse_one(sql) {
Ok(Statement::CreateVirtualTable(cvt)) => cvt,
_ => return Ok(None),
};
// `fts5vocab` is derived from another FTS5 table's documents; compute it
// here (the module's cursor has no database access).
#[cfg(feature = "fts5")]
if cvt.module.eq_ignore_ascii_case("fts5vocab") {
return Ok(Some(self.scan_fts5vocab(&cvt.args, name, alias)?));
}
let module = self
.vtab_registry
.get(&cvt.module)
.ok_or_else(|| Error::Error(format!("no such module: {}", cvt.module)))?;
let arg_refs: Vec<&str> = cvt.args.iter().map(String::as_str).collect();
let schema = module.dyn_connect(&arg_refs)?;
let label = alias.unwrap_or(name).to_string();
let columns: Vec<ColumnInfo> = schema
.columns
.iter()
.map(|n| ColumnInfo {
name: n.clone(),
table: label.clone(),
affinity: eval::Affinity::Blob,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
})
.collect();
// Validate an FTS5 `MATCH` query's column filters once, before scanning:
// a `col:` / `{…}:` filter naming a non-existent column is a query error
// (`no such column: NAME`), and a malformed brace is a syntax error — both
// reported by SQLite at cursor-filter time, so even an empty table errors.
#[cfg(feature = "fts5")]
if cvt.module.eq_ignore_ascii_case("fts5")
&& let Some((sel, params)) = pushdown
&& let Some(where_expr) = sel.where_clause.as_ref()
&& let Some((query, operand)) = self.fts5_match_query(where_expr, params)
{
// The MATCH operand must refer to this table (the table
// itself, its alias, or one of its columns); validate the
// query's `col:`/`{…}:` filters against its full declared
// column list (`schema.columns`, indexed and UNINDEXED).
let names_scope = operand.eq_ignore_ascii_case(name)
|| alias.is_some_and(|a| operand.eq_ignore_ascii_case(a))
|| schema
.columns
.iter()
.any(|c| c.eq_ignore_ascii_case(&operand));
if names_scope {
let tok = crate::vtab::fts5_tok_config(&arg_refs);
if let Some(msg) =
crate::vtab::fts5_query_column_error(&query, &schema.columns, tok)
{
return Err(Error::Error(msg));
}
}
}
// A persistent module keeps its rows in the `<vtab>_data` backing table;
// scan that directly (run_core re-applies the full WHERE, so the rows are
// a valid superset). Computed modules go through the cursor path below.
if module.dyn_persistent() {
// geopoly keeps the polygon + user columns in `<name>_rowid`'s aux
// columns (`a0..aN`), the bounding box in the byte-compatible node
// tree. Read the aux columns for each entry, pruning candidates by any
// `geopoly_overlap`/`geopoly_within(_shape, Q)` in the WHERE (Q's bbox
// is a superset filter; `run_core` re-applies the exact predicate).
if cvt.module.eq_ignore_ascii_case("geopoly")
&& self.schema.table(&format!("{name}_node")).is_some()
{
let bbox = match pushdown {
Some((sel, params)) => sel
.where_clause
.as_ref()
.and_then(|w| self.geopoly_query_bbox(w, &columns, params))
.unwrap_or_default(),
None => Vec::new(),
};
let rows = self.scan_geopoly(name, &bbox)?;
return Ok(Some((columns, rows)));
}
// An R-Tree (written by SQLite or by graphite) keeps its entries in the
// `<name>_node` b-tree of nodes (byte-compatible on-disk format), not
// graphite's generic `<name>_data` backing table. Read the node tree
// directly. Aux (`+col`) columns live in `<name>_rowid`'s `a0..aN` and
// are joined back in by `scan_rtree_aux`.
let rtree = cvt.module.eq_ignore_ascii_case("rtree")
|| cvt.module.eq_ignore_ascii_case("rtree_i32");
if rtree
&& self.schema.table(&format!("{name}_node")).is_some()
&& self.schema.table(&format!("{name}_data")).is_none()
{
let n_coords = crate::vtab::RTreeModule::n_coords(&arg_refs);
if columns.len() > n_coords {
let integer = cvt.module.eq_ignore_ascii_case("rtree_i32");
// Spatial pushdown: turn the query's coordinate comparisons into
// per-dimension bounds the node walk uses to prune subtrees.
// Column 0 is the rowid/id; columns 1.. are the coordinates.
let bbox: Vec<(usize, ConstraintOp, f64)> = match pushdown {
Some((sel, params)) => {
let (cs, vs) = collect_vtab_constraints(sel, &columns, params);
cs.iter()
.zip(vs)
.filter_map(|(c, v)| {
let ci = c.column.checked_sub(1)?;
if ci >= n_coords {
return None;
}
let fv = match v {
Value::Integer(i) => i as f64,
Value::Real(r) => r,
_ => return None,
};
matches!(
c.op,
ConstraintOp::Eq
| ConstraintOp::Gt
| ConstraintOp::Le
| ConstraintOp::Lt
| ConstraintOp::Ge
)
.then_some((ci, c.op, fv))
})
.collect()
}
None => Vec::new(),
};
let rows = if columns.len() == 1 + n_coords {
self.scan_rtree_nodes(name, n_coords, integer, &bbox)?
} else {
self.scan_rtree_aux(name, n_coords, integer, &bbox)?
};
return Ok(Some((columns, rows)));
}
}
// A SQLite-written FTS5 keeps its documents in `<name>_content`
// (`id, c0, c1, …`), with the inverted index in `<name>_data`/`_idx`.
// graphite answers queries — including `MATCH` — from the documents via
// its scan-based matcher, so reading the content is sufficient. An
// external-content table (`content='<tbl>'`) has no `_content` shadow;
// its documents come from the named content table (via
// `fts5_load_documents`). (graphite's own non-sqlite FTS5 stores docs in
// `_data` and takes the generic backing path below.)
#[cfg(feature = "fts5")]
if cvt.module.eq_ignore_ascii_case("fts5")
&& (self.schema.table(&format!("{name}_content")).is_some()
|| crate::vtab::fts5_no_local_content(&arg_refs))
{
// D2b-2: a single bare-term `MATCH` (`tbl MATCH 'word'`) reads the
// term's doclist from the segment index and fetches only those
// `_content` rows by rowid, instead of scanning + tokenizing every
// document. Falls back to the full scan for any shape the index
// can't serve identically. run_core re-applies the full WHERE, so
// the rows (rowid-ascending, like the scan) stay a valid superset.
//
// Skip the index route for a table written earlier in the current
// transaction: its segment index is intentionally left stale until
// the commit-time flush (`fts5_flush_txn`), so the doclist would
// miss this transaction's uncommitted inserts and still list its
// deletes. The full `_content` scan below reflects the live
// (pager-managed) documents, giving correct in-transaction `MATCH`
// visibility. Contentless/external tables have no `_content` and
// keep their index maintained per statement, so they are exempt.
if !self.fts5_txn_dirty.contains_key(name)
&& let Some(rows) =
self.fts5_try_index_match(name, alias, &arg_refs, pushdown)?
{
return Ok(Some((columns, rows)));
}
// Contentless (`content=''`): no stored text, so a full scan yields
// one all-NULL row per live document (rowids from `_docsize`). A
// `MATCH` reaching here was not index-routable above — and a
// contentless table has no text to fall back on, so re-checking the
// (NULL) columns would silently UNDER-match. Decline such a query
// rather than return a wrong (subset) result.
if crate::vtab::fts5_is_contentless(&arg_refs) {
if let Some((sel, params)) = pushdown
&& let Some(e) = sel.where_clause.as_ref()
&& self.fts5_where_has_unroutable_match(name, &arg_refs, e, params)?
{
return Err(Error::Unsupported(
"fts5: this MATCH query shape is not supported on a \
contentless table (no stored text to match against)",
));
}
let docsize_meta = self.table_meta(&format!("{name}_docsize"), None)?;
let ncols = schema.columns.len();
let mut ids: Vec<i64> = self
.scan_table(&docsize_meta)?
.iter()
.map(|(r, _)| *r)
.collect();
ids.sort_unstable();
let rows = ids
.into_iter()
.map(|rowid| InputRow {
values: alloc::vec![Value::Null; ncols],
rowid: Some(rowid),
})
.collect();
return Ok(Some((columns, rows)));
}
// The scan-based matcher tokenizes each document. For external
// content, the fts5 column values (and rowids) come from the content
// table; otherwise from the `<name>_content` shadow.
let rows = self
.fts5_load_documents(name, &schema.columns, &arg_refs)?
.into_iter()
.map(|(rowid, values)| InputRow {
values,
rowid: Some(rowid),
})
.collect();
return Ok(Some((columns, rows)));
}
let backing = format!("{name}_data");
let bmeta = self.table_meta(&backing, None)?;
let rows = self
.scan_table(&bmeta)?
.into_iter()
.map(|(rowid, values)| InputRow {
values,
rowid: Some(rowid),
})
.collect();
return Ok(Some((columns, rows)));
}
// Constraint pushdown: offer the WHERE's usable comparisons to the module,
// let it choose a plan, then hand back the bound values it requested.
let (constraints, bound_values) = match pushdown {
Some((sel, params)) => collect_vtab_constraints(sel, &columns, params),
None => (Vec::new(), Vec::new()),
};
let plan = module.dyn_best_index(&constraints)?;
let argv = order_vtab_argv(&plan, &bound_values);
let mut cursor = module.dyn_open(&arg_refs, &plan, &argv)?;
let ncols = columns.len();
let mut rows = Vec::new();
while let Some(row) = cursor.dyn_next()? {
let values = (0..ncols).map(|i| row.dyn_column(i)).collect();
rows.push(InputRow {
values,
rowid: Some(row.dyn_rowid()),
});
}
Ok(Some((columns, rows)))
}
/// Materialize each `WITH` CTE of `sel` into the environment, in declaration
/// order (so a later CTE may reference an earlier one). Recursive CTEs are
/// evaluated with the fixed-point loop.
/// Materialize `ctes` into the environment. `outer_cap` (the consuming query's
/// `LIMIT`+`OFFSET`, set only when that query streams the CTE 1:1 — see
/// `recursive_cte_outer_cap`) bounds an otherwise-infinite recursive CTE so a
/// `SELECT … FROM rcte LIMIT k` over an unterminated recursion yields `k` rows
/// like sqlite instead of running to the runaway guard.
fn push_ctes(
&self,
ctes: &[Cte],
params: &Params,
outer_cap: Option<usize>,
seeds: Option<&[alloc::string::String]>,
) -> Result<()> {
// SQLite rejects two CTEs that share a name (case-insensitive) within one
// WITH clause, naming the duplicate (second) occurrence. A same name in a
// nested WITH is a separate scope (a separate `push_ctes` call) and stays
// legal. Checked before materializing, as SQLite rejects it at prepare time.
// This runs for every CTE, used or not — a duplicate name is an error even
// when neither is referenced.
for (i, cte) in ctes.iter().enumerate() {
if ctes[..i]
.iter()
.any(|prev| prev.name.eq_ignore_ascii_case(&cte.name))
{
return Err(Error::Error(alloc::format!(
"duplicate WITH table name: {}",
cte.name
)));
}
}
// Which CTEs the consuming statement actually reaches. SQLite never
// analyzes an unreferenced CTE, so a bad table/column inside it is not an
// error. Callers without a seed list (legacy) materialize every CTE.
let used: alloc::vec::Vec<bool> = match seeds {
Some(s) => cte_mask_from_seeds(s, ctes),
None => alloc::vec![true; ctes.len()],
};
// Sibling dependency edges among the used CTEs: CTE `i` depends on sibling
// `j` (j != i) when i's body names j. A *direct* self-reference is
// recursion, not a dependency, so it is excluded here. SQLite makes every
// CTE in a WITH mutually visible — forward references included — so a
// dependency must be materialized before its dependents, and a true cycle
// is rejected with `circular reference: <name>`.
let lname: alloc::vec::Vec<alloc::string::String> =
ctes.iter().map(|c| c.name.to_ascii_lowercase()).collect();
let dep_list: alloc::vec::Vec<alloc::vec::Vec<usize>> = (0..ctes.len())
.map(|i| {
if !used[i] {
return alloc::vec::Vec::new();
}
let mut refs = alloc::vec::Vec::new();
collect_scoped(&ctes[i].select, &mut refs);
let mut out = alloc::vec::Vec::new();
for r in &refs {
let rl = r.to_ascii_lowercase();
if let Some(j) = lname.iter().position(|n| *n == rl)
&& j != i
&& used[j]
&& !out.contains(&j)
{
out.push(j);
}
}
out
})
.collect();
// Cycle detection in *entry order* — the order the consuming statement
// first names the CTEs — so the reported name matches SQLite, which
// expands CTEs on demand from the outer query: over an `a`<->`b` cycle,
// `… SELECT * FROM a` reports `a` while `… FROM b` reports `b`. The named
// CTE is the one re-entered while still being expanded.
let entry_order: alloc::vec::Vec<usize> = match seeds {
Some(s) => {
let mut order = alloc::vec::Vec::new();
for r in s {
let rl = r.to_ascii_lowercase();
if let Some(j) = lname.iter().position(|n| *n == rl)
&& used[j]
&& !order.contains(&j)
{
order.push(j);
}
}
order
}
None => (0..ctes.len()).filter(|&i| used[i]).collect(),
};
// 0 = unvisited, 1 = on the current expansion stack, 2 = fully expanded.
let mut state = alloc::vec![0u8; ctes.len()];
for &start in &entry_order {
if state[start] != 0 {
continue;
}
state[start] = 1;
let mut stack: alloc::vec::Vec<(usize, usize)> = alloc::vec![(start, 0)];
while let Some(&(node, di)) = stack.last() {
if di < dep_list[node].len() {
stack.last_mut().unwrap().1 += 1;
let v = dep_list[node][di];
match state[v] {
1 => {
return Err(Error::Error(alloc::format!(
"circular reference: {}",
ctes[v].name
)));
}
0 => {
state[v] = 1;
stack.push((v, 0));
}
_ => {}
}
} else {
state[node] = 2;
stack.pop();
}
}
}
// Materialization order: dependencies before dependents, but otherwise in
// declaration order so independent CTEs keep their natural evaluation
// order. For backward-only references (every legacy query) this is exactly
// declaration order, so existing behaviour is unchanged.
let mut order: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
let mut placed = alloc::vec![false; ctes.len()];
for start in 0..ctes.len() {
if !used[start] || placed[start] {
continue;
}
let mut stack: alloc::vec::Vec<(usize, usize)> = alloc::vec![(start, 0)];
while let Some(&(node, di)) = stack.last() {
if di < dep_list[node].len() {
stack.last_mut().unwrap().1 += 1;
let v = dep_list[node][di];
if !placed[v] && !stack.iter().any(|&(n, _)| n == v) {
stack.push((v, 0));
}
} else {
if !placed[node] {
placed[node] = true;
order.push(node);
}
stack.pop();
}
}
}
for &i in &order {
let cte = &ctes[i];
let binding = if references_name(&cte.select, &cte.name) {
self.eval_recursive_cte(cte, params, outer_cap)?
} else {
self.materialize_plain_cte(cte, params)?
};
self.cte_env.borrow_mut().push(binding);
}
Ok(())
}
/// Look up a CTE by name in the current environment (innermost first),
/// returning a copy of its columns + rows relabeled to `alias` if given.
fn lookup_cte(
&self,
name: &str,
alias: Option<&str>,
) -> Option<(Vec<ColumnInfo>, Vec<InputRow>)> {
let env = self.cte_env.borrow();
let b = env
.iter()
.rev()
.find(|b| b.name.eq_ignore_ascii_case(name))?;
let label = alias.unwrap_or(&b.name);
let columns = b
.columns
.iter()
.map(|c| ColumnInfo {
name: c.name.clone(),
table: label.to_string(),
affinity: c.affinity,
collation: c.collation,
schema: None,
hidden: false,
})
.collect();
Some((columns, b.rows.clone()))
}
/// Build the column metadata for a CTE from its body's output labels (or its
/// explicit `(col, …)` list), labeled with the CTE name.
fn cte_columns(&self, cte: &Cte, body_cols: &[String]) -> Result<Vec<ColumnInfo>> {
let names = if cte.columns.is_empty() {
body_cols.to_vec()
} else {
// An explicit column list must match the body's column count, as in
// SQLite (`table t has N values for M columns`).
if cte.columns.len() != body_cols.len() {
return Err(Error::Error(alloc::format!(
"table {} has {} values for {} columns",
cte.name,
body_cols.len(),
cte.columns.len()
)));
}
cte.columns.clone()
};
Ok(names
.into_iter()
.map(|n| ColumnInfo {
name: n,
table: cte.name.clone(),
affinity: eval::Affinity::Blob,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
})
.collect())
}
/// A non-recursive CTE: run its body once.
fn materialize_plain_cte(&self, cte: &Cte, params: &Params) -> Result<CteBinding> {
let result = self.run_select(&cte.select, params)?;
let columns = self.cte_columns(cte, &result.columns)?;
let rows = result
.rows
.into_iter()
.map(|values| InputRow {
values,
rowid: None,
})
.collect();
Ok(CteBinding {
name: cte.name.clone(),
columns,
rows,
})
}
/// A recursive CTE: `anchor [UNION [ALL] recursive]`. Evaluate the anchor,
/// then repeatedly evaluate the recursive term against the rows produced by
/// the previous step (bound to the CTE's name) until no new rows appear.
fn eval_recursive_cte(
&self,
cte: &Cte,
params: &Params,
outer_cap: Option<usize>,
) -> Result<CteBinding> {
// Flatten the body into arms: (op-before-this-arm, select). The first
// arm has no preceding op.
let mut arms: Vec<(Option<CompoundOp>, Select)> = Vec::new();
let mut base = (*cte.select).clone();
// A LIMIT/OFFSET on the CTE definition bounds the rows it produces — and
// crucially terminates an otherwise-infinite recursion. Capture them
// before stripping the per-arm clauses below. (A negative LIMIT means
// "no limit", as elsewhere in SQLite.)
let rec_limit = match &base.limit {
Some(e) => {
let n = must_be_int(eval::eval(
e,
&EvalCtx::rowless(params).with_subqueries(self),
)?)?;
(n >= 0).then_some(n as usize)
}
None => None,
};
let rec_offset = match &base.offset {
Some(e) => must_be_int(eval::eval(
e,
&EvalCtx::rowless(params).with_subqueries(self),
)?)?
.max(0) as usize,
None => 0,
};
let compound = core::mem::take(&mut base.compound);
// The recursive-select's ORDER BY (if any) controls the work *queue* — see
// the priority-queue model below. Capture it before stripping the tail.
let rec_order = core::mem::take(&mut base.order_by);
base.limit = None;
base.offset = None;
arms.push((None, base));
for (op, mut s) in compound {
s.order_by.clear();
s.limit = None;
s.offset = None;
arms.push((Some(op), s));
}
// Partition into leading anchor arms and trailing recursive arms.
let mut anchor: Vec<Select> = Vec::new();
let mut recursive: Vec<Select> = Vec::new();
let mut rec_distinct = false;
let mut in_rec = false;
for (op, s) in arms {
if !in_rec && references_name_select(&s, &cte.name) {
in_rec = true;
rec_distinct = matches!(op, Some(CompoundOp::Union));
}
if in_rec {
recursive.push(s);
} else {
anchor.push(s);
}
}
if anchor.is_empty() {
// The recursive table appears already in the first arm, with no leading
// non-recursive anchor to seed the recursion (`WITH c AS (SELECT * FROM
// c) …`, or a recursive arm placed before the anchor). SQLite rejects
// this as a circular reference, naming the CTE.
return Err(Error::Error(alloc::format!(
"circular reference: {}",
cte.name
)));
}
if recursive.is_empty() {
return Err(Error::Unsupported(
"recursive CTE must have a non-recursive anchor and a recursive term",
));
}
// SQLite permits a recursive term to name the recursive table only once in
// its FROM clause; a self-join on it (`FROM c, c`) is rejected at prepare
// time. graphite would otherwise run the cross-join and report a misleading
// `ambiguous column name`.
for s in &recursive {
if from_reference_count(s, &cte.name) > 1 {
return Err(Error::Error(alloc::format!(
"multiple references to recursive table: {}",
cte.name
)));
}
}
// SQLite rejects a recursive term that is itself an aggregate or windowed
// query — the recursion has no fixed point to iterate to — at prepare
// time, before any rows are produced. A window function takes precedence
// over an aggregate in the message. (An aggregate confined to a *subquery*
// of the recursive term, or in the anchor, is fine — `has_result_aggregate`
// / `has_window` only inspect the arm's own top-level result columns /
// `ORDER BY`, not nested SELECTs. A bare `HAVING` on a non-aggregate arm
// keeps its own distinct error, raised when the arm runs below.)
for s in &recursive {
if window::has_window(s) {
return Err(Error::Error(
"cannot use window functions in recursive queries".into(),
));
}
if self.has_result_aggregate(s) || !s.group_by.is_empty() {
return Err(Error::Error(
"recursive aggregate queries not supported".into(),
));
}
}
// Evaluate the anchor (a compound of the anchor arms).
let mut anchor_rows: Vec<Vec<Value>> = Vec::new();
for a in &anchor {
let r = self.run_select(a, params)?;
anchor_rows.extend(r.rows);
}
let body_cols = self.run_select(&anchor[0], params)?.columns;
let columns = self.cte_columns(cte, &body_cols)?;
// Resolve the recursive term's ORDER BY. Like any compound ORDER BY, each
// term must name an output column — by 1-based position, or by the
// *intrinsic* result-column name of the recursive SELECT (`body_cols`),
// NOT the CTE's renamed columns — otherwise SQLite rejects it at prepare
// time. Resolving up front reproduces that error (`ORDER BY <cte-col>`, a
// base column, or an expression → "does not match any column …"), and the
// resulting sort keys drive the priority-queue extraction below.
// A sort key: (output-column index, descending, nulls-first, collation).
type SortKey = (usize, bool, Option<bool>, crate::value::Collation);
let rec_keys: Option<Vec<SortKey>> = if rec_order.is_empty() {
None
} else {
check_positional_terms(&[], &rec_order, body_cols.len())?;
let colls = {
let (cols, _) = self.scan_source(&anchor[0], params)?;
self.output_collations(&anchor[0], &cols, params)
};
let mut keys = Vec::with_capacity(rec_order.len());
for (i, term) in rec_order.iter().enumerate() {
let idx = resolve_order_index(&term.expr, &body_cols, body_cols.len()).ok_or_else(
|| {
Error::Error(alloc::format!(
"{} ORDER BY term does not match any column in the result set",
ordinal(i + 1),
))
},
)?;
// An explicit `COLLATE` on the term wins over the output column's.
let coll = explicit_collation(&term.expr)
.unwrap_or_else(|| colls.get(idx).copied().unwrap_or_default());
keys.push((idx, term.descending, term.nulls_first, coll));
}
Some(keys)
};
if rec_distinct {
dedup_rows(&mut anchor_rows);
}
// Push a working binding the recursive term resolves against; update it
// each iteration. Guard against runaway recursion.
let slot = self.cte_env.borrow().len();
self.cte_env.borrow_mut().push(CteBinding {
name: cte.name.clone(),
columns: columns.clone(),
rows: Vec::new(),
});
let mut all_rows: Vec<Vec<Value>>;
let result: Result<()> = if let Some(keys) = &rec_keys {
// Priority-queue model (recursive ORDER BY present). SQLite pulls one
// row at a time from the work queue — the one that sorts *first* under
// the ORDER BY — emits it, then runs the recursive term on just that
// row and enqueues the results. With no ORDER BY the queue is a FIFO
// (the breadth-first batch model below); an ORDER BY turns it into a
// priority queue (SQLite's documented depth-/breadth-first control).
let mut queue: Vec<Vec<Value>> = Vec::new();
let mut seen: Vec<Vec<Value>> = Vec::new();
for row in anchor_rows {
if rec_distinct && seen.iter().any(|s| rows_equal(s, &row)) {
continue;
}
if rec_distinct {
seen.push(row.clone());
}
queue.push(row);
}
all_rows = Vec::new();
let mut guard = 0usize;
loop {
if queue.is_empty() {
break Ok(());
}
guard += 1;
if guard > 1_000_000 {
self.cte_env.borrow_mut().truncate(slot);
return Err(Error::Error("recursive CTE did not terminate".into()));
}
// Extract the row that sorts first under the ORDER BY. Ties keep
// FIFO insertion order (`best` only advances on a strict Less).
let mut best = 0usize;
for i in 1..queue.len() {
let mut less = false;
for (idx, desc, nf, coll) in keys {
let ord = cmp_order(&queue[i][*idx], &queue[best][*idx], *desc, *nf, *coll);
if ord != core::cmp::Ordering::Equal {
less = ord == core::cmp::Ordering::Less;
break;
}
}
if less {
best = i;
}
}
let row = queue.remove(best);
all_rows.push(row.clone());
// Stop once the CTE's LIMIT (after OFFSET), or the consuming
// query's LIMIT (+OFFSET), is satisfied.
if let Some(lim) = rec_limit
&& all_rows.len() >= rec_offset.saturating_add(lim)
{
break Ok(());
}
if let Some(cap) = outer_cap
&& all_rows.len() >= cap
{
break Ok(());
}
self.cte_env.borrow_mut()[slot].rows = alloc::vec![InputRow {
values: row,
rowid: None,
}];
let mut produced: Vec<Vec<Value>> = Vec::new();
for r in &recursive {
match self.run_select(r, params) {
Ok(res) => produced.extend(res.rows),
Err(e) => {
self.cte_env.borrow_mut().truncate(slot);
return Err(e);
}
}
}
for prow in produced {
if rec_distinct && seen.iter().any(|s| rows_equal(s, &prow)) {
continue;
}
if rec_distinct {
seen.push(prow.clone());
}
queue.push(prow);
}
}
} else {
// Breadth-first FIFO batch model — no recursive ORDER BY. Each pass
// runs the recursive term over the whole previous batch at once.
all_rows = anchor_rows.clone();
let mut working = anchor_rows;
let mut guard = 0usize;
loop {
guard += 1;
if guard > 1_000_000 {
self.cte_env.borrow_mut().truncate(slot);
return Err(Error::Error("recursive CTE did not terminate".into()));
}
// Bind the working set.
self.cte_env.borrow_mut()[slot].rows = working
.iter()
.cloned()
.map(|values| InputRow {
values,
rowid: None,
})
.collect();
let mut produced: Vec<Vec<Value>> = Vec::new();
for r in &recursive {
match self.run_select(r, params) {
Ok(res) => produced.extend(res.rows),
Err(e) => {
self.cte_env.borrow_mut().truncate(slot);
return Err(e);
}
}
}
// Keep only genuinely new rows (for UNION; UNION ALL keeps all but
// still must terminate — SQLite requires the recursive query to
// eventually produce nothing).
let mut fresh: Vec<Vec<Value>> = Vec::new();
for row in produced {
if rec_distinct && all_rows.iter().any(|s| rows_equal(s, &row)) {
continue;
}
fresh.push(row);
}
if fresh.is_empty() {
break Ok(());
}
all_rows.extend(fresh.iter().cloned());
working = fresh;
// Stop once the CTE's LIMIT (after OFFSET) is satisfied.
if let Some(lim) = rec_limit
&& all_rows.len() >= rec_offset.saturating_add(lim)
{
break Ok(());
}
// Stop once the consuming query's LIMIT (+OFFSET) is satisfied —
// this terminates an otherwise-infinite recursion
// `SELECT … FROM rcte LIMIT k`.
if let Some(cap) = outer_cap
&& all_rows.len() >= cap
{
break Ok(());
}
}
};
self.cte_env.borrow_mut().truncate(slot);
result?;
// Apply the CTE definition's OFFSET/LIMIT to the produced rows.
if rec_offset > 0 {
all_rows.drain(..rec_offset.min(all_rows.len()));
}
if let Some(lim) = rec_limit {
all_rows.truncate(lim);
}
let rows = all_rows
.into_iter()
.map(|values| InputRow {
values,
rowid: None,
})
.collect();
Ok(CteBinding {
name: cte.name.clone(),
columns,
rows,
})
}
/// If `name` is a view, run its `SELECT` and return its columns + rows.
/// A temp view shadows a main view of the same name (like a temp table), and
/// is read through its own (temp) database via [`scan_db_view`](Self::scan_db_view).
fn try_view(
&self,
name: &str,
alias: Option<&str>,
params: &Params,
) -> Result<Option<(Vec<ColumnInfo>, Vec<InputRow>)>> {
use crate::schema::ObjectType;
if self.temp_has_view(name) {
return self.scan_db_view(DbRef::Temp, name, alias, params);
}
let obj = match self
.schema
.objects()
.iter()
.find(|o| o.obj_type == ObjectType::View && o.name.eq_ignore_ascii_case(name))
{
Some(o) => o.clone(),
None => return Ok(None),
};
let sql = obj
.sql
.as_deref()
.ok_or_else(|| Error::Corrupt("view has no CREATE statement".into()))?;
let Statement::CreateView(cv) = sql::parse_one(sql)? else {
return Err(Error::Corrupt("schema sql is not CREATE VIEW".into()));
};
let result = self.run_select(&cv.select, params)?;
// An explicit `CREATE VIEW v(c1, …)` column list must match the body's
// column count; sqlite reports this when the view is *used*, not created.
if !cv.columns.is_empty() && cv.columns.len() != result.columns.len() {
return Err(Error::Error(format!(
"expected {} columns for '{name}' but got {}",
cv.columns.len(),
result.columns.len()
)));
}
let label = alias.unwrap_or(name).to_string();
// Column names: explicit view columns, else the SELECT's output labels.
let names = if cv.columns.is_empty() {
result.columns.clone()
} else {
cv.columns.clone()
};
// A view column inherits the affinity AND collation of its defining
// expression's origin (a direct column reference takes its base column's),
// exactly as a derived-table subquery does — so `ORDER BY`/`WHERE`/`min`/
// `max` over the view honor a NOCASE base column. Explicit `(col, …)` names
// only rename; the origin is positional from the body.
let origins = self.subquery_column_origins(&cv.select);
let columns: Vec<ColumnInfo> = names
.into_iter()
.enumerate()
.map(|(i, n)| {
let (affinity, collation) = origins
.as_ref()
.and_then(|o| o.get(i).copied())
.unwrap_or((eval::Affinity::Blob, crate::value::Collation::default()));
ColumnInfo {
name: n,
table: label.clone(),
affinity,
collation,
schema: None,
hidden: false,
}
})
.collect();
let rows = result
.rows
.into_iter()
.map(|values| InputRow {
values,
rowid: None,
})
.collect();
Ok(Some((columns, rows)))
}
fn exec_drop(&mut self, d: &Drop) -> Result<()> {
use crate::schema::ObjectType;
if matches!(d.kind, DropKind::Table) {
// An internal `sqlite_` table may not be dropped — and this outranks
// `IF EXISTS` for a catalog/internal table that actually exists.
self.reject_internal_table_ddl(&d.name, "dropped")?;
}
// Dropping a persistent virtual table also drops its shadow tables, as
// sqlite does: the generic `<name>_data` backing, or an R-Tree's
// `_node`/`_rowid`/`_parent` node tables.
if matches!(d.kind, DropKind::Table) && self.is_virtual_table(&d.name) {
for suffix in [
"_data", "_node", "_rowid", "_parent", "_content", "_docsize", "_config", "_idx",
"_gpost",
] {
let backing = format!("{}{suffix}", d.name);
if self.schema.table(&backing).is_some() {
self.exec_drop(&Drop {
kind: DropKind::Table,
if_exists: false,
name: backing,
schema: d.schema.clone(),
})?;
}
}
}
let want = match d.kind {
DropKind::Table => ObjectType::Table,
DropKind::Index => ObjectType::Index,
DropKind::View => ObjectType::View,
DropKind::Trigger => ObjectType::Trigger,
};
// Find the object (and, for a table, its dependent indexes) to remove.
let target = self
.schema
.objects()
.iter()
.find(|o| o.obj_type == want && o.name == d.name)
.cloned();
let Some(obj) = target else {
// SQLite's table↔view confusion hint when a same-named object of the
// other kind exists. This fires even with `IF EXISTS` — that clause
// suppresses a *missing* object, not a *wrong-type* one.
if let Some(other) = self.schema.objects().iter().find(|o| o.name == d.name) {
match (d.kind, other.obj_type) {
(DropKind::Table, ObjectType::View) => {
return Err(Error::Error(format!(
"use DROP VIEW to delete view {}",
d.name
)));
}
(DropKind::View, ObjectType::Table) => {
return Err(Error::Error(format!(
"use DROP TABLE to delete table {}",
d.name
)));
}
_ => {}
}
}
if d.if_exists {
return Ok(());
}
let kind = match d.kind {
DropKind::Table => "table",
DropKind::Index => "index",
DropKind::View => "view",
DropKind::Trigger => "trigger",
};
return Err(Error::Error(format!("no such {kind}: {}", d.name)));
};
// Collect the schema rows (by rowid) and b-tree roots to drop.
let mut roots_to_free = Vec::new();
let mut names_to_remove = Vec::new();
roots_to_free.push(obj.rootpage);
names_to_remove.push(obj.name.clone());
if want == ObjectType::Table {
for idx in self.schema.indexes_on(&obj.name) {
roots_to_free.push(idx.rootpage);
names_to_remove.push(idx.name.clone());
}
// Triggers on the table are dropped with it (SQLite cascades these).
for o in self.schema.objects() {
if o.obj_type == ObjectType::Trigger && o.tbl_name.eq_ignore_ascii_case(&obj.name) {
roots_to_free.push(o.rootpage); // triggers have rootpage 0
names_to_remove.push(o.name.clone());
}
}
}
// Map names -> sqlite_schema rowids (scan page 1).
let victim_rowids = self.schema_rowids_for(&names_to_remove)?;
let w = self.backend.writer()?;
for root in roots_to_free {
if root != 0 {
free_tree(w, root)?;
}
}
for rid in victim_rowids {
delete_table(w, crate::schema::SCHEMA_ROOT_PAGE, rid)?;
}
let cookie = w.header().schema_cookie.wrapping_add(1);
w.header_mut().schema_cookie = cookie;
self.schema = Schema::read(self.backend.source())?;
// Dropping a table also removes its AUTOINCREMENT row from
// `sqlite_sequence`, like SQLite.
if want == ObjectType::Table && self.schema.table("sqlite_sequence").is_some() {
let root = self.schema.table("sqlite_sequence").unwrap().rootpage;
let meta = self.table_meta("sqlite_sequence", None)?;
let victims: Vec<i64> = self
.scan_table(&meta)?
.into_iter()
.filter(|(_, v)| matches!(&v[0], Value::Text(t) if t == &obj.name))
.map(|(rid, _)| rid)
.collect();
for rid in victims {
delete_table(self.backend.writer()?, root, rid)?;
}
}
Ok(())
}
/// SQLite forbids structural DDL (`ALTER`, `DROP TABLE`, `CREATE INDEX`) on
/// any table whose name begins with `sqlite_` — the schema catalog and the
/// other internal bookkeeping tables. The check fires only once the target
/// is known to exist, so a *missing* `sqlite_`-prefixed name (`sqlite_stat1`
/// when absent) still reports `no such table`; but the schema catalog is
/// always present. `verb` is `altered` / `dropped` / `indexed`; the reported
/// name is the catalog's canonical spelling, otherwise the table's stored name.
fn reject_internal_table_ddl(&self, name: &str, verb: &str) -> Result<()> {
if let Some(display) = schema_catalog_display_name(name) {
return Err(Error::Error(alloc::format!(
"table {display} may not be {verb}"
)));
}
if name.len() >= 7
&& name[..7].eq_ignore_ascii_case("sqlite_")
&& let Some(obj) = self.schema.table(name)
{
return Err(Error::Error(alloc::format!(
"table {} may not be {verb}",
obj.name
)));
}
Ok(())
}
fn exec_alter(&mut self, a: &Alter) -> Result<()> {
// Internal savepoint name for the RENAME COLUMN rollback (A-alter-2); the
// NUL prefix keeps it out of the user savepoint namespace.
const ALTER_SAVEPOINT: &str = "\u{0}graphite_alter";
self.reject_internal_table_ddl(&a.table, "altered")?;
// A virtual table can be renamed (sqlite renames its backing tables too),
// but not otherwise altered — and it isn't a CREATE TABLE, so it must not
// reach the regular path below.
if self.is_virtual_table(&a.table) {
if let AlterAction::RenameTable(new_name) = &a.action {
return self.rename_virtual_table(&a.table, new_name);
}
return Err(Error::Error("virtual tables may not be altered".into()));
}
let obj = self
.schema
.table(&a.table)
.cloned()
.ok_or_else(|| Error::Error(format!("no such table: {}", a.table)))?;
let sql = obj
.sql
.as_deref()
.ok_or_else(|| Error::Corrupt("table has no CREATE statement".into()))?;
let Statement::CreateTable(mut ct) = sql::parse_one(sql)? else {
return Err(Error::Corrupt("schema sql is not CREATE TABLE".into()));
};
if let AlterAction::DropColumn(name) = &a.action {
return self.exec_drop_column(a, ct, name);
}
// A RENAME COLUMN can leave a dependent view unresolvable (a shape neither
// graphite nor SQLite can rewrite — e.g. a `USING(col)` join whose column
// vanishes, or a derived table that exposes the renamed column and is
// consumed). SQLite applies the rename, re-validates every dependent, and
// rolls back with `error in view … after rename: …` if any no longer
// resolves (ROADMAP A-alter-2). Snapshot the staged schema first.
let rename_col = matches!(&a.action, AlterAction::RenameColumn { .. });
if rename_col {
self.backend.writer()?.savepoint(ALTER_SAVEPOINT);
}
match &a.action {
AlterAction::DropColumn(_) => unreachable!("handled above"),
AlterAction::AddColumn(cd, col_text) => {
if ct
.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(&cd.name))
{
return Err(Error::Error(format!("duplicate column name: {}", cd.name)));
}
// SQLite forbids a few constraints on ADD COLUMN: a UNIQUE or
// PRIMARY KEY column is always rejected; a NOT NULL column whose
// default is NULL is rejected only when the table already has
// rows (which would otherwise hold a NULL).
for k in &cd.constraints {
match k {
ColumnConstraint::Unique(_) => {
return Err(Error::Error("Cannot add a UNIQUE column".into()));
}
ColumnConstraint::PrimaryKey { .. } => {
return Err(Error::Error("Cannot add a PRIMARY KEY column".into()));
}
// A column `DEFAULT` must be constant — no column reference —
// exactly as on `CREATE TABLE`.
ColumnConstraint::Default(e, _)
if unknown_column_ref(e, &[], false, None).is_some() =>
{
return Err(Error::Error(format!(
"default value of column [{}] is not constant",
cd.name
)));
}
_ => {}
}
}
let not_null = cd
.constraints
.iter()
.any(|k| matches!(k, ColumnConstraint::NotNull(_)));
if not_null {
let default = cd.constraints.iter().find_map(|k| match k {
ColumnConstraint::Default(e, _) => Some(e),
_ => None,
});
let no_params = Params::default();
let default_is_null = match default {
None => true,
Some(e) => {
let ctx = EvalCtx::rowless(&no_params).with_subqueries(self);
matches!(eval::eval(e, &ctx), Ok(Value::Null) | Err(_))
}
};
if default_is_null && !self.table_is_empty(&a.table)? {
return Err(Error::Error(
"Cannot add a NOT NULL column with default value NULL".into(),
));
}
}
ct.columns.push(cd.clone());
// Append the new column's verbatim text to the stored CREATE (like
// sqlite); fall back to reprinting from the AST if its source or
// the column-list close can't be located.
let reprint = sql::print::create_table(&ct);
let table = a.table.clone();
let col_text = col_text.clone();
self.rewrite_schema_rows(|cols| {
if is_text(&cols[0], "table") && is_text(&cols[1], &table) {
let updated = match (&col_text, cols.get(4)) {
(Some(t), Some(Value::Text(old))) => {
append_column_to_create(old, t).unwrap_or_else(|| reprint.clone())
}
_ => reprint.clone(),
};
cols[4] = Value::Text(updated.into());
true
} else {
false
}
})?;
}
AlterAction::RenameTable(new_name) => {
// The new name must not collide with any existing table or index,
// including renaming a table to its own name, as in SQLite.
if self
.schema
.objects()
.iter()
.any(|o| o.name.eq_ignore_ascii_case(new_name))
{
return Err(Error::Error(format!(
"there is already another table or index with this name: {new_name}"
)));
}
let old = a.table.clone();
let new_name = new_name.clone();
self.rewrite_schema_rows(|cols| {
if is_text(&cols[0], "table") && is_text(&cols[1], &old) {
cols[1] = Value::Text(new_name.clone().into());
cols[2] = Value::Text(new_name.clone().into());
// Edit the table name in the stored CREATE text in place
// (preserving the body verbatim), like SQLite — rather than
// reprinting the whole definition from the AST.
if let Some(Value::Text(old_sql)) = cols.get(4).cloned() {
// Rename the table token itself, and any self-referential
// foreign key (`REFERENCES <old>`) in its own body.
let renamed = rename_table_token_after(&old_sql, "table", &new_name);
cols[4] = Value::Text(
rewrite_fk_references(&renamed, &old, &new_name).into(),
);
}
true
} else if is_text(&cols[2], &old) {
// Dependent index/trigger/view: repoint, and rewrite an
// index's `ON` clause / a trigger's body to the new name.
cols[2] = Value::Text(new_name.clone().into());
if is_text(&cols[0], "index") {
// Repoint the index's `ON <table>` to the new name in
// place (preserving the rest), like SQLite.
if let Some(Value::Text(isql)) = cols.get(4).cloned() {
cols[4] = Value::Text(
rename_table_token_after(&isql, "on", &new_name).into(),
);
}
} else if is_text(&cols[0], "trigger") {
// A trigger ON the renamed table: rewrite the renamed
// name throughout its stored text (the `ON` clause and
// any body references), like SQLite.
if let Some(Value::Text(tsql)) = cols.get(4).cloned() {
cols[4] = Value::Text(
rewrite_ident_tokens(
&tsql,
&old,
&sql::print::ident(&new_name),
)
.into(),
);
}
}
true
} else if is_text(&cols[0], "view") {
// A view whose SELECT references the renamed table: rewrite
// the table name throughout its stored body (formatting
// preserved), so `SELECT … FROM v` keeps working.
match cols.get(4).cloned() {
Some(Value::Text(vsql)) if view_uses_table(&vsql, &old) => {
cols[4] = Value::Text(
rewrite_ident_tokens(
&vsql,
&old,
&sql::print::ident(&new_name),
)
.into(),
);
true
}
_ => false,
}
} else if is_text(&cols[0], "trigger") {
// A trigger on ANOTHER table whose body references the
// renamed table (e.g. `INSERT INTO <table> …`): rewrite the
// renamed name throughout its stored text.
match cols.get(4).cloned() {
Some(Value::Text(tsql)) if trigger_uses_table(&tsql, &old) => {
cols[4] = Value::Text(
rewrite_ident_tokens(
&tsql,
&old,
&sql::print::ident(&new_name),
)
.into(),
);
true
}
_ => false,
}
} else if is_text(&cols[0], "table") {
// Another table whose foreign key targets the renamed table:
// repoint its `REFERENCES <old>` to the new name (leaving its
// own name and any references to other tables untouched).
match cols.get(4).cloned() {
Some(Value::Text(tsql)) => {
let rewritten = rewrite_fk_references(&tsql, &old, &new_name);
if rewritten != tsql {
cols[4] = Value::Text(rewritten.into());
true
} else {
false
}
}
_ => false,
}
} else {
false
}
})?;
}
AlterAction::RenameColumn { old, new, new_text } => {
let pos = ct
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(old))
.ok_or_else(|| Error::Error(format!("no such column: \"{old}\"")))?;
// Renaming onto an existing column name is rejected, like SQLite.
// SQLite reports this as a *post-rename* validation failure: it
// applies the rename, re-parses the table, and the duplicate-column
// check fires while re-adding the later of the two colliding
// columns — so the reported name is whichever sits at the higher
// index after the rename (the renamed column when it moved onto an
// earlier name's slot, else the pre-existing column). The whole
// thing is wrapped in `error in table <T> after rename: …`.
if let Some((j, other)) = ct
.columns
.iter()
.enumerate()
.find(|(i, c)| *i != pos && c.name.eq_ignore_ascii_case(new))
{
let dup = if pos > j {
new.clone()
} else {
other.name.clone()
};
return Err(Error::Error(format!(
"error in table {} after rename: duplicate column name: {dup}",
a.table
)));
}
ct.columns[pos].name = new.clone();
// Propagate the rename into the table's own expressions and
// column lists, which still reference the old name (otherwise the
// CHECK / generated / default would break after the rename).
let rename = |e: &mut Expr| rename_column_ref(e, &a.table, old, new);
for col in &mut ct.columns {
for k in &mut col.constraints {
match k {
ColumnConstraint::Check(e, _) | ColumnConstraint::Default(e, _) => {
rename(e)
}
ColumnConstraint::Generated { expr, .. } => rename(expr),
_ => {}
}
}
}
for tc in &mut ct.constraints {
match tc {
TableConstraint::PrimaryKey(n, _) => {
for (nm, _) in n {
if nm.eq_ignore_ascii_case(old) {
*nm = new.clone();
}
}
}
TableConstraint::Unique(n, _) => {
for (nm, _) in n {
if nm.eq_ignore_ascii_case(old) {
*nm = new.clone();
}
}
}
TableConstraint::Check(e, _) => rename(e),
TableConstraint::ForeignKey(fk) => {
for nm in &mut fk.columns {
if nm.eq_ignore_ascii_case(old) {
*nm = new.clone();
}
}
}
}
}
// The AST reprint is only a fallback; normally we edit the stored
// text in place so the column's formatting is preserved like sqlite.
let reprint = sql::print::create_table(&ct);
let table = a.table.clone();
let old = old.clone();
let new_text = new_text.clone();
// Snapshot every base table's column names, so a multi-source view
// rewrite (A-rn3) can tell whether the renamed column name is
// unique across a join's sources.
let table_cols: alloc::collections::BTreeMap<String, Vec<String>> = self
.schema
.objects()
.iter()
.filter(|o| o.obj_type == crate::schema::ObjectType::Table)
.filter_map(|o| {
self.table_meta(&o.name, None).ok().map(|m| {
(
o.name.clone(),
m.columns.iter().map(|c| c.name.clone()).collect(),
)
})
})
.collect();
self.rewrite_schema_rows(|cols| {
if is_text(&cols[0], "table") && is_text(&cols[1], &table) {
// The table's own definition: rename the bare column wherever
// it appears (column list, CHECK/generated/default exprs) and
// any `<table>.col` self-qualified reference (e.g. a CHECK
// written `CHECK(t.a > 0)`), like SQLite. Other `x.col`
// qualifiers can't occur in a single-table definition.
cols[4] = Value::Text(
match cols.get(4) {
Some(Value::Text(s)) => rewrite_column_tokens(
s,
core::slice::from_ref(&table),
&old,
&new_text,
BareRewrite::All,
),
_ => reprint.clone(),
}
.into(),
);
true
} else if is_text(&cols[0], "index") && is_text(&cols[2], &table) {
// Rewrite an index over this table if it names the column —
// both bare (`ON t(col)`) and `<table>.col`-qualified (e.g. a
// partial-index `WHERE t.col > 0`) references.
if let Some(Value::Text(isql)) = cols.get(4).cloned() {
let rewritten = rewrite_column_tokens(
&isql,
core::slice::from_ref(&table),
&old,
&new_text,
BareRewrite::All,
);
if rewritten != isql {
cols[4] = Value::Text(rewritten.into());
return true;
}
}
false
} else if is_text(&cols[0], "table") {
// Another table whose foreign key references the renamed
// parent column: rewrite `REFERENCES <table>(old)` only.
if let Some(Value::Text(csql)) = cols.get(4).cloned() {
let rewritten =
rewrite_fk_parent_column(&csql, &table, &old, &new_text);
if rewritten != csql {
cols[4] = Value::Text(rewritten.into());
return true;
}
}
false
} else if is_text(&cols[0], "view") {
// A single-source view (only the renamed table) rewrites
// every reference (bare + qualified). A multi-source view
// (a join of base tables) rewrites `<renamed-table>.old`
// always, and a bare `old` only when that name is unique
// across the sources (A-rn3). Views with subqueries/CTEs/
// non-base sources are still left untouched.
match cols.get(4).cloned() {
Some(Value::Text(vsql)) => {
let rewritten = if let Some(quals) =
view_single_source_column_quals(&vsql, &table, &old)
{
rewrite_column_tokens(
&vsql,
&quals,
&old,
&new_text,
BareRewrite::All,
)
} else if let Some(quals) =
view_only_table_quals(&vsql, &table, &old)
{
// Single-source view whose body nests expression
// subqueries that reference only the renamed table.
rewrite_column_tokens(
&vsql,
&quals,
&old,
&new_text,
BareRewrite::All,
)
} else if let Some((quals, bare)) =
view_multi_source_quals(&vsql, &table, &old, &table_cols)
{
rewrite_column_tokens(
&vsql,
&quals,
&old,
&new_text,
BareRewrite::from_bool(bare),
)
} else if let Some((quals, bare)) =
view_global_unique_quals(&vsql, &table, &old, &table_cols)
{
// The renamed table is reached only through a
// nested subquery (top-level FROM is another
// base table); a bare `old` is rewritten when
// the column name is globally unique.
rewrite_column_tokens(&vsql, &quals, &old, &new_text, bare)
} else {
vsql.as_str().to_string()
};
if rewritten != vsql {
cols[4] = Value::Text(rewritten.into());
return true;
}
false
}
_ => false,
}
} else if is_text(&cols[0], "trigger") {
// A trigger ON the renamed table whose body references ONLY
// that table: NEW/OLD and bare/qualified column refs all
// resolve to it, so a full token rewrite is safe and
// complete. When the body also touches other tables, the
// bare refs are ambiguous, but `NEW.old`/`OLD.old` still
// bind to the renamed table, so rewrite just those. (The
// remaining multi-source bare/`UPDATE OF` refs are the
// A-rn3 remainder.)
match cols.get(4).cloned() {
Some(Value::Text(tsql)) => {
let rewritten = if let Some(quals) =
trigger_single_source_quals(&tsql, &table, &old)
{
rewrite_column_tokens(
&tsql,
&quals,
&old,
&new_text,
BareRewrite::All,
)
} else if let Some((quals, bare)) =
trigger_global_unique_quals(&tsql, &table, &old, &table_cols)
{
// `bare` is a `BareRewrite` (None/All/At-spans):
// a mixed trigger body rewrites only the bare
// occurrences that scope-resolve to the renamed
// table, like the view path. The renamed table is
// reached across objects (the trigger is on
// another table, or its body touches more than
// one) — globally unique → every ref binds to it
// (`All`); scope-resolved → `None`/`At`. Must
// precede the `NEW`/`OLD`-only branch below, which
// would otherwise short-circuit a trigger ON the
// renamed table and miss the bare refs.
rewrite_column_tokens(&tsql, &quals, &old, &new_text, bare)
} else if trigger_on_renamed_table(&tsql, &table, &old) {
rewrite_column_tokens(
&tsql,
&[String::from("NEW"), String::from("OLD")],
&old,
&new_text,
BareRewrite::None,
)
} else if trigger_body_single_source_over(&tsql, &table, &old) {
// A trigger on ANOTHER table whose body reads/
// writes only the renamed table: every bare and
// `<table>.`-qualified ref binds to it (its own
// NEW/OLD belong to a different table and are
// left alone, as `<table>` is the only qual).
rewrite_column_tokens(
&tsql,
core::slice::from_ref(&table),
&old,
&new_text,
BareRewrite::All,
)
} else {
tsql.as_str().to_string()
};
if rewritten != tsql {
cols[4] = Value::Text(rewritten.into());
return true;
}
false
}
_ => false,
}
} else {
false
}
})?;
}
}
let cookie = self
.backend
.writer()?
.header()
.schema_cookie
.wrapping_add(1);
self.backend.writer()?.header_mut().schema_cookie = cookie;
self.schema = Schema::read(self.backend.source())?;
if rename_col {
// Re-validate dependents against the post-rename schema; a broken view
// or trigger rolls the whole rename back, matching SQLite.
let renamed_old = match &a.action {
AlterAction::RenameColumn { old, .. } => old.as_str(),
_ => "",
};
let broken = self
.first_broken_view_after_rename(&a.table)
.or_else(|| self.first_broken_trigger_after_rename(&a.table, renamed_old));
if let Some(err) = broken {
self.backend
.writer()?
.rollback_to_savepoint(ALTER_SAVEPOINT)?;
self.backend.writer()?.release_savepoint(ALTER_SAVEPOINT)?;
self.schema = Schema::read(self.backend.source())?;
return Err(err);
}
self.backend.writer()?.release_savepoint(ALTER_SAVEPOINT)?;
}
Ok(())
}
/// After a RENAME COLUMN, find the first dependent view that no longer resolves
/// against the post-rename schema (ROADMAP A-alter-2). Each candidate view — one
/// whose stored text mentions `table`, so a pre-existing latent error in an
/// unrelated view can never turn a rename into a false rejection — is probed
/// minimally (`SELECT * FROM "v" LIMIT 0`); a resolution error means the rename
/// broke it. Returns that view's error wrapped as SQLite renders it (`error in
/// view NAME after rename: <detail>`), in schema (creation) order, or `None` if
/// every dependent still resolves. (Trigger dependents are A-alter-2b.)
fn first_broken_view_after_rename(&self, table: &str) -> Option<Error> {
let needle = table.to_ascii_lowercase();
for obj in self.schema.objects() {
if obj.obj_type != crate::schema::ObjectType::View {
continue;
}
let Some(vsql) = &obj.sql else { continue };
if !vsql.to_ascii_lowercase().contains(&needle) {
continue;
}
let probe = format!(
"SELECT * FROM \"{}\" LIMIT 0",
obj.name.replace('"', "\"\"")
);
if let Err(e) = self.query(&probe) {
let detail = match &e {
Error::Error(m) => m.clone(),
other => other.to_string(),
};
return Some(Error::Error(format!(
"error in view {} after rename: {detail}",
obj.name
)));
}
}
None
}
/// The trigger counterpart of [`Self::first_broken_view_after_rename`]. A
/// `RENAME COLUMN` that graphite's propagation cannot fully rewrite (a derived
/// table, `USING`/`NATURAL` join, or CTE in a trigger body reaching the renamed
/// column) leaves a dangling reference; SQLite rejects and rolls back such a
/// rename with `error in trigger NAME after rename: <detail>`. graphite can't
/// query a trigger directly, so it resolves each trigger body statement via a
/// static probe `SELECT` (see [`trigger_probe_selects`]) with `NEW`/`OLD`
/// neutralised, and rejects only when a probe fails with a genuine
/// renamed-column resolution error ([`trigger_break_detail`]).
fn first_broken_trigger_after_rename(&self, table: &str, old: &str) -> Option<Error> {
let tneedle = table.to_ascii_lowercase();
for obj in self.schema.objects() {
if obj.obj_type != crate::schema::ObjectType::Trigger {
continue;
}
let Some(tsql) = &obj.sql else { continue };
if !tsql.to_ascii_lowercase().contains(&tneedle) {
continue;
}
let Ok(Statement::CreateTrigger(ct)) = sql::parse_one(tsql) else {
continue;
};
for probe in trigger_probe_selects(&ct) {
if let Err(e) = self.run_select(&probe, &Params::default()) {
let detail = match &e {
Error::Error(m) => m.clone(),
other => other.to_string(),
};
if trigger_break_detail(&detail, old) {
return Some(Error::Error(format!(
"error in trigger {} after rename: {detail}",
obj.name
)));
}
}
}
}
None
}
/// `ALTER TABLE … RENAME TO` for a virtual table: rename its persistent
/// `<name>_data` backing table (a normal table) and rewrite its own schema row
/// (name, tbl_name, and the stored `CREATE VIRTUAL TABLE` text), matching
/// sqlite, which renames a vtab and its shadow tables.
fn rename_virtual_table(&mut self, old: &str, new: &str) -> Result<()> {
if self
.schema
.objects()
.iter()
.any(|o| o.name.eq_ignore_ascii_case(new))
{
return Err(Error::Error(format!(
"there is already another table or index with this name: {new}"
)));
}
// Rename the persistent shadow tables first (ordinary tables): the
// generic `<name>_data`, or an R-Tree's `_node`/`_rowid`/`_parent`.
for suffix in [
"_data", "_node", "_rowid", "_parent", "_content", "_docsize", "_config", "_idx",
"_gpost",
] {
let backing_old = format!("{old}{suffix}");
if self.schema.table(&backing_old).is_some() {
self.exec_alter(&Alter {
schema: None,
table: backing_old,
action: AlterAction::RenameTable(format!("{new}{suffix}")),
})?;
}
}
let old_s = old.to_string();
let new_s = new.to_string();
self.rewrite_schema_rows(|cols| {
if is_text(&cols[0], "table") && is_text(&cols[1], &old_s) {
cols[1] = Value::Text(new_s.clone().into());
cols[2] = Value::Text(new_s.clone().into());
if let Some(Value::Text(s)) = cols.get(4).cloned() {
cols[4] = Value::Text(
rewrite_ident_tokens(&s, &old_s, &sql::print::ident(&new_s)).into(),
);
}
true
} else {
false
}
})?;
let cookie = self
.backend
.writer()?
.header()
.schema_cookie
.wrapping_add(1);
self.backend.writer()?.header_mut().schema_cookie = cookie;
self.schema = Schema::read(self.backend.source())?;
Ok(())
}
/// `ALTER TABLE … DROP COLUMN name`: remove the column from the schema and
/// rewrite every row without it, then rebuild the indexes. To stay correct,
/// columns that participate in the structure (PRIMARY KEY, UNIQUE, an index,
/// a foreign key, a CHECK, or generation) are refused — matching SQLite, which
/// rejects dropping such columns.
fn exec_drop_column(&mut self, a: &Alter, mut ct: CreateTable, name: &str) -> Result<()> {
let pos = ct
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
.ok_or_else(|| Error::Error(format!("no such column: \"{name}\"")))?;
// SQLite refuses in a fixed order with specific messages. A column-level
// PRIMARY KEY/UNIQUE (and an INTEGER PRIMARY KEY, or any column named by a
// table-level PRIMARY KEY) is rejected outright; everything else is
// reported the way sqlite does it — by regenerating the schema without the
// column and re-parsing — so a table CHECK, generated column, table
// UNIQUE, table FK, or explicit index that *references* the dropped column
// yields `error in {table|index} … after drop column: …`, while a
// constraint that does not reference it drops cleanly (graphite used to
// refuse all of these unconditionally).
let pk_named = ct.columns[pos]
.constraints
.iter()
.any(|c| matches!(c, ColumnConstraint::PrimaryKey { .. }))
|| ct.constraints.iter().any(|tc| {
matches!(tc, TableConstraint::PrimaryKey(n, _)
if n.iter().any(|(x, _)| x.eq_ignore_ascii_case(name)))
});
if pk_named {
return Err(Error::Error(format!(
"cannot drop PRIMARY KEY column: \"{name}\""
)));
}
if ct.columns[pos]
.constraints
.iter()
.any(|c| matches!(c, ColumnConstraint::Unique(_)))
{
return Err(Error::Error(format!(
"cannot drop UNIQUE column: \"{name}\""
)));
}
if ct.columns.len() <= 1 {
return Err(Error::Error(format!(
"cannot drop column \"{name}\": no other columns exist"
)));
}
// Whether `e` references the dropped column by name (qualified or not).
let refs_dropped = |e: &Expr| {
let mut hit = false;
walk_shallow_columns(e, &mut |_s, _t, col, _q| {
if col.eq_ignore_ascii_case(name) {
hit = true;
}
});
hit
};
let in_table = format!("error in table {} after drop column: ", a.table);
// SQLite re-validates the regenerated table, and a table must keep at least
// one non-generated column. Dropping the last ordinary column (leaving only
// GENERATED ALWAYS columns) is rejected with this rule *before* the
// generated-expression re-resolution below — so `DROP COLUMN a` from
// `t(a, b AS (a+1))` reports the non-generated-column rule, not the
// `no such column: a` that resolving `b`'s now-dangling expression yields.
let non_generated = ct
.columns
.iter()
.enumerate()
.filter(|(i, c)| {
*i != pos
&& !c
.constraints
.iter()
.any(|cc| matches!(cc, ColumnConstraint::Generated { .. }))
})
.count();
if non_generated == 0 {
return Err(Error::Error(format!(
"{in_table}must have at least one non-generated column"
)));
}
// A generated column or a CHECK on *another* column that mentions the
// dropped column makes the regenerated table text un-reparseable. The
// dropped column's own constraints go away with it, so skip `pos`.
for (i, c) in ct.columns.iter().enumerate() {
if i == pos {
continue;
}
if c.constraints.iter().any(|cc| match cc {
ColumnConstraint::Check(e, _) | ColumnConstraint::Generated { expr: e, .. } => {
refs_dropped(e)
}
_ => false,
}) {
return Err(Error::Error(format!("{in_table}no such column: {name}")));
}
}
for tc in &ct.constraints {
match tc {
TableConstraint::Check(e, _) if refs_dropped(e) => {
return Err(Error::Error(format!("{in_table}no such column: {name}")));
}
TableConstraint::Unique(n, _)
if n.iter().any(|(x, _)| x.eq_ignore_ascii_case(name)) =>
{
return Err(Error::Error(format!("{in_table}no such column: {name}")));
}
TableConstraint::ForeignKey(fk)
if fk.columns.iter().any(|x| x.eq_ignore_ascii_case(name)) =>
{
return Err(Error::Error(format!(
"{in_table}unknown column \"{name}\" in foreign key definition"
)));
}
_ => {}
}
}
let meta = self.table_meta(&a.table, None)?;
// SQLite re-validates *every* schema object against the post-drop schema,
// in `sqlite_schema` (rowid / creation) order, and reports the first that
// no longer resolves as `error in {kind} NAME after drop column: …`. The
// altered table's own row is rowid-first, so its structural checks above
// correctly precede everything; among the dependents that follow it —
// indexes, views, and triggers — we honor that same creation order.
//
// An index on a dropped column is reported by name; a view or trigger is
// reported when a reference provably binds to the dropped column. The
// binding is decided by the same provers RENAME COLUMN uses (if a rename
// *would* rewrite a reference, that reference resolves to this column, so
// the drop breaks it), so detection never produces a false rejection: a
// body the provers cannot bind is simply left to the prior accept path.
let idx_metas = self.indexes_of(&a.table)?;
// Snapshot every base table's columns for the views'/triggers' global-
// uniqueness provers (mirrors the RENAME COLUMN setup).
let table_cols: alloc::collections::BTreeMap<String, Vec<String>> = self
.schema
.objects()
.iter()
.filter(|o| o.obj_type == crate::schema::ObjectType::Table)
.filter_map(|o| {
self.table_meta(&o.name, None).ok().map(|m| {
(
o.name.clone(),
m.columns.iter().map(|c| c.name.clone()).collect(),
)
})
})
.collect();
for obj in self.schema.objects() {
match obj.obj_type {
crate::schema::ObjectType::Index
if obj.tbl_name.eq_ignore_ascii_case(&a.table)
&& !obj.name.starts_with("sqlite_autoindex_") =>
{
// An explicit index that references the column (an auto-index
// backing a table UNIQUE/PK is covered by the table re-parse
// above).
if let Some(idx) = idx_metas
.iter()
.find(|m| m.name.eq_ignore_ascii_case(&obj.name))
{
let references = idx.cols.contains(&pos)
|| idx
.key_exprs
.as_ref()
.is_some_and(|es| es.iter().any(&refs_dropped))
|| idx.partial.as_ref().is_some_and(&refs_dropped);
if references {
return Err(Error::Error(format!(
"error in index {} after drop column: no such column: {name}",
obj.name
)));
}
}
}
crate::schema::ObjectType::View => {
if let Some(vsql) = &obj.sql
&& let Some(r) = view_drop_break_ref(vsql, &a.table, name, &table_cols)
{
return Err(Error::Error(format!(
"error in view {} after drop column: no such column: {r}",
obj.name
)));
}
}
crate::schema::ObjectType::Trigger => {
if let Some(tsql) = &obj.sql
&& let Some(r) = trigger_drop_break_ref(tsql, &a.table, name, &table_cols)
{
return Err(Error::Error(format!(
"error in trigger {} after drop column: no such column: {r}",
obj.name
)));
}
}
_ => {}
}
}
// Read the rows, drop the column's value from each.
let new_rows: Vec<(i64, Vec<Value>)> = self
.scan_table(&meta)?
.into_iter()
.map(|(rid, mut vals)| {
vals.remove(pos);
(rid, vals)
})
.collect();
// Update the schema's CREATE TABLE text.
ct.columns.remove(pos);
// Remove the column from the stored CREATE text in place (preserving the
// other columns verbatim), like sqlite; fall back to an AST reprint.
let reprint = sql::print::create_table(&ct);
let table = a.table.clone();
let dropped = name.to_string();
self.rewrite_schema_rows(|cols| {
if is_text(&cols[0], "table") && is_text(&cols[1], &table) {
let updated = match cols.get(4) {
Some(Value::Text(old)) => {
drop_column_from_create(old, &dropped).unwrap_or_else(|| reprint.clone())
}
_ => reprint.clone(),
};
cols[4] = Value::Text(updated.into());
true
} else {
false
}
})?;
self.schema = Schema::read(self.backend.source())?;
let new_meta = self.table_meta(&a.table, None)?;
// Rewrite the table b-tree with the narrowed rows.
clear_table(self.backend.writer()?, new_meta.root)?;
for (rid, vals) in &new_rows {
let mut stored = vals.clone();
if let Some(ipk) = new_meta.ipk {
stored[ipk] = Value::Null;
}
let record = encode_record(&stored);
insert_table(self.backend.writer()?, new_meta.root, *rid, &record)?;
}
// Index column positions shifted; rebuild them.
let new_indexes = self.indexes_of(&a.table)?;
self.rebuild_indexes(&new_meta, &new_indexes)?;
let cookie = self
.backend
.writer()?
.header()
.schema_cookie
.wrapping_add(1);
self.backend.writer()?.header_mut().schema_cookie = cookie;
self.schema = Schema::read(self.backend.source())?;
Ok(())
}
/// Scan `sqlite_schema`, let `f` mutate each decoded 5-column row in place,
/// and rewrite (delete + re-insert at the same rowid) the rows it changed.
fn rewrite_schema_rows(&mut self, mut f: impl FnMut(&mut Vec<Value>) -> bool) -> Result<()> {
let encoding = self.backend.source().header().text_encoding;
let mut changes: Vec<(i64, Vec<u8>)> = Vec::new();
{
let mut cur = TableCursor::new(self.backend.source(), crate::schema::SCHEMA_ROOT_PAGE);
let mut ok = cur.first()?;
while ok {
let mut cols = decode_record(&cur.payload()?, encoding)?;
cols.resize(5, Value::Null);
if f(&mut cols) {
changes.push((cur.rowid()?, encode_record(&cols)));
}
ok = cur.next()?;
}
}
let w = self.backend.writer()?;
for (rid, rec) in changes {
delete_table(w, crate::schema::SCHEMA_ROOT_PAGE, rid)?;
insert_table(w, crate::schema::SCHEMA_ROOT_PAGE, rid, &rec)?;
}
Ok(())
}
/// Resolve the `sqlite_schema` rowids of the objects named in `names`.
fn schema_rowids_for(&self, names: &[String]) -> Result<Vec<i64>> {
let encoding = self.backend.source().header().text_encoding;
let mut out = Vec::new();
let mut cur = TableCursor::new(self.backend.source(), crate::schema::SCHEMA_ROOT_PAGE);
let mut ok = cur.first()?;
while ok {
let cols = decode_record(&cur.payload()?, encoding)?;
if let Some(Value::Text(name)) = cols.get(1)
&& names.iter().any(|n| n == name)
{
out.push(cur.rowid()?);
}
ok = cur.next()?;
}
Ok(out)
}
/// Seek a `WITHOUT ROWID` table's clustered PRIMARY KEY b-tree for the rows
/// whose leading PK columns the `WHERE` constrains by equality (`… WHERE
/// pk = ?`), instead of scanning. The b-tree entries are the rows themselves,
/// stored PK-first, so an equality-prefix seek yields them directly.
/// `run_core` re-applies the full `WHERE`, so returning a superset is fine.
/// Returns `None` (→ caller scans) when no leading-PK equality is usable.
fn try_without_rowid_pk_seek(
&self,
meta: &TableMeta,
sel: &Select,
params: &Params,
) -> Result<Option<Vec<InputRow>>> {
let Some(where_expr) = &sel.where_clause else {
return Ok(None);
};
let hint = sel.from.as_ref().and_then(|f| f.first.index_hint.as_ref());
if matches!(hint, Some(IndexHint::NotIndexed)) {
return Ok(None);
}
let pk = &meta.storage_order[..meta.pk_len];
if pk.is_empty() {
return Ok(None);
}
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
// Build the seek key from the longest leading-PK prefix the WHERE
// constrains by `= const`, with the b-tree's storage collations.
let storage_colls = wr_storage_collations(meta);
let mut key = Vec::new();
let mut colls = Vec::new();
for (i, &c) in pk.iter().enumerate() {
let Some((_, v)) = eqs.iter().find(|(col, _)| *col == c) else {
break;
};
if matches!(v, Value::Null) {
break; // PK columns are NOT NULL; `pk = NULL` matches nothing
}
key.push(meta.columns[c].affinity.coerce(v.clone()));
colls.push(storage_colls[i]);
}
if key.is_empty() {
return Ok(None);
}
// Seek the clustered PK b-tree with the same per-column directions it was
// written with, truncated to the seeked key prefix (`&[]` when all-asc).
let all_descs = meta.pk_descs();
let descs: &[bool] = if all_descs.is_empty() {
&[]
} else {
&all_descs[..key.len()]
};
let records = crate::btree::index_seek_records(
self.backend.source(),
meta.root,
&key,
&colls,
descs,
)?;
let mut out = Vec::with_capacity(records.len());
for storage in records {
let mut row = unpermute_row(meta, storage);
self.compute_generated(meta, &mut row, params)?;
out.push(InputRow {
values: row,
rowid: None,
});
}
Ok(Some(out))
}
/// IN-list / same-column equality OR-chain variant of
/// [`try_without_rowid_pk_seek`](Self::try_without_rowid_pk_seek): a
/// `k IN (a, b, …)` (or the equivalent `k = a OR k = b OR …`, which
/// [`find_in_constraint`] collapses to the same shape) on the *leading* PK column
/// seeks the clustered b-tree once per distinct value instead of scanning. Each
/// distinct leading-PK value addresses a disjoint slice of the b-tree, so the
/// concatenation is duplicate-free (repeated list values are de-duplicated); a
/// superset is fine regardless, since `run_core` re-applies the full `WHERE`.
/// Declines (→ scan) when the IN column is not the leading PK column, when any
/// value is `NULL` (never a usable key — mirrors [`Self::try_index_in`]), or under
/// a `NOT INDEXED` hint.
fn try_without_rowid_pk_in(
&self,
meta: &TableMeta,
sel: &Select,
params: &Params,
) -> Result<Option<Vec<InputRow>>> {
let Some(where_expr) = &sel.where_clause else {
return Ok(None);
};
let hint = sel.from.as_ref().and_then(|f| f.first.index_hint.as_ref());
if matches!(hint, Some(IndexHint::NotIndexed)) {
return Ok(None);
}
let pk = &meta.storage_order[..meta.pk_len];
let Some(&lead) = pk.first() else {
return Ok(None);
};
let Some((col, values)) = find_in_constraint(where_expr, &meta.columns, params) else {
return Ok(None);
};
if col != lead || values.iter().any(|v| matches!(v, Value::Null)) {
return Ok(None);
}
let coll = wr_storage_collations(meta)[0];
let aff = meta.columns[lead].affinity;
// Leading-PK direction: an all-ascending PK passes `&[]`; a DESC leading
// PK seeks the b-tree with the same direction it was written with.
let lead_descs: &[bool] = if meta.pk_descs().is_empty() {
&[]
} else {
&meta.pk_descending[..1]
};
let mut out = Vec::new();
let mut seen: Vec<Value> = Vec::new();
for v in &values {
let key_val = aff.coerce(v.clone());
if seen.contains(&key_val) {
continue;
}
seen.push(key_val.clone());
let records = crate::btree::index_seek_records(
self.backend.source(),
meta.root,
&[key_val],
&[coll],
lead_descs,
)?;
for storage in records {
let mut row = unpermute_row(meta, storage);
self.compute_generated(meta, &mut row, params)?;
out.push(InputRow {
values: row,
rowid: None,
});
}
}
Ok(Some(out))
}
/// Range variant of [`try_without_rowid_pk_seek`](Self::try_without_rowid_pk_seek):
/// a `< / <= / > / >= / BETWEEN` bound on the *leading* PK column walks the
/// clustered b-tree between bounds instead of scanning. A superset is fine
/// (`run_core` re-applies the full `WHERE`). Returns `None` (→ scan) when the
/// leading PK column has no range bound.
fn try_without_rowid_pk_range(
&self,
meta: &TableMeta,
sel: &Select,
params: &Params,
) -> Result<Option<Vec<InputRow>>> {
let Some(where_expr) = &sel.where_clause else {
return Ok(None);
};
let hint = sel.from.as_ref().and_then(|f| f.first.index_hint.as_ref());
if matches!(hint, Some(IndexHint::NotIndexed)) {
return Ok(None);
}
let pk = &meta.storage_order[..meta.pk_len];
let Some(&lead) = pk.first() else {
return Ok(None);
};
// A range on a DESC leading PK column would need the value-space bounds
// swapped into key-sort space; mirroring the secondary-index DESC-range
// deferral in a139244, decline and fall back to a scan (still correct via
// `run_core`'s WHERE re-filter).
if meta.pk_descending.first().copied().unwrap_or(false) {
return Ok(None);
}
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
let Some(b) = ranges.get(&lead) else {
return Ok(None);
};
let aff = meta.columns[lead].affinity;
let coll = wr_storage_collations(meta)[0];
let lower = b.lower.as_ref().map(|(v, i)| (aff.coerce(v.clone()), *i));
let upper = b.upper.as_ref().map(|(v, i)| (aff.coerce(v.clone()), *i));
let colls = [coll];
let lower_arg = lower
.as_ref()
.map(|(v, inc)| (core::slice::from_ref(v), *inc));
let upper_arg = upper
.as_ref()
.map(|(v, inc)| (core::slice::from_ref(v), *inc));
let records = crate::btree::index_range_records(
self.backend.source(),
meta.root,
lower_arg,
upper_arg,
&colls,
&[],
)?;
let mut out = Vec::with_capacity(records.len());
for storage in records {
let mut row = unpermute_row(meta, storage);
self.compute_generated(meta, &mut row, params)?;
out.push(InputRow {
values: row,
rowid: None,
});
}
Ok(Some(out))
}
/// Seek a *secondary* index of a WITHOUT ROWID table on an equality of its
/// leading column(s). A WITHOUT ROWID index record is `(indexed cols…, PK
/// cols…)`, so when the index plus the PK covers every referenced column the
/// row is read straight from the index record; otherwise the PK columns from
/// each record seek the clustered b-tree for the full row. `run_core`
/// re-applies the full WHERE, so a superset is fine.
fn try_without_rowid_index_seek(
&self,
meta: &TableMeta,
table_name: &str,
sel: &Select,
params: &Params,
) -> Result<Option<Vec<InputRow>>> {
let Some(where_expr) = &sel.where_clause else {
return Ok(None);
};
let hint = sel.from.as_ref().and_then(|f| f.first.index_hint.as_ref());
if matches!(hint, Some(IndexHint::NotIndexed)) {
return Ok(None);
}
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
eqs.retain(|(_, v)| !matches!(v, Value::Null));
// `col IS NULL` is a seekable NULL-key equality (see `collect_isnull_cols`),
// mirroring the rowid-table seek in `try_index_lookup`.
let mut is_null_cols: Vec<usize> = Vec::new();
collect_isnull_cols(where_expr, &meta.columns, &mut is_null_cols);
if eqs.is_empty() && is_null_cols.is_empty() {
return Ok(None);
}
let pk: Vec<usize> = meta.storage_order[..meta.pk_len].to_vec();
let indexes = self.indexes_of(table_name)?;
if let Some(IndexHint::IndexedBy(n)) = hint
&& !indexes.iter().any(|i| i.name.eq_ignore_ascii_case(n))
{
return Err(Error::Error(alloc::format!("no such index: {n}")));
}
let src = self.backend.source();
for idx in &indexes {
if let Some(IndexHint::IndexedBy(n)) = hint
&& !idx.name.eq_ignore_ascii_case(n)
{
continue;
}
if idx.partial.is_some() || idx.key_exprs.is_some() {
continue;
}
// Equality prefix over the index's leading columns (a `col IS NULL`
// contributes a NULL key component).
let mut key = Vec::new();
let mut colls = Vec::new();
for (i, &c) in idx.cols.iter().enumerate() {
if let Some((_, v)) = eqs.iter().find(|(col, _)| *col == c) {
key.push(meta.columns[c].affinity.coerce(v.clone()));
} else if is_null_cols.contains(&c) {
key.push(Value::Null);
} else {
break;
}
colls.push(idx.collations.get(i).copied().unwrap_or_default());
}
if key.is_empty() {
continue;
}
let records =
crate::btree::index_seek_records(src, idx.root, &key, &colls, idx.seek_descs())?;
let covering = self.wr_index_covers(idx, &pk, meta, sel, where_expr);
return Ok(Some(
self.wr_index_rows(meta, idx, &pk, records, covering, params)?,
));
}
Ok(None)
}
/// Whether a WITHOUT ROWID secondary index covers the query (so its rows can
/// be read straight from the index records). A *named* index counts as holding
/// its columns plus the trailing PK columns; an implicit UNIQUE/PK autoindex
/// (`sqlite_autoindex_*`) counts only its own — matching SQLite's `COVERING
/// INDEX` vs `INDEX` wording.
fn wr_index_covers(
&self,
idx: &IndexMeta,
pk: &[usize],
meta: &TableMeta,
sel: &Select,
where_expr: &Expr,
) -> bool {
let mut avail = idx.cols.clone();
if !idx.name.starts_with("sqlite_autoindex_") {
for &p in pk {
if !avail.contains(&p) {
avail.push(p);
}
}
}
self.seek_index_covers(sel, meta, &avail, where_expr)
}
/// Build rows from a WITHOUT ROWID secondary index's seeked/scanned records.
/// Each record is `(indexed cols…, trailing PK cols…)`, where the trailing PK
/// is deduplicated against the index key columns (SQLite's `isDupColumn`; see
/// [`wr_trailing_pk`]) — so a PK column that overlaps an index key column
/// appears only in the key part, not repeated at the tail. When `covering`,
/// reconstruct the referenced columns straight from the record (the rest are
/// unreferenced, left NULL); otherwise the PK columns (read from wherever they
/// live in the record) seek the clustered b-tree for the full row.
fn wr_index_rows(
&self,
meta: &TableMeta,
idx: &IndexMeta,
pk: &[usize],
records: Vec<Vec<Value>>,
covering: bool,
params: &Params,
) -> Result<Vec<InputRow>> {
// Where each PK column's value lives inside a record: overlapping PK
// columns are found among the leading index-key columns; the rest are the
// trailing (deduped) PK columns, in order after the index key.
let (trailing_pk, ..) = wr_trailing_pk(&idx.cols, &idx.collations, pk, meta);
let pk_slot = |pc: usize| -> usize {
if let Some(t) = trailing_pk.iter().position(|&c| c == pc) {
idx.cols.len() + t
} else {
// Overlaps a key column with the same collation: read it there.
idx.cols.iter().position(|&c| c == pc).unwrap_or(0)
}
};
let mut out = Vec::with_capacity(records.len());
if covering {
for rec in &records {
let mut values = alloc::vec![Value::Null; meta.columns.len()];
for (i, &mc) in idx.cols.iter().enumerate() {
values[mc] = rec[i].clone();
}
for &pc in pk {
values[pc] = rec[pk_slot(pc)].clone();
}
promote_real_columns(meta, &mut values);
out.push(InputRow {
values,
rowid: None,
});
}
} else {
let src = self.backend.source();
let pk_colls: Vec<crate::value::Collation> =
wr_storage_collations(meta)[..pk.len()].to_vec();
// Full-PK seek into the clustered b-tree: match its stored directions.
let pk_descs = meta.pk_descs().to_vec();
for rec in &records {
let pk_key: Vec<Value> = pk.iter().map(|&pc| rec[pk_slot(pc)].clone()).collect();
for storage in
crate::btree::index_seek_records(src, meta.root, &pk_key, &pk_colls, &pk_descs)?
{
let mut row = unpermute_row(meta, storage);
self.compute_generated(meta, &mut row, params)?;
out.push(InputRow {
values: row,
rowid: None,
});
}
}
}
Ok(out)
}
/// Range variant of [`try_without_rowid_index_seek`](Self::try_without_rowid_index_seek):
/// a bound on the *leading* column of a WITHOUT ROWID secondary index walks
/// the index between bounds (covering or PK-fetching, as above).
fn try_without_rowid_index_range(
&self,
meta: &TableMeta,
table_name: &str,
sel: &Select,
params: &Params,
) -> Result<Option<Vec<InputRow>>> {
let Some(where_expr) = &sel.where_clause else {
return Ok(None);
};
let hint = sel.from.as_ref().and_then(|f| f.first.index_hint.as_ref());
if matches!(hint, Some(IndexHint::NotIndexed)) {
return Ok(None);
}
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
if ranges.is_empty() {
return Ok(None);
}
let pk: Vec<usize> = meta.storage_order[..meta.pk_len].to_vec();
let indexes = self.indexes_of(table_name)?;
if let Some(IndexHint::IndexedBy(n)) = hint
&& !indexes.iter().any(|i| i.name.eq_ignore_ascii_case(n))
{
return Err(Error::Error(alloc::format!("no such index: {n}")));
}
for idx in &indexes {
if let Some(IndexHint::IndexedBy(n)) = hint
&& !idx.name.eq_ignore_ascii_case(n)
{
continue;
}
if idx.partial.is_some() || idx.key_exprs.is_some() {
continue;
}
let Some(&lead) = idx.cols.first() else {
continue;
};
let Some(b) = ranges.get(&lead) else {
continue;
};
let aff = meta.columns[lead].affinity;
let coll = idx.collations.first().copied().unwrap_or_default();
// When the leading index column is stored DESC, value order is reversed
// in key-sort space: swap the value-space lower/upper bounds so they
// become the stored-space lower/upper (inclusivity travels with its
// bound), and tell the b-tree the column is descending via
// `idx.seek_descs()`. See the rowid secondary-index range path and
// `prefix_cmp`'s per-column reversal.
let lead_desc = idx.descending.first().copied().unwrap_or(false);
let (val_lower, val_upper) = if lead_desc {
(b.upper.as_ref(), b.lower.as_ref())
} else {
(b.lower.as_ref(), b.upper.as_ref())
};
let lower = val_lower.map(|(v, i)| (aff.coerce(v.clone()), *i));
let upper = val_upper.map(|(v, i)| (aff.coerce(v.clone()), *i));
let colls = [coll];
let lower_arg = lower
.as_ref()
.map(|(v, inc)| (core::slice::from_ref(v), *inc));
let upper_arg = upper
.as_ref()
.map(|(v, inc)| (core::slice::from_ref(v), *inc));
let records = crate::btree::index_range_records(
self.backend.source(),
idx.root,
lower_arg,
upper_arg,
&colls,
idx.seek_descs(),
)?;
let covering = self.wr_index_covers(idx, &pk, meta, sel, where_expr);
return Ok(Some(
self.wr_index_rows(meta, idx, &pk, records, covering, params)?,
));
}
Ok(None)
}
/// Choose the secondary index a `col = const` / `col IS NULL` equality-prefix
/// seek should use, applying SQLite 3.50.4's cost tiebreaks. Shared by
/// [`try_index_lookup`](Self::try_index_lookup) (which performs the seek) and
/// [`eqp_access`](Self::eqp_access) (which reports it) so the two can never
/// disagree about the index name. Returns the chosen [`IndexMeta`] together
/// with the matched leading-prefix length, or `None` when no plain index seeks
/// the prefix.
///
/// Candidate = a non-partial, non-expression index whose leading column(s) are
/// all equality/`IS NULL`-constrained (a `matched` prefix ≥ 1). The ordering,
/// best-first, mirrors what probing `sqlite3` 3.50.4 produces:
///
/// 1. **`est` ascending** — the `sqlite_stat1` avg-eq at the matched prefix
/// when statistics exist, else a sentinel (`u64::MAX - matched.len()`) that
/// makes a *longer* matched prefix (more selective) win. Selectivity always
/// dominates, so a stat-driven or longer-prefix choice is never overridden
/// by the covering/width tiebreaks below (conservative: mixed stat/covering
/// interactions keep the pre-existing selectivity behavior).
/// 2. At equal `est`, a **query-covering** index (holds every referenced column
/// — or the rowid, present in every index record — so the table b-tree
/// lookup is skipped) beats a non-covering one, even if wider.
/// 3. Among equal-`est` covering candidates, the **narrower** estimated key
/// width wins (same `szEst`/`LogEst` width model as
/// [`covering_scan`](Self::covering_scan)).
/// 4. Final tiebreak: the **newest** index (highest rootpage) — SQLite
/// considers indexes newest-first and keeps the first of an equal cost.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn choose_seek_index(
&self,
sel: Option<&Select>,
meta: &TableMeta,
table_name: &str,
where_expr: &Expr,
eqs: &[(usize, Value, crate::value::Collation)],
is_null_cols: &[usize],
hint: Option<&IndexHint>,
) -> Result<Option<(IndexMeta, usize)>> {
let stats = self.stat1_map();
// Per-index estimated key width, in `LogEst` units, for the covering
// width tiebreak (identical model to `covering_scan`): Σ szEst(key col) + 1
// (the trailing rowid), then `logest(width * 4)`.
let szests = self.table_col_szests(table_name).unwrap_or_default();
let width_of = |idx: &IndexMeta| -> i16 {
let w: u32 = idx
.cols
.iter()
.map(|&c| szests.get(c).copied().unwrap_or(1))
.sum::<u32>()
+ 1;
logest(u64::from(w) * 4)
};
// The ordering key for a candidate, compared best-first. `est` ascending is
// primary; then covering (false < true, so negate); then width ascending;
// then newest (root descending, so negate).
#[allow(clippy::type_complexity)]
let mut best: Option<(
(u64, bool, i16, core::cmp::Reverse<u32>),
IndexMeta,
usize,
)> = None;
// For the winning candidate, remember whether the estimate came from
// sqlite_stat4 samples (`Some(matched_rows)`), whether it covers, and its
// key width — the inputs to the scan-vs-seek cost comparison below.
let mut best_stat4: Option<(u64, bool, i16)> = None;
for idx in self.indexes_of(table_name)? {
if let Some(IndexHint::IndexedBy(n)) = hint
&& !idx.name.eq_ignore_ascii_case(n)
{
continue;
}
if idx.partial.is_some() || idx.key_exprs.is_some() {
continue;
}
let mut matched = 0usize;
for (i, &c) in idx.cols.iter().enumerate() {
// An equality serves this index column only when its effective
// collation equals the index's stored collation for the column
// (B9j) — so a `NOCASE` index serves `= 'x' COLLATE NOCASE` while a
// `BINARY` index does not. `col IS NULL` is a collation-independent
// NULL-key seek.
let idx_coll = idx.collations.get(i).copied().unwrap_or_default();
if eqs
.iter()
.any(|(col, _, coll)| *col == c && *coll == idx_coll)
|| is_null_cols.contains(&c)
{
matched += 1;
} else {
break;
}
}
if matched == 0 {
continue;
}
let stat1_row = stats.get(&idx.name);
let est = stat1_row
.and_then(|s| s.get(matched).copied())
.unwrap_or(u64::MAX - matched as u64);
// STAT4 refinement: when this index has stat1 statistics *and*
// sqlite_stat4 samples, and the whole matched leading prefix is
// equality- (or `IS NULL`-) constrained by *known* values, replace
// the stat1 average-eq estimate with the value-specific one from the
// samples — exactly what sqlite's `whereEqualScanEst` does. Only the
// stat1-present branch is touched, so databases without ANALYZE keep
// the sentinel behaviour byte-for-byte.
let stat4_est = stat1_row.and_then(|ai_row_est| {
self.stat4_equal_est(&idx, matched, eqs, is_null_cols, ai_row_est)
});
let est = stat4_est.unwrap_or(est);
// A covering candidate holds every referenced column; `seek_index_covers`
// makes the same decision the render uses. Without an enclosing `SELECT`
// (DELETE/UPDATE/OR-disjunct) nothing covers.
let covering = sel
.map(|s| self.seek_index_covers(s, meta, &idx.cols, where_expr))
.unwrap_or(false);
let width = width_of(&idx);
// Sort key: est asc, covering-first (`!covering` asc), narrower width
// asc, newest (root desc). Width models sqlite's per-row index cost
// (a narrower index is cheaper to walk for the same matched rows), and
// ties fall to the newest index. `Ord` on the tuple with `min` picks
// the best. (A rare cost-model corner sqlite's full LogEst formula
// decides differently — a non-covering seek over an all-untyped-column
// table where the wider composite wins — is left as-is; it is not a
// regression and matching it needs the data/type-driven row-cost port.)
let key = (est, !covering, width, core::cmp::Reverse(idx.root));
let take = match &best {
None => true,
Some((bk, _, _)) => key < *bk,
};
if take {
best = Some((key, idx, matched));
best_stat4 = stat4_est.map(|e| (e, covering, width));
}
}
let Some((_, idx, matched)) = best else {
return Ok(None);
};
// Scan-vs-search: sqlite compares the full-table-scan cost against the
// index-seek cost and picks the cheaper. When the chosen index is
// NON-COVERING and its stat4 estimate says the equality matches a large
// fraction of the table, the per-matched-row table lookup makes a full
// scan cheaper — sqlite renders `SCAN`, not `SEARCH`. Only fire when the
// estimate is stat4-backed (so no-stats / stat1-only tables are byte-
// identical to before) and the index is non-covering (a covering seek
// needs no table lookup, so a scan never wins). Returning `None` routes
// both `try_index_lookup` and `eqp_access` to their SCAN paths.
//
// Restricted to an enclosing `SELECT` (`sel` present): a DELETE/UPDATE
// plans under `WHERE_ONEPASS_DESIRED`, where sqlite's full-scan-via-index
// cost branch is suppressed and the row-visiting seek is kept regardless
// of selectivity — so those keep their prior always-SEARCH behavior.
if let Some((est_rows, covering, width)) = best_stat4
&& sel.is_some()
&& !covering
&& self.full_scan_beats_seek(table_name, meta, est_rows, width)
{
return Ok(None);
}
Ok(Some((idx, matched)))
}
/// Port of sqlite's full-table-scan vs non-covering-index-seek cost
/// comparison (`whereLoopAddBtree`): returns true when a full table scan is
/// no more expensive than seeking `est_rows` rows through a non-covering
/// index of LogEst key-width `idx_width`, requiring one table lookup per
/// matched row.
///
/// Both costs are computed in LogEst units exactly as sqlite does. The full
/// scan (rowid table, STAT4 present) costs `rRun = rSize + 14`. The index seek
/// costs `rCostIdx = LogEstAdd(rLogSize, nOut + 1 + 15*szIdxRow/szTabRow)`, then
/// `rRun = LogEstAdd(rCostIdx, nOut + 16)` for the per-row table lookups — where
/// `rSize = LogEst(nRow)`, `rLogSize = estLog(rSize)`, `nOut = LogEst(est_rows)`.
/// Only reached with STAT4 data, so the `-2` STAT4 scan discount always applies.
///
/// The scan wins only when its `rRun` is strictly lower than the seek's: on a
/// `rRun` tie both loops survive `whereLoopFindLesser` (neither dominates — the
/// seek has the smaller `nOut`) and `wherePathSolver` then keeps the seek because
/// its lower output row count yields the lower downstream path cost. So a tie
/// goes to the SEARCH, matching sqlite.
fn full_scan_beats_seek(
&self,
table_name: &str,
meta: &TableMeta,
est_rows: u64,
idx_width: i16,
) -> bool {
// WITHOUT ROWID tables never seek a secondary index in this planner, and
// sqlite's IPK-scan cost model above is rowid-specific; restrict to rowid
// tables (the only ones this branch can be reached for).
if meta.without_rowid {
return false;
}
// nRow: the table's row count from stat1 (the index row's leading value).
// Without it there is no scan cost to compare, so keep the seek.
let Some(n_row) = self.table_stat1_rows(table_name) else {
return false;
};
if n_row == 0 {
return false;
}
let r_size = logest(n_row); // LogEst(nRow)
let r_log_size = est_log(r_size);
let n_out = logest(est_rows.max(1)); // LogEst(matched rows)
// Table row width (szTabRow) = LogEst((Σ szEst(col) + 1) * 4).
let szests = self.table_col_szests(table_name).unwrap_or_default();
let w_tab: u32 = szests.iter().copied().sum::<u32>() + 1;
let sz_tab_row = logest(u64::from(w_tab) * 4).max(1) as i32;
// The index key width is passed in already as LogEst((Σ szEst(key)+1)*4).
let sz_idx_row = idx_width as i32;
// Full-scan cost (rowid IPK, STAT4 present): rSize + 16 - 2.
let scan = r_size + 14;
// Non-covering index-seek cost.
let per_row = 1 + (15 * sz_idx_row) / sz_tab_row;
let r_cost_idx = logest_add(r_log_size, n_out + per_row as i16);
let seek = logest_add(r_cost_idx, n_out + 16);
scan < seek
}
/// Range analogue of [`Self::full_scan_beats_seek`]: decide whether sqlite
/// renders `SCAN` instead of a range `SEARCH` on the chosen NON-COVERING
/// index. The matched-row estimate `n_out` is the STAT4 `whereRangeScanEst`
/// output (a LogEst, fed directly — no lossy round-trip through a row count).
/// `n_bounds` is the number of present range bounds (1 for `>`/`<`, 2 for a
/// two-sided range).
///
/// This replicates the relevant slice of sqlite's `whereLoopAddBtree` +
/// `whereLoopFindLesser` + `wherePathSolver`. Both loops' `(rRun, nOut)` are
/// computed the way sqlite does — the full scan's `nOut` is `rSize` reduced by
/// one LogEst per range term (`whereLoopOutputAdjust`, `pLoop->nOut--` for a
/// non-EQ term) — and the same domination test decides the winner:
/// * scan dominates seek (`scan.rRun<=seek.rRun && scan.nOut<=seek.nOut`) →
/// the seek is discarded, so `SCAN`;
/// * otherwise both survive and `wherePathSolver` keeps the lower `rRun`
/// (`SEARCH` on a tie, since the seek has the smaller `nOut`).
///
/// (The covering case — where a rejected covering seek falls back to a plain
/// table scan — is handled by the caller, which excludes covering indexes; see
/// the note in `choose_range_index`.)
fn full_scan_beats_range(
&self,
table_name: &str,
meta: &TableMeta,
n_out: i16,
idx_width: i16,
n_bounds: i16,
) -> bool {
if meta.without_rowid {
return false;
}
let Some(n_row) = self.table_stat1_rows(table_name) else {
return false;
};
if n_row == 0 {
return false;
}
let r_size = logest(n_row); // LogEst(nRow)
let r_log_size = est_log(r_size);
// Table row width (szTabRow) = LogEst(Σ szEst(col) * 4), plus a +1 for the
// implicit rowid ONLY when the table has no INTEGER PRIMARY KEY column
// (sqlite's `estimateTableWidth`: `if( pTab->iPKey<0 ) wTable++`).
let szests = self.table_col_szests(table_name).unwrap_or_default();
let mut w_tab: u32 = szests.iter().copied().sum::<u32>();
if meta.ipk.is_none() {
w_tab += 1;
}
let sz_tab_row = logest(u64::from(w_tab) * 4).max(1) as i32;
let sz_idx_row = idx_width as i32;
// Full-scan loop: rRun = rSize + 16 - 2 (STAT4), nOut = rSize reduced by
// one LogEst per range term (whereLoopOutputAdjust's `pLoop->nOut--`).
let scan_run = r_size + 14;
let scan_out = r_size - n_bounds;
// Non-covering index-seek loop: rCostIdx = LogEstAdd(rLogSize, nOut + 1 +
// 15*szIdx/szTab), then + (nOut + 16) for the per-matched-row table lookup.
let per_row = 1 + (15 * sz_idx_row) / sz_tab_row;
let r_cost_idx = logest_add(r_log_size, n_out + per_row as i16);
let seek_run = logest_add(r_cost_idx, n_out + 16);
let seek_out = n_out;
// whereLoopFindLesser: the earlier-inserted full scan discards the seek
// template when it is no worse on BOTH cost and output rows.
if scan_run <= seek_run && scan_out <= seek_out {
return true;
}
// Otherwise both survive; the solver keeps the lower rRun. On a tie the
// seek wins (its smaller nOut lowers the downstream path cost), so the
// scan wins only strictly.
scan_run < seek_run
}
/// The table's estimated row count from `sqlite_stat1` (the leading integer
/// of any index row for `table`, all of which share the table's row count),
/// or `None` when the table has no `sqlite_stat1` data.
fn table_stat1_rows(&self, table: &str) -> Option<u64> {
self.schema.table("sqlite_stat1")?;
let meta = self.table_meta("sqlite_stat1", None).ok()?;
let rows = self.scan_table(&meta).ok()?;
for (_, vals) in rows {
if let Some(Value::Text(tbl)) = vals.first()
&& tbl == table
&& let Some(Value::Text(stat)) = vals.get(2)
&& let Some(n) = stat.split_whitespace().next().and_then(|t| t.parse().ok())
{
return Some(n);
}
}
None
}
/// STAT4-driven equality selectivity estimate for `idx` when its leading
/// `matched` columns are all equality- or `IS NULL`-constrained by known
/// values. Builds the probe record from those constraints and runs sqlite's
/// `whereEqualScanEst` (`stat4::equal_scan_est`) against the index's
/// `sqlite_stat4` samples. Returns the value-specific estimated row count, or
/// `None` when no stat4 samples exist for the index or the probe cannot be
/// formed (leaving the caller on its stat1 average-eq estimate).
///
/// `ai_row_est` is the index's `sqlite_stat1` integer list (`[nRow, avgEq_1,
/// …]`), used by `initAvgEq` for the non-matching-sample fallback.
fn stat4_equal_est(
&self,
idx: &IndexMeta,
matched: usize,
eqs: &[(usize, Value, crate::value::Collation)],
is_null_cols: &[usize],
ai_row_est: &[u64],
) -> Option<u64> {
// Build the probe: the value bound to each of the leading `matched` index
// key columns, in index-column order. A column constrained by `IS NULL`
// probes with NULL; otherwise the `col = value` constant.
let mut rec: Vec<Value> = Vec::with_capacity(matched);
for &c in idx.cols.iter().take(matched) {
if let Some((_, v, _)) = eqs.iter().find(|(col, _, _)| *col == c) {
rec.push(v.clone());
} else if is_null_cols.contains(&c) {
rec.push(Value::Null);
} else {
return None;
}
}
if rec.is_empty() {
return None;
}
let (samples, n_sample_col) = self.stat4_samples(&idx.name)?;
// Guard: the sample record must have at least as many columns as the
// probe (it always does — key cols + trailing rowid/pk), and the probe
// must not exceed the key columns (we only equality-probe key columns).
if n_sample_col == 0 || rec.len() > n_sample_col {
return None;
}
let n_key_col = idx.cols.len();
let colls = &idx.collations;
let descs = &idx.descending;
crate::exec::stat4::equal_scan_est(
samples,
n_sample_col,
n_key_col,
ai_row_est,
&rec,
colls,
descs,
)
}
/// Estimated key width of `idx` in `LogEst` units, for the covering-index
/// width tiebreak — the same model `covering_scan` / `choose_seek_index` use:
/// Σ szEst(key col) + 1 (the trailing rowid), then `logest(width * 4)`.
fn index_seek_width(&self, table_name: &str, idx: &IndexMeta) -> i16 {
let szests = self.table_col_szests(table_name).unwrap_or_default();
let w: u32 = idx
.cols
.iter()
.map(|&c| szests.get(c).copied().unwrap_or(1))
.sum::<u32>()
+ 1;
logest(u64::from(w) * 4)
}
/// Pick the plain secondary index for a *range-leading* seek (a range bound on
/// the index's leading column), preferring a query-covering index over a
/// non-covering one (it skips the table b-tree lookup) and, among covering
/// candidates, the narrower one (ties → newest). Non-covering candidates keep
/// first-encountered order — sqlite's choice among several non-covering
/// same-prefix indexes is the full data/type-driven cost model, so we only add
/// the clear covering preference here. `try_index_range` and `eqp_access` both
/// call this so the executed seek and its EQP render never disagree.
fn choose_range_index(
&self,
sel: Option<&Select>,
meta: &TableMeta,
table_name: &str,
where_expr: &Expr,
ranges: &alloc::collections::BTreeMap<usize, RangeBound>,
hint: Option<&IndexHint>,
) -> Result<Option<IndexMeta>> {
// Sort key, best (min) first: covering before non-covering (`!covering`),
// then — *only among covering* — narrower width and newest (`Reverse(root)`).
// Non-covering candidates all share the (true, 0, Reverse(0)) key, so the
// first-encountered one is kept.
type RangeKey = (bool, i16, core::cmp::Reverse<u32>);
let mut best: Option<IndexMeta> = None;
let mut best_key: Option<RangeKey> = None;
// Number of plain range-leading candidate indexes: the scan-vs-seek gate
// below only fires when there is exactly one, since sqlite's choice among
// several range-leading indexes is the full cost model this planner does
// not port (it may prefer a *different, more selective* index rather than
// fall back to a scan).
let mut n_candidates = 0usize;
for idx in self.indexes_of(table_name)? {
if let Some(IndexHint::IndexedBy(n)) = hint
&& !idx.name.eq_ignore_ascii_case(n)
{
continue;
}
if idx.partial.is_some() || idx.key_exprs.is_some() {
continue;
}
let Some(&lead) = idx.cols.first() else {
continue;
};
if !ranges.contains_key(&lead) {
continue;
}
// The range bound serves this index only when its effective collation
// matches the index's leading-column collation (B9j).
let lead_coll = idx.collations.first().copied().unwrap_or_default();
if range_collation(where_expr, &meta.columns, lead) != Some(lead_coll) {
continue;
}
n_candidates += 1;
let covering = sel
.map(|s| self.seek_index_covers(s, meta, &idx.cols, where_expr))
.unwrap_or(false);
let key: RangeKey = if covering {
(
false,
self.index_seek_width(table_name, &idx),
core::cmp::Reverse(idx.root),
)
} else {
(true, 0, core::cmp::Reverse(0))
};
if best_key.is_none_or(|bk| key < bk) {
best_key = Some(key);
best = Some(idx);
}
}
// Scan-vs-search for a range seek — the range analogue of the equality
// branch in `choose_seek_index`. When the chosen NON-COVERING index's
// STAT4 range estimate (a faithful `whereRangeScanEst`) says the bound
// selects a large fraction of the table, the per-matched-row table lookup
// makes a full scan cheaper, so sqlite renders `SCAN`, not `SEARCH`.
// Returning `None` routes both `try_index_range` and `eqp_access` onto
// their scan paths. Gated exactly like the equality case: only with STAT4
// data (so no-stats / stat1-only databases are byte-identical to today),
// only for a non-covering plain index, and only inside an enclosing
// `SELECT` (a DELETE/UPDATE keeps its row-visiting seek).
//
// The covering case is deliberately excluded: when sqlite's cost model
// rejects a *covering* range seek it falls back to a plain table `SCAN t`,
// but graphite's separate covering-scan optimization would still render
// `SCAN … USING COVERING INDEX` there (a pre-existing, orthogonal
// divergence that also shows up on a WHERE-less `SELECT <indexed-col>`).
// Restricting to non-covering keeps this change from interacting with
// that path, so no new diffs are introduced.
//
// Also skip the gate under an `INDEXED BY` hint (which forces the seek —
// sqlite never falls back to a scan) and when more than one range-leading
// index competes (sqlite may seek a different, more selective one).
if let Some(idx) = &best
&& sel.is_some()
&& n_candidates == 1
&& !matches!(hint, Some(IndexHint::IndexedBy(_)))
{
let lead = idx.cols[0];
let covering = sel
.map(|s| self.seek_index_covers(s, meta, &idx.cols, where_expr))
.unwrap_or(false);
let bound = &ranges[&lead];
let n_bounds = (bound.lower.is_some() as i16) + (bound.upper.is_some() as i16);
if !covering && let Some(n_out) = self.stat4_range_est(idx, bound) {
let width = self.index_seek_width(table_name, idx);
if self.full_scan_beats_range(table_name, meta, n_out, width, n_bounds) {
return Ok(None);
}
}
}
Ok(best)
}
/// STAT4-driven range selectivity estimate for `idx` when its leading key
/// column has the range bound `bound`. Builds the lower/upper probe values
/// (swapping them for a DESC leading column, as sqlite does) and runs the
/// `nEq == 0` STAT4 path of `whereRangeScanEst` (`stat4::range_scan_est`),
/// returning the estimated output-row count as a **LogEst** (`pLoop->nOut`) —
/// or `None` when no stat4 samples exist for the index or neither bound has a
/// known literal value.
///
/// The LogEst arithmetic that follows the sample lookup (`LogEst(iUpper -
/// iLower)`, the same-sample 4× discount, the per-extracted-bound `nOut--`,
/// and the final `if(nNew<nOut) nOut=nNew`) is ported verbatim from
/// `whereRangeScanEst` so the resulting estimate matches sqlite 3.50.4. Since
/// every present literal bound is extracted from the samples here, the
/// post-block `whereRangeAdjust` / closed-range −20 (which only apply to
/// bounds sqlite could *not* extract) never fire — matching sqlite.
fn stat4_range_est(&self, idx: &IndexMeta, bound: &RangeBound) -> Option<i16> {
if idx.cols.is_empty() {
return None;
}
let ai_row_est = self.stat1_map();
let ai_row_est = ai_row_est.get(&idx.name)?;
let (samples, n_sample_col) = self.stat4_samples(&idx.name)?;
if n_sample_col == 0 {
return None;
}
let n_key_col = idx.cols.len();
let colls = &idx.collations;
let descs = &idx.descending;
let lead_desc = idx.descending.first().copied().unwrap_or(false);
// A DESC leading column reverses value order in key space, so the value-
// space bounds swap roles (sqlite's `SWAP(pLower, pUpper)`).
let (lower, upper) = if lead_desc {
(bound.upper.clone(), bound.lower.clone())
} else {
(bound.lower.clone(), bound.upper.clone())
};
if lower.is_none() && upper.is_none() {
return None;
}
let r = crate::exec::stat4::range_scan_est(
samples,
n_sample_col,
n_key_col,
ai_row_est,
lower,
upper,
colls,
descs,
)?;
// Port of the LogEst tail of whereRangeScanEst (nEq == 0 branch). `nOut`
// starts at LogEst(nRowEst0) — the index's row count — since with nEq == 0
// the pre-range estimate is the whole index. Each bound the sampler could
// extract decrements nOut by 1 LogEst (sqlite's `nOut--`).
let mut n_out: i16 = logest(r.n_row_est0.max(1));
n_out -= (r.lower_extracted as i16) + (r.upper_extracted as i16);
// nNew = LogEst(iUpper - iLower), minus 20 (÷4) when both bounds resolved
// to the same sample (the STAT4 "same sample" tuning); LogEst(2) when the
// span collapsed. Then `if(nNew<nOut) nOut=nNew`.
let n_new: i16 = if r.i_upper > r.i_lower {
let mut nn = logest(r.i_upper - r.i_lower);
if r.same_sample {
nn -= 20;
}
nn
} else {
10 // LogEst(2)
};
if n_new < n_out {
n_out = n_new;
}
// With both literal bounds extracted, the post-#ifdef whereRangeAdjust and
// closed-range −20 operate on already-cleared pLower/pUpper (no-ops), and
// the trailing `nOut -= (pLower!=0)+(pUpper!=0)` subtracts 0. So `n_out`
// above is sqlite's final `pLoop->nOut`.
Some(n_out)
}
/// The index metadata (root + indexed column positions) for `table`.
/// Try to satisfy a single-table query with an index equality lookup instead
/// of a full scan: pick the index whose longest leftmost column prefix is
/// covered by `col = const` predicates in the `WHERE`, seek it, and fetch the
/// matching rows by rowid. Returns `None` (→ full scan) if no index applies.
fn try_index_lookup(
&self,
meta: &TableMeta,
table_name: &str,
sel: &Select,
params: &Params,
) -> Result<Option<Vec<InputRow>>> {
let Some(where_expr) = &sel.where_clause else {
return Ok(None);
};
// `NOT INDEXED` forbids any index for this table; `INDEXED BY name`
// restricts to one named index (validated below).
let hint = sel.from.as_ref().and_then(|f| f.first.index_hint.as_ref());
if matches!(hint, Some(IndexHint::NotIndexed)) {
return Ok(None);
}
// `rowid` / `_rowid_` / `oid` `= N` or `IN (list)`: seek the rowid table
// b-tree directly — works with or without an explicit INTEGER PRIMARY KEY
// column, and is cheaper than any secondary index. `INDEXED BY` names a
// specific index, so it forbids this fast path. (`run_core` re-applies the
// full WHERE, so the seeked rows are a valid superset.)
if !matches!(hint, Some(IndexHint::IndexedBy(_)))
&& let Some(mut rowids) =
rowid_seek_constraint(where_expr, &meta.columns, meta.ipk, params)
{
// When a sole `ORDER BY` on the rowid/IPK is satisfied by walking the
// values in ascending rowid order (`in_seek_order`), seek them sorted
// so `run_core` can elide the temp b-tree (it reverses for DESC). The
// recogniser is in lockstep with this sort — both key off the same
// `find_in_constraint` IPK `IN`/OR shape.
if self.in_seek_order(sel, params).is_some() {
rowids.sort_unstable();
}
let encoding = self.backend.source().header().text_encoding;
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut out = Vec::new();
let mut seen: Vec<i64> = Vec::new();
for rid in rowids {
if seen.contains(&rid) {
continue;
}
seen.push(rid);
if cur.seek(rid)? {
let values = self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
out.push(InputRow {
values,
rowid: Some(rid),
});
}
}
return Ok(Some(out));
}
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
// `col IS NULL` is a separate, seekable NULL-key equality (see
// `collect_isnull_cols`). Tracked apart from `eqs` so the rowid/IPK fast
// paths below never seek on it (`rowid IS NULL` scans, as in sqlite).
let mut is_null_cols: Vec<usize> = Vec::new();
collect_isnull_cols(where_expr, &meta.columns, &mut is_null_cols);
if eqs.iter().any(|(_, v)| matches!(v, Value::Null))
|| (eqs.is_empty() && is_null_cols.is_empty())
{
// No usable column equality (`col = NULL` is never true). A plain or
// partial *column* index can't seek, but an *expression* index might
// (e.g. `lower(x) = 'b'` leaves no column eq behind). Try that, then
// let the scan handle the rest.
return self.partial_expr_lookup(meta, table_name, sel, where_expr, params);
}
// Rowid (INTEGER PRIMARY KEY) equality: seek the table b-tree directly
// by rowid. run_core re-applies the full WHERE, so returning the single
// candidate row is a valid superset even when the literal isn't an exact
// integer (e.g. `id = 5.5` seeks rowid 5, then gets filtered out).
// The rowid (INTEGER PRIMARY KEY) is not a named index, so `INDEXED BY`
// forbids this fast path.
if !matches!(hint, Some(IndexHint::IndexedBy(_)))
&& let Some(ipk) = meta.ipk
&& let Some((_, v)) = eqs.iter().find(|(c, _)| *c == ipk)
{
let rid = eval::to_i64(v);
let encoding = self.backend.source().header().text_encoding;
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut out = Vec::new();
if cur.seek(rid)? {
let values = self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
out.push(InputRow {
values,
rowid: Some(rid),
});
}
return Ok(Some(out));
}
// `INDEXED BY name` must name a real index of this table.
if let Some(IndexHint::IndexedBy(n)) = hint
&& !self
.indexes_of(table_name)?
.iter()
.any(|i| i.name.eq_ignore_ascii_case(n))
{
return Err(Error::Error(alloc::format!("no such index: {n}")));
}
// Choose the index to seek via the shared cost tiebreaks (kept in lockstep
// with `eqp_access`, which reports the same choice). Plain column indexes
// take priority; if none seeks the prefix, try a partial or expression
// index whose eligibility we can prove from the `WHERE` structure (see
// `partial_expr_seek`) — this keeps plain-index behavior byte-identical
// while extending seeks to the new index kinds.
// Collation-aware equalities (un-gated, tagged with each comparison's
// effective collation) drive the index choice and the seek key, so a
// `NOCASE` index can serve `= 'x' COLLATE NOCASE` (B9j). The rowid/IPK fast
// path above deliberately keeps the column-collation-gated `eqs`.
let mut eqs_coll = Vec::new();
collect_eq_constraints_coll(where_expr, &meta.columns, params, &mut eqs_coll);
let Some((idx, matched)) = self.choose_seek_index(
Some(sel),
meta,
table_name,
where_expr,
&eqs_coll,
&is_null_cols,
hint,
)?
else {
return self.partial_expr_lookup(meta, table_name, sel, where_expr, params);
};
// Reconstruct the coerced seek key (with per-column collations/DESC flags)
// for the chosen index's matched leading prefix.
let mut key = Vec::with_capacity(matched);
for &c in &idx.cols[..matched] {
if let Some((_, v, _)) = eqs_coll.iter().find(|(col, _, _)| *col == c) {
key.push(meta.columns[c].affinity.coerce(v.clone()));
} else {
// `col IS NULL`: a NULL index key, which the prefix seek matches
// against the index's NULL-keyed entries.
key.push(Value::Null);
}
}
let root = idx.root;
let full_colls = idx.collations.clone();
let idx_cols = idx.cols.clone();
let full_descs = idx.seek_descs().to_vec();
if key.is_empty() {
return Ok(None);
}
// Covering seek: when the chosen index holds every referenced column (the
// result columns, the `WHERE` columns, and any `ORDER BY`), read straight
// from the index — `eqp_access` reports `USING COVERING INDEX` for the
// same decision. Stays in lockstep with the table-fetch path below
// (`run_core` re-applies the full `WHERE` to the superset of index rows).
if self.seek_index_covers(sel, meta, &idx_cols, where_expr) {
return Ok(Some(self.covering_seek_rows(meta, root, &idx_cols)?));
}
// Equality prefix followed by a range on the *next* index column
// (`x=? AND y>?`): extend the exact-prefix seek to a bounded range over
// `[eq…, low] .. [eq…, high]`, matching SQLite (and reported the same way
// by `eqp_access`). Falls through to the plain prefix seek otherwise.
let next_pos = key.len();
// A range on the next index column. When that column is stored DESC, value
// order is reversed in key-sort space, so we (a) tell the b-tree the column
// is descending (`descs` covers `..=next_pos`), and (b) SWAP the value-space
// lower/upper bounds — the value lower bound becomes the stored-space upper
// bound and vice versa (inclusivity travels with its bound). See
// `prefix_cmp`'s per-column reversal.
let next_is_desc = full_descs.get(next_pos).copied().unwrap_or(false);
if let Some(&next_col) = idx_cols.get(next_pos) {
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
if let Some(b) = ranges.get(&next_col) {
let aff = meta.columns[next_col].affinity;
let colls = full_colls[..=next_pos].to_vec();
// Build the stored-space (lower, upper) key/inclusivity from the
// value-space bounds, swapping them for a DESC column.
let (val_lower, val_upper) = if next_is_desc {
(b.upper.as_ref(), b.lower.as_ref())
} else {
(b.lower.as_ref(), b.upper.as_ref())
};
let mut lo_key = key.clone();
let lo_inc = match val_lower {
Some((v, inc)) => {
lo_key.push(aff.coerce(v.clone()));
*inc
}
None => true,
};
let mut hi_key = key.clone();
let hi_inc = match val_upper {
Some((v, inc)) => {
hi_key.push(aff.coerce(v.clone()));
*inc
}
None => true,
};
// The equality prefix may include DESC columns; include the ranged
// next column's stored direction too. Clamp for an auto/expression
// index whose `full_descs` is empty (all ascending).
let descs = &full_descs[..(next_pos + 1).min(full_descs.len())];
let rowids = crate::btree::index_range_rowids(
self.backend.source(),
root,
Some((lo_key.as_slice(), lo_inc)),
Some((hi_key.as_slice(), hi_inc)),
&colls,
descs,
)?;
let encoding = self.backend.source().header().text_encoding;
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut out = Vec::new();
for rid in rowids {
if cur.seek(rid)? {
let values = self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
out.push(InputRow {
values,
rowid: Some(rid),
});
}
}
return Ok(Some(out));
}
}
// The equality prefix consumed every *declared* index column, but a range on
// the table's rowid still seeks: the rowid is the implicit trailing key
// component of every secondary index entry, so `x=? AND rowid>?` bounds the
// `(x, rowid)` range `[eq…, lo] .. [eq…, hi]` (SQLite renders it the same way).
// Superset-safe — `run_core` re-applies the full `WHERE`.
if next_pos == idx_cols.len() && meta.ipk.is_some() {
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
let rowid_bound = meta
.ipk
.and_then(|ipk| ranges.remove(&ipk))
.or_else(|| rowid_alias_range(where_expr, meta, params));
if let Some(b) = rowid_bound {
let mut colls = full_colls[..next_pos].to_vec();
colls.push(crate::value::Collation::default());
let mut lo_key = key.clone();
let lo_inc = match b.lower.as_ref() {
Some((v, inc)) => {
lo_key.push(v.clone());
*inc
}
None => true,
};
let mut hi_key = key.clone();
let hi_inc = match b.upper.as_ref() {
Some((v, inc)) => {
hi_key.push(v.clone());
*inc
}
None => true,
};
// Equality prefix may include DESC columns; the trailing rowid
// component is always ascending (defaults false past the prefix).
// Clamp for an auto index whose `full_descs` is empty.
let descs = &full_descs[..next_pos.min(full_descs.len())];
let rowids = crate::btree::index_range_rowids(
self.backend.source(),
root,
Some((lo_key.as_slice(), lo_inc)),
Some((hi_key.as_slice(), hi_inc)),
&colls,
descs,
)?;
let encoding = self.backend.source().header().text_encoding;
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut out = Vec::new();
for rid in rowids {
if cur.seek(rid)? {
let values = self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
out.push(InputRow {
values,
rowid: Some(rid),
});
}
}
return Ok(Some(out));
}
}
let n = key.len();
// `full_descs` is empty for an auto/expression index (all ascending), so
// clamp — the b-tree defaults missing per-column flags to ascending.
let descs = &full_descs[..n.min(full_descs.len())];
self.index_seek_fetch(meta, root, &key, &full_colls[..n], descs)
}
/// Fetch table rows for an equality index seek: collect the matching rowids
/// from the index, then read each row from the table b-tree. Returns a
/// superset (`run_core` re-applies the full `WHERE`). `descs` carries the
/// per-column `DESC` flags (empty ⇒ all ascending).
fn index_seek_fetch(
&self,
meta: &TableMeta,
root: u32,
key: &[Value],
colls: &[crate::value::Collation],
descs: &[bool],
) -> Result<Option<Vec<InputRow>>> {
let rowids =
crate::btree::index_seek_rowids(self.backend.source(), root, key, colls, descs)?;
let encoding = self.backend.source().header().text_encoding;
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut out = Vec::new();
for rid in rowids {
if cur.seek(rid)? {
let values = self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
out.push(InputRow {
values,
rowid: Some(rid),
});
}
}
Ok(Some(out))
}
/// Equality-seek fallback for partial / expression indexes, used when no
/// plain column index applied. Picks the first index (honoring `INDEXED BY`)
/// for which [`partial_expr_seek`](Self::partial_expr_seek) proves a seek is
/// valid, fetches its rows, and returns the superset. Returns `None` (→ scan)
/// when none qualifies. `eqp_access` mirrors this exact choice.
fn partial_expr_lookup(
&self,
meta: &TableMeta,
table_name: &str,
sel: &Select,
where_expr: &Expr,
params: &Params,
) -> Result<Option<Vec<InputRow>>> {
let hint = sel.from.as_ref().and_then(|f| f.first.index_hint.as_ref());
for idx in self.indexes_of(table_name)? {
if let Some(IndexHint::IndexedBy(n)) = hint
&& !idx.name.eq_ignore_ascii_case(n)
{
continue;
}
if let Some((key, colls)) = self.partial_expr_seek(&idx, where_expr, meta, params)? {
// Expression indexes don't map keys back to table columns, so they
// are never a covering seek here; fetch table rows by rowid (a
// superset re-filtered by `run_core`). Expression-index keys seek
// all-ascending (`&[]`), matching how they are inserted.
return self.index_seek_fetch(meta, idx.root, &key, &colls, &[]);
}
}
Ok(None)
}
/// Decide whether a *partial* or *expression* index can serve an equality
/// seek for `where_expr`, and if so return the seek `(key, collations)`.
///
/// The rules are deliberately conservative (no general implication):
///
/// * **Partial index** (`CREATE INDEX … WHERE pred`): usable only when `pred`
/// appears verbatim (modulo redundant parens) as a top-level `AND` conjunct
/// of the query's `WHERE`, so every row the seek can return is one the index
/// actually stores. A partial index over plain columns then seeks like an
/// ordinary column index; a partial *expression* index must additionally
/// satisfy the expression rule below.
/// * **Expression index** (`CREATE INDEX … (expr)`): usable when a top-level
/// `AND` conjunct is `<indexed-expr> = <const>` (either operand order), with
/// `<indexed-expr>` structurally equal to the index's single key expression.
/// The seek key is the evaluated constant; the index stores that same value
/// per row, so the seek finds a superset.
///
/// Returns `None` for plain column indexes (handled by the caller's main
/// loop) and whenever the proof above fails. `eqp_access` calls this same
/// helper, keeping the plan string in lockstep with what executes.
fn partial_expr_seek(
&self,
idx: &IndexMeta,
where_expr: &Expr,
meta: &TableMeta,
params: &Params,
) -> Result<Option<(Vec<Value>, Vec<crate::value::Collation>)>> {
// Plain column index: not our concern.
if idx.partial.is_none() && idx.key_exprs.is_none() {
return Ok(None);
}
let mut conjuncts = Vec::new();
and_conjuncts(where_expr, &mut conjuncts);
// A partial predicate must be guaranteed by a top-level conjunct.
if let Some(pred) = &idx.partial
&& !conjuncts.iter().any(|c| expr_eq_modulo_parens(c, pred))
{
return Ok(None);
}
match &idx.key_exprs {
// Partial index over plain columns: seek as an ordinary column index.
None => {
let mut key = Vec::new();
let mut colls = Vec::new();
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
for (pos, &c) in idx.cols.iter().enumerate() {
match eqs
.iter()
.find(|(col, v)| *col == c && !matches!(v, Value::Null))
{
Some((_, v)) => {
key.push(meta.columns[c].affinity.coerce(v.clone()));
colls.push(idx.collations[pos]);
}
None => break,
}
}
if key.is_empty() {
return Ok(None);
}
Ok(Some((key, colls)))
}
// Expression index: match a conjunct `<key_expr> = <const>`. Only a
// single-term key is supported (the common `lower(x)` shape).
Some(exprs) => {
let [key_expr] = exprs.as_slice() else {
return Ok(None);
};
for c in &conjuncts {
let Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} = unparen(c)
else {
continue;
};
// `<key_expr> = <const>` or `<const> = <key_expr>`.
let val = if expr_eq_modulo_parens(left, key_expr) {
const_value(right, params)
} else if expr_eq_modulo_parens(right, key_expr) {
const_value(left, params)
} else {
None
};
if let Some(v) = val {
if matches!(v, Value::Null) {
continue; // `expr = NULL` is never true
}
let coll = idx.collations.first().copied().unwrap_or_default();
return Ok(Some((alloc::vec![v], alloc::vec![coll])));
}
}
Ok(None)
}
}
}
/// Try to satisfy a single-table query with an index *range* scan: pick an
/// index whose leading column is constrained by a `<`/`<=`/`>`/`>=`/`BETWEEN`
/// predicate, walk the index between those bounds, and fetch the rows by
/// rowid. Like [`try_index_lookup`](Self::try_index_lookup) this returns a
/// superset — `run_core` re-applies the full `WHERE`. Returns `None` (→ scan)
/// when no index applies.
fn try_index_range(
&self,
meta: &TableMeta,
table_name: &str,
sel: &Select,
params: &Params,
) -> Result<Option<Vec<InputRow>>> {
let Some(where_expr) = &sel.where_clause else {
return Ok(None);
};
let hint = sel.from.as_ref().and_then(|f| f.first.index_hint.as_ref());
if matches!(hint, Some(IndexHint::NotIndexed)) {
return Ok(None);
}
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints_coll(where_expr, &meta.columns, params, &mut ranges);
if ranges.is_empty() {
return Ok(None);
}
// Seek-vs-sort: when this is a *single open-ended* range and the `ORDER BY`
// is fully served by another index, sqlite walks that ORDER-BY index to
// avoid the sort rather than seek the range (the range's ~1/4 default
// selectivity does not pay for losing the ordered walk). `order_index_scan`
// only returns `Some` with a WHERE present in exactly that case (its gate
// otherwise bails on any seek), so defer to it here; `run_core` re-applies
// the WHERE to the ordered rows, keeping the result correct.
if self.order_index_scan(sel, params).is_some() {
return Ok(None);
}
// Rowid (INTEGER PRIMARY KEY) range: walk the table b-tree between integer
// bounds. `INDEXED BY` forbids this (the rowid is not a named index). Only
// integer bounds are taken (a non-integer literal falls to the scan); the
// returned span is a superset, so the boundary rows are filtered by the
// re-applied WHERE.
if !matches!(hint, Some(IndexHint::IndexedBy(_)))
&& let Some(ipk) = meta.ipk
&& let Some(b) = ranges.get(&ipk)
{
let int_bound = |o: &Option<(Value, bool)>| match o {
Some((Value::Integer(i), _)) => Some(*i),
None => None,
_ => Some(i64::MAX), // sentinel: a non-integer bound disables it
};
let lo = int_bound(&b.lower);
let hi = int_bound(&b.upper);
// Disable when a present bound is non-integer (sentinel hit on
// the wrong side).
let lo_ok = b.lower.is_none() || matches!(b.lower, Some((Value::Integer(_), _)));
let hi_ok = b.upper.is_none() || matches!(b.upper, Some((Value::Integer(_), _)));
if lo_ok && hi_ok {
let start = lo.unwrap_or(i64::MIN);
let stop = hi.unwrap_or(i64::MAX);
let encoding = self.backend.source().header().text_encoding;
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut out = Vec::new();
let mut ok = if start == i64::MIN {
cur.first()?
} else {
cur.seek(start)?;
cur.is_valid()
};
while ok {
let rid = cur.rowid()?;
if rid > stop {
break;
}
let values = self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
out.push(InputRow {
values,
rowid: Some(rid),
});
ok = cur.next()?;
}
return Ok(Some(out));
}
}
// Choose a plain index whose leading column has a range bound, preferring
// a covering one (skips the table lookup); `eqp_access` renders the same
// pick via the shared `choose_range_index`, honoring `INDEXED BY`.
let indexes = self.indexes_of(table_name)?;
if let Some(IndexHint::IndexedBy(n)) = hint
&& !indexes.iter().any(|i| i.name.eq_ignore_ascii_case(n))
{
return Err(Error::Error(alloc::format!("no such index: {n}")));
}
#[allow(clippy::type_complexity)]
let mut chosen: Option<(
u32,
RangeBound,
crate::value::Collation,
Vec<usize>,
bool,
)> = None;
if let Some(idx) =
self.choose_range_index(Some(sel), meta, table_name, where_expr, &ranges, hint)?
{
let lead = idx.cols[0];
// For a DESC leading column the value order is reversed in key-sort
// space, so the value-space bounds are SWAPPED and the b-tree is told
// the column is descending.
let lead_desc = idx.descending.first().copied().unwrap_or(false);
let b = &ranges[&lead];
let coll = idx.collations.first().copied().unwrap_or_default();
let aff = meta.columns[lead].affinity;
let (lo, hi) = if lead_desc {
(b.upper.as_ref(), b.lower.as_ref())
} else {
(b.lower.as_ref(), b.upper.as_ref())
};
let bound = RangeBound {
lower: lo.map(|(v, i)| (aff.coerce(v.clone()), *i)),
upper: hi.map(|(v, i)| (aff.coerce(v.clone()), *i)),
};
chosen = Some((idx.root, bound, coll, idx.cols.clone(), lead_desc));
}
match chosen {
Some((root, bound, coll, idx_cols, lead_desc)) => {
// Covering range seek: read from the index when it holds every
// referenced column (lockstep with `eqp_access`'s `COVERING INDEX`).
// The covering walk reads the whole index (a superset re-filtered by
// `run_core`), so its correctness is direction-independent.
if self.seek_index_covers(sel, meta, &idx_cols, where_expr) {
return Ok(Some(self.covering_seek_rows(meta, root, &idx_cols)?));
}
Ok(Some(
self.range_seek_fetch(meta, root, &bound, coll, lead_desc)?,
))
}
None => {
// A3b: a partial or expression index whose key column / expression
// has a range bound (and, for a partial index, whose predicate the
// WHERE guarantees). Always a non-covering fetch — `eqp_access`
// mirrors this in its partial/expression range fallback.
for idx in &indexes {
if let Some(IndexHint::IndexedBy(n)) = hint
&& !idx.name.eq_ignore_ascii_case(n)
{
continue;
}
if let Some((bound, coll)) =
self.partial_expr_range(idx, where_expr, meta, params)
{
// `partial_expr_range` only returns ASC-leading indexes
// (DESC-leading partial ranges are deferred there).
return Ok(Some(
self.range_seek_fetch(meta, idx.root, &bound, coll, false)?,
));
}
}
Ok(None)
}
}
}
/// Pick the sole covering index a bare `col IS NOT NULL` can seek. `col IS
/// NOT NULL` selects every non-NULL key — a `col > NULL` lower-bounded range
/// spanning ~the whole table — so sqlite only prefers the index over a plain
/// scan when that index is *covering* (a near-full-table non-covering seek,
/// re-fetching every row by rowid, loses to a scan). graphite has no
/// selectivity cost model, so this matches sqlite by gating strictly on the
/// covering case: it returns rows only when a single plain index's leading
/// column is `IS NOT NULL`-constrained and the index covers the query. The
/// bare non-covering `SELECT *` keeps falling through to the scan, exactly as
/// sqlite plans it. Walking the whole index and letting `run_core` re-apply
/// the `WHERE` drops the NULL-keyed entries (the superset invariant), so the
/// surviving rows arrive in index order — the same order sqlite's seek yields.
/// Must stay in lockstep with `eqp_access`'s matching covering branch.
fn try_isnotnull_covering(
&self,
meta: &TableMeta,
table_name: &str,
sel: &Select,
params: &Params,
) -> Result<Option<Vec<InputRow>>> {
let _ = params;
let Some(where_expr) = &sel.where_clause else {
return Ok(None);
};
let hint = sel.from.as_ref().and_then(|f| f.first.index_hint.as_ref());
if matches!(hint, Some(IndexHint::NotIndexed)) {
return Ok(None);
}
let mut isnotnull_cols: Vec<usize> = Vec::new();
collect_isnotnull_cols(where_expr, &meta.columns, &mut isnotnull_cols);
if isnotnull_cols.is_empty() {
return Ok(None);
}
let Some((_, root, idx_cols)) = self.isnotnull_covering_index(
meta,
table_name,
sel,
where_expr,
&isnotnull_cols,
hint,
)?
else {
return Ok(None);
};
Ok(Some(self.covering_seek_rows(meta, root, &idx_cols)?))
}
/// The index `try_isnotnull_covering` / `eqp_access` agree to seek for a
/// `col IS NOT NULL`: a single plain (non-partial, non-expression) index
/// whose leading column is in `isnotnull_cols` and which covers the whole
/// query. Honors `INDEXED BY` (filter to the named index, erroring if it
/// doesn't exist) and declines on ambiguity (two qualifying indexes — sqlite's
/// no-stats tiebreak is creation-order-dependent), returning the chosen
/// index's `(name, root, cols)`.
#[allow(clippy::type_complexity)]
fn isnotnull_covering_index(
&self,
meta: &TableMeta,
table_name: &str,
sel: &Select,
where_expr: &Expr,
isnotnull_cols: &[usize],
hint: Option<&IndexHint>,
) -> Result<Option<(String, u32, Vec<usize>)>> {
let indexes = self.indexes_of(table_name)?;
if let Some(IndexHint::IndexedBy(n)) = hint
&& !indexes.iter().any(|i| i.name.eq_ignore_ascii_case(n))
{
return Err(Error::Error(alloc::format!("no such index: {n}")));
}
let mut qualifying = indexes.iter().filter(|idx| {
if let Some(IndexHint::IndexedBy(n)) = hint
&& !idx.name.eq_ignore_ascii_case(n)
{
return false;
}
idx.partial.is_none()
&& idx.key_exprs.is_none()
&& idx.cols.first().is_some_and(|c| isnotnull_cols.contains(c))
&& self.seek_index_covers(sel, meta, &idx.cols, where_expr)
});
let Some(chosen) = qualifying.next() else {
return Ok(None);
};
if qualifying.next().is_some() {
return Ok(None);
}
Ok(Some((
chosen.name.clone(),
chosen.root,
chosen.cols.clone(),
)))
}
/// Walk an index between `bound`'s lower/upper keys (single leading column,
/// under `coll`) and fetch each matching row from the table by rowid. Returns
/// a superset — `run_core` re-applies the full `WHERE`. `lead_desc` is the
/// stored direction of the leading column; the caller has ALREADY swapped the
/// value-space bounds into stored-key order when it is `true`.
fn range_seek_fetch(
&self,
meta: &TableMeta,
root: u32,
bound: &RangeBound,
coll: crate::value::Collation,
lead_desc: bool,
) -> Result<Vec<InputRow>> {
let colls = [coll];
let descs = [lead_desc];
let lower_key = bound.lower.as_ref().map(|(v, _)| core::slice::from_ref(v));
let upper_key = bound.upper.as_ref().map(|(v, _)| core::slice::from_ref(v));
let lower = lower_key.map(|k| (k, bound.lower.as_ref().unwrap().1));
let upper = upper_key.map(|k| (k, bound.upper.as_ref().unwrap().1));
let rowids = crate::btree::index_range_rowids(
self.backend.source(),
root,
lower,
upper,
&colls,
&descs,
)?;
let encoding = self.backend.source().header().text_encoding;
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut out = Vec::new();
for rid in rowids {
if cur.seek(rid)? {
let values = self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
out.push(InputRow {
values,
rowid: Some(rid),
});
}
}
Ok(out)
}
/// Seek each key through an index (single leading column/expression, under
/// `colls`), union the matching rowids, and fetch each row from the table.
/// Shared by the plain, partial, and expression `IN`-list seek paths. Returns
/// a superset (`run_core` re-applies the full `WHERE`).
fn in_seek_fetch(
&self,
meta: &TableMeta,
root: u32,
colls: &[crate::value::Collation],
descs: &[bool],
keys: &[Vec<Value>],
) -> Result<Vec<InputRow>> {
let src = self.backend.source();
let encoding = src.header().text_encoding;
// SQLite seeks an `IN` list in *sorted key order*, so the rows (absent an
// `ORDER BY`) come out in index order, not list order. Sort the keys the
// same way — component-wise under the index collations — so a non-covering
// `IN` seek reproduces that order (within one key, `index_seek_rowids`
// already returns rowids ascending, matching the trailing-rowid index sort).
let mut keys: Vec<Vec<Value>> = keys.to_vec();
keys.sort_by(|a, b| {
for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() {
let c = colls.get(i).copied().unwrap_or_default();
let o = crate::value::cmp_values_coll(x, y, c);
if o != core::cmp::Ordering::Equal {
return o;
}
}
core::cmp::Ordering::Equal
});
let mut rowids: Vec<i64> = Vec::new();
for key in &keys {
for rid in crate::btree::index_seek_rowids(src, root, key, colls, descs)? {
if !rowids.contains(&rid) {
rowids.push(rid);
}
}
}
let mut cur = TableCursor::new(src, meta.root);
let mut out = Vec::new();
for rid in rowids {
if cur.seek(rid)? {
let values = self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
out.push(InputRow {
values,
rowid: Some(rid),
});
}
}
Ok(out)
}
/// A3b range analogue of [`partial_expr_seek`](Self::partial_expr_seek): for a
/// partial or expression index, return the range bound (and collation) to seek
/// — a `<`/`<=`/`>`/`>=` constraint on the partial index's leading column (with
/// its predicate guaranteed by the `WHERE`), or on an expression index's keyed
/// expression. `None` when the index doesn't apply.
fn partial_expr_range(
&self,
idx: &IndexMeta,
where_expr: &Expr,
meta: &TableMeta,
params: &Params,
) -> Option<(RangeBound, crate::value::Collation)> {
if idx.partial.is_none() && idx.key_exprs.is_none() {
return None;
}
let mut conjuncts = Vec::new();
and_conjuncts(where_expr, &mut conjuncts);
if let Some(pred) = &idx.partial
&& !conjuncts.iter().any(|c| expr_eq_modulo_parens(c, pred))
{
return None;
}
let coll = idx.collations.first().copied().unwrap_or_default();
match &idx.key_exprs {
// Partial index over plain columns: a range on the leading column.
None => {
let lead = *idx.cols.first()?;
let mut ranges = alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
let b = ranges.get(&lead)?;
let aff = meta.columns[lead].affinity;
Some((
RangeBound {
lower: b.lower.as_ref().map(|(v, i)| (aff.coerce(v.clone()), *i)),
upper: b.upper.as_ref().map(|(v, i)| (aff.coerce(v.clone()), *i)),
},
coll,
))
}
// Expression index: collect range conjuncts `<key_expr> <op> <const>`.
Some(exprs) => {
let [key_expr] = exprs.as_slice() else {
return None;
};
let mut bound = RangeBound {
lower: None,
upper: None,
};
for c in &conjuncts {
let Expr::Binary { op, left, right } = unparen(c) else {
continue;
};
// Normalize to `key_expr <op> const`, mirroring the operator
// when the expression is on the right.
let (val, op) = if expr_eq_modulo_parens(left, key_expr) {
(const_value(right, params), *op)
} else if expr_eq_modulo_parens(right, key_expr) {
(const_value(left, params), mirror_comparison(*op))
} else {
continue;
};
let Some(v) = val else { continue };
if matches!(v, Value::Null) {
continue;
}
match op {
BinaryOp::Gt => bound.lower = Some((v, false)),
BinaryOp::GtEq => bound.lower = Some((v, true)),
BinaryOp::Lt => bound.upper = Some((v, false)),
BinaryOp::LtEq => bound.upper = Some((v, true)),
_ => {}
}
}
if bound.lower.is_none() && bound.upper.is_none() {
return None;
}
Some((bound, coll))
}
}
}
/// Try to satisfy a single-table query with per-value index seeks for a
/// `column IN (const, …)` predicate: seek each list value through an index on
/// that column (or the rowid b-tree for an `INTEGER PRIMARY KEY`), union the
/// rowids, and fetch the rows. Returns a superset (`run_core` re-applies the
/// full `WHERE`), or `None` (→ scan) when no index applies.
fn try_index_in(
&self,
meta: &TableMeta,
table_name: &str,
sel: &Select,
params: &Params,
) -> Result<Option<Vec<InputRow>>> {
let Some(where_expr) = &sel.where_clause else {
return Ok(None);
};
let hint = sel.from.as_ref().and_then(|f| f.first.index_hint.as_ref());
if matches!(hint, Some(IndexHint::NotIndexed)) {
return Ok(None);
}
let indexes = self.indexes_of(table_name)?;
if let Some(IndexHint::IndexedBy(n)) = hint
&& !indexes.iter().any(|i| i.name.eq_ignore_ascii_case(n))
{
return Err(Error::Error(alloc::format!("no such index: {n}")));
}
let by_name = |idx: &IndexMeta| match hint {
Some(IndexHint::IndexedBy(n)) => idx.name.eq_ignore_ascii_case(n),
_ => true,
};
// Column `IN (…)`: rowid b-tree, a plain index, or a partial index whose
// leading column is the IN column (and whose predicate the WHERE proves).
if let Some((col, values)) = find_in_constraint(where_expr, &meta.columns, params) {
// A `NULL` list entry is never a usable seek key (`x = NULL` is never
// true), so drop it and seek the rest: `x IN (5, NULL, 2)` matches
// exactly the rows `x IN (5, 2)` does, and `run_core` re-applies the
// full `IN` (superset-safe). Seek only when a non-NULL key remains.
let values: Vec<Value> = values
.into_iter()
.filter(|v| !matches!(v, Value::Null))
.collect();
if !values.is_empty() {
let encoding = self.backend.source().header().text_encoding;
let aff = meta.columns[col].affinity;
// Rowid IN-list: seek the table b-tree directly for each value.
if !matches!(hint, Some(IndexHint::IndexedBy(_)))
&& let Some(ipk) = meta.ipk
&& col == ipk
{
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut out = Vec::new();
let mut seen: Vec<i64> = Vec::new();
for v in &values {
let rid = eval::to_i64(v);
if seen.contains(&rid) {
continue;
}
seen.push(rid);
if cur.seek(rid)? {
let values =
self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
out.push(InputRow {
values,
rowid: Some(rid),
});
}
}
return Ok(Some(out));
}
let keys: Vec<Vec<Value>> = values
.iter()
.map(|v| alloc::vec![aff.coerce(v.clone())])
.collect();
// A plain index whose leading column is the IN column.
for idx in &indexes {
if !by_name(idx) || idx.partial.is_some() || idx.key_exprs.is_some() {
continue;
}
if idx.cols.first() == Some(&col) {
if self.seek_index_covers(sel, meta, &idx.cols, where_expr) {
return Ok(Some(self.covering_seek_rows(meta, idx.root, &idx.cols)?));
}
let coll = idx.collations.first().copied().unwrap_or_default();
return Ok(Some(self.in_seek_fetch(
meta,
idx.root,
&[coll],
idx.seek_descs(),
&keys,
)?));
}
}
// A3b: a partial index on the IN column with its predicate proven.
for idx in &indexes {
if !by_name(idx) || idx.key_exprs.is_some() || idx.partial.is_none() {
continue;
}
if idx.cols.first() == Some(&col) && partial_pred_guaranteed(idx, where_expr) {
let coll = idx.collations.first().copied().unwrap_or_default();
return Ok(Some(self.in_seek_fetch(
meta,
idx.root,
&[coll],
idx.seek_descs(),
&keys,
)?));
}
}
}
}
// A3b: an expression index keyed by `<expr>` with `<expr> IN (…)`.
for idx in &indexes {
if !by_name(idx) {
continue;
}
let Some(exprs) = &idx.key_exprs else {
continue;
};
let [key_expr] = exprs.as_slice() else {
continue;
};
if !partial_pred_guaranteed(idx, where_expr) {
continue;
}
let Some(values) = find_expr_in_values(key_expr, where_expr, params) else {
continue;
};
if values.iter().any(|v| matches!(v, Value::Null)) {
continue;
}
let coll = idx.collations.first().copied().unwrap_or_default();
let keys: Vec<Vec<Value>> = values.iter().map(|v| alloc::vec![v.clone()]).collect();
// Expression-index keys seek all-ascending (`&[]`), matching insert.
return Ok(Some(self.in_seek_fetch(
meta,
idx.root,
&[coll],
&[],
&keys,
)?));
}
Ok(None)
}
/// Find a plain (non-partial, non-expression) index whose leading column is
/// `col`, returning its root page and leading collation. Honors `INDEXED BY`.
fn leading_index_for(
&self,
table_name: &str,
col: usize,
hint: Option<&IndexHint>,
) -> Result<Option<(u32, crate::value::Collation)>> {
for idx in &self.indexes_of(table_name)? {
if let Some(IndexHint::IndexedBy(n)) = hint
&& !idx.name.eq_ignore_ascii_case(n)
{
continue;
}
if idx.partial.is_some() || idx.key_exprs.is_some() {
continue;
}
// A DESC leading column stores entries in reversed order; this helper's
// callers seek all-ascending (`&[]`). Skip it so they fall back to a
// scan rather than navigating the b-tree the wrong way. (Deferral.)
if idx.descending.first().copied().unwrap_or(false) {
continue;
}
if idx.cols.first() == Some(&col) {
return Ok(Some((
idx.root,
idx.collations.first().copied().unwrap_or_default(),
)));
}
}
Ok(None)
}
/// Rowids matching `col IN values` (or `col = v` with a one-element slice) via
/// the rowid b-tree or an index, or `None` when neither applies.
fn seek_col_values(
&self,
meta: &TableMeta,
table_name: &str,
hint: Option<&IndexHint>,
col: usize,
values: &[Value],
) -> Result<Option<Vec<i64>>> {
let mut rowids: Vec<i64> = Vec::new();
// Rowid column: each value is itself a candidate rowid.
if !matches!(hint, Some(IndexHint::IndexedBy(_))) && meta.ipk == Some(col) {
for v in values {
let rid = eval::to_i64(v);
if !rowids.contains(&rid) {
rowids.push(rid);
}
}
return Ok(Some(rowids));
}
let Some((root, coll)) = self.leading_index_for(table_name, col, hint)? else {
return Ok(None);
};
let aff = meta.columns[col].affinity;
let colls = [coll];
for v in values {
let key = [aff.coerce(v.clone())];
// `leading_index_for` only returns ASC-leading indexes, so seek
// all-ascending (`&[]`).
for rid in
crate::btree::index_seek_rowids(self.backend.source(), root, &key, &colls, &[])?
{
if !rowids.contains(&rid) {
rowids.push(rid);
}
}
}
Ok(Some(rowids))
}
/// Rowids matching a range `bound` on `col` via the rowid b-tree (integer
/// bounds) or an index, or `None` when neither applies.
fn seek_col_range(
&self,
meta: &TableMeta,
table_name: &str,
hint: Option<&IndexHint>,
col: usize,
bound: &RangeBound,
) -> Result<Option<Vec<i64>>> {
// Rowid integer range: walk the table b-tree between bounds.
if !matches!(hint, Some(IndexHint::IndexedBy(_))) && meta.ipk == Some(col) {
let lo_int =
bound.lower.is_none() || matches!(bound.lower, Some((Value::Integer(_), _)));
let hi_int =
bound.upper.is_none() || matches!(bound.upper, Some((Value::Integer(_), _)));
if !(lo_int && hi_int) {
return Ok(None);
}
let start = match &bound.lower {
Some((Value::Integer(i), _)) => *i,
_ => i64::MIN,
};
let stop = match &bound.upper {
Some((Value::Integer(i), _)) => *i,
_ => i64::MAX,
};
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut rowids = Vec::new();
let mut ok = if start == i64::MIN {
cur.first()?
} else {
cur.seek(start)?;
cur.is_valid()
};
while ok {
let rid = cur.rowid()?;
if rid > stop {
break;
}
rowids.push(rid);
ok = cur.next()?;
}
return Ok(Some(rowids));
}
let Some((root, coll)) = self.leading_index_for(table_name, col, hint)? else {
return Ok(None);
};
let aff = meta.columns[col].affinity;
let lo = bound
.lower
.as_ref()
.map(|(v, i)| (aff.coerce(v.clone()), *i));
let hi = bound
.upper
.as_ref()
.map(|(v, i)| (aff.coerce(v.clone()), *i));
let colls = [coll];
let lower = lo.as_ref().map(|(v, i)| (core::slice::from_ref(v), *i));
let upper = hi.as_ref().map(|(v, i)| (core::slice::from_ref(v), *i));
// `leading_index_for` only returns ASC-leading indexes (`&[]`).
let rowids = crate::btree::index_range_rowids(
self.backend.source(),
root,
lower,
upper,
&colls,
&[],
)?;
Ok(Some(rowids))
}
/// Rowids for one seekable predicate atom (`col = c`, `col IN (…)`, or a range
/// on `col`), or `None` if it is not index/rowid-seekable. Superset semantics:
/// the caller re-applies the full `WHERE`.
fn predicate_rowids(
&self,
meta: &TableMeta,
table_name: &str,
hint: Option<&IndexHint>,
pred: &Expr,
params: &Params,
) -> Result<Option<Vec<i64>>> {
if let Some((col, vals)) = find_in_constraint(pred, &meta.columns, params) {
if vals.iter().any(|v| matches!(v, Value::Null)) {
return Ok(None);
}
return self.seek_col_values(meta, table_name, hint, col, &vals);
}
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(pred, &meta.columns, params, &mut eqs);
eqs.retain(|(_, v)| !matches!(v, Value::Null));
if let Some((col, v)) = eqs.into_iter().next() {
return self.seek_col_values(meta, table_name, hint, col, &[v]);
}
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(pred, &meta.columns, params, &mut ranges);
if let Some((&col, bound)) = ranges.iter().next() {
return self.seek_col_range(meta, table_name, hint, col, bound);
}
Ok(None)
}
/// Try to satisfy a single-table query whose `WHERE` is a top-level `OR` of
/// individually-seekable predicates: seek each disjunct, union the rowids, and
/// fetch the rows once. Returns `None` (→ scan) unless *every* disjunct is
/// seekable. Superset semantics — `run_core` re-applies the full `WHERE`.
fn try_index_or(
&self,
meta: &TableMeta,
table_name: &str,
sel: &Select,
params: &Params,
) -> Result<Option<Vec<InputRow>>> {
let Some(where_expr) = &sel.where_clause else {
return Ok(None);
};
let hint = sel.from.as_ref().and_then(|f| f.first.index_hint.as_ref());
if matches!(hint, Some(IndexHint::NotIndexed)) {
return Ok(None);
}
// Flatten the top-level OR chain; require at least two disjuncts.
let mut disjuncts: Vec<&Expr> = Vec::new();
flatten_or(where_expr, &mut disjuncts);
if disjuncts.len() < 2 {
return Ok(None);
}
// Every disjunct must be seekable, else a scan is needed regardless.
let mut rowids: Vec<i64> = Vec::new();
for d in disjuncts {
match self.predicate_rowids(meta, table_name, hint, d, params)? {
Some(rs) => {
for r in rs {
if !rowids.contains(&r) {
rowids.push(r);
}
}
}
None => return Ok(None),
}
}
let encoding = self.backend.source().header().text_encoding;
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut out = Vec::new();
for rid in rowids {
if cur.seek(rid)? {
let values = self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
out.push(InputRow {
values,
rowid: Some(rid),
});
}
}
Ok(Some(out))
}
/// `EXPLAIN QUERY PLAN <stmt>` -> the `(id, parent, notused, detail)` rows
/// that SQLite's API returns. The detail strings describe graphitesql's
/// *actual* execution plan (it does not reorder joins), matching SQLite's
/// format for the single-table SCAN/SEARCH cases.
fn explain_query_plan(&self, stmt: &Statement, params: &Params) -> Result<QueryResult> {
let mut details: Vec<(i64, i64, String)> = Vec::new();
let mut next_id = 1i64;
match stmt {
Statement::Select(sel) => {
self.eqp_select(sel, 0, &mut next_id, &mut details, params)?
}
Statement::Delete(d) => {
let meta = self.table_meta(&d.table, None)?;
let detail = self.eqp_access_hinted(
&d.table,
&d.table,
&meta,
d.where_clause.as_ref(),
None,
params,
d.index_hint.as_ref(),
)?;
let access_id = next_id;
next_id += 1;
details.push((access_id, 0, detail));
// A single non-correlated scalar subquery in the WHERE renders a
// `SCALAR SUBQUERY 1` sibling of the scan, its body as the child —
// only when no CTE / trailing clause shifts SQLite's id counter.
if d.ctes.is_empty()
&& d.order_by.is_empty()
&& d.limit.is_none()
&& d.offset.is_none()
&& d.returning.is_empty()
&& let Some(body) = d
.where_clause
.as_ref()
.and_then(|w| self.eqp_dml_scalar_subquery(&[w]))
{
let sid = next_id;
next_id += 1;
details.push((sid, 0, String::from("SCALAR SUBQUERY 1")));
self.eqp_select(body, sid, &mut next_id, &mut details, params)?;
}
}
Statement::Update(u) => {
let meta = self.table_meta(&u.table, None)?;
let detail = self.eqp_access_hinted(
&u.table,
&u.table,
&meta,
u.where_clause.as_ref(),
None,
params,
u.index_hint.as_ref(),
)?;
let access_id = next_id;
next_id += 1;
details.push((access_id, 0, detail));
// As for DELETE, but the lone scalar subquery may live in a `SET`
// assignment, the `WHERE`, or a single row-value `SET (…)=(SELECT …)`.
// Multiple SET subqueries are emitted in source order yet numbered in
// reverse (codegen-fragile), so only the single-subquery case (always
// `SCALAR SUBQUERY 1`) is rendered; `UPDATE … FROM` / a trailing clause
// / a CTE / `RETURNING` each shift the plan and decline.
if u.ctes.is_empty()
&& u.from.is_none()
&& u.order_by.is_empty()
&& u.limit.is_none()
&& u.offset.is_none()
&& u.returning.is_empty()
{
// The body of the lone subquery, whichever clause holds it.
let body: Option<&Select> = if u.row_assignments.is_empty() {
let mut exprs: Vec<&Expr> = u.assignments.iter().map(|(_, e)| e).collect();
if let Some(w) = u.where_clause.as_ref() {
exprs.push(w);
}
self.eqp_dml_scalar_subquery(&exprs)
} else if u.row_assignments.len() == 1
&& !u.assignments.iter().any(|(_, e)| expr_has_subquery(e))
&& !u.where_clause.as_ref().is_some_and(expr_has_subquery)
{
// The sole subquery is the row-value `SET (…)=(SELECT …)` body
// (a correlated / compound body is caught by the renderable
// check — SQLite renders those as different node kinds).
let rv = u.row_assignments[0].1.as_ref();
self.eqp_scalar_bodies_renderable(&[rv]).then_some(rv)
} else {
None
};
if let Some(body) = body {
let sid = next_id;
next_id += 1;
details.push((sid, 0, String::from("SCALAR SUBQUERY 1")));
self.eqp_select(body, sid, &mut next_id, &mut details, params)?;
}
}
}
Statement::Insert(ins) => match &ins.source {
InsertSource::Select(sel) => {
self.eqp_select(sel, 0, &mut next_id, &mut details, params)?;
}
// A single-row `VALUES` carrying one non-correlated scalar subquery
// renders just that `SCALAR SUBQUERY 1` node (an INSERT has no scan of
// its own). A multi-row `VALUES` adds a `SCAN N CONSTANT ROWS` node and
// shifts the numbering, and several subqueries are reverse-numbered —
// both fragile — so only the single-row / single-subquery case renders.
InsertSource::Values(rows)
if ins.ctes.is_empty()
&& ins.upsert.is_empty()
&& ins.returning.is_empty()
&& rows.len() == 1 =>
{
let exprs: Vec<&Expr> = rows[0].iter().collect();
if let Some(body) = self.eqp_dml_scalar_subquery(&exprs) {
let sid = next_id;
next_id += 1;
details.push((sid, 0, String::from("SCALAR SUBQUERY 1")));
self.eqp_select(body, sid, &mut next_id, &mut details, params)?;
}
}
_ => {}
},
_ => return Err(Error::Unsupported("EXPLAIN QUERY PLAN for this statement")),
}
Ok(QueryResult {
columns: alloc::vec![
String::from("id"),
String::from("parent"),
String::from("notused"),
String::from("detail"),
],
rows: details
.into_iter()
.map(|(id, parent, detail)| {
alloc::vec![
Value::Integer(id),
Value::Integer(parent),
Value::Integer(0),
Value::Text(detail.into()),
]
})
.collect(),
})
}
/// Emit query-plan nodes for one SELECT under `parent`.
/// EXPLAIN QUERY PLAN detail for a virtual-table scan: sqlite's
/// `SCAN <label> VIRTUAL TABLE INDEX <idxNum>:<idxStr>`. The module's
/// `best_index` chooses the plan from the offered `WHERE` constraints; a
/// persistent module (which scans its backing table) reports a plain scan.
fn eqp_vtab_detail(
&self,
name: &str,
label: &str,
sel: &Select,
params: &Params,
) -> Result<String> {
use crate::schema::ObjectType;
let plain = || alloc::format!("SCAN {label} VIRTUAL TABLE INDEX 0:");
let cvt = self
.schema
.objects()
.iter()
.find(|o| o.obj_type == ObjectType::Table && o.name.eq_ignore_ascii_case(name))
.and_then(|o| o.sql.as_deref())
.and_then(|s| match sql::parse_one(s) {
Ok(Statement::CreateVirtualTable(cvt)) => Some(cvt),
_ => None,
});
let Some(cvt) = cvt else { return Ok(plain()) };
let Some(module) = self.vtab_registry.get(&cvt.module) else {
return Ok(plain());
};
// The module's `best_index` chooses the reported plan from the offered
// `WHERE` constraints — even for a persistent module, whose execution scans
// `<name>_data` but whose reported `idxNum:idxStr` should still match SQLite
// (e.g. rtree's spatial encoding). A module with no pushdown returns the
// default plan, rendering the plain `INDEX 0:`.
let arg_refs: Vec<&str> = cvt.args.iter().map(String::as_str).collect();
let schema = module.dyn_connect(&arg_refs)?;
let columns: Vec<ColumnInfo> = schema
.columns
.iter()
.map(|n| ColumnInfo {
name: n.clone(),
table: label.to_string(),
affinity: eval::Affinity::Blob,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
})
.collect();
// geopoly's plan is driven by the spatial `geopoly_overlap`/`geopoly_within`
// functions (which the generic collector doesn't see) and a rowid equality,
// matching sqlite's `geopolyBestIndex`: rowid `=` → `1:rowid`, overlap →
// `2:rtree`, within → `3:rtree`, else a `4:fullscan`.
if cvt.module.eq_ignore_ascii_case("geopoly") {
let (num, s) = self.geopoly_eqp_plan(sel, params);
return Ok(alloc::format!("SCAN {label} VIRTUAL TABLE INDEX {num}:{s}"));
}
// FTS5's plan is driven by `MATCH` (a desugared `match()` function the
// generic constraint collector doesn't see) and `ORDER BY rank`, so report
// it directly to match sqlite's `xBestIndex`: `MATCH` is `M<col>` (the
// matched column's 0-based index, or the column count for a table-wide
// match), a rowid equality is `=`, and `ORDER BY rank` sets the
// order-by-consumed bit (32) in idxNum.
#[cfg(feature = "fts5")]
if cvt.module.eq_ignore_ascii_case("fts5") {
let mut idx_str = String::new();
let mut matched = false;
if let Some(where_expr) = &sel.where_clause {
if let Some((_, operand)) = self.fts5_match_query(where_expr, params) {
let col = schema
.columns
.iter()
.position(|c| c.eq_ignore_ascii_case(&operand))
.unwrap_or(schema.columns.len());
idx_str = alloc::format!("M{col}");
matched = true;
} else if fts5_rowid_eq(where_expr, params) {
idx_str.push('=');
}
}
// With a MATCH, FTS5 can return rows already ordered by `rank` (idxNum
// bit 32) or by `rowid` (bit 64), consuming the ORDER BY.
let order_bit = if matched && sel.order_by.len() == 1 && !sel.order_by[0].descending {
match &sel.order_by[0].expr {
Expr::Column {
table: None,
column,
..
} if column.eq_ignore_ascii_case("rank") => 32,
Expr::Column {
table: None,
column,
..
} if matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) =>
{
64
}
_ => 0,
}
} else {
0
};
return Ok(alloc::format!(
"SCAN {label} VIRTUAL TABLE INDEX {order_bit}:{idx_str}"
));
}
let (constraints, _) = collect_vtab_constraints(sel, &columns, params);
let plan = module.dyn_best_index(&constraints)?;
Ok(alloc::format!(
"SCAN {label} VIRTUAL TABLE INDEX {}:{}",
plan.idx_num,
plan.idx_str.as_deref().unwrap_or("")
))
}
/// The `WHERE`-clause scalar subqueries to render as `SCALAR SUBQUERY N`
/// child nodes of an `EXPLAIN QUERY PLAN`, in SQLite's numbering order, when
/// doing so is provably byte-exact — otherwise `None` (emit nothing, the
/// prior behaviour).
///
/// SQLite assigns every subquery in a statement a sequential id and emits a
/// node for each (it never constant-folds one away: even `(SELECT 5)` becomes
/// `SCALAR SUBQUERY n` over a `SCAN CONSTANT ROW`). That id is *shared* with
/// CTE materialisations and compound arms, so it is only a clean `1..n` —
/// which is all we can predict — when the query has no CTEs, the subqueries
/// live solely in the `WHERE` clause (one anywhere else would shift the
/// count), and each is a non-correlated, non-compound scalar `(SELECT …)` over
/// base tables with no further nested subquery. `IN (SELECT …)` (a `LIST
/// SUBQUERY` with a bloom filter) and `EXISTS` (often `CORRELATED`) are
/// different node shapes we decline here. Because SQLite *always* emits a node
/// for such a subquery and we previously emitted none, adding the correct node
/// can only converge a plan or leave it diverging — never regress one.
fn eqp_where_scalar_subqueries<'a>(&self, sel: &'a Select) -> Option<Vec<&'a Select>> {
// A subquery in any non-WHERE clause would consume a subquery id and shift
// the numbering past what we can predict — decline the whole query.
let elsewhere = sel.columns.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
}) || sel.group_by.iter().any(expr_has_subquery)
|| sel.having.as_ref().is_some_and(expr_has_subquery)
|| sel.order_by.iter().any(|t| expr_has_subquery(&t.expr))
|| sel.limit.as_ref().is_some_and(expr_has_subquery)
|| sel.offset.as_ref().is_some_and(expr_has_subquery);
if elsewhere {
return None;
}
let where_expr = sel.where_clause.as_ref()?;
// Collect the WHERE subqueries in pre-order (SQLite's left-to-right
// numbering order) without descending into a subquery body, so this is
// exactly the top-level set. Any `IN (SELECT)` / `EXISTS` makes the set
// unrenderable here.
let mut subs: Vec<&Select> = Vec::new();
if !collect_where_scalar_subqueries(where_expr, &mut subs) || subs.is_empty() {
return None;
}
if !self.eqp_scalar_bodies_renderable(&subs) {
return None;
}
Some(subs)
}
/// The single non-correlated scalar subquery in an UPDATE/DELETE's `SET` /
/// `WHERE` expressions to render as `SCALAR SUBQUERY 1`, when provably
/// byte-exact. SQLite numbers DML subqueries with the same shared counter as a
/// SELECT's, and several `SET` subqueries are emitted in source order yet
/// numbered in *reverse* (codegen-fragile), so only the unambiguous single-
/// subquery case is rendered — it is always `SCALAR SUBQUERY 1`. `EXISTS` /
/// `IN (SELECT)` (different node shapes) and a correlated body (a `CORRELATED
/// SCALAR SUBQUERY` node) decline via the shared collector / renderable check.
/// SQLite always emits a node here where we emitted none, so adding the correct
/// one can only converge a plan, never regress.
fn eqp_dml_scalar_subquery<'a>(&self, exprs: &[&'a Expr]) -> Option<&'a Select> {
let mut subs: Vec<&Select> = Vec::new();
for e in exprs {
if !collect_where_scalar_subqueries(e, &mut subs) {
return None;
}
}
if subs.len() != 1 || !self.eqp_scalar_bodies_renderable(&subs) {
return None;
}
Some(subs[0])
}
/// Whether every collected scalar-subquery body renders byte-exactly and
/// leaves SQLite's id counter at a clean `1..n`: a plain scalar select over
/// base tables — no join, no compound, no CTE, non-correlated
/// (`vdbe_subquery_foldable`), and no further nested subquery. Shared by the
/// WHERE and projection collectors.
fn eqp_scalar_bodies_renderable(&self, subs: &[&Select]) -> bool {
subs.iter().all(|body| {
body.compound.is_empty()
&& body.ctes.is_empty()
&& body.from.as_ref().is_none_or(|f| f.joins.is_empty())
&& !select_no_from_has_subquery(body)
&& self.vdbe_subquery_foldable(body)
})
}
/// The projection (`SELECT`-list) scalar subqueries to render as `SCALAR
/// SUBQUERY N`, the result-column analogue of
/// [`Self::eqp_where_scalar_subqueries`].
///
/// SQLite numbers and renders a projection subquery's node just like a WHERE
/// one, but *sequences* it differently: it is evaluated after grouping, so its
/// node sits *after* a `USE TEMP B-TREE FOR GROUP BY` sorter (yet still
/// *before* a DISTINCT / ORDER BY sorter). Our single insertion point — right
/// after the scan — matches SQLite only for the no-GROUP-BY shapes, so we
/// decline any GROUP BY / HAVING and render the remaining DISTINCT / ORDER BY /
/// LIMIT / plain cases. As with the WHERE form, the subqueries must live solely
/// in the projection (one in WHERE or a trailing clause would shift the shared
/// id counter), and each must be a non-correlated, non-compound scalar
/// `(SELECT …)` over base tables with no nested subquery. Numbered `1..n` in
/// left-to-right column order. SQLite always emits such a node where we emitted
/// none, so rendering the correct one can only converge a plan, never regress.
fn eqp_projection_scalar_subqueries<'a>(&self, sel: &'a Select) -> Option<Vec<&'a Select>> {
// A projection subquery is sequenced *after* the GROUP BY sorter — a
// different insertion point than ours — so decline any grouping / HAVING.
// Likewise decline `DISTINCT`: graphite's separate `USE TEMP B-TREE FOR
// DISTINCT` EQP node does not fire when a projection column is a subquery,
// so emitting the scalar node here would leave the plan still diverging
// (missing the DISTINCT sorter) rather than fully byte-exact.
if !sel.group_by.is_empty() || sel.having.is_some() || sel.distinct {
return None;
}
// Subqueries must appear only in the projection list; one in WHERE or a
// trailing clause would consume an id and shift the count off `1..n`.
let elsewhere = sel.where_clause.as_ref().is_some_and(expr_has_subquery)
|| sel.order_by.iter().any(|t| expr_has_subquery(&t.expr))
|| sel.limit.as_ref().is_some_and(expr_has_subquery)
|| sel.offset.as_ref().is_some_and(expr_has_subquery);
if elsewhere {
return None;
}
// Collect projection subqueries left-to-right (SQLite's numbering order)
// without descending into a body. Any `IN (SELECT)` / `EXISTS` in a column
// makes the set unrenderable here.
let mut subs: Vec<&Select> = Vec::new();
for col in &sel.columns {
match col {
ResultColumn::Expr { expr, .. } => {
if !collect_where_scalar_subqueries(expr, &mut subs) {
return None;
}
}
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => {}
}
}
if subs.is_empty() || !self.eqp_scalar_bodies_renderable(&subs) {
return None;
}
Some(subs)
}
/// The `ORDER BY` scalar subqueries to render as `SCALAR SUBQUERY N`, the
/// third positional analogue of [`Self::eqp_where_scalar_subqueries`] /
/// [`Self::eqp_projection_scalar_subqueries`].
///
/// An `ORDER BY` subquery is sequenced just like a WHERE one — *after* the
/// scan and *before* the `USE TEMP B-TREE FOR ORDER BY` sorter — so our single
/// insertion point right after the scan matches SQLite. The exceptions need
/// declining: a `GROUP BY` / `HAVING` shifts the node *after* the grouping
/// sorter (a different insertion point), and `DISTINCT` introduces a separate
/// `USE TEMP B-TREE FOR DISTINCT` sorter whose interplay with the ORDER BY
/// sorter we do not model here. As with the other forms, the subqueries must
/// live solely in `ORDER BY` (one elsewhere would shift the shared id counter),
/// and each must be a non-correlated, non-compound scalar `(SELECT …)` over base
/// tables with no nested subquery. Numbered `1..n` left-to-right in term order.
/// SQLite always emits such a node where we emitted none, so rendering the
/// correct one can only converge a plan, never regress.
fn eqp_orderby_scalar_subqueries<'a>(&self, sel: &'a Select) -> Option<Vec<&'a Select>> {
if !sel.group_by.is_empty() || sel.having.is_some() || sel.distinct {
return None;
}
// Subqueries must appear only in ORDER BY; one in WHERE / the projection /
// LIMIT / OFFSET would consume an id and shift the count off `1..n`.
let elsewhere = sel.where_clause.as_ref().is_some_and(expr_has_subquery)
|| sel.columns.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
})
|| sel.limit.as_ref().is_some_and(expr_has_subquery)
|| sel.offset.as_ref().is_some_and(expr_has_subquery);
if elsewhere {
return None;
}
// Collect ORDER BY subqueries left-to-right (SQLite's numbering order)
// without descending into a body. Any `IN (SELECT)` / `EXISTS` term makes
// the set unrenderable here.
let mut subs: Vec<&Select> = Vec::new();
for term in &sel.order_by {
if !collect_where_scalar_subqueries(&term.expr, &mut subs) {
return None;
}
}
if subs.is_empty() || !self.eqp_scalar_bodies_renderable(&subs) {
return None;
}
Some(subs)
}
/// The projection scalar subqueries of a *grouped* query — the GROUP BY
/// analogue of [`Self::eqp_projection_scalar_subqueries`].
///
/// With a `GROUP BY`, SQLite sequences a projection subquery's node *after*
/// the grouping sorter (`USE TEMP B-TREE FOR GROUP BY`, and any distinct-
/// aggregate b-trees) yet still *before* an ORDER BY sorter — a second
/// insertion point distinct from the after-scan one the un-grouped collectors
/// use. The caller emits the returned bodies at exactly that point, so this
/// declines the no-GROUP-BY shapes (handled by the un-grouped collectors) and
/// `DISTINCT` (its separate sorter's interplay with ORDER BY we do not model).
/// A `HAVING` with no subquery is fine (the node still numbers `1..n` after the
/// grouping sorter), but a subquery in `HAVING` / `WHERE` / `ORDER BY` /
/// `LIMIT` / `OFFSET` reorders or renumbers the nodes, so any such case
/// declines. Each body must be a non-correlated, non-compound scalar
/// `(SELECT …)` over base tables with no nested subquery. Numbered `1..n` in
/// left-to-right column order. SQLite always emits such a node where we emitted
/// none, so rendering the correct one can only converge a plan, never regress.
fn eqp_grouped_projection_scalar_subqueries<'a>(
&self,
sel: &'a Select,
) -> Option<Vec<&'a Select>> {
if sel.group_by.is_empty() || sel.distinct {
return None;
}
// Subqueries must live solely in the projection; one in WHERE / HAVING /
// ORDER BY / LIMIT / OFFSET would consume an id and shift the count off
// `1..n` (a HAVING subquery in particular reorders the nodes).
let elsewhere = sel.where_clause.as_ref().is_some_and(expr_has_subquery)
|| sel.having.as_ref().is_some_and(expr_has_subquery)
|| sel.order_by.iter().any(|t| expr_has_subquery(&t.expr))
|| sel.limit.as_ref().is_some_and(expr_has_subquery)
|| sel.offset.as_ref().is_some_and(expr_has_subquery);
if elsewhere {
return None;
}
let mut subs: Vec<&Select> = Vec::new();
for col in &sel.columns {
match col {
ResultColumn::Expr { expr, .. } => {
if !collect_where_scalar_subqueries(expr, &mut subs) {
return None;
}
}
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => {}
}
}
if subs.is_empty() || !self.eqp_scalar_bodies_renderable(&subs) {
return None;
}
Some(subs)
}
/// The access keyword plus at most one trailing `USE TEMP B-TREE FOR …` node
/// SQLite renders for an outer query reading from a *materialized* source — a
/// recursive-CTE co-routine or a multi-row `VALUES` clause in `FROM`. Both
/// share the same shape: the source has no usable index, so a lone
/// `min()`/`max()` seeks one end (`SEARCH`) while everything else `SCAN`s, and a
/// single outer `GROUP BY` / `DISTINCT` / `ORDER BY` appends one root-level
/// temp-b-tree node.
///
/// `Some((kw, trailing))` renders; `None` declines (an expression-position
/// subquery, a `min(DISTINCT …)`, or a *combination* of GROUP BY / DISTINCT /
/// ORDER BY that SQLite folds or reorders). `kw` is `"SEARCH"` for a lone
/// min/max without `GROUP BY`, else `"SCAN"`; `trailing` is the temp-b-tree
/// label (`HAVING` rides along `GROUP BY` only; an `ORDER BY` over a bare
/// aggregate is elided as a single row, so it declines).
fn eqp_materialized_outer_render(
&self,
sel: &Select,
) -> Option<(&'static str, Option<&'static str>)> {
let outer_base_ok = sel.compound.is_empty()
&& !window::has_window(sel)
&& !sel.columns.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
})
&& !sel.where_clause.as_ref().is_some_and(expr_has_subquery)
&& !sel.having.as_ref().is_some_and(expr_has_subquery);
let minmax = coroutine_outer_minmax(sel);
if !outer_base_ok || minmax == Some(true) {
return None;
}
let has_group = !sel.group_by.is_empty();
let has_having = sel.having.is_some();
let outer_kw = if !has_group && minmax == Some(false) {
"SEARCH"
} else {
"SCAN"
};
match (has_group, sel.distinct, !sel.order_by.is_empty()) {
(false, false, false) if !has_having => Some((outer_kw, None)),
(true, false, false) => Some((outer_kw, Some("GROUP BY"))),
(false, true, false) if !has_having => Some((outer_kw, Some("DISTINCT"))),
(false, false, true) if !has_having && !self.has_aggregate(sel) => {
Some((outer_kw, Some("ORDER BY")))
}
_ => None,
}
}
/// Render the `MERGE (<OP>)` tree for a top-level compound carrying a trailing
/// `ORDER BY` (see the caller in [`Self::eqp_select`]). `arms[0..=hi]` are the
/// compound arms (already cloned with the `ORDER BY` pushed in and their own
/// compound tail / `LIMIT` cleared); `ops[i]` is the operator joining the
/// accumulated head `arms[0..=i]` with `arms[i+1]`. The combination is
/// left-associative: the outermost `MERGE` uses `ops[hi-1]`, its `LEFT` child is
/// the recursively built head over `arms[0..=hi-1]` and its `RIGHT` child is
/// `arms[hi]`. Node ids are allocated head-first so the depth-first, id-sorted
/// render order matches SQLite's.
#[allow(clippy::too_many_arguments)]
fn eqp_merge_build(
&self,
arms: &[Select],
ops: &[CompoundOp],
hi: usize,
parent: i64,
next_id: &mut i64,
out: &mut Vec<(i64, i64, String)>,
params: &Params,
) -> Result<()> {
if hi == 0 {
return self.eqp_select(&arms[0], parent, next_id, out, params);
}
let detail = match ops[hi - 1] {
CompoundOp::Union => "MERGE (UNION)",
CompoundOp::UnionAll => "MERGE (UNION ALL)",
CompoundOp::Intersect => "MERGE (INTERSECT)",
CompoundOp::Except => "MERGE (EXCEPT)",
};
let merge_id = *next_id;
*next_id += 1;
out.push((merge_id, parent, String::from(detail)));
let left_id = *next_id;
*next_id += 1;
out.push((left_id, merge_id, String::from("LEFT")));
self.eqp_merge_build(arms, ops, hi - 1, left_id, next_id, out, params)?;
let right_id = *next_id;
*next_id += 1;
out.push((right_id, merge_id, String::from("RIGHT")));
self.eqp_select(&arms[hi], right_id, next_id, out, params)
}
fn eqp_select(
&self,
sel: &Select,
parent: i64,
next_id: &mut i64,
out: &mut Vec<(i64, i64, String)>,
params: &Params,
) -> Result<()> {
// Mirror run_core's comma-join → ON promotion so the plan reflects how the
// query actually runs.
let promo_tables = sel
.from
.as_ref()
.map(|f| self.comma_join_table_columns(f))
.unwrap_or_default();
let rewritten;
let sel = match promote_comma_join_ons(sel, &promo_tables) {
Some(r) => {
rewritten = r;
&rewritten
}
None => sel,
};
// A positional `GROUP BY` / `ORDER BY` term out of range is a prepare-time
// error in SQLite — reported the same for `EXPLAIN QUERY PLAN` as for the
// executed statement. `run_core` runs this check (after wildcard expansion);
// mirror it here so the plan path doesn't silently build a tree for an invalid
// query. With no `*`/`t.*` in the projection the output-column count is exactly
// `sel.columns.len()` (each result column is one output column); a wildcard
// projection needs the resolved source columns, so leave that to the scan.
if !sel
.columns
.iter()
.any(|c| matches!(c, ResultColumn::Wildcard | ResultColumn::TableWildcard(_)))
{
check_positional_terms(&sel.group_by, &sel.order_by, sel.columns.len())?;
}
// A multi-row `VALUES` clause desugars to `UNION ALL` compound arms, but
// SQLite folds them into a single `SCAN N-ROW VALUES CLAUSE` node (a lone
// `VALUES (…)` row is `SCAN CONSTANT ROW`, handled by the `FROM`-less path
// below). `value_arm_count` is how many leading compound arms belong to
// that clause; the rest are true compound continuations.
let value_arm_count = sel.values_rows.saturating_sub(1).min(sel.compound.len());
let real_compound = &sel.compound[value_arm_count..];
// A subquery in any row switches SQLite to a plural `SCAN N CONSTANT ROWS`
// shape with interposed subquery nodes we do not model, so decline.
let values_renderable =
sel.values_rows >= 1 && !values_clause_has_subquery(sel, value_arm_count);
// The folded `VALUES` clause as a single node under `parent` (only ever
// reached with `values_rows >= 2`, i.e. an `N-ROW VALUES CLAUSE`).
let push_values_node =
|next_id: &mut i64, out: &mut Vec<(i64, i64, String)>, parent: i64| {
let id = *next_id;
*next_id += 1;
out.push((
id,
parent,
alloc::format!("SCAN {}-ROW VALUES CLAUSE", sel.values_rows),
));
};
// A compound query (`… UNION / UNION ALL / INTERSECT / EXCEPT …`) renders as
// a `COMPOUND QUERY` node whose first child is the `LEFT-MOST SUBQUERY` (the
// first arm's plan) followed by one operator node per continuation, each
// parenting that arm's plan. A trailing `ORDER BY` on the whole compound
// switches SQLite to an entirely different `MERGE (UNION)` plan we don't
// model, so decline when one is present (a bare `LIMIT`/`OFFSET` keeps the
// plain tree, so it is allowed).
if !real_compound.is_empty() {
if !sel.order_by.is_empty() {
// A trailing `ORDER BY` on the whole compound switches SQLite to a
// `MERGE (<OP>)` plan: each arm is rendered with the `ORDER BY`
// pushed in (so it can stream pre-sorted) and the arms are combined
// left-associatively under nested `MERGE` nodes whose `LEFT` child is
// the accumulated head and `RIGHT` child is the next arm. We render
// this only for plain positional terms with default null-ordering: a
// named term needs per-arm position translation, an explicit
// `COLLATE` takes SQLite to a different CO-ROUTINE+materialize shape,
// and an explicit `NULLS FIRST`/`LAST` diverges from our per-arm sort
// choice — those decline. A leading or interspersed `VALUES` arm
// (folded to a `SCAN N-ROW VALUES CLAUSE`) also declines.
//
// Additionally, the merge sorts each arm by the *whole* output row
// (a set operation must compare full rows), so when the `ORDER BY`
// covers only a prefix of the output columns SQLite appends a per-arm
// `USE TEMP B-TREE FOR LAST TERM OF ORDER BY` for the rest. We only
// render when the positional terms cover *all* output columns (so the
// recursed per-arm plan needs no extra sort term); a partial cover
// declines. That needs a known column count, so a `*`/`t.*`
// projection (count unresolved here) declines too.
let ncols =
if sel.columns.iter().any(|c| {
matches!(c, ResultColumn::Wildcard | ResultColumn::TableWildcard(_))
}) {
0
} else {
sel.columns.len()
};
//
// A term may be a bare positional integer or a bare unqualified
// name/alias — SQLite resolves a compound's `ORDER BY` against the
// result-set column names, so we map each name to its output
// position and rewrite the whole `ORDER BY` to positional before
// pushing it into the arms (a name would not resolve inside a later
// arm whose columns differ). An expression, a qualified or
// `COLLATE`-wrapped term, or a non-redundant explicit `NULLS` ordering
// (one a single uniform walk can't produce) is unresolvable here and
// declines; a redundant `NULLS` (matching the walk's natural placement)
// is treated like a bare term and rewritten to positional below.
let resolve_pos = |t: &OrderTerm| -> Option<usize> {
if !redundant_nulls(t) {
return None;
}
match &t.expr {
Expr::Literal(Literal::Integer(n)) if *n >= 1 && *n as usize <= ncols => {
Some(*n as usize)
}
Expr::Column {
schema: None,
table: None,
column,
..
} => sel
.columns
.iter()
.position(|c| match c {
ResultColumn::Expr { expr, alias, .. } => alias
.as_deref()
.or(match expr {
Expr::Column { column, .. } => Some(column.as_str()),
_ => None,
})
.is_some_and(|nm| nm.eq_ignore_ascii_case(column)),
_ => false,
})
.map(|i| i + 1),
_ => None,
}
};
let mut covered = alloc::vec![false; ncols];
let mut pos_order: Vec<OrderTerm> = Vec::with_capacity(sel.order_by.len());
let mut resolvable = ncols >= 1;
for t in &sel.order_by {
match resolve_pos(t) {
Some(p) => {
covered[p - 1] = true;
pos_order.push(OrderTerm {
expr: Expr::Literal(Literal::Integer(p as i64)),
descending: t.descending,
nulls_first: None,
});
}
None => {
resolvable = false;
break;
}
}
}
let no_values = sel.values_rows == 0
&& real_compound.iter().all(|(_, arm)| arm.values_rows == 0);
if resolvable && no_values {
// A trailing `ORDER BY` turns the compound into a `MERGE` plan: each
// arm streams pre-sorted and the merge key is the explicit `ORDER BY`
// terms. Whenever a de-duplicating operator (`UNION`/`INTERSECT`/
// `EXCEPT`) governs an arm, that arm must instead emit whole rows in
// order (the set operation compares full rows), so SQLite appends the
// not-yet-covered output columns (ascending) to that arm's sort —
// surfacing as a per-arm `USE TEMP B-TREE FOR [LAST [N TERMS] OF]
// ORDER BY`. An arm is governed by a dedup op iff one appears in the
// operator suffix from that arm onward; a pure `UNION ALL` compound
// appends nothing. The head arm is governed by every operator.
let uncovered = (1..=ncols).filter(|&p| !covered[p - 1]).map(|p| OrderTerm {
expr: Expr::Literal(Literal::Integer(p as i64)),
descending: false,
nulls_first: None,
});
let full_order: Vec<OrderTerm> =
pos_order.iter().cloned().chain(uncovered).collect();
let is_dedup = |op: &CompoundOp| !matches!(op, CompoundOp::UnionAll);
let order_for = |full: bool| -> Vec<OrderTerm> {
if full {
full_order.clone()
} else {
pos_order.clone()
}
};
// The head arm is `sel` without its compound tail / whole-compound
// `LIMIT`/`OFFSET`; every arm carries its effective `ORDER BY` and the
// shared `WITH` clause.
let mut arms: Vec<Select> = Vec::with_capacity(real_compound.len() + 1);
let mut first = sel.clone();
first.compound = Vec::new();
first.order_by = order_for(real_compound.iter().any(|(op, _)| is_dedup(op)));
first.limit = None;
first.offset = None;
arms.push(first);
let mut ops: Vec<CompoundOp> = Vec::with_capacity(real_compound.len());
for (j, (op, arm)) in real_compound.iter().enumerate() {
ops.push(*op);
let arm_full = real_compound[j..].iter().any(|(o, _)| is_dedup(o));
let mut arm = arm.clone();
if arm.ctes.is_empty() {
arm.ctes = sel.ctes.clone();
}
arm.compound = Vec::new();
arm.order_by = order_for(arm_full);
arm.limit = None;
arm.offset = None;
arms.push(arm);
}
let hi = arms.len() - 1;
return self.eqp_merge_build(&arms, &ops, hi, parent, next_id, out, params);
}
return Err(Error::Unsupported(
"EXPLAIN QUERY PLAN for this query shape",
));
}
let compound_id = *next_id;
*next_id += 1;
out.push((compound_id, parent, String::from("COMPOUND QUERY")));
let left_id = *next_id;
*next_id += 1;
out.push((left_id, compound_id, String::from("LEFT-MOST SUBQUERY")));
if value_arm_count >= 1 {
// The left-most arm is a folded multi-row `VALUES` clause.
if !values_renderable {
return Err(Error::Unsupported(
"EXPLAIN QUERY PLAN for this query shape",
));
}
push_values_node(next_id, out, left_id);
} else {
// The first arm is `sel` itself without its compound tail / the outer
// modifiers (which belong to the whole compound, not the arm).
let mut first = sel.clone();
first.compound = Vec::new();
first.limit = None;
first.offset = None;
self.eqp_select(&first, left_id, next_id, out, params)?;
}
for (op, arm) in real_compound {
let detail = match op {
CompoundOp::Union => "UNION USING TEMP B-TREE",
CompoundOp::UnionAll => "UNION ALL",
CompoundOp::Intersect => "INTERSECT USING TEMP B-TREE",
CompoundOp::Except => "EXCEPT USING TEMP B-TREE",
};
let op_id = *next_id;
*next_id += 1;
out.push((op_id, compound_id, String::from(detail)));
// The `WITH` clause is shared across all arms; propagate it to an arm
// that carries none so a CTE reference in a later arm still resolves.
let mut arm = arm.clone();
if arm.ctes.is_empty() {
arm.ctes = sel.ctes.clone();
}
self.eqp_select(&arm, op_id, next_id, out, params)?;
}
return Ok(());
}
// No true compound continuation. A pure multi-row `VALUES` clause folds to
// one node; a subquery-bearing one declines. (A single-row `VALUES` has no
// value arms and falls through to the `FROM`-less `SCAN CONSTANT ROW` path.)
if value_arm_count >= 1 {
if !values_renderable {
return Err(Error::Unsupported(
"EXPLAIN QUERY PLAN for this query shape",
));
}
push_values_node(next_id, out, parent);
return Ok(());
}
let Some(from) = &sel.from else {
// A `FROM`-less SELECT scans a single synthetic constant row. SQLite
// renders it `SCAN CONSTANT ROW` (this also covers a single-row
// `VALUES(...)`, which desugars to a no-compound, no-FROM select).
// A multi-row VALUES / UNION desugars to a compound (its own tree), so
// only the no-compound case is rendered here.
if !sel.compound.is_empty() {
return Ok(());
}
if !select_no_from_has_subquery(sel) {
// No subquery: the bare constant row.
let id = *next_id;
*next_id += 1;
out.push((id, parent, String::from("SCAN CONSTANT ROW")));
} else if let Some(subs) = self
.eqp_where_scalar_subqueries(sel)
.or_else(|| self.eqp_projection_scalar_subqueries(sel))
{
// A clean, single-position set of non-correlated scalar subqueries:
// SQLite renders the constant row followed by a `SCALAR SUBQUERY N`
// sibling per subquery (numbered left-to-right, body recursed as the
// child). A cross-position set (subqueries in both projection and
// WHERE) is reverse/renumbered and an `EXISTS` / `IN (SELECT)` is a
// different node shape — those collectors decline, leaving the prior
// emit-nothing behaviour.
let cr_id = *next_id;
*next_id += 1;
out.push((cr_id, parent, String::from("SCAN CONSTANT ROW")));
for (i, body) in subs.iter().enumerate() {
let sid = *next_id;
*next_id += 1;
out.push((sid, parent, alloc::format!("SCALAR SUBQUERY {}", i + 1)));
self.eqp_select(body, sid, next_id, out, params)?;
}
}
return Ok(());
};
// A view source has no b-tree of its own: SQLite flattens the view body into
// the outer plan exactly as it does a derived table. Rewrite `FROM v` into
// `FROM (<view body>) AS v` and recurse, reusing the derived-table machinery
// (which renders the flattenable shapes and declines the rest). Previously any
// view source crashed EQP with a malformed `no such table: <view>` (the view
// name fell through to a base-table `table_meta` lookup). Only the no-join,
// unaliased-name case is rewritten; a view combined with a join is left to the
// existing decline path.
if from.joins.is_empty()
&& from.first.subquery.is_none()
&& from.first.tvf_args.is_none()
&& self.lookup_cte(&from.first.name, None).is_none()
&& !sel
.ctes
.iter()
.any(|c| c.name.eq_ignore_ascii_case(&from.first.name))
&& self.is_view(&from.first.name)
&& let Some(view_select) = self
.schema
.objects()
.iter()
.find(|o| {
o.obj_type == crate::schema::ObjectType::View
&& o.name.eq_ignore_ascii_case(&from.first.name)
})
.and_then(|o| o.sql.as_deref())
.and_then(|s| match sql::parse_one(s) {
Ok(Statement::CreateView(cv)) => Some(cv.select),
_ => None,
})
{
let mut rewritten = sel.clone();
if let Some(f) = rewritten.from.as_mut() {
let view_name = f.first.name.clone();
f.first.subquery = Some(view_select);
// Keep the view name as the source's bind qualifier so a
// `v.col` reference still resolves once flattened.
if f.first.alias.is_none() {
f.first.alias = Some(view_name);
}
}
return self.eqp_select(&rewritten, parent, next_id, out, params);
}
let label = eqp_label(&from.first);
// Cost-based N-table (≥3) join reorder: when the executor drives the join in
// a permuted order (`ntable_join_order`), the EQP must render its SCAN/SEARCH
// nodes in that same execution order. Bind `join_from` to the permuted clause
// (else the declared one) and route every multi-table join-EQP branch through
// it, so both paths stay in lockstep. `ntable_join_order` fires only for
// `from.joins.len() >= 2`, so the single-table rendering is untouched.
let ntable_reordered = self.ntable_join_order(sel, from).map(|(f, _, _, _)| f);
let join_from: &FromClause = ntable_reordered.as_ref().unwrap_or(from);
// A virtual table scans through its module, not a b-tree — render sqlite's
// `VIRTUAL TABLE INDEX <n>:<str>` node and skip the regular-table planning
// (which would otherwise parse the CREATE VIRTUAL TABLE as a CREATE TABLE
// and fail).
if from.joins.is_empty()
&& from.first.subquery.is_none()
&& from.first.tvf_args.is_none()
&& self.lookup_cte(&from.first.name, None).is_none()
&& self.is_virtual_table(&from.first.name)
{
let detail = self.eqp_vtab_detail(&from.first.name, &label, sel, params)?;
let id = *next_id;
*next_id += 1;
out.push((id, parent, detail));
return Ok(());
}
// A subquery FROM source — a derived table (`FROM (<body>) [AS x]`) or a
// `WITH`-clause CTE reference (`FROM c`, whose body is the CTE definition) —
// has no b-tree to look up, so it must be handled before `table_meta` (which
// would fail with an empty name for a derived table, or a `no such table: c`
// for a CTE). SQLite treats the two identically here: it *flattens* most
// such sources into the outer plan (`FROM (SELECT * FROM t)` and a CTE
// `c AS (SELECT * FROM t)` both read as a plain `SCAN t`), and predicting
// which bodies flatten is the codegen-order-fragile territory we don't
// model. The deterministic shape is a *constant-row* body: SQLite can't
// flatten it (there is no table to merge), so it always materializes as a
// `CO-ROUTINE` whose child is the body's `SCAN CONSTANT ROW`, followed by
// the outer `SCAN`. We render that byte-exactly only when the label is
// deterministic — a derived table's *alias* (an unaliased one gets the
// fragile `(subquery-N)` numbering, so it has none) or the CTE's own name —
// and the outer query adds no further plan nodes (`DISTINCT`/`GROUP BY`/
// `ORDER BY`/a compound/an expression-position subquery would each add one).
let is_cte_name = |n: &str| sel.ctes.iter().any(|c| c.name.eq_ignore_ascii_case(n));
// Resolve the body + its CO-ROUTINE label. A derived table's label is its
// alias; a CTE reference's label is the CTE name (only when the reference is
// itself unaliased and not a TVF call).
let derived: Option<(&Select, Option<&str>)> = if let Some(sub) = &from.first.subquery {
Some((sub.as_ref(), from.first.alias.as_deref()))
} else if from.first.tvf_args.is_none() && from.first.alias.is_none() {
sel.ctes
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&from.first.name))
.map(|c| (c.select.as_ref(), Some(c.name.as_str())))
} else {
None
};
// A `WITH c AS MATERIALIZED (…)` hint forces SQLite to materialize the CTE
// rather than flatten it into the outer plan, even when the body is a
// trivially-inlinable single source.
let cte_forces_materialize = from.first.subquery.is_none()
&& from.first.tvf_args.is_none()
&& from.first.alias.is_none()
&& sel.ctes.iter().any(|c| {
c.name.eq_ignore_ascii_case(&from.first.name) && c.materialized == Some(true)
});
if from.joins.is_empty()
&& let Some((sub, co_label)) = derived
{
let from_cte = from.first.subquery.is_none();
// An explicit `MATERIALIZED` hint renders a `MATERIALIZE <name>`
// node whose child is the body's plan (recursed normally),
// followed by the outer query's `{SCAN|SEARCH} <name>` plus one
// optional trailing temp-b-tree node — the same outer shape as the
// co-routine paths, just a forced materialization of any body.
if from_cte
&& cte_forces_materialize
&& let Some(name) = co_label
{
// A pure *multi-row* `VALUES` body materializes as
// `SCAN {N} CONSTANT ROWS` — the CTE-materialization phrasing,
// NOT the FROM-source's `SCAN {N}-ROW VALUES CLAUSE` the body
// would otherwise recurse into. A single-row `VALUES(…)`
// recurses to the correct singular `SCAN CONSTANT ROW`. A
// subquery-bearing row would need a `SCALAR SUBQUERY` node we
// don't model, so decline rather than mis-render.
let body_arm = sub.values_rows.saturating_sub(1).min(sub.compound.len());
let body_pure_values = body_arm >= 1
&& body_arm == sub.compound.len()
&& !values_clause_has_subquery(sub, body_arm);
let body_values_with_subquery = sub.values_rows >= 1
&& !body_pure_values
&& values_clause_has_subquery(sub, body_arm);
if body_values_with_subquery {
return Err(Error::Unsupported(
"EXPLAIN QUERY PLAN for this query shape",
));
}
if let Some((outer_kw, trailing)) = self.eqp_materialized_outer_render(sel) {
let mat_id = *next_id;
*next_id += 1;
out.push((mat_id, parent, alloc::format!("MATERIALIZE {name}")));
if body_pure_values {
let rid = *next_id;
*next_id += 1;
out.push((
rid,
mat_id,
alloc::format!("SCAN {} CONSTANT ROWS", sub.values_rows),
));
} else {
self.eqp_select(sub, mat_id, next_id, out, params)?;
}
let scan_id = *next_id;
*next_id += 1;
out.push((scan_id, parent, alloc::format!("{outer_kw} {name}")));
if let Some(lbl) = trailing {
let tid = *next_id;
*next_id += 1;
out.push((tid, parent, alloc::format!("USE TEMP B-TREE FOR {lbl}")));
}
return Ok(());
}
}
// A *multi-row* `VALUES` clause as a derived `FROM` source is the
// values clause itself — SQLite reads it directly (no co-routine,
// unlike a one-row `VALUES` or a flattening sub-SELECT) as a single
// `{SCAN|SEARCH} N-ROW VALUES CLAUSE` node plus the outer query's
// one optional trailing temp-b-tree node. (A subquery in any row
// switches it to the plural `SCAN N CONSTANT ROWS` shape, so decline
// via `values_clause_has_subquery`.)
if !from_cte {
let value_arm_count = sub.values_rows.saturating_sub(1).min(sub.compound.len());
let pure_values = value_arm_count >= 1
&& value_arm_count == sub.compound.len()
&& !values_clause_has_subquery(sub, value_arm_count);
if pure_values
&& let Some((outer_kw, trailing)) = self.eqp_materialized_outer_render(sel)
{
let id = *next_id;
*next_id += 1;
out.push((
id,
parent,
alloc::format!("{outer_kw} {}-ROW VALUES CLAUSE", sub.values_rows),
));
if let Some(lbl) = trailing {
let tid = *next_id;
*next_id += 1;
out.push((tid, parent, alloc::format!("USE TEMP B-TREE FOR {lbl}")));
}
return Ok(());
}
}
// A *recursive* CTE — a self-referential compound body — can't
// flatten. SQLite renders it as a `CO-ROUTINE <name>` whose two
// children are `SETUP` (the non-recursive anchor's plan) and
// `RECURSIVE STEP` (the recursive arm's plan, in which the
// self-reference reads as a plain `SCAN <name>` of the
// materialized table), followed by the outer `SCAN <name>`. We
// render the canonical two-arm shape — one anchor arm that does
// not name the CTE, one recursive arm whose `FROM` is a bare
// reference to it — when the outer query adds no further nodes.
if from_cte
&& let Some(name) = co_label
&& sub.compound.len() == 1
&& sub.order_by.is_empty()
&& sub.limit.is_none()
&& sub.offset.is_none()
{
let rec_arm = &sub.compound[0].1;
let mut anchor = sub.clone();
anchor.compound.clear();
let is_recursive =
!references_name_select(&anchor, name) && references_name_select(rec_arm, name);
// The recursive arm's only source is the bare CTE
// reference (no join, no alias, no subquery), and
// neither it nor the outer query carries an
// expression-position subquery that would add nodes.
let rec_simple =
rec_arm.from.as_ref().is_some_and(|f| {
f.joins.is_empty()
&& f.first.name.eq_ignore_ascii_case(name)
&& f.first.subquery.is_none()
&& f.first.tvf_args.is_none()
&& f.first.alias.is_none()
}) && !rec_arm.columns.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
}) && !rec_arm.where_clause.as_ref().is_some_and(expr_has_subquery);
// The access keyword plus at most ONE trailing
// temp-b-tree node SQLite renders for the outer query over
// the materialized co-routine (shared with the multi-row
// `VALUES`-in-`FROM` path). `None` = decline.
let outer_render = self.eqp_materialized_outer_render(sel);
if is_recursive
&& rec_simple
&& let Some((outer_kw, trailing)) = outer_render
{
let co_id = *next_id;
*next_id += 1;
out.push((co_id, parent, alloc::format!("CO-ROUTINE {name}")));
let setup_id = *next_id;
*next_id += 1;
out.push((setup_id, co_id, String::from("SETUP")));
// The anchor names no CTE, so a normal recursion
// renders its plan safely.
self.eqp_select(&anchor, setup_id, next_id, out, params)?;
let step_id = *next_id;
*next_id += 1;
out.push((step_id, co_id, String::from("RECURSIVE STEP")));
let rec_scan = *next_id;
*next_id += 1;
out.push((rec_scan, step_id, alloc::format!("SCAN {name}")));
let scan_id = *next_id;
*next_id += 1;
out.push((scan_id, parent, alloc::format!("{outer_kw} {name}")));
if let Some(lbl) = trailing {
let tid = *next_id;
*next_id += 1;
out.push((tid, parent, alloc::format!("USE TEMP B-TREE FOR {lbl}")));
}
return Ok(());
}
}
// A *multi-row* `VALUES` clause as a CTE body cannot flatten into
// the outer plan: SQLite materializes it as a `CO-ROUTINE <name>`
// whose single child is `SCAN {N} CONSTANT ROWS` (note the plural
// "CONSTANT ROWS" phrasing — distinct from the `SCAN {N}-ROW VALUES
// CLAUSE` node a `VALUES`-in-`FROM` source folds to), followed by the
// outer query's `{SCAN|SEARCH} <name>` plus one optional trailing
// temp-b-tree node. A single-row body falls through to the
// `body_is_const_row` path below (`SCAN CONSTANT ROW`, singular).
if from_cte && let Some(name) = co_label {
let value_arm_count = sub.values_rows.saturating_sub(1).min(sub.compound.len());
let pure_values = value_arm_count >= 1
&& value_arm_count == sub.compound.len()
&& !values_clause_has_subquery(sub, value_arm_count);
if pure_values
&& let Some((outer_kw, trailing)) = self.eqp_materialized_outer_render(sel)
{
let co_id = *next_id;
*next_id += 1;
out.push((co_id, parent, alloc::format!("CO-ROUTINE {name}")));
let rows_id = *next_id;
*next_id += 1;
out.push((
rows_id,
co_id,
alloc::format!("SCAN {} CONSTANT ROWS", sub.values_rows),
));
let scan_id = *next_id;
*next_id += 1;
out.push((scan_id, parent, alloc::format!("{outer_kw} {name}")));
if let Some(lbl) = trailing {
let tid = *next_id;
*next_id += 1;
out.push((tid, parent, alloc::format!("USE TEMP B-TREE FOR {lbl}")));
}
return Ok(());
}
}
let body_is_const_row =
sub.from.is_none() && sub.compound.is_empty() && !select_no_from_has_subquery(sub);
let outer_adds_no_nodes = !sel.distinct
&& sel.compound.is_empty()
&& sel.group_by.is_empty()
&& sel.having.is_none()
&& sel.order_by.is_empty()
&& !sel.columns.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
})
&& !sel.where_clause.as_ref().is_some_and(expr_has_subquery);
if let (Some(label), true, true) = (co_label, body_is_const_row, outer_adds_no_nodes) {
let co_id = *next_id;
*next_id += 1;
out.push((co_id, parent, alloc::format!("CO-ROUTINE {label}")));
// The body renders as its `SCAN CONSTANT ROW` child of the
// co-routine node.
self.eqp_select(sub, co_id, next_id, out, params)?;
let scan_id = *next_id;
*next_id += 1;
out.push((scan_id, parent, alloc::format!("SCAN {label}")));
return Ok(());
}
// A *flattenable* body: SQLite merges the subquery into the outer
// plan (`FROM (SELECT * FROM t)` reads as a plain `SCAN t`). When the
// outer is a bare `SELECT *` over the source with no other clauses,
// `SELECT * FROM (<body>)` is plan-equivalent to `<body>` itself, so
// we render it by recursing into the body under the SAME parent (no
// `CO-ROUTINE` wrapper, no outer `SCAN`). We restrict to the
// provably-equivalent subset:
// - a *pure-wildcard* outer with no `WHERE`. A narrower projection
// (`SELECT a FROM …`) would re-derive the covering-index choice
// after the merge, and an outer `WHERE` pushes into the flattened
// scan (turning a `SCAN` into a `SEARCH`) — neither is captured by
// recursing into the raw body. (A `WITH` clause on the outer is
// expected for a CTE reference and adds no node when its only
// reference is the single flattened source; a derived table keeps
// the original `no outer CTE` requirement.)
// - a body that is a single *base-table* scan: no inner join
// (SQLite cost-reorders those, diverging from our plan), no inner
// CTE/view/vtab/subquery source, and no aggregate / `DISTINCT` /
// compound / window / `LIMIT`/`OFFSET` (each makes SQLite
// materialize a `CO-ROUTINE` instead). An inner `WHERE` /
// `ORDER BY` is fine — the same planner renders it identically. An
// inner projection/`WHERE` subquery would add `SCALAR SUBQUERY`
// nodes we don't model, so it is excluded.
let outer_is_pure_wildcard = matches!(sel.columns.as_slice(), [ResultColumn::Wildcard])
&& sel.where_clause.is_none()
&& (from_cte || sel.ctes.is_empty())
&& outer_adds_no_nodes;
let inner_base_scan_no_limit = sub.from.as_ref().is_some_and(|f| {
f.joins.is_empty()
&& f.first.subquery.is_none()
&& f.first.tvf_args.is_none()
&& self.lookup_cte(&f.first.name, None).is_none()
&& !is_cte_name(&f.first.name)
&& !self.is_view(&f.first.name)
&& !self.is_virtual_table(&f.first.name)
}) && sub.ctes.is_empty()
&& !select_is_aggregate_query(sub)
&& !sub.distinct
&& sub.compound.is_empty()
&& sub.window_defs.is_empty()
&& !sub.columns.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
})
&& !sub.where_clause.as_ref().is_some_and(expr_has_subquery);
let inner_is_base_table_scan =
inner_base_scan_no_limit && sub.limit.is_none() && sub.offset.is_none();
// A bare `LIMIT` body (no `OFFSET`) also flattens under a *narrower*
// projection — SQLite substitutes the outer projection into the `LIMIT`
// body (`SELECT a FROM (SELECT * FROM t LIMIT 5)` → `SCAN t USING
// COVERING INDEX`). Only when the outer carries no `WHERE`: a predicate
// over a `LIMIT` body is filter-after-limit, which SQLite materializes
// as a `CO-ROUTINE` (handled below) rather than folding into the scan.
let inner_scan_flatten_ok = inner_is_base_table_scan
|| (inner_base_scan_no_limit && sub.offset.is_none() && sel.where_clause.is_none());
// A pure-wildcard outer over a single base-table body recurses into the
// body's own plan. A bare `LIMIT` body (no `OFFSET`) flattens the same
// way — SQLite renders just the body's `SCAN`/index walk, the `LIMIT`
// adding no plan node (`SELECT * FROM (SELECT * FROM t LIMIT 5)` →
// `SCAN t`, `(… ORDER BY b LIMIT 5)` → `SCAN t USING INDEX tb`). An
// `OFFSET` body materializes as a `CO-ROUTINE` instead, so it is excluded
// and declines (as does a narrower / `WHERE`-bearing outer over a `LIMIT`
// body — those need separate merge handling).
if outer_is_pure_wildcard && inner_base_scan_no_limit && sub.offset.is_none() {
return self.eqp_select(sub, parent, next_id, out, params);
}
// The general flatten: the outer may *narrow* the projection (`SELECT a`
// / `SELECT a,b` instead of `*`) and/or carry a `WHERE`. SQLite folds the
// derived table away, so the outer projection picks the access path
// (`SELECT a` over an indexed table → a COVERING-INDEX scan) and the
// outer predicate tightens a `SCAN` into a `SEARCH`. We reproduce it by
// rebuilding the inner body with the outer projection substituted and the
// outer predicate ANDed in, then recursing — `eqp_select` re-derives the
// covering-index / seek from the merged body exactly — but only when the
// merge is provably name-sound:
// - the inner projection's output columns are *knowable* and each
// maps to a base column we can substitute: all *bare columns*
// (aliased or not — `a AS aa` maps the output `aa` back to base `a`),
// or a single `*` / all `t.*` over the base table (output names are
// the base table's columns). A computed `a+1 AS x` projection has no
// base column to seek on, so the merge declines.
// - every outer projection column is a bare `Column`, and the outer
// projection/predicate reference only names the source actually
// outputs (else SQLite raises `no such column`, so we decline). A
// wildcard outer keeps the body's own projection. A qualifier may be
// the derived source's own alias / CTE name (`co_label`) — it refers
// to the source itself, so it is *stripped* on merge (`s.a` → `a`);
// any *other* qualifier would not resolve, so the merge declines.
// The outer must still add no other nodes and the body be a single
// base-table scan (same gate as the pure-wildcard case).
let bind_matches = |table: &Option<String>| {
table
.as_deref()
.is_none_or(|t| co_label.is_some_and(|b| t.eq_ignore_ascii_case(b)))
};
let outer_is_wildcard = matches!(sel.columns.as_slice(), [ResultColumn::Wildcard]);
let outer_proj_bare_columns = !sel.columns.is_empty()
&& sel.columns.iter().all(|c| match c {
ResultColumn::Expr { expr, alias, .. } => {
alias.is_none()
&& matches!(expr, Expr::Column { table, .. } if bind_matches(table))
}
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
});
let outer_flattenable_proj = outer_is_wildcard || outer_proj_bare_columns;
let outer_general_flatten =
outer_flattenable_proj && (from_cte || sel.ctes.is_empty()) && outer_adds_no_nodes;
// The derived source's `(output_name, base_column)` map. For a bare-
// column inner each `[base] AS [out]` pair maps the output back to its
// base column; for a `*` / `t.*` inner the outputs *are* the base
// table's columns (identity pairs). A computed inner projection has no
// base column, so it yields `None` and the merge declines.
let inner_bare_cols: Option<Vec<(String, String)>> = sub
.columns
.iter()
.map(|c| match c {
ResultColumn::Expr {
expr: Expr::Column { column, .. },
alias,
..
} => Some((
alias.clone().unwrap_or_else(|| column.clone()),
column.clone(),
)),
_ => None,
})
.collect();
let inner_all_wildcard = !sub.columns.is_empty()
&& sub
.columns
.iter()
.all(|c| matches!(c, ResultColumn::Wildcard | ResultColumn::TableWildcard(_)));
let derived_map: Option<Vec<(String, String)>> = inner_bare_cols.or_else(|| {
inner_all_wildcard
.then(|| {
sub.from
.as_ref()
.and_then(|f| self.table_meta(&f.first.name, None).ok())
.map(|m| {
m.columns
.iter()
.map(|c| (c.name.clone(), c.name.clone()))
.collect()
})
})
.flatten()
});
let outer_refs_resolve = |map: &[(String, String)]| {
let names: Vec<String> = map.iter().map(|(o, _)| o.clone()).collect();
sel.columns.iter().all(|c| match c {
ResultColumn::Expr { expr, .. } => all_column_names_in(expr, &names),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => true,
}) && sel
.where_clause
.as_ref()
.is_none_or(|p| all_column_names_in(p, &names))
};
let outer_where_qualifiers_ok = sel
.where_clause
.as_ref()
.is_none_or(|p| all_qualifiers_match(p, co_label));
if let (true, true, true, Some(rename)) = (
outer_general_flatten,
inner_scan_flatten_ok,
outer_where_qualifiers_ok && !(outer_is_wildcard && sel.where_clause.is_none()),
derived_map.as_ref(),
) && outer_refs_resolve(rename)
{
let mut merged = sub.clone();
if !outer_is_wildcard {
let mut cols = sel.columns.clone();
for c in &mut cols {
if let ResultColumn::Expr { expr, .. } = c {
rewrite_flattened_column(expr, co_label, rename);
}
}
merged.columns = cols;
}
if let Some(pred) = &sel.where_clause {
let mut pred = pred.clone();
rewrite_flattened_column(&mut pred, co_label, rename);
merged.where_clause = Some(match merged.where_clause.take() {
Some(inner) => Expr::Binary {
op: BinaryOp::And,
left: Box::new(inner),
right: Box::new(pred),
},
None => pred,
});
}
return self.eqp_select(&merged, parent, next_id, out, params);
}
// A bare-`LIMIT` body (no `OFFSET`) under an outer `ORDER BY` (and no
// `WHERE`) flattens: SQLite pushes the outer projection + `ORDER BY` into
// the flattened scan (`SELECT * FROM (SELECT * FROM t LIMIT 5) ORDER BY b`
// → `SCAN t USING INDEX tb`). We merge the outer projection + `ORDER BY`
// into the `LIMIT` body and recurse — the body's own `eqp_select` renders
// the ORDER-BY index walk / temp-b-tree. Same name-soundness gate as the
// projection merge, plus every `ORDER BY` column must name a source output
// (a positional term needs no rename).
// Restricted to a *single* ORDER BY term: a lone term is either fully
// served by an index walk (`SCAN … USING INDEX`) or fully unsorted
// (`SCAN … + USE TEMP B-TREE FOR ORDER BY`), both matching SQLite's outer
// plan; a multi-term ORDER BY whose leading prefix is indexed but tail is
// not would render a partial-sort `LAST TERM` here while SQLite full-sorts
// the materialized `LIMIT` rows — so multi-term declines.
if outer_flattenable_proj
&& sel.order_by.len() == 1
&& sel.where_clause.is_none()
&& sel.group_by.is_empty()
&& sel.having.is_none()
&& !sel.distinct
&& sel.compound.is_empty()
&& (from_cte || sel.ctes.is_empty())
&& inner_base_scan_no_limit
&& sub.limit.is_some()
&& sub.offset.is_none()
&& !sel.columns.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
})
&& !sel.order_by.iter().any(|t| expr_has_subquery(&t.expr))
&& sel
.order_by
.iter()
.all(|t| all_qualifiers_match(&t.expr, co_label))
&& let Some(rename) = derived_map.as_ref()
{
let names: Vec<String> = rename.iter().map(|(o, _)| o.clone()).collect();
let order_refs_ok = sel
.order_by
.iter()
.all(|t| all_column_names_in(&t.expr, &names));
if outer_refs_resolve(rename) && order_refs_ok {
let mut merged = sub.clone();
if !outer_is_wildcard {
let mut cols = sel.columns.clone();
for c in &mut cols {
if let ResultColumn::Expr { expr, .. } = c {
rewrite_flattened_column(expr, co_label, rename);
}
}
merged.columns = cols;
}
let mut order_by = sel.order_by.clone();
for t in &mut order_by {
rewrite_flattened_column(&mut t.expr, co_label, rename);
}
merged.order_by = order_by;
return self.eqp_select(&merged, parent, next_id, out, params);
}
}
// A *compound* CTE/derived body that carries at least one dedup set
// operator (`UNION` / `INTERSECT` / `EXCEPT`) cannot flatten into the
// outer plan: SQLite materializes it as a `CO-ROUTINE <name>` whose
// single child is the body's `COMPOUND QUERY` plan (recursed
// normally — `LEFT-MOST SUBQUERY` plus one operator node per arm,
// including any interspersed `UNION ALL`), followed by the outer
// query's `{SCAN|SEARCH} <name>` plus at most one trailing temp-b-tree
// node. We render this only when:
// - the label is deterministic (a derived table's alias or the CTE
// name — an unaliased derived table gets the codegen-fragile
// `(subquery-N)` numbering, so it has none and declines);
// - some arm is a dedup operator. A body whose every operator is
// `UNION ALL` streams without a dedup b-tree and *flattens* to a
// bare `COMPOUND QUERY` (no co-routine) — a codegen-fragile shape we
// don't model — so it declines;
// - the body has no `ORDER BY` (which would switch it to the
// `MERGE (…)` plan the recursion declines anyway) and the outer
// query adds no `WHERE` (a predicate pushes into the arms,
// re-deriving their scans) beyond the nodes
// `eqp_materialized_outer_render` accounts for.
if let Some(name) = co_label {
let body_arm = sub.values_rows.saturating_sub(1).min(sub.compound.len());
let real = &sub.compound[body_arm..];
let any_dedup = real.iter().any(|(op, _)| {
matches!(
op,
CompoundOp::Union | CompoundOp::Intersect | CompoundOp::Except
)
});
if any_dedup
&& sub.order_by.is_empty()
&& sel.where_clause.is_none()
&& let Some((outer_kw, trailing)) = self.eqp_materialized_outer_render(sel)
{
let co_id = *next_id;
*next_id += 1;
out.push((co_id, parent, alloc::format!("CO-ROUTINE {name}")));
// The body's `COMPOUND QUERY` subtree renders as the
// co-routine node's child via the normal compound path.
self.eqp_select(sub, co_id, next_id, out, params)?;
let scan_id = *next_id;
*next_id += 1;
out.push((scan_id, parent, alloc::format!("{outer_kw} {name}")));
if let Some(lbl) = trailing {
let tid = *next_id;
*next_id += 1;
out.push((tid, parent, alloc::format!("USE TEMP B-TREE FOR {lbl}")));
}
return Ok(());
}
}
// An *aggregate* or *DISTINCT* CTE/derived body over a single base
// table also can't flatten: SQLite materializes it as a
// `CO-ROUTINE <name>` whose child is the body's own plan, then the outer
// `{SCAN|SEARCH} <name>` plus at most one trailing temp-b-tree — the same
// wrapper as the compound case. Rendered only with a deterministic
// label, a single-base-table body with no compound / window / `ORDER BY`
// / `LIMIT` / nested subquery, no outer `WHERE`, and an outer shape
// `eqp_materialized_outer_render` accounts for. The body child is the
// body's own (already byte-exact) aggregate / DISTINCT plan.
if let Some(name) = co_label {
let body_single_base_table = sub.from.as_ref().is_some_and(|f| {
f.joins.is_empty()
&& f.first.subquery.is_none()
&& f.first.tvf_args.is_none()
&& self.lookup_cte(&f.first.name, None).is_none()
&& !is_cte_name(&f.first.name)
&& !self.is_view(&f.first.name)
&& !self.is_virtual_table(&f.first.name)
});
let renderable_aggregate_body = (select_is_aggregate_query(sub) || sub.distinct)
&& body_single_base_table
&& sub.compound.is_empty()
&& sub.window_defs.is_empty()
&& sub.order_by.is_empty()
&& sub.limit.is_none()
&& sub.offset.is_none()
&& !sub.columns.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
})
&& !sub.where_clause.as_ref().is_some_and(expr_has_subquery);
if renderable_aggregate_body
&& sel.where_clause.is_none()
&& let Some((outer_kw, trailing)) = self.eqp_materialized_outer_render(sel)
{
let co_id = *next_id;
*next_id += 1;
out.push((co_id, parent, alloc::format!("CO-ROUTINE {name}")));
self.eqp_select(sub, co_id, next_id, out, params)?;
let scan_id = *next_id;
*next_id += 1;
out.push((scan_id, parent, alloc::format!("{outer_kw} {name}")));
if let Some(lbl) = trailing {
let tid = *next_id;
*next_id += 1;
out.push((tid, parent, alloc::format!("USE TEMP B-TREE FOR {lbl}")));
}
return Ok(());
}
}
// A `LIMIT`/`OFFSET` body that does NOT flatten materializes as a
// CO-ROUTINE whose child is the body's own plan, then the outer
// `{SCAN|SEARCH} <name>` (+ optional trailing temp-b-tree). SQLite
// flattens a *bare* `LIMIT` body under a pure-wildcard / narrower /
// outer-`ORDER BY` outer (the pure-wildcard case is handled above; the
// narrower / outer-`ORDER BY` cases still decline), but takes the
// co-routine path once an `OFFSET`, an outer `WHERE`, or an outer
// aggregate is present (each changes the semantics vs a plain flatten).
if let Some(name) = co_label {
let body_single_base_table = sub.from.as_ref().is_some_and(|f| {
f.joins.is_empty()
&& f.first.subquery.is_none()
&& f.first.tvf_args.is_none()
&& self.lookup_cte(&f.first.name, None).is_none()
&& !is_cte_name(&f.first.name)
&& !self.is_view(&f.first.name)
&& !self.is_virtual_table(&f.first.name)
});
let limit_body = body_single_base_table
&& (sub.limit.is_some() || sub.offset.is_some())
&& sub.compound.is_empty()
&& sub.window_defs.is_empty()
&& !select_is_aggregate_query(sub)
&& !sub.distinct
&& !sub.columns.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
})
&& !sub.where_clause.as_ref().is_some_and(expr_has_subquery);
let non_flattenable = sub.offset.is_some()
|| sel.where_clause.is_some()
|| select_is_aggregate_query(sel);
if limit_body
&& non_flattenable
&& let Some((outer_kw, trailing)) = self.eqp_materialized_outer_render(sel)
{
let co_id = *next_id;
*next_id += 1;
out.push((co_id, parent, alloc::format!("CO-ROUTINE {name}")));
self.eqp_select(sub, co_id, next_id, out, params)?;
let scan_id = *next_id;
*next_id += 1;
out.push((scan_id, parent, alloc::format!("{outer_kw} {name}")));
if let Some(lbl) = trailing {
let tid = *next_id;
*next_id += 1;
out.push((tid, parent, alloc::format!("USE TEMP B-TREE FOR {lbl}")));
}
return Ok(());
}
}
// Any other shape (a narrowing/clause-bearing outer, a table-bearing
// body we can't prove flattenable, a `UNION ALL`-only compound body, or
// an outer query that emits extra nodes) is not one we render
// byte-exactly.
return Err(Error::Unsupported(
"EXPLAIN QUERY PLAN for this query shape",
));
}
// A subquery/CTE/view source that survives to here is combined with a join —
// SQLite cost-reorders such plans (BLOOM FILTER / AUTOMATIC COVERING INDEX /
// table reordering) into a shape we can't render byte-exactly. Decline
// cleanly rather than fall through to `table_meta` with an empty name (which
// crashed with a malformed `no such table: `), a `no such table: c` for a
// CTE, a `no such table: v` for a view, or a malformed empty-named
// `SCAN AS s` node for a derived join source.
if from.first.subquery.is_some()
|| is_cte_name(&from.first.name)
|| self.is_view(&from.first.name)
|| from.joins.iter().any(|j| {
j.table.subquery.is_some()
|| is_cte_name(&j.table.name)
|| self.is_view(&j.table.name)
})
{
return Err(Error::Unsupported(
"EXPLAIN QUERY PLAN for this query shape",
));
}
// First source.
let meta = self.table_meta(&from.first.name, from.first.alias.as_deref())?;
// `NOT INDEXED` forbids every index on this table, so SQLite plans a plain
// full `SCAN` (a lone `min`/`max` still reads one end and reads `SEARCH t`)
// with no index walk — a `WHERE` seek, covering scan, ORDER-BY index walk, and
// MULTI-INDEX OR all collapse to that scan, and the ORDER BY / GROUP BY /
// DISTINCT sorters re-appear. The executor already honors the hint (rows are
// unchanged); this brings the plan into lockstep. Only the no-join single-table
// case carries the hint here.
// A WITHOUT ROWID table is excluded: SQLite still serves its clustered-PK and
// even a covering secondary-index seek under the hint, which the ordinary
// `eqp_access` path already renders in lockstep — so only plain rowid tables
// take the collapse-to-SCAN handling here.
let hint_not_indexed = from.joins.is_empty()
&& !meta.without_rowid
&& matches!(from.first.index_hint, Some(IndexHint::NotIndexed));
// A top-level OR of seekable disjuncts is a MULTI-INDEX OR plan (multiple
// rows); otherwise a single SCAN/SEARCH node.
// The single-table scan-line text, captured so the GROUP BY / DISTINCT
// temp-b-tree decision below can restrict itself to the clean bare-`SCAN`
// case (no covering index, no seek to surprise the access order).
let mut single_scan_detail: Option<String> = None;
if from.joins.is_empty()
&& !hint_not_indexed
&& self.eqp_or_plan(
&label,
&from.first.name,
&meta,
sel.where_clause.as_ref(),
parent,
next_id,
out,
params,
)?
{
// rows already emitted
} else {
let detail = if hint_not_indexed {
// `NOT INDEXED`: no *secondary* index may be used, but the rowid /
// INTEGER PRIMARY KEY / WITHOUT ROWID PK seeks (the table's own
// clustered key) survive, and a lone `min`/`max` still reads one end
// (`SEARCH t`, no index detail). `eqp_access(not_indexed=true)` renders
// exactly those — every secondary-index seek collapses to a plain SCAN.
let lone_minmax = sel.where_clause.is_none()
&& sel.group_by.is_empty()
&& sel.having.is_none()
&& !sel.distinct
&& self.single_minmax_shape(sel, &meta).is_some();
if lone_minmax {
alloc::format!("SEARCH {label}")
} else {
// A rowid / IPK seek survives the hint; a secondary-index seek
// collapses to a plain `SCAN`.
self.eqp_access_hinted(
&label,
&from.first.name,
&meta,
sel.where_clause.as_ref(),
Some(sel),
params,
from.first.index_hint.as_ref(),
)?
}
} else if from.joins.is_empty() {
// A single `min(col)`/`max(col)` aggregate (no GROUP BY/HAVING/WHERE,
// no other aggregate) is the min/max optimization: sqlite seeks one
// end of an ordered scan and labels the access `SEARCH`. Checked
// before the covering-`SCAN` branch (which a min/max query would
// otherwise match) so the label matches sqlite.
if let Some(d) = self.minmax_search_detail(sel, &meta, &label) {
d
}
// `SELECT count(*)` answered by counting a full secondary index
// (B2b) reads as `USING COVERING INDEX`. Kept in lockstep with
// `run_core` via the shared `count_covering_index` helper. SQLite
// labels this particular plan with the *table name* even when the
// table is aliased (unlike every other scan, which uses the alias).
else if let Some((name, _)) = self.count_covering_index(sel) {
alloc::format!("SCAN {} USING COVERING INDEX {name}", from.first.name)
}
// A full index scanned to satisfy ORDER BY reads as `USING INDEX`,
// or `USING COVERING INDEX` when it holds every referenced column.
else if let Some(s) = self.order_index_scan(sel, params) {
let kind = if s.covering {
"COVERING INDEX"
} else {
"INDEX"
};
alloc::format!("SCAN {label} USING {kind} {}", s.name)
}
// A covered query with no seek reads from a covering index (B2),
// in lockstep with `run_core`'s `covering_scan`.
else if let Some((name, _, _)) = self.covering_scan(sel, &meta, params) {
alloc::format!("SCAN {label} USING COVERING INDEX {name}")
} else {
self.eqp_access(
&label,
&from.first.name,
&meta,
sel.where_clause.as_ref(),
Some(sel),
params,
)?
}
} else if self.two_table_rowid_inner_swap(from).is_some() {
// Cost-based two-table rowid-inner swap (in lockstep with the
// executor): the SECOND table drives (scanned), `from.first` is the
// rowid-sought inner — so the outer SCAN node names the second
// table, and the join loop below renders `SEARCH <first> USING
// INTEGER PRIMARY KEY`. The driver may itself read a covering index.
self.eqp_join_scan_detail(
sel,
from,
&from.joins[0].table,
&eqp_label(&from.joins[0].table),
)
} else if self.join_first_rowid_seek(sel, from, params).is_none()
&& self.two_table_index_inner_swap(from).is_some()
{
// Cost-based two-table secondary-index-inner swap: same as the rowid
// swap but `from.first` is sought by a secondary index instead — the
// SECOND table still drives (scanned), so the outer SCAN node names
// it and the block below renders `SEARCH <first> USING [COVERING]
// INDEX <idx> (<col>=?)`. Deferred to the rowid seek when `from.first`
// also has a `rowid = <const>` equality (the more selective plan).
self.eqp_join_scan_detail(
sel,
from,
&from.joins[0].table,
&eqp_label(&from.joins[0].table),
)
} else if self.join_first_rowid_seek(sel, from, params).is_some() {
// The driver carries its own `rowid = <const>` equality — sqlite (and
// now the executor, in lockstep) seeks that one row instead of
// scanning: `SEARCH <driver> USING INTEGER PRIMARY KEY (rowid=?)`.
alloc::format!(
"SEARCH {} USING INTEGER PRIMARY KEY (rowid=?)",
eqp_label(&from.first)
)
} else if let Some((idx, col)) = self.join_first_index_seek(sel, from, params) {
// The driver carries an equality on a single-column secondary index —
// sqlite seeks it: `SEARCH <driver> USING INDEX <idx> (<col>=?)`. The
// matches share the key value so they arrive in rowid order, matching
// the executor's scan + re-applied-WHERE order (EQP-only, no exec change).
alloc::format!(
"SEARCH {} USING INDEX {idx} ({col}=?)",
eqp_label(&from.first)
)
} else {
// Joins run as nested-loop scans in `join_from` order — declaration
// order, or the cost-based N-table permutation. The driver may read a
// covering index (rows in index-key order).
self.eqp_join_scan_detail(
sel,
join_from,
&join_from.first,
&eqp_label(&join_from.first),
)
};
if from.joins.is_empty() {
single_scan_detail = Some(detail.clone());
}
// SQLite spills each `DISTINCT` aggregate through its own transient
// b-tree, rendered *before* the scan line (when there is no GROUP BY).
// The node is emitted exactly when the access path is a bare full
// `SCAN {label}`: no index then delivers the distinct values pre-ordered,
// so every distinct aggregate needs its own sort — independent of any
// WHERE/ORDER BY (which only matter insofar as they engage an index, and
// an engaged index changes `detail` away from the bare scan). GROUP BY is
// the separate `group_distinct_btree` path (node placed after the scan).
if from.joins.is_empty()
&& detail == alloc::format!("SCAN {label}")
&& sel.group_by.is_empty()
{
for fname in self.distinct_agg_btrees(sel, &meta, true) {
let id = *next_id;
*next_id += 1;
out.push((
id,
parent,
alloc::format!("USE TEMP B-TREE FOR {fname}(DISTINCT)"),
));
}
}
let id = *next_id;
*next_id += 1;
out.push((id, parent, detail));
}
// A non-correlated scalar subquery in the WHERE clause, the projection, or
// ORDER BY is computed once and rendered by SQLite as a `SCALAR SUBQUERY N`
// sibling of the scan node, numbered left-to-right, with the subquery body's
// plan as its child. A WHERE / ORDER BY subquery is placed before any
// GROUP BY / ORDER BY sorter; a projection subquery before DISTINCT / ORDER
// BY but after GROUP BY (so the projection / ORDER BY forms are declined when
// grouping is present — see the three collectors). Either way we only emit
// when the whole set is provably `1..n`; single-table queries only, so this
// runs before the join-folding below (which is a no-op when there are no
// joins). The three positions are mutually exclusive: each collector declines
// if another clause holds a subquery.
if from.joins.is_empty()
&& let Some(subs) = self
.eqp_where_scalar_subqueries(sel)
.or_else(|| self.eqp_projection_scalar_subqueries(sel))
.or_else(|| self.eqp_orderby_scalar_subqueries(sel))
{
for (i, body) in subs.iter().enumerate() {
let scalar_id = *next_id;
*next_id += 1;
out.push((
scalar_id,
parent,
alloc::format!("SCALAR SUBQUERY {}", i + 1),
));
self.eqp_select(body, scalar_id, next_id, out, params)?;
}
}
// A single non-correlated `[NOT] IN (SELECT …)` in the WHERE renders a
// `LIST SUBQUERY 1` node (child = the body's plan, then a `CREATE BLOOM FILTER`
// sibling under it) after the access. It emits in two provably-byte-exact cases:
// - `NOT IN` / an IN column that is *not* seekable → graphite's access is a
// bare `SCAN {label}`, matching SQLite (which also scans);
// - a positive `IN` on a *seekable* (rowid / index-leading) column → the
// executor folds the subquery to a value list and seeks per candidate
// (`try_index_in`), and `eqp_access`'s placeholder fold renders the matching
// `SEARCH {label} … (col=?)` — but only when that access line *is* the IN
// column's seek (a competing equality/range on another column would make
// SQLite's cost-model choice diverge, so we require the rendered access to
// seek the IN column exactly — `(in_col=?)`).
if from.joins.is_empty()
&& let Some((body, negated, operand)) = sel
.where_clause
.as_ref()
.and_then(|w| single_where_in_select(w))
{
let operand_is_rowid = matches!(operand, Expr::Column { column, .. }
if is_rowid_alias(column)
&& !meta.columns.iter().any(|c| c.name.eq_ignore_ascii_case(column)));
let in_col_idx = col_index(operand, &meta.columns);
let in_col_seekable = operand_is_rowid
|| in_col_idx.is_some_and(|c| {
meta.ipk == Some(c)
|| self
.indexes_of(&from.first.name)
.is_ok_and(|ixs| ixs.iter().any(|i| i.cols.first() == Some(&c)))
});
let bare_scan =
single_scan_detail.as_deref() == Some(alloc::format!("SCAN {label}").as_str());
// The seek-column render tag: a rowid / INTEGER-PRIMARY-KEY IN reads
// `(rowid=?)` (the IPK column renders as `rowid` in the access line even
// when referenced by its declared name), a secondary-index IN reads
// `(col=?)`.
let in_col_tag = if operand_is_rowid || (in_col_idx.is_some() && in_col_idx == meta.ipk)
{
Some(alloc::string::String::from("rowid"))
} else {
in_col_idx.map(|c| meta.columns[c].name.clone())
};
let seek_is_in_col = !negated
&& in_col_seekable
&& in_col_tag.as_deref().is_some_and(|nm| {
single_scan_detail.as_deref().is_some_and(|d| {
d.starts_with(alloc::format!("SEARCH {label}").as_str())
&& d.contains(alloc::format!("({nm}=?)").as_str())
})
});
let nonseek_case = (negated || !in_col_seekable) && bare_scan;
// With a bare `SCAN` outer, a simple indexed-column subquery is
// evaluated by iterating that index (a single `… FOR IN-OPERATOR`
// node) rather than materializing a `LIST SUBQUERY` + bloom filter.
let in_op_node = if nonseek_case {
self.in_operator_index_node(body)
} else {
None
};
if let Some(node) = in_op_node {
let n_id = *next_id;
*next_id += 1;
out.push((n_id, parent, node));
} else if (nonseek_case || seek_is_in_col) && self.eqp_scalar_bodies_renderable(&[body])
{
let list_id = *next_id;
*next_id += 1;
out.push((list_id, parent, String::from("LIST SUBQUERY 1")));
self.eqp_select(body, list_id, next_id, out, params)?;
let bloom_id = *next_id;
*next_id += 1;
out.push((bloom_id, list_id, String::from("CREATE BLOOM FILTER")));
}
}
// Cost-based two-table rowid-inner swap (in lockstep with the executor):
// when the drive is reordered to scan the SECOND table and seek
// `from.first` by rowid, the single inner node is `SEARCH <first> USING
// INTEGER PRIMARY KEY (rowid=?)` — the outer SCAN node (emitted above)
// already names the second table.
if self.two_table_rowid_inner_swap(from).is_some() {
let id = *next_id;
*next_id += 1;
out.push((
id,
parent,
alloc::format!(
"SEARCH {} USING INTEGER PRIMARY KEY (rowid=?)",
eqp_label(&from.first)
),
));
}
// Cost-based two-table secondary-index-inner swap (in lockstep with the
// executor): the SECOND table drives (SCAN node emitted above), `from.first`
// is the index-sought inner — `SEARCH <first> USING [COVERING] INDEX <idx>
// (<col>=?)`. `COVERING` iff every `from.first` column the query needs is in
// the index (matching sqlite's cost-model label).
else if self.join_first_rowid_seek(sel, from, params).is_none()
&& let Some((_, first_meta, idx)) = self.two_table_index_inner_swap(from)
{
let col = &first_meta.columns[idx.cols[0]].name;
let second_meta = self
.table_meta(
&from.joins[0].table.name,
from.joins[0].table.alias.as_deref(),
)
.ok();
let covering = second_meta
.as_ref()
.is_some_and(|sm| self.index_swap_covers(sel, from, &first_meta, sm, &idx));
let kind = if covering { "COVERING INDEX" } else { "INDEX" };
let id = *next_id;
*next_id += 1;
out.push((
id,
parent,
alloc::format!(
"SEARCH {} USING {kind} {} ({col}=?)",
eqp_label(&from.first),
idx.name
),
));
}
// Fold each join in FROM order, tracking the accumulated left columns so
// the rowid-seek decision (shared with the executor via `rowid_join_seek`)
// can print `SEARCH … USING INTEGER PRIMARY KEY (rowid=?)` in lockstep
// with how it actually runs.
else if !join_from.joins.is_empty() {
let mut left_columns = self.resolve_join_source(&join_from.first, params)?.0;
for join in &join_from.joins {
let label = eqp_label(&join.table);
// SQLite tags the inner side of a LEFT join with a ` LEFT-JOIN`
// suffix on its SEARCH node (every seek kind), so the outer-row
// null-padding is visible in the plan.
let left_suffix = if matches!(join.kind, JoinKind::Left) {
" LEFT-JOIN"
} else {
""
};
// Most joins emit one plan row; an automatic-index (hash) join
// emits two (a BLOOM FILTER then the SEARCH), so collect details.
let (details, jcols): (Vec<String>, Vec<ColumnInfo>) = if let Some((
_,
inner_meta,
)) =
self.rowid_join_seek(join, &left_columns)
{
(
alloc::vec![alloc::format!(
"SEARCH {label} USING INTEGER PRIMARY KEY (rowid=?){left_suffix}"
)],
inner_meta.columns,
)
} else if let Some((_, inner_meta, idx)) = self.index_join_seek(join, &left_columns)
{
let col = &inner_meta.columns[idx.cols[0]].name;
// `USING COVERING INDEX` iff the index holds every column of the
// inner table the query needs (matching sqlite's cost label).
let inner_names = [
join.table.name.as_str(),
join.table.alias.as_deref().unwrap_or(""),
];
let kind = if self.join_seek_index_covers(
sel,
join_from,
&inner_names,
&inner_meta,
&idx,
) {
"COVERING INDEX"
} else {
"INDEX"
};
(
alloc::vec![alloc::format!(
"SEARCH {label} USING {kind} {} ({col}=?){left_suffix}",
idx.name
)],
inner_meta.columns,
)
} else if let Some((_, inner_meta)) =
self.without_rowid_pk_join_seek(join, &left_columns)
{
let col = &inner_meta.columns[inner_meta.storage_order[0]].name;
(
alloc::vec![alloc::format!(
"SEARCH {label} USING PRIMARY KEY ({col}=?){left_suffix}"
)],
inner_meta.columns,
)
} else {
let jcols = self.resolve_join_source(&join.table, params)?.0;
// The executor builds a transient hash index for an INNER/LEFT
// equi-join (`ON l.x = r.y`) on an otherwise-unindexed inner
// table; SQLite reports that as a BLOOM FILTER + AUTOMATIC
// COVERING INDEX seek (NATURAL/USING and non-equi joins stay a
// plain SCAN, as graphite runs them with a nested loop).
let auto_col = if join.natural
|| !join.using.is_empty()
|| !matches!(join.kind, JoinKind::Inner | JoinKind::Left)
// When the driver is itself seeked (a rowid or single-column
// secondary-index equality), sqlite does not build a transient
// auto-index for the inner — it scans it (a seek estimates few
// driver rows). Suppress the AUTOMATIC-COVERING-INDEX label to
// match (graphite nested-loops either way, so this is EQP-only).
// A real index on the inner still takes the `index_join_seek`
// branch above and renders `SEARCH … USING INDEX`.
|| sel.from.as_ref().is_some_and(|f| {
self.join_first_rowid_seek(sel, f, params).is_some()
|| self.join_first_index_seek(sel, f, params).is_some()
}) {
None
} else {
join.on.as_ref().and_then(|on| {
let mut combined = left_columns.clone();
combined.extend(jcols.iter().cloned());
join_equi_cols(on, &combined, left_columns.len())
.map(|(_, ri)| jcols[ri].name.clone())
})
};
match auto_col {
Some(col) => (
alloc::vec![
alloc::format!("BLOOM FILTER ON {label} ({col}=?)"),
alloc::format!(
"SEARCH {label} USING AUTOMATIC COVERING INDEX ({col}=?){left_suffix}"
),
],
jcols,
),
// A plain-scanned inner (no automatic index) may itself read
// a covering secondary index — rendered in lockstep with the
// executor's covering-order inner scan. sqlite tags a LEFT
// join's inner scan node with ` LEFT-JOIN` (as for its SEARCH
// nodes above).
None => (
alloc::vec![alloc::format!(
"{}{left_suffix}",
self.eqp_join_scan_detail(sel, join_from, &join.table, &label)
)],
jcols,
),
}
};
for detail in details {
let id = *next_id;
*next_id += 1;
out.push((id, parent, detail));
}
let left_width = left_columns.len();
left_columns.extend(jcols);
// Mirror the executor's NATURAL / USING coalescing: each join
// column folds into its left output position and the right
// duplicate is dropped, so a later join's `left_width` stays
// aligned (a rowid-seek join never uses NATURAL / USING).
if join.natural || !join.using.is_empty() {
let mut drop: Vec<usize> = if join.natural {
(left_width..left_columns.len())
.filter(|&rl| {
left_columns[..left_width]
.iter()
.any(|c| c.name.eq_ignore_ascii_case(&left_columns[rl].name))
})
.collect()
} else {
join.using
.iter()
.filter_map(|name| {
(left_width..left_columns.len())
.find(|&rl| left_columns[rl].name.eq_ignore_ascii_case(name))
})
.collect()
};
drop.sort_unstable();
drop.dedup();
for &d in drop.iter().rev() {
left_columns.remove(d);
}
}
}
}
// SQLite spills GROUP BY / DISTINCT through a transient b-tree when the
// access order does not already cluster the key columns. Render it for the
// clean single-table bare-`SCAN` case, placed after the SCAN line and
// before any ORDER BY node — matching sqlite's node order.
let mut group_btree_suppresses_order = false;
// The GROUP BY / DISTINCT temp-b-tree also materializes over an *unambiguous*
// access path that doesn't already yield the grouping order: a bare `SCAN`, or
// a rowid RANGE seek (`SEARCH … INTEGER PRIMARY KEY (rowid>?…)`), which returns
// rows in rowid order — never the group/distinct-key order — and, being the
// table's own clustered key, involves no secondary-index *choice* (so no
// cost-model divergence, unlike a secondary-index seek — see roadmap B9h). A
// rowid *equality* seek is a single row (grouping is a no-op), so it is
// excluded. `group_distinct_btree`'s own "a secondary index leads the first key
// column" guard still declines the shapes where sqlite would walk an index.
let rowid_range_seek = single_scan_detail.as_deref().is_some_and(|d| {
d.starts_with(&alloc::format!(
"SEARCH {label} USING INTEGER PRIMARY KEY (rowid"
)) && (d.contains('>') || d.contains('<'))
});
// A no-op `DISTINCT` (its projection pins the rowid/IPK, so it removes
// nothing) is planned by sqlite as if absent — no `FOR DISTINCT` node.
if (single_scan_detail.as_deref() == Some(alloc::format!("SCAN {label}").as_str())
|| rowid_range_seek)
&& !self.distinct_is_noop(sel, &meta, &label)
&& let Some((kind, suppress)) =
self.group_distinct_btree(sel, &meta, &from.first.name, hint_not_indexed)
{
let id = *next_id;
*next_id += 1;
out.push((id, parent, alloc::format!("USE TEMP B-TREE FOR {kind}")));
group_btree_suppresses_order = suppress;
// With grouping, each distinct aggregate spills through its own
// transient b-tree *after* the GROUP BY node (the scan order serves
// the group key, not the distinct values, so nothing is elided). The
// node order matches sqlite's: GROUP BY first, then the distinct
// aggregates in result-column order.
for fname in self.distinct_agg_btrees(sel, &meta, false) {
let id = *next_id;
*next_id += 1;
out.push((
id,
parent,
alloc::format!("USE TEMP B-TREE FOR {fname}(DISTINCT)"),
));
}
}
// The join analogue of the single-table `group_distinct_btree` above: a
// two-table INNER join whose driver scan order does not already cluster the
// GROUP BY / DISTINCT key spills through a transient b-tree, placed after
// the join's SCAN/SEARCH nodes and before any ORDER BY node — exactly
// sqlite's placement. `join_group_distinct_clustered` returns true only when
// the key is a leading prefix of the driver's scan order (the C1/C2/E2
// shapes sqlite elides), in which case we emit nothing.
//
// Strictly scoped to the two-table single-INNER-join shapes
// `join_driver_scan_order` models (checked via `.is_some()`): only there do
// we know the driver and its scan order precisely enough to reproduce
// sqlite's *both* emit-and-elide decisions. A LEFT/RIGHT/FULL join, an
// N>2-table join, or a join whose driver scan order graphite renders
// differently (a BLOOM-FILTER automatic-index inner, a differing join
// order) is left with its previous behaviour — sqlite's node choice there
// depends on its own (already-divergent) driver analysis, so emitting a
// node here could add one sqlite does not (e.g. a LEFT join whose rowid
// driver clusters the key, which sqlite elides).
if !from.joins.is_empty() && self.join_driver_scan_order(sel, from).is_some() {
let kind = if !sel.group_by.is_empty() && !sel.distinct {
Some("GROUP BY")
} else if sel.distinct
&& sel.group_by.is_empty()
// A wildcard-projection DISTINCT can't have its key enumerated —
// decline (leave graphite's current no-node behaviour) rather than
// guess.
&& sel.columns.iter().all(|c| matches!(c, ResultColumn::Expr { .. }))
{
Some("DISTINCT")
} else {
None
};
if let Some(kind) = kind
&& !self.join_group_distinct_clustered(sel, from)
{
let id = *next_id;
*next_id += 1;
out.push((id, parent, alloc::format!("USE TEMP B-TREE FOR {kind}")));
// sqlite folds a GROUP BY query's ORDER BY into this grouping
// sorter when every ORDER BY term is exactly the GROUP BY key
// (same columns, in order) — the F1/F2 shapes emit only the
// GROUP BY node. A DISTINCT b-tree is ascending-only, so a DESC
// term keeps its own sort. Any foreign / aggregate / expression
// ORDER BY term (F3/F4) leaves the ORDER BY node in place.
if kind == "GROUP BY" && !sel.order_by.is_empty() {
let key_ids: Vec<Option<(String, String)>> = sel
.group_by
.iter()
.map(|e| self.join_key_column_identity(sel, from, e))
.collect();
let folds = key_ids.iter().all(|k| k.is_some())
&& sel.order_by.len() == key_ids.len()
&& sel.order_by.iter().enumerate().all(|(i, term)| {
redundant_nulls(term)
&& self
.join_key_column_identity(sel, from, &term.expr)
.zip(key_ids[i].as_ref())
.is_some_and(|((tt, tc), (kt, kc))| {
tt.eq_ignore_ascii_case(kt) && tc.eq_ignore_ascii_case(kc)
})
});
if folds {
group_btree_suppresses_order = true;
}
}
}
}
// A projection scalar subquery in a GROUP BY query is sequenced by SQLite
// *after* the grouping sorter (and any distinct-aggregate b-trees) but
// *before* an ORDER BY sorter — a second insertion point, distinct from the
// after-scan one used by the un-grouped collectors above. Single-table only
// (the join fold handles the multi-table shapes). Numbered `1..n` in
// left-to-right column order; the set is provably `1..n` (no subquery in any
// other clause), so emitting it can only converge the plan.
if from.joins.is_empty()
&& let Some(subs) = self.eqp_grouped_projection_scalar_subqueries(sel)
{
for (i, body) in subs.iter().enumerate() {
let scalar_id = *next_id;
*next_id += 1;
out.push((
scalar_id,
parent,
alloc::format!("SCALAR SUBQUERY {}", i + 1),
));
self.eqp_select(body, scalar_id, next_id, out, params)?;
}
}
// ORDER BY that we satisfy with an in-memory sort — unless the scan already
// yields the requested order (no temp b-tree then, like sqlite), or the
// grouping b-tree above already delivers exactly this order (sqlite folds
// the sort into it, emitting no separate ORDER BY node). When a seek walks
// a *prefix* of the ORDER BY in order, only the trailing terms are sorted,
// which sqlite reports as "LAST n TERM[S] OF ORDER BY".
//
// A bare aggregate query — aggregate functions with no GROUP BY — collapses
// the whole table to exactly one row, so any ORDER BY is a no-op and sqlite
// emits no sorter for it. (A window function makes the output per-row again,
// so it is excluded.)
let single_row_aggregate =
sel.group_by.is_empty() && self.has_aggregate(sel) && !window::has_window(sel);
// For a two-table INNER join the driver's own scan order can already supply
// a leading prefix of the ORDER BY (its rowid order for an IPK driver, or
// its covering-index key order): sqlite then elides the whole sort (prefix
// == every term) or reports only the unsupplied trailing terms. Terms on
// the seeked inner, or beyond the driver's key, are not supplied and stay
// sorted. A GROUP BY reshapes the output (post-grouping order is the group
// key, not the driver scan), so the driver-prefix elision only applies to a
// non-grouped ORDER BY. `join_order_prefix` returns 0 for any shape it does
// not model, keeping the existing full sorter.
let join_supplied = if !from.joins.is_empty() && sel.group_by.is_empty() {
self.join_order_prefix(sel, from)
} else {
0
};
if !sel.order_by.is_empty()
&& !group_btree_suppresses_order
&& !single_row_aggregate
&& self.order_satisfied_by_scan(sel, params).is_none()
&& join_supplied < sel.order_by.len()
{
let n = sel.order_by.len();
// Only the trailing terms are sorted when the access walks a prefix of
// the ORDER BY in order: a non-covering index walk (mixed direction,
// `order_index_scan.sorted_suffix`), a WHERE seek (`seek_order_prefix`),
// a no-WHERE covering-index scan (`scan_order_prefix`), or — for a join
// — the driver scan supplying `join_supplied` leading terms.
let sorted = if join_supplied > 0 {
n - join_supplied.min(n)
} else if let Some(s) = self.order_index_scan(sel, params) {
s.sorted_suffix.min(n)
} else if let Some((k, _)) = self.seek_order_prefix(sel, params) {
n - k.min(n)
} else {
// A plain SCAN supplies no order; but a leading `col = <const>` WHERE
// equality still pins that term to a constant, so sqlite drops it
// (`order_const_lead`). A covering scan (`scan_order_prefix > 0`) keeps
// its index-order credit (constant/covered interleaving is rare and
// left to the full sort).
let sp = self.scan_order_prefix(sel, params);
let credited = if sp == 0 {
self.order_const_lead(sel, params)
} else {
sp
};
n - credited.min(n)
};
// `sorted == 0` means the access path already yields every ORDER BY term
// in order, so no sort is needed — emit nothing (a `LAST 0 TERMS` node is
// never valid SQLite output). This arises for an aggregate `GROUP BY a
// ORDER BY a` answered by an index on `a`, where the group-by access
// provides the order but `order_satisfied_by_scan` does not recognise it.
if sorted > 0 {
let detail = match sorted {
_ if sorted >= n => String::from("USE TEMP B-TREE FOR ORDER BY"),
1 => String::from("USE TEMP B-TREE FOR LAST TERM OF ORDER BY"),
_ => alloc::format!("USE TEMP B-TREE FOR LAST {sorted} TERMS OF ORDER BY"),
};
let id = *next_id;
*next_id += 1;
out.push((id, parent, detail));
}
}
Ok(())
}
/// Emit a SQLite-style `MULTI-INDEX OR` plan when `where_clause` is a
/// top-level `OR` whose every disjunct is index/rowid-seekable (i.e. each
/// disjunct's [`eqp_access`](Self::eqp_access) yields a `SEARCH`). Returns
/// `true` (rows pushed) when it applies, else `false` (caller emits the plain
/// node). Mirrors [`try_index_or`](Self::try_index_or)'s applicability.
#[allow(clippy::too_many_arguments)]
fn eqp_or_plan(
&self,
label: &str,
table: &str,
meta: &TableMeta,
where_clause: Option<&Expr>,
parent: i64,
next_id: &mut i64,
out: &mut Vec<(i64, i64, String)>,
params: &Params,
) -> Result<bool> {
let Some(where_expr) = where_clause else {
return Ok(false);
};
let mut disjuncts: Vec<&Expr> = Vec::new();
flatten_or(where_expr, &mut disjuncts);
if disjuncts.len() < 2 {
return Ok(false);
}
// A `rowid = a OR rowid = b OR …` chain seeks the rowid table b-tree as a
// single set of candidates (`eqp_access` renders it `SEARCH … USING INTEGER
// PRIMARY KEY (rowid=?)`), exactly as the executor's `rowid_seek_constraint`
// path does — sqlite plans it the same, not a MULTI-INDEX OR. Decline here so
// the single SEARCH node renders.
if rowid_seek_constraint(where_expr, &meta.columns, meta.ipk, params).is_some() {
return Ok(false);
}
// A same-column equality OR-chain (`a = 1 OR a = 2 OR …`) is the equivalent of
// `a IN (1, 2, …)`: `find_in_constraint` recognises it, the executor's
// `try_index_in` seeks the single index for it, and `eqp_access` renders one
// `SEARCH … USING INDEX` (or a SCAN if `a` has no index). sqlite plans it the
// same way — never a MULTI-INDEX OR — so decline and let that single node show.
if find_in_constraint(where_expr, &meta.columns, params).is_some() {
return Ok(false);
}
// Each disjunct must seek (its eqp_access is a SEARCH, not a SCAN).
let mut details = Vec::with_capacity(disjuncts.len());
for d in &disjuncts {
let detail = self.eqp_access(label, table, meta, Some(d), None, params)?;
if !detail.starts_with("SEARCH") {
return Ok(false);
}
details.push(detail);
}
let or_id = *next_id;
*next_id += 1;
out.push((or_id, parent, String::from("MULTI-INDEX OR")));
for (i, detail) in details.into_iter().enumerate() {
let idx_id = *next_id;
*next_id += 1;
out.push((idx_id, or_id, alloc::format!("INDEX {}", i + 1)));
let search_id = *next_id;
*next_id += 1;
out.push((search_id, idx_id, detail));
}
Ok(true)
}
/// When an `[NOT] IN (SELECT …)` is evaluated by iterating an index on the
/// subquery's column instead of materializing its result, SQLite renders a
/// single `… FOR IN-OPERATOR` node (a child of the outer `SCAN`) in place of
/// the `LIST SUBQUERY` / `CREATE BLOOM FILTER` subtree. This happens for a
/// *simple* `SELECT <col> FROM <table> [ORDER BY …]` whose single projected
/// column is a plain column that is indexed: a secondary index leading with
/// the column renders `USING INDEX <name> FOR IN-OPERATOR`; the rowid /
/// INTEGER PRIMARY KEY renders `USING ROWID SEARCH ON TABLE <table> FOR
/// IN-OPERATOR`. Any `WHERE`/`GROUP BY`/`HAVING`/`DISTINCT`/`LIMIT`/`OFFSET`,
/// a join, a compound/CTE, an expression projection, or an unindexed column
/// disqualifies it (→ `None`, so the caller keeps the `LIST SUBQUERY` form).
fn in_operator_index_node(&self, body: &Select) -> Option<String> {
if body.distinct
|| body.where_clause.is_some()
|| !body.group_by.is_empty()
|| body.having.is_some()
|| body.limit.is_some()
|| body.offset.is_some()
|| !body.compound.is_empty()
|| !body.ctes.is_empty()
|| !body.window_defs.is_empty()
{
return None;
}
let from = body.from.as_ref()?;
if !from.joins.is_empty() {
return None;
}
let tref = &from.first;
if tref.subquery.is_some()
|| tref.tvf_args.is_some()
|| tref.schema.is_some()
|| self.is_bare_tvf(tref)
|| self.is_view(&tref.name)
{
return None;
}
// A single plain-column projection (no expression, no `*`).
if body.columns.len() != 1 {
return None;
}
let ResultColumn::Expr { expr, .. } = &body.columns[0] else {
return None;
};
let mut proj = expr;
while let Expr::Paren(inner) = proj {
proj = inner;
}
let Expr::Column {
column,
table,
schema: None,
..
} = proj
else {
return None;
};
let meta = self.table_meta(&tref.name, tref.alias.as_deref()).ok()?;
// A qualifier on the projected column must name the subquery's table.
if let Some(t) = table {
let qual = tref.alias.as_deref().unwrap_or(&tref.name);
if !t.eq_ignore_ascii_case(qual) {
return None;
}
}
let col_idx = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column));
// The rowid / INTEGER PRIMARY KEY of a rowid table → ROWID search form.
let is_rowid = !meta.without_rowid
&& (col_idx == meta.ipk && col_idx.is_some()
|| (is_rowid_alias(column) && col_idx.is_none()));
if is_rowid {
return Some(alloc::format!(
"USING ROWID SEARCH ON TABLE {} FOR IN-OPERATOR",
tref.name
));
}
// A plain (non-partial, non-expression) secondary index leading with the
// column → index-iteration form. Only when the choice is *unambiguous*:
// if two or more plain indexes lead with the column, which one SQLite
// iterates is a cost-model tiebreak (index width / uniqueness) we can't
// reproduce against the stat1-only oracle, so defer to the `LIST SUBQUERY`
// form (its pre-existing render) rather than guess the wrong index name.
let ci = col_idx?;
let ixs = self.indexes_of(&tref.name).ok()?;
let mut leading = ixs.iter().filter(|i| {
i.partial.is_none() && i.key_exprs.is_none() && i.cols.first() == Some(&ci)
});
let ix = leading.next()?;
if leading.next().is_some() {
return None;
}
Some(alloc::format!("USING INDEX {} FOR IN-OPERATOR", ix.name))
}
/// [`eqp_access`](Self::eqp_access), then collapse a *secondary*-index seek to a
/// plain `SCAN` when the table carries a `NOT INDEXED` hint — the hint forbids
/// every secondary index (including an implicit `sqlite_autoindex_…` for a
/// non-integer PK / UNIQUE), but the rowid / INTEGER PRIMARY KEY seek (the table's
/// own clustered key) survives. A WITHOUT ROWID table is left untouched: SQLite
/// still serves its clustered-PK and even a covering secondary seek under the hint,
/// which the plain `eqp_access` render already matches.
#[allow(clippy::too_many_arguments)]
fn eqp_access_hinted(
&self,
label: &str,
table: &str,
meta: &TableMeta,
where_clause: Option<&Expr>,
sel: Option<&Select>,
params: &Params,
hint: Option<&IndexHint>,
) -> Result<String> {
let acc = self.eqp_access(label, table, meta, where_clause, sel, params)?;
if !meta.without_rowid
&& matches!(hint, Some(IndexHint::NotIndexed))
&& (acc.contains("USING INDEX") || acc.contains("USING COVERING INDEX"))
{
return Ok(alloc::format!("SCAN {label}"));
}
Ok(acc)
}
/// The SCAN/SEARCH detail string for accessing one table given its WHERE.
/// `label` is the display name (alias if any); `table` is the real table
/// name used to look up its indexes. `sel`, when present, is the enclosing
/// `SELECT`: a seek whose index covers every referenced column reads as
/// `USING COVERING INDEX` (B2b), kept in lockstep with the executor's
/// [`seek_index_covers`](Self::seek_index_covers) decision. `None` (DELETE /
/// UPDATE / OR-plan disjuncts, which all touch the table) never covers.
fn eqp_access(
&self,
label: &str,
table: &str,
meta: &TableMeta,
where_clause: Option<&Expr>,
sel: Option<&Select>,
params: &Params,
) -> Result<String> {
let Some(where_expr) = where_clause else {
return Ok(alloc::format!("SCAN {label}"));
};
// A non-correlated scalar subquery used as a comparison operand
// (`col = (SELECT …)`) seeks the same as a constant would: SQLite evaluates it
// once and plans a `SEARCH`. Replace it with a placeholder literal (structurally,
// without running it — matching SQLite, which plans the seek without evaluating
// the subquery) so the constraint collectors below recognize the seek. The
// executor mirrors this by folding the subquery to its value before its seek.
// Restricted to a `SELECT` (`sel` present): a DELETE/UPDATE with a subquery
// `WHERE` is a two-pass plan SQLite renders `USING COVERING INDEX`, which the
// `sel`-less `eqp_access` can't reproduce, so it is left to its prior SCAN.
let folded = sel.and_then(|_| self.placeholder_fold_seek_where(where_expr));
let where_expr = folded.as_ref().unwrap_or(where_expr);
// `INDEX` vs `COVERING INDEX` for a seek through `idx_cols`: the same
// decision the executor's seek paths make via `seek_index_covers`.
let index_kw = |idx_cols: &[usize]| -> &'static str {
match sel {
Some(s) if self.seek_index_covers(s, meta, idx_cols, where_expr) => {
"COVERING INDEX"
}
_ => "INDEX",
}
};
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
eqs.retain(|(_, v)| !matches!(v, Value::Null));
// `col IS NULL` seeks a NULL index key (kept in lockstep with
// `try_index_lookup`). Tracked apart from `eqs` so the rowid/IPK paths
// below never fire for it (`rowid IS NULL` scans, as in sqlite).
let mut is_null_cols: Vec<usize> = Vec::new();
collect_isnull_cols(where_expr, &meta.columns, &mut is_null_cols);
// WITHOUT ROWID: the executor seeks the clustered PRIMARY KEY b-tree on a
// leading-PK equality (`try_without_rowid_pk_seek`) and otherwise scans —
// it never uses a secondary index — so report exactly that.
if meta.without_rowid {
let pk = &meta.storage_order[..meta.pk_len];
// A leading-PK equality prefix (matches try_without_rowid_pk_seek).
let mut names = Vec::new();
for &c in pk {
if eqs.iter().any(|(col, _)| *col == c) {
names.push(alloc::format!("{}=?", meta.columns[c].name));
} else {
break;
}
}
if !names.is_empty() {
return Ok(alloc::format!(
"SEARCH {label} USING PRIMARY KEY ({})",
names.join(" AND ")
));
}
// An IN-list / same-column equality OR-chain on the leading PK column
// seeks the clustered b-tree per value (try_without_rowid_pk_in). As in the
// rowid/secondary-index IN branch, a NULL list entry doesn't change the
// plan label — sqlite still reports the seek (the NULL just never matches).
if let Some((col, _)) = find_in_constraint(where_expr, &meta.columns, params)
&& pk.first() == Some(&col)
{
return Ok(alloc::format!(
"SEARCH {label} USING PRIMARY KEY ({}=?)",
meta.columns[col].name
));
}
// Else a range bound on the leading PK column (try_without_rowid_pk_range).
if let Some(&lead) = pk.first() {
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
if let Some(b) = ranges.get(&lead) {
let name = &meta.columns[lead].name;
// SQLite renders bounds as `>`/`<` regardless of inclusivity.
let cond = match (&b.lower, &b.upper) {
(Some(_), Some(_)) => alloc::format!("{name}>? AND {name}<?"),
(Some(_), None) => alloc::format!("{name}>?"),
(None, Some(_)) => alloc::format!("{name}<?"),
(None, None) => String::new(),
};
if !cond.is_empty() {
return Ok(alloc::format!("SEARCH {label} USING PRIMARY KEY ({cond})"));
}
}
}
// A secondary index whose leading column(s) the WHERE constrains by
// equality (matches try_without_rowid_index_seek). Its records carry
// the PK columns, so covering accounts for idx.cols ∪ pk.
for idx in self.indexes_of(table)? {
if idx.partial.is_some() || idx.key_exprs.is_some() {
continue;
}
let mut matched = Vec::new();
for &c in &idx.cols {
if eqs.iter().any(|(col, _)| *col == c) || is_null_cols.contains(&c) {
matched.push(c);
} else {
break;
}
}
if matched.is_empty() {
continue;
}
let mut avail = idx.cols.clone();
if !idx.name.starts_with("sqlite_autoindex_") {
for &p in pk {
if !avail.contains(&p) {
avail.push(p);
}
}
}
let kw = match sel {
Some(s) if self.seek_index_covers(s, meta, &avail, where_expr) => {
"COVERING INDEX"
}
_ => "INDEX",
};
let cond = matched
.iter()
.map(|&c| alloc::format!("{}=?", meta.columns[c].name))
.collect::<Vec<_>>()
.join(" AND ");
return Ok(alloc::format!(
"SEARCH {label} USING {kw} {} ({cond})",
idx.name
));
}
// Else a range bound on a secondary index's leading column
// (matches try_without_rowid_index_range).
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
for idx in self.indexes_of(table)? {
if idx.partial.is_some() || idx.key_exprs.is_some() {
continue;
}
let Some(&lead) = idx.cols.first() else {
continue;
};
let Some(b) = ranges.get(&lead) else {
continue;
};
let name = &meta.columns[lead].name;
// A DESC leading column reverses value order, but the rendered
// predicate reads in value space; `try_without_rowid_index_range`
// swaps the bounds internally, so we stay in lockstep by rendering
// the same value-space `>?`/`<?` condition either way.
let cond = match (&b.lower, &b.upper) {
(Some(_), Some(_)) => alloc::format!("{name}>? AND {name}<?"),
(Some(_), None) => alloc::format!("{name}>?"),
(None, Some(_)) => alloc::format!("{name}<?"),
(None, None) => continue,
};
let mut avail = idx.cols.clone();
if !idx.name.starts_with("sqlite_autoindex_") {
for &p in pk {
if !avail.contains(&p) {
avail.push(p);
}
}
}
let kw = match sel {
Some(s) if self.seek_index_covers(s, meta, &avail, where_expr) => {
"COVERING INDEX"
}
_ => "INDEX",
};
return Ok(alloc::format!(
"SEARCH {label} USING {kw} {} ({cond})",
idx.name
));
}
return Ok(alloc::format!("SCAN {label}"));
}
// A `rowid`/`_rowid_`/`oid` `= N` or `IN (list)` seek wins (matches the
// rowid fast path at the top of try_index_lookup), with or without an IPK.
if rowid_seek_constraint(where_expr, &meta.columns, meta.ipk, params).is_some() {
return Ok(alloc::format!(
"SEARCH {label} USING INTEGER PRIMARY KEY (rowid=?)"
));
}
// Rowid equality wins, as in try_index_lookup.
if let Some(ipk) = meta.ipk
&& eqs.iter().any(|(c, _)| *c == ipk)
{
return Ok(alloc::format!(
"SEARCH {label} USING INTEGER PRIMARY KEY (rowid=?)"
));
}
// Index covering the longest leftmost prefix of equalities, chosen by the
// SAME cost tiebreaks the executor's seek uses (`choose_seek_index`), so
// the EQP reports exactly the index `try_index_lookup` will seek — covering
// beats non-covering, narrower covering wins, ties go to the newest index.
// The shared chooser iterates `indexes_of` (which includes the implicit
// `sqlite_autoindex_*` PK/UNIQUE indexes), so a non-integer PRIMARY KEY or
// UNIQUE column reads as `SEARCH … USING INDEX sqlite_autoindex_…`, not
// `SCAN`. Partial/expression indexes are handled by the separate fallback
// below. `sel` is threaded through so covering candidates are recognized
// (a `None` caller — DELETE/UPDATE/OR-disjunct — treats nothing as covering,
// matching the render, which never emits `COVERING INDEX` without a `sel`).
// An `INDEXED BY name` hint on the FROM table restricts the chooser to that
// one index, exactly as it does for the executor's seek (`NOT INDEXED` is
// handled by `eqp_access_hinted`, which collapses the seek to a SCAN after
// this, so it is not applied here).
let indexed_by = sel
.and_then(|s| s.from.as_ref())
.and_then(|f| f.first.index_hint.as_ref())
.filter(|h| matches!(h, IndexHint::IndexedBy(_)));
// Collation-aware equalities so the EQP names the same index the executor
// seeks, including a `NOCASE` index for `= 'x' COLLATE NOCASE` (B9j).
let mut eqs_coll = Vec::new();
collect_eq_constraints_coll(where_expr, &meta.columns, params, &mut eqs_coll);
let chosen = self.choose_seek_index(
sel,
meta,
table,
where_expr,
&eqs_coll,
&is_null_cols,
indexed_by,
)?;
if let Some((idx, matched_len)) = chosen {
let matched: Vec<usize> = idx.cols[..matched_len].to_vec();
let idx_name = &idx.name;
let idx_cols = &idx.cols;
let mut conds = matched
.iter()
.map(|&c| alloc::format!("{}=?", meta.columns[c].name))
.collect::<Vec<_>>();
// A range on the column after the equality prefix is seeked too
// (matches the eq-prefix + range path in try_index_lookup). Once the
// prefix consumes every declared column, a range on the table's rowid
// (the index's implicit trailing key) still seeks — rendered `rowid>?`.
// The range on the column after the equality prefix seeks whether it
// is ASC or DESC (the DESC bounds are swapped in `try_index_lookup`).
if let Some(&next_col) = idx_cols.get(matched.len()) {
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
if let Some(b) = ranges.get(&next_col) {
let name = &meta.columns[next_col].name;
if b.lower.is_some() {
conds.push(alloc::format!("{name}>?"));
}
if b.upper.is_some() {
conds.push(alloc::format!("{name}<?"));
}
}
} else if matched.len() == idx_cols.len() && meta.ipk.is_some() {
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
let rowid_bound = meta
.ipk
.and_then(|ipk| ranges.remove(&ipk))
.or_else(|| rowid_alias_range(where_expr, meta, params));
if let Some(b) = rowid_bound {
if b.lower.is_some() {
conds.push(String::from("rowid>?"));
}
if b.upper.is_some() {
conds.push(String::from("rowid<?"));
}
}
}
let kw = index_kw(idx_cols);
return Ok(alloc::format!(
"SEARCH {label} USING {kw} {idx_name} ({})",
conds.join(" AND ")
));
}
// Partial / expression equality seek — in lockstep with the same
// fallback in `try_index_lookup` (plain column indexes win first; this
// fires only when none applied). `partial_expr_seek` proves eligibility.
for idx in self.indexes_of(table)? {
if self
.partial_expr_seek(&idx, where_expr, meta, params)?
.is_some()
{
let cond = match &idx.key_exprs {
// Partial column index: render the matched leading columns.
None => idx
.cols
.iter()
.take_while(|&&c| eqs.iter().any(|(col, _)| *col == c))
.map(|&c| alloc::format!("{}=?", meta.columns[c].name))
.collect::<Vec<_>>()
.join(" AND "),
// Expression index: the indexed expression compared to a value.
Some(_) => "<expr>=?".into(),
};
return Ok(alloc::format!(
"SEARCH {label} USING INDEX {} ({cond})",
idx.name
));
}
}
// No equality index applied. Mirror run_core's remaining fast paths
// (range, then IN) so the plan reflects what actually executes. Find the
// name/columns of a plain index by its leading column.
let leading_index = |target: usize| -> Option<(String, Vec<usize>)> {
for obj in self.schema.indexes_on(table) {
let sql = obj.sql.as_ref()?;
let Ok(Statement::CreateIndex(ci)) = sql::parse_one(sql) else {
continue;
};
if ci.where_clause.is_some() {
continue;
}
let Ok(cols) = self.index_columns(meta, &ci) else {
continue;
};
if cols.first() == Some(&target) {
return Some((obj.name.clone(), cols));
}
}
None
};
// Range scan: rowid (integer bounds) walks the table b-tree; an indexed
// leading column seeks its index.
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints_coll(where_expr, &meta.columns, params, &mut ranges);
if let Some(ipk) = meta.ipk
&& let Some(b) = ranges.get(&ipk)
{
let lo_int = b.lower.is_none() || matches!(b.lower, Some((Value::Integer(_), _)));
let hi_int = b.upper.is_none() || matches!(b.upper, Some((Value::Integer(_), _)));
if lo_int && hi_int {
let cond = match (&b.lower, &b.upper) {
(Some(_), Some(_)) => "rowid>? AND rowid<?",
(Some(_), None) => "rowid>?",
(None, Some(_)) => "rowid<?",
(None, None) => "",
};
if !cond.is_empty() {
return Ok(alloc::format!(
"SEARCH {label} USING INTEGER PRIMARY KEY ({cond})"
));
}
}
}
// Prefer a covering index for the range-leading seek — the SAME choice the
// executor's `try_index_range` makes via `choose_range_index`, so the plan
// matches what runs.
let range_hint = sel
.and_then(|s| s.from.as_ref())
.and_then(|f| f.first.index_hint.as_ref());
if let Some(idx) =
self.choose_range_index(sel, meta, table, where_expr, &ranges, range_hint)?
{
let lead = idx.cols[0];
let name = &meta.columns[lead].name;
let bound = &ranges[&lead];
// SQLite's EQP renders bounds as `>`/`<` regardless of inclusivity.
let cond = match (&bound.lower, &bound.upper) {
(Some(_), Some(_)) => alloc::format!("{name}>? AND {name}<?"),
(Some(_), None) => alloc::format!("{name}>?"),
(None, Some(_)) => alloc::format!("{name}<?"),
(None, None) => String::new(),
};
if !cond.is_empty() {
let kw = index_kw(&idx.cols);
return Ok(alloc::format!(
"SEARCH {label} USING {kw} {} ({cond})",
idx.name
));
}
}
// A3b: a partial or expression index range seek (mirrors the
// `partial_expr_range` fallback in try_index_range; always non-covering).
for idx in self.indexes_of(table)? {
if let Some((bound, _)) = self.partial_expr_range(&idx, where_expr, meta, params) {
let cond = |name: &str| match (&bound.lower, &bound.upper) {
(Some(_), Some(_)) => alloc::format!("{name}>? AND {name}<?"),
(Some(_), None) => alloc::format!("{name}>?"),
(None, Some(_)) => alloc::format!("{name}<?"),
(None, None) => String::new(),
};
let rendered = match &idx.key_exprs {
None => cond(&meta.columns[idx.cols[0]].name),
Some(_) => cond("<expr>"),
};
if !rendered.is_empty() {
return Ok(alloc::format!(
"SEARCH {label} USING INDEX {} ({rendered})",
idx.name
));
}
}
}
// IN-list seek: rowid b-tree, a plain/partial index on the IN column, or
// an expression index keyed by the IN'd expression (mirrors try_index_in).
if let Some((col, _)) = find_in_constraint(where_expr, &meta.columns, params) {
if meta.ipk == Some(col) {
return Ok(alloc::format!(
"SEARCH {label} USING INTEGER PRIMARY KEY (rowid=?)"
));
}
if let Some((idx_name, idx_cols)) = leading_index(col) {
let name = &meta.columns[col].name;
let kw = index_kw(&idx_cols);
return Ok(alloc::format!(
"SEARCH {label} USING {kw} {idx_name} ({name}=?)"
));
}
// A3b: a partial index on the IN column with its predicate proven.
for idx in self.indexes_of(table)? {
if idx.key_exprs.is_some() || idx.partial.is_none() {
continue;
}
if idx.cols.first() == Some(&col) && partial_pred_guaranteed(&idx, where_expr) {
let name = &meta.columns[col].name;
return Ok(alloc::format!(
"SEARCH {label} USING INDEX {} ({name}=?)",
idx.name
));
}
}
}
// A3b: an expression index keyed by `<expr>` with `<expr> IN (…)`.
for idx in self.indexes_of(table)? {
let Some(exprs) = &idx.key_exprs else {
continue;
};
let [key_expr] = exprs.as_slice() else {
continue;
};
if partial_pred_guaranteed(&idx, where_expr)
&& find_expr_in_values(key_expr, where_expr, params).is_some()
{
return Ok(alloc::format!(
"SEARCH {label} USING INDEX {} (<expr>=?)",
idx.name
));
}
}
// A bare `col IS NOT NULL` covering seek (mirrors `try_isnotnull_covering`):
// sqlite reads the sole covering index as `col>?` (NULLs sort first, so the
// non-NULL keys are the `> NULL` suffix). Gated on covering only — the
// near-full-table non-covering case stays `SCAN` on both sides.
{
let hint = sel
.and_then(|s| s.from.as_ref())
.and_then(|f| f.first.index_hint.as_ref());
if !matches!(hint, Some(IndexHint::NotIndexed)) {
let mut isnotnull_cols: Vec<usize> = Vec::new();
collect_isnotnull_cols(where_expr, &meta.columns, &mut isnotnull_cols);
if let Some(s) = sel
&& !isnotnull_cols.is_empty()
&& let Some((name, _, idx_cols)) = self.isnotnull_covering_index(
meta,
table,
s,
where_expr,
&isnotnull_cols,
hint,
)?
{
let lead = idx_cols[0];
return Ok(alloc::format!(
"SEARCH {label} USING COVERING INDEX {name} ({}>?)",
meta.columns[lead].name
));
}
}
}
Ok(alloc::format!("SCAN {label}"))
}
/// Validate an `INDEXED BY name` hint on a DML target (`UPDATE`/`DELETE`):
/// the named index must exist on `table`, otherwise `no such index: name`
/// (case-insensitive, matching sqlite). `NOT INDEXED` and an absent hint are
/// always fine. The hint only steers the planner, so it never changes the
/// statement's result — this is purely a name-existence check.
fn validate_index_hint(&self, table: &str, hint: Option<&IndexHint>) -> Result<()> {
if let Some(IndexHint::IndexedBy(n)) = hint {
let indexes = self.indexes_of(table)?;
if !indexes.iter().any(|i| i.name.eq_ignore_ascii_case(n)) {
return Err(Error::Error(alloc::format!("no such index: {n}")));
}
}
Ok(())
}
fn indexes_of(&self, table: &str) -> Result<Vec<IndexMeta>> {
let tmeta = match self.schema.table(table) {
Some(_) => self.table_meta(table, None)?,
None => return Ok(Vec::new()),
};
let mut out = Vec::new();
for obj in self.schema.indexes_on(table) {
match &obj.sql {
Some(sql) => {
let Statement::CreateIndex(ci) = sql::parse_one(sql)? else {
continue;
};
let (cols, key_exprs, collations) = self.index_key_spec(&tmeta, &ci)?;
// Per-column DESC flags, aligned with `cols` for a plain index.
let descending = if key_exprs.is_none() {
ci.columns.iter().map(|t| t.descending).collect()
} else {
Vec::new()
};
out.push(IndexMeta {
name: obj.name.clone(),
root: obj.rootpage,
cols,
collations,
descending,
partial: ci.where_clause.clone(),
key_exprs,
unique: ci.unique,
is_auto: false,
});
}
// Automatic index: its columns are the n-th UNIQUE/PK set.
None => {
if let Some(n) = autoindex_number(&obj.name, table)
&& let Some((cols, _, descs)) = tmeta.unique.get(n - 1)
{
let collations = self.col_collations(&tmeta, cols);
// `descs` is populated per key column by
// `collect_unique_sets` (all-false when ascending),
// so the auto-index seeks in the same direction its
// b-tree was written — see `IndexMeta::seek_descs`.
out.push(IndexMeta {
name: obj.name.clone(),
root: obj.rootpage,
descending: descs.clone(),
cols: cols.clone(),
collations,
partial: None,
key_exprs: None,
unique: true,
is_auto: true,
});
}
}
}
}
Ok(out)
}
fn index_columns(&self, tmeta: &TableMeta, ci: &CreateIndex) -> Result<Vec<usize>> {
Ok(self.index_columns_coll(tmeta, ci)?.0)
}
/// Resolve an index's columns to `(positions, collations)`. A column may
/// carry an explicit `COLLATE name`; otherwise it inherits the table
/// column's declared collation.
fn index_columns_coll(
&self,
tmeta: &TableMeta,
ci: &CreateIndex,
) -> Result<(Vec<usize>, Vec<crate::value::Collation>)> {
let mut cols = Vec::new();
let mut colls = Vec::new();
for term in &ci.columns {
// Peel an explicit COLLATE off the index column expression.
let (inner, explicit) = match &term.expr {
Expr::Collate { expr, collation } => (
expr.as_ref(),
crate::value::resolve_collation_name(collation),
),
e => (e, None),
};
let Expr::Column { column, .. } = inner else {
return Err(Error::Unsupported("expression indexes"));
};
let pos = tmeta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column))
.ok_or_else(|| Error::Error(format!("no such column: {column}")))?;
cols.push(pos);
colls.push(explicit.unwrap_or(tmeta.columns[pos].collation));
}
Ok((cols, colls))
}
/// Resolve an index's key terms to `(cols, key_exprs, collations)`. When every
/// term is a plain column, `key_exprs` is `None` and `cols` holds the column
/// positions. When any term is an expression (`lower(x)`, `a + b`, …), it is
/// an expression index: `key_exprs` holds the COLLATE-peeled term expressions
/// (evaluated per row to form the key) and `cols` is empty.
#[allow(clippy::type_complexity)]
fn index_key_spec(
&self,
tmeta: &TableMeta,
ci: &CreateIndex,
) -> Result<(Vec<usize>, Option<Vec<Expr>>, Vec<crate::value::Collation>)> {
let mut cols = Vec::new();
let mut exprs = Vec::new();
let mut colls = Vec::new();
let mut is_expr = false;
for term in &ci.columns {
let (inner, explicit) = match &term.expr {
Expr::Collate { expr, collation } => (
expr.as_ref(),
crate::value::resolve_collation_name(collation),
),
e => (e, None),
};
exprs.push(inner.clone());
match inner {
Expr::Column { column, .. } => {
let pos = tmeta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column))
.ok_or_else(|| Error::Error(format!("no such column: {column}")))?;
cols.push(pos);
colls.push(explicit.unwrap_or(tmeta.columns[pos].collation));
}
_ => {
is_expr = true;
colls.push(explicit.unwrap_or_default());
}
}
}
if is_expr {
Ok((Vec::new(), Some(exprs), colls))
} else {
Ok((cols, None, colls))
}
}
/// The on-disk index key bytes for `idx` over a table row: evaluated key
/// expressions for an expression index, else the column values.
fn index_key_bytes(
&self,
idx: &IndexMeta,
meta: &TableMeta,
values: &[Value],
rowid: i64,
params: &Params,
) -> Result<Vec<u8>> {
match &idx.key_exprs {
None => Ok(index_key(
&idx.cols,
&realify_columns_for_storage(meta, values),
rowid,
)),
Some(exprs) => {
let ctx = row_ctx(values, &meta.columns, Some(rowid), params).with_subqueries(self);
let mut key: Vec<Value> = exprs
.iter()
.map(|e| eval::eval(e, &ctx))
.collect::<Result<_>>()?;
key.push(Value::Integer(rowid));
Ok(encode_record(&key))
}
}
}
/// The declared collations of a set of table columns (for autoindexes).
fn col_collations(&self, tmeta: &TableMeta, cols: &[usize]) -> Vec<crate::value::Collation> {
cols.iter().map(|&c| tmeta.columns[c].collation).collect()
}
/// Whether a row belongs in `idx`: always for a full index, else whether the
/// partial-index predicate holds for the row.
fn row_in_index(
&self,
idx: &IndexMeta,
tmeta: &TableMeta,
values: &[Value],
rowid: Option<i64>,
params: &Params,
) -> Result<bool> {
match &idx.partial {
None => Ok(true),
Some(pred) => {
let ctx = row_ctx(values, &tmeta.columns, rowid, params).with_subqueries(self);
Ok(eval::truth(&eval::eval(pred, &ctx)?) == Some(true))
}
}
}
/// Rebuild every index of a table in place (used after DELETE/UPDATE).
fn rebuild_indexes(&mut self, tmeta: &TableMeta, indexes: &[IndexMeta]) -> Result<()> {
if indexes.is_empty() {
return Ok(());
}
let rows = self.scan_table(tmeta)?;
let no_params = Params::default();
// Precompute, per index, the key bytes for each included row (partial
// predicate + expression evaluation) before taking the writer borrow.
let mut per_index: Vec<Vec<Vec<u8>>> = Vec::with_capacity(indexes.len());
for idx in indexes {
let mut keys = Vec::new();
for (rowid, values) in &rows {
if self.row_in_index(idx, tmeta, values, Some(*rowid), &no_params)? {
keys.push(self.index_key_bytes(idx, tmeta, values, *rowid, &no_params)?);
}
}
per_index.push(keys);
}
let w = self.backend.writer()?;
for (idx, keys) in indexes.iter().zip(&per_index) {
clear_index(w, idx.root)?;
for key in keys {
insert_index(w, idx.root, key, &idx.collations, idx.seek_descs())?;
}
}
Ok(())
}
/// Rowids of rows in `meta` satisfying `pred` (all rows if `None`).
/// Reduce candidate rowids by an `UPDATE`/`DELETE` `ORDER BY … LIMIT …`
/// clause (the SQLite update/delete-limit extension): order the rows by the
/// terms, then apply `OFFSET`/`LIMIT` (a negative limit means no limit). With
/// no `ORDER BY`, the candidates keep their scan (rowid) order.
fn order_limit_rowids(
&self,
meta: &TableMeta,
rowids: Vec<i64>,
order_by: &[OrderTerm],
limit: Option<&Expr>,
offset: Option<&Expr>,
params: &Params,
) -> Result<Vec<i64>> {
let mut rowids = rowids;
if !order_by.is_empty() {
let mut keyed: Vec<(i64, Vec<Value>)> = Vec::with_capacity(rowids.len());
for rid in rowids {
let row = self.read_row(meta, rid)?.unwrap_or_default();
let ctx = row_ctx(&row, &meta.columns, Some(rid), params).with_subqueries(self);
let keys = order_by
.iter()
.map(|t| eval::eval(&t.expr, &ctx))
.collect::<Result<Vec<_>>>()?;
keyed.push((rid, keys));
}
keyed.sort_by(|a, b| {
for (i, t) in order_by.iter().enumerate() {
let o = cmp_order(
&a.1[i],
&b.1[i],
t.descending,
t.nulls_first,
crate::value::Collation::Binary,
);
if o != core::cmp::Ordering::Equal {
return o;
}
}
core::cmp::Ordering::Equal
});
rowids = keyed.into_iter().map(|(r, _)| r).collect();
}
let off = match offset {
Some(e) => must_be_int(eval::eval(
e,
&EvalCtx::rowless(params).with_subqueries(self),
)?)?
.max(0) as usize,
None => 0,
};
if off > 0 {
rowids.drain(0..off.min(rowids.len()));
}
if let Some(e) = limit {
let n = eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?);
if n >= 0 {
rowids.truncate(n as usize);
}
}
Ok(rowids)
}
fn matching_rowids(
&self,
meta: &TableMeta,
pred: Option<&Expr>,
params: &Params,
) -> Result<Vec<i64>> {
let mut out = Vec::new();
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let encoding = self.backend.source().header().text_encoding;
let mut ok = cur.first()?;
while ok {
let rowid = cur.rowid()?;
let values = self.decode_full_row(meta, rowid, &cur.payload()?, encoding)?;
let keep = match pred {
Some(p) => {
let ctx =
row_ctx(&values, &meta.columns, Some(rowid), params).with_subqueries(self);
eval::truth(&eval::eval(p, &ctx)?) == Some(true)
}
None => true,
};
if keep {
out.push(rowid);
}
ok = cur.next()?;
}
Ok(out)
}
/// The next rowid to assign for the table b-tree at `root` (max + 1, or 1).
fn next_rowid(&self, root: u32) -> Result<i64> {
let mut cur = TableCursor::new(self.backend.source(), root);
if cur.last()? {
// Saturate so a table whose largest rowid is already `i64::MAX` does
// not overflow here; `auto_rowid` then detects the exhausted range
// (the saturated candidate is itself occupied) and either fails an
// AUTOINCREMENT table or picks a random free rowid, like sqlite.
Ok(cur.rowid()?.saturating_add(1))
} else {
Ok(1)
}
}
/// Allocate the rowid for an auto-assigned `INTEGER PRIMARY KEY` (or implicit
/// rowid) row. `cand` is the sequential candidate (largest existing rowid + 1,
/// saturated at `i64::MAX`). In the common case the candidate is free and is
/// returned as-is. When the sequential range is exhausted — `cand` has
/// saturated to an already-occupied `i64::MAX` — sqlite either fails an
/// `AUTOINCREMENT` table with `SQLITE_FULL` ("database or disk is full") or,
/// for a plain rowid table, picks a random free rowid. We mirror both.
fn auto_rowid(&self, root: u32, autoincrement: bool, cand: i64) -> Result<i64> {
let occupied = |r: i64| -> Result<bool> {
let mut cur = TableCursor::new(self.backend.source(), root);
cur.seek(r)
};
if cand < i64::MAX || !occupied(cand)? {
return Ok(cand);
}
if autoincrement {
return Err(Error::Error("database or disk is full".into()));
}
loop {
// A positive, non-zero rowid (sqlite never auto-assigns rowid <= 0).
let r = (eval::Subqueries::next_random(self) & i64::MAX).max(1);
if !occupied(r)? {
return Ok(r);
}
}
}
// ---- SELECT execution ---------------------------------------------------
/// The cap (`LIMIT`+`OFFSET`) to bound a recursive CTE by, when `sel` streams a
/// single recursive CTE 1:1 — `SELECT <cols> FROM <rcte> LIMIT k [OFFSET o]`
/// with no WHERE / ORDER BY / GROUP BY / DISTINCT / join / aggregate / compound.
/// Then an unterminated recursion still yields `k` rows, as sqlite (which
/// evaluates the CTE lazily) does; the outer LIMIT/OFFSET still slice as usual.
fn recursive_cte_outer_cap(&self, sel: &Select, params: &Params) -> Option<usize> {
if sel.ctes.len() != 1
|| !sel.compound.is_empty()
|| sel.distinct
|| !sel.group_by.is_empty()
|| !sel.order_by.is_empty()
|| sel.where_clause.is_some()
|| sel.having.is_some()
|| self.has_aggregate(sel)
{
return None;
}
let cte = &sel.ctes[0];
if !references_name(&cte.select, &cte.name) {
return None; // not a recursive CTE
}
let from = sel.from.as_ref()?;
if !from.joins.is_empty()
|| from.first.subquery.is_some()
|| from.first.tvf_args.is_some()
|| !from.first.name.eq_ignore_ascii_case(&cte.name)
{
return None;
}
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let n = must_be_int(eval::eval(sel.limit.as_ref()?, &ctx).ok()?).ok()?;
if n < 0 {
return None; // a negative LIMIT is unbounded — nothing to cap with
}
let offset = match &sel.offset {
Some(e) => must_be_int(eval::eval(e, &ctx).ok()?).ok()?.max(0) as usize,
None => 0,
};
Some((n as usize).saturating_add(offset))
}
fn run_select(&self, sel: &Select, params: &Params) -> Result<QueryResult> {
// An explicit `COLLATE <name>` that is actually consumed (a comparison,
// ORDER BY/GROUP BY/DISTINCT key, IN/BETWEEN, or min/max) must name a known
// collating sequence — sqlite errors "no such collation sequence" there
// (but not on an unused projection COLLATE). Nested subqueries validate
// themselves when they run.
validate_used_collations(sel)?;
// Materialize this query's `WITH` CTEs into the environment for the
// duration of the query, then restore the previous scope. (The opt-in
// VDBE fast path is attempted per query block inside `run_core`, so it
// also covers each arm of a compound query.)
let base = self.cte_env.borrow().len();
let outer_cap = self.recursive_cte_outer_cap(sel, params);
// Only materialize the CTEs the body actually reaches: SQLite leaves an
// unreferenced CTE unanalyzed, so a bad column/table in it is not an error.
let mut seeds = alloc::vec::Vec::new();
collect_source_names(sel, &mut seeds);
let pushed = self.push_ctes(&sel.ctes, params, outer_cap, Some(&seeds));
let result = pushed.and_then(|()| self.run_select_compound(sel, params));
self.cte_env.borrow_mut().truncate(base);
result
}
fn run_select_compound(&self, sel: &Select, params: &Params) -> Result<QueryResult> {
if sel.compound.is_empty() {
return self.run_core(sel, params);
}
// Compound query: run the first core (without the trailing ORDER BY/LIMIT
// and compound tail), then fold in each operand, then order/limit the whole.
let mut first = sel.clone();
first.compound = Vec::new();
first.order_by = Vec::new();
first.limit = None;
first.offset = None;
let mut result = self.run_core(&first, params)?;
// Compound set operations (UNION/INTERSECT/EXCEPT) compare rows under the
// left SELECT's per-column collations.
let colls = {
let (cols, _) = self.scan_source(&first, params)?;
self.output_collations(&first, &cols, params)
};
// A multi-row `VALUES (…),(…)` desugars to a `UNION ALL` chain whose
// operands are bare FROM-less projections auto-aliased `column1`,
// `column2`, … (see `values_core`); an explicit `SELECT … UNION ALL
// SELECT …` is also FROM-less but does not carry those aliases. SQLite
// rejects a column-count mismatch in either case but with different
// wording, so pick the message by which kind this is — matching only the
// VALUES alias shape avoids misreporting an explicit `UNION ALL`.
let is_values = sel.from.is_none()
&& sel.where_clause.is_none()
&& sel.group_by.is_empty()
&& is_values_projection(&sel.columns)
&& sel.compound.iter().all(|(op, c)| {
*op == CompoundOp::UnionAll && c.from.is_none() && is_values_projection(&c.columns)
});
for (op, operand) in &sel.compound {
// Run the operand fully: a `VALUES (…),(…)` operand desugars to a
// SELECT carrying its extra rows in its *own* compound tail, so it
// must be expanded (not just its first core) or those rows are lost.
let r = self.run_select_compound(operand, params)?;
// Every operand of a compound query (and every row of a multi-row
// `VALUES`) must project the same number of columns. SQLite rejects a
// mismatch; match that (errors-vs-succeeds, not exact text).
if r.columns.len() != result.columns.len() {
// When the *right* operand of the mismatching step is itself a
// `VALUES` clause, SQLite reports the VALUES-specific message
// (regardless of the operator or whether the left is a SELECT);
// otherwise it names the operator at the mismatch.
let operand_is_values =
operand.from.is_none() && is_values_projection(&operand.columns);
return Err(Error::Error(if is_values || operand_is_values {
"all VALUES must have the same number of terms".into()
} else {
let kw = match op {
CompoundOp::Union => "UNION",
CompoundOp::UnionAll => "UNION ALL",
CompoundOp::Intersect => "INTERSECT",
CompoundOp::Except => "EXCEPT",
};
alloc::format!(
"SELECTs to the left and right of {kw} do not have the same \
number of result columns"
)
}));
}
result.rows = apply_compound(*op, result.rows, r.rows, &colls);
}
// A dedup set operation (UNION/INTERSECT/EXCEPT) yields rows in sorted
// order in SQLite — its dedup is implemented via a sorter — whereas
// UNION ALL preserves order. With no explicit ORDER BY, sort the combined
// result by all output columns (ascending, under each column's collation;
// NULLs first) to match. An explicit ORDER BY is applied below instead.
if sel.order_by.is_empty()
&& sel
.compound
.iter()
.any(|(op, _)| *op != CompoundOp::UnionAll)
{
result.rows.sort_by(|a, b| {
for (i, va) in a.iter().enumerate() {
let coll = colls.get(i).copied().unwrap_or_default();
let ord = crate::value::cmp_values_coll(va, &b[i], coll);
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
core::cmp::Ordering::Equal
});
}
self.compound_order_limit(&mut result, sel, params, &colls)?;
Ok(result)
}
/// Apply a compound query's overall `ORDER BY` / `LIMIT` / `OFFSET` to the
/// already-combined rows (terms must reference output columns by position or
/// name).
fn compound_order_limit(
&self,
result: &mut QueryResult,
sel: &Select,
params: &Params,
colls: &[crate::value::Collation],
) -> Result<()> {
if !sel.order_by.is_empty() {
// A positional ORDER BY term must name an output column (SQLite).
check_positional_terms(&[], &sel.order_by, result.columns.len())?;
let mut keys = Vec::new();
for (i, term) in sel.order_by.iter().enumerate() {
let idx = resolve_order_index(&term.expr, &result.columns, result.columns.len())
.ok_or_else(|| {
// SQLite: a compound ORDER BY term must name an output
// column (by position or alias); an arbitrary expression
// is rejected with the term's 1-based ordinal.
Error::Error(alloc::format!(
"{} ORDER BY term does not match any column in the result set",
ordinal(i + 1),
))
})?;
// An explicit `COLLATE` on the ORDER BY term wins; otherwise the
// output column's collation (from the left SELECT) applies.
let coll = explicit_collation(&term.expr)
.unwrap_or_else(|| colls.get(idx).copied().unwrap_or_default());
keys.push((idx, term.descending, term.nulls_first, coll));
}
// A compound that deduplicates (any UNION / INTERSECT / EXCEPT arm)
// materializes its rows through a sorter keyed by the ORDER BY terms
// *followed by every remaining result column ascending* — that trailing
// key is what detects adjacent duplicates. So rows tied on the ORDER BY
// break by the other columns ascending (NULLs first), regardless of the
// ORDER BY's own direction. A pure UNION ALL chain does no dedup, so its
// ties keep input order (a stable sort). (A *mixed* chain that combines
// a dedup op with UNION ALL is sorted with plan-dependent tie order that
// graphite does not reproduce exactly — a narrow residual.)
let dedups = sel
.compound
.iter()
.any(|(op, _)| *op != CompoundOp::UnionAll);
if dedups {
let used: Vec<usize> = keys.iter().map(|(i, ..)| *i).collect();
for j in 0..result.columns.len() {
if !used.contains(&j) {
let coll = colls.get(j).copied().unwrap_or_default();
keys.push((j, false, None, coll));
}
}
}
result.rows.sort_by(|a, b| {
for (idx, desc, nf, coll) in &keys {
let ord = cmp_order(&a[*idx], &b[*idx], *desc, *nf, *coll);
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
core::cmp::Ordering::Equal
});
}
let offset = match &sel.offset {
Some(e) => must_be_int(eval::eval(
e,
&EvalCtx::rowless(params).with_subqueries(self),
)?)?
.max(0) as usize,
None => 0,
};
// A negative LIMIT means "no limit" in SQLite (OFFSET still applies).
let limit = match &sel.limit {
Some(e) => {
let n = must_be_int(eval::eval(
e,
&EvalCtx::rowless(params).with_subqueries(self),
)?)?;
if n < 0 { None } else { Some(n as usize) }
}
None => None,
};
if offset > 0 {
result.rows.drain(0..offset.min(result.rows.len()));
}
if let Some(n) = limit {
result.rows.truncate(n);
}
Ok(())
}
/// Compute every window function in `sel` over `rows`, append each result as
/// a synthetic column on `columns`/`rows`, and return a rewritten `SELECT`
/// whose projection/ORDER BY reference those columns.
/// When a plain window-function query has no outer `ORDER BY`, SQLite emits
/// rows in the *first* window's `(PARTITION BY …, ORDER BY …)` order — a side
/// effect of how it evaluates windows (it sorts the rows into partition+order
/// order and never shuffles them back to the scan order). Build that implicit
/// ordering so a plain windowed `SELECT` is row-for-row byte-compatible with
/// sqlite. Returns `None` when there is no window function, or the first one
/// has neither `PARTITION BY` nor `ORDER BY` (e.g. `OVER ()`) — then the scan
/// order is left untouched, as sqlite leaves it.
fn window_output_order(&self, sel: &Select) -> Result<Option<Vec<OrderTerm>>> {
let wins = window::collect_window_exprs(sel);
let Some(first) = wins.first() else {
return Ok(None);
};
let resolved = resolve_window_ref(first, &sel.window_defs)?;
let Expr::Function {
over: Some(spec), ..
} = &resolved
else {
return Ok(None);
};
if spec.partition_by.is_empty() && spec.order_by.is_empty() {
return Ok(None);
}
let mut terms: Vec<OrderTerm> =
Vec::with_capacity(spec.partition_by.len() + spec.order_by.len());
// PARTITION BY keys sort ascending (NULLs first), then the window's own
// ORDER BY terms with their directions.
for p in &spec.partition_by {
terms.push(OrderTerm {
expr: p.clone(),
descending: false,
nulls_first: None,
});
}
terms.extend(spec.order_by.iter().cloned());
Ok(Some(terms))
}
fn apply_windows(
&self,
sel: &Select,
columns: &mut Vec<ColumnInfo>,
rows: &mut [InputRow],
params: &Params,
) -> Result<Select> {
let wins = window::collect_window_exprs(sel);
let mut new_sel = sel.clone();
for (k, wexpr) in wins.iter().enumerate() {
// Resolve `OVER name` against the query's WINDOW definitions, then
// compute with the resolved spec (but replace the original node).
let resolved = resolve_window_ref(wexpr, &sel.window_defs)?;
let values = self.compute_window(&resolved, columns, rows, params)?;
let col_name = alloc::format!("__win{k}");
columns.push(ColumnInfo {
name: col_name.clone(),
table: String::new(),
affinity: eval::Affinity::Blob,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
});
for (row, v) in rows.iter_mut().zip(values) {
row.values.push(v);
}
let repl = Expr::Column {
schema: None,
table: None,
column: col_name,
quoted: false,
span: Span::none(),
};
window::replace_window_expr(&mut new_sel, wexpr, &repl);
}
Ok(new_sel)
}
/// Compute one window function across all `rows`, returning a value per row
/// (aligned with `rows`).
fn compute_window(
&self,
wexpr: &Expr,
columns: &[ColumnInfo],
rows: &[InputRow],
params: &Params,
) -> Result<Vec<Value>> {
let Expr::Function {
name,
distinct,
args,
star,
filter,
over: Some(spec),
..
} = wexpr
else {
return Err(Error::Error("not a window function".into()));
};
// SQLite rejects DISTINCT in a window function.
if *distinct {
return Err(Error::Error(
"DISTINCT is not supported for window functions".into(),
));
}
// A RANGE frame with a value offset bound (`<n> PRECEDING`/`<n>
// FOLLOWING`, as opposed to UNBOUNDED or CURRENT ROW) compares the
// ORDER BY value plus/minus the offset, so SQLite requires exactly one
// ORDER BY expression — neither zero nor several. ROWS/GROUPS offsets
// are positional and carry no such requirement.
if let Some(frame) = &spec.frame
&& frame.mode == FrameMode::Range
&& (matches!(
frame.start,
FrameBound::Preceding(_) | FrameBound::Following(_)
) || matches!(
frame.end,
FrameBound::Preceding(_) | FrameBound::Following(_)
))
&& spec.order_by.len() != 1
{
return Err(Error::Error(
"RANGE with offset PRECEDING/FOLLOWING requires one ORDER BY expression".into(),
));
}
let lname = name.to_ascii_lowercase();
let n = rows.len();
// Arity validation for the built-in ranking/value window functions (an
// aggregate used as a window function — `sum(x) OVER …` — falls through to
// the aggregate path). SQLite rejects a wrong count: `row_number(1)`,
// `lag()`, `ntile()`, `nth_value(1)` are all "wrong number of arguments".
if let Some((lo, hi)) = builtin_window_arity(&lname)
&& (args.len() < lo || args.len() > hi)
{
return Err(Error::Error(alloc::format!(
"wrong number of arguments to function {lname}()"
)));
}
// Per-row partition keys, order keys, argument values, and FILTER mask.
let mut part_keys: Vec<Vec<Value>> = Vec::with_capacity(n);
let mut ord_keys: Vec<Vec<Value>> = Vec::with_capacity(n);
let mut arg_vals: Vec<Vec<Value>> = Vec::with_capacity(n);
let mut passes: Vec<bool> = Vec::with_capacity(n);
for r in rows {
let ctx = r.ctx(columns, params).with_subqueries(self);
part_keys.push(
spec.partition_by
.iter()
.map(|e| eval::eval(e, &ctx))
.collect::<Result<_>>()?,
);
ord_keys.push(
spec.order_by
.iter()
.map(|t| eval::eval(&t.expr, &ctx))
.collect::<Result<_>>()?,
);
arg_vals.push(
args.iter()
.map(|e| eval::eval(e, &ctx))
.collect::<Result<_>>()?,
);
// FILTER (WHERE …) restricts which rows the aggregate sees.
passes.push(match filter {
Some(pred) => eval::truth(&eval::eval(pred, &ctx)?) == Some(true),
None => true,
});
}
let descending: Vec<bool> = spec.order_by.iter().map(|t| t.descending).collect();
// The explicit `NULLS FIRST`/`LAST` per ORDER BY term (None ⇒ SQLite's
// default: NULLs first under ASC, last under DESC). Dropping this made a
// window `ORDER BY x NULLS LAST` (or `DESC NULLS FIRST`) place NULLs at
// the default end, so rank/frame results diverged from sqlite.
let ord_nulls: Vec<Option<bool>> = spec.order_by.iter().map(|t| t.nulls_first).collect();
// The collation of each PARTITION BY / ORDER BY key (an explicit
// `COLLATE`, else the expression's column collation, else BINARY), so
// partitioning, ordering, and peer detection honor it like sqlite.
let kctx = row_ctx(&[], columns, None, params);
let part_colls: Vec<crate::value::Collation> = spec
.partition_by
.iter()
.map(|e| eval::key_collation(e, &kctx))
.collect();
let ord_colls: Vec<crate::value::Collation> = spec
.order_by
.iter()
.map(|t| eval::key_collation(&t.expr, &kctx))
.collect();
// Partition rows by partition key, preserving first-seen order.
let mut partitions: Vec<Vec<usize>> = Vec::new();
let mut part_of: Vec<usize> = Vec::new();
for i in 0..n {
let p = partitions.iter().position(|members| {
cmp_keys_coll(&part_keys[members[0]], &part_keys[i], &[], &part_colls).is_eq()
});
match p {
Some(idx) => {
partitions[idx].push(i);
part_of.push(idx);
}
None => {
part_of.push(partitions.len());
partitions.push(alloc::vec![i]);
}
}
}
let mut result = alloc::vec![Value::Null; n];
for members in &partitions {
// Order the partition's rows (stable).
let mut ordered = members.clone();
ordered.sort_by(|&a, &b| {
cmp_keys_coll_nulls(
&ord_keys[a],
&ord_keys[b],
&descending,
&ord_nulls,
&ord_colls,
)
});
self.fill_window_partition(
&lname,
// `count()` (no arguments) tallies every row, exactly like
// `count(*)`, so the frame counter must treat it as a star call.
*star || (lname == "count" && args.is_empty()),
&ordered,
&ord_keys,
&ord_colls,
&arg_vals,
&passes,
spec,
&mut result,
)?;
}
Ok(result)
}
/// Fill `result` for one ordered partition `ordered` (indices into the row
/// arrays), honoring `spec`'s frame (or the default frame).
#[allow(clippy::too_many_arguments)]
fn fill_window_partition(
&self,
lname: &str,
star: bool,
ordered: &[usize],
ord_keys: &[Vec<Value>],
ord_colls: &[crate::value::Collation],
arg_vals: &[Vec<Value>],
passes: &[bool],
spec: &WindowSpec,
result: &mut [Value],
) -> Result<()> {
let m = ordered.len();
// Peer-group id per ordered position (for RANGE/GROUPS frames).
let mut gid = alloc::vec![0usize; m];
for q in 1..m {
gid[q] = gid[q - 1]
+ usize::from(
!cmp_keys_coll(
&ord_keys[ordered[q - 1]],
&ord_keys[ordered[q]],
&[],
ord_colls,
)
.is_eq(),
);
}
// The single ORDER BY value per ordered position, for RANGE value
// offsets (`RANGE n PRECEDING/FOLLOWING`, which SQLite restricts to one
// ordering term), and its direction.
let ovals: Vec<Value> = if spec.order_by.len() == 1 {
ordered
.iter()
.map(|&i| ord_keys[i].first().cloned().unwrap_or(Value::Null))
.collect()
} else {
Vec::new()
};
let desc = spec.order_by.first().map(|t| t.descending).unwrap_or(false);
// The frame's EXCLUDE clause (default NO OTHERS).
let exclude = spec
.frame
.as_ref()
.map(|f| f.exclude)
.unwrap_or(FrameExclude::NoOthers);
// Resolve the (constant) frame offsets once. SQLite validates them at run
// time and defers the check over an empty partition, so only resolve when
// there is at least one row.
let rframe = match (&spec.frame, m > 0) {
(Some(f), true) => Some(resolve_frame(f)?),
_ => None,
};
let order_by_empty = spec.order_by.is_empty();
// Ranking values per ordered position.
for p in 0..m {
let idx = ordered[p];
let (fstart, fend) =
frame_bounds(p, m, &gid, rframe.as_ref(), order_by_empty, &ovals, desc);
// Positions of the frame after applying EXCLUDE.
let fpos: Vec<usize> = (fstart..fend)
.filter(|&k| match exclude {
FrameExclude::NoOthers => true,
FrameExclude::CurrentRow => k != p,
FrameExclude::Group => gid[k] != gid[p],
FrameExclude::Ties => gid[k] != gid[p] || k == p,
})
.collect();
let val = match lname {
"row_number" => Value::Integer(p as i64 + 1),
"rank" => {
// 1 + number of strictly-preceding rows by order key.
let mut r = p;
while r > 0
&& cmp_keys_coll(&ord_keys[ordered[r - 1]], &ord_keys[idx], &[], ord_colls)
.is_eq()
{
r -= 1;
}
Value::Integer(r as i64 + 1)
}
"dense_rank" => {
let mut dr = 1i64;
for q in 1..=p {
if !cmp_keys_coll(
&ord_keys[ordered[q - 1]],
&ord_keys[ordered[q]],
&[],
ord_colls,
)
.is_eq()
{
dr += 1;
}
}
Value::Integer(dr)
}
"percent_rank" => {
// (rank - 1) / (rows - 1); 0 for a single-row partition.
let mut r = p;
while r > 0
&& cmp_keys_coll(&ord_keys[ordered[r - 1]], &ord_keys[idx], &[], ord_colls)
.is_eq()
{
r -= 1;
}
if m > 1 {
Value::Real(r as f64 / (m - 1) as f64)
} else {
Value::Real(0.0)
}
}
"cume_dist" => {
// (# rows ordered <= current, incl. peers) / rows.
let mut last = p;
while last + 1 < m
&& cmp_keys_coll(
&ord_keys[idx],
&ord_keys[ordered[last + 1]],
&[],
ord_colls,
)
.is_eq()
{
last += 1;
}
Value::Real((last + 1) as f64 / m as f64)
}
"ntile" => {
// SQLite takes the integer value (truncating a real, parsing
// text) and requires it >= 1, else errors.
let buckets = arg_vals[idx].first().map(eval::to_i64).unwrap_or(0);
if buckets < 1 {
return Err(Error::Error(
"argument of ntile must be a positive integer".into(),
));
}
Value::Integer(ntile_bucket(p, m, buckets))
}
"lag" | "lead" => {
let offset = arg_vals[idx].get(1).map(eval::to_i64).unwrap_or(1);
let default = arg_vals[idx].get(2).cloned().unwrap_or(Value::Null);
let target = if lname == "lag" {
p as i64 - offset
} else {
p as i64 + offset
};
if target >= 0 && (target as usize) < m {
arg_vals[ordered[target as usize]]
.first()
.cloned()
.unwrap_or(Value::Null)
} else {
default
}
}
"first_value" => fpos
.first()
.and_then(|&k| arg_vals[ordered[k]].first().cloned())
.unwrap_or(Value::Null),
"last_value" => fpos
.last()
.and_then(|&k| arg_vals[ordered[k]].first().cloned())
.unwrap_or(Value::Null),
"nth_value" => {
// SQLite requires the second argument to be a positive integer
// under numeric affinity: 2.0 and '2' are accepted, but 1.5,
// 0, a negative, or NULL error.
let raw = arg_vals[idx].get(1).cloned().unwrap_or(Value::Null);
let nth = match eval::Affinity::Numeric.coerce(raw) {
Value::Integer(n) if n >= 1 => n,
_ => {
return Err(Error::Error(
"second argument to nth_value must be a positive integer".into(),
));
}
};
// nth row within the (post-EXCLUDE) frame (1-based).
fpos.get((nth - 1) as usize)
.and_then(|&k| arg_vals[ordered[k]].first().cloned())
.unwrap_or(Value::Null)
}
// Aggregate windows over the frame (honoring any FILTER mask).
_ => {
let frame: Vec<&Vec<Value>> = fpos
.iter()
.filter(|&&k| passes[ordered[k]])
.map(|&k| &arg_vals[ordered[k]])
.collect();
match window_aggregate(lname, star, &frame) {
Ok(v) => v,
// A user-registered aggregate used as a window function:
// drive it over the frame with a fresh accumulator (the
// recompute-per-row path — no xInverse optimization). This
// is what makes `sqlite3_create_window_function`'s common
// case work; built-in names take precedence above.
Err(Error::Unsupported(_)) if self.aggregates.contains_key(lname) => {
let factory = &self.aggregates[lname];
let mut acc = factory();
for &row in &frame {
acc.step(row)?;
}
acc.finalize()?
}
Err(e) => return Err(e),
}
}
};
result[idx] = val;
}
Ok(())
}
/// The collating sequence to apply to each `ORDER BY` term (an explicit
/// `COLLATE`, else the underlying column's collation, else `BINARY`).
fn order_collations(
&self,
sel: &Select,
columns: &[ColumnInfo],
params: &Params,
) -> Vec<crate::value::Collation> {
let ctx = row_ctx(&[], columns, None, params);
// An ORDER BY term that is a bare position (`ORDER BY 1`) or an output
// alias takes the collation of the *output column* it names — including an
// explicit `COLLATE` written on that column's projection (`SELECT a COLLATE
// NOCASE … ORDER BY 1`). An explicit `COLLATE` on the term itself still
// wins. Only when the term names no output column does its own expression
// collation apply (a bare source-column ref → that column's collation).
let labels = self.output_labels(sel, columns);
let out_colls = self.output_collations(sel, columns, params);
sel.order_by
.iter()
.map(|t| {
if let Some(c) = explicit_collation(&t.expr) {
return c;
}
if let Some(idx) = resolve_order_index(&t.expr, &labels, out_colls.len())
&& let Some(c) = out_colls.get(idx)
{
return *c;
}
eval::key_collation(&t.expr, &ctx)
})
.collect()
}
/// The collation of each projected output column (a column's collation, an
/// explicit `COLLATE`, else `BINARY`). Wildcards expand to the source columns.
fn output_collations(
&self,
sel: &Select,
columns: &[ColumnInfo],
params: &Params,
) -> Vec<crate::value::Collation> {
let ctx = row_ctx(&[], columns, None, params);
let mut out = Vec::new();
for col in &sel.columns {
match col {
ResultColumn::Expr { expr, .. } => out.push(eval::key_collation(expr, &ctx)),
ResultColumn::Wildcard => {
out.extend(columns.iter().filter(|c| !c.hidden).map(|c| c.collation));
}
ResultColumn::TableWildcard(t) => out.extend(
columns
.iter()
.filter(|c| !c.hidden && c.table.eq_ignore_ascii_case(t))
.map(|c| c.collation),
),
}
}
out
}
/// Whether the query groups by *exactly* the rowid / INTEGER PRIMARY KEY of
/// its single base table. Such a `GROUP BY` degenerates to one row per group,
/// emitted in rowid order, so sqlite plain-scans the table (it never picks a
/// covering index for it) and — for a sole `ORDER BY` term on that same key —
/// needs no temp b-tree. `label` is the table's alias-or-name (the group key
/// may qualify with it). `meta` must be this table's metadata.
fn group_by_is_rowid(&self, sel: &Select, meta: &TableMeta, label: &str) -> bool {
if sel.group_by.len() != 1 || sel.distinct || meta.without_rowid {
return false;
}
let Expr::Column {
schema: None,
table,
column,
..
} = &sel.group_by[0]
else {
return false;
};
if table
.as_deref()
.is_some_and(|tn| !tn.eq_ignore_ascii_case(label))
{
return false;
}
let shadowed = meta
.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(column));
let is_rowid_alias = matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) && !shadowed;
let is_ipk = meta
.ipk
.is_some_and(|i| meta.columns[i].name.eq_ignore_ascii_case(column));
is_rowid_alias || is_ipk
}
/// Whether a `DISTINCT` is a no-op because the projection includes the rowid /
/// INTEGER PRIMARY KEY of its single base table: that column is unique per row,
/// so no two output rows can be equal and the de-duplication removes nothing.
/// sqlite then plans the query exactly as if `DISTINCT` were absent (it may
/// still pick a covering index for the scan; only the *redundant* `ORDER BY`
/// temp b-tree on the rowid is dropped). A bare `*` / `t.*` counts when the
/// table has an explicit INTEGER PRIMARY KEY column (it is in the expansion);
/// an *expression* projection (`id+0`) does not — sqlite keeps a `DISTINCT`
/// b-tree for it. `meta` must be this table's metadata, `label` its alias-or-name.
fn distinct_is_noop(&self, sel: &Select, meta: &TableMeta, label: &str) -> bool {
if !sel.distinct {
return false;
}
let col_is_rowid = |table: &Option<String>, column: &str| -> bool {
if table
.as_deref()
.is_some_and(|tn| !tn.eq_ignore_ascii_case(label))
{
return false;
}
let shadowed = meta
.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(column));
let is_alias = matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) && !shadowed
&& !meta.without_rowid;
let is_ipk = meta
.ipk
.is_some_and(|i| meta.columns[i].name.eq_ignore_ascii_case(column));
is_alias || is_ipk
};
sel.columns.iter().any(|rc| match rc {
ResultColumn::Expr {
expr:
Expr::Column {
schema: None,
table,
column,
..
},
..
} => col_is_rowid(table, column),
// `*` / `t.*` expands to include an explicit INTEGER PRIMARY KEY column.
ResultColumn::Wildcard => meta.ipk.is_some(),
ResultColumn::TableWildcard(t) => meta.ipk.is_some() && t.eq_ignore_ascii_case(label),
_ => false,
})
}
/// When a query's sole `ORDER BY` term is the rowid / INTEGER PRIMARY KEY of
/// a single plain table that is scanned in full (no `WHERE`, no aggregate,
/// window, or non-trivial `DISTINCT`), the table b-tree already yields rows in
/// rowid order
/// — so the sort is redundant. A `GROUP BY` is permitted only when it is itself
/// exactly that rowid/IPK ([`group_by_is_rowid`](Self::group_by_is_rowid)): each
/// group is then a single row in rowid order, and sqlite suppresses the sort —
/// but only for a *single-term* `ORDER BY` on that key (unlike the plain scan
/// below, it does not elide trailing terms via key uniqueness). Returns
/// `Some(descending)` in that case (the caller reverses for `DESC`), else `None`
/// (sort normally). Shared by `run_core` and `eqp_access` so execution and
/// `EXPLAIN QUERY PLAN` agree.
fn rowid_ordered_scan(&self, sel: &Select) -> Option<bool> {
let from = sel.from.as_ref()?;
if !from.joins.is_empty() {
return None;
}
let t = &from.first;
if t.subquery.is_some() || t.tvf_args.is_some() || t.schema.is_some() {
return None;
}
if sel.where_clause.is_some() || sel.order_by.is_empty() {
return None;
}
if window::has_window(sel) {
return None;
}
// A CTE/view of the same name is not a rowid table scan.
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return None;
}
let label = t.alias.as_deref().unwrap_or(&t.name);
let meta = self.table_meta(&t.name, t.alias.as_deref()).ok()?;
if meta.without_rowid {
return None;
}
// A `DISTINCT` is acceptable only when it is a no-op — its projection pins the
// rowid/IPK, so it removes nothing and sqlite plans the query as if absent.
if sel.distinct && !self.distinct_is_noop(sel, &meta, label) {
return None;
}
// A `GROUP BY` is acceptable only when it is itself exactly the rowid/IPK:
// each group is then a single row in rowid order. Unlike the plain-scan
// case, sqlite does NOT then elide trailing `ORDER BY` terms via the key's
// uniqueness (e.g. `GROUP BY id ORDER BY id, a` still sorts), so require a
// single-term `ORDER BY`. A non-rowid `GROUP BY` disqualifies the scan.
let rowid_group = self.group_by_is_rowid(sel, &meta, label);
if !sel.group_by.is_empty() && !rowid_group {
return None;
}
if rowid_group {
if sel.order_by.len() != 1 {
return None;
}
// An aggregate such as `count(*)` is fine here — every group is one row
// — and a `HAVING` only filters whole (singleton, rowid-ordered) groups,
// so neither disturbs the order; both are permitted in this case only.
} else if sel.having.is_some() || self.has_aggregate(sel) {
return None;
}
// The *leading* ORDER BY term must be a plain (un-COLLATE'd) reference to
// the rowid or the INTEGER PRIMARY KEY column of this table. Because that
// key is unique, any trailing terms can never break a tie — the rowid
// scan order alone fully determines the result order — so a multi-term
// `ORDER BY id, b` is satisfied by the scan exactly like a lone `ORDER BY
// id`, matching sqlite (which emits no temp b-tree for either). (In the
// rowid-group case there is only the one term, checked above.)
let order_cols = order_projection(&sel.columns, &meta.columns);
let term = &sel.order_by[0];
let (tbl, col) = match order_key_expr(&order_cols, &term.expr) {
Expr::Column { table, column, .. } => (table.as_deref(), column.as_str()),
_ => return None,
};
if tbl.is_some_and(|tn| !tn.eq_ignore_ascii_case(label)) {
return None;
}
let shadowed = meta
.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(col));
let is_rowid_alias = matches!(
col.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) && !shadowed;
let is_ipk = meta
.ipk
.is_some_and(|i| meta.columns[i].name.eq_ignore_ascii_case(col));
if is_rowid_alias || is_ipk {
Some(term.descending)
} else {
None
}
}
/// The `WITHOUT ROWID` analogue of [`rowid_ordered_scan`]: such a table is
/// stored as a b-tree clustered by its PRIMARY KEY, so a full scan yields rows
/// in `storage_order` (PK columns first, then the rest) ascending. When the
/// whole `ORDER BY` is a uniform-direction **contiguous prefix** of that storage
/// order, the scan already produces the requested order — ascending needs no
/// sorter, descending only the executor's materialise-then-reverse — so sqlite
/// plans a bare `SCAN`. Returns `Some(descending)`.
///
/// Restricted to an all-ascending PK (`meta.pk_all_asc`): graphite stores every
/// `WITHOUT ROWID` PK ascending regardless of a declared `DESC`, so for a `DESC`
/// PK its storage order would not match sqlite's and the elision would diverge —
/// we decline and keep the sorter. A `WHERE` clause, join, grouping, aggregate,
/// window, `DISTINCT`, or a non-redundant explicit `NULLS` ordering (one the
/// uniform storage walk can't produce, per [`redundant_nulls`]) also declines. (A
/// mixed-direction or non-prefix `ORDER BY` falls through to the existing
/// full-sort path, which still differs from sqlite's *partial* sorter for such a
/// query — a separate, pre-existing divergence not addressed here.)
fn without_rowid_ordered_scan(&self, sel: &Select) -> Option<bool> {
let from = sel.from.as_ref()?;
if !from.joins.is_empty() {
return None;
}
let t = &from.first;
if t.subquery.is_some() || t.tvf_args.is_some() || t.schema.is_some() {
return None;
}
if sel.where_clause.is_some()
|| sel.order_by.is_empty()
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
|| self.has_aggregate(sel)
|| window::has_window(sel)
{
return None;
}
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return None;
}
let label = t.alias.as_deref().unwrap_or(&t.name);
let meta = self.table_meta(&t.name, t.alias.as_deref()).ok()?;
if !meta.without_rowid {
return None;
}
// The ORDER BY must be a contiguous prefix of the PK-clustered storage
// order, and every term's direction *relative to the stored column's
// direction* must be uniform: the b-tree walks in storage order, so a
// per-term requested/stored mismatch is `true` and all terms must agree
// (either all match the walk, or all reverse it — a single global
// reverse). `order_projection` resolves a `SELECT *` wildcard / positional
// ordinal to the column it names; a `COLLATE`-wrapped term is an
// `Expr::Collate`, not a bare column, and bails — a bare `ORDER BY col`
// inherently uses the column's storage collation.
if sel.order_by.len() > meta.storage_order.len() {
return None;
}
let order_cols = order_projection(&sel.columns, &meta.columns);
let mut reverse: Option<bool> = None;
for (i, term) in sel.order_by.iter().enumerate() {
if !redundant_nulls(term) {
return None;
}
let (tbl, col) = match order_key_expr(&order_cols, &term.expr) {
Expr::Column { table, column, .. } => (table.as_deref(), column.as_str()),
_ => return None,
};
if tbl.is_some_and(|tn| !tn.eq_ignore_ascii_case(label)) {
return None;
}
if !meta.columns[meta.storage_order[i]]
.name
.eq_ignore_ascii_case(col)
{
return None;
}
// Stored direction of the storage-order column at position `i`: a PK
// column carries its declared `DESC`; trailing non-PK columns are
// stored ascending.
let stored_desc = meta.pk_descending.get(i).copied().unwrap_or(false);
let rev = term.descending != stored_desc;
if *reverse.get_or_insert(rev) != rev {
return None;
}
}
Some(reverse.unwrap_or(false))
}
/// The `WHERE`-seek analogue of [`without_rowid_ordered_scan`]: a leading-PK
/// equality (`try_without_rowid_pk_seek`) or range (`try_without_rowid_pk_range`)
/// seeks the PK-clustered b-tree and then walks it forward, so the rows arrive
/// in PK storage order from the seek point — exactly the orders SQLite plans
/// with no sorter on top of the `SEARCH … USING PRIMARY KEY (…)`. The executor
/// tries these PK seeks before any secondary index or scan, so a leading-PK
/// constraint guarantees the PK-ordered walk.
///
/// Equality-pinned columns (any `col = const` / `col IS NULL` conjunct) are
/// constant across the seeked rows, so SQLite drops `ORDER BY` terms on them; if
/// every remaining term is then a uniform-direction contiguous prefix of the
/// walked storage order (skipping the constant columns there too), the sort is
/// elided. Returns `Some(descending)`. Restricted to an all-ascending PK
/// (`meta.pk_all_asc`); a leading-PK `IN`-list (`try_without_rowid_pk_in`, whose
/// multi-value order is not proven here) declines.
fn without_rowid_seek_order(&self, sel: &Select, params: &Params) -> Option<bool> {
let from = sel.from.as_ref()?;
if !from.joins.is_empty() {
return None;
}
let t = &from.first;
if t.subquery.is_some()
|| t.tvf_args.is_some()
|| t.schema.is_some()
|| t.index_hint.is_some()
{
return None;
}
let where_expr = sel.where_clause.as_ref()?;
if sel.order_by.is_empty()
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
|| self.has_aggregate(sel)
|| window::has_window(sel)
{
return None;
}
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return None;
}
let label = t.alias.as_deref().unwrap_or(&t.name);
let meta = self.table_meta(&t.name, t.alias.as_deref()).ok()?;
if !meta.without_rowid {
return None;
}
let pk = &meta.storage_order[..meta.pk_len];
// Columns the WHERE pins to a single value — constant across the result, so
// an `ORDER BY` term on one of them carries no ordering and is dropped.
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
eqs.retain(|(_, v)| !matches!(v, Value::Null));
let mut const_cols: Vec<usize> = eqs.iter().map(|(c, _)| *c).collect();
collect_isnull_cols(where_expr, &meta.columns, &mut const_cols);
// The seek mode picks where the PK walk begins. A leading-PK equality
// prefix (the contiguous run of `pk[i]` pinned by `=`) seeks past those
// columns; otherwise a leading-PK range walks the whole PK from the start.
// A leading-PK `IN`-list takes a different (unproven-order) path — decline.
let eq_prefix = pk
.iter()
.take_while(|&&c| eqs.iter().any(|(col, _)| *col == c))
.count();
let walk_start = if eq_prefix > 0 {
eq_prefix
} else {
if let Some((col, _)) = find_in_constraint(where_expr, &meta.columns, params)
&& pk.first() == Some(&col)
{
return None;
}
let lead = *pk.first()?;
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
match ranges.get(&lead) {
Some(b) if b.lower.is_some() || b.upper.is_some() => 0,
_ => return None,
}
};
// Walk the remaining storage columns, dropping the constant ones, and check
// every non-constant ORDER BY term lands on the next walked column in one
// uniform direction (default NULLs). A bare `ORDER BY col` uses the column's
// own collation, which is its storage collation; a `COLLATE`-wrapped term is
// an `Expr::Collate`, not a bare column, and bails.
let order_cols = order_projection(&sel.columns, &meta.columns);
let storage = &meta.storage_order;
let mut walk = walk_start;
let mut dir: Option<bool> = None;
for term in &sel.order_by {
let (tbl, col_name) = match order_key_expr(&order_cols, &term.expr) {
Expr::Column { table, column, .. } => (table.as_deref(), column.as_str()),
_ => return None,
};
if tbl.is_some_and(|tn| !tn.eq_ignore_ascii_case(label)) {
return None;
}
let oc = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(col_name))?;
if const_cols.contains(&oc) {
continue; // constant column: contributes no ordering
}
if !redundant_nulls(term) {
return None;
}
while walk < storage.len() && const_cols.contains(&storage[walk]) {
walk += 1;
}
if walk >= storage.len() || storage[walk] != oc {
return None;
}
// Direction *relative to the stored column's direction*: the b-tree
// walks in storage order, so a match needs no reverse and a mismatch
// reverses. All terms must agree on that single global reverse flag.
let rev = term.descending != meta.storage_desc(walk);
let d = *dir.get_or_insert(rev);
if rev != d {
return None;
}
walk += 1;
}
// Every term was either constant or matched the walk in `dir` (or all terms
// were constant — a fully-pinned key, at most one row, any order trivially
// satisfied → no reversal needed).
Some(dir.unwrap_or(false))
}
/// The full-scan analogue of [`without_rowid_seek_order`]: a `WITHOUT ROWID`
/// table whose `WHERE` constrains *only* non-seekable columns is still walked
/// by a full scan of the PK-clustered b-tree, so the surviving rows arrive in
/// PK storage order — SQLite plans a bare `SCAN w` and elides the sorter for a
/// uniform `ORDER BY` prefix of that order, while graphite kept a spurious
/// `USE TEMP B-TREE FOR ORDER BY`.
///
/// The PK-ordered walk only holds while *no* seek fires. graphite seeks a
/// `WITHOUT ROWID` table's PRIMARY KEY when its leading key column is
/// constrained (handled by [`without_rowid_seek_order`]) and a secondary index
/// when *that* index's leading column is constrained (its walk is the index's
/// order, not the PK's — and unlike a rowid table, `order_index_scan` never
/// picks a secondary index for *ordering* here, so an unconstrained index is
/// never walked). So this path stands down if the leading PK column, or any
/// secondary index's leading column, carries an equality / `IN` / range
/// constraint; otherwise the scan is the PK b-tree and the equality-pinned
/// columns drop out of the `ORDER BY` exactly as in the seek case.
fn without_rowid_scan_filtered_order(&self, sel: &Select, params: &Params) -> Option<bool> {
let from = sel.from.as_ref()?;
if !from.joins.is_empty() {
return None;
}
let t = &from.first;
if t.subquery.is_some()
|| t.tvf_args.is_some()
|| t.schema.is_some()
|| t.index_hint.is_some()
{
return None;
}
let where_expr = sel.where_clause.as_ref()?;
if sel.order_by.is_empty()
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
|| self.has_aggregate(sel)
|| window::has_window(sel)
{
return None;
}
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return None;
}
let label = t.alias.as_deref().unwrap_or(&t.name);
let meta = self.table_meta(&t.name, t.alias.as_deref()).ok()?;
if !meta.without_rowid {
return None;
}
let lead = *meta.storage_order[..meta.pk_len].first()?;
// Gather the WHERE's column constraints once: equalities (the constant /
// pinned columns), an `IN`-list, and range bounds. A leading-column
// constraint of any of these kinds would steer the executor onto a seek.
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
eqs.retain(|(_, v)| !matches!(v, Value::Null));
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
let in_col = find_in_constraint(where_expr, &meta.columns, params).map(|(c, _)| c);
// `true` when column `c` would drive a PK or index seek.
let seekable = |c: usize| -> bool {
eqs.iter().any(|(col, _)| *col == c)
|| in_col == Some(c)
|| ranges
.get(&c)
.is_some_and(|b| b.lower.is_some() || b.upper.is_some())
};
// A constrained leading PK column → PK seek (the seek path's job). A
// constrained secondary-index leading column → that index is seeked.
if seekable(lead) {
return None;
}
for idx in self.indexes_of(&t.name).ok()? {
if idx.partial.is_some() || idx.key_exprs.is_some() {
continue;
}
if idx.cols.first().is_some_and(|&c| seekable(c)) {
return None;
}
}
// The access path is the full PK-ordered scan. Drop the equality-pinned
// (constant) columns and match every remaining ORDER BY term against the
// next non-constant storage column in one uniform direction (default NULLs).
let mut const_cols: Vec<usize> = eqs.iter().map(|(c, _)| *c).collect();
collect_isnull_cols(where_expr, &meta.columns, &mut const_cols);
let order_cols = order_projection(&sel.columns, &meta.columns);
let storage = &meta.storage_order;
let mut walk = 0usize;
let mut dir: Option<bool> = None;
let mut consumed = 0usize;
for term in &sel.order_by {
let (tbl, col_name) = match order_key_expr(&order_cols, &term.expr) {
Expr::Column { table, column, .. } => (table.as_deref(), column.as_str()),
_ => return None,
};
if tbl.is_some_and(|tn| !tn.eq_ignore_ascii_case(label)) {
return None;
}
let oc = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(col_name))?;
if const_cols.contains(&oc) {
continue;
}
let before = walk;
while walk < storage.len() && const_cols.contains(&storage[walk]) {
walk += 1;
}
// An *internal* pinned-column skip (a constant column sitting between two
// consumed terms) makes the later term functionally determined by the
// earlier ones, so graphite *could* drop it — but SQLite keeps a partial
// `USE TEMP B-TREE FOR LAST TERM OF ORDER BY` there, which graphite does
// not model. Decline so that pre-existing divergence stays exactly as it
// was (graphite's full sorter) rather than becoming a new one.
if consumed > 0 && walk > before {
return None;
}
if walk >= storage.len() || storage[walk] != oc {
return None;
}
// Reverse iff the requested direction differs from the stored column's
// direction; all terms must agree on one global reverse flag.
let rev = term.descending != meta.storage_desc(walk);
let d = *dir.get_or_insert(rev);
if rev != d || !redundant_nulls(term) {
return None;
}
walk += 1;
consumed += 1;
}
Some(dir.unwrap_or(false))
}
/// The secondary-index analogue of [`rowid_ordered_scan`]: when the same
/// single-table full-scan shape has its sole `ORDER BY` term as a plain
/// column that is the leading column of a full (non-partial, non-expression)
/// index whose collation matches the column's, scanning that index in key
/// order yields rows in `ORDER BY` order. Returns `(index name, root,
/// collations, descending)`. NULLs sort first in the index (ascending),
/// matching `ORDER BY col ASC`; reversing for `DESC` puts them last, matching
/// `ORDER BY col DESC` — so both directions are exact.
fn order_index_scan(&self, sel: &Select, params: &Params) -> Option<OrderIndexScan> {
let from = sel.from.as_ref()?;
if !from.joins.is_empty() {
return None;
}
let t = &from.first;
if t.subquery.is_some() || t.tvf_args.is_some() || t.schema.is_some() {
return None;
}
// `NOT INDEXED` forbids walking any index to satisfy the ORDER BY, so SQLite
// sorts (a temp b-tree) — never an index scan.
if matches!(t.index_hint, Some(IndexHint::NotIndexed)) {
return None;
}
if !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
|| sel.order_by.is_empty()
{
return None;
}
// A WHERE is allowed only when it is *not* served by a seek index — i.e. the
// access would otherwise be a full table SCAN. SQLite then walks the
// ORDER-BY index to avoid the sort (B9h sort-avoidance); when the WHERE does
// seek an index, that seek (and any sort) is planned instead, so bail here.
// The executor reaches this path only after every seek attempt fails, and
// `run_core` re-applies the WHERE to the ordered rows downstream, so the
// rows stay correct either way — this gate keeps the EQP/order-satisfied
// decision in lockstep with SQLite.
// When admitted via the single-open-range rule below, the name of the index
// that range would otherwise seek — the override is suppressed if the chosen
// ORDER-BY index turns out to be the same one (the seek is already ordered).
let mut seek_index: Option<String> = None;
if let Some(w) = &sel.where_clause {
let meta = self.table_meta(&t.name, t.alias.as_deref()).ok()?;
let label = t.alias.as_deref().unwrap_or(&t.name);
let access = self
.eqp_access(label, &t.name, &meta, Some(w), Some(sel), params)
.ok()?;
// A plain full scan reads as `SCAN <label>` with no `USING …` index;
// sqlite then walks the ORDER-BY index to avoid the sort (B9h).
let plain_scan = access.starts_with("SCAN ") && !access.contains(" USING ");
// A *single open-ended* range seek (`b>?`, `b<?`, …) is not selective
// enough (default ~1/4 rows) to beat walking the ORDER-BY index and
// avoiding the sort, so sqlite prefers the ORDER-BY index there too —
// unlike an equality / bounded range (`… AND …`) / `IN` (all `=?`),
// which stay a seek + sort. Recognised structurally from the render (a
// lone `>`/`<` bound, no ` AND `, no bare `=`). Only without ANALYSE,
// whose value-specific selectivity is not modelled by this heuristic;
// with stats the seek-vs-scan cost decides (`run_core` re-applies the
// WHERE to the ordered rows, so results stay correct either way).
let single_open_range = access.starts_with("SEARCH ")
&& access.contains(" (")
&& (access.contains('>') || access.contains('<'))
&& !access.contains(" AND ")
&& self.stat1_map().is_empty();
if !plain_scan && !single_open_range {
return None;
}
// The index this range would seek. If the ORDER-BY index chosen below is
// this SAME index, the seek itself already yields ordered rows (B9j
// seek-order-credit → a SEARCH, not a plain SCAN), so this override must
// not fire; recorded here and checked after the order index is picked.
if single_open_range {
// `… USING [COVERING] INDEX <name> (<bound>)` → `<name>`.
seek_index = access
.rsplit(" USING ")
.next()
.map(|s| {
s.trim_start_matches("COVERING ")
.trim_start_matches("INDEX ")
})
.and_then(|s| s.split(" (").next())
.map(|s| s.trim().to_string());
}
}
if self.has_aggregate(sel) || window::has_window(sel) {
return None;
}
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return None;
}
let label = t.alias.as_deref().unwrap_or(&t.name);
let meta = self.table_meta(&t.name, t.alias.as_deref()).ok()?;
if meta.without_rowid {
return None;
}
// Resolve every `ORDER BY` term to a plain table column. A secondary index
// (stored ascending; reversed for a leading DESC) walks its columns in ONE
// direction, so it satisfies a uniform leading PREFIX of the ORDER BY;
// trailing terms that change direction are sorted by the caller (`sorted_
// suffix`). The walk yields the default NULL placement for its direction, so
// a redundant explicit `NULLS` clause (matching that default, per
// [`redundant_nulls`]) is fine; the opposite placement needs a two-pass scan
// we don't model and disqualifies the index, as does a `COLLATE`/non-column
// term (not a plain column).
let order_cols = order_projection(&sel.columns, &meta.columns);
let descending = sel.order_by[0].descending;
let mut cols: Vec<usize> = Vec::with_capacity(sel.order_by.len());
// Each term's *effective* collation (an explicit `COLLATE`, else the
// column's declared collation) — an index serves the term only when its
// stored collation for that column equals this (B9j).
let mut term_colls: Vec<crate::value::Collation> = Vec::with_capacity(sel.order_by.len());
let mut uniform_prefix = 0usize;
let mut prefix_open = true;
for term in &sel.order_by {
if !redundant_nulls(term) {
return None;
}
let resolved = order_key_expr(&order_cols, &term.expr);
let explicit = explicit_collation(resolved);
// Peel an explicit `COLLATE` / parens down to the underlying column.
let mut base = resolved;
while let Expr::Collate { expr, .. } | Expr::Paren(expr) = base {
base = expr;
}
let (tbl, col_name) = match base {
Expr::Column { table, column, .. } => (table.as_deref(), column.as_str()),
_ => return None,
};
if tbl.is_some_and(|tn| !tn.eq_ignore_ascii_case(label)) {
return None;
}
let col = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(col_name))?;
term_colls.push(explicit.unwrap_or(meta.columns[col].collation));
cols.push(col);
if prefix_open && term.descending == descending {
uniform_prefix += 1;
} else {
prefix_open = false;
}
}
// A lone rowid/IPK term is the `rowid_ordered_scan` case.
if cols.len() == 1 && meta.ipk == Some(cols[0]) {
return None;
}
// An index whose leading columns agree with a LEADING PREFIX of the ORDER
// BY columns (in order, same collation) walks that prefix in order; sqlite
// sorts only the remaining terms. The usable prefix is the shorter of the
// index/ORDER-BY column match (`match_len`) and the uniform-direction run
// (`uniform_prefix`) — an index can be SHORTER than the ORDER BY (`ORDER BY
// a, b` over an index on `a` → walk `a`, sort `b`) as well as longer. When
// the prefix is the whole ORDER BY (`sorted_suffix == 0`) the walk needs no
// sort; a partial walk (`sorted_suffix > 0`) is taken only for the
// NON-covering case (the covered one is `covering_scan` + `scan_order_
// prefix`, which already reads in order).
// Score every qualifying index and keep the best rather than the first: an
// index that orders MORE of the `ORDER BY` (smaller `sorted_suffix`) wins,
// then a covering one (no table fetch), then narrower width, then newest —
// so `ORDER BY a, b` over `ia(a)`+`iab(a,b)` reads the covering `iab` fully
// in order instead of walking `ia` and sorting `b`, matching sqlite.
type OrderKey = (usize, bool, i16, core::cmp::Reverse<u32>);
let mut best: Option<(OrderKey, OrderIndexScan)> = None;
for idx in self.indexes_of(&t.name).ok()? {
if idx.partial.is_some() || idx.key_exprs.is_some() {
continue;
}
let match_len = idx
.cols
.iter()
.zip(cols.iter())
.take_while(|(a, b)| a == b)
.count();
let mut ordered = match_len.min(uniform_prefix);
// Restrict the ordered prefix to the run over which the walk's stored
// direction bears a UNIFORM relationship to the requested direction. A
// mixed-direction index (`a ASC, b DESC`) walked forward yields `a` up
// and `b` down, so it can serve `ORDER BY a, b DESC` (reverse=false) or
// its full reversal `ORDER BY a DESC, b` (reverse=true), but not a
// uniform-requested `ORDER BY a, b`. `reverse` is that relationship.
let mut reverse: Option<bool> = None;
for i in 0..ordered {
let stored_desc = idx.descending.get(i).copied().unwrap_or(false);
let this_reverse = stored_desc != sel.order_by[i].descending;
match reverse {
None => reverse = Some(this_reverse),
Some(r) if r != this_reverse => {
ordered = i;
break;
}
Some(_) => {}
}
}
if ordered == 0 {
continue;
}
let descending = reverse.unwrap_or(false);
// The index serves each ordered term only when its stored collation
// matches that term's *effective* collation (an explicit `COLLATE` or
// the column's declared collation) — B9j.
let coll_ok = (0..ordered).all(|i| idx.collations[i] == term_colls[i]);
if !coll_ok {
continue;
}
// Every secondary index on a rowid table is implicitly ordered by
// `(key columns…, rowid)`, with the rowid stored ASCENDING. So once the
// walk has consumed ALL of the index's explicit columns as a uniform-
// direction prefix, a trailing ORDER BY term that is the INTEGER PRIMARY
// KEY (i.e. the rowid) is ordered too — provided the matched columns are
// all ascending, so the single forward/backward walk keeps the rowid in
// phase (a DESC index column stores the rowid out of phase under
// reversal). The rowid then fully determines the row order, so nothing
// after it needs sorting: `ORDER BY b, id` over an index on `(b)` is
// served entirely by the walk, like sqlite (no temp b-tree). This holds
// for a UNIQUE index too (its entries are still `(key…, rowid)`, with
// multiple NULLs broken by rowid) — but only a *named* index has
// accurate per-column directions; an automatic UNIQUE/PK index assumes
// ascending, so it is excluded to avoid mis-crediting a `UNIQUE(b DESC)`
// constraint.
let rowid_tail = match_len == idx.cols.len()
&& ordered < uniform_prefix
&& meta.ipk == Some(cols[ordered])
&& !idx.is_auto
&& idx.descending.iter().take(match_len).all(|d| !d);
let sorted_suffix = if rowid_tail { 0 } else { cols.len() - ordered };
// `COVERING` requires the index to hold *every* referenced column —
// including the `WHERE` columns, which `index_covers_query` (projection
// + ORDER BY only) omits. When a seek predicate is served here (the
// single-open-range case), an uncovered WHERE column still needs the
// table row, so sqlite drops the `COVERING` label; fold that in.
let covering = self.index_covers_query(sel, &meta, &idx.cols)
&& sel
.where_clause
.as_ref()
.is_none_or(|w| where_cols_covered(w, &meta, &idx.cols));
if sorted_suffix > 0 && covering {
continue;
}
let key: OrderKey = (
sorted_suffix,
!covering,
self.index_seek_width(&t.name, &idx),
core::cmp::Reverse(idx.root),
);
if best.as_ref().is_none_or(|(bk, _)| key < *bk) {
best = Some((
key,
OrderIndexScan {
name: idx.name,
root: idx.root,
colls: idx.collations,
cols: idx.cols,
descending,
covering,
sorted_suffix,
},
));
}
}
// Suppress the single-open-range override when the ORDER-BY index picked is
// the very index the range seeks: there the SEARCH already reads in order
// (seek-order-credit), so sqlite keeps the SEARCH rather than a plain SCAN.
if let (Some(seek), Some((_, s))) = (&seek_index, &best)
&& s.name.eq_ignore_ascii_case(seek)
{
return None;
}
best.map(|(_, s)| s)
}
/// For a no-`WHERE` query whose access is a covering-index scan
/// ([`covering_scan`]) but whose `ORDER BY` is NOT fully satisfied by that
/// walk (mixed directions), the number of LEADING `ORDER BY` terms the index
/// already yields in order. The walk direction is fixed by the first term;
/// each further term must stay in that direction and continue matching the
/// index's columns/collations, else the prefix ends there. sqlite sorts only
/// the remaining terms — "USE TEMP B-TREE FOR LAST n TERMS OF ORDER BY". Zero
/// when no covering scan applies or the first term already breaks.
fn scan_order_prefix(&self, sel: &Select, params: &Params) -> usize {
if sel.order_by.is_empty() {
return 0;
}
let Some(from) = sel.from.as_ref() else {
return 0;
};
if !from.joins.is_empty() {
return 0;
}
let Ok(meta) = self.table_meta(&from.first.name, from.first.alias.as_deref()) else {
return 0;
};
// The index `covering_scan` reads from (its choice must match the EQP).
let Some((name, _, _)) = self.covering_scan(sel, &meta, params) else {
return 0;
};
let Ok(indexes) = self.indexes_of(&from.first.name) else {
return 0;
};
let Some(idx) = indexes
.into_iter()
.find(|i| i.name.eq_ignore_ascii_case(&name))
else {
return 0;
};
let label = from.first.alias.as_deref().unwrap_or(&from.first.name);
let order_cols = order_projection(&sel.columns, &meta.columns);
// The forward walk yields column `i` in its STORED direction
// (`idx.descending[i]`). A term is served only when its (stored-dir vs
// requested-dir) relationship matches that of the first served term — a
// single physical walk cannot mix. `backward` is that uniform relationship.
let mut backward: Option<bool> = None;
let mut k = 0usize;
for (i, term) in sel.order_by.iter().enumerate() {
if i >= idx.cols.len() || !redundant_nulls(term) {
break;
}
let stored_desc = idx.descending.get(i).copied().unwrap_or(false);
let this_backward = stored_desc != term.descending;
match backward {
None => backward = Some(this_backward),
Some(b) if b != this_backward => break,
Some(_) => {}
}
let (tbl, col_name) = match order_key_expr(&order_cols, &term.expr) {
Expr::Column { table, column, .. } => (table.as_deref(), column.as_str()),
_ => break,
};
if tbl.is_some_and(|tn| !tn.eq_ignore_ascii_case(label)) {
break;
}
let Some(col) = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(col_name))
else {
break;
};
if col != idx.cols[i] || idx.collations[i] != meta.columns[col].collation {
break;
}
k += 1;
}
k
}
/// Covering check for a WHERE-driven *seek* (B2b, seek case): on top of
/// [`index_covers_query`](Self::index_covers_query) (result columns + `ORDER
/// BY`), every column the `WHERE` clause references must also be covered by
/// `idx_cols` or be the rowid. The seek's own index column is covered by
/// construction, but a residual predicate on some *other* column (e.g.
/// `WHERE c=5 AND b>0`) would still need the table unless that column is in
/// the index too. Conservative: any construct whose referenced columns can't
/// be enumerated (a subquery/`EXISTS`/`IN (SELECT …)`) makes this `false`, so
/// the caller falls back to the always-correct table-fetch path.
fn seek_index_covers(
&self,
sel: &Select,
meta: &TableMeta,
idx_cols: &[usize],
where_expr: &Expr,
) -> bool {
// `query_cols_covered` recurses through function/aggregate arguments, so a
// covered-only-by-WHERE aggregate (`SELECT count(*) … WHERE a=?`,
// `sum(a) … WHERE a=?`) qualifies as covering — matching sqlite, which
// labels that seek `USING COVERING INDEX`. It also folds in the GROUP BY /
// HAVING / ORDER BY / WHERE coverage checks; the explicit `where_expr`
// check below is retained for the (executor) call sites that narrow the
// predicate before reaching here.
if !self.query_cols_covered(sel, meta, idx_cols) {
return false;
}
where_cols_covered(where_expr, meta, idx_cols)
}
/// Build the input rows of a covering seek by walking the chosen index and
/// keeping every record (a superset — `run_core` re-applies the full `WHERE`,
/// so the seek's own predicate filters out non-matching keys). Each record is
/// `(indexed col values…, rowid)`; indexed columns are mapped onto their table
/// positions and the rowid fills the `INTEGER PRIMARY KEY` column, exactly as
/// the ordered covering scan does. Reads only the index b-tree — never the
/// table.
fn covering_seek_rows(
&self,
meta: &TableMeta,
root: u32,
idx_cols: &[usize],
) -> Result<Vec<InputRow>> {
let src = self.backend.source();
let encoding = src.header().text_encoding;
let mut icur = IndexCursor::new(src, root);
let mut out = Vec::new();
while let Some(payload) = icur.next()? {
let rec = decode_record(&payload, encoding)?;
let rowid = match rec.get(idx_cols.len()) {
Some(Value::Integer(r)) => *r,
_ => return Err(Error::Corrupt("index record missing rowid".into())),
};
let mut values = alloc::vec![Value::Null; meta.columns.len()];
for (i, &mc) in idx_cols.iter().enumerate() {
values[mc] = rec[i].clone();
}
promote_real_columns(meta, &mut values);
if let Some(ipk) = meta.ipk {
values[ipk] = Value::Integer(rowid);
}
out.push(InputRow {
values,
rowid: Some(rowid),
});
}
Ok(out)
}
/// Conservative covering check (B2): every column the query references
/// (result columns + `ORDER BY`) is an indexed column or the rowid, which is
/// present in every index record. Returns `false` on anything it cannot prove
/// covered — an expression/function/subquery result column, a wildcard over a
/// non-covered column, or any generated column on the table.
fn index_covers_query(&self, sel: &Select, meta: &TableMeta, idx_cols: &[usize]) -> bool {
if meta.generated.iter().any(|g| g.is_some()) {
return false;
}
let covered = |ci: usize| idx_cols.contains(&ci) || meta.ipk == Some(ci);
let col_ok = |expr: &Expr| -> bool {
match expr {
Expr::Column { column, .. } => match meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column))
{
Some(ci) => covered(ci),
None => matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
),
},
_ => false,
}
};
for rc in &sel.columns {
match rc {
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => {
if !(0..meta.columns.len()).all(covered) {
return false;
}
}
ResultColumn::Expr { expr, .. } => {
if !col_ok(expr) {
return false;
}
}
}
}
// A positional/alias `ORDER BY` term references the column it resolves to,
// not the literal ordinal; resolve it (through the wildcard-expanded
// projection, so an ordinal over `SELECT *` resolves too) so an index that
// holds that column is still recognised as covering (`SELECT b FROM t ORDER
// BY 1`, and the all-columns-covered `SELECT * FROM s ORDER BY 1`).
let order_cols = order_projection(&sel.columns, &meta.columns);
sel.order_by
.iter()
.all(|t| col_ok(order_key_expr(&order_cols, &t.expr)))
}
/// Thorough covering test for a *full-table covering scan*: every column the
/// query references anywhere — result projection (including aggregate
/// arguments), `GROUP BY`, `HAVING`, `ORDER BY`, and `WHERE` — is held by
/// `idx_cols` or is the rowid. Conservative: a wildcard over an uncovered
/// column, a generated column, a window function, or a subquery makes it
/// `false`. Unlike [`index_covers_query`](Self::index_covers_query) (plain
/// projections only) this recurses through function calls, so an aggregate
/// like `count(*)` / `sum(b)` over covered columns qualifies.
fn query_cols_covered(&self, sel: &Select, meta: &TableMeta, idx_cols: &[usize]) -> bool {
if meta.generated.iter().any(|g| g.is_some()) {
return false;
}
let covered_all =
(0..meta.columns.len()).all(|ci| idx_cols.contains(&ci) || meta.ipk == Some(ci));
for rc in &sel.columns {
match rc {
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => {
if !covered_all {
return false;
}
}
ResultColumn::Expr { expr, .. } => {
if !where_cols_covered(expr, meta, idx_cols) {
return false;
}
}
}
}
let order_cols = order_projection(&sel.columns, &meta.columns);
sel.group_by
.iter()
.all(|e| where_cols_covered(e, meta, idx_cols))
&& sel
.having
.as_ref()
.is_none_or(|h| where_cols_covered(h, meta, idx_cols))
&& sel
.order_by
.iter()
.all(|t| where_cols_covered(order_key_expr(&order_cols, &t.expr), meta, idx_cols))
&& sel
.where_clause
.as_ref()
.is_none_or(|w| where_cols_covered(w, meta, idx_cols))
}
/// For a single-table, plain-`SCAN` query that groups (`GROUP BY`) or
/// deduplicates (`DISTINCT`) over plain columns, decide whether `EXPLAIN
/// QUERY PLAN` should print `USE TEMP B-TREE FOR GROUP BY` / `FOR DISTINCT`
/// (and which), plus — when an `ORDER BY` is present — whether sqlite reuses
/// that same grouping b-tree to satisfy the sort (suppressing the separate
/// `USE TEMP B-TREE FOR ORDER BY` node).
///
/// SQLite materializes a transient b-tree whenever the access order does not
/// already cluster the key columns. Over a bare table scan that is *always*
/// the case, except when the single key is the rowid (rows already arrive in
/// rowid order — sqlite emits no node). The caller invokes this only for
/// graphite's bare `SCAN t` line, so there is no covering index or seek to
/// reorder rows; we additionally decline when sqlite would instead walk a
/// secondary index leading with the first key (rendering `SCAN t USING INDEX
/// it`, a scan-line shape graphite does not produce for grouping, so the whole
/// plan would diverge). `WITHOUT ROWID` tables are clustered by their primary
/// key — a separate, deferred case — and are excluded here.
///
/// The returned `bool` is `suppress_order_by`: `true` iff the query's `ORDER
/// BY` key list is *exactly* the grouping key list and the grouping b-tree
/// already delivers that order. `GROUP BY` can be walked either direction (any
/// per-column ASC/DESC); a `DISTINCT` b-tree is ascending-only (all terms must
/// be ASC). Either way the default NULL ordering must be in force (no explicit
/// `NULLS`). When the `ORDER BY` contains a term we cannot resolve to a plain
/// column of this table (positional, alias, expression, `COLLATE`), we return
/// `None` entirely — declining the whole node — rather than risk a plan that
/// emits the grouping node with a mis-decided sort node.
fn group_distinct_btree(
&self,
sel: &Select,
meta: &TableMeta,
tname: &str,
not_indexed: bool,
) -> Option<(&'static str, bool)> {
if !sel.compound.is_empty() || meta.without_rowid {
return None;
}
// Exactly one of GROUP BY / DISTINCT, over plain columns of this table.
let (kind, key_exprs): (&'static str, Vec<&Expr>) = if !sel.group_by.is_empty() {
if sel.distinct {
return None;
}
("GROUP BY", sel.group_by.iter().collect())
} else if sel.distinct {
let mut ks = Vec::with_capacity(sel.columns.len());
for rc in &sel.columns {
match rc {
ResultColumn::Expr { expr, .. } => ks.push(expr),
_ => return None, // wildcard projection → deferred
}
}
("DISTINCT", ks)
} else {
return None;
};
// Map every key to a plain column position of this table; bail otherwise.
let mut key_cols = Vec::with_capacity(key_exprs.len());
for e in &key_exprs {
match e {
Expr::Column {
schema: None,
table,
column,
..
} if table
.as_deref()
.is_none_or(|t| t.eq_ignore_ascii_case(tname)) =>
{
let pos = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column))?;
key_cols.push(pos);
}
_ => return None, // expression / qualified-other key → deferred
}
}
if key_cols.is_empty() {
return None;
}
// Rows already arrive clustered by the rowid: `GROUP BY` / `DISTINCT` on
// the integer primary key alone needs no temp b-tree (no sqlite node).
if key_cols.len() == 1 && meta.ipk == Some(key_cols[0]) {
return None;
}
// A secondary index leading with the first key column makes sqlite walk it
// (`SCAN t USING INDEX it`) rather than plain-scan — a scan-line shape
// graphite does not emit for grouping. Decline so the plan never desyncs.
// Under `NOT INDEXED` no index may be walked, so sqlite always materializes the
// grouping b-tree — skip this bail.
let first = key_cols[0];
if !not_indexed && let Ok(indexes) = self.indexes_of(tname) {
for idx in indexes {
if idx.partial.is_none()
&& idx.key_exprs.is_none()
&& idx.cols.first() == Some(&first)
{
return None;
}
}
}
// Decide whether sqlite folds the `ORDER BY` into this grouping b-tree. The
// b-tree itself always materializes (the caller emits its node regardless of
// the `ORDER BY`); the only question here is whether a *separate* sort node is
// still needed. sqlite reuses the grouping order — suppressing the ORDER BY
// node — exactly when every term names a grouping key column (directly, by
// 1-based position, or through an output alias), the resolved term list equals
// the key list, and each term's sort options are compatible: a GROUP BY b-tree
// can be walked to honor any per-column ASC/DESC (even mixed), while a DISTINCT
// b-tree is ascending-only; either way the NULL placement must be the default
// for that term's direction (ASC ⇒ NULLS FIRST, DESC ⇒ NULLS LAST). Any
// deviation simply leaves the ORDER BY node in place — it never declines the
// grouping node.
let as_table_col = |x: &Expr| -> Option<usize> {
match x {
Expr::Column {
schema: None,
table,
column,
..
} if table
.as_deref()
.is_none_or(|t| t.eq_ignore_ascii_case(tname)) =>
{
meta.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column))
}
_ => None,
}
};
// Resolve one ORDER BY term to a plain column position of this table, following
// a positional ordinal or an output alias to its underlying result column.
// `None` ⇒ the term is an aggregate / expression / foreign column, defeating
// the fold.
let resolve_term = |e: &Expr| -> Option<usize> {
if let Some(p) = as_table_col(e) {
return Some(p);
}
let ri = if let Some(n) = positional_int(e) {
usize::try_from(n).ok()?.checked_sub(1)?
} else if let Expr::Column {
schema: None,
table: None,
column,
..
} = e
{
sel.columns.iter().position(|rc| {
matches!(rc, ResultColumn::Expr { alias: Some(a), .. }
if a.eq_ignore_ascii_case(column))
})?
} else {
return None;
};
match sel.columns.get(ri)? {
ResultColumn::Expr { expr, .. } => as_table_col(expr),
_ => None,
}
};
let suppress_order_by = !sel.order_by.is_empty() && {
let mut ob_cols = Vec::with_capacity(sel.order_by.len());
let mut ok = true;
for term in &sel.order_by {
let nulls_default = redundant_nulls(term);
if !nulls_default || (kind == "DISTINCT" && term.descending) {
ok = false;
break;
}
match resolve_term(&term.expr) {
Some(p) => ob_cols.push(p),
None => {
ok = false;
break;
}
}
}
ok && ob_cols == key_cols
};
Some((kind, suppress_order_by))
}
/// Choose a full secondary index to satisfy a query by a *covering scan* —
/// reading every needed column from the index instead of the table — when no
/// `WHERE` seek and no ORDER-BY index walk applies. Restricted to the
/// no-`WHERE` case so no seek competes for the plan (keeping `eqp_select` and
/// `run_core` trivially in lockstep), to ordinary rowid tables, and — like
/// [`count_covering_index`](Self::count_covering_index) — to the *unambiguous*
/// case of **exactly one** covering index, so the chosen name matches sqlite
/// without replicating its cost-based tie-break. Returns `(name, root, cols)`.
fn covering_scan(
&self,
sel: &Select,
meta: &TableMeta,
params: &Params,
) -> Option<(String, u32, Vec<usize>)> {
let from = sel.from.as_ref()?;
if !from.joins.is_empty() {
return None;
}
let t = &from.first;
if t.subquery.is_some() || t.tvf_args.is_some() || t.schema.is_some() {
return None;
}
if window::has_window(sel) || meta.without_rowid {
return None;
}
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return None;
}
// A `WHERE` that *seeks* an index is a `SEARCH`, owned by the `eqp_access`
// seek path (which runs after this in both the EQP chain and the executor).
// A covering *full* scan applies only when no seek does — `eqp_access`
// renders exactly a bare `SCAN {label}` in that case, so gate on it. (The
// executor reaches here only after its own seek attempts fail, so this keeps
// the two in lockstep.) A covering index must also hold every `WHERE` column,
// which `query_cols_covered` already checks below.
if let Some(w) = &sel.where_clause {
let label = t.alias.as_deref().unwrap_or(&t.name);
let acc = self
.eqp_access(label, &t.name, meta, Some(w), Some(sel), params)
.ok()?;
if acc != alloc::format!("SCAN {label}") {
return None;
}
}
// If the ORDER BY is already satisfied by a scan's natural order — the
// rowid order of a table scan (`rowid_ordered_scan`) or an index walk
// (`order_index_scan`) — leave it alone. A covering scan reads in index
// order, which would silently break a `rowid_ordered_scan` that assumed
// the rows arrive in rowid order (and the ordered-index case already
// renders as covering).
if self.order_satisfied_by_scan(sel, params).is_some() {
return None;
}
// `GROUP BY` on the rowid/IPK degenerates to a rowid-ordered plain scan;
// sqlite never picks a covering index for it (it would still bare-`SCAN t`).
let label = t.alias.as_deref().unwrap_or(&t.name);
if self.group_by_is_rowid(sel, meta, label) {
return None;
}
let covering: Vec<_> = self
.indexes_of(&t.name)
.ok()?
.into_iter()
.filter(|idx| {
idx.partial.is_none()
&& idx.key_exprs.is_none()
&& self.query_cols_covered(sel, meta, &idx.cols)
})
.collect();
// A `GROUP BY` / `DISTINCT` / `ORDER BY` query walks the index to produce
// its keys in order (avoiding a full sort — for a partial sort the index
// still supplies the leading terms), so SQLite reads from a covering index
// there *regardless* of width; only a bare projection is a pure width
// choice. (A fully sort-satisfying scan already bailed above via
// `order_satisfied_by_scan`.) Among several covering candidates SQLite picks
// the narrowest (ties → newest), the same choice as a bare covering scan —
// so we pick deterministically here instead of declining on 2+.
if !sel.group_by.is_empty() || sel.distinct || !sel.order_by.is_empty() {
let chosen = covering.into_iter().min_by_key(|idx| {
(
self.index_seek_width(&t.name, idx),
core::cmp::Reverse(idx.root),
)
})?;
return Some((chosen.name, chosen.root, chosen.cols));
}
// Plain no-`WHERE` projection: port SQLite's covering-scan cost choice
// (`estimateTableWidth` / `estimateIndexWidth`): the table's estimated row
// width is `Σ szEst(col) (+1 if no INTEGER PRIMARY KEY)`; an index's is
// `Σ szEst(key col) + 1` (the trailing rowid). A covering index is used only
// when its width (in `LogEst` units) is *strictly* less than the table's,
// and among the candidates the narrowest wins — ties broken by the
// most-recently-created index (highest rootpage; SQLite considers indexes
// newest-first and keeps the first of an equal cost). Verified against the
// sqlite3 3.50.4 planner.
let szests = self.table_col_szests(&t.name).unwrap_or_default();
let szest_of = |i: usize| szests.get(i).copied().unwrap_or(1);
let mut wtable: u32 = (0..meta.columns.len()).map(szest_of).sum();
if meta.ipk.is_none() {
wtable += 1;
}
let sz_tab = logest(u64::from(wtable) * 4);
let chosen = covering
.into_iter()
.map(|idx| {
let widx: u32 = idx.cols.iter().map(|&c| szest_of(c)).sum::<u32>() + 1;
(logest(u64::from(widx) * 4), idx)
})
.filter(|(sz_idx, _)| *sz_idx < sz_tab)
.min_by(|(sa, ia), (sb, ib)| sa.cmp(sb).then(ib.root.cmp(&ia.root)))?
.1;
Some((chosen.name, chosen.root, chosen.cols))
}
/// Choose a plain secondary index for scanning ONE table that participates in a
/// join (the outer driver, or a materialised/scanned inner) via a *covering*
/// index — reading the table's rows in index-key order instead of rowid order.
/// This is the join analogue of [`covering_scan`](Self::covering_scan): the same
/// covering rule (a non-partial, non-expression index that holds every column of
/// THAT table referenced anywhere in the query — projection / `ON` / `WHERE` /
/// `GROUP BY` / `HAVING` / `ORDER BY`; the rowid counts as covered) and the same
/// width gate (`logest`-width strictly less than the table's, narrowest wins,
/// ties → newest). Reordering the scan changes an unordered join's output ROW
/// ORDER, so it must mirror sqlite exactly — hence the tight gates.
///
/// Gated to a plain base table in `main` (no subquery / TVF / CTE / view /
/// schema-qualified source), an ordinary rowid table (never `WITHOUT ROWID`),
/// with no generated columns and no window function in the query. Returns the
/// chosen [`IndexMeta`], or `None` to scan the table plainly (rowid order).
fn join_scan_covering_index(
&self,
sel: &Select,
from: &FromClause,
tref: &TableRef,
meta: &TableMeta,
) -> Option<IndexMeta> {
// Only plain base tables in `main` — a derived/CTE/view/TVF source has no
// secondary index to walk, and a schema-qualified source is materialised
// through its own backend.
if tref.subquery.is_some()
|| tref.tvf_args.is_some()
|| self.is_bare_tvf(tref)
|| tref.schema.is_some()
|| self.lookup_cte(&tref.name, tref.alias.as_deref()).is_some()
|| self.is_view(&tref.name)
|| self.unqualified_db(&tref.name) != DbRef::Main
{
return None;
}
if meta.without_rowid || window::has_window(sel) {
return None;
}
// A generated column can never be proven covered (it is not stored in a
// secondary index).
if meta.generated.iter().any(|g| g.is_some()) {
return None;
}
let szests = self.table_col_szests(&tref.name).unwrap_or_default();
let szest_of = |i: usize| szests.get(i).copied().unwrap_or(1);
let mut wtable: u32 = (0..meta.columns.len()).map(szest_of).sum();
if meta.ipk.is_none() {
wtable += 1;
}
let sz_tab = logest(u64::from(wtable) * 4);
// Among the plain covering indexes that are strictly narrower than the
// table, pick the narrowest (ties → newest = highest rootpage), the exact
// cost choice `covering_scan` makes for a bare projection.
self.indexes_of(&tref.name)
.ok()?
.into_iter()
.filter(|idx| {
idx.partial.is_none()
&& idx.key_exprs.is_none()
&& self.table_cols_covered_by_index(sel, from, tref, meta, idx)
})
.map(|idx| {
let widx: u32 = idx.cols.iter().map(|&c| szest_of(c)).sum::<u32>() + 1;
(logest(u64::from(widx) * 4), idx)
})
.filter(|(sz_idx, _)| *sz_idx < sz_tab)
.min_by(|(sa, ia), (sb, ib)| sa.cmp(sb).then(ib.root.cmp(&ia.root)))
.map(|(_, idx)| idx)
}
/// The EQP scan-detail line for a join table `tref` scanned with label
/// `label`: `SCAN <label> USING COVERING INDEX <idx>` when
/// [`join_scan_covering_index`] picks one for it (kept in lockstep with the
/// executor's covering-order scan), else a plain `SCAN <label>`.
fn eqp_join_scan_detail(
&self,
sel: &Select,
from: &FromClause,
tref: &TableRef,
label: &str,
) -> String {
if let Ok(meta) = self.table_meta(&tref.name, tref.alias.as_deref())
&& let Some(idx) = self.join_scan_covering_index(sel, from, tref, &meta)
{
return alloc::format!("SCAN {label} USING COVERING INDEX {}", idx.name);
}
alloc::format!("SCAN {label}")
}
/// The DRIVER (outer) table of a *two-table* join and the ordered list of
/// column identities its scan already yields, as `(driver_label, [(label,
/// colname), …])`. Mirrors exactly how the executor scans the driver:
/// - a covering-index driver (`join_scan_covering_index`) yields rows in that
/// index's key-column order, so the ordered columns are the index columns;
/// - otherwise a plain / rowid scan yields rowid order, so the ordered column
/// is the driver's INTEGER PRIMARY KEY (if any) — a table with no IPK has no
/// query-visible scan-order column and yields an empty list.
///
/// The driver is `from.joins[0].table` when a cost-based swap
/// (`two_table_rowid_inner_swap` / `two_table_index_inner_swap`) reorders the
/// plan to drive the second table, else `from.first`. Scoped to a single
/// `INNER` join of two plain `main` base tables — the shapes whose driver scan
/// order graphite renders in lockstep (`eqp_join_scan_detail`); any other shape
/// (N>2, LEFT/RIGHT/FULL, NATURAL/USING, derived/CTE/view/TVF source) returns
/// `None` so the caller keeps its unconditional sorter, never eliding a node
/// sqlite would keep.
fn join_driver_scan_order(
&self,
sel: &Select,
from: &FromClause,
) -> Option<(String, Vec<(String, String)>)> {
if from.joins.len() != 1 {
return None;
}
let join = &from.joins[0];
if !matches!(join.kind, JoinKind::Inner) || join.natural || !join.using.is_empty() {
return None;
}
// The driver is the second table under either cost-based swap, else the
// first source — but a `rowid = <const>` seek on `from.first` takes
// precedence over the index-inner swap (as in the executor/EQP), so
// `from.first` drives then.
let driver: &TableRef = if self
.join_first_rowid_seek(sel, from, &Params::default())
.is_none()
&& (self.two_table_rowid_inner_swap(from).is_some()
|| self.two_table_index_inner_swap(from).is_some())
{
&join.table
} else {
&from.first
};
// Only a plain `main` base table has a scan order we can name.
if driver.subquery.is_some()
|| driver.tvf_args.is_some()
|| self.is_bare_tvf(driver)
|| driver.schema.is_some()
|| self
.lookup_cte(&driver.name, driver.alias.as_deref())
.is_some()
|| self.is_view(&driver.name)
|| self.unqualified_db(&driver.name) != DbRef::Main
{
return None;
}
let meta = self
.table_meta(&driver.name, driver.alias.as_deref())
.ok()?;
let label = eqp_label(driver);
// A covering-index driver scan yields the index-key column order; otherwise
// the rowid scan yields IPK order (or nothing nameable without an IPK).
let cols: Vec<(String, String)> =
if let Some(idx) = self.join_scan_covering_index(sel, from, driver, &meta) {
idx.cols
.iter()
.map(|&c| (label.clone(), meta.columns[c].name.clone()))
.collect()
} else if let Some(ipk) = meta.ipk {
alloc::vec![(label.clone(), meta.columns[ipk].name.clone())]
} else {
Vec::new()
};
Some((label, cols))
}
/// Whether the `ORDER BY` of a two-table INNER join needs no sort because the
/// driver (`from.first`) is a single-row `rowid = <const>` seek
/// ([`join_first_rowid_seek`]). With one driver row, every driver column is
/// constant, and every inner column equated to a driver column by a top-level
/// `ON` equality (`driver.a = inner.b`) is likewise constant — an `ORDER BY` over
/// those is valid in any order. Additionally, when the inner is a plain
/// rowid-order scan (a rowid table with an IPK and *no* secondary indexes, so it
/// cannot be seeked or covering-scanned into another order), the whole output
/// arrives in inner-rowid order, so a trailing `ORDER BY` on the inner's rowid is
/// satisfied too (the rowid is unique, so every later term is then vacuous).
/// sqlite emits no `USE TEMP B-TREE FOR ORDER BY` for these. Conservative: an
/// outer join (inner columns may be NULL, not constant), or any non-constant /
/// non-inner-rowid / unresolvable / `COLLATE`d term, returns `false`.
fn join_order_all_constant(&self, sel: &Select, from: &FromClause, params: &Params) -> bool {
if from.joins.len() != 1 || sel.order_by.is_empty() {
return false;
}
if self.join_first_rowid_seek(sel, from, params).is_none() {
return false;
}
let join = &from.joins[0];
if !matches!(join.kind, JoinKind::Inner) || join.natural || !join.using.is_empty() {
return false;
}
let (Ok(dmeta), Ok(imeta)) = (
self.table_meta(&from.first.name, from.first.alias.as_deref()),
self.table_meta(&join.table.name, join.table.alias.as_deref()),
) else {
return false;
};
let dlabel = from.first.alias.as_deref().unwrap_or(&from.first.name);
let ilabel = join.table.alias.as_deref().unwrap_or(&join.table.name);
// Inner columns equated to a driver column by a top-level ON equality are
// constant (the driver is a single row).
let mut inner_const: Vec<String> = Vec::new();
if let Some(on) = &join.on {
let mut conj: Vec<&Expr> = Vec::new();
and_conjuncts(on, &mut conj);
let is_col = |e: &Expr, label: &str, meta: &TableMeta| -> Option<String> {
let mut base = e;
while let Expr::Paren(inner) = base {
base = inner;
}
match base {
Expr::Column { table, column, .. }
if table
.as_deref()
.is_none_or(|t| t.eq_ignore_ascii_case(label))
&& meta
.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(column)) =>
{
Some(column.clone())
}
_ => None,
}
};
for c in conj {
let mut base = c;
while let Expr::Paren(inner) = base {
base = inner;
}
if let Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} = base
{
for (a, b) in [(left, right), (right, left)] {
if is_col(a, dlabel, &dmeta).is_some()
&& let Some(icol) = is_col(b, ilabel, &imeta)
{
inner_const.push(icol);
}
}
}
}
}
// The inner's own rowid is also satisfied WITHOUT a sort when the inner is a
// plain rowid-order scan — which, for the single driver row, means the whole
// output arrives in inner-rowid order. Gated *very* conservatively: a rowid
// table with an IPK and NO secondary indexes at all (so it cannot be
// index-seeked or covering-scanned into a different order). The rowid is
// unique, so once an ORDER BY term is the inner rowid (ascending), every later
// term is satisfied too.
// The inner arrives in rowid order (so `ORDER BY inner.rowid` needs no sort)
// for the single driver row when its access path is rowid-ordered:
// * a plain rowid-order scan (no index seek, no covering scan), or
// * a single-value equality seek on a **single-column** index over the join
// column — every match shares the one key value, tie-broken by rowid, so
// the rows come out in rowid order (covering or not).
// A **multi-column** index over the join column would order by its key suffix,
// and a covering *scan* of any *other* index would be in that index's order —
// both need a sort, and are excluded. No `WHERE` eq/range on the inner may seek
// or reorder it (the join is driven purely by the single driver row). An
// *unrelated* secondary index is fine.
let seek_cols: Vec<usize> = inner_const
.iter()
.filter_map(|n| {
imeta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(n))
})
.collect();
let leads_join_col =
|ix: &IndexMeta| ix.cols.first().is_some_and(|c| seek_cols.contains(c));
let inner_plain_rowid_scan = imeta.ipk.is_some()
&& !imeta.without_rowid
&& match self.join_scan_covering_index(sel, from, &join.table, &imeta) {
// Plain rowid scan (nothing covers cheaper) — rowid order.
None => true,
// The only covering index allowed is the single-column join-seek index
// itself: that is a single-value seek (rowid order), not a scan.
Some(idx) => idx.cols.len() == 1 && leads_join_col(&idx),
}
&& self
.indexes_of(&join.table.name)
.map(|ixs| !ixs.iter().any(|ix| ix.cols.len() > 1 && leads_join_col(ix)))
.unwrap_or(false)
&& {
let mut eqs: Vec<(usize, Value)> = Vec::new();
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
if let Some(w) = &sel.where_clause {
collect_eq_constraints(w, &imeta.columns, params, &mut eqs);
collect_range_constraints(w, &imeta.columns, params, &mut ranges);
}
eqs.is_empty() && ranges.is_empty()
};
let inner_rowid = imeta.ipk.map(|i| imeta.columns[i].name.clone());
let mut rowid_seen = false;
for term in &sel.order_by {
if rowid_seen {
continue; // a unique rowid already fixed the order of everything after
}
let Some((tbl, col)) = self.join_key_column_identity(sel, from, &term.expr) else {
return false;
};
let is_driver = tbl.eq_ignore_ascii_case(dlabel)
&& dmeta
.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(&col));
let is_inner_const = tbl.eq_ignore_ascii_case(ilabel)
&& inner_const.iter().any(|c| c.eq_ignore_ascii_case(&col));
let is_inner_rowid = inner_plain_rowid_scan
&& !term.descending
&& tbl.eq_ignore_ascii_case(ilabel)
&& inner_rowid
.as_deref()
.is_some_and(|r| r.eq_ignore_ascii_case(&col));
if is_driver || is_inner_const {
continue;
}
if is_inner_rowid {
rowid_seen = true;
continue;
}
return false;
}
true
}
/// Resolve one `ORDER BY` / `GROUP BY` / `DISTINCT` key expression to a
/// `(table_label, colname)` identity against a two-table join, using the same
/// output-alias / positional resolution as the single-table paths. Returns
/// `None` for any expression that is not a plain (un-`COLLATE`'d) column
/// reference, or whose bare name is ambiguous across the two sources.
fn join_key_column_identity(
&self,
sel: &Select,
from: &FromClause,
expr: &Expr,
) -> Option<(String, String)> {
let join = &from.joins[0];
let first_meta = self
.table_meta(&from.first.name, from.first.alias.as_deref())
.ok()?;
let second_meta = self
.table_meta(&join.table.name, join.table.alias.as_deref())
.ok()?;
let first_label = eqp_label(&from.first);
let second_label = eqp_label(&join.table);
// Resolve an output alias / positional ordinal to the underlying expr, using
// the combined projection for wildcard ordinal expansion.
let mut combined = first_meta.columns.clone();
combined.extend(second_meta.columns.iter().cloned());
let proj = order_projection(&sel.columns, &combined);
let key = order_key_expr(&proj, expr);
let (tbl, col) = match key {
Expr::Column {
schema: None,
table,
column,
..
} => (table.as_deref(), column.as_str()),
_ => return None,
};
let in_first = first_meta
.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(col));
let in_second = second_meta
.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(col));
match tbl {
Some(t) if t.eq_ignore_ascii_case(&first_label) && in_first => {
Some((first_label, col.to_string()))
}
Some(t) if t.eq_ignore_ascii_case(&second_label) && in_second => {
Some((second_label, col.to_string()))
}
Some(_) => None,
None => {
// A bare name present in both sources is ambiguous — decline.
if in_first && !in_second {
Some((first_label, col.to_string()))
} else if in_second && !in_first {
Some((second_label, col.to_string()))
} else {
None
}
}
}
}
/// Whether the DISTINCT / GROUP BY key of a two-table join is fully clustered
/// by the driver's scan order (a prefix of `join_driver_scan_order`), in which
/// case sqlite emits NO `USE TEMP B-TREE FOR {DISTINCT,GROUP BY}` node. Every
/// key column must map to a leading driver-order column, in order and without
/// gaps; any key column on the seeked inner (or an expression / ambiguous key)
/// means the driver order does not cluster the key → the b-tree stays.
fn join_group_distinct_clustered(&self, sel: &Select, from: &FromClause) -> bool {
let key_exprs: Vec<&Expr> = if !sel.group_by.is_empty() {
sel.group_by.iter().collect()
} else if sel.distinct {
let mut ks = Vec::with_capacity(sel.columns.len());
for rc in &sel.columns {
match rc {
ResultColumn::Expr { expr, .. } => ks.push(expr),
_ => return false, // wildcard → cannot enumerate the key
}
}
ks
} else {
return false;
};
if key_exprs.is_empty() {
return false;
}
let Some((_, driver_cols)) = self.join_driver_scan_order(sel, from) else {
return false;
};
if key_exprs.len() > driver_cols.len() {
return false;
}
for (i, e) in key_exprs.iter().enumerate() {
let Some((tbl, col)) = self.join_key_column_identity(sel, from, e) else {
return false;
};
let (dt, dc) = &driver_cols[i];
if !tbl.eq_ignore_ascii_case(dt) || !col.eq_ignore_ascii_case(dc) {
return false;
}
}
true
}
/// How many leading `ORDER BY` terms of a two-table join the driver's scan
/// order already supplies — the count of leading terms that map, in order and
/// uniform direction, onto the driver's leading scan-order columns. `0` means
/// the driver supplies none (full sort); `n == order_by.len()` means the whole
/// sort is elided; `0 < k < n` is a partial sort (`LAST … TERMS`). Direction is
/// free (sqlite walks the driver index / rowid either way) but must be uniform
/// across the matched prefix. Any term on the seeked inner, an expression, or a
/// non-driver-order column stops the prefix.
fn join_order_prefix(&self, sel: &Select, from: &FromClause) -> usize {
if sel.order_by.is_empty() {
return 0;
}
let Some((_, driver_cols)) = self.join_driver_scan_order(sel, from) else {
return 0;
};
let mut matched = 0usize;
let mut dir: Option<bool> = None;
for term in &sel.order_by {
if matched >= driver_cols.len() {
break;
}
let Some((tbl, col)) = self.join_key_column_identity(sel, from, &term.expr) else {
break;
};
let (dt, dc) = &driver_cols[matched];
if !tbl.eq_ignore_ascii_case(dt) || !col.eq_ignore_ascii_case(dc) {
break;
}
match dir {
None => dir = Some(term.descending),
Some(d) if d == term.descending => {}
Some(_) => break, // mixed direction breaks the uniform walk
}
matched += 1;
}
matched
}
/// Whether `idx` (a plain secondary index on `tref`, whose table is `meta`)
/// covers every column of THAT table the join query references — anywhere in
/// the projection, the join `ON` predicates, `WHERE`, `GROUP BY`, `HAVING`, and
/// `ORDER BY`. A column of another table, a literal, or the rowid is always
/// "covered" (the rowid is stored in every index record); only a `tref` column
/// missing from the index defeats it. Conservative: any construct whose
/// per-table column footprint cannot be enumerated exactly — a subquery /
/// EXISTS / IN-SELECT, a windowed / `FILTER` call, or a `tref` wildcard over an
/// uncovered column — reports *not* covered (the safe plain-scan render).
///
/// Modelled on [`index_swap_covers`](Self::index_swap_covers) but generalised
/// to a single target table identified by name/alias, so it works for any table
/// in an N-table join, not just the two-table swap's `from.first`.
fn table_cols_covered_by_index(
&self,
sel: &Select,
from: &FromClause,
tref: &TableRef,
meta: &TableMeta,
idx: &IndexMeta,
) -> bool {
let target_names: [&str; 2] = [&tref.name, tref.alias.as_deref().unwrap_or("")];
let is_target = |t: &str| {
target_names
.iter()
.any(|n| !n.is_empty() && n.eq_ignore_ascii_case(t))
};
let idx_covers = |ci: usize| idx.cols.contains(&ci) || meta.ipk == Some(ci);
// The names of every OTHER source in the join, so a bare column that also
// exists in another table stays ambiguous (→ bail) rather than being
// silently attributed to `tref`.
let mut other_names: Vec<String> = Vec::new();
let mut push_names = |t: &TableRef| {
if !is_target(&t.name) && t.alias.as_deref() != Some("") {
other_names.push(t.name.clone());
if let Some(a) = &t.alias {
other_names.push(a.clone());
}
}
};
if !core::ptr::eq(&from.first, tref) {
push_names(&from.first);
}
for j in &from.joins {
if !core::ptr::eq(&j.table, tref) {
push_names(&j.table);
}
}
// Resolve one column ref: `Some(true)` covered / not this table / rowid,
// `Some(false)` an uncovered `tref` column, `None` cannot decide (bail).
let resolve = |table: Option<&str>, column: &str| -> Option<bool> {
let in_target = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column));
match table {
Some(t) if is_target(t) => match in_target {
Some(ci) => Some(idx_covers(ci)),
None => {
if matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) {
Some(true) // rowid is always in the index record
} else {
None
}
}
},
// A qualified reference to a KNOWN other source: not our table.
Some(t) if other_names.iter().any(|n| n.eq_ignore_ascii_case(t)) => Some(true),
Some(_) => None, // unknown qualifier → bail
None => {
// Unqualified: if it names a `tref` column it must be covered;
// but if the same bare name also exists in another source it is
// ambiguous here — bail rather than guess the owner.
match in_target {
Some(ci) => {
let in_other = other_names.iter().any(|n| {
self.table_meta(n, None).is_ok_and(|om| {
om.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(column))
})
});
if in_other { None } else { Some(idx_covers(ci)) }
}
None => {
if matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) {
None // bare rowid is ambiguous across sources → bail
} else {
Some(true) // some other table's column
}
}
}
}
}
};
fn walk(e: &Expr, resolve: &dyn Fn(Option<&str>, &str) -> Option<bool>) -> bool {
match e {
Expr::Literal(_) | Expr::Parameter(_) => true,
Expr::Column { table, column, .. } => {
resolve(table.as_deref(), column) == Some(true)
}
Expr::Unary { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. }
| Expr::Paren(expr) => walk(expr, resolve),
Expr::Binary { left, right, .. } => walk(left, resolve) && walk(right, resolve),
Expr::Between {
expr, low, high, ..
} => walk(expr, resolve) && walk(low, resolve) && walk(high, resolve),
Expr::InList { expr, list, .. } => {
walk(expr, resolve) && list.iter().all(|x| walk(x, resolve))
}
Expr::RowValue(items) => items.iter().all(|x| walk(x, resolve)),
Expr::Function {
args, filter, over, ..
} => over.is_none() && filter.is_none() && args.iter().all(|x| walk(x, resolve)),
Expr::Case {
operand,
when_then,
else_result,
} => {
operand.as_deref().map(|o| walk(o, resolve)).unwrap_or(true)
&& when_then
.iter()
.all(|(w, t)| walk(w, resolve) && walk(t, resolve))
&& else_result
.as_deref()
.map(|x| walk(x, resolve))
.unwrap_or(true)
}
Expr::Subquery(_) | Expr::Exists { .. } | Expr::InSelect { .. } => false,
}
}
let all_target_covered = (0..meta.columns.len()).all(idx_covers);
for rc in &sel.columns {
match rc {
ResultColumn::Wildcard => {
if !all_target_covered {
return false;
}
}
ResultColumn::TableWildcard(t) => {
if is_target(t) && !all_target_covered {
return false;
}
}
ResultColumn::Expr { expr, .. } => {
if !walk(expr, &resolve) {
return false;
}
}
}
}
for j in &from.joins {
if let Some(on) = j.on.as_ref()
&& !walk(on, &resolve)
{
return false;
}
}
if let Some(w) = sel.where_clause.as_ref()
&& !walk(w, &resolve)
{
return false;
}
if !sel.group_by.iter().all(|e| walk(e, &resolve)) {
return false;
}
if let Some(h) = sel.having.as_ref()
&& !walk(h, &resolve)
{
return false;
}
if !sel.order_by.iter().all(|t| walk(&t.expr, &resolve)) {
return false;
}
true
}
/// Scan a plain rowid table's rows in the key order of `idx` (a plain secondary
/// index on it): walk the index b-tree to enumerate rowids in key order, then
/// fetch each full declared-order row by rowid. Used to visit a join table via a
/// covering index (chosen by [`join_scan_covering_index`]) so an unordered
/// join's output row order matches sqlite's index-order scan. Returns full rows
/// (every column), so callers may treat it as a drop-in for the plain scan.
fn scan_table_via_index(&self, meta: &TableMeta, idx: &IndexMeta) -> Result<Vec<Vec<Value>>> {
let src = self.backend.source();
let encoding = src.header().text_encoding;
let rowids = crate::btree::index_range_rowids(
src,
idx.root,
None,
None,
&idx.collations,
idx.seek_descs(),
)?;
let mut cur = TableCursor::new(src, meta.root);
let mut out = Vec::with_capacity(rowids.len());
for rid in rowids {
if cur.seek(rid)? {
out.push(self.decode_full_row(meta, rid, &cur.payload()?, encoding)?);
}
}
Ok(out)
}
/// The per-column [`col_szest`] estimates for a rowid table, aligned with its
/// declared column order (which matches `TableMeta::columns` for a rowid
/// table). Parses the stored `CREATE TABLE` for the raw declared type of each
/// column (an untyped column is `1`, not the `BLOB` fallback other paths use).
/// Returns an empty vector when the table can't be resolved, so callers fall
/// back to a size of `1` per column.
fn table_col_szests(&self, table: &str) -> Option<Vec<u32>> {
let obj = self.schema.table(table)?;
let Ok(Statement::CreateTable(ct)) = sql::parse_one(obj.sql.as_deref()?) else {
return None;
};
Some(
ct.columns
.iter()
.map(|c| col_szest(c.type_name.as_deref()))
.collect(),
)
}
/// SQLite's min/max optimization: a query whose only aggregate is a single
/// `min(col)` / `max(col)` (no `GROUP BY`, no `HAVING`, no `WHERE`, no second
/// aggregate; the call may be wrapped in scalar expressions and may be
/// `DISTINCT`) reads one end of an ordered scan, so `EXPLAIN QUERY PLAN`
/// renders its access as `SEARCH` rather than `SCAN`. Returns that detail
/// string when the optimization applies, else `None`.
///
/// graphite still *executes* this as an ordinary (covering) scan that folds
/// the aggregate — the result is a single row, so the access label is the only
/// observable difference and the value already matches sqlite. The index
/// choice is shared with [`covering_scan`](Self::covering_scan) so the
/// `USING COVERING INDEX` clause stays in lockstep; a min/max over an
/// unindexed column reads a bare `SEARCH <table>`. The `WHERE`-bearing case
/// (which sqlite may serve from a *non-covering* index) is left to the
/// ordinary access path.
/// SQLite spills every `DISTINCT` aggregate *except* `min`/`max` (which seek
/// one end of an ordered scan instead) through its own transient b-tree,
/// rendered as `USE TEMP B-TREE FOR <fname>(DISTINCT)` *before* the scan line —
/// one node per such call, in result-column order. Returns the lowercased
/// function names of those calls, or an empty vector when none apply or the
/// shape must be declined.
///
/// Declined (empty) shapes: a `min(DISTINCT …)`/`max(DISTINCT …)` (the SEARCH
/// path renders those, not this node); a multi-argument `DISTINCT` aggregate
/// (SQLite rejects it at prepare time); and a `FILTER`/windowed/in-aggregate-
/// `ORDER BY` call (different plan). The caller fires this only for the clean
/// bare-`SCAN t` case (no `WHERE`/`GROUP BY`/`ORDER BY`/join), where no covering
/// index or seek can deliver the distinct values pre-ordered, so SQLite emits a
/// node for *every* distinct aggregate (none is elided) and graphite's scan-line
/// choice already matches.
///
/// `elide` enables the ordered-scan elision (a lone distinct aggregate over the
/// scan's leading column). It applies only without `GROUP BY`: with grouping the
/// scan order serves the group key, not the distinct values, so every distinct
/// aggregate still spills (the caller passes `false`).
fn distinct_agg_btrees(&self, sel: &Select, meta: &TableMeta, elide: bool) -> Vec<String> {
// (lowercase name, bare-argument column index) for each *unique* distinct
// aggregate, in first-occurrence order. SQLite's `AggInfo` coalesces
// identical aggregate calls, so `count(DISTINCT b)+count(DISTINCT b)` spills
// through a single b-tree, not two.
let mut uniq: Vec<(String, Option<usize>)> = Vec::new();
let mut seen: Vec<String> = Vec::new();
// Any non-min/max aggregate that is *not* `DISTINCT` (e.g. `sum(b)`): its
// presence means the query has more than one aggregate, which disqualifies
// the ordered-scan elision below.
let mut other_agg = false;
let mut total_cols = 0usize;
let mut agg_arg_cols = 0usize;
let mut decline = false;
for rc in &sel.columns {
let ResultColumn::Expr { expr, .. } = rc else {
return Vec::new();
};
window::visit(expr, &mut |node| match node {
Expr::Column { .. } => total_cols += 1,
Expr::Function {
name,
distinct,
args,
star,
filter,
order_by,
over,
..
} => {
if !func::is_aggregate_call(name, args.len(), *star) {
return;
}
for a in args {
window::visit(a, &mut |n| {
if matches!(n, Expr::Column { .. }) {
agg_arg_cols += 1;
}
});
}
if !*distinct {
other_agg = true;
return;
}
// `min`/`max(DISTINCT …)` seek an ordered end (the SEARCH path),
// and a multi-argument or filtered/windowed `DISTINCT` aggregate
// is either rejected at prepare time or plans differently — leave
// those shapes alone.
if over.is_some()
|| filter.is_some()
|| !order_by.is_empty()
|| args.len() != 1
|| name.eq_ignore_ascii_case("min")
|| name.eq_ignore_ascii_case("max")
{
decline = true;
return;
}
let key = alloc::format!(
"{}\u{0}{}",
name.to_ascii_lowercase(),
sql::print::expr(&args[0])
);
if !seen.contains(&key) {
seen.push(key);
uniq.push((
name.to_ascii_lowercase(),
col_index(&args[0], &meta.columns),
));
}
}
_ => {}
});
}
if decline {
return Vec::new();
}
// Elision: when the bare table scan already yields the distinct column in
// sorted order — the rowid-aliasing INTEGER PRIMARY KEY of a rowid table, or
// the leading primary-key column of a WITHOUT ROWID table — and that single
// distinct aggregate is the *entire* computation (one unique distinct
// aggregate, no other aggregate, no bare column reference), SQLite consumes
// the ordered scan directly and emits no b-tree node.
let lead = if meta.without_rowid {
meta.storage_order.first().copied()
} else {
meta.ipk
};
let bare_cols = total_cols.saturating_sub(agg_arg_cols);
if elide
&& uniq.len() == 1
&& !other_agg
&& bare_cols == 0
&& let (Some(arg), Some(l)) = (uniq[0].1, lead)
&& arg == l
{
return Vec::new();
}
uniq.into_iter().map(|(n, _)| n).collect()
}
fn minmax_search_detail(&self, sel: &Select, meta: &TableMeta, label: &str) -> Option<String> {
if sel.where_clause.is_some()
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
{
// A `WHERE` (sqlite serves the seek from the WHERE clause's index) and
// `DISTINCT` (sqlite adds a `USE TEMP B-TREE FOR DISTINCT` line even
// over the single row), like a `HAVING` (which suppresses the seek and
// reads `SCAN`), each render differently; leave those to the ordinary
// access path.
return None;
}
let from = sel.from.as_ref()?;
if !from.joins.is_empty() {
return None;
}
let t = &from.first;
if t.subquery.is_some() || t.tvf_args.is_some() || t.schema.is_some() {
return None;
}
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return None;
}
// `seek_col` is `Some(pos)` when the min/max argument is a bare table column
// (so a one-end seek can walk an index leading with it), `None` when it is
// an expression/constant (only a *covering* full index or a bare scan).
let (seek_col, arg_distinct, col_refs) = self.single_minmax_shape(sel, meta)?;
let usable = |i: &&IndexMeta| i.partial.is_none() && i.key_exprs.is_none();
let idxs = self.indexes_of(&t.name).ok()?;
// A full (non-partial, non-expression) index that covers *every* referenced
// column AND is NARROWER than the table (fewer columns → smaller szEst) lets
// sqlite full-scan the index in place of the table for a min/max with no
// one-end seek. An index as wide as the table (it carries every column) is
// not cheaper than the table itself, so sqlite scans the table — this is the
// szEst cost distinction, approximated here by column count. Exactly one such
// index → unambiguous; two or more → sqlite's cost model picks one, which we
// do not replicate, so leave it `None`.
let table_ncols = meta.columns.len();
let mut covering = idxs.iter().filter(|i| {
usable(i) && i.cols.len() < table_ncols && self.query_cols_covered(sel, meta, &i.cols)
});
let covering_name = covering
.next()
.filter(|_| covering.next().is_none())
.map(|c| &c.name);
// `min(DISTINCT x)` makes sqlite materialize the distinct values in a
// transient b-tree (`USE TEMP B-TREE FOR min(DISTINCT)`) — *except* the one
// case where the call is the sole result column and the b-tree it seeks
// already yields that column sorted (so the values arrive distinct for
// free), which elides the node. That holds only when the argument is the
// *leading* column of the seek structure: a secondary index that begins
// with it (covering, since it is the lone reference), or — for a
// `WITHOUT ROWID` table — the first primary-key column. A non-leading
// column (`min(DISTINCT b)` over an `(a, b)` index), an extra reference, or
// an expression argument all keep the temp-b-tree node, which graphite does
// not render, so those are left to the ordinary access path.
if arg_distinct {
let col = seek_col?;
if col_refs > 1 {
return None;
}
let mut leading = idxs
.iter()
.filter(|i| usable(i) && i.cols.first() == Some(&col));
if let Some(i) = leading.next() {
return match leading.next() {
None => Some(alloc::format!(
"SEARCH {label} USING COVERING INDEX {}",
i.name
)),
Some(_) => None,
};
}
if meta.without_rowid && meta.pk_len > 0 && meta.storage_order.first() == Some(&col) {
return Some(alloc::format!("SEARCH {label} USING PRIMARY KEY"));
}
return None;
}
// A `WITHOUT ROWID` table *is* its own clustered primary-key b-tree: it
// carries every column, so any one-end seek runs over the primary key (or a
// covering secondary index). Preserved exactly.
if meta.without_rowid {
if let Some(name) = covering_name {
return Some(alloc::format!("SEARCH {label} USING COVERING INDEX {name}"));
}
return Some(alloc::format!("SEARCH {label} USING PRIMARY KEY"));
}
// A rowid table: a bare min/max argument that *leads* an index enables a
// one-end SEEK — cheap regardless of the index width — labelled `COVERING`
// iff that index covers every referenced column, else a plain non-covering
// `USING INDEX`.
if let Some(col) = seek_col {
let mut leading = idxs
.iter()
.filter(|i| usable(i) && i.cols.first() == Some(&col));
if let Some(i) = leading.next()
&& leading.next().is_none()
{
return Some(if self.query_cols_covered(sel, meta, &i.cols) {
alloc::format!("SEARCH {label} USING COVERING INDEX {}", i.name)
} else {
alloc::format!("SEARCH {label} USING INDEX {}", i.name)
});
}
}
// No one-end seek (a non-leading column, or an expression/constant argument):
// sqlite full-scans the cheaper of {a covering index narrower than the table,
// the table}. A narrower covering index wins (`covering_name` is already
// restricted to `cols.len() < table_ncols`); otherwise it scans the table —
// a bare one-end `SEARCH t`, NOT an as-wide-as-the-table covering index.
if let Some(name) = covering_name {
return Some(alloc::format!("SEARCH {label} USING COVERING INDEX {name}"));
}
Some(alloc::format!("SEARCH {label}"))
}
/// Structural precondition of SQLite's min/max optimization: `sel`'s result set
/// holds *exactly one* aggregate call and it is a single-argument `min`/`max`
/// (not `*`), with no window function. Scalar wrappers around the call
/// (`abs(min(a))`, `max(a)+1`, `1+min(a)`) and *additional* referenced columns
/// (`min(a), b`) are allowed — sqlite still seeks one end, only the covering-ness
/// of the access changes. A second aggregate (`min(a), max(a)`, `min(a),
/// count(*)`) or a windowed call disqualifies it.
///
/// Returns `(seek_col, arg_distinct, col_refs)`:
/// * `seek_col` is `Some(pos)` when the min/max argument is a bare table column
/// (its position in `meta.columns`, enabling a non-covering index seek),
/// `None` when the argument is an expression or constant (`min(a+1)`, `min(1)`
/// — only a covering full index or a bare scan);
/// * `arg_distinct` is the call's `DISTINCT` flag (`min(DISTINCT a)`);
/// * `col_refs` counts bare column references across the whole result set (the
/// aggregate's own argument included), used to recognise the lone-column
/// shape that elides sqlite's `USE TEMP B-TREE FOR min(DISTINCT)` node.
///
/// `None` when the shape does not qualify. A `FILTER (WHERE …)` or in-aggregate
/// `ORDER BY` on the call also disqualifies it (both change sqlite's plan).
fn single_minmax_shape(
&self,
sel: &Select,
meta: &TableMeta,
) -> Option<(Option<usize>, bool, usize)> {
let mut agg_count = 0usize;
let mut minmax_count = 0usize;
let mut minmax_arg_col: Option<String> = None;
let mut arg_distinct = false;
let mut col_refs = 0usize;
let mut disqualified = false;
for rc in &sel.columns {
let ResultColumn::Expr { expr, .. } = rc else {
return None;
};
window::visit(expr, &mut |node| match node {
Expr::Function {
name,
distinct,
args,
star,
filter,
order_by,
over,
..
} => {
if over.is_some() || filter.is_some() || !order_by.is_empty() {
disqualified = true;
return;
}
if func::is_aggregate_call(name, args.len(), *star) {
agg_count += 1;
if !*star
&& args.len() == 1
&& (name.eq_ignore_ascii_case("min")
|| name.eq_ignore_ascii_case("max"))
{
minmax_count += 1;
arg_distinct = *distinct;
if let Expr::Column { column, .. } = &args[0] {
minmax_arg_col = Some(column.clone());
}
}
}
}
Expr::Column { .. } => col_refs += 1,
_ => {}
});
}
if disqualified || agg_count != 1 || minmax_count != 1 {
return None;
}
// A bare-column argument maps to its column position; a non-column argument
// (expression/constant) has no seek column.
let seek_col = minmax_arg_col.and_then(|c| {
meta.columns
.iter()
.position(|ci| ci.name.eq_ignore_ascii_case(&c))
});
Some((seek_col, arg_distinct, col_refs))
}
/// `SELECT count(*) FROM <single rowid table>` can be answered by counting a
/// full secondary index's entries instead of scanning the table — a full,
/// non-partial index has exactly one entry per table row, and its b-tree is
/// usually smaller (B2b). This returns `Some((index name, root))` only in the
/// unambiguous case so execution and `EXPLAIN QUERY PLAN` agree:
///
/// * the query is exactly one bare `count(*)` projection — no `WHERE`,
/// `GROUP BY`, `HAVING`, `DISTINCT`, `ORDER BY`, joins, subquery, or TVF;
/// * the source is an ordinary rowid table (not `WITHOUT ROWID`, view, or CTE);
/// * the table has **exactly one** full (non-partial, non-expression)
/// secondary index, so the chosen name is unambiguous and matches sqlite.
///
/// Zero or multiple such indexes → `None` (fall back to the plain `SCAN t`),
/// never guessing. Shared by `run_core` and `eqp_select`.
fn count_covering_index(&self, sel: &Select) -> Option<(String, u32)> {
let from = sel.from.as_ref()?;
if !from.joins.is_empty() {
return None;
}
let t = &from.first;
if t.subquery.is_some() || t.tvf_args.is_some() || t.schema.is_some() {
return None;
}
if sel.where_clause.is_some()
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
|| !sel.order_by.is_empty()
{
return None;
}
if window::has_window(sel) {
return None;
}
// The projection must be exactly a single bare `count(*)`.
if sel.columns.len() != 1 {
return None;
}
let ResultColumn::Expr { expr, .. } = &sel.columns[0] else {
return None;
};
match expr {
Expr::Function {
name,
distinct: false,
star: true,
filter: None,
over: None,
..
} if name.eq_ignore_ascii_case("count") => {}
_ => return None,
}
// The source must be an ordinary rowid table (not a view or CTE).
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return None;
}
let meta = self.table_meta(&t.name, t.alias.as_deref()).ok()?;
if meta.without_rowid {
return None;
}
// A `count(*)` needs no columns, so every full secondary index "covers" it.
// The choice — and whether a covering scan is cheaper than a plain table
// scan at all — is the shared cost model in `covering_scan` (which picks the
// narrowest index strictly narrower than the table, or `None` so the caller
// `SCAN`s the table).
let (name, root, _) = self.covering_scan(sel, &meta, &Params::default())?;
Some((name, root))
}
/// Whether a single-table scan already yields rows in the query's `ORDER BY`
/// order (so `run_core` can skip the sort, reversing for `DESC`). Combines the
/// rowid/IPK and secondary-index cases; shared with `eqp_access`.
fn order_satisfied_by_scan(&self, sel: &Select, params: &Params) -> Option<bool> {
if let Some(d) = self.rowid_ordered_scan(sel) {
return Some(d);
}
if let Some(d) = self.without_rowid_ordered_scan(sel) {
return Some(d);
}
if let Some(d) = self.without_rowid_seek_order(sel, params) {
return Some(d);
}
if let Some(d) = self.without_rowid_scan_filtered_order(sel, params) {
return Some(d);
}
if let Some(s) = self.order_index_scan(sel, params) {
// A mixed-direction walk only orders the leading prefix; the caller
// still sorts, so the ORDER BY is not fully satisfied by the scan.
if s.sorted_suffix == 0 {
return Some(s.descending);
}
}
// A `WHERE` seek that walks an index in key order satisfies the ORDER BY
// when *every* term matches the walked columns (B0b-iii).
if let Some(d) = self.in_seek_order(sel, params) {
return Some(d);
}
// A single-row rowid/IPK equality seek returns at most one row, so any
// ORDER BY over the single base table is already satisfied.
if self.rowid_eq_single_row(sel, params) {
return Some(false);
}
// A full `UNIQUE`-index equality likewise matches at most one row (the
// secondary-index analogue), so any ORDER BY is trivially satisfied.
if self.unique_eq_single_row(sel, params) {
return Some(false);
}
// Every ORDER BY term pinned to a constant by a `col = <const>` WHERE
// equality (even on a plain SCAN, so `seek_order_prefix` does not apply): the
// whole ORDER BY is then vacuously satisfied, so no sort is needed. Guarded on
// a NON-empty ORDER BY — with none, `order_const_lead` is a vacuous `0 == 0`
// and this must not claim satisfaction (that would perturb no-ORDER-BY plans
// like a `count(*)` covering-index scan).
if !sel.order_by.is_empty() && self.order_const_lead(sel, params) == sel.order_by.len() {
return Some(false);
}
// A two-table INNER join driven by a single-row `rowid = <const>` seek whose
// every ORDER BY term is constant (a driver column, or an inner column equated
// to a driver column by the ON) needs no sort — the rows are valid in any
// order, exactly as sqlite plans it.
if let Some(from) = sel.from.as_ref()
&& self.join_order_all_constant(sel, from, params)
{
return Some(false);
}
match self.seek_order_prefix(sel, params) {
Some((k, descending)) if k == sel.order_by.len() => Some(descending),
_ => None,
}
}
/// A rowid/IPK `IN`-list — or the equivalent same-column equality `OR`-chain,
/// which [`find_in_constraint`] collapses to the same shape — seeks the table
/// b-tree once per value. When the executor walks those values in ascending rowid
/// order ([`Self::try_index_in`] sorts them when this returns `Some`), the rows
/// arrive in rowid order, so a sole leading `ORDER BY` term on the rowid / INTEGER
/// PRIMARY KEY column needs no temp b-tree — sqlite plans it the same way. Returns
/// `Some(descending)` for that leading term. Because the rowid/IPK is unique, any
/// trailing `ORDER BY` terms can never break a tie, so a multi-term
/// `ORDER BY id, b` is satisfied exactly like a lone `ORDER BY id` (mirrors
/// [`Self::rowid_ordered_scan`]). Scoped to the rowid/IPK seek column; a secondary
/// index or `WITHOUT ROWID` PK `IN`/OR seek still sorts.
fn in_seek_order(&self, sel: &Select, params: &Params) -> Option<bool> {
let from = sel.from.as_ref()?;
if !from.joins.is_empty() {
return None;
}
let t = &from.first;
if t.subquery.is_some()
|| t.tvf_args.is_some()
|| t.schema.is_some()
|| t.index_hint.is_some()
{
return None;
}
let where_expr = sel.where_clause.as_ref()?;
if sel.order_by.is_empty()
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
|| self.has_aggregate(sel)
|| window::has_window(sel)
{
return None;
}
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return None;
}
let label = t.alias.as_deref().unwrap_or(&t.name);
let meta = self.table_meta(&t.name, t.alias.as_deref()).ok()?;
if meta.without_rowid {
return None;
}
// The WHERE must be a rowid/IPK `IN`-list (or collapsed equality OR-chain),
// matching the rowid fast path in `try_index_in`: the IN column is the IPK and
// no list entry is NULL.
let ipk = meta.ipk?;
let (col, vals) = find_in_constraint(where_expr, &meta.columns, params)?;
if col != ipk || vals.iter().any(|v| matches!(v, Value::Null)) {
return None;
}
// The leading ORDER BY term must be a plain (un-COLLATE'd) reference to the
// rowid / IPK column of this table; its uniqueness makes trailing terms
// irrelevant. A `COLLATE` wrapper is `Expr::Collate`, rejected by the match.
let order_cols = order_projection(&sel.columns, &meta.columns);
let term = &sel.order_by[0];
let (tbl, ocol) = match order_key_expr(&order_cols, &term.expr) {
Expr::Column { table, column, .. } => (table.as_deref(), column.as_str()),
_ => return None,
};
if tbl.is_some_and(|tn| !tn.eq_ignore_ascii_case(label)) {
return None;
}
let shadowed = meta
.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(ocol));
let is_rowid_alias = matches!(
ocol.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) && !shadowed;
let is_ipk = meta.columns[ipk].name.eq_ignore_ascii_case(ocol);
if is_rowid_alias || is_ipk {
Some(term.descending)
} else {
None
}
}
/// A bare rowid / INTEGER PRIMARY KEY equality (`rowid = const`, or a single-
/// element `IN`) seeks the table b-tree for at most one row — the rowid is
/// unique — so *any* `ORDER BY` over the single base table is trivially
/// satisfied and needs no temp b-tree, exactly as sqlite plans it. Unlike
/// [`Self::in_seek_order`], the ORDER BY terms need not name the rowid: with one
/// row there is nothing to sort, whatever the terms are. Multi-row rowid seeks
/// (`IN`-lists, equality `OR`-chains) collapse to several rowids and stay with
/// `in_seek_order`, which checks the leading ORDER BY column.
fn rowid_eq_single_row(&self, sel: &Select, params: &Params) -> bool {
let Some(from) = sel.from.as_ref() else {
return false;
};
if !from.joins.is_empty() {
return false;
}
let t = &from.first;
if t.subquery.is_some()
|| t.tvf_args.is_some()
|| t.schema.is_some()
|| t.index_hint.is_some()
{
return false;
}
let Some(where_expr) = sel.where_clause.as_ref() else {
return false;
};
if sel.order_by.is_empty()
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
|| self.has_aggregate(sel)
|| window::has_window(sel)
{
return false;
}
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return false;
}
let Ok(meta) = self.table_meta(&t.name, t.alias.as_deref()) else {
return false;
};
if meta.without_rowid {
return false;
}
// A single seeked rowid (`= const` or one-element `IN`) means at most one
// matching row; an `IN`-list or `OR`-chain of several rowids does not.
matches!(
rowid_seek_constraint(where_expr, &meta.columns, meta.ipk, params),
Some(v) if v.len() == 1
)
}
/// A `WHERE` whose top-level equalities pin *every* column of some non-partial,
/// plain-column `UNIQUE` index to a non-NULL constant matches at most one row —
/// the index enforces uniqueness over that column set — so *any* `ORDER BY` over
/// the single base table is trivially satisfied and needs no temp b-tree, exactly
/// as sqlite plans it (the secondary-index analogue of [`Self::rowid_eq_single_row`]).
///
/// Soundness rests on the seek collation matching the index collation: an
/// equality is only counted when its comparison collation is the column's default
/// ([`collect_eq_constraints`] enforces that), and the index is only accepted when
/// each of its columns is indexed under that same default collation — otherwise a
/// (say) `NOCASE` column under a `BINARY`-unique index could match two rows
/// (`'x'`/`'X'`) that the index treats as distinct. `IS NULL` / `= NULL` never
/// count (NULLs are not unique). Conservative on any mismatch: returns `false`,
/// the sort stays, and the ORDER-BY differential corpus catches over-claims.
fn unique_eq_single_row(&self, sel: &Select, params: &Params) -> bool {
let Some(from) = sel.from.as_ref() else {
return false;
};
if !from.joins.is_empty() {
return false;
}
let t = &from.first;
if t.subquery.is_some()
|| t.tvf_args.is_some()
|| t.schema.is_some()
|| t.index_hint.is_some()
{
return false;
}
let Some(where_expr) = sel.where_clause.as_ref() else {
return false;
};
if sel.order_by.is_empty()
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
|| self.has_aggregate(sel)
|| window::has_window(sel)
{
return false;
}
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return false;
}
let Ok(meta) = self.table_meta(&t.name, t.alias.as_deref()) else {
return false;
};
// Columns pinned to a NON-NULL constant by a top-level `=` / `IS` equality
// whose comparison collation is the column's default.
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
let pinned: alloc::collections::BTreeSet<usize> = eqs
.iter()
.filter(|(_, v)| !matches!(v, Value::Null))
.map(|(i, _)| *i)
.collect();
if pinned.is_empty() {
return false;
}
// Some non-partial, plain-column UNIQUE index — indexed entirely under the
// columns' default collations — has all its columns pinned → at most one row.
self.indexes_of(&t.name)
.map(|ixs| {
ixs.iter().any(|ix| {
ix.unique
&& ix.partial.is_none()
&& ix.key_exprs.is_none()
&& !ix.cols.is_empty()
&& ix.cols.len() == ix.collations.len()
&& ix.cols.iter().zip(&ix.collations).all(|(&c, coll)| {
pinned.contains(&c) && *coll == meta.columns[c].collation
})
})
})
.unwrap_or(false)
}
/// How many leading `ORDER BY` terms a `WHERE` seek already produces in order,
/// and the walk direction — the shared core of B0b-iii (full match → skip the
/// sort) and the partial-sort EXPLAIN label. A seek walks its index in key
/// order, so the rows arrive ordered by the index columns that follow any
/// equality prefix; this returns `(k, descending)` where `k` of the ORDER BY
/// terms match that walk (uniform direction, matching collation, default
/// NULLs). `k == order_by.len()` means no sort is needed; `0 < k < n` is a
/// partial sort. Returns `None` when no unambiguous seek applies.
///
/// Mirrors `try_index_lookup` / `try_index_range`'s index choice conservatively
/// so it never claims an order the executor will not produce: an equality seek
/// needs exactly one plain secondary index whose leading column the `WHERE`
/// constrains by equality (and no rowid equality); a range seek needs no column
/// equality at all (so `try_index_lookup` declines), no partial/expression
/// index on the table, no range on the rowid, and exactly one plain secondary
/// index whose leading column is range-constrained. Any looseness only mislabels
/// EXPLAIN (the sort still runs), and the ORDER-BY differential corpus catches it.
fn seek_order_prefix(&self, sel: &Select, params: &Params) -> Option<(usize, bool)> {
let from = sel.from.as_ref()?;
if !from.joins.is_empty() {
return None;
}
let t = &from.first;
if t.subquery.is_some()
|| t.tvf_args.is_some()
|| t.schema.is_some()
|| from.first.index_hint.is_some()
{
return None;
}
let where_expr = sel.where_clause.as_ref()?;
if sel.order_by.is_empty()
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
|| self.has_aggregate(sel)
|| window::has_window(sel)
{
return None;
}
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
return None;
}
let label = t.alias.as_deref().unwrap_or(&t.name);
let meta = self.table_meta(&t.name, t.alias.as_deref()).ok()?;
if meta.without_rowid {
return None;
}
let indexes = self.indexes_of(&t.name).ok()?;
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
eqs.retain(|(_, v)| !matches!(v, Value::Null));
// A `col IS NULL` conjunct pins `col` to a single (NULL) key, exactly like a
// value equality: it both makes `col` a seekable leading index column and a
// constant the ORDER BY can drop. Tracked apart from `eqs` so it never feeds
// the rowid/IPK fast-path checks (an IPK is never NULL).
let mut is_null_cols: Vec<usize> = Vec::new();
collect_isnull_cols(where_expr, &meta.columns, &mut is_null_cols);
// The chosen index and the length of its equality-pinned prefix. The seek
// walks the columns *after* that prefix in index-ascending order.
let (idx, prefix): (&IndexMeta, usize) = if !eqs.is_empty() || !is_null_cols.is_empty() {
// Equality seek (try_index_lookup). A rowid/IPK equality returns at most
// one row — a different path; bail to the cheap, correct sort.
if meta
.ipk
.is_some_and(|ipk| eqs.iter().any(|(c, _)| *c == ipk))
{
return None;
}
// Pick the exact index the executor's equality/`IS NULL`-prefix seek
// walks (via the shared `choose_seek_index`), with its pinned-prefix
// length, so the order credit is for the index that actually runs.
let mut eqs_coll = Vec::new();
collect_eq_constraints_coll(where_expr, &meta.columns, params, &mut eqs_coll);
let (chosen, prefix) = self
.choose_seek_index(
Some(sel),
&meta,
&t.name,
where_expr,
&eqs_coll,
&is_null_cols,
None,
)
.ok()??;
let idx = indexes.iter().find(|i| i.root == chosen.root)?;
(idx, prefix)
} else {
// Range seek (try_index_range). Guard so the chosen index is exactly the
// one the executor walks (see the doc comment).
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
alloc::collections::BTreeMap::new();
collect_range_constraints_coll(where_expr, &meta.columns, params, &mut ranges);
if ranges.is_empty() {
return None;
}
if meta.ipk.is_some_and(|ipk| ranges.contains_key(&ipk)) {
return None;
}
if indexes
.iter()
.any(|idx| idx.partial.is_some() || idx.key_exprs.is_some())
{
return None;
}
// Pick the exact index the executor's range seek walks (covering
// preference etc.), via the shared `choose_range_index`, so the order
// credit is for the index that actually runs.
let chosen = self
.choose_range_index(Some(sel), &meta, &t.name, where_expr, &ranges, None)
.ok()??;
let idx = indexes.iter().find(|i| i.root == chosen.root)?;
(idx, 0)
};
let walk_cols = &idx.cols[prefix..];
let walk_colls = &idx.collations[prefix..];
// Per-walked-column stored direction. A DESC index column is stored (and
// walked) in reverse value order, so the walk satisfies an ORDER BY term
// on it only when the *relationship* between the stored direction and the
// requested direction is uniform across all walked terms. `reverse` is that
// uniform relationship: `true` means the physical walk yields the reverse
// of the desired order (the caller reverses the whole result once).
let walk_descs: &[bool] = idx.descending.get(prefix..).unwrap_or(&[]);
// Count the leading ORDER BY terms the walk already produces: each must be a
// plain column of this table, matching the next walked column under its own
// collation, with a uniform stored-direction relationship (default NULLs).
let order_cols = order_projection(&sel.columns, &meta.columns);
let mut reverse: Option<bool> = None;
let mut k = 0;
for term in &sel.order_by {
if k >= walk_cols.len() || !redundant_nulls(term) {
break;
}
let walk_desc = walk_descs.get(k).copied().unwrap_or(false);
let this_reverse = walk_desc != term.descending;
match reverse {
None => reverse = Some(this_reverse),
Some(r) if r != this_reverse => break,
Some(_) => {}
}
// Peel an explicit `COLLATE` (setting the term's effective collation)
// down to the underlying column, so `ORDER BY b COLLATE NOCASE` is
// credited against a NOCASE index walk (B9j).
let resolved = order_key_expr(&order_cols, &term.expr);
let explicit = explicit_collation(resolved);
let mut base = resolved;
while let Expr::Collate { expr, .. } | Expr::Paren(expr) = base {
base = expr;
}
let (tbl, col_name) = match base {
Expr::Column { table, column, .. } => (table.as_deref(), column.as_str()),
_ => break,
};
if tbl.is_some_and(|tn| !tn.eq_ignore_ascii_case(label)) {
break;
}
let Some(oc) = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(col_name))
else {
break;
};
let eff_coll = explicit.unwrap_or(meta.columns[oc].collation);
if walk_cols[k] != oc || walk_colls[k] != eff_coll {
break;
}
k += 1;
}
// The uniform walk direction relative to the request. `false` (no reverse
// needed) is the vacuous default when no term was consumed.
let descending = reverse.unwrap_or(false);
// Trailing rowid: once the walk has consumed the index's whole key, it
// continues in rowid order, so an ORDER BY term that is the INTEGER PRIMARY
// KEY right after the full key is already ordered too. The rowid is stored
// ascending, so this only holds when every walked column is ascending (a
// DESC column would put the rowid out of phase under a reversed walk) and
// the index is a named one with accurate directions (not an automatic
// UNIQUE/PK index). Mirrors `order_index_scan`'s no-WHERE trailing credit.
if k == walk_cols.len()
&& k < sel.order_by.len()
&& !idx.is_auto
&& idx.descending[prefix..].iter().all(|d| !d)
{
let term = &sel.order_by[k];
if let Expr::Column { table, column, .. } = order_key_expr(&order_cols, &term.expr) {
let tbl_ok = table
.as_deref()
.is_none_or(|tn| tn.eq_ignore_ascii_case(label));
let pos = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column));
if tbl_ok
&& meta.ipk == pos
&& pos.is_some()
&& term.descending == descending
&& term.nulls_first.is_none()
{
k += 1;
}
}
}
// Equality-pinned columns are constant across the seeked rows, so sqlite
// drops ORDER BY terms on them entirely. If, after dropping those, the walk
// (plus a single trailing rowid) orders every remaining term in one uniform
// direction, the sort is skipped — even when a pinned term *leads* the ORDER
// BY, in which case the effective walk direction comes from the first
// non-constant term and may differ from `order_by[0]`. Purely additive: it
// only upgrades a not-yet-full result to a full skip; the partial label
// computed above is otherwise left untouched.
if k < sel.order_by.len() {
// `eff` is the uniform reverse relationship (walk-stored-dir vs
// requested-dir); a DESC-stored walked column flips it, so all
// non-constant terms must agree on it for a single walk to satisfy them.
let mut eff: Option<bool> = None;
let mut wp = 0usize;
let mut rowid_used = false;
let mut fully = true;
// The leading run of ORDER BY terms each satisfied by being
// equality-constant or matched by the walk/rowid in sequence. When a term
// eventually breaks the run, this is the correct `LAST (n - lead) TERMS`
// split (sqlite drops a *leading* constant term but still sorts a trailing
// one after any unsatisfied term).
let mut lead = 0usize;
for term in &sel.order_by {
let Expr::Column { table, column, .. } = order_key_expr(&order_cols, &term.expr)
else {
fully = false;
break;
};
if table
.as_deref()
.is_some_and(|tn| !tn.eq_ignore_ascii_case(label))
{
fully = false;
break;
}
let Some(pos) = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column))
else {
fully = false;
break;
};
if eqs.iter().any(|(c, _)| *c == pos) || is_null_cols.contains(&pos) {
lead += 1;
continue; // constant under the WHERE equality / IS NULL
}
if !redundant_nulls(term) {
fully = false;
break;
}
// Stored direction of the next walked column (rowid trailing = ASC).
let walk_desc = walk_descs.get(wp).copied().unwrap_or(false);
let this_reverse = walk_desc != term.descending;
let d = *eff.get_or_insert(this_reverse);
if this_reverse != d {
fully = false;
break;
}
if wp < walk_cols.len()
&& walk_cols[wp] == pos
&& walk_colls[wp] == meta.columns[pos].collation
{
wp += 1;
lead += 1;
} else if !rowid_used
&& wp == walk_cols.len()
&& !idx.is_auto
&& idx.descending[prefix..].iter().all(|x| !x)
&& meta.ipk == Some(pos)
{
rowid_used = true;
lead += 1;
} else {
fully = false;
break;
}
}
if fully {
// `eff` is None only when *every* term was constant — any walk
// direction yields the required (vacuous) order.
return Some((sel.order_by.len(), eff.unwrap_or(false)));
}
// Not fully satisfied, but a leading constant/walked run may still let
// sqlite sort only the trailing terms (`LAST N TERMS OF ORDER BY`). The
// returned direction is irrelevant here — callers ignore it unless the
// whole ORDER BY is satisfied (`k == n`), which this is not.
return Some((k.max(lead), descending));
}
Some((k, descending))
}
/// The number of leading `ORDER BY` terms that are pinned to a constant by a
/// `col = <const>` (or `col IS NULL`) WHERE equality on a *single* base table.
/// sqlite drops such leading terms from the sort regardless of the access path
/// (even a plain `SCAN`, where [`seek_order_prefix`] does not apply because
/// nothing is seeked). Single-table, non-grouped, non-aggregate, no window — the
/// same shapes `seek_order_prefix` handles; returns 0 otherwise. The pinned
/// column need not be indexed (a plain-scan `WHERE y = 5 ORDER BY y, z` still
/// drops `y`).
fn order_const_lead(&self, sel: &Select, params: &Params) -> usize {
let Some(from) = sel.from.as_ref() else {
return 0;
};
if !from.joins.is_empty() {
return 0;
}
let t = &from.first;
if t.subquery.is_some() || t.tvf_args.is_some() || self.is_bare_tvf(t) || t.schema.is_some()
{
return 0;
}
if sel.order_by.is_empty()
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
|| self.has_aggregate(sel)
|| window::has_window(sel)
{
return 0;
}
let Some(where_expr) = sel.where_clause.as_ref() else {
return 0;
};
let Ok(meta) = self.table_meta(&t.name, t.alias.as_deref()) else {
return 0;
};
let label = t.alias.as_deref().unwrap_or(&t.name);
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
eqs.retain(|(_, v)| !matches!(v, Value::Null));
let mut is_null_cols: Vec<usize> = Vec::new();
collect_isnull_cols(where_expr, &meta.columns, &mut is_null_cols);
if eqs.is_empty() && is_null_cols.is_empty() {
return 0;
}
let order_cols = order_projection(&sel.columns, &meta.columns);
let mut lead = 0usize;
for term in &sel.order_by {
if !redundant_nulls(term) {
break;
}
let Expr::Column { table, column, .. } = order_key_expr(&order_cols, &term.expr) else {
break;
};
if table
.as_deref()
.is_some_and(|tn| !tn.eq_ignore_ascii_case(label))
{
break;
}
let Some(pos) = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column))
else {
break;
};
if eqs.iter().any(|(c, _)| *c == pos) || is_null_cols.contains(&pos) {
lead += 1;
} else {
break;
}
}
lead
}
/// The first `match(query, operand)` call in a WHERE clause's `AND`/`OR` tree,
/// as `(query text, operand column name)`. The operand names either the table
/// (a table-wide match) or a single column (`col MATCH …`, which scopes the
/// score to that column).
#[cfg(feature = "fts5")]
fn fts5_match_query(&self, expr: &Expr, params: &Params) -> Option<(String, String)> {
match expr {
Expr::Function { name, args, .. }
if name.eq_ignore_ascii_case("match") && args.len() == 2 =>
{
let v = eval::eval(&args[0], &eval::EvalCtx::rowless(params)).ok()?;
let operand = match &args[1] {
Expr::Column { column, .. } => column.clone(),
_ => return None,
};
Some((eval::to_text(&v), operand))
}
Expr::Binary { left, right, .. } => self
.fts5_match_query(left, params)
.or_else(|| self.fts5_match_query(right, params)),
Expr::Unary { expr, .. } | Expr::Paren(expr) => self.fts5_match_query(expr, params),
_ => None,
}
}
/// Whether `where_expr` contains a `MATCH` over the fts5 table `name` whose
/// query shape the index router (`fts5_index_match_rowids`) cannot serve. Used
/// by the contentless scan path to decline such a query (no stored text to fall
/// back on) instead of silently under-matching.
#[cfg(feature = "fts5")]
fn fts5_where_has_unroutable_match(
&self,
name: &str,
arg_refs: &[&str],
where_expr: &Expr,
params: &Params,
) -> Result<bool> {
let Some((query, operand)) = self.fts5_match_query(where_expr, params) else {
return Ok(false);
};
// The operand must name this table (a table-wide search) or one of its
// columns; a `col : …` embedded in the query string is handled by the router.
let names_table = operand.eq_ignore_ascii_case(name)
|| self
.vtab_meta(name)?
.2
.columns
.iter()
.any(|c| c.eq_ignore_ascii_case(&operand));
if !names_table {
return Ok(false);
}
Ok(self
.fts5_index_match_rowids(name, arg_refs, &query)?
.is_none())
}
/// Build the per-query [`Fts5QueryCtx`] for an FTS5 `MATCH` query over a single
/// `fts5` table that references `rank`/`bm25()`/`highlight()`, or `None`. The
/// bm25 corpus is computed only when `rank`/`bm25()` is referenced —
/// `highlight()` needs just the query. Its statistics span the WHOLE table (a
/// fresh unfiltered scan), not the post-`MATCH` `input_rows`, because sqlite's
/// `avgdl`/`nHit` are whole-table denominators.
#[cfg(feature = "fts5")]
fn fts5_query_ctx(
&self,
sel: &Select,
columns: &[ColumnInfo],
input_rows: &[InputRow],
params: &Params,
) -> Option<Fts5QueryCtx> {
const AUX: &[&str] = &["rank", "bm25", "highlight", "snippet"];
const RANK: &[&str] = &["rank", "bm25"];
if !select_mentions(sel, AUX) {
return None;
}
let from = sel.from.as_ref()?;
if !from.joins.is_empty()
|| from.first.subquery.is_some()
|| from.first.tvf_args.is_some()
|| from.first.schema.is_some()
{
return None;
}
// The source must be an `fts5` virtual table.
let (module, vargs, _) = self.vtab_meta(&from.first.name).ok()?;
if !module.eq_ignore_ascii_case("fts5") {
return None;
}
// Columns declared `UNINDEXED` are excluded from matching/ranking; `None`
// when every column is searchable (avoids per-row name checks).
let arg_refs: Vec<&str> = vargs.iter().map(String::as_str).collect();
let all = crate::vtab::fts5_indexed_columns(&arg_refs);
let indexed = (all.len() != columns.len()).then_some(all);
let tok = crate::vtab::fts5_tok_config(&arg_refs);
let (query, operand) = self.fts5_match_query(sel.where_clause.as_ref()?, params)?;
let col_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
// A `col MATCH …` operand scopes the query to that column; a table-wide
// `t MATCH …` (operand names the table, not a column) does not.
let scope = col_names
.iter()
.find(|n| n.eq_ignore_ascii_case(&operand))
.cloned();
// Score the corpus only when ranking is actually referenced.
let bm25 = select_mentions(sel, RANK).then(|| {
// SQLite's bm25 corpus statistics — `avgdl` (total tokens across the
// table / total row count) and each phrase's `nHit` (rows containing
// it) — span the WHOLE table, not just the rows that matched `MATCH`.
// `input_rows` here is the post-`MATCH` subset (the scan pushes the
// predicate down), so computing avgdl/nHit over it would use the wrong
// denominators and diverge from sqlite. Re-scan the fts5 table
// unfiltered for the corpus; fall back to the matched rows if that scan
// is unavailable (reproducing the prior behavior). Scoring still only
// reads matched rows — unmatched rows are never looked up by rowid.
let all_rows = self
.try_virtual_table(&from.first.name, from.first.alias.as_deref(), None)
.ok()
.flatten()
.map(|(_, rows)| rows);
let corpus_rows: &[InputRow] = all_rows.as_deref().unwrap_or(input_rows);
let docs: Vec<Vec<String>> = corpus_rows
.iter()
.map(|r| r.values.iter().map(eval::to_text).collect())
.collect();
let corpus = crate::vtab::fts5_bm25_corpus(
&query,
&col_names,
&docs,
scope.as_deref(),
indexed.as_deref(),
tok,
);
let index = corpus_rows
.iter()
.enumerate()
.filter_map(|(i, r)| Some((r.rowid?, i)))
.collect();
(corpus, index)
});
// The configured default rank function (from the `_config` `rank` row), if
// any — only relevant when ranking is referenced (a bare `rank`/`ORDER BY
// rank`), so skip the shadow read otherwise.
let rank = select_mentions(sel, RANK)
.then(|| self.fts5_config_rank(&from.first.name))
.flatten();
Some(Fts5QueryCtx {
col_names,
query,
scope,
indexed,
tok,
bm25,
rank,
})
}
/// Reject a built-in aggregate call with the wrong number of arguments in
/// any of `sel`'s clauses, at prepare time. SQLite resolves a function's
/// arity during analysis — before it decides whether the call is misused or
/// out of place — so `sum(a,a)`, `avg()`, `count(1,2)` error with `wrong
/// number of arguments to function NAME()` in every clause and even over an
/// empty/fully-filtered table, *ahead* of the placement checks (`misuse of
/// aggregate …`, `aggregate functions are not allowed in the GROUP BY
/// clause`). graphite's per-group evaluator only caught the arity when a
/// group was actually produced, and those placement checks otherwise fired
/// first. Running this at the top of `run_core` (before the VDBE attempt and
/// before any reject/placement check) reproduces SQLite's ordering.
fn reject_aggregate_arity_in_select(&self, sel: &Select) -> Result<()> {
let check = |e: &Expr| self.reject_aggregate_arity(e);
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
check(expr)?;
}
}
if let Some(w) = &sel.where_clause {
check(w)?;
}
// HAVING in a non-aggregate query is itself rejected ("HAVING clause on
// a non-aggregate query") ahead of any arity check, so only validate the
// HAVING expression in a genuine aggregate context.
if let Some(h) = &sel.having
&& (!sel.group_by.is_empty() || self.has_result_aggregate(sel))
{
check(h)?;
}
for g in &sel.group_by {
check(g)?;
}
for t in &sel.order_by {
check(&t.expr)?;
}
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
check(on)?;
}
}
}
Ok(())
}
/// Resolve and arity-check every scalar function call in `sel`'s own clauses
/// at prepare time, matching SQLite — an unknown name is `no such function:
/// NAME` and a wrong argument count is `wrong number of arguments to function
/// NAME()`, raised before the query runs (so a `SELECT abs(a,b) FROM t` over
/// an *empty* table is still rejected, where the row-evaluated tree-walker
/// would silently produce nothing). Mirrors `reject_aggregate_arity_in_select`'s
/// clause coverage; `reject_unresolved_functions` skips aggregate and window
/// calls (they have their own checks). Column resolution runs first on every
/// path that reaches this — the tree-walker's own resolver, or, on the VDBE
/// fast path, the VDBE compiler (which only succeeds when all columns resolve)
/// — so a missing column still wins for the common single-fault expression.
fn reject_unresolved_functions_in_select(&self, sel: &Select) -> Result<()> {
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
self.reject_unresolved_functions(expr)?;
}
}
if let Some(w) = &sel.where_clause {
self.reject_unresolved_functions(w)?;
}
// As with the arity check, HAVING in a non-aggregate query is rejected by
// its own placement error first, so only resolve it in an aggregate context.
if let Some(h) = &sel.having
&& (!sel.group_by.is_empty() || self.has_result_aggregate(sel))
{
self.reject_unresolved_functions(h)?;
}
for g in &sel.group_by {
self.reject_unresolved_functions(g)?;
}
for t in &sel.order_by {
self.reject_unresolved_functions(&t.expr)?;
}
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
self.reject_unresolved_functions(on)?;
}
}
}
Ok(())
}
/// Eager `no such function` / `wrong number of arguments` check for scalar calls
/// inside an **expression-position subquery** (`(SELECT …)`, `EXISTS (…)`,
/// `… IN (SELECT …)`). [`Self::reject_unresolved_functions_in_select`]'s
/// `window::visit` walk never descends into a nested subquery body, so an unknown
/// or wrong-arity call there was only noticed at row evaluation — missed entirely
/// over an empty / fully-filtered outer table where SQLite still rejects at
/// prepare time. This collects each subquery the outer expressions carry and,
/// **only when the body is column-clean** against its own `FROM` plus the outer
/// scope ([`Self::subquery_body_columns_clean`]), checks its scalar calls. The
/// column-clean gate preserves SQLite's precedence: a `no such column` it would
/// report first is never masked by a function error (`SELECT (SELECT nope(zzz))`
/// stays a missing-column case, left to the lazy path). A subquery it cannot
/// fully verify — correlated-but-missing, compound, or further-nested — is left
/// alone, so this never raises a false positive. `cols` is the outer query's scan
/// scope (the sole correlation scope, since this runs only at the outermost
/// query).
fn reject_unresolved_functions_in_subqueries(
&self,
sel: &Select,
cols: &[ColumnInfo],
) -> Result<()> {
let mut targets: Vec<&Expr> = Vec::new();
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
targets.push(expr);
}
}
if let Some(w) = &sel.where_clause {
targets.push(w);
}
if let Some(h) = &sel.having {
targets.push(h);
}
for g in &sel.group_by {
targets.push(g);
}
for t in &sel.order_by {
targets.push(&t.expr);
}
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
targets.push(on);
}
}
}
let mut subs: Vec<&Select> = Vec::new();
for e in targets {
collect_subselects(e, &mut subs);
}
for sub in subs {
if self.subquery_body_columns_clean(sub, cols) {
self.reject_unresolved_functions_in_select(sub)?;
}
}
Ok(())
}
/// Reject an `expr IN (SELECT …)` whose subquery yields a different number of
/// columns than the left-hand side expects — SQLite reports `sub-select
/// returns N columns - expected M` at prepare time, so the mismatch is caught
/// even over an empty (or fully filtered) outer table where the row-evaluated
/// `IN` is never reached and graphite's lazy check never fires. `cols` is the
/// outer query's scan scope (this runs only at the outermost query, so it is
/// the sole correlation scope a subquery body can bind to). Mirrors
/// `reject_unresolved_functions_in_select`'s clause coverage. The check fires
/// only when every column the subquery and the LHS reference resolves: a
/// missing column is SQLite's error *first*, and graphite resolves those
/// lazily, so a dirty subquery is left to its existing behaviour rather than
/// risk reporting an arity error where a `no such column` is due.
fn reject_invalid_in_subquery_arity(&self, sel: &Select, cols: &[ColumnInfo]) -> Result<()> {
let mut targets: Vec<&Expr> = Vec::new();
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
targets.push(expr);
}
}
if let Some(w) = &sel.where_clause {
targets.push(w);
}
if let Some(h) = &sel.having {
targets.push(h);
}
for g in &sel.group_by {
targets.push(g);
}
for t in &sel.order_by {
targets.push(&t.expr);
}
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
targets.push(on);
}
}
}
for e in targets {
self.walk_in_subquery_arity(e, cols)?;
}
Ok(())
}
/// Walk `e` for top-level-scope `expr IN (SELECT …)` nodes — descending
/// through scalar operands but never into a nested subquery body, which
/// carries its own scope — and arity-check each. See
/// [`Self::reject_invalid_in_subquery_arity`].
fn walk_in_subquery_arity(&self, e: &Expr, cols: &[ColumnInfo]) -> Result<()> {
match e {
Expr::InSelect { expr, select, .. } => {
self.check_in_subquery_arity(expr, select, cols)?;
// The LHS shares this scope, so a further `IN` nested in it is
// still resolvable here; the subquery body is not descended.
self.walk_in_subquery_arity(expr, cols)?;
}
Expr::Unary { expr, .. } => self.walk_in_subquery_arity(expr, cols)?,
Expr::Binary { left, right, .. } => {
self.walk_in_subquery_arity(left, cols)?;
self.walk_in_subquery_arity(right, cols)?;
}
Expr::Function {
args,
filter,
order_by,
..
} => {
for a in args {
self.walk_in_subquery_arity(a, cols)?;
}
if let Some(flt) = filter {
self.walk_in_subquery_arity(flt, cols)?;
}
for t in order_by {
self.walk_in_subquery_arity(&t.expr, cols)?;
}
}
Expr::IsNull { expr, .. } => self.walk_in_subquery_arity(expr, cols)?,
Expr::InList { expr, list, .. } => {
self.walk_in_subquery_arity(expr, cols)?;
for a in list {
self.walk_in_subquery_arity(a, cols)?;
}
}
Expr::Between {
expr, low, high, ..
} => {
self.walk_in_subquery_arity(expr, cols)?;
self.walk_in_subquery_arity(low, cols)?;
self.walk_in_subquery_arity(high, cols)?;
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
self.walk_in_subquery_arity(o, cols)?;
}
for (w, t) in when_then {
self.walk_in_subquery_arity(w, cols)?;
self.walk_in_subquery_arity(t, cols)?;
}
if let Some(el) = else_result {
self.walk_in_subquery_arity(el, cols)?;
}
}
Expr::Cast { expr, .. } => self.walk_in_subquery_arity(expr, cols)?,
Expr::Collate { expr, .. } => self.walk_in_subquery_arity(expr, cols)?,
Expr::Paren(inner) => self.walk_in_subquery_arity(inner, cols)?,
Expr::RowValue(items) => {
for it in items {
self.walk_in_subquery_arity(it, cols)?;
}
}
_ => {}
}
Ok(())
}
/// Arity-check one `lhs IN (select)`: the LHS arity is its row-value width (a
/// bare scalar is 1), the subquery width is its structural output-column count
/// (no rows needed). Reports the mismatch only when the subquery and LHS are
/// column-clean — see [`Self::reject_invalid_in_subquery_arity`].
fn check_in_subquery_arity(
&self,
lhs: &Expr,
select: &Select,
outer_cols: &[ColumnInfo],
) -> Result<()> {
let width = eval::Subqueries::row_column_affinities(self, select).len();
if width == 0 {
// Scan failed or an unknown shape — leave it to the lazy path.
return Ok(());
}
let expected = match lhs {
Expr::RowValue(v) => v.len(),
_ => 1,
};
if width == expected {
return Ok(());
}
if self.in_subquery_columns_clean(lhs, select, outer_cols) {
return Err(Error::Error(alloc::format!(
"sub-select returns {width} columns - expected {expected}"
)));
}
Ok(())
}
/// Whether every column the LHS and the subquery body reference resolves, so
/// an arity error would not mask a `no such column` SQLite reports first. The
/// LHS resolves against the outer scope only; the subquery body against its
/// own FROM plus the outer scope (a correlated reference). Conservative: any
/// shape it cannot fully verify — a scan failure, a compound subquery, or a
/// further-nested subquery whose own columns it does not walk — returns
/// `false`, leaving the mismatch to the existing lazy behaviour.
fn in_subquery_columns_clean(
&self,
lhs: &Expr,
select: &Select,
outer_cols: &[ColumnInfo],
) -> bool {
// The LHS lives in the outer scope; the subquery body resolves against
// its own FROM plus the outer columns (handled by the body helper).
let mut lhs_ok = true;
walk_shallow_columns(lhs, &mut |_schema, table, column, _quoted| {
if lhs_ok && !column_resolves(outer_cols, table, column) {
lhs_ok = false;
}
});
lhs_ok && self.subquery_body_columns_clean(select, outer_cols)
}
/// Whether every column the subquery body references resolves, against its
/// own FROM plus the outer (correlation) scope — the LHS-free half of
/// [`Self::in_subquery_columns_clean`], shared with the scalar-subquery arity
/// check. Conservative: a compound (`UNION`/…) subquery, a scan failure, or a
/// clause hiding a further-nested subquery (which `walk_shallow_columns` does
/// not descend) all return `false` so a hidden bad column is never mistaken
/// for a clean body.
fn subquery_body_columns_clean(&self, select: &Select, outer_cols: &[ColumnInfo]) -> bool {
if !select.compound.is_empty() {
return false;
}
let params = Params::default();
let Ok((incols, _)) = self.scan_source(select, ¶ms) else {
return false;
};
// A bare name may match one of the subquery's own output aliases (a
// GROUP BY / HAVING / ORDER BY reference), so exempt those.
let aliases: Vec<&str> = select
.columns
.iter()
.filter_map(|c| match c {
ResultColumn::Expr { alias: Some(a), .. } => Some(a.as_str()),
_ => None,
})
.collect();
let mut targets: Vec<&Expr> = Vec::new();
for rc in &select.columns {
if let ResultColumn::Expr { expr, .. } = rc {
targets.push(expr);
}
}
if let Some(w) = &select.where_clause {
targets.push(w);
}
if let Some(h) = &select.having {
targets.push(h);
}
for g in &select.group_by {
targets.push(g);
}
for t in &select.order_by {
targets.push(&t.expr);
}
if let Some(from) = &select.from {
for j in &from.joins {
if let Some(on) = &j.on {
targets.push(on);
}
}
}
for e in &targets {
let mut nested = Vec::new();
collect_subselects(e, &mut nested);
if !nested.is_empty() {
return false;
}
}
let mut clean = true;
for e in targets {
if !clean {
break;
}
walk_shallow_columns(e, &mut |_schema, table, column, _quoted| {
if !clean {
return;
}
if column_resolves(&incols, table, column)
|| column_resolves(outer_cols, table, column)
{
return;
}
if table.is_none() && aliases.iter().any(|a| a.eq_ignore_ascii_case(column)) {
return;
}
clean = false;
});
}
clean
}
/// Eager `no such column` for a column reference inside an expression-position
/// subquery body (A-prepare-correlated). SQLite resolves every reference at
/// prepare time, so a subquery-body reference that binds to neither the
/// subquery's own `FROM` nor any enclosing (correlation) scope errors even
/// when the outer table is empty or every row is filtered out — exactly where
/// graphite's lazy, per-row resolution never reaches the subquery and so
/// missed it. `outer_cols` is the accumulated enclosing scope: the outermost
/// query's scan columns at entry, extended by each level's own `FROM` as the
/// walk descends.
///
/// Conservative throughout, because a false positive would reject valid SQL in
/// the byte-exact differential suite: a compound subquery body, a `FROM` that
/// [`Self::scan_source`] cannot build, and a reference whose only candidate
/// column carries an unknown origin (`schema: None`) are each left to the lazy
/// path rather than risk a spurious error. See [`column_resolves_scoped`].
fn validate_subquery_body_columns(
&self,
sel: &Select,
outer_cols: &[ColumnInfo],
) -> Result<()> {
let mut targets: Vec<&Expr> = Vec::new();
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
targets.push(expr);
}
}
if let Some(w) = &sel.where_clause {
targets.push(w);
}
if let Some(h) = &sel.having {
targets.push(h);
}
for g in &sel.group_by {
targets.push(g);
}
for t in &sel.order_by {
targets.push(&t.expr);
}
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
targets.push(on);
}
}
}
let mut subs: Vec<&Select> = Vec::new();
for e in targets {
collect_subselects(e, &mut subs);
}
for sub in subs {
self.check_subquery_body_columns(sub, outer_cols)?;
}
Ok(())
}
/// Resolve every shallow column reference in one expression-position subquery
/// body against its own `FROM` plus the enclosing scope, raising the first
/// `no such column` SQLite would, then recurse into further-nested bodies with
/// this body's `FROM` added to the scope. See
/// [`Self::validate_subquery_body_columns`].
fn check_subquery_body_columns(&self, sub: &Select, outer_cols: &[ColumnInfo]) -> Result<()> {
// A compound body carries its own per-arm scope rules — leave it to the
// lazy path rather than risk a wrong resolution here. The one exception is
// a fully `FROM`-less compound (every arm, head included, has no `FROM` and
// no further nesting) — a multi-row `VALUES` desugars to exactly this. Such
// an arm's column references can only bind to the enclosing scope (there is
// no local table), so they are safe to resolve eagerly, catching a bad
// column in a non-first `VALUES` row (`… IN (VALUES(1),(zzz))`) that the
// lazy path misses over an empty/filtered outer.
if !sub.compound.is_empty() {
let from_less = sub.from.is_none()
&& sub
.compound
.iter()
.all(|(_, a)| a.from.is_none() && a.compound.is_empty());
if from_less {
let arms = core::iter::once(sub).chain(sub.compound.iter().map(|(_, a)| a));
for arm in arms {
for rc in &arm.columns {
let ResultColumn::Expr { expr, .. } = rc else {
continue;
};
let mut missing: Option<Error> = None;
walk_shallow_columns(expr, &mut |schema, table, column, quoted| {
if missing.is_none()
&& !column_resolves_scoped(outer_cols, schema, table, column)
{
missing = Some(eval::no_such_column(schema, table, column, quoted));
}
});
if let Some(e) = missing {
return Err(e);
}
}
}
}
return Ok(());
}
let params = Params::default();
let Ok((incols, _)) = self.scan_source(sub, ¶ms) else {
return Ok(());
};
// A bare name may match one of the body's own output aliases (a
// GROUP BY / HAVING / ORDER BY reference), so exempt those.
let aliases: Vec<&str> = sub
.columns
.iter()
.filter_map(|c| match c {
ResultColumn::Expr { alias: Some(a), .. } => Some(a.as_str()),
_ => None,
})
.collect();
let mut targets: Vec<&Expr> = Vec::new();
for rc in &sub.columns {
if let ResultColumn::Expr { expr, .. } = rc {
targets.push(expr);
}
}
if let Some(w) = &sub.where_clause {
targets.push(w);
}
if let Some(h) = &sub.having {
targets.push(h);
}
for g in &sub.group_by {
targets.push(g);
}
for t in &sub.order_by {
targets.push(&t.expr);
}
if let Some(from) = &sub.from {
for j in &from.joins {
if let Some(on) = &j.on {
targets.push(on);
}
}
}
let mut missing: Option<Error> = None;
for e in &targets {
if missing.is_some() {
break;
}
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if missing.is_some() {
return;
}
if column_resolves_scoped(&incols, schema, table, column)
|| column_resolves_scoped(outer_cols, schema, table, column)
{
return;
}
if schema.is_none()
&& table.is_none()
&& aliases.iter().any(|a| a.eq_ignore_ascii_case(column))
{
return;
}
missing = Some(eval::no_such_column(schema, table, column, quoted));
});
}
if let Some(e) = missing {
return Err(e);
}
// Descend into further-nested bodies; their correlation scope is this
// body's own `FROM` plus everything already enclosing.
let mut combined = incols;
combined.extend_from_slice(outer_cols);
self.validate_subquery_body_columns(sub, &combined)
}
/// Reject a multi-column scalar subquery `(SELECT a, b …)` used where a single
/// value is required — SQLite reports `sub-select returns N columns - expected
/// 1` at prepare time, but graphite resolved the subquery lazily and so
/// silently accepted it over an empty/filtered table. Mirrors
/// [`Self::reject_invalid_in_subquery_arity`]'s clause coverage and column-clean
/// gate. A subquery that is the direct operand of a comparison (`=`/`<`/`IS`/…)
/// or `BETWEEN` is *not* this error — SQLite treats it as a row value there and
/// reports `row value misused` instead — so those positions are skipped.
fn reject_invalid_scalar_subquery_arity(
&self,
sel: &Select,
cols: &[ColumnInfo],
) -> Result<()> {
let mut targets: Vec<&Expr> = Vec::new();
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
targets.push(expr);
}
}
if let Some(w) = &sel.where_clause {
targets.push(w);
}
if let Some(h) = &sel.having {
targets.push(h);
}
for g in &sel.group_by {
targets.push(g);
}
for t in &sel.order_by {
targets.push(&t.expr);
}
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
targets.push(on);
}
}
}
for e in targets {
self.walk_scalar_subquery_arity(e, cols, false)?;
}
Ok(())
}
/// Walk `e` for scalar subqueries used in a single-value position and arity
/// check each — see [`Self::reject_invalid_scalar_subquery_arity`]. `in_cmp`
/// tracks whether `e` is the direct operand of a comparison/`BETWEEN` (where a
/// wide subquery is `row value misused`, not this arity error). A nested
/// subquery body is not descended (its own scope validates itself).
fn walk_scalar_subquery_arity(
&self,
e: &Expr,
cols: &[ColumnInfo],
in_cmp: bool,
) -> Result<()> {
match e {
Expr::Subquery(select) => {
if !in_cmp {
let width = eval::Subqueries::row_column_affinities(self, select).len();
if width > 1 && self.subquery_body_columns_clean(select, cols) {
return Err(Error::Error(alloc::format!(
"sub-select returns {width} columns - expected 1"
)));
}
}
}
Expr::Binary {
op, left, right, ..
} => {
let cmp = matches!(
op,
BinaryOp::Eq
| BinaryOp::NotEq
| BinaryOp::Lt
| BinaryOp::LtEq
| BinaryOp::Gt
| BinaryOp::GtEq
| BinaryOp::Is
| BinaryOp::IsNot
);
self.walk_scalar_subquery_arity(left, cols, cmp)?;
self.walk_scalar_subquery_arity(right, cols, cmp)?;
}
Expr::Between {
expr, low, high, ..
} => {
// All three operands of a `BETWEEN` are comparison operands.
self.walk_scalar_subquery_arity(expr, cols, true)?;
self.walk_scalar_subquery_arity(low, cols, true)?;
self.walk_scalar_subquery_arity(high, cols, true)?;
}
Expr::Unary { expr, .. } => self.walk_scalar_subquery_arity(expr, cols, false)?,
Expr::Function {
args,
filter,
order_by,
..
} => {
for a in args {
self.walk_scalar_subquery_arity(a, cols, false)?;
}
if let Some(flt) = filter {
self.walk_scalar_subquery_arity(flt, cols, false)?;
}
for t in order_by {
self.walk_scalar_subquery_arity(&t.expr, cols, false)?;
}
}
Expr::IsNull { expr, .. } => self.walk_scalar_subquery_arity(expr, cols, false)?,
Expr::InList { expr, list, .. } => {
self.walk_scalar_subquery_arity(expr, cols, false)?;
for a in list {
self.walk_scalar_subquery_arity(a, cols, false)?;
}
}
Expr::InSelect { expr, .. } => self.walk_scalar_subquery_arity(expr, cols, false)?,
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
self.walk_scalar_subquery_arity(o, cols, false)?;
}
for (w, t) in when_then {
self.walk_scalar_subquery_arity(w, cols, false)?;
self.walk_scalar_subquery_arity(t, cols, false)?;
}
if let Some(el) = else_result {
self.walk_scalar_subquery_arity(el, cols, false)?;
}
}
Expr::Cast { expr, .. } => self.walk_scalar_subquery_arity(expr, cols, false)?,
Expr::Collate { expr, .. } => self.walk_scalar_subquery_arity(expr, cols, false)?,
// A parenthesised comparison operand keeps its `in_cmp` status.
Expr::Paren(inner) => self.walk_scalar_subquery_arity(inner, cols, in_cmp)?,
Expr::RowValue(items) => {
for it in items {
self.walk_scalar_subquery_arity(it, cols, false)?;
}
}
_ => {}
}
Ok(())
}
/// Reject a row value `(a, b, …)` used where a single value is required, and a
/// comparison/`BETWEEN` whose operands have mismatched row arity — both are
/// `row value misused` in SQLite, raised at prepare time. graphite evaluates
/// the misuse per row (the `Expr::RowValue` arm of `eval`, and the
/// `operand_arity` checks on `=`/`IS`/`BETWEEN`), so over an empty or
/// fully-filtered table — where no row is ever evaluated — it was silently
/// accepted. The clause coverage mirrors the subquery-arity walkers. A
/// *multi-column subquery* in a plain scalar position is a different message
/// (`sub-select returns N columns - expected 1`, handled by
/// [`Self::reject_invalid_scalar_subquery_arity`]) and is left to that check;
/// here a subquery only participates as the wide operand of a row comparison.
fn reject_row_value_misuse(&self, sel: &Select, cols: &[ColumnInfo]) -> Result<()> {
let mut targets: Vec<&Expr> = Vec::new();
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
targets.push(expr);
}
}
if let Some(w) = &sel.where_clause {
targets.push(w);
}
if let Some(h) = &sel.having {
targets.push(h);
}
for g in &sel.group_by {
targets.push(g);
}
for t in &sel.order_by {
targets.push(&t.expr);
}
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
targets.push(on);
}
}
}
for e in targets {
self.walk_row_value_misuse(e, cols)?;
}
Ok(())
}
/// The structural row arity of a comparison operand at prepare time, mirroring
/// the runtime `operand_arity`: a literal `(a, b, …)` row value's length, a
/// column-clean subquery's output-column count (no rows evaluated), or 1 for
/// an ordinary scalar. Returns `None` when a subquery operand is *not*
/// column-clean — SQLite reports its `no such column` before any misuse, so
/// the caller must skip the arity check rather than risk the wrong message.
fn row_arity(&self, e: &Expr, cols: &[ColumnInfo]) -> Option<usize> {
match unparen(e) {
Expr::RowValue(items) => Some(items.len()),
Expr::Subquery(select) => {
if self.subquery_body_columns_clean(select, cols) {
let w = eval::Subqueries::row_column_affinities(self, select).len();
Some(if w == 0 { 1 } else { w })
} else {
None
}
}
_ => Some(1),
}
}
/// Walk `e` in a *scalar* position (one value expected): a bare row value
/// there is `row value misused`. Comparison/`BETWEEN` nodes are the one place a
/// row value is legal — their operands are checked for matching arity via
/// [`Self::walk_row_value_misuse_operand`] instead. See
/// [`Self::reject_row_value_misuse`].
fn walk_row_value_misuse(&self, e: &Expr, cols: &[ColumnInfo]) -> Result<()> {
match e {
Expr::RowValue(_) => {
// A bare row value in a scalar position.
return Err(Error::Error("row value misused".into()));
}
Expr::Paren(inner) => self.walk_row_value_misuse(inner, cols)?,
Expr::Binary {
op, left, right, ..
} => {
if matches!(
op,
BinaryOp::Eq
| BinaryOp::NotEq
| BinaryOp::Lt
| BinaryOp::LtEq
| BinaryOp::Gt
| BinaryOp::GtEq
| BinaryOp::Is
| BinaryOp::IsNot
) {
if let (Some(la), Some(ra)) =
(self.row_arity(left, cols), self.row_arity(right, cols))
&& (la > 1 || ra > 1)
&& la != ra
{
return Err(Error::Error("row value misused".into()));
}
self.walk_row_value_misuse_operand(left, cols)?;
self.walk_row_value_misuse_operand(right, cols)?;
} else {
// Arithmetic, logical, concat — both operands are scalar.
self.walk_row_value_misuse(left, cols)?;
self.walk_row_value_misuse(right, cols)?;
}
}
Expr::Between {
expr, low, high, ..
} => {
if let (Some(ea), Some(la), Some(ha)) = (
self.row_arity(expr, cols),
self.row_arity(low, cols),
self.row_arity(high, cols),
) && (ea > 1 || la > 1 || ha > 1)
&& (ea != la || ea != ha)
{
return Err(Error::Error("row value misused".into()));
}
self.walk_row_value_misuse_operand(expr, cols)?;
self.walk_row_value_misuse_operand(low, cols)?;
self.walk_row_value_misuse_operand(high, cols)?;
}
Expr::Unary { expr, .. } => self.walk_row_value_misuse(expr, cols)?,
Expr::Function {
args,
filter,
order_by,
..
} => {
for a in args {
self.walk_row_value_misuse(a, cols)?;
}
if let Some(flt) = filter {
self.walk_row_value_misuse(flt, cols)?;
}
for t in order_by {
self.walk_row_value_misuse(&t.expr, cols)?;
}
}
Expr::IsNull { expr, .. } => self.walk_row_value_misuse(expr, cols)?,
Expr::InList { expr, list, .. } => {
// `(a,b) IN ((1,2),…)` — the LHS and each list element may be a
// row value, but every element must have the LHS's arity. A
// wider/narrower element under a row LHS is `IN(...) element has
// N terms - expected M`; a row element under a scalar LHS is
// `row value misused`. (SQLite checks this at prepare time;
// graphite evaluated it per row, so an empty/filtered table — or
// even a matching first element — masked the bad one.)
if let Some(m) = self.row_arity(expr, cols) {
for a in list {
let Some(n) = self.row_arity(a, cols) else {
continue;
};
if m >= 2 {
if n != m {
let term = if n == 1 { "term" } else { "terms" };
return Err(Error::Error(alloc::format!(
"IN(...) element has {n} {term} - expected {m}"
)));
}
} else if n >= 2 {
return Err(Error::Error("row value misused".into()));
}
}
}
self.walk_row_value_misuse_operand(expr, cols)?;
for a in list {
self.walk_row_value_misuse_operand(a, cols)?;
}
}
Expr::InSelect { expr, .. } => self.walk_row_value_misuse_operand(expr, cols)?,
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
self.walk_row_value_misuse(o, cols)?;
}
for (w, t) in when_then {
self.walk_row_value_misuse(w, cols)?;
self.walk_row_value_misuse(t, cols)?;
}
if let Some(el) = else_result {
self.walk_row_value_misuse(el, cols)?;
}
}
Expr::Cast { expr, .. } => self.walk_row_value_misuse(expr, cols)?,
Expr::Collate { expr, .. } => self.walk_row_value_misuse(expr, cols)?,
_ => {}
}
Ok(())
}
/// Walk `e` where a row value *is* permitted (a direct operand of a row
/// comparison / `BETWEEN` / `IN`): its outer row-ness is fine, but each element
/// is a scalar position. A subquery operand is the legal wide form and its body
/// validates itself, so it is not descended.
fn walk_row_value_misuse_operand(&self, e: &Expr, cols: &[ColumnInfo]) -> Result<()> {
match unparen(e) {
Expr::RowValue(items) => {
for it in items {
self.walk_row_value_misuse(it, cols)?;
}
}
Expr::Subquery(_) => {}
other => self.walk_row_value_misuse(other, cols)?,
}
Ok(())
}
/// The arity guard for one expression — see `reject_aggregate_arity_in_select`.
/// Walks `e` (stopping at subquery boundaries, which validate themselves) for
/// each aggregate call, whether plain or used as a window function (`agg(…)
/// OVER (…)` — SQLite arity-checks the windowed form the same way); the bounds
/// mirror `eval_aggregated`'s exactly so a statically-rejected call is one the
/// evaluator would also reject. A registered UDAF carries its own arity, and
/// `min`/`max` count as aggregates only at one argument (the multi-arg forms
/// are scalar) — both excluded by `func::is_aggregate_call`. The built-in
/// window functions (`row_number`, `lag`, …) are not aggregates, so
/// `is_aggregate_call` filters them out and their arity is left untouched. The
/// one exception is `min()`/`max()` with *zero* arguments, handled explicitly
/// below since `is_aggregate_call` only treats them as aggregates at one arg.
fn reject_aggregate_arity(&self, e: &Expr) -> Result<()> {
let mut err: Option<Error> = None;
window::visit(e, &mut |n| {
if err.is_some() {
return;
}
if let Expr::Function {
name,
args,
star,
over,
..
} = n
{
let lname = name.to_ascii_lowercase();
// `min()`/`max()` with zero arguments matches neither the one-arg
// aggregate nor the (>=2)-arg scalar form, so it is a wrong-arg-count
// error. `is_aggregate_call` reports min/max as aggregates only at one
// argument, so the gate below would skip the bare zero-arg call and
// leave it to be caught lazily (i.e. never, over an empty table). The
// windowed form (`max() OVER ()`) is a different error — `min`/`max`
// may not be window functions at all — so it is left to that check.
if (lname == "min" || lname == "max")
&& args.is_empty()
&& !*star
&& over.is_none()
&& !self.aggregates.contains_key(&lname)
{
err = Some(Error::Error(alloc::format!(
"wrong number of arguments to function {lname}()"
)));
return;
}
if self.aggregates.contains_key(&lname)
|| !func::is_aggregate_call(&lname, args.len(), *star)
{
return;
}
let max_args = match lname.as_str() {
"group_concat" | "string_agg" | "json_group_object" | "jsonb_group_object" => 2,
_ => 1,
};
let too_many = args.len() > max_args;
let too_few = (args.is_empty() && lname != "count")
|| (lname == "string_agg" && args.len() < 2)
|| ((lname == "json_group_object" || lname == "jsonb_group_object")
&& args.len() < 2);
if too_many || too_few {
err = Some(Error::Error(alloc::format!(
"wrong number of arguments to function {lname}()"
)));
}
}
});
err.map_or(Ok(()), Err)
}
fn run_core(&self, sel: &Select, params: &Params) -> Result<QueryResult> {
// A `FROM`-less wildcard projection (`SELECT *` / `SELECT X.*` with no
// table) is a prepare-time error in SQLite — `no tables specified` /
// `no such table: X` — with the highest resolution precedence (it wins
// over a missing LIMIT column, a wrong-arity aggregate, and a compound
// column-count mismatch), so it runs before every other check. Recursive
// over the whole tree, so it is gated to the outermost query level (a
// nested level re-enters `run_core` with a non-empty `outer_scope`).
if self.outer_scope.borrow().is_empty() {
reject_fromless_wildcard(sel)?;
}
// Aggregate arity is resolved at prepare time, ahead of every placement
// and misuse check and independent of row production — see
// `reject_aggregate_arity_in_select`.
self.reject_aggregate_arity_in_select(sel)?;
// A `LIMIT`/`OFFSET` is resolved with no table columns in scope — not even
// a correlated outer column — so any column reference in it is `no such
// column: NAME`, which SQLite reports ahead of every other resolution
// error in the statement (the result columns, `WHERE`, an unknown
// function, or an aggregate misuse the same `LIMIT` would otherwise raise:
// `LIMIT sum(a)`/`LIMIT nope(a)` → `no such column: a`, while
// `LIMIT count(*)`, with no column argument, stays a `misuse`). Checked
// here, before the VDBE attempt and every later check, on each query
// level's own `LIMIT`/`OFFSET` (a nested `SELECT` carries its own scope
// and is not descended). The lazy evaluator would otherwise resolve the
// aggregate's misuse, or a correlated outer column, before the missing
// one and so silently accept it.
if let Some(l) = &sel.limit {
reject_scopeless_column_ref(l)?;
}
if let Some(o) = &sel.offset {
reject_scopeless_column_ref(o)?;
}
// Opt-in VDBE fast path (Track B, B7a): when enabled and this block takes
// no bound parameters, try the experimental engine first and use its
// result only on success — every unsupported shape, and every error, is
// left to the tree-walker, which remains the source of truth. The VDBE
// never alters state, so a failed attempt is side-effect-free. Routing
// here (per query block) rather than at the whole-query level means each
// arm of a compound query is accelerated too, while the tree-walker still
// performs the set combination. Skipped inside a correlated/nested scope
// (non-empty `outer_scope`): the spike resolves columns by bare name and
// would mis-resolve an outer-qualified reference to a same-named inner
// column.
if self.use_vdbe.get() && self.outer_scope.borrow().is_empty() {
// No params → run the VDBE on `sel` directly. With params, substitute
// the explicit (`?N`/`:name`) ones into the compiled expressions so the
// param-less VDBE can run the query; an anonymous `?` (or no explicit
// param in those expressions) returns None → fall through.
let substituted;
let vsel = if params.positional.is_empty() && params.named.is_empty() {
Some(sel)
} else {
match substitute_params(sel, params) {
Some(s) => {
substituted = s;
Some(&substituted)
}
None => None,
}
};
if let Some(vsel) = vsel
&& let Ok(result) = self.run_select_vdbe(vsel)
{
// The VDBE compiles a *known* scalar call without re-checking
// its arity, and never evaluates it over zero rows — so a
// wrong-arity call (`abs(a,b)`) would slip through silently
// where SQLite rejects it at prepare time. A VDBE success means
// every column resolved, so an unresolved-function fault is now
// the sole possible error and is safe to surface here without
// masking a missing column.
self.reject_unresolved_functions_in_select(sel)?;
// A scalar call inside an expression-position subquery is
// likewise compiled without an arity recheck and may never
// execute (empty / fully-filtered outer table), so validate
// those too. The scan scope isn't materialized yet here, but
// an uncorrelated FROM-less subquery — the only shape the
// const arm inlines — is column-clean regardless of it, so an
// empty scope checks exactly those and safely skips anything
// correlated (no false positive, missing-column precedence
// preserved).
self.reject_unresolved_functions_in_subqueries(sel, &[])?;
// The VDBE now also routes a single-table scan carrying a
// CORRELATED scalar/`EXISTS` subquery (B5c-2), whose body the
// interpreter evaluates lazily per outer row — so an invalid body
// over a zero-row/filtered scan (`a > (SELECT 1,2)` → row value
// misused; `(SELECT u.a)` → no such column) would slip through the
// way the reverted first attempt did. Run the same prepare-time
// subquery/row-value validation the tree-walker path runs at the
// outermost level, over the outer FROM scope resolved WITHOUT
// materializing rows. Order mirrors the tree-walker's: a missing
// column wins, then subquery arity, then row-value misuse. A
// correlated subquery is only ever routed for a single-table scan,
// which `window_join_source_columns` resolves exactly; any shape it
// cannot resolve carries no correlated body, so the empty-scope
// fallback validates only self-contained subqueries (no false
// positive).
if self.outer_scope.borrow().is_empty() {
// Resolve the outer FROM scope for validating subquery bodies.
// When it can't be resolved cheaply — a CTE / view / derived /
// virtual source that `window_join_source_columns` declines —
// SKIP the eager checks rather than run them against an *empty*
// scope: a correlated subquery legitimately references that
// outer scope, so an empty one falsely reports `no such column`
// (e.g. `WITH t AS (…) SELECT (SELECT … WHERE x=t.a) FROM t`).
// The query already produced `result`; the lazy path covers the
// rest. A resolvable (plain-table) FROM still validates fully.
let scope = match &sel.from {
None => Some(Vec::new()),
Some(_) => self.window_join_source_columns(sel).ok(),
};
if let Some(scope) = scope {
// A bare reference inside a subquery that binds to an
// enclosing FROM carrying that name on two sources is
// ambiguous — rejected statically, before the body-column
// resolution below (the tree-walker's order).
self.validate_nested_ambiguity(sel, &scope)?;
self.validate_subquery_body_columns(sel, &scope)?;
self.reject_invalid_in_subquery_arity(sel, &scope)?;
self.reject_invalid_scalar_subquery_arity(sel, &scope)?;
self.reject_row_value_misuse(sel, &scope)?;
// Unknown / wrong-arity function calls inside a subquery
// body, over the real scope — mirrors the tree-walker's
// outermost `reject_unresolved_functions_in_subqueries`.
self.reject_unresolved_functions_in_subqueries(sel, &scope)?;
}
}
return Ok(result);
}
}
// Promote `FROM a, b WHERE a.x = b.y` to an explicit join `ON` so the join
// fold can seek/hash it (the equality stays in WHERE, so results are
// identical). All later uses of `sel` see the rewritten form. Unqualified
// equalities (`WHERE x = y`) resolve via each source's column names.
let promo_tables = sel
.from
.as_ref()
.map(|f| self.comma_join_table_columns(f))
.unwrap_or_default();
let rewritten;
let sel = match promote_comma_join_ons(sel, &promo_tables) {
Some(r) => {
rewritten = r;
&rewritten
}
None => sel,
};
// A join `ON` predicate is evaluated per candidate row pair, before any
// grouping — an aggregate or window function there is a misuse, never an
// aggregate-legit context. Checked here, before `scan_source` materializes
// the join: a non-empty table would otherwise hit the lazy per-row error
// (with the wrong wording) first, and an empty one would silently accept.
// SQLite uses the function-form aggregate wording and rejects at prepare.
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
reject_misused_window(on)?;
reject_misused_aggregate(on, false)?;
}
}
}
// `SELECT count(*) FROM t` over a single rowid table with exactly one full
// secondary index counts that index's entries instead of scanning the
// table (B2b). Kept in lockstep with `eqp_select` via the shared
// `count_covering_index` helper so EQP reports `USING COVERING INDEX`.
if let Some((_, root)) = self.count_covering_index(sel) {
let mut cur = IndexCursor::new(self.backend.source(), root);
let mut n = 0i64;
while cur.next()?.is_some() {
n += 1;
}
let label = self.output_labels(sel, &[]).pop().unwrap_or_default();
// The single aggregate row is still subject to LIMIT / OFFSET:
// `count(*) … LIMIT 0` yields no rows, `… OFFSET 1` skips the row.
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let offset = match &sel.offset {
Some(e) => must_be_int(eval::eval(e, &ctx)?)?.max(0) as usize,
None => 0,
};
let limit = match &sel.limit {
// A negative LIMIT means "no limit"; OFFSET still applies.
Some(e) => must_be_int(eval::eval(e, &ctx)?)?,
None => -1,
};
let mut rows = if offset >= 1 || limit == 0 {
Vec::new()
} else {
alloc::vec![alloc::vec![Value::Integer(n)]]
};
if limit >= 0 {
rows.truncate(limit as usize);
}
return Ok(QueryResult {
columns: alloc::vec![label],
rows,
});
}
let (columns, input_rows) = self.scan_source(sel, params)?;
// FTS5 relevance: if this query references `rank` / `bm25()` over an `fts5`
// table, build its query context (and bm25 corpus, if ranked) now and
// expose it to `rank`/`bm25()`/`highlight()` during projection and ORDER BY.
// The guard restores any outer query's context (and clears it for a
// non-FTS5 query) when this scope ends.
#[cfg(feature = "fts5")]
let _fts5_rank_guard = Fts5RankGuard {
conn: self,
prev: core::mem::replace(
&mut *self.fts5_rank.borrow_mut(),
self.fts5_query_ctx(sel, &columns, &input_rows, params),
),
};
// SQLite lets WHERE/GROUP BY/HAVING reference a SELECT-list alias, with a
// real column of the same name taking precedence. Rewrite those clauses
// by substituting each unshadowed alias with its defining expression.
let alias_rewritten;
let sel = match alias_substituted(sel, &columns) {
Some(s) => {
alias_rewritten = s;
&alias_rewritten
}
None => sel,
};
// An unqualified (or self-join-qualified) column reference that matches
// columns from two different FROM sources is ambiguous — SQLite rejects
// it. Checked after alias substitution so an ORDER BY/GROUP BY/HAVING
// reference to an unshadowed output alias is already rewritten to its
// defining expression and not mistaken for an ambiguous column.
validate_unambiguous_columns(sel, &columns, &|t| self.wildcard_source_qualifier(sel, t))?;
// SQLite also rejects an ambiguous reference *inside a subquery* that binds
// to an enclosing FROM, statically (whether or not the subquery executes).
// Run that scope-aware pass once, at the outermost query: `columns` here is
// the known top scope, and each nested level resolves against it.
// SQLite resolves every column reference at prepare time, so a missing
// column errors even when the table is empty (or every row is filtered
// out). The tree-walker resolves lazily, per row, so it would otherwise
// miss that error for a result that never reaches projection evaluation.
// Both passes are scope-sensitive, so run them only at the outermost
// query: a nested/correlated body (a non-empty `outer_scope`) may bind a
// reference to an enclosing FROM that this query's `columns` cannot see.
if self.outer_scope.borrow().is_empty() {
self.validate_nested_ambiguity(sel, &columns)?;
self.validate_columns_exist(sel, &columns)?;
self.validate_window_over_columns(sel)?;
self.validate_derived_columns(sel, &columns)?;
self.validate_join_derived_columns(sel)?;
// A column reference inside an expression-position subquery body that
// binds to neither the body's own FROM nor any enclosing scope is a
// prepare-time `no such column` in SQLite — caught here even over an
// empty/filtered outer table the lazy path never reaches. Run after the
// outer column checks (an outer fault wins) and before the arity gates
// (a missing column is SQLite's first error).
self.validate_subquery_body_columns(sel, &columns)?;
// An `IN (SELECT …)` whose width disagrees with the LHS is a
// prepare-time error in SQLite; run it after column resolution so a
// missing column (its first error) still wins.
self.reject_invalid_in_subquery_arity(sel, &columns)?;
// Likewise a multi-column scalar subquery used where one value is
// required (`sub-select returns N columns - expected 1`).
self.reject_invalid_scalar_subquery_arity(sel, &columns)?;
// And a row value in a scalar position, or a comparison/`BETWEEN`
// whose operands disagree in row arity (`row value misused`).
self.reject_row_value_misuse(sel, &columns)?;
}
// A positional `GROUP BY` / `ORDER BY` term (an integer literal) must name
// an output column (1..=ncols); SQLite rejects one out of range. The count
// is taken after wildcard expansion.
let ncols = self.output_labels(sel, &columns).len();
check_positional_terms(&sel.group_by, &sel.order_by, ncols)?;
// SQLite forbids an aggregate function anywhere inside a GROUP BY term
// (even nested, e.g. `1 + count(*)`), reporting a dedicated error rather
// than the generic "aggregate … used outside an aggregate context" — and
// catching the case (`GROUP BY max(a)` over a real table) that lazy
// per-row evaluation would otherwise accept silently.
// A *positional* term names an output column, so `GROUP BY 2` over
// `SELECT a, count(*)` is just as forbidden — resolve the ordinal to its
// result expression and check that too (SQLite reports the same error,
// not the generic "misuse of aggregate function" that lazy substitution
// would otherwise surface).
{
let is_agg = |name: &str, n: usize, star: bool| {
func::is_aggregate_call(name, n, star)
|| self.aggregates.contains_key(&name.to_ascii_lowercase())
};
let resolves_to_agg = |g: &Expr| {
positional_int(g)
.and_then(|n| usize::try_from(n).ok())
.filter(|&n| n >= 1)
.and_then(|n| sel.columns.get(n - 1))
.and_then(|c| match c {
ResultColumn::Expr { expr, .. } => Some(expr),
_ => None,
})
.is_some_and(|e| expr_contains_agg(e, &is_agg))
};
if sel
.group_by
.iter()
.any(|g| expr_contains_agg(g, &is_agg) || resolves_to_agg(g))
{
return Err(Error::Error(
"aggregate functions are not allowed in the GROUP BY clause".into(),
));
}
}
// A window function is valid only in the result columns and ORDER BY of
// its query; in a GROUP BY, HAVING, or WHERE it is a misuse. SQLite
// rejects these at prepare time (so they error even over an empty/filtered
// table, which lazy per-row evaluation would otherwise silently accept).
for g in &sel.group_by {
reject_misused_window(g)?;
}
// A window misuse in HAVING is only reported once HAVING itself is legal:
// on a non-aggregate query SQLite emits `HAVING clause on a non-aggregate
// query` first (see below), so defer both window checks to a genuine
// aggregate context (a GROUP BY or a result-column aggregate).
if let Some(h) = &sel.having
&& (!sel.group_by.is_empty() || self.has_result_aggregate(sel))
{
reject_misused_window(h)?;
reject_window_without_over(h)?;
}
// An aggregate function in the WHERE clause is a misuse: WHERE filters
// individual rows, before any grouping. SQLite rejects it at prepare time
// (so it errors even over an empty/fully-filtered table, which lazy
// per-row evaluation would otherwise silently accept). The wording depends
// on whether this is an aggregate query — see `reject_misused_aggregate`.
if let Some(w) = &sel.where_clause {
reject_misused_window(w)?;
reject_misused_aggregate(w, select_is_aggregate_query(sel))?;
}
// An aggregate in the ORDER BY of a *non-aggregate* query is a misuse
// (ORDER BY of a query that has GROUP BY/HAVING or an aggregate result
// column may legitimately use one). SQLite resolves ORDER BY in a context
// where aggregates are otherwise allowed, so the misuse here always reads
// with the colon wording (`misuse of aggregate: f()`), unlike WHERE. It is
// rejected at prepare time. (A window function in ORDER BY is valid, so it
// is not checked here.)
if sel.compound.is_empty() && !select_is_aggregate_query(sel) {
for t in &sel.order_by {
// A window nested in the aggregate's argument is named ahead of the
// outer aggregate's own misuse — SQLite resolves the inner call
// first (`sum(row_number() OVER ())` → the window, not `sum`).
reject_nested_aggregate_arg(&t.expr)?;
reject_misused_aggregate(&t.expr, true)?;
}
}
// A `FILTER (WHERE …)` clause restricts which rows an aggregate consumes,
// so it is meaningful only on an aggregate call. SQLite rejects it on a
// plain scalar function (`abs(x) FILTER(WHERE …)`) at prepare time, in
// every position; graphite's evaluator silently ignored the clause and
// returned the bare value. Check each scalar-expression position here.
// Unknown / wrong-arity scalar function calls are caught by
// `reject_unresolved_functions_in_select` (run ahead of this block, on
// both the VDBE-success and tree-walker paths).
self.reject_unresolved_functions_in_select(sel)?;
// Scalar calls inside an expression-position subquery are not reached by the
// walk above; check them at the outermost query, after the outer call so an
// outer fault still wins. Gated to a column-clean subquery body so a
// `no such column` SQLite reports first is never masked.
if self.outer_scope.borrow().is_empty() {
self.reject_unresolved_functions_in_subqueries(sel, &columns)?;
}
{
let is_agg = |name: &str, n: usize, star: bool| {
func::is_aggregate_call(name, n, star)
|| self.aggregates.contains_key(&name.to_ascii_lowercase())
};
let is_known_scalar =
|name: &str, n: usize, star: bool| self.scalar_function_exists(name, n, star);
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
reject_filter_on_non_aggregate(expr, &is_agg)?;
reject_aggregate_in_filter(expr, &is_agg)?;
reject_invalid_window_function(expr, &is_agg, &is_known_scalar)?;
reject_window_without_over(expr)?;
reject_star_argument(expr)?;
reject_invalid_likelihood(expr)?;
reject_nested_aggregate_arg(expr)?;
reject_window_in_window(expr)?;
}
}
// A window function nested in a named window's PARTITION BY / ORDER BY
// (`WINDOW w AS (ORDER BY sum(a) OVER ())`) — the spec lives apart from
// the `OVER w` call site, so check the definitions directly.
for (_, spec) in &sel.window_defs {
reject_window_in_windowspec(spec)?;
}
if let Some(w) = &sel.where_clause {
reject_filter_on_non_aggregate(w, &is_agg)?;
reject_aggregate_in_filter(w, &is_agg)?;
reject_window_without_over(w)?;
reject_star_argument(w)?;
reject_invalid_likelihood(w)?;
}
if let Some(h) = &sel.having {
reject_filter_on_non_aggregate(h, &is_agg)?;
reject_aggregate_in_filter(h, &is_agg)?;
// A `*` arg in HAVING is only reached once the HAVING itself is
// valid: SQLite reports `HAVING clause on a non-aggregate query`
// ahead of the arity error, so defer the star check to a genuine
// aggregate context (a GROUP BY or a result-column aggregate).
if !sel.group_by.is_empty() || self.has_result_aggregate(sel) {
reject_star_argument(h)?;
reject_invalid_likelihood(h)?;
reject_nested_aggregate_arg(h)?;
}
}
for g in &sel.group_by {
reject_filter_on_non_aggregate(g, &is_agg)?;
reject_aggregate_in_filter(g, &is_agg)?;
reject_window_without_over(g)?;
reject_star_argument(g)?;
reject_invalid_likelihood(g)?;
}
for t in &sel.order_by {
reject_filter_on_non_aggregate(&t.expr, &is_agg)?;
reject_aggregate_in_filter(&t.expr, &is_agg)?;
reject_invalid_window_function(&t.expr, &is_agg, &is_known_scalar)?;
reject_window_without_over(&t.expr)?;
reject_star_argument(&t.expr)?;
reject_invalid_likelihood(&t.expr)?;
reject_nested_aggregate_arg(&t.expr)?;
}
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
reject_filter_on_non_aggregate(on, &is_agg)?;
reject_aggregate_in_filter(on, &is_agg)?;
reject_window_without_over(on)?;
reject_star_argument(on)?;
reject_invalid_likelihood(on)?;
}
}
}
}
// Apply WHERE.
let mut rows: Vec<InputRow> = Vec::new();
for r in input_rows {
if let Some(pred) = &sel.where_clause {
let ctx = r.ctx(&columns, params).with_subqueries(self);
if eval::truth(&eval::eval(pred, &ctx)?) != Some(true) {
continue;
}
}
rows.push(r);
}
// Windows, aggregation/grouping, projection, DISTINCT, ORDER BY and
// LIMIT/OFFSET all run over these post-WHERE rows. Factored into
// `finish_from_rows` so the VDBE window dispatcher can reuse the exact same
// tail after producing the base rows itself.
self.finish_from_rows(sel, columns, rows, params)
}
/// Finish a query block from its post-`WHERE` rows: apply window functions,
/// aggregation/grouping and projection, then `DISTINCT`, `ORDER BY` and
/// `LIMIT`/`OFFSET`. `columns` is the input rows' column metadata (windows
/// append synthetic columns to it). This is the second half of `run_core`,
/// extracted so the VDBE window path ([`run_window_vdbe`]) can drive it over
/// rows it scanned itself.
fn finish_from_rows(
&self,
sel: &Select,
mut columns: Vec<ColumnInfo>,
mut rows: Vec<InputRow>,
params: &Params,
) -> Result<QueryResult> {
// A window function combined with GROUP BY / aggregates: SQLite applies
// the window *after* grouping (it runs over the post-aggregation rows, and
// an aggregate inside a window argument or spec is the group's aggregate).
// `eval_windowed_aggregate` handles grouping, the windows, and projection,
// returning rows + sort keys just like the other eval paths — so it feeds
// the same DISTINCT / ORDER BY / LIMIT post-processing below.
let windowed_agg = window::has_window(sel)
&& (!sel.group_by.is_empty()
|| self.has_aggregate(sel)
|| self.has_over_spec_aggregate(sel));
// Plain window functions (no GROUP BY/aggregate): compute over the
// post-WHERE rows, append the results as synthetic columns, and rewrite the
// projection to reference them. Capture the output labels from the ORIGINAL
// projection first — `apply_windows` rewrites each window call to a `__winN`
// column reference, which would otherwise name the output column `__winN`
// instead of its source text (`sum(a) OVER ()`).
// A plain-window query is rewritten below (each `f(x) OVER …` call becomes
// a `__winN` column reference), after which `window::has_window` reports
// false — so the scan-order `ORDER BY` shortcut (`order_satisfied_by_scan`),
// which is guarded off for windowed queries, would wrongly fire. But these
// rows were materialized by the base scan in its own (rowid) order, NOT the
// index/ORDER-BY order that shortcut assumes, so applying it drops the sort
// and yields unsorted output. Remember the pre-rewrite window state and
// force the real sort in that case.
let is_plain_windowed = window::has_window(sel) && !windowed_agg;
let window_labels = if is_plain_windowed {
Some(self.output_labels(sel, &columns))
} else {
None
};
let rewritten;
let sel = if is_plain_windowed {
let mut w = self.apply_windows(sel, &mut columns, &mut rows, params)?;
// Absent an explicit ORDER BY, match sqlite's window-induced row order.
if w.order_by.is_empty()
&& let Some(order) = self.window_output_order(sel)?
{
w.order_by = order;
}
rewritten = w;
&rewritten
} else {
sel
};
let aggregated = !sel.group_by.is_empty() || self.has_aggregate(sel);
// A HAVING clause requires an aggregate *context*: a GROUP BY, or an
// aggregate in the result columns. An aggregate that appears *only* inside
// HAVING does not make the query aggregate — SQLite rejects HAVING there
// ("HAVING clause on a non-aggregate query"), e.g. `SELECT 1 HAVING max(x)`.
if sel.having.is_some() && sel.group_by.is_empty() && !self.has_result_aggregate(sel) {
return Err(Error::Error(
"HAVING clause on a non-aggregate query".into(),
));
}
let (mut out_labels, mut out) = if windowed_agg {
self.eval_windowed_aggregate(sel, &columns, rows, params)?
} else if aggregated {
self.eval_aggregated(sel, &columns, rows, params)?
} else {
self.eval_simple(sel, &columns, rows, params)?
};
// Restore the pre-rewrite labels for a plain windowed query (above).
if let Some(labels) = window_labels {
out_labels = labels;
}
// DISTINCT (dedupe on output values, preserving first occurrence), each
// output column compared under its collation.
if sel.distinct {
let colls = self.output_collations(sel, &columns, params);
let mut seen: Vec<Vec<Value>> = Vec::new();
out.retain(|row| {
if seen.iter().any(|s| rows_equal_coll(s, &row.values, &colls)) {
false
} else {
seen.push(row.values.clone());
true
}
});
}
// ORDER BY. A query already produced in rowid order by the table scan
// (sole ORDER BY term = rowid / INTEGER PRIMARY KEY) skips the sort —
// just reversing for DESC — matching sqlite's plain SCAN with no temp
// b-tree.
if !sel.order_by.is_empty() {
// For a plain-window query the rows were produced by the base scan in
// rowid order (the window rewrite hid the window calls, so the scan-
// order shortcut can no longer tell), so always sort — never trust
// `order_satisfied_by_scan` here.
let scan_order = if is_plain_windowed {
None
} else {
self.order_satisfied_by_scan(sel, params)
};
match scan_order {
Some(true) => out.reverse(),
Some(false) => {}
None => {
let colls = self.order_collations(sel, &columns, params);
// Stable sort by the precomputed sort keys, each under its collation.
out.sort_by(|a, b| {
for (i, term) in sel.order_by.iter().enumerate() {
let ord = cmp_order(
&a.sort_keys[i],
&b.sort_keys[i],
term.descending,
term.nulls_first,
colls[i],
);
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
core::cmp::Ordering::Equal
});
}
}
}
// OFFSET / LIMIT.
let offset = match &sel.offset {
Some(e) => must_be_int(eval::eval(
e,
&EvalCtx::rowless(params).with_subqueries(self),
)?)?
.max(0) as usize,
None => 0,
};
// A negative LIMIT means "no limit" in SQLite (OFFSET still applies).
let limit = match &sel.limit {
Some(e) => {
let n = must_be_int(eval::eval(
e,
&EvalCtx::rowless(params).with_subqueries(self),
)?)?;
if n < 0 { None } else { Some(n as usize) }
}
None => None,
};
let mut final_rows: Vec<Vec<Value>> =
out.into_iter().skip(offset).map(|r| r.values).collect();
if let Some(n) = limit {
final_rows.truncate(n);
}
Ok(QueryResult {
columns: out_labels,
rows: final_rows,
})
}
/// Run a window-function `SELECT` over a single plain table on the VDBE
/// (Track B5c-4). The window evaluation itself is not bytecode; instead the
/// base table is scanned (with `WHERE` applied) by the VDBE, and the rows are
/// fed to the shared `finish_from_rows` tail — analogous to how
/// `run_compound_vdbe` reuses the set-combine helpers. The base scan appends
/// each row's rowid as a trailing column so a `rowid`/`_rowid_`/`oid`
/// reference anywhere in the query resolves; a `WITHOUT ROWID` table makes
/// that projection bail, so such queries fall back. A plain join, a derived
/// subquery, a whole-query `WITH` CTE, a view source, and a table-valued
/// function source are also handled (all but the join carry no rowid, so a
/// `rowid` reference there defers); any shape the base scan cannot run (a
/// virtual-table source, a non-`main` schema, a `NATURAL`/`USING` join, …)
/// returns `Unsupported`, falling the whole query back to the tree-walker.
/// The `ColumnInfo` for a derived / CTE window source body — the same column
/// model the non-window derived-scan path (`scan_one`) uses. A constant /
/// `VALUES` body's columns carry no affinity and BINARY collation; any other
/// single-source body resolves each column's `(affinity, collation)` through
/// `subquery_column_origins`, with names from the body's output (`resolved_
/// view_columns`). `rename` applies an explicit CTE `(cols…)` list. Returns
/// `Unsupported` for a body neither helper can resolve (a join, a non-constant
/// compound, a view, a TVF), so the window defers to the tree-walker.
fn window_source_columns(
&self,
sub: &Select,
qualifier: &str,
rename: Option<&[String]>,
) -> Result<Vec<ColumnInfo>> {
let apply_rename = |names: Vec<String>| -> Result<Vec<String>> {
match rename {
Some(r) if r.len() == names.len() => Ok(r.to_vec()),
Some(_) => Err(Error::Unsupported(
"VDBE window: source column count mismatch",
)),
None => Ok(names),
}
};
// A constant / `VALUES` body — no base table in any compound arm (a
// top-level `VALUES (…),(…)` desugars to a `UNION ALL` of FROM-less
// constant cores). Its columns carry no affinity and BINARY collation, so
// the base scan's `scan_one` materializes them the same way.
if sub.from.is_none() && sub.compound.iter().all(|(_, s)| s.from.is_none()) {
let result = self.run_select(sub, &Params::default())?;
let names = apply_rename(result.columns)?;
return Ok(names
.into_iter()
.map(|n| ColumnInfo {
name: n,
table: qualifier.to_string(),
affinity: eval::Affinity::from_type(None),
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
})
.collect());
}
let origins = self
.subquery_column_origins(sub)
.ok_or(Error::Unsupported("VDBE window: non-plain derived source"))?;
let body = self.resolved_view_columns(sub).ok_or(Error::Unsupported(
"VDBE window: derived columns unresolved",
))?;
let names = apply_rename(body.iter().map(|(n, _)| n.clone()).collect())?;
if names.len() != origins.len() {
return Err(Error::Unsupported(
"VDBE window: derived column count mismatch",
));
}
Ok(names
.into_iter()
.zip(&origins)
.map(|(n, (aff, coll))| ColumnInfo {
name: n,
table: qualifier.to_string(),
affinity: *aff,
collation: *coll,
schema: None,
hidden: false,
})
.collect())
}
fn run_window_vdbe(&self, sel: &Select) -> Result<QueryResult> {
// Whether `sel` references a `rowid`/`_rowid_`/`oid` pseudo-column anywhere
// in its expressions (projection, `WHERE`, `GROUP BY`, `HAVING`, `ORDER BY`,
// or any window's `PARTITION BY`/`ORDER BY`, including a nested `OVER`).
// The join path below supplies no per-row rowid (a joined row has none), so
// it must defer whenever a `None` rowid could become observable.
fn is_rowid_name(n: &str) -> bool {
n.eq_ignore_ascii_case("rowid")
|| n.eq_ignore_ascii_case("_rowid_")
|| n.eq_ignore_ascii_case("oid")
}
fn spec_has_rowid(spec: &WindowSpec) -> bool {
spec.partition_by.iter().any(expr_has_rowid)
|| spec.order_by.iter().any(|t| expr_has_rowid(&t.expr))
}
fn expr_has_rowid(e: &Expr) -> bool {
let mut found = false;
window::visit(e, &mut |node| match node {
Expr::Column { column, .. } if is_rowid_name(column) => found = true,
Expr::Function {
over: Some(spec), ..
} if spec_has_rowid(spec) => found = true,
_ => {}
});
found
}
fn select_mentions_rowid(sel: &Select) -> bool {
sel.columns
.iter()
.any(|c| matches!(c, ResultColumn::Expr { expr, .. } if expr_has_rowid(expr)))
|| sel.where_clause.as_ref().is_some_and(expr_has_rowid)
|| sel.group_by.iter().any(expr_has_rowid)
|| sel.having.as_ref().is_some_and(expr_has_rowid)
|| sel.order_by.iter().any(|t| expr_has_rowid(&t.expr))
|| sel.window_defs.iter().any(|(_, spec)| spec_has_rowid(spec))
}
let Some(from) = &sel.from else {
return Err(Error::Unsupported("VDBE window: no FROM"));
};
// The source is a single plain rowid table (rowid is appended so a `rowid`
// reference resolves), a plain N-table join, a derived subquery, or a
// `FROM` reference naming a whole-query `WITH` CTE (the last three have no
// single rowid, so they are only taken when no rowid is referenced).
// `rowid_source` records whether a trailing rowid is scanned. A join that
// carries CTEs still defers: its column set is resolved *statically*
// (`static_scope_columns`), which can't see a CTE binding, so a CTE that
// shadows a real table name there would resolve to the wrong columns.
let is_join = !from.joins.is_empty();
if is_join && !sel.ctes.is_empty() {
return Err(Error::Unsupported("VDBE window: join carries CTEs"));
}
let mut rowid_source = false;
let columns = if is_join {
if select_mentions_rowid(sel) {
return Err(Error::Unsupported("VDBE window: join references rowid"));
}
// `static_scope_columns` yields the `SELECT *` column set in expansion
// order from plain base tables (no rows read). When a join source is a
// view or TVF it returns `None`; `window_join_source_columns` then
// resolves each source's columns by materializing it exactly as the base
// scan's `scan_one` does (a `NATURAL`/`USING`, derived, CTE, or
// schema-qualified join source still defers).
match self.static_scope_columns(sel) {
Some(cols) => cols,
None => self.window_join_source_columns(sel)?,
}
} else {
let tref = &from.first;
if let Some(sub) = &tref.subquery {
// A derived subquery source has no rowid, so (like a join) defer if
// a rowid is referenced. Resolve its columns through the same model
// the derived scan path uses (constant/`VALUES` or single-source
// chain); a join / non-constant compound / view / TVF body defers.
if tref.tvf_args.is_some() || tref.index_hint.is_some() {
return Err(Error::Unsupported("VDBE window: non-plain source"));
}
if select_mentions_rowid(sel) {
return Err(Error::Unsupported(
"VDBE window: derived source references rowid",
));
}
let qualifier = tref.alias.clone().unwrap_or_default();
self.window_source_columns(sub, &qualifier, None)?
} else if let Some(cte) =
(tref.tvf_args.is_none() && tref.index_hint.is_none() && tref.schema.is_none())
.then(|| {
sel.ctes
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&tref.name))
})
.flatten()
{
// A `FROM` reference naming a whole-query `WITH` CTE: resolve its
// columns through the CTE body — with the explicit `WITH
// name(cols…)` rename applied — exactly like the derived-subquery
// branch. The base scan (`run_select_vdbe(&base)` below, with
// `base.ctes` retained) materializes the CTE through that same
// derived path, so columns and rows stay in lockstep. A CTE has no
// rowid, so defer if one is referenced.
if select_mentions_rowid(sel) {
return Err(Error::Unsupported(
"VDBE window: CTE source references rowid",
));
}
let qualifier = tref.alias.clone().unwrap_or_else(|| tref.name.clone());
let rename = (!cte.columns.is_empty()).then_some(cte.columns.as_slice());
self.window_source_columns(cte.select.as_ref(), &qualifier, rename)?
} else if tref.index_hint.is_none()
&& (tref.tvf_args.is_some() || self.is_bare_tvf(tref))
{
// A table-valued function window source (`generate_series(…)`,
// `json_each` / `json_tree`, the table-valued `pragma_<name>(…)`
// form). The base scan materializes it through `scan_one`'s TVF
// branch (which masks the hidden input columns), so `tvf_rows` here
// resolves the matching *visible* column model. A TVF row has no
// rowid, so defer if one is referenced.
if select_mentions_rowid(sel) {
return Err(Error::Unsupported(
"VDBE window: TVF source references rowid",
));
}
// Columns only — a cap of 0 avoids materialising an unbounded
// `generate_series` just to read its column metadata.
let (cinfos, _rows) = self.tvf_rows_capped(tref, &Params::default(), Some(0))?;
cinfos.into_iter().filter(|ci| !ci.hidden).collect()
} else if tref.tvf_args.is_none()
&& tref.index_hint.is_none()
&& tref.schema.is_none()
&& self.is_view(&tref.name)
&& !self
.cte_env
.borrow()
.iter()
.any(|b| b.name.eq_ignore_ascii_case(&tref.name))
{
// A view named directly as the window source. The base scan
// materializes it through `scan_one` (which runs the stored body and
// defers on a non-BINARY column), so columns and rows stay in
// lockstep; `try_view` here resolves the same per-column
// `(affinity, collation)` model the base scan exposes. A view has no
// rowid, so defer if one is referenced.
if select_mentions_rowid(sel) {
return Err(Error::Unsupported(
"VDBE window: view source references rowid",
));
}
let (cinfos, _rows) = self
.try_view(&tref.name, tref.alias.as_deref(), &Params::default())?
.ok_or(Error::Unsupported("VDBE window: view not found"))?;
cinfos
} else {
if tref.tvf_args.is_some()
|| tref.index_hint.is_some()
|| tref.schema.is_some()
|| self.is_bare_tvf(tref)
|| self.is_view(&tref.name)
|| self.is_virtual_table(&tref.name)
|| self
.cte_env
.borrow()
.iter()
.any(|b| b.name.eq_ignore_ascii_case(&tref.name))
{
return Err(Error::Unsupported("VDBE window: non-plain source"));
}
rowid_source = true;
self.table_meta(&tref.name, tref.alias.as_deref())?.columns
}
};
let ncols = columns.len();
// Scan the base source with `WHERE` applied; for a single table append each
// row's rowid as a trailing column. Everything else (`GROUP BY`, `HAVING`,
// `ORDER BY`, `LIMIT`, `DISTINCT`, the windows) is stripped — the shared
// `finish_from_rows` tail re-runs it over the scanned rows.
let mut base = sel.clone();
base.distinct = false;
base.group_by = Vec::new();
base.having = None;
base.window_defs = Vec::new();
base.order_by = Vec::new();
base.limit = None;
base.offset = None;
base.columns = if rowid_source {
alloc::vec![
ResultColumn::Wildcard,
ResultColumn::Expr {
expr: Expr::Column {
schema: None,
table: None,
column: "rowid".into(),
quoted: false,
span: Span::none(),
},
alias: Some("__winrowid__".into()),
source: None,
},
]
} else {
alloc::vec![ResultColumn::Wildcard]
};
let scanned = self.run_select_vdbe(&base)?;
let mut rows: Vec<InputRow> = Vec::with_capacity(scanned.rows.len());
for mut values in scanned.rows {
let rowid = if !rowid_source {
if values.len() != ncols {
return Err(Error::Unsupported("VDBE window: column count mismatch"));
}
None
} else {
// [base columns…, rowid]: split the trailing rowid back off.
if values.len() != ncols + 1 {
return Err(Error::Unsupported("VDBE window: column count mismatch"));
}
match values.pop() {
Some(Value::Integer(id)) => Some(id),
_ => None,
}
};
rows.push(InputRow { values, rowid });
}
self.finish_from_rows(sel, columns, rows, &Params::default())
}
/// The column metadata visible to `sel`'s expressions (its `FROM` sources'
/// columns), derived *statically* — no rows are read — for the ambiguity
/// check. Returns `None` ("unknown") for anything but plain main-database
/// tables joined by comma/`ON` (a view, CTE, derived table, table-valued
/// function, schema-qualified name, or `NATURAL`/`USING` coalescing), so the
/// caller never guesses a binding it cannot prove. A `NATURAL`/`USING` join is
/// treated as unknown rather than approximated, since its coalescing changes
/// the column set.
fn static_scope_columns(&self, sel: &Select) -> Option<Vec<ColumnInfo>> {
let Some(from) = &sel.from else {
return Some(Vec::new());
};
if from.joins.iter().any(|j| j.natural || !j.using.is_empty()) {
return None;
}
let mut cols = Vec::new();
for tref in core::iter::once(&from.first).chain(from.joins.iter().map(|j| &j.table)) {
// Only a plain, unqualified, main-database table is statically known.
if tref.subquery.is_some()
|| tref.tvf_args.is_some()
|| tref.schema.is_some()
|| self.is_bare_tvf(tref)
|| self.is_view(&tref.name)
|| self
.cte_env
.borrow()
.iter()
.any(|b| b.name.eq_ignore_ascii_case(&tref.name))
{
return None;
}
let meta = self.table_meta(&tref.name, tref.alias.as_deref()).ok()?;
cols.extend(meta.columns);
}
Some(cols)
}
/// Resolve a join window source's full `SELECT *` column list when one or more
/// sources is a view or table-valued function — the cases `static_scope_columns`
/// reports as unknown (it reads no rows). Each source's columns are resolved
/// exactly as the base scan's `scan_one` exposes them: a plain main-database
/// table via `table_meta`, a view via `try_view`, a visible-masked TVF via
/// `tvf_rows`. A `NATURAL`/`USING` join (coalesced columns), or a derived / CTE /
/// virtual / schema-qualified source, defers — the base scan's column order there
/// can't be proven to match. (A non-BINARY view column is caught by the base scan
/// itself, which refuses it, so the whole window query falls back.)
fn window_join_source_columns(&self, sel: &Select) -> Result<Vec<ColumnInfo>> {
let from = sel
.from
.as_ref()
.ok_or(Error::Unsupported("VDBE window: no FROM"))?;
// Accumulate the combined column model left-to-right, coalescing each
// `NATURAL`/`USING` join's shared columns exactly as the base scan
// (`run_select_vdbe`'s outer-join path) does: the right duplicate is dropped
// and the coalesced column keeps the left source's metadata. A plain join
// simply concatenates.
let mut cols = self.window_join_one_source(&from.first)?;
for j in &from.joins {
let src = self.window_join_one_source(&j.table)?;
let lw = cols.len();
// Coalesce pairs `(left index, right local index)`: NATURAL matches every
// shared column name; USING matches the named columns (which must be
// present in both sides).
let pairs: Vec<(usize, usize)> = if j.natural {
src.iter()
.enumerate()
.filter_map(|(rl, rc)| {
cols.iter()
.position(|lc| lc.name.eq_ignore_ascii_case(&rc.name))
.map(|li| (li, rl))
})
.collect()
} else if !j.using.is_empty() {
let mut v = Vec::with_capacity(j.using.len());
for name in &j.using {
let li = cols.iter().position(|c| c.name.eq_ignore_ascii_case(name));
let rl = src.iter().position(|c| c.name.eq_ignore_ascii_case(name));
match (li, rl) {
(Some(li), Some(rl)) => v.push((li, rl)),
// A USING column absent from a side is an error the
// tree-walker reports; defer so it surfaces there.
_ => {
return Err(Error::Unsupported(
"VDBE window: USING column not in both sources",
));
}
}
}
v
} else {
Vec::new()
};
cols.extend(src);
// Drop the right duplicates (highest index first) so the surviving
// coalesced column appears once, in its left position.
if !pairs.is_empty() {
let mut drop: Vec<usize> = pairs.iter().map(|&(_, rl)| lw + rl).collect();
drop.sort_unstable();
drop.dedup();
for &d in drop.iter().rev() {
cols.remove(d);
}
}
}
Ok(cols)
}
/// Resolve one join-source `TableRef`'s columns exactly as the window base scan
/// exposes them: a plain table via `table_meta`, a view via `try_view`, a
/// visible-masked TVF via `tvf_rows`, and a derived subquery via
/// `window_source_columns`. A CTE-shadowing name, a virtual table, or a
/// schema-qualified / index-hinted source defers.
fn window_join_one_source(&self, tref: &sql::ast::TableRef) -> Result<Vec<ColumnInfo>> {
if let Some(sub) = &tref.subquery {
// A derived subquery join source: resolve its output columns through the
// same `(affinity, collation)` model the single-source derived window
// branch uses. A body that is itself a join / non-constant compound /
// view / TVF, or a non-BINARY derived column, makes the base scan decline
// and the whole window query defer.
if tref.tvf_args.is_some() || tref.schema.is_some() || tref.index_hint.is_some() {
return Err(Error::Unsupported("VDBE window: non-plain join source"));
}
let qualifier = tref.alias.clone().unwrap_or_default();
return self.window_source_columns(sub, &qualifier, None);
}
if tref.schema.is_some() || tref.index_hint.is_some() {
return Err(Error::Unsupported("VDBE window: non-plain join source"));
}
let shadows_cte = self
.cte_env
.borrow()
.iter()
.any(|b| b.name.eq_ignore_ascii_case(&tref.name));
if tref.tvf_args.is_some() || self.is_bare_tvf(tref) {
// Columns only — cap at 0 (see the sibling call in the row path).
let (cinfos, _rows) = self.tvf_rows_capped(tref, &Params::default(), Some(0))?;
Ok(cinfos.into_iter().filter(|ci| !ci.hidden).collect())
} else if !shadows_cte && self.is_view(&tref.name) {
let (cinfos, _rows) = self
.try_view(&tref.name, tref.alias.as_deref(), &Params::default())?
.ok_or(Error::Unsupported("VDBE window: view not found"))?;
Ok(cinfos)
} else if shadows_cte || self.is_virtual_table(&tref.name) {
Err(Error::Unsupported("VDBE window: non-plain join source"))
} else {
let meta = self.table_meta(&tref.name, tref.alias.as_deref())?;
Ok(meta.columns)
}
}
/// Static, scope-aware ambiguity check for nested subqueries, run once at the
/// top level (`outer_scope` empty). SQLite rejects an ambiguous column
/// reference at prepare time — including one inside a subquery that binds to
/// an enclosing query's `FROM` — regardless of whether the subquery ever
/// executes. `top` is this query's own (known) column list. Each nested
/// subquery is resolved against [its own scope, … enclosing scopes]; an
/// undeterminable scope simply stops resolution for a reference (see
/// [`first_ambiguous_in_scopes`]), so the check never reports a false positive.
/// Re-create SQLite's eager "no such column" check for the cases that can be
/// resolved here without any chance of a false positive: a bare or qualified
/// column reference in the projection or `WHERE` of a top-level, window-free
/// block whose every `FROM` source is a plain (non-virtual, non-subquery,
/// non-TVF) base table or view, joined only by `INNER`/`LEFT`/… `ON` (no
/// `NATURAL`/`USING` column coalescing). A reference matching no source column
/// is the error SQLite reports at prepare time; the tree-walker would only hit
/// it once a row reaches evaluation, so an empty or fully-filtered result
/// silently swallowed it.
///
/// Deliberately narrow. It inspects only this query's own projection/`WHERE`
/// (never a nested subquery body, which may bind a name to *this* query as its
/// outer scope), skips `GROUP BY`/`HAVING`/`ORDER BY` (which may name an output
/// alias or a positional ordinal), and never flags a rowid alias or a date/time
/// keyword pseudo-column. So it only ever rejects a name that per-row
/// evaluation would have rejected too — it just does so eagerly, like SQLite.
fn validate_columns_exist(&self, sel: &Select, columns: &[ColumnInfo]) -> Result<()> {
if window::has_window(sel) {
return Ok(());
}
let Some(from) = &sel.from else {
return self.validate_no_from_columns(sel);
};
// Every FROM source must be a plain, non-virtual base table/view, and every
// join an ordinary `ON`/cross join (a `NATURAL`/`USING` join coalesces
// columns, so `columns` would not list a name the body legitimately uses).
let mut srcs = alloc::vec![&from.first];
for j in &from.joins {
if j.natural || !j.using.is_empty() {
return Ok(());
}
srcs.push(&j.table);
}
let mut labels: Vec<&str> = Vec::new();
for s in &srcs {
let plain = s.subquery.is_none() && s.tvf_args.is_none() && !s.name.is_empty();
if !plain || self.is_virtual_table(&s.name) {
return Ok(());
}
labels.push(s.alias.as_deref().unwrap_or(&s.name));
}
// A `table.*` whose qualifier names no FROM source is `no such table: X`
// in SQLite, statically — a star qualifier is never an alias or ordinal.
for c in &sel.columns {
if let ResultColumn::TableWildcard(q) = c
&& !labels.iter().any(|l| l.eq_ignore_ascii_case(q))
{
return Err(Error::Error(alloc::format!("no such table: {q}")));
}
}
let mut targets: Vec<&Expr> = Vec::new();
for c in &sel.columns {
if let ResultColumn::Expr { expr, .. } = c {
targets.push(expr);
}
}
if let Some(w) = &sel.where_clause {
targets.push(w);
}
// An `ON` predicate can only reference the FROM sources' base columns —
// never an output alias or ordinal — so it is as safe to check as `WHERE`.
for j in &from.joins {
if let Some(on) = &j.on {
targets.push(on);
}
}
// `GROUP BY`/`HAVING`/`ORDER BY` may name an output alias (resolved
// ahead of a base column) or a positional ordinal — neither of which is
// a base column in `columns`. A *qualified* ref (`t.col`) is never an
// alias or an ordinal, so it must resolve to a base column. A *bare* ref
// is a base column unless it matches an output alias (an ordinal is an
// integer literal, never a column ref, so it is skipped by the walk); so
// collect the explicit aliases and exempt a bare name that matches one.
let aliases: Vec<&str> = sel
.columns
.iter()
.filter_map(|c| match c {
ResultColumn::Expr { alias: Some(a), .. } => Some(a.as_str()),
_ => None,
})
.collect();
let mut clause_refs: Vec<&Expr> = Vec::new();
for g in &sel.group_by {
clause_refs.push(g);
}
if let Some(h) = &sel.having {
clause_refs.push(h);
}
for o in &sel.order_by {
clause_refs.push(&o.expr);
}
// The database each source resolves to (`main`/`temp`/an attached name),
// aligned with `labels`. A three-part `schema.table.column` reference must
// name this database for the matched source; SQLite validates the
// qualifier even when the named database exists elsewhere.
let src_dbs: Vec<alloc::string::String> = srcs
.iter()
.map(|s| match s.schema.as_deref() {
Some(q) => q.to_ascii_lowercase(),
None => match self.unqualified_db(&s.name) {
DbRef::Temp => alloc::string::String::from("temp"),
_ => alloc::string::String::from("main"),
},
})
.collect();
// Resolve one reference against `columns`; `None` if it resolves (or is a
// pseudo-column), else the `no such column` message. Borrows only
// `columns`/`labels`/`src_dbs`, so the accumulator below can read
// `missing` between walks.
let column_missing = |schema: Option<&str>,
table: Option<&str>,
column: &str,
quoted: bool|
-> Option<Error> {
// A three-part qualifier must match *some* source whose table name AND
// database both agree — not merely the first source sharing the table
// name (two attached databases can each hold a table `t`, so `m2.t.c`
// must find the `m2` source, not stop at `m1`). Checked before the
// pseudo-column shortcut, since `bad.t.rowid` is just as wrong as
// `bad.t.col`.
if let Some(sch) = schema {
let t = table.unwrap_or_default();
let ok = labels
.iter()
.zip(&src_dbs)
.any(|(l, db)| l.eq_ignore_ascii_case(t) && db.eq_ignore_ascii_case(sch));
if !ok {
return Some(eval::no_such_column(schema, table, column, quoted));
}
}
// rowid aliases and date/time keyword pseudo-columns resolve without
// appearing in the table's declared column list.
if matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "oid" | "_rowid_" | "current_date" | "current_time" | "current_timestamp"
) {
return None;
}
let n = columns
.iter()
.filter(|c| {
c.name.eq_ignore_ascii_case(column)
&& table.is_none_or(|t| c.table.eq_ignore_ascii_case(t))
})
.count();
(n == 0).then(|| eval::no_such_column(schema, table, column, quoted))
};
let mut missing: Option<Error> = None;
for e in targets {
if missing.is_some() {
break;
}
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if missing.is_none() {
missing = column_missing(schema, table, column, quoted);
}
});
}
for e in clause_refs {
if missing.is_some() {
break;
}
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if missing.is_some() {
return;
}
// A qualified ref is always a base column. A bare name is too,
// unless it matches an output alias (which takes precedence).
if table.is_some() {
missing = column_missing(schema, table, column, quoted);
} else if !aliases.iter().any(|a| a.eq_ignore_ascii_case(column)) {
missing = column_missing(None, None, column, quoted);
}
});
}
match missing {
Some(e) => Err(e),
None => Ok(()),
}
}
/// The FROM-less arm of [`Self::validate_columns_exist`]: a `SELECT` with no
/// `FROM` has no columns in scope, so *any* column reference is `no such
/// column` — which SQLite reports at prepare time even when the reference sits
/// in a short-circuited branch (e.g. the never-taken arm of `IFNULL(1, zzz)`)
/// that the lazy per-row evaluator would skip. Runs only at the outermost
/// query (the caller's `outer_scope.is_empty()` gate), so a *correlated*
/// FROM-less subquery — which legitimately reads an enclosing FROM — is never
/// reached here. Output aliases remain referenceable from `GROUP BY` / `HAVING`
/// / `ORDER BY`; the `current_date`/`current_time`/`current_timestamp` keyword
/// pseudo-values resolve without a table (a `rowid` alias does not).
fn validate_no_from_columns(&self, sel: &Select) -> Result<()> {
// A `table.*` has no source to name.
for c in &sel.columns {
if let ResultColumn::TableWildcard(q) = c {
return Err(Error::Error(alloc::format!("no such table: {q}")));
}
}
let is_datetime_kw = |column: &str| {
matches!(
column.to_ascii_lowercase().as_str(),
"current_date" | "current_time" | "current_timestamp"
)
};
let aliases: Vec<&str> = sel
.columns
.iter()
.filter_map(|c| match c {
ResultColumn::Expr { alias: Some(a), .. } => Some(a.as_str()),
_ => None,
})
.collect();
let mut missing: Option<Error> = None;
// Projection and WHERE: an output alias is *not* visible here (SQLite
// rejects `SELECT 1 AS x, x`), so every column reference is missing.
let mut targets: Vec<&Expr> = Vec::new();
for c in &sel.columns {
if let ResultColumn::Expr { expr, .. } = c {
targets.push(expr);
}
}
if let Some(w) = &sel.where_clause {
targets.push(w);
}
for e in targets {
if missing.is_some() {
break;
}
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if missing.is_none() && !is_datetime_kw(column) {
missing = Some(eval::no_such_column(schema, table, column, quoted));
}
});
}
// GROUP BY / HAVING / ORDER BY may name an output alias.
let mut clause_refs: Vec<&Expr> = Vec::new();
for g in &sel.group_by {
clause_refs.push(g);
}
if let Some(h) = &sel.having {
clause_refs.push(h);
}
for o in &sel.order_by {
clause_refs.push(&o.expr);
}
for e in clause_refs {
if missing.is_some() {
break;
}
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if missing.is_some() || is_datetime_kw(column) {
return;
}
if table.is_some() || !aliases.iter().any(|a| a.eq_ignore_ascii_case(column)) {
missing = Some(eval::no_such_column(schema, table, column, quoted));
}
});
}
match missing {
Some(e) => Err(e),
None => Ok(()),
}
}
/// Eager `no such column` check for the `PARTITION BY` / `ORDER BY` terms of a
/// window `OVER` clause (and named `WINDOW …` definition).
/// [`Self::validate_columns_exist`] bails on any window query, so its column
/// references were resolved only lazily and missed over an empty/filtered
/// input. A window partition/order term binds strictly to a base column of the
/// `FROM` (never an output alias — `PARTITION BY <alias>` is `no such column`
/// in SQLite), so it resolves against the scanned source `columns` exactly like
/// the base-column targets. Conservatively limited to plain base-table / view
/// sources (a subquery / TVF / vtab / `NATURAL`/`USING` source bails, never a
/// false positive).
fn validate_window_over_columns(&self, sel: &Select) -> Result<()> {
if !window::has_window(sel) {
return Ok(());
}
let Some(from) = &sel.from else { return Ok(()) };
let mut srcs = alloc::vec![&from.first];
for j in &from.joins {
if j.natural || !j.using.is_empty() {
return Ok(());
}
srcs.push(&j.table);
}
// Resolve the base column set from schema metadata (no row scan, so the
// check is cheap even when the VDBE window path calls it before executing).
// Any source that can't be resolved from metadata alone — a subquery, TVF,
// schema-qualified, or virtual table — bails the whole check (never a false
// positive).
let mut columns: Vec<ColumnInfo> = Vec::new();
for s in &srcs {
if s.subquery.is_some()
|| s.tvf_args.is_some()
|| s.schema.is_some()
|| s.name.is_empty()
{
return Ok(());
}
let Some(cols) = self.source_columns_of(s) else {
return Ok(());
};
let label = s.alias.clone().unwrap_or_else(|| s.name.clone());
for (name, _) in cols {
columns.push(ColumnInfo {
name,
table: label.clone(),
schema: None,
affinity: eval::Affinity::Blob,
collation: crate::value::Collation::Binary,
hidden: false,
});
}
}
let columns = &columns[..];
// Every window spec in play: the `WINDOW`-clause definitions plus each
// window function's inline `OVER (…)` spec found in the projection or the
// top-level `ORDER BY`. `window::visit` stops at nested subqueries (they
// validate their own specs), so only this query level is gathered.
let mut specs: Vec<WindowSpec> = sel.window_defs.iter().map(|(_, s)| s.clone()).collect();
let gather = |e: &Expr, specs: &mut Vec<WindowSpec>| {
window::visit(e, &mut |m| {
if let Expr::Function {
over: Some(spec), ..
} = m
{
specs.push(spec.clone());
}
});
};
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
gather(expr, &mut specs);
}
}
for t in &sel.order_by {
gather(&t.expr, &mut specs);
}
// Base-column targets with NO output-alias exemption: the projection exprs
// (`walk_shallow_columns` visits a window function's arguments and `FILTER`
// predicate), `WHERE`, each join `ON`, and every window spec's
// `PARTITION BY` / `ORDER BY` (which never bind to an output alias).
let mut strict: Vec<&Expr> = Vec::new();
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
strict.push(expr);
}
}
if let Some(w) = &sel.where_clause {
strict.push(w);
}
for j in &from.joins {
if let Some(on) = &j.on {
strict.push(on);
}
}
for spec in &specs {
windowspec_parts(spec, &mut strict);
}
// `GROUP BY` / `HAVING` / the query's top-level `ORDER BY` may name an
// output alias with a bare identifier, which is not a base column.
let aliases: Vec<&str> = sel
.columns
.iter()
.filter_map(|c| match c {
ResultColumn::Expr { alias: Some(a), .. } => Some(a.as_str()),
_ => None,
})
.collect();
let mut clause_refs: Vec<&Expr> = Vec::new();
for g in &sel.group_by {
clause_refs.push(g);
}
if let Some(h) = &sel.having {
clause_refs.push(h);
}
for t in &sel.order_by {
clause_refs.push(&t.expr);
}
let mut missing: Option<Error> = None;
for e in strict {
if missing.is_some() {
break;
}
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if missing.is_none() && !column_resolves_scoped(columns, schema, table, column) {
missing = Some(eval::no_such_column(schema, table, column, quoted));
}
});
}
for e in clause_refs {
if missing.is_some() {
break;
}
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if missing.is_some() {
return;
}
if table.is_none() && aliases.iter().any(|a| a.eq_ignore_ascii_case(column)) {
return;
}
if !column_resolves_scoped(columns, schema, table, column) {
missing = Some(eval::no_such_column(schema, table, column, quoted));
}
});
}
match missing {
Some(e) => Err(e),
None => Ok(()),
}
}
/// Eager `no such column` check for a query whose *sole* `FROM` source is a
/// derived table (a parenthesized subquery), the counterpart of
/// [`Self::validate_columns_exist`] for a case that one bails on. SQLite
/// resolves references at prepare time, so a reference to a column the derived
/// table does not expose errors even when it yields no rows; the tree-walker
/// resolves per row and would otherwise miss that error over an empty (or
/// fully-filtered) derived table. `columns` is the derived table's resolved
/// output list. Unlike a base table, a subquery has no `rowid`, so a plain
/// membership test over `columns` is exact — there is no pseudo-column
/// shortcut. Only the outermost query calls this (the caller guards on an
/// empty `outer_scope`), so every top-level reference must bind here; there is
/// no enclosing `FROM`. A *schema-qualified* reference is left for per-row
/// evaluation (conservative — this never raises a false positive). The derived
/// body validates its own references when it runs, so this does not descend
/// into it (`walk_shallow_columns` stops at nested subqueries).
fn validate_derived_columns(&self, sel: &Select, columns: &[ColumnInfo]) -> Result<()> {
let Some(from) = &sel.from else { return Ok(()) };
// One source, no joins, no window (a window query resolves differently).
if !from.joins.is_empty() || window::has_window(sel) {
return Ok(());
}
let s = &from.first;
// The sole source must be a derived table: a subquery, not a table-valued
// function or a base table/view.
if s.subquery.is_none() || s.tvf_args.is_some() {
return Ok(());
}
let alias = s.alias.as_deref();
// A `q.*` / `q.col` qualifier may name only the derived table's alias; with
// no alias, no qualifier resolves.
let qual_ok = |q: &str| alias.is_some_and(|a| a.eq_ignore_ascii_case(q));
// `tbl.*` whose qualifier names no source is `no such table: X`, statically
// (a star qualifier is never an alias-of-an-alias or an ordinal).
for c in &sel.columns {
if let ResultColumn::TableWildcard(q) = c
&& !qual_ok(q)
{
return Err(Error::Error(alloc::format!("no such table: {q}")));
}
}
// Whether a reference resolves to a derived-table column. A schema-qualified
// ref is conservatively treated as resolving (left to per-row evaluation).
let resolves = |schema: Option<&str>, table: Option<&str>, column: &str| -> bool {
if schema.is_some() {
return true;
}
if let Some(t) = table
&& !qual_ok(t)
{
return false;
}
columns.iter().any(|c| c.name.eq_ignore_ascii_case(column))
};
// Result-set expressions and `WHERE` can only name a derived column (a
// result expression cannot reference a sibling output alias).
let mut targets: Vec<&Expr> = Vec::new();
for c in &sel.columns {
if let ResultColumn::Expr { expr, .. } = c {
targets.push(expr);
}
}
if let Some(w) = &sel.where_clause {
targets.push(w);
}
// `GROUP BY` / `HAVING` / `ORDER BY` may instead name an output alias
// (resolved ahead of a base column); exempt a bare name that matches one.
let aliases: Vec<&str> = sel
.columns
.iter()
.filter_map(|c| match c {
ResultColumn::Expr { alias: Some(a), .. } => Some(a.as_str()),
_ => None,
})
.collect();
let mut clause_refs: Vec<&Expr> = Vec::new();
for g in &sel.group_by {
clause_refs.push(g);
}
if let Some(h) = &sel.having {
clause_refs.push(h);
}
for o in &sel.order_by {
clause_refs.push(&o.expr);
}
let mut missing: Option<Error> = None;
for e in targets {
if missing.is_some() {
break;
}
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if missing.is_none() && !resolves(schema, table, column) {
missing = Some(eval::no_such_column(schema, table, column, quoted));
}
});
}
for e in clause_refs {
if missing.is_some() {
break;
}
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if missing.is_some() {
return;
}
if table.is_some() {
if !resolves(schema, table, column) {
missing = Some(eval::no_such_column(schema, table, column, quoted));
}
} else if !aliases.iter().any(|a| a.eq_ignore_ascii_case(column))
&& !resolves(None, None, column)
{
missing = Some(eval::no_such_column(None, None, column, quoted));
}
});
}
match missing {
Some(e) => Err(e),
None => Ok(()),
}
}
/// Eager `no such column` check for a window-free top-level query whose `FROM`
/// is a **join that [`Self::validate_columns_exist`] declines** — either it
/// includes a derived (subquery) source (that validator bails on a non-plain
/// source) or it is a `NATURAL`/`USING` join (which coalesces names, so the flat
/// `columns` scope that validator uses would not list a qualified `u.g` of a
/// coalesced pair). Without this, a reference to a column no source exposes was
/// silently accepted over an empty / fully-filtered result. Each source's columns
/// are resolved exactly as the scan exposes them
/// ([`Self::window_join_one_source`]); a source that cannot be resolved cleanly
/// (a virtual table, a non-constant TVF, a non-BINARY derived column, …) bails
/// the whole check conservatively, so it never raises a false positive. A bare
/// name resolves if **any** source exposes it; a qualified `u.g` checks source
/// `u` specifically — so both `t.g` and `u.g` of a `NATURAL`/`USING`-coalesced
/// pair resolve, matching SQLite. Only a *base table* carries a `rowid`, so a
/// qualified `x.rowid` over a derived `x` is `no such column` while a bare `rowid`
/// resolves. A genuinely *ambiguous* bare name (shared but not coalesced) is left
/// to per-row evaluation — this check only catches missing names, never ambiguity.
fn validate_join_derived_columns(&self, sel: &Select) -> Result<()> {
let Some(from) = &sel.from else { return Ok(()) };
if from.joins.is_empty() || window::has_window(sel) {
return Ok(());
}
let mut srcs = alloc::vec![&from.first];
for j in &from.joins {
srcs.push(&j.table);
}
// Take over only for the shapes `validate_columns_exist` bails on: a derived
// (subquery) source, or a `NATURAL`/`USING` coalesced join. An all-base/view
// `ON`/cross join is that validator's responsibility.
let has_coalesce = from.joins.iter().any(|j| j.natural || !j.using.is_empty());
let has_derived = srcs.iter().any(|s| s.subquery.is_some());
if !has_coalesce && !has_derived {
return Ok(());
}
struct Src {
label: alloc::string::String,
names: Vec<alloc::string::String>,
has_rowid: bool,
}
let mut scope: Vec<Src> = Vec::with_capacity(srcs.len());
for s in &srcs {
if s.schema.is_some() || s.index_hint.is_some() {
return Ok(());
}
let cols = match self.window_join_one_source(s) {
Ok(c) => c,
Err(_) => return Ok(()),
};
let has_rowid = s.subquery.is_none()
&& s.tvf_args.is_none()
&& !self.is_bare_tvf(s)
&& !self.is_view(&s.name)
&& !self.is_virtual_table(&s.name);
scope.push(Src {
label: s.alias.as_deref().unwrap_or(&s.name).into(),
names: cols.into_iter().map(|c| c.name).collect(),
has_rowid,
});
}
// A `tbl.*` whose qualifier names no source is `no such table: X`.
for c in &sel.columns {
if let ResultColumn::TableWildcard(q) = c
&& !scope.iter().any(|s| s.label.eq_ignore_ascii_case(q))
{
return Err(Error::Error(alloc::format!("no such table: {q}")));
}
}
let is_rowid_kw =
|c: &str| matches!(c.to_ascii_lowercase().as_str(), "rowid" | "oid" | "_rowid_");
let is_dt_kw = |c: &str| {
matches!(
c.to_ascii_lowercase().as_str(),
"current_date" | "current_time" | "current_timestamp"
)
};
// `None` if the reference resolves, else its `no such column` message.
let resolves = |schema: Option<&str>, table: Option<&str>, column: &str| -> bool {
// A three-part qualifier is left to per-row evaluation (conservative).
if schema.is_some() || is_dt_kw(column) {
return true;
}
if let Some(t) = table {
let Some(src) = scope.iter().find(|s| s.label.eq_ignore_ascii_case(t)) else {
return false;
};
if is_rowid_kw(column) {
return src.has_rowid;
}
return src.names.iter().any(|n| n.eq_ignore_ascii_case(column));
}
// A bare `rowid` binds to any base-table source (conservatively resolved).
if is_rowid_kw(column) {
return true;
}
scope
.iter()
.any(|s| s.names.iter().any(|n| n.eq_ignore_ascii_case(column)))
};
// Result-set / `WHERE` / `ON` expressions can only name a source column.
let mut targets: Vec<&Expr> = Vec::new();
for c in &sel.columns {
if let ResultColumn::Expr { expr, .. } = c {
targets.push(expr);
}
}
if let Some(w) = &sel.where_clause {
targets.push(w);
}
for j in &from.joins {
if let Some(on) = &j.on {
targets.push(on);
}
}
// `GROUP BY` / `HAVING` / `ORDER BY` may name an output alias.
let aliases: Vec<&str> = sel
.columns
.iter()
.filter_map(|c| match c {
ResultColumn::Expr { alias: Some(a), .. } => Some(a.as_str()),
_ => None,
})
.collect();
let mut clause_refs: Vec<&Expr> = Vec::new();
for g in &sel.group_by {
clause_refs.push(g);
}
if let Some(h) = &sel.having {
clause_refs.push(h);
}
for o in &sel.order_by {
clause_refs.push(&o.expr);
}
let mut missing: Option<Error> = None;
for e in targets {
if missing.is_some() {
break;
}
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if missing.is_none() && !resolves(schema, table, column) {
missing = Some(eval::no_such_column(schema, table, column, quoted));
}
});
}
for e in clause_refs {
if missing.is_some() {
break;
}
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if missing.is_some() {
return;
}
if table.is_some() {
if !resolves(schema, table, column) {
missing = Some(eval::no_such_column(schema, table, column, quoted));
}
} else if !aliases.iter().any(|a| a.eq_ignore_ascii_case(column))
&& !resolves(None, None, column)
{
missing = Some(eval::no_such_column(None, None, column, quoted));
}
});
}
match missing {
Some(e) => Err(e),
None => Ok(()),
}
}
/// Eager "no such column" check for a `DELETE`/`UPDATE` `WHERE` predicate,
/// `SET`-value and `RETURNING` expressions, the DML counterpart of
/// [`Self::validate_columns_exist`]. SQLite resolves these at prepare time, so a
/// bogus column errors even over an empty table; the tree-walker resolved them
/// per row, so a statement that matched no row silently accepted the bad name. A
/// `DELETE`/`UPDATE` target takes no alias and (for the cases the caller admits)
/// has no `FROM`, so every reference resolves to `table` — a bare name must be
/// one of its columns, and a qualified ref is judged only when its qualifier *is*
/// the target table (an `OLD`/`NEW`/other-source qualifier is left alone).
///
/// A three-part `schema.table.column` qualifier is also validated against
/// `target_db` (the database the target resolves to). In `WHERE`/`SET` a correct
/// qualifier (`main.t.a` for a `main` target) resolves like the bare column; a
/// mismatch is `no such column: schema.table.column`. In `RETURNING`, SQLite
/// rejects *any* schema-qualified reference — even a correct one — so the
/// `returning` exprs are checked with `allow_schema = false`. Nested-subquery
/// bodies are walked shallowly (not entered), so this only rejects what per-row
/// evaluation would have rejected too.
fn validate_dml_refs(
&self,
table: &str,
target_db: &str,
columns: &[ColumnInfo],
exprs: &[&Expr],
returning: &[&Expr],
) -> Result<()> {
let mut missing: Option<Error> = None;
let check = |e: &Expr, allow_schema: bool, missing: &mut Option<Error>| {
walk_shallow_columns(e, &mut |schema, tbl, col, quoted| {
if missing.is_some() {
return;
}
// A qualified ref is only ours to judge when it names the target; a
// qualifier naming another `FROM` source / `OLD` / `NEW` is resolved
// elsewhere.
if let Some(q) = tbl
&& !q.eq_ignore_ascii_case(table)
{
return;
}
// The database qualifier is checked before the rowid/pseudo-column
// shortcut (`bad.t.rowid` is just as wrong as `bad.t.col`): in
// `WHERE`/`SET` it must name the target's database; in `RETURNING`
// it is never allowed.
if let Some(sch) = schema
&& !(allow_schema && sch.eq_ignore_ascii_case(target_db))
{
*missing = Some(eval::no_such_column(schema, tbl, col, quoted));
return;
}
if matches!(
col.to_ascii_lowercase().as_str(),
"rowid"
| "oid"
| "_rowid_"
| "current_date"
| "current_time"
| "current_timestamp"
) {
return;
}
if !columns.iter().any(|c| c.name.eq_ignore_ascii_case(col)) {
*missing = Some(eval::no_such_column(schema, tbl, col, quoted));
}
});
};
for e in exprs {
if missing.is_some() {
break;
}
check(e, true, &mut missing);
}
for e in returning {
if missing.is_some() {
break;
}
check(e, false, &mut missing);
}
if let Some(e) = missing {
return Err(e);
}
// An aggregate or window function in an UPDATE/DELETE WHERE or an UPDATE
// assignment value is a misuse (these statements are never aggregate
// queries, and have no result-column/ORDER BY context where a window is
// valid). SQLite rejects it at prepare time; graphite otherwise evaluated
// it lazily and so silently accepted it over an empty/filtered table.
let is_agg = |name: &str, n: usize, star: bool| {
func::is_aggregate_call(name, n, star)
|| self.aggregates.contains_key(&name.to_ascii_lowercase())
};
for e in exprs {
reject_misused_window(e)?;
reject_misused_aggregate(e, false)?;
reject_filter_on_non_aggregate(e, &is_agg)?;
// An unknown or wrong-arity *scalar* call in a SET value or WHERE
// predicate is a prepare-time error in SQLite; graphite otherwise
// resolved it lazily and so silently accepted it over an empty or
// fully-filtered table (no row ever evaluates the call). Runs after
// the aggregate/window misuse checks so a misused aggregate keeps its
// own wording — the existence pass only fires when nothing else did.
self.reject_unresolved_functions(e)?;
}
// A `RETURNING` clause projects one row per modified row, so it is never an
// aggregate query and offers no window context either. SQLite rejects an
// aggregate or window function here (`misuse of aggregate function …()` /
// `misuse of window function …()`); a window-only builtin called without
// `OVER` is the same misuse. (INSERT … RETURNING is validated on a separate
// path and, like SQLite, is not subject to this.)
for e in returning {
// Unlike a SET/WHERE expression, a `RETURNING`/SELECT-position
// aggregate passes name resolution and is only flagged as a misuse
// afterwards, so SQLite resolves an unknown/wrong-arity scalar across
// the whole expression *first*: `RETURNING nope(count(*))` is `no such
// function: nope`, while `RETURNING abs(count(*))` — outer name known —
// is `misuse of aggregate function count()`. (Column existence was
// already checked above, so `RETURNING nope(zzz)` → `no such column`.)
self.reject_unresolved_functions(e)?;
reject_misused_window(e)?;
reject_window_without_over(e)?;
reject_misused_aggregate(e, false)?;
}
// An `IN (SELECT …)` whose width disagrees with the LHS is a prepare-time
// error, the same as on the SELECT path. The target table's `columns` are
// the outer scope a (correlated) subquery body binds to; column existence
// was resolved above, so a missing column still wins.
for e in exprs {
self.walk_in_subquery_arity(e, columns)?;
self.walk_scalar_subquery_arity(e, columns, false)?;
self.walk_row_value_misuse(e, columns)?;
}
for e in returning {
self.walk_in_subquery_arity(e, columns)?;
self.walk_scalar_subquery_arity(e, columns, false)?;
self.walk_row_value_misuse(e, columns)?;
}
Ok(())
}
fn validate_nested_ambiguity(&self, sel: &Select, top: &[ColumnInfo]) -> Result<()> {
let scopes = alloc::vec![Some(top.to_vec())];
self.walk_nested_ambiguity(sel, &scopes)
}
fn walk_nested_ambiguity(
&self,
sel: &Select,
scopes: &[Option<Vec<ColumnInfo>>],
) -> Result<()> {
// Gather this level's directly-nested subqueries (scalar, EXISTS, IN); each
// is recursed into below with its own scope pushed.
let mut subs: Vec<&Select> = Vec::new();
vdbe_block_exprs(sel, &mut |e| collect_subselects(e, &mut subs));
for sub in subs {
let mut child: Vec<Option<Vec<ColumnInfo>>> =
alloc::vec![self.static_scope_columns(sub)];
child.extend(scopes.iter().cloned());
if let Some(msg) = first_ambiguous_in_scopes(sub, &child) {
return Err(Error::Error(msg));
}
self.walk_nested_ambiguity(sub, &child)?;
}
Ok(())
}
/// Scan the `FROM` source into column metadata and decoded input rows.
/// Row bound for a sole-source `generate_series` scan; `Some(OFFSET+LIMIT)`
/// only when the query consumes exactly the first rows of its single source in
/// order (one unfiltered source, no aggregation / window / DISTINCT / ORDER BY
/// / compound, constant non-negative integer LIMIT + optional OFFSET). Else
/// `None` (materialise fully) — never a wrong result.
fn generate_series_scan_cap(&self, sel: &Select) -> Option<usize> {
let f = sel.from.as_ref()?;
if !f.joins.is_empty()
|| sel.where_clause.is_some()
|| !sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.distinct
|| !sel.order_by.is_empty()
|| !sel.compound.is_empty()
|| self.has_aggregate(sel)
|| window::has_window(sel)
{
return None;
}
let lit_uint = |e: &Expr| -> Option<usize> {
match e {
Expr::Literal(sql::ast::Literal::Integer(n)) if *n >= 0 => usize::try_from(*n).ok(),
_ => None,
}
};
let limit = lit_uint(sel.limit.as_ref()?)?;
let offset = match sel.offset.as_ref() {
Some(o) => lit_uint(o)?,
None => 0,
};
limit.checked_add(offset)
}
fn scan_source(
&self,
sel: &Select,
params: &Params,
) -> Result<(Vec<ColumnInfo>, Vec<InputRow>)> {
let Some(from) = &sel.from else {
// No FROM: a single empty row (e.g. `SELECT 1+1`).
return Ok((
Vec::new(),
alloc::vec![InputRow {
values: Vec::new(),
rowid: None
}],
));
};
// `INDEXED BY <name>` requires the named index to exist on the table —
// sqlite errors "no such index" otherwise, even though graphite may
// full-scan regardless of the hint. Accept an explicit index by name or an
// `sqlite_autoindex_<table>_*` implicit index (lenient on the exact number).
for tref in core::iter::once(&from.first).chain(from.joins.iter().map(|j| &j.table)) {
if let Some(IndexHint::IndexedBy(name)) = &tref.index_hint
&& self.schema.table(&tref.name).is_some()
{
let auto_prefix =
alloc::format!("sqlite_autoindex_{}_", tref.name.to_ascii_lowercase());
let known = self
.schema
.indexes_on(&tref.name)
.any(|o| o.name.eq_ignore_ascii_case(name))
|| name.to_ascii_lowercase().starts_with(&auto_prefix);
if !known {
return Err(Error::Error(alloc::format!("no such index: {name}")));
}
}
}
if from.joins.is_empty() && from.first.subquery.is_none() && from.first.tvf_args.is_none() {
// An explicit qualifier picks the database; an unqualified name may be
// shadowed by a temp table. A non-main database is read by
// materializing the table through its own backend.
// `sqlite_temp_master`/`sqlite_temp_schema` read the temp catalog
// (empty when no temp database exists).
if from.first.schema.is_none() && is_temp_schema_table(&from.first.name) {
let alias = from.first.alias.as_deref();
return match &self.temp_db {
Some(_) => self.scan_db_table(DbRef::Temp, "sqlite_master", alias),
None => Ok((
schema_table_meta(alias.unwrap_or(&from.first.name)).columns,
Vec::new(),
)),
};
}
// The eponymous read-only vtabs (`dbstat` — per-page storage stats;
// `sqlite_dbpage` — raw page bytes) exist in *every* schema, but the
// database they report is governed by their hidden `schema` column,
// which SQLite defaults to `main`. The table qualifier
// (`main.`/`temp.`/`<attached>.`) only selects which schema's table
// object is referenced — it does NOT change the reported database, so
// `aux.dbstat` and `temp.dbstat` both still report `main`. (Targeting
// another database needs a `WHERE schema='aux'` constraint — a hidden-
// column pushdown not yet implemented.) A real user table of the name
// in the *referenced* schema shadows the eponymous table.
let lname = from.first.name.to_ascii_lowercase();
if matches!(lname.as_str(), "dbstat" | "sqlite_dbpage") {
let qual_db = match from.first.schema.as_deref() {
None => DbRef::Main,
Some(s) => self.resolve_db(Some(s))?,
};
let shadowed = match qual_db {
DbRef::Main => self.schema.table(&lname).is_some(),
DbRef::Temp => self
.temp_db
.as_ref()
.is_some_and(|t| t.schema.table(&lname).is_some()),
DbRef::Attached(i) => self.attached[i].schema.table(&lname).is_some(),
};
if !shadowed {
// Always report `main` (the default `schema` column value).
let alias = from.first.alias.as_deref();
let src = self.backend.source();
return match lname.as_str() {
"dbstat" => self.scan_dbstat(&self.schema, src, alias),
_ => self.scan_dbpage(src, alias),
};
}
}
let db = match from.first.schema.as_deref() {
Some(_) => self.resolve_db_or_missing(
from.first.schema.as_deref(),
&from.first.name,
"table",
)?,
// Don't let a temp table shadow a CTE or view of the same name.
None if self.lookup_cte(&from.first.name, None).is_none()
&& !self.is_view(&from.first.name) =>
{
self.unqualified_db(&from.first.name)
}
None => DbRef::Main,
};
if db != DbRef::Main {
self.guard_qualified_temp(db, from.first.schema.as_deref(), &from.first.name)?;
let alias = from.first.alias.as_deref();
if let Some(r) = self.scan_db_view(db, &from.first.name, alias, params)? {
return Ok(r);
}
return self
.scan_db_table(db, &from.first.name, alias)
.map_err(|e| {
Self::qualify_missing(from.first.schema.as_deref(), &from.first.name, e)
});
}
}
// A table-valued function used as the sole source.
if from.joins.is_empty() && (from.first.tvf_args.is_some() || self.is_bare_tvf(&from.first))
{
// A bare eponymous TVF (`FROM pragma_table_info WHERE arg='t'`,
// `FROM json_each WHERE json='[…]'`) takes its hidden arguments from
// equality constraints on its hidden input columns. Push those into the
// call so the function is actually driven; run_core still re-applies the
// full WHERE and the echoed hidden columns satisfy it, so this is a
// superset — never wrong.
let pushed;
let source = if from.first.tvf_args.is_none() && self.is_bare_tvf(&from.first) {
pushed = Self::push_bare_tvf_args(&from.first, sel.where_clause.as_ref());
&pushed
} else {
&from.first
};
let cap = self.generate_series_scan_cap(sel);
let (columns, rows) = self.tvf_rows_capped(source, params, cap)?;
let input = rows
.into_iter()
.map(|values| InputRow {
values,
rowid: None,
})
.collect();
return Ok((columns, input));
}
// A derived-table subquery used as the sole source.
if from.joins.is_empty()
&& let Some(sub) = &from.first.subquery
{
let (columns, rows) =
self.run_subquery_source(sub, from.first.alias.as_deref(), params)?;
let input = rows
.into_iter()
.map(|values| InputRow {
values,
rowid: None,
})
.collect();
return Ok((columns, input));
}
// A `WITH` common table expression used as the sole source.
if from.joins.is_empty()
&& let Some((columns, rows)) =
self.lookup_cte(&from.first.name, from.first.alias.as_deref())
{
return Ok((columns, rows));
}
// A view as the sole source: run its SELECT in place.
if from.joins.is_empty()
&& let Some((columns, rows)) =
self.try_view(&from.first.name, from.first.alias.as_deref(), params)?
{
return Ok((columns, rows));
}
// A virtual table as the sole source: drain its module's cursor, pushing
// the query's WHERE constraints into the module (it may restrict what it
// produces; run_core still re-applies the full WHERE, so this is a
// superset — never wrong).
if from.joins.is_empty()
&& from.first.schema.is_none()
&& let Some((columns, rows)) = self.try_virtual_table(
&from.first.name,
from.first.alias.as_deref(),
Some((sel, params)),
)?
{
return Ok((columns, rows));
}
// Single-table fast path. Try an index-driven equality lookup first; the
// full WHERE is still applied by run_core, so the index only needs to
// return a superset of matching rows.
if from.joins.is_empty() {
// Fold a non-correlated scalar subquery used as a seek operand
// (`col = (SELECT …)`) to its value so the seek can use it — the same
// seek `eqp_access` renders. Only the seek *decision* uses the folded
// WHERE; `run_core` re-applies the original (superset-safe). A subquery
// that fails to fold (correlated / bare-column / erroring) is left in
// place and the query scans, exactly as before.
let seek_where;
let sel = match &sel.where_clause {
Some(w) => {
let mut changed = false;
let fw = self.fold_subquery_expr(w, &mut changed);
if changed {
let mut s = sel.clone();
s.where_clause = Some(fw);
seek_where = s;
&seek_where
} else {
sel
}
}
None => sel,
};
let mut first_meta = self
.table_meta(&from.first.name, from.first.alias.as_deref())
.map_err(|e| {
Self::qualify_missing(from.first.schema.as_deref(), &from.first.name, e)
})?;
// This fast path is only reached for a main-database base table (a
// non-main source returned earlier); stamp the `main` origin so the
// `*`-wildcard and correlated-subquery validators see the column's
// database. See `scan_db_table` / `resolve_join_source`.
let db_label = self.db_label(DbRef::Main);
for col in &mut first_meta.columns {
col.schema = Some(db_label.clone());
}
if first_meta.without_rowid {
// A leading-PK equality or range seeks the clustered b-tree; else
// scan.
if let Some(rows) = self.try_without_rowid_pk_seek(&first_meta, sel, params)? {
return Ok((first_meta.columns, rows));
}
if let Some(rows) = self.try_without_rowid_pk_in(&first_meta, sel, params)? {
return Ok((first_meta.columns, rows));
}
if let Some(rows) = self.try_without_rowid_pk_range(&first_meta, sel, params)? {
return Ok((first_meta.columns, rows));
}
if let Some(rows) =
self.try_without_rowid_index_seek(&first_meta, &from.first.name, sel, params)?
{
return Ok((first_meta.columns, rows));
}
if let Some(rows) =
self.try_without_rowid_index_range(&first_meta, &from.first.name, sel, params)?
{
return Ok((first_meta.columns, rows));
}
let input_rows = self
.scan_without_rowid(&first_meta)?
.into_iter()
.map(|values| InputRow {
values,
rowid: None,
})
.collect();
return Ok((first_meta.columns, input_rows));
}
if let Some(rows) = self.try_index_lookup(&first_meta, &from.first.name, sel, params)? {
return Ok((first_meta.columns, rows));
}
if let Some(rows) = self.try_index_range(&first_meta, &from.first.name, sel, params)? {
return Ok((first_meta.columns, rows));
}
if let Some(rows) = self.try_index_in(&first_meta, &from.first.name, sel, params)? {
return Ok((first_meta.columns, rows));
}
if let Some(rows) = self.try_index_or(&first_meta, &from.first.name, sel, params)? {
return Ok((first_meta.columns, rows));
}
if let Some(rows) =
self.try_isnotnull_covering(&first_meta, &from.first.name, sel, params)?
{
return Ok((first_meta.columns, rows));
}
// ORDER BY satisfied by a full secondary index (B0): walk that index
// in key order, so `run_core` can skip the sort. Must stay in lockstep
// with `order_satisfied_by_scan`/`eqp_access`. When the index covers
// every referenced column (B2), build rows from the index records and
// skip the table b-tree entirely; otherwise fetch each row by rowid.
if let Some(s) = self.order_index_scan(sel, params) {
let src = self.backend.source();
let encoding = src.header().text_encoding;
if s.covering {
let mut icur = IndexCursor::new(src, s.root);
let mut input_rows = Vec::new();
while let Some(payload) = icur.next()? {
let rec = decode_record(&payload, encoding)?;
// The record is `(indexed col values…, rowid)`.
let rowid = match rec.get(s.cols.len()) {
Some(Value::Integer(r)) => *r,
_ => return Err(Error::Corrupt("index record missing rowid".into())),
};
let mut values = alloc::vec![Value::Null; first_meta.columns.len()];
for (i, &mc) in s.cols.iter().enumerate() {
values[mc] = rec[i].clone();
}
promote_real_columns(&first_meta, &mut values);
if let Some(ipk) = first_meta.ipk {
values[ipk] = Value::Integer(rowid);
}
input_rows.push(InputRow {
values,
rowid: Some(rowid),
});
}
return Ok((first_meta.columns, input_rows));
}
let rowids =
crate::btree::index_range_rowids(src, s.root, None, None, &s.colls, &[])?;
let mut cur = TableCursor::new(src, first_meta.root);
let mut input_rows = Vec::with_capacity(rowids.len());
for rid in rowids {
if cur.seek(rid)? {
let values =
self.decode_full_row(&first_meta, rid, &cur.payload()?, encoding)?;
input_rows.push(InputRow {
values,
rowid: Some(rid),
});
}
}
return Ok((first_meta.columns, input_rows));
}
// Covering scan (B2): no seek and no ORDER-BY index walk applied, but a
// full index holds every column the query needs — read the rows from
// that index instead of the table. `eqp_select` reports the matching
// `SCAN … USING COVERING INDEX`.
if let Some((_, root, cols)) = self.covering_scan(sel, &first_meta, params) {
return Ok((
first_meta.columns.clone(),
self.covering_seek_rows(&first_meta, root, &cols)?,
));
}
let input_rows = self
.scan_table(&first_meta)?
.into_iter()
.map(|(rowid, values)| InputRow {
values,
rowid: Some(rowid),
})
.collect();
return Ok((first_meta.columns, input_rows));
}
// A table-qualified rowid alias (`t.rowid`) in a join needs each base
// table to contribute its rowid as a hidden tagged column so the reference
// resolves per-table (a joined row carries no single rowid). Each cost-based
// swap/reorder path below threads those hidden rowid columns when
// `with_rowid` is set — so the reorder still applies (matching sqlite's row
// order) AND the qualified rowid alias still resolves. When `with_rowid` is
// clear the paths run byte-identically to before.
let with_rowid = select_references_qualified_rowid(sel);
// Join case: resolve the first source (CTE, view, or table), then fold
// in joins. The driver is fully scanned; when a covering secondary index
// holds every `from.first` column the query needs, scan it in index order
// (matching sqlite's row order). When a swap below fires instead, the second
// table drives and these rows are discarded — only `columns` (metadata) is
// reused, which the covering reorder leaves unchanged.
let (columns, rows) = if let Some(rid) = self.join_first_rowid_seek(sel, from, params) {
// The driver carries its own `rowid = <const>` — seek that one row rather
// than scanning the whole table (in lockstep with the `SEARCH … (rowid=?)`
// EQP). The fold re-applies the full WHERE, so the result is unchanged.
self.resolve_join_driver_rowid_seek(&from.first, rid, with_rowid)?
} else {
self.resolve_join_scan_source_rowid(sel, from, &from.first, params, with_rowid)?
};
{
// Cost-based join-order (two-table rowid-inner swap): when driving from
// `from.first` would seek the second table by a secondary index but
// driving from the second table instead seeks `from.first` by its cheaper
// rowid, prefer the latter (matching sqlite's plan and its row order).
// EXECUTION-only: the produced user columns/rows stay in DECLARED order, so
// `SELECT *` and the projection are unaffected; when `with_rowid`, the two
// hidden per-table rowid columns trail the user columns. Gated tightly by
// `two_table_rowid_inner_swap`; every other join shape falls through to
// the unchanged fold below.
if let Some((driver_join_local, first_meta, first_ipk)) =
self.two_table_rowid_inner_swap(from)
{
let _ = first_ipk;
let (out_columns, out_rows) = self.exec_two_table_rowid_inner_swap(
sel,
from,
&columns,
driver_join_local,
&first_meta,
params,
with_rowid,
)?;
let input_rows = out_rows
.into_iter()
.map(|values| InputRow {
values,
rowid: None,
})
.collect();
return Ok((out_columns, input_rows));
}
// Cost-based join-order (two-table secondary-index-inner swap): the
// secondary-index analogue of the rowid swap above. When driving from
// `from.first` cannot seek the second table on its join column but
// `from.first`'s own join column is the leading column of a usable
// secondary index, scan the second table and seek `from.first` by that
// index (matching sqlite's plan and row order). Mutually exclusive with the
// rowid swap (which requires `from.first`'s column to BE its rowid IPK).
// EXECUTION-only: user columns/rows stay in DECLARED order; the hidden
// per-table rowid columns (if `with_rowid`) trail them.
if self.join_first_rowid_seek(sel, from, params).is_none()
&& let Some((driver_join_local, first_meta, idx)) =
self.two_table_index_inner_swap(from)
{
let (out_columns, out_rows) = self.exec_two_table_index_inner_swap(
sel,
from,
&columns,
driver_join_local,
&first_meta,
&idx,
params,
with_rowid,
)?;
let input_rows = out_rows
.into_iter()
.map(|values| InputRow {
values,
rowid: None,
})
.collect();
return Ok((out_columns, input_rows));
}
// Cost-based join-order for THREE OR MORE plain-INNER `main` base tables:
// sqlite drives the join from a table it must SCAN and pulls the
// rowid-/index-seekable tables into the inner positions, so an unordered
// query's rows come out in the chosen driver's scan order. When
// `ntable_join_order` confidently matches sqlite's ordering it returns a
// permuted `FromClause` plus the column-slot remap back to DECLARED order.
// The fold runs on the permuted clause (so every inner is seeked in
// lockstep with how the EQP emitter renders it), then the columns and every
// row are remapped to declared order — so `SELECT *` / `t.*` and the
// projection see the unchanged declared layout; only the row ORDER changes.
if let Some((reordered, remap, _, _)) = self.ntable_join_order(sel, from) {
let (drv_columns, drv_rows) = self.resolve_join_scan_source_rowid(
sel,
&reordered,
&reordered.first,
params,
with_rowid,
)?;
let (exec_columns, exec_rows) = self.fold_joins_rowid(
sel,
&reordered,
drv_columns,
drv_rows,
params,
with_rowid,
)?;
// `remap[declared_user_slot] = exec_user_slot`, but with `with_rowid`
// the fold interleaves a hidden rowid column after each base table's
// user block, so the raw execution slots no longer line up with
// `remap` (which assumes NO hidden columns). Split the execution layout
// into (user columns, hidden rowid columns), apply `remap` over just the
// user subsequence to recover declared order, then APPEND the hidden
// rowid columns unchanged (their table tag lets `t.rowid` resolve).
let user_slots: Vec<usize> = (0..exec_columns.len())
.filter(|&s| !exec_columns[s].hidden)
.collect();
let hidden_slots: Vec<usize> = (0..exec_columns.len())
.filter(|&s| exec_columns[s].hidden)
.collect();
let out_columns: Vec<ColumnInfo> = remap
.iter()
.map(|&u| exec_columns[user_slots[u]].clone())
.chain(hidden_slots.iter().map(|&s| exec_columns[s].clone()))
.collect();
let input_rows = exec_rows
.into_iter()
.map(|row| InputRow {
values: remap
.iter()
.map(|&u| row[user_slots[u]].clone())
.chain(hidden_slots.iter().map(|&s| row[s].clone()))
.collect(),
rowid: None,
})
.collect();
return Ok((out_columns, input_rows));
}
}
// Fold each join in with a nested-loop, evaluating its ON predicate
// against the columns accumulated so far plus the joined table's.
let (columns, rows) =
self.fold_joins_rowid(sel, from, columns, rows, params, with_rowid)?;
let input_rows = rows
.into_iter()
.map(|values| InputRow {
values,
rowid: None, // ambiguous across joined tables
})
.collect();
Ok((columns, input_rows))
}
/// Fold every `from.joins[i]` onto the already-materialised driver
/// (`columns`/`rows`, laid out as `from.first` then the joins folded so far)
/// with a nested loop, seeking the inner by rowid / secondary index /
/// clustered PK when its join column allows and otherwise materialising and
/// hash-probing it. The `ON` (or NATURAL/USING equality) gates each combined
/// row; LEFT/RIGHT/FULL emit the null-padded unmatched rows. Returns the
/// joined columns (in the given `from`'s layout) and rows. Shared by the
/// ordinary declaration-order join path and the N-table cost-based reorder
/// (which calls it on a permuted `FromClause` and remaps the result back to
/// declared column order — see [`ntable_join_order`](Self::ntable_join_order)).
///
/// With `with_rowid` set, each base rowid table folded in also contributes a
/// trailing hidden `rowid` column (see
/// [`resolve_join_source_rowid`](Self::resolve_join_source_rowid)), so a
/// table-qualified rowid alias resolves per-table. Hidden columns never take
/// part in `NATURAL`/`USING` matching.
fn fold_joins_rowid(
&self,
sel: &Select,
from: &FromClause,
mut columns: Vec<ColumnInfo>,
mut rows: Vec<Vec<Value>>,
params: &Params,
with_rowid: bool,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
for join in &from.joins {
// A LATERAL / correlated table-valued function inner (`FROM t,
// json_each(t.data)`): the TVF's arguments reference an outer FROM
// column, so it must be re-materialized per outer row with that row
// bound. (A non-correlated TVF — constant arguments — is materialized
// once by the normal path below.) NATURAL/USING coalescing over a TVF is
// not handled here and falls through.
if !join.natural
&& join.using.is_empty()
&& self.is_correlated_tvf(&join.table, &columns)
{
let (new_columns, joined) =
self.exec_lateral_tvf_join(join, &columns, &rows, params)?;
columns = new_columns;
rows = joined;
continue;
}
// Roadmap B1a: when the inner table's join column is its rowid IPK,
// seek the one inner row by rowid per outer row instead of
// materializing and nested-looping it. Identical results to the
// materialize path (the full `ON` is re-evaluated on the seeked row).
if let Some((outer_col, inner_meta)) = self.rowid_join_seek(join, &columns) {
let (new_columns, joined) = self.exec_rowid_join_seek(
join,
&columns,
&rows,
outer_col,
&inner_meta,
params,
with_rowid,
)?;
columns = new_columns;
rows = joined;
continue;
}
// Roadmap B1a² (index case): when the inner join column is the
// leading column of a usable secondary index, seek that index per
// outer row instead of materializing the inner table. A non-unique
// key may fan out to several inner rows. Identical results to the
// materialize path (the full `ON` is re-evaluated on each seeked row).
if let Some((outer_col, inner_meta, idx)) = self.index_join_seek(join, &columns) {
let (new_columns, joined) = self.exec_index_join_seek(
join,
&columns,
&rows,
outer_col,
&inner_meta,
&idx,
params,
with_rowid,
)?;
columns = new_columns;
rows = joined;
continue;
}
// WITHOUT ROWID inner table joined on its leading PRIMARY KEY: seek the
// clustered b-tree per outer row instead of materializing it.
if let Some((outer_col, inner_meta)) = self.without_rowid_pk_join_seek(join, &columns) {
let (new_columns, joined) = self.exec_without_rowid_pk_join_seek(
join,
&columns,
&rows,
outer_col,
&inner_meta,
params,
)?;
columns = new_columns;
rows = joined;
continue;
}
// The inner is materialised and nested-looped (no seek applied). When a
// covering secondary index holds every inner-table column the query
// needs AND the inner is a *plain* SCAN (not an equi-join for which
// sqlite builds an automatic hash index instead — see `covers_inner`),
// scan it in index order so the join's output row order matches sqlite's
// covering-index inner scan. An equi-hash inner produces rows in DRIVER
// order regardless of the inner's scan order, so reordering it there
// would diverge from sqlite (which renders AUTOMATIC INDEX, not a
// covering scan) — leave those in rowid order.
let inner_is_equi_hash = !join.natural
&& join.using.is_empty()
&& matches!(join.kind, JoinKind::Inner | JoinKind::Left)
&& join.on.as_ref().is_some_and(|on| {
let plain = self
.resolve_join_source(&join.table, params)
.map(|(c, _)| c);
plain.is_ok_and(|jc| {
let mut combined = columns.clone();
combined.extend(jc);
join_equi_cols(on, &combined, columns.len()).is_some()
})
});
let (jcols, jrows) = if inner_is_equi_hash {
self.resolve_join_source_rowid(&join.table, params, with_rowid)?
} else {
self.resolve_join_scan_source_rowid(sel, from, &join.table, params, with_rowid)?
};
let left_width = columns.len();
// `NATURAL` / `USING` join columns, as `(left index, right local
// index)` pairs: the join matches on equality of these and coalesces
// each into the single left-side output column. `NATURAL` pairs every
// commonly-named column; with no common column it degrades to a cross
// join (empty `pairs`), matching SQLite. Hidden columns (the per-table
// rowid slots) never take part in the common-name matching.
let pairs: Vec<(usize, usize)> = if join.natural {
jcols
.iter()
.enumerate()
.filter(|(_, rc)| !rc.hidden)
.filter_map(|(rl, rc)| {
columns
.iter()
.position(|c| !c.hidden && c.name.eq_ignore_ascii_case(&rc.name))
.map(|li| (li, rl))
})
.collect()
} else if !join.using.is_empty() {
let mut v = Vec::with_capacity(join.using.len());
for name in &join.using {
let li = columns
.iter()
.position(|c| !c.hidden && c.name.eq_ignore_ascii_case(name));
let rl = jcols
.iter()
.position(|c| !c.hidden && c.name.eq_ignore_ascii_case(name));
match (li, rl) {
(Some(li), Some(rl)) => v.push((li, rl)),
_ => {
return Err(Error::Error(format!(
"cannot join using column {name} - column not present in both tables"
)));
}
}
}
v
} else {
Vec::new()
};
let mut new_columns = columns.clone();
new_columns.extend(jcols.iter().cloned());
let n_jcols = jcols.len();
let mut joined: Vec<Vec<Value>> = Vec::new();
let mut right_matched = alloc::vec![false; jrows.len()];
// Build a hash index on the joined table when the ON predicate has an
// equi-join `left.col = right.col`, turning the O(n*m) nested loop into
// a probe. The full ON is still evaluated on each candidate (the hash
// only narrows which right rows to test), so semantics are unchanged.
// `NATURAL`/`USING` joins evaluate their equality directly (below) and
// use the nested loop.
let equi = if pairs.is_empty() {
join.on
.as_ref()
.and_then(|on| join_equi_cols(on, &new_columns, left_width))
} else {
None
};
let hash: Option<(usize, alloc::collections::BTreeMap<JoinKey, Vec<usize>>)> = equi
.map(|(li, ri_local)| {
let mut map: alloc::collections::BTreeMap<JoinKey, Vec<usize>> =
alloc::collections::BTreeMap::new();
for (ri, right) in jrows.iter().enumerate() {
for k in join_keys_of(&right[ri_local]) {
map.entry(k).or_default().push(ri);
}
}
(li, map)
});
for left in &rows {
let mut matched = false;
// Right rows to test: the hash candidates (sorted, deduped, so the
// output order matches the nested loop) or every right row.
let candidates: Vec<usize> = match &hash {
Some((li, map)) => {
let mut c: Vec<usize> = Vec::new();
for k in join_keys_of(&left[*li]) {
if let Some(idxs) = map.get(&k) {
c.extend_from_slice(idxs);
}
}
c.sort_unstable();
c.dedup();
c
}
None => (0..jrows.len()).collect(),
};
for ri in candidates {
let right = &jrows[ri];
let mut combined = left.clone();
combined.extend(right.iter().cloned());
let keep = if !pairs.is_empty() {
// NATURAL / USING: all join columns must be `=` equal (a
// NULL on either side is not a match), each under the left
// column's collation.
pairs.iter().all(|&(li, rl)| {
let coll = new_columns[li].collation;
// Apply each side's column affinity, like an `ON l = r`
// equality, so a cross-type USING/NATURAL key matches
// (INTEGER 1 = TEXT '1').
let (lv, rv) = eval::apply_comparison_affinity(
combined[li].clone(),
Some(new_columns[li].affinity),
combined[left_width + rl].clone(),
Some(new_columns[left_width + rl].affinity),
);
eval::truth(&eval::compare_op(BinaryOp::Eq, &lv, &rv, coll))
== Some(true)
})
} else {
match &join.on {
Some(on) => {
let ctx = row_ctx(&combined, &new_columns, None, params);
eval::truth(&eval::eval(on, &ctx)?) == Some(true)
}
None => true, // CROSS / comma join
}
};
if keep {
joined.push(combined);
matched = true;
right_matched[ri] = true;
}
}
// LEFT/FULL: emit the left row with NULLs when nothing matched.
if !matched && matches!(join.kind, JoinKind::Left | JoinKind::Full) {
let mut combined = left.clone();
combined.extend(core::iter::repeat_n(Value::Null, n_jcols));
joined.push(combined);
}
}
// RIGHT/FULL: emit each unmatched right row with NULLs for the left.
if matches!(join.kind, JoinKind::Right | JoinKind::Full) {
for (ri, right) in jrows.iter().enumerate() {
if !right_matched[ri] {
let mut combined = alloc::vec![Value::Null; left_width];
combined.extend(right.iter().cloned());
joined.push(combined);
}
}
}
// NATURAL / USING: coalesce each join column into its left output
// position (`COALESCE(left, right)` — the left value, or the right's
// when the left side is NULL from an outer join), then drop the right
// duplicate columns so each join column appears once.
if !pairs.is_empty() {
let mut drop: Vec<usize> = pairs.iter().map(|&(_, rl)| left_width + rl).collect();
drop.sort_unstable();
drop.dedup();
for row in &mut joined {
for &(li, rl) in &pairs {
if matches!(row[li], Value::Null) {
row[li] = row[left_width + rl].clone();
}
}
for &d in drop.iter().rev() {
row.remove(d);
}
}
for &d in drop.iter().rev() {
new_columns.remove(d);
}
}
columns = new_columns;
rows = joined;
}
Ok((columns, rows))
}
/// Resolve one table reference in a join to its columns + row values,
/// consulting the CTE environment before the schema (so a CTE — including a
/// recursive one — can appear as a join source).
/// Run a derived-table subquery (`FROM (SELECT …) AS alias`) into column
/// metadata (labeled with the alias) and row values.
/// A bare eponymous table-valued function (no parentheses) used as a `FROM`
/// source: a `pragma_<name>` form, or `json_each` / `json_tree`. These take
/// their hidden arguments from `WHERE` equalities (see `push_bare_tvf_args`)
/// rather than a parenthesised list — unless a real table, view, or CTE of the
/// same name shadows them. `generate_series` is deliberately excluded: its
/// default `stop` is unbounded, which the materialising tree-walker cannot
/// stream, so its bare form stays `no such table` (deferred to the VDBE track).
///
/// For a `pragma_*` name this only routes it into the TVF path; whether it is
/// a *valid* table source is decided by [`pragma_has_tvf`] in `tvf_rows`.
fn is_bare_tvf(&self, tref: &TableRef) -> bool {
let lname = tref.name.to_ascii_lowercase();
tref.tvf_args.is_none()
&& tref.subquery.is_none()
&& tref.schema.is_none()
&& (lname.starts_with("pragma_")
|| matches!(
lname.as_str(),
"json_each" | "json_tree" | "generate_series"
))
&& self.lookup_cte(&tref.name, None).is_none()
&& !self.is_view(&tref.name)
&& self.unqualified_db(&tref.name) == DbRef::Main
&& self.schema.table(&tref.name).is_none()
}
/// Drive a bare eponymous table-valued function from its `WHERE` clause: a
/// `pragma_*` / `json_each` / `json_tree` source written without an argument
/// list takes its hidden positional arguments from equality constraints on its
/// hidden input columns — `arg` (+ optional `schema`) for a pragma TVF, `json`
/// (+ optional `root`) for `json_each` / `json_tree` — exactly as SQLite's
/// eponymous virtual tables consume those hidden-column constraints. Returns a
/// clone of `tref` with synthesized positional `tvf_args` when the leading
/// (required) constraint is present; otherwise an unchanged clone (the
/// argument-less form, which yields no rows). Only top-level `AND`-conjoined
/// equalities against a literal/parameter are consumed; the full `WHERE` is
/// still re-applied by run_core (the echoed hidden columns satisfy it), so this
/// never widens or narrows the result incorrectly.
fn push_bare_tvf_args(tref: &TableRef, where_clause: Option<&Expr>) -> TableRef {
let lname = tref.name.to_ascii_lowercase();
// Hidden input columns in positional order; the first is the required
// driver, the rest are an optional trailing run.
let cols: &[&str] = match lname.as_str() {
"json_each" | "json_tree" => &["json", "root"],
"generate_series" => &["start", "stop", "step"],
_ if lname.starts_with("pragma_") => &["arg", "schema"],
_ => return tref.clone(),
};
let label = tref.alias.as_deref().unwrap_or(&tref.name);
let find = |col: &str| -> Option<Expr> {
let mut out = None;
if let Some(w) = where_clause {
collect_tvf_eq(w, label, col, &mut out);
}
out
};
let mut result = tref.clone();
if let Some(first) = find(cols[0]) {
let mut args = alloc::vec![first];
for c in &cols[1..] {
match find(c) {
Some(e) => args.push(e),
None => break,
}
}
result.tvf_args = Some(args);
}
result
}
/// Produce the rows of a table-valued function (`generate_series`, `json_each`,
/// `json_tree`) used as a `FROM` source.
fn tvf_rows(
&self,
tref: &TableRef,
params: &Params,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
self.tvf_rows_capped(tref, params, None)
}
/// [`tvf_rows`](Self::tvf_rows) with an optional upper bound on the number of
/// `generate_series` rows produced (see `generate_series_scan_cap`).
fn tvf_rows_capped(
&self,
tref: &TableRef,
params: &Params,
series_cap: Option<usize>,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
let args = tref.tvf_args.as_deref().unwrap_or(&[]);
let lname = tref.name.to_ascii_lowercase();
let label = tref.alias.clone().unwrap_or_else(|| tref.name.clone());
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let col = |name: &str, affinity| ColumnInfo {
name: String::from(name),
table: label.clone(),
affinity,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
};
// A hidden column (`json_each`/`json_tree`'s `json`/`root` input columns)
// is resolvable by name but omitted from `*` / `tbl.*` expansion.
let hcol = |name: &str, affinity| ColumnInfo {
name: String::from(name),
table: label.clone(),
affinity,
collation: crate::value::Collation::default(),
schema: None,
hidden: true,
};
match lname.as_str() {
"generate_series" => {
if args.is_empty() {
return Err(Error::Error(
"first argument to \"generate_series()\" missing or unusable".into(),
));
}
let nums: Vec<i64> = args
.iter()
.map(|a| eval::eval(a, &ctx).map(|v| eval::to_i64(&v)))
.collect::<Result<_>>()?;
let start = nums[0];
// With no explicit stop, SQLite's generate_series runs to 2^32-1
// (0xFFFFFFFF), not to `start` — matching its documented default.
let stop = nums.get(1).copied().unwrap_or(0xFFFF_FFFF);
// SQLite's generate_series treats a step of 0 as 1.
let step = match nums.get(2).copied().unwrap_or(1) {
0 => 1,
s => s,
};
let mut rows = Vec::new();
if step != 0 {
let mut v = start;
loop {
// Stop once the caller's row bound is met — checked before
// generating, so a cap of 0 (a columns-only probe) produces
// no rows and an unbounded default series can't run away.
if series_cap.is_some_and(|c| rows.len() >= c) {
break;
}
let in_range = if step > 0 { v <= stop } else { v >= stop };
if !in_range {
break;
}
// Each row echoes the (effective) `start`/`stop`/`step` in its
// hidden input columns — constant per row, exactly like SQLite,
// so a bare `generate_series` driven from `WHERE start=… AND
// stop=…` re-satisfies its own predicate. The trailing rowid is
// the value itself (SQLite's generate_series rowid).
rows.push(alloc::vec![
Value::Integer(v),
Value::Integer(start),
Value::Integer(stop),
Value::Integer(step),
Value::Integer(v),
]);
match v.checked_add(step) {
Some(n) => v = n,
None => break, // i64 overflow ends the series
}
}
}
Ok((
alloc::vec![
col("value", eval::Affinity::Integer),
hcol("start", eval::Affinity::Integer),
hcol("stop", eval::Affinity::Integer),
hcol("step", eval::Affinity::Integer),
hcol("rowid", eval::Affinity::Integer),
],
rows,
))
}
"json_each" | "json_tree" => {
let columns = alloc::vec![
col("key", eval::Affinity::Blob),
col("value", eval::Affinity::Blob),
col("type", eval::Affinity::Text),
col("atom", eval::Affinity::Blob),
col("id", eval::Affinity::Integer),
col("parent", eval::Affinity::Integer),
col("fullkey", eval::Affinity::Text),
col("path", eval::Affinity::Text),
// Hidden input columns: `json` echoes the document argument
// verbatim (constant per row), `root` the path argument
// (default `$`). Both are excluded from `*` expansion.
hcol("json", eval::Affinity::Blob),
hcol("root", eval::Affinity::Text),
// The implicit table-valued-function rowid: a 0-based counter
// over the emitted rows, matching SQLite's json_each/json_tree.
hcol("rowid", eval::Affinity::Integer),
];
// SQLite caps these table-valued functions at two arguments —
// the JSON document and an optional path — and rejects more as a
// structural error (before evaluating any of them).
if args.len() > 2 {
return Err(Error::Error(format!(
"too many arguments on {lname}() - max 2"
)));
}
// With no argument at all — or a NULL document — the function
// yields no rows, exactly like `json_each(NULL)`.
let doc = match args.first() {
Some(doc_arg) => eval::eval(doc_arg, &ctx)?,
None => return Ok((columns, Vec::new())),
};
if matches!(doc, Value::Null) {
return Ok((columns, Vec::new()));
}
// A BLOB document is SQLite's binary JSONB (decoded as a complete
// value, trailing bytes rejected); a text/numeric document is
// parsed as JSON text. Either failure is `malformed JSON`.
let root = match &doc {
Value::Blob(b) => crate::exec::json::Json::from_jsonb(b),
_ => crate::exec::json::parse(&eval::to_text(&doc)),
};
let Some(root) = root else {
return Err(Error::Error("malformed JSON".into()));
};
// An optional second argument is a path to navigate to first; the
// walk is then rooted at that element (e.g. `json_each(x, '$.a')`
// iterates `$.a`'s children, with `$.a…` paths). A path that does
// not resolve yields no rows.
let (target, root_path, base_off, base_id) = match args.get(1) {
Some(path_arg) => {
let p = eval::to_text(&eval::eval(path_arg, &ctx)?);
match crate::exec::json::navigate_with_offset(&root, &p) {
Some((sub, voff, id)) => (sub, p, voff, id),
None => return Ok((columns, Vec::new())),
}
}
None => (&root, String::from("$"), 0usize, 0usize),
};
let mut rows = Vec::new();
if lname == "json_tree" {
// The root row carries the path's final component as its key and
// its parent path in the `path` column.
let (parent_path, key) = split_json_path(&root, &root_path, base_id as i64);
json_tree_walk(
target,
key,
&root_path,
&parent_path,
JsonbPos {
value_off: base_off as i64,
id: base_id as i64,
},
None,
&mut rows,
);
} else {
json_each_children(
target,
&root_path,
base_off as i64,
base_id as i64,
&mut rows,
);
}
// Append the hidden `json`/`root` values to every emitted row,
// matching the two trailing hidden columns. `json` echoes the
// document argument as-is (a JSONB blob stays a blob); `root`
// is the path argument text (default `$`).
let root_val = Value::Text(root_path.into());
for (i, row) in rows.iter_mut().enumerate() {
row.push(doc.clone());
row.push(root_val.clone());
row.push(Value::Integer(i as i64)); // rowid: 0-based row counter
}
Ok((columns, rows))
}
// `pragma_<name>(arg)` is the table-valued form of a PRAGMA, usable in
// a FROM clause (e.g. `SELECT name FROM pragma_table_info('t')`).
pragma if pragma.starts_with("pragma_") => {
let bare = &pragma["pragma_".len()..];
// Only a pragma that SQLite exposes as an eponymous table-valued
// function is a valid FROM source; an unrecognized name (or a
// statement-only pragma like `wal_checkpoint`) is `no such table`,
// not a silently-empty result.
if !pragma_has_tvf(bare) {
return Err(Error::Error(format!("no such table: {}", tref.name)));
}
// A pragma TVF's 2nd argument is the schema/database qualifier
// (`pragma_table_info(arg, schema)`), mirroring `PRAGMA <db>.name`.
let schema = match args.get(1) {
Some(Expr::Literal(Literal::Str(s))) if !s.is_empty() => Some(s.clone()),
_ => None,
};
let p = Pragma {
schema,
name: String::from(bare),
value: args.first().cloned(),
};
let result = self.run_pragma(&p)?;
// SQLite exposes every pragma table-valued function with two
// hidden input columns — `schema` (the database) and `arg` (the
// pragma argument) — that echo the call/constraint values and are
// omitted from `*` expansion. They let the bare form be driven by
// `WHERE arg=…` (see `push_pragma_tvf_args`); the call form
// (`pragma_table_info('t')`) echoes its positional `(arg, schema)`.
let mut columns: Vec<ColumnInfo> = result
.columns
.iter()
.map(|n| col(n, eval::Affinity::Blob))
.collect();
columns.push(hcol("schema", eval::Affinity::Text));
columns.push(hcol("arg", eval::Affinity::Text));
// The implicit rowid of a pragma table-valued function is the
// 1-based row number, matching SQLite.
columns.push(hcol("rowid", eval::Affinity::Integer));
let arg_val = match args.first() {
Some(a) => eval::eval(a, &ctx)?,
None => Value::Null,
};
let schema_val = match args.get(1) {
Some(a) => eval::eval(a, &ctx)?,
None => Value::Null,
};
let rows = result
.rows
.into_iter()
.enumerate()
.map(|(i, mut r)| {
r.push(schema_val.clone());
r.push(arg_val.clone());
r.push(Value::Integer(i as i64 + 1));
r
})
.collect();
Ok((columns, rows))
}
_ => {
// Not a built-in table-valued function. SQLite resolves the bare
// name as a table/view: if such an object exists, calling it with
// an argument list is `'<name>' is not a function` (the qualifier,
// if any, is dropped); otherwise it is a plain missing table, with
// the schema qualifier echoed as written (an unknown qualifier is
// `no such table: bad.t`, never `unknown database bad`).
use crate::schema::ObjectType;
let exists = is_main_schema_table(&tref.name)
|| match self.resolve_db(tref.schema.as_deref()) {
Ok(db) if db == DbRef::Temp && self.temp_db.is_none() => false,
Ok(db) => {
let (schema, _) = self.db_parts(db);
schema.objects().iter().any(|o| {
matches!(o.obj_type, ObjectType::Table | ObjectType::View)
&& o.name == tref.name
})
}
Err(_) => false,
};
if exists {
return Err(Error::Error(format!("'{}' is not a function", tref.name)));
}
let qualified = match &tref.schema {
Some(q) => format!("{q}.{}", tref.name),
None => tref.name.clone(),
};
Err(Error::Error(format!("no such table: {qualified}")))
}
}
}
fn run_subquery_source(
&self,
select: &Select,
alias: Option<&str>,
params: &Params,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
let result = self.run_select(select, params)?;
let label = alias.unwrap_or("").to_string();
// A derived column inherits the affinity AND collation of its origin (a
// direct column reference, transparent through parens / an explicit
// `COLLATE`), matching sqlite; an expression column has NONE affinity and
// BINARY collation. Resolved for a single-base-table subquery; a join /
// nested subquery / TVF source leaves the conservative NONE/BINARY default.
let origins = self.subquery_column_origins(select);
let columns = result
.columns
.iter()
.enumerate()
.map(|(i, n)| {
let (affinity, collation) = origins
.as_ref()
.and_then(|o| o.get(i).copied())
.unwrap_or((eval::Affinity::Blob, crate::value::Collation::default()));
ColumnInfo {
name: n.clone(),
table: label.clone(),
affinity,
collation,
schema: None,
hidden: false,
}
})
.collect();
Ok((columns, result.rows))
}
/// The `(affinity, collation)` each output column of a single-base-table
/// subquery inherits from its origin — a direct column reference (through
/// parens / `COLLATE`) takes its base column's affinity and collation (an
/// explicit `COLLATE` overrides the collation); any other expression is
/// `(BLOB, BINARY)`. Returns `None` (caller defaults all to `BLOB`/`BINARY`)
/// for a compound / join / nested / TVF subquery, or a count mismatch.
fn subquery_column_origins(&self, select: &Select) -> Option<Vec<ColOrigin>> {
self.subquery_column_origins_in(select, &[])
}
/// As [`subquery_column_origins`](Self::subquery_column_origins), but with a
/// slice of in-scope CTEs whose bodies a `FROM` reference may name (so a sibling
/// CTE reference resolves to that CTE's own column origins instead of failing).
/// Existing callers use the no-CTE wrapper above; only the VDBE derived-source
/// path threads `sel.ctes` in, so the tree-walker paths are unaffected.
fn subquery_column_origins_in(&self, select: &Select, ctes: &[Cte]) -> Option<Vec<ColOrigin>> {
// The leftmost arm's per-column origins.
let head = self.arm_column_origins(select, ctes)?;
if select.compound.is_empty() {
return Some(head);
}
// A compound (UNION / UNION ALL / INTERSECT / EXCEPT) body resolves only when
// *every* arm yields the identical `(affinity, collation)` for each column —
// then the result column carries that shared origin (e.g. two `INTEGER`
// arms keep INTEGER affinity, so an outer `v = '2'` coerces and matches). Any
// per-column disagreement, a differing column count, an unresolvable arm, or a
// nested compound arm defers to the tree-walker, whose conservative NONE/BINARY
// default already matches SQLite for mixed-affinity arms (verified vs sqlite3).
for (_, arm) in &select.compound {
if !arm.compound.is_empty() {
return None;
}
let arm_origins = self.arm_column_origins(arm, ctes)?;
if arm_origins.len() != head.len() || arm_origins != head {
return None;
}
}
Some(head)
}
/// One arm of a (possibly compound) subquery: its per-column `(affinity,
/// collation)` origins. A single source or a *plain* join body resolves; a
/// NATURAL/USING join, a FROM-less arm, or an unresolvable source returns `None`.
/// [`subquery_column_origins_in`](Self::subquery_column_origins_in) combines the
/// arms.
fn arm_column_origins(&self, select: &Select, ctes: &[Cte]) -> Option<Vec<ColOrigin>> {
let from = select.from.as_ref()?;
// A NATURAL / USING join coalesces its shared columns into a single output
// column whose affinity is the left source's — a bare-name lookup across both
// sources can't disambiguate that. Defer those; a plain join (CROSS / comma /
// `ON`) keeps every source column distinct, so each output column resolves to
// exactly one source.
if from.joins.iter().any(|j| j.natural || !j.using.is_empty()) {
return None;
}
// Each FROM source's `(label, named (affinity, collation) columns)`. A base
// table reads them from its meta; a nested subquery / sibling-CTE source
// recurses, so a collation/affinity flows through any depth of single-source
// derived tables. For a plain join body the sources are the first table then
// each joined table in declaration order — the same order the scan's combined
// schema concatenates them, so positional column counts line up.
let mut sources: Vec<(String, Vec<(String, ColOrigin)>)> = Vec::new();
for tref in core::iter::once(&from.first).chain(from.joins.iter().map(|j| &j.table)) {
let label = tref.alias.clone().unwrap_or_else(|| tref.name.clone());
sources.push((label, self.named_source_origins_in(tref, ctes)?));
}
let base = |table: Option<&str>, col: &str| -> Option<ColOrigin> {
match table {
// A qualified `t.col` resolves within the one named source.
Some(t) => {
let (_, src) = sources.iter().find(|(l, _)| l.eq_ignore_ascii_case(t))?;
src.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(col))
.map(|(_, o)| *o)
}
// A bare `col` must name exactly one source's column (a valid body
// already guarantees this — an ambiguous bare name never produced
// rows); an ambiguous match bails to the conservative default.
None => {
let mut found = None;
for (_, src) in &sources {
if let Some((_, o)) = src.iter().find(|(n, _)| n.eq_ignore_ascii_case(col))
{
if found.is_some() {
return None;
}
found = Some(*o);
}
}
found
}
}
};
fn origin(e: &Expr, base: &dyn Fn(Option<&str>, &str) -> Option<ColOrigin>) -> ColOrigin {
match e {
Expr::Paren(inner) => origin(inner, base),
Expr::Column { table, column, .. } => base(table.as_deref(), column)
.unwrap_or((eval::Affinity::Blob, crate::value::Collation::default())),
Expr::Collate { expr, collation } => {
let (aff, base_coll) = origin(expr, base);
(
aff,
crate::value::resolve_collation_name(collation).unwrap_or(base_coll),
)
}
_ => (eval::Affinity::Blob, crate::value::Collation::default()),
}
}
let mut out = Vec::new();
for rc in &select.columns {
match rc {
ResultColumn::Wildcard => {
for (_, src) in &sources {
out.extend(src.iter().map(|(_, o)| *o));
}
}
ResultColumn::TableWildcard(t) => {
let (_, src) = sources.iter().find(|(l, _)| l.eq_ignore_ascii_case(t))?;
out.extend(src.iter().map(|(_, o)| *o));
}
ResultColumn::Expr { expr, .. } => out.push(origin(expr, &base)),
}
}
Some(out)
}
/// A single FROM source's `(name, (affinity, collation))` per column. A base
/// table reads its meta; a nested subquery recurses through
/// `subquery_column_origins_in` (its names from `resolved_view_columns`), so an
/// inherited affinity/collation flows through nested single-source derived
/// tables. A `FROM` reference that names an in-scope CTE (from `ctes`) resolves
/// through that CTE's body — so an outer derived source whose body reads a
/// *sibling* CTE inherits the right `(affinity, collation)` per column. A view /
/// TVF / join-or-compound (incl. recursive) subquery or CTE body returns `None`.
fn named_source_origins_in(
&self,
tref: &TableRef,
ctes: &[Cte],
) -> Option<Vec<(String, ColOrigin)>> {
if tref.tvf_args.is_some() {
return None;
}
if let Some(sub) = &tref.subquery {
let names = self.resolved_view_columns(sub)?;
let origins = self.subquery_column_origins_in(sub, ctes)?;
if names.len() != origins.len() {
return None;
}
return Some(
names
.into_iter()
.zip(origins)
.map(|((n, _), o)| (n, o))
.collect(),
);
}
// A `FROM` reference naming an in-scope CTE resolves through that CTE's body
// (recursively CTE-scope-aware, so a chain of sibling references resolves).
// The names come from the body's output, or the explicit `WITH name(cols…)`
// rename. A base table of the same name is shadowed by the CTE, matching the
// outer scan's own CTE-before-table precedence.
if tref.schema.is_none()
&& let Some(c) = ctes
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&tref.name))
{
// A *recursive* CTE names itself in its own body; descending into
// it with `c` still in scope would recurse without end. Its column
// origins are conservative anyway (the documented `None` for a
// recursive body), so stop here. Otherwise resolve through the body
// with `c` removed from scope — a non-recursive CTE never names
// itself, and dropping it keeps sibling references resolvable while
// guaranteeing termination.
if references_name(&c.select, &c.name) {
return None;
}
let inner: Vec<Cte> = ctes
.iter()
.filter(|x| !x.name.eq_ignore_ascii_case(&c.name))
.cloned()
.collect();
let origins = self.subquery_column_origins_in(&c.select, &inner)?;
let names: Vec<String> = if c.columns.is_empty() {
self.resolved_view_columns(&c.select)?
.into_iter()
.map(|(n, _)| n)
.collect()
} else {
c.columns.clone()
};
if names.len() != origins.len() {
return None;
}
return Some(names.into_iter().zip(origins).collect());
}
// A view source resolves through its stored body: the view's output columns
// carry their defining expressions' `(affinity, collation)`, so a derived
// table / outer predicate over the view coerces exactly as it would over the
// body (e.g. `(SELECT g AS v FROM vt) WHERE v = '2'` keeps `vt.g`'s INTEGER
// affinity). Unqualified only — a schema-qualified name never names a view here.
if tref.schema.is_none() && self.is_view(&tref.name) {
return self.view_named_origins(&tref.name);
}
// A base table only — a CTE source out of scope defers to the conservative
// default.
self.schema.table(&tref.name)?;
let meta = self.table_meta(&tref.name, tref.alias.as_deref()).ok()?;
Some(
meta.columns
.iter()
.map(|c| (c.name.clone(), (c.affinity, c.collation)))
.collect(),
)
}
/// A view's per-column `(name, (affinity, collation))` origins, resolved by
/// parsing its stored `CREATE VIEW` and threading the body through
/// [`subquery_column_origins`](Self::subquery_column_origins) — exactly the
/// origins [`try_view`](Self::try_view) assigns when it materializes the view.
/// Returns `None` (conservative defer) when the view body's origins can't be
/// resolved (a join/compound/CTE/TVF body the resolver declines), so a caller
/// keeps the NONE/BINARY default rather than guess.
fn view_named_origins(&self, name: &str) -> Option<Vec<(String, ColOrigin)>> {
let sql = if self.temp_has_view(name) {
self.temp_db
.as_ref()?
.schema
.objects()
.iter()
.find(|o| {
o.obj_type == crate::schema::ObjectType::View
&& o.name.eq_ignore_ascii_case(name)
})?
.sql
.clone()?
} else {
self.schema
.objects()
.iter()
.find(|o| {
o.obj_type == crate::schema::ObjectType::View
&& o.name.eq_ignore_ascii_case(name)
})?
.sql
.clone()?
};
let Ok(Statement::CreateView(cv)) = sql::parse_one(&sql) else {
return None;
};
let origins = self.subquery_column_origins(&cv.select)?;
let names: Vec<String> = if cv.columns.is_empty() {
self.resolved_view_columns(&cv.select)?
.into_iter()
.map(|(n, _)| n)
.collect()
} else {
cv.columns.clone()
};
if names.len() != origins.len() {
return None;
}
Some(names.into_iter().zip(origins).collect())
}
/// The single shared decision for the rowid-seek join optimization (roadmap
/// B1a): when a `JOIN`'s `ON` is a lone equi-join `outer.col = u.ipk` (or the
/// mirror) whose right side is the inner table `u`'s INTEGER PRIMARY KEY, the
/// inner row can be fetched by rowid per outer row instead of materializing
/// and nested-looping `u`. Returns `(outer_col_index, inner_meta)` when it
/// applies; `None` otherwise (the caller falls back to materialize/hash).
///
/// Used by BOTH the executor (to seek) and the join EQP emitter (to print
/// `SEARCH … USING INTEGER PRIMARY KEY (rowid=?)` instead of `SCAN`), so the
/// two never diverge. `left_columns` is the column list accumulated for the
/// left side so far (its width is where the inner table's columns begin).
fn rowid_join_seek(
&self,
join: &Join,
left_columns: &[ColumnInfo],
) -> Option<(usize, TableMeta)> {
// Only plain INNER / LEFT joins with a single `ON` equality — never
// NATURAL / USING / CROSS / RIGHT / FULL.
if !matches!(join.kind, JoinKind::Inner | JoinKind::Left)
|| join.natural
|| !join.using.is_empty()
{
return None;
}
let on = join.on.as_ref()?;
let tref = &join.table;
// The inner table must be a plain base table in `main`: not a subquery /
// CTE / view / TVF, and not schema-qualified.
if tref.subquery.is_some()
|| tref.tvf_args.is_some()
|| self.is_bare_tvf(tref)
|| tref.schema.is_some()
|| self.lookup_cte(&tref.name, tref.alias.as_deref()).is_some()
|| self.is_view(&tref.name)
|| self.unqualified_db(&tref.name) != DbRef::Main
{
return None;
}
let meta = self.table_meta(&tref.name, tref.alias.as_deref()).ok()?;
// Must have a rowid IPK (rules out WITHOUT ROWID, which has `ipk == None`).
let ipk = meta.ipk?;
// The `ON` must be a single top-level `=` (after unwrapping parens), one
// side the inner table's IPK column and the other a left-side column.
let mut on = on;
while let Expr::Paren(inner) = on {
on = inner;
}
let left_width = left_columns.len();
let mut combined = left_columns.to_vec();
combined.extend(meta.columns.iter().cloned());
let (a, b) = match on {
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => (col_index(left, &combined)?, col_index(right, &combined)?),
_ => return None,
};
// Identify which side is the inner IPK (`left_width + ipk`) and which is
// the outer column (a left-side index).
let inner_ipk = left_width + ipk;
let outer = if a == inner_ipk && b < left_width {
b
} else if b == inner_ipk && a < left_width {
a
} else {
return None;
};
Some((outer, meta))
}
/// The index-seek companion of [`rowid_join_seek`](Self::rowid_join_seek)
/// (roadmap B1a², index case): when a `JOIN`'s `ON` is a lone equi-join
/// `outer.col = u.k` whose right side `u.k` is the *leading column of a full
/// (non-partial, non-expression) secondary index* on the inner plain base
/// table `u`, the matching inner rows can be found by seeking that index per
/// outer row instead of materializing and nested-looping `u`. Returns the
/// outer column index, the inner table meta, and the chosen index when it
/// applies; `None` otherwise.
///
/// The rowid/IPK case is preferred — callers must consult
/// [`rowid_join_seek`](Self::rowid_join_seek) first and only fall through to
/// this when that returns `None`. Shared by BOTH the executor (to seek) and
/// the join EQP emitter (to print `SEARCH … USING INDEX <name> (<col>=?)`),
/// so the two never diverge.
fn index_join_seek(
&self,
join: &Join,
left_columns: &[ColumnInfo],
) -> Option<(usize, TableMeta, IndexMeta)> {
if !matches!(join.kind, JoinKind::Inner | JoinKind::Left)
|| join.natural
|| !join.using.is_empty()
{
return None;
}
let on = join.on.as_ref()?;
let tref = &join.table;
// The inner table must be a plain base table in `main`: not a subquery /
// CTE / view / TVF, and not schema-qualified.
if tref.subquery.is_some()
|| tref.tvf_args.is_some()
|| self.is_bare_tvf(tref)
|| tref.schema.is_some()
|| self.lookup_cte(&tref.name, tref.alias.as_deref()).is_some()
|| self.is_view(&tref.name)
|| self.unqualified_db(&tref.name) != DbRef::Main
{
return None;
}
let meta = self.table_meta(&tref.name, tref.alias.as_deref()).ok()?;
if meta.without_rowid {
return None;
}
// The `ON` must be a single top-level `=` (after unwrapping parens), one
// side an inner-table column and the other a left-side column.
let mut on = on;
while let Expr::Paren(inner) = on {
on = inner;
}
let left_width = left_columns.len();
let mut combined = left_columns.to_vec();
combined.extend(meta.columns.iter().cloned());
let (a, b) = match on {
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => (col_index(left, &combined)?, col_index(right, &combined)?),
_ => return None,
};
// One side must be an inner column (>= left_width) and the other a
// left-side column (< left_width).
let (inner_idx, outer) = if a >= left_width && b < left_width {
(a - left_width, b)
} else if b >= left_width && a < left_width {
(b - left_width, a)
} else {
return None;
};
// The inner join column must be the *leading* column of a full index (not
// partial, not expression). Pick the first such index by catalog order so
// the choice is deterministic and matches the EQP emitter.
let indexes = self.indexes_of(&tref.name).ok()?;
let idx = indexes.into_iter().find(|i| {
i.partial.is_none() && i.key_exprs.is_none() && i.cols.first() == Some(&inner_idx)
})?;
// SQLite's `sqlite3IndexAffinityOk`: an index seek for `inner = outer` is
// only usable when the comparison's affinity is compatible with the index
// column's affinity — otherwise seeking the raw key would MISS matches an
// affinity-correct comparison finds. For two columns the comparison affinity
// is NUMERIC if either side is numeric (else BLOB); a NUMERIC comparison
// needs a numeric index column (a text/blob-stored index can't be numerically
// seeked). E.g. an INTEGER outer equated to an untyped inner index column
// (which stores its values as text) declines here — matching sqlite, which
// scans that table instead of a wrong-result index seek.
if !index_seek_affinity_ok(combined[outer].affinity, meta.columns[inner_idx].affinity) {
return None;
}
Some((outer, meta, idx))
}
/// The WITHOUT ROWID companion of [`index_join_seek`](Self::index_join_seek):
/// when the inner table is WITHOUT ROWID and the `ON` equates an outer column
/// with its *leading* PRIMARY KEY column, the inner row is found by seeking
/// the clustered b-tree per outer row (`SEARCH … USING PRIMARY KEY (col=?)`)
/// instead of scanning. Callers consult this after `rowid_join_seek` and
/// `index_join_seek` (which both decline WITHOUT ROWID tables). Returns
/// `(outer column index, inner meta)`.
fn without_rowid_pk_join_seek(
&self,
join: &Join,
left_columns: &[ColumnInfo],
) -> Option<(usize, TableMeta)> {
if !matches!(join.kind, JoinKind::Inner | JoinKind::Left)
|| join.natural
|| !join.using.is_empty()
{
return None;
}
let on = join.on.as_ref()?;
let tref = &join.table;
if tref.subquery.is_some()
|| tref.tvf_args.is_some()
|| self.is_bare_tvf(tref)
|| tref.schema.is_some()
|| self.lookup_cte(&tref.name, tref.alias.as_deref()).is_some()
|| self.is_view(&tref.name)
|| self.unqualified_db(&tref.name) != DbRef::Main
{
return None;
}
let meta = self.table_meta(&tref.name, tref.alias.as_deref()).ok()?;
if !meta.without_rowid || meta.pk_len == 0 {
return None;
}
let lead_pk = meta.storage_order[0];
let mut on = on;
while let Expr::Paren(inner) = on {
on = inner;
}
let left_width = left_columns.len();
let mut combined = left_columns.to_vec();
combined.extend(meta.columns.iter().cloned());
let (a, b) = match on {
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => (col_index(left, &combined)?, col_index(right, &combined)?),
_ => return None,
};
let (inner_idx, outer) = if a >= left_width && b < left_width {
(a - left_width, b)
} else if b >= left_width && a < left_width {
(b - left_width, a)
} else {
return None;
};
if inner_idx != lead_pk {
return None;
}
Some((outer, meta))
}
/// Execute a WITHOUT ROWID PK-seek join (decided by
/// [`without_rowid_pk_join_seek`](Self::without_rowid_pk_join_seek)): for each
/// outer row, seek the inner table's clustered b-tree by the join key, decode
/// each matching record to a row, combine, and re-evaluate the full `ON`.
/// INNER drops an unmatched outer row; LEFT NULL-extends it.
fn exec_without_rowid_pk_join_seek(
&self,
join: &Join,
columns: &[ColumnInfo],
rows: &[Vec<Value>],
outer_col: usize,
inner_meta: &TableMeta,
params: &Params,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
let mut new_columns = columns.to_vec();
new_columns.extend(inner_meta.columns.iter().cloned());
let n_jcols = inner_meta.columns.len();
let on = join.on.as_ref();
let is_left = matches!(join.kind, JoinKind::Left);
let lead = inner_meta.storage_order[0];
let coll = wr_storage_collations(inner_meta)[0];
// Leading-PK direction of the inner clustered b-tree (`&[]` when all-asc).
let lead_descs: &[bool] = if inner_meta.pk_descs().is_empty() {
&[]
} else {
&inner_meta.pk_descending[..1]
};
let src = self.backend.source();
let mut joined: Vec<Vec<Value>> = Vec::new();
for left in rows {
let mut matched = false;
if !matches!(left[outer_col], Value::Null) {
let key = [inner_meta.columns[lead]
.affinity
.coerce(left[outer_col].clone())];
let records = crate::btree::index_seek_records(
src,
inner_meta.root,
&key,
&[coll],
lead_descs,
)?;
for storage in records {
let mut inner = unpermute_row(inner_meta, storage);
self.compute_generated(inner_meta, &mut inner, params)?;
let mut row = left.clone();
row.extend(inner);
let keep = match on {
Some(on) => {
let ctx = row_ctx(&row, &new_columns, None, params);
eval::truth(&eval::eval(on, &ctx)?) == Some(true)
}
None => true,
};
if keep {
joined.push(row);
matched = true;
}
}
}
if !matched && is_left {
let mut combined = left.clone();
combined.extend(core::iter::repeat_n(Value::Null, n_jcols));
joined.push(combined);
}
}
Ok((new_columns, joined))
}
/// Execute one index-seek join (decided by
/// [`index_join_seek`](Self::index_join_seek)): for each outer row, take the
/// join-key value, seek the chosen secondary index for matching rowids, fetch
/// each inner row by rowid, combine, and re-evaluate the full `ON` so results
/// are byte-identical to the materialize/hash path. A non-unique index key may
/// match multiple inner rows — one combined row is emitted per match. INNER
/// drops an outer row with no inner match; LEFT NULL-extends it. A NULL key
/// (or one with no index match) yields no inner rows.
#[allow(clippy::too_many_arguments)]
fn exec_index_join_seek(
&self,
join: &Join,
columns: &[ColumnInfo],
rows: &[Vec<Value>],
outer_col: usize,
inner_meta: &TableMeta,
idx: &IndexMeta,
params: &Params,
with_rowid: bool,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
let encoding = self.backend.source().header().text_encoding;
let mut new_columns = columns.to_vec();
new_columns.extend(inner_meta.columns.iter().cloned());
// Plain rowid base-table inner: contribute its hidden rowid slot when the
// query needs per-table rowids.
if with_rowid {
let label = join.table.alias.as_deref().unwrap_or(&join.table.name);
new_columns.push(hidden_rowid_col(label, Some(self.db_label(DbRef::Main))));
}
let n_jcols = new_columns.len() - columns.len();
let on = join.on.as_ref();
let is_left = matches!(join.kind, JoinKind::Left);
let lead = idx.cols[0];
let coll = idx.collations[0];
let src = self.backend.source();
let mut cur = TableCursor::new(self.backend.source(), inner_meta.root);
let mut joined: Vec<Vec<Value>> = Vec::new();
for left in rows {
let mut matched = false;
// A NULL outer key never equi-joins; skip the seek (no inner match).
if !matches!(left[outer_col], Value::Null) {
// Coerce the key to the leading column's affinity, mirroring
// `try_index_lookup` so the index comparison is identical.
let key = [inner_meta.columns[lead]
.affinity
.coerce(left[outer_col].clone())];
let colls = [coll];
let rowids =
crate::btree::index_seek_rowids(src, idx.root, &key, &colls, idx.seek_descs())?;
for rid in rowids {
if cur.seek(rid)? {
let inner =
self.decode_full_row(inner_meta, rid, &cur.payload()?, encoding)?;
let mut row = left.clone();
row.extend(inner);
if with_rowid {
row.push(Value::Integer(rid));
}
let keep = match on {
Some(on) => {
let ctx = row_ctx(&row, &new_columns, None, params);
eval::truth(&eval::eval(on, &ctx)?) == Some(true)
}
None => true,
};
if keep {
joined.push(row);
matched = true;
}
}
}
}
// LEFT: emit the outer row NULL-extended when nothing matched.
if !matched && is_left {
let mut combined = left.clone();
combined.extend(core::iter::repeat_n(Value::Null, n_jcols));
joined.push(combined);
}
}
Ok((new_columns, joined))
}
/// The LogEst full-*scan* `rRun` for `table_name` when it is the join's
/// driver — sqlite's `whereLoopAddBtree` full-table-scan cost `rSize + 16 -
/// 2*(has STAT4)`, where `rSize = LogEst(nRow)`. `nOut` of a driver scan is
/// `rSize` (no single-table WHERE restriction is modelled here — the callers
/// only fire on a bare equi-join). Returns `None` without `sqlite_stat1` data
/// (no stats ⇒ no cost swap, keeping the no-analyze plan byte-identical).
fn join_scan_cost(&self, table_name: &str) -> Option<(i16, i16)> {
let n_row = self.table_stat1_rows(table_name)?;
if n_row == 0 {
return None;
}
let r_size = logest(n_row);
// STAT4 discount (−2) applies when the table has any STAT4 samples.
let has_stat4 = self
.indexes_of(table_name)
.ok()
.into_iter()
.flatten()
.any(|idx| self.stat4_samples(&idx.name).is_some());
let scan_run = r_size + 16 - if has_stat4 { 2 } else { 0 };
Some((scan_run, r_size))
}
/// The LogEst `(rRun, nOut)` of seeking `meta`/`table_name` by its local join
/// column `local` as a join *inner* — sqlite's `whereLoopAddBtreeIndex`
/// equality cost for a `col = <outer-column>` seek (a non-constant RHS, so
/// STAT4 is not probed and the estimate rests on `sqlite_stat1`). Returns
/// `None` when `local` is not cheaply seekable (not the rowid IPK, not the
/// leading column of a usable plain secondary index) or `sqlite_stat1` is
/// absent — in which case there is no cost to compare and the caller keeps the
/// declaration-order plan.
///
/// rowid/IPK seek: `nOut = 0` (LogEst(1), a unique row); IPK type ⇒ `rCostIdx
/// = LogEstAdd(rLogSize, nOut + 16)` and no per-row table lookup (`WHERE_IPK`),
/// so `rRun = rCostIdx`. Secondary-index seek: `nOut = aiRowLogEst[1]` (the
/// stat1 average rows per distinct leading key, exactly sqlite's `nOut +=
/// aiRowLogEst[1] - aiRowLogEst[0]` starting from `rSize = aiRowLogEst[0]`);
/// `rCostIdx = LogEstAdd(rLogSize, nOut + 1 + 15*szIdx/szTab)`, then the
/// non-covering table lookup `rRun = LogEstAdd(rCostIdx, nOut + 16)`.
fn join_seek_cost(
&self,
table_name: &str,
meta: &TableMeta,
local: usize,
) -> Option<(i16, i16)> {
let n_row = self.table_stat1_rows(table_name)?;
if n_row == 0 {
return None;
}
let r_size = logest(n_row);
let r_log_size = est_log(r_size);
// rowid IPK seek: unique row, no per-row table lookup.
if meta.ipk == Some(local) && !meta.without_rowid {
let n_out: i16 = 0; // LogEst(1)
let r_cost_idx = logest_add(r_log_size, n_out + 16);
return Some((r_cost_idx, n_out));
}
// Leading column of a usable plain secondary index (non-partial,
// non-expression) — the seek path `index_join_seek` exploits.
let idx = self.indexes_of(table_name).ok()?.into_iter().find(|i| {
i.partial.is_none() && i.key_exprs.is_none() && i.cols.first() == Some(&local)
})?;
// nOut = aiRowLogEst[1] (average rows per distinct leading key). Without a
// second stat1 value there is no per-value estimate, so decline.
let stats = self.stat1_map();
let ai = stats.get(&idx.name)?;
let n_out = logest(*ai.get(1)?.max(&1));
// Table + index row widths (szTabRow / szIdxRow), as in `full_scan_beats_seek`.
let szests = self.table_col_szests(table_name).unwrap_or_default();
let w_tab: u32 = szests.iter().copied().sum::<u32>() + 1;
let sz_tab_row = logest(u64::from(w_tab) * 4).max(1) as i32;
let sz_idx_row = self.index_seek_width(table_name, &idx) as i32;
let per_row = 1 + (15 * sz_idx_row) / sz_tab_row;
let r_cost_idx = logest_add(r_log_size, n_out + per_row as i16);
let r_run = logest_add(r_cost_idx, n_out + 16);
Some((r_run, n_out))
}
/// Whether sqlite's cost model prefers driving the *second* table of a
/// two-table equi-join (scanning it, seeking `from.first` as the inner) over
/// driving `from.first`. Only meaningful — and only consulted — when BOTH join
/// columns are cheaply seekable (else one of the existing one-sided swaps, or
/// the declaration-order fold, already makes the seekable side the inner).
///
/// Computes the LogEst path cost each way with the exact `wherePathSolver`
/// recurrence — driving `D` (scan) then seeking inner `I` costs `LogEstAdd(
/// scanRun(D), seekRun(I) + scanOut(D) )` (the inner seek repeated once per
/// driver row) — and returns `true` only when driving the second is STRICTLY
/// cheaper (a tie keeps declaration order, matching sqlite's `wherePathSolver`,
/// which discards a new path that is no better than the incumbent). Returns
/// `false` whenever either side's cost is unavailable (no `sqlite_stat1`), so a
/// no-analyze database is byte-identical to today.
fn two_table_second_drives_cheaper(
&self,
first_name: &str,
first_meta: &TableMeta,
first_local: usize,
second_name: &str,
second_meta: &TableMeta,
second_local: usize,
) -> bool {
let (Some((first_scan_run, first_scan_out)), Some((second_scan_run, second_scan_out))) = (
self.join_scan_cost(first_name),
self.join_scan_cost(second_name),
) else {
return false;
};
let (Some((first_seek_run, _)), Some((second_seek_run, _))) = (
self.join_seek_cost(first_name, first_meta, first_local),
self.join_seek_cost(second_name, second_meta, second_local),
) else {
return false;
};
// Driving `from.first` (scan), seeking the second table as the inner.
let drive_first = logest_add(first_scan_run, second_seek_run + first_scan_out);
// Driving the second table (scan), seeking `from.first` as the inner.
let drive_second = logest_add(second_scan_run, first_seek_run + second_scan_out);
drive_second < drive_first
}
/// Cost-based join-order decision for a *two-table* equi-join: when driving
/// from `from.first` would seek the inner table by a *secondary* index while
/// driving from the second table would instead seek `from.first` by its
/// cheaper rowid / INTEGER PRIMARY KEY, prefer the latter — matching sqlite,
/// which makes the rowid-seekable table the inner one (a rowid seek is
/// cheaper than a secondary-index seek). Reordering the drive changes the
/// output *row order* for an unordered query (rows come out in the second
/// table's scan order), so it must mirror sqlite exactly.
///
/// Tightly gated — returns `Some((driver_join_local, first_meta, first_ipk))`
/// *only* when ALL hold, else `None` (leave the join exactly as today):
/// - exactly two tables (`from.joins.len() == 1`);
/// - a plain `INNER` / comma / `CROSS` join with an `ON` (NATURAL/USING/outer
/// never — those constrain or fix the order);
/// - both sources are plain base tables in `main` (no subquery/CTE/view/TVF,
/// not schema-qualified);
/// - the `ON` is a single top-level `=` equating `from.first`'s join column
/// with the second table's join column, where `from.first`'s column IS
/// `from.first`'s own rowid IPK and the second table's column is NOT its own
/// rowid IPK (so the swap is unambiguously the rowid-inner one — if both are
/// rowid, or neither, leave as-is).
///
/// The returned `driver_join_local` is the *local* column index (within the
/// second table) whose value is used to seek `from.first` by rowid; `first_ipk`
/// is `from.first`'s IPK column index. The reorder is EXECUTION-only — the
/// produced columns and rows stay in DECLARED order (`[first cols, second
/// cols]`), see [`exec_two_table_rowid_inner_swap`](Self::exec_two_table_rowid_inner_swap).
fn two_table_rowid_inner_swap(&self, from: &FromClause) -> Option<(usize, TableMeta, usize)> {
if from.joins.len() != 1 {
return None;
}
let join = &from.joins[0];
// Plain INNER / comma / CROSS only — never LEFT/RIGHT/FULL/NATURAL/USING.
if !matches!(join.kind, JoinKind::Inner) || join.natural || !join.using.is_empty() {
return None;
}
let on = join.on.as_ref()?;
// Both sources must be plain base tables in `main`.
let is_plain_main = |tref: &TableRef| -> bool {
tref.subquery.is_none()
&& tref.tvf_args.is_none()
&& !self.is_bare_tvf(tref)
&& tref.schema.is_none()
&& self.lookup_cte(&tref.name, tref.alias.as_deref()).is_none()
&& !self.is_view(&tref.name)
&& self.unqualified_db(&tref.name) == DbRef::Main
};
let first_ref = &from.first;
let second_ref = &join.table;
if !is_plain_main(first_ref) || !is_plain_main(second_ref) {
return None;
}
let first_meta = self
.table_meta(&first_ref.name, first_ref.alias.as_deref())
.ok()?;
let second_meta = self
.table_meta(&second_ref.name, second_ref.alias.as_deref())
.ok()?;
// `from.first` must have a rowid IPK; the swap makes it the rowid inner.
let first_ipk = first_meta.ipk?;
// Resolve the `ON` `=` sides against the DECLARED `[first, second]` column
// layout (first's columns then second's).
let mut on = on;
while let Expr::Paren(inner) = on {
on = inner;
}
let first_width = first_meta.columns.len();
let mut combined = first_meta.columns.clone();
combined.extend(second_meta.columns.iter().cloned());
let (a, b) = match on {
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => (col_index(left, &combined)?, col_index(right, &combined)?),
_ => return None,
};
// One side must be `from.first`'s IPK, the other a second-table column.
let (first_side_ipk, second_local) = if a == first_ipk && b >= first_width {
(true, b - first_width)
} else if b == first_ipk && a >= first_width {
(true, a - first_width)
} else {
(false, 0)
};
if !first_side_ipk {
return None;
}
// The second table's join column must NOT be its own rowid IPK — else both
// sides are rowid-seekable and which one is the inner is a COST decision:
// sqlite drives whichever table it must scan the fewest rows of. Without
// stats (`two_table_second_drives_cheaper` ⇒ false) keep the historical
// behaviour (leave as declaration order, `from.first` drives); with stats,
// fire the swap (drive the second, seek `from.first` by rowid) only when
// the LogEst path cost of driving the second is strictly lower.
if second_meta.ipk == Some(second_local) {
let first_local = first_ipk;
if self.two_table_second_drives_cheaper(
&first_ref.name,
&first_meta,
first_local,
&second_ref.name,
&second_meta,
second_local,
) {
return Some((second_local, first_meta, first_ipk));
}
return None;
}
Some((second_local, first_meta, first_ipk))
}
/// Execute the reordered two-table join decided by
/// [`two_table_rowid_inner_swap`](Self::two_table_rowid_inner_swap): scan the
/// SECOND table as the driver and, for each driver row, seek `from.first` by
/// rowid (its IPK) to the driver row's join value. Output rows come out in the
/// second table's scan order (matching sqlite), but the produced columns and
/// every row stay in DECLARED order `[first cols, second cols]`, so `SELECT *`
/// / `t.*` expansion and the projection see the same layout as the unreordered
/// join. The full `ON` is re-evaluated on each assembled row (superset
/// invariant), so every rowid-coercion corner is filtered exactly as the
/// nested-loop path would.
#[allow(clippy::too_many_arguments)]
fn exec_two_table_rowid_inner_swap(
&self,
sel: &Select,
from: &FromClause,
first_columns: &[ColumnInfo],
driver_join_local: usize,
first_meta: &TableMeta,
params: &Params,
with_rowid: bool,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
let join = &from.joins[0];
let encoding = self.backend.source().header().text_encoding;
// Driver = the second table, scanned in its natural (rowid) order — or in a
// covering secondary index's key order when one holds every second-table
// column the query needs (matching sqlite's covering-index scan of the
// driver, which then fixes the join's output row order). When `with_rowid`,
// the driver source appends its own trailing hidden rowid column.
let (driver_columns, driver_rows) =
self.resolve_join_scan_source_rowid(sel, from, &join.table, params, with_rowid)?;
// `first_columns` already carries `from.first`'s trailing hidden rowid column
// (from the earlier `resolve_join_scan_source_rowid`) when `with_rowid`; the
// seeked rowid supplies its value below. The seek payload only decodes the
// user columns, so isolate them here.
let first_user_width = first_columns.iter().filter(|c| !c.hidden).count();
// Declared output layout: `[first user cols, first rowid?, second user cols,
// second rowid?]` — matching `fold_joins_rowid`'s per-table interleaving so a
// qualified rowid resolves identically to the non-swap path.
let mut out_columns = first_columns.to_vec();
out_columns.extend(driver_columns.iter().cloned());
let on = join.on.as_ref();
let mut cur = TableCursor::new(self.backend.source(), first_meta.root);
let mut joined: Vec<Vec<Value>> = Vec::new();
for driver in &driver_rows {
// Coerce the driver's join value to a candidate rowid for `from.first`.
let key = &driver[driver_join_local];
let candidate = match key {
Value::Integer(i) => Some(*i),
Value::Real(_) | Value::Text(_) => match eval::to_number(key) {
Value::Integer(i) => Some(i),
Value::Real(r) if r == (r as i64) as f64 => Some(r as i64),
_ => None,
},
Value::Null | Value::Blob(_) => None,
};
if let Some(rid) = candidate
&& cur.seek(rid)?
{
let mut first_row =
self.decode_full_row(first_meta, rid, &cur.payload()?, encoding)?;
debug_assert_eq!(first_row.len(), first_user_width);
// Assemble in DECLARED order: first table's user row, its hidden
// rowid (the seeked `rid`), then the driver's row (whose own hidden
// rowid already trails it).
if with_rowid {
first_row.push(Value::Integer(rid));
}
let mut combined = first_row;
combined.extend(driver.iter().cloned());
let keep = match on {
Some(on) => {
let ctx = row_ctx(&combined, &out_columns, None, params);
eval::truth(&eval::eval(on, &ctx)?) == Some(true)
}
None => true,
};
if keep {
joined.push(combined);
}
}
}
Ok((out_columns, joined))
}
/// Whether table `name` (with `meta`) can be *sought* by its local column
/// `local` — i.e. that column is the table's rowid INTEGER PRIMARY KEY, or the
/// *leading* column of a usable plain secondary index (non-partial,
/// non-expression). This is the "is the inner cheaply seekable" test the
/// forward index/rowid join paths already exploit; the secondary-index-inner
/// swap uses it to detect when the SECOND table is NOT seekable (so driving it
/// as the scan and seeking `from.first` is the right reorder).
fn is_local_col_seekable(&self, name: &str, meta: &TableMeta, local: usize) -> bool {
if meta.ipk == Some(local) {
return true;
}
self.indexes_of(name).is_ok_and(|ixs| {
ixs.iter().any(|i| {
i.partial.is_none() && i.key_exprs.is_none() && i.cols.first() == Some(&local)
})
})
}
/// Cost-based join-order decision for a *two-table* equi-join, the
/// secondary-index analogue of [`two_table_rowid_inner_swap`](Self::two_table_rowid_inner_swap):
/// when driving from `from.first` cannot seek the second table (it is not
/// rowid/index seekable on its join column) yet `from.first`'s own join column
/// is the *leading* column of a usable plain secondary index, prefer to SCAN the
/// second table and seek `from.first` by that index as the inner — matching
/// sqlite, which makes the seekable table the inner one. Reordering the drive
/// changes the output *row order* for an unordered query (rows come out in the
/// second table's scan order), so it must mirror sqlite exactly.
///
/// Tightly gated — returns `Some((driver_join_local, first_meta, idx))` *only*
/// when ALL hold, else `None` (leave the join exactly as today):
/// - exactly two tables (`from.joins.len() == 1`);
/// - a plain `INNER` / comma / `CROSS` join with an `ON` (NATURAL/USING/outer
/// never — those constrain or fix the order);
/// - both sources are plain base tables in `main` (no subquery/CTE/view/TVF,
/// not schema-qualified);
/// - the `ON` is a single top-level `=` equating `from.first`'s join column with
/// the second table's join column;
/// - `from.first`'s join column is NOT its own rowid IPK (that is the rowid
/// slice's job — the two must never both fire) but IS the leading column of a
/// usable plain secondary index on `from.first`;
/// - the second table's join column is NOT seekable (neither rowid IPK nor an
/// index-leading column) — if it *were* seekable the existing forward
/// index/rowid seek path already makes it the inner, so leave those alone.
///
/// The returned `driver_join_local` is the *local* column index (within the
/// second table) whose value seeks `from.first`'s index; `idx` is that index on
/// `from.first`. The reorder is EXECUTION-only — produced columns and rows stay
/// in DECLARED order (`[first cols, second cols]`), see
/// [`exec_two_table_index_inner_swap`](Self::exec_two_table_index_inner_swap).
fn two_table_index_inner_swap(
&self,
from: &FromClause,
) -> Option<(usize, TableMeta, IndexMeta)> {
if from.joins.len() != 1 {
return None;
}
let join = &from.joins[0];
// Plain INNER / comma / CROSS only — never LEFT/RIGHT/FULL/NATURAL/USING.
if !matches!(join.kind, JoinKind::Inner) || join.natural || !join.using.is_empty() {
return None;
}
let on = join.on.as_ref()?;
// Both sources must be plain base tables in `main`.
let is_plain_main = |tref: &TableRef| -> bool {
tref.subquery.is_none()
&& tref.tvf_args.is_none()
&& !self.is_bare_tvf(tref)
&& tref.schema.is_none()
&& self.lookup_cte(&tref.name, tref.alias.as_deref()).is_none()
&& !self.is_view(&tref.name)
&& self.unqualified_db(&tref.name) == DbRef::Main
};
let first_ref = &from.first;
let second_ref = &join.table;
if !is_plain_main(first_ref) || !is_plain_main(second_ref) {
return None;
}
let first_meta = self
.table_meta(&first_ref.name, first_ref.alias.as_deref())
.ok()?;
let second_meta = self
.table_meta(&second_ref.name, second_ref.alias.as_deref())
.ok()?;
// A WITHOUT ROWID `from.first` is seeked by its clustered PK, not the
// secondary-index machinery this slice reuses — leave it to other paths.
if first_meta.without_rowid {
return None;
}
// Resolve the `ON` `=` sides against the DECLARED `[first, second]` column
// layout (first's columns then second's).
let mut on = on;
while let Expr::Paren(inner) = on {
on = inner;
}
let first_width = first_meta.columns.len();
let mut combined = first_meta.columns.clone();
combined.extend(second_meta.columns.iter().cloned());
let (a, b) = match on {
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => (col_index(left, &combined)?, col_index(right, &combined)?),
_ => return None,
};
// One side must be a `from.first` column, the other a second-table column.
let (first_local, second_local) = if a < first_width && b >= first_width {
(a, b - first_width)
} else if b < first_width && a >= first_width {
(b, a - first_width)
} else {
return None;
};
// `from.first`'s join column must NOT be its rowid IPK — that is the rowid
// slice (`two_table_rowid_inner_swap`); the two must be mutually exclusive.
if first_meta.ipk == Some(first_local) {
return None;
}
// `from.first`'s join column must be the LEADING column of a usable plain
// secondary index (non-partial, non-expression). Pick the first such index
// by catalog order for a deterministic choice matching the EQP emitter.
let idx = self
.indexes_of(&first_ref.name)
.ok()?
.into_iter()
.find(|i| {
i.partial.is_none() && i.key_exprs.is_none() && i.cols.first() == Some(&first_local)
})?;
// The index seek must be affinity-sound (sqlite's `sqlite3IndexAffinityOk`):
// a NUMERIC comparison cannot seek a text/blob-stored index, so an INTEGER
// driver key against `from.first`'s untyped/TEXT index column would miss
// matches — decline the swap (keep the forward materialise, which filters
// with the correct affinity) exactly as the forward `index_join_seek` does.
if !index_seek_affinity_ok(
second_meta.columns[second_local].affinity,
first_meta.columns[first_local].affinity,
) {
return None;
}
// When the second table's join column is ALSO seekable (rowid or
// index-leading), which table is the inner is a COST decision. Without
// stats keep the historical behaviour: the existing forward seek path makes
// the second table the inner (declaration order drives `from.first`), so
// decline here. With stats, fire this swap (drive the second, seek
// `from.first` by its index) only when the LogEst path cost of driving the
// second is strictly lower — matching sqlite, which drives the table it
// scans the fewest rows of.
if self.is_local_col_seekable(&second_ref.name, &second_meta, second_local) {
if self.two_table_second_drives_cheaper(
&first_ref.name,
&first_meta,
first_local,
&second_ref.name,
&second_meta,
second_local,
) {
return Some((second_local, first_meta, idx));
}
return None;
}
Some((second_local, first_meta, idx))
}
/// Cost-based join-order search for THREE OR MORE tables (the N-table
/// generalisation of [`two_table_rowid_inner_swap`](Self::two_table_rowid_inner_swap)
/// / [`two_table_index_inner_swap`](Self::two_table_index_inner_swap)).
///
/// sqlite chooses the drive order that minimises total cost: a table sought by
/// ROWID/INTEGER-PRIMARY-KEY is the cheapest inner, then a plain secondary-index
/// seek, then a full scan — so it pulls the seekable tables into the inner
/// positions and drives from a table it must scan. We model that with a greedy
/// "cheapest connected next" search rooted at each candidate driver (small N for
/// a hand-written query), scoring by the summed per-table access cost (rowid
/// seek `0` < index seek `1` < materialised scan `2`, weighted so the cheaper
/// inner always wins) and keeping the least-cost order (declaration order as the
/// tie-break — a stable choice that matches sqlite's, which we VERIFY on
/// asymmetric data in `tests/join_order_ntable.rs`).
///
/// Returns `Some((reordered, remap))` — a permuted `FromClause` whose `first` is
/// the chosen driver and whose `joins` place each remaining table (carrying the
/// `ON` edge that connects it to the already-placed tables) in cost order, plus
/// `remap`: for each DECLARED column slot, the slot it occupies in the permuted
/// execution layout, so the caller can restore declared column order after the
/// fold. Returns `None` (leave declaration-order execution unchanged — rows may
/// then differ from sqlite, as today, which is preferable to a WRONG order) when
/// any gate fails or the chosen order is not the declaration order AND we cannot
/// place every table by a connecting equi-edge.
///
/// Tightly gated — ALL must hold, else `None`:
/// - at least three tables (`from.joins.len() >= 2`);
/// - every join is a plain `INNER` / comma / `CROSS` with an `ON` (never
/// LEFT/RIGHT/FULL/NATURAL/USING — those constrain or fix the order);
/// - every source is a plain base table in `main` (no subquery/CTE/view/TVF, not
/// schema-qualified) with no `INDEXED BY` / `NOT INDEXED` hint;
/// - every `ON` is a single top-level `=` equating a column of one table with a
/// column of another (a self-equality or a non-column side declines);
/// - the join graph is connected from the chosen driver (no cross-product step).
///
/// Returns four values: the reordered clause, the declared-to-execution column
/// remap, the table permutation (placement position to declared table index), and
/// whether every inner is a single-match seek. The last two serve the VDBE swap
/// path (the permutation is its `loop_order`; the flag gates whether the reorder
/// is order-safe there); the tree-walker exec / EQP callers use only the first two.
#[allow(clippy::type_complexity)]
fn ntable_join_order(
&self,
sel: &Select,
from: &FromClause,
) -> Option<(FromClause, Vec<usize>, Vec<usize>, bool)> {
if from.joins.len() < 2 {
return None;
}
let is_plain_main = |tref: &TableRef| -> bool {
tref.subquery.is_none()
&& tref.tvf_args.is_none()
&& !self.is_bare_tvf(tref)
&& tref.schema.is_none()
&& tref.index_hint.is_none()
&& self.lookup_cte(&tref.name, tref.alias.as_deref()).is_none()
&& !self.is_view(&tref.name)
&& self.unqualified_db(&tref.name) == DbRef::Main
};
// Collect every table reference in declared order (`first`, then each join's
// table) and verify each join is a plain INNER with an `ON`.
let trefs: Vec<&TableRef> = core::iter::once(&from.first)
.chain(from.joins.iter().map(|j| &j.table))
.collect();
for tref in &trefs {
if !is_plain_main(tref) {
return None;
}
}
for join in &from.joins {
if !matches!(join.kind, JoinKind::Inner) || join.natural || !join.using.is_empty() {
return None;
}
join.on.as_ref()?;
}
let n = trefs.len();
// Per-table metadata and the declared column layout, tracking each table's
// column block `[start, start+width)` so an `ON`'s global column index maps
// back to `(table, local)`.
let mut metas: Vec<TableMeta> = Vec::with_capacity(n);
let mut declared_cols: Vec<ColumnInfo> = Vec::new();
let mut block_start: Vec<usize> = Vec::with_capacity(n);
for tref in &trefs {
let meta = self.table_meta(&tref.name, tref.alias.as_deref()).ok()?;
block_start.push(declared_cols.len());
declared_cols.extend(meta.columns.iter().cloned());
metas.push(meta);
}
let owner_of = |global: usize| -> usize {
// The last block whose start is <= global owns the column.
block_start.iter().rposition(|&s| s <= global).unwrap_or(0)
};
// A `WHERE` restriction on a SINGLE table shifts sqlite's driver choice
// toward that table by its selectivity — a cost factor we do not model.
// Rather than risk a WRONG row order, decline whenever a top-level `WHERE`
// conjunct references columns of exactly one base table (a single-table
// restriction, not a pure cross-table equi-join) — leaving the join in
// declaration-order execution (its row order may then differ from sqlite,
// which is acceptable; only a wrong order is not). A conjunct referencing no
// resolvable column (a constant, or a column we cannot place) also declines,
// conservatively. Subqueries are walked shallowly, so an uncorrelated
// subquery restriction is treated as no-column → decline.
if let Some(w) = sel.where_clause.as_ref() {
let mut conjuncts: Vec<&Expr> = Vec::new();
and_conjuncts(w, &mut conjuncts);
for c in conjuncts {
let mut tables: Vec<usize> = Vec::new();
let mut unresolved = false;
walk_shallow_columns(c, &mut |_schema, table, column, quoted| {
let cref = Expr::Column {
schema: None,
table: table.map(|t| t.to_string()),
column: column.to_string(),
quoted,
span: Span::none(),
};
match col_index(&cref, &declared_cols) {
Some(g) => {
let t = owner_of(g);
if !tables.contains(&t) {
tables.push(t);
}
}
None => unresolved = true,
}
});
if unresolved {
return None;
}
// A single-table restriction (exactly one table referenced) declines.
if tables.len() == 1 {
return None;
}
}
}
// Extract each `ON` as an undirected equi-edge between two distinct tables,
// recording the join index whose `ON` it is (so the reordered clause reuses
// that exact predicate). A self-equality or a non-column side declines.
struct Edge {
a: usize,
b: usize,
join_idx: usize,
}
let mut edges: Vec<Edge> = Vec::with_capacity(from.joins.len());
for (ji, join) in from.joins.iter().enumerate() {
let mut on = join.on.as_ref()?;
while let Expr::Paren(inner) = on {
on = inner;
}
let (l, r) = match on {
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => (
col_index(left, &declared_cols)?,
col_index(right, &declared_cols)?,
),
_ => return None,
};
let (ta, tb) = (owner_of(l), owner_of(r));
if ta == tb {
return None;
}
edges.push(Edge {
a: ta,
b: tb,
join_idx: ji,
});
}
// Greedy cost-ordered placement from a given driver. Returns the placement
// order (table indices, driver first) each paired with the join index whose
// `ON` connects it, the COARSE tie-break cost (rowid `0` < index/PK `1` <
// scan `2`, summed — the historical ordering key), and, when every table's
// LogEst cost is available (`sqlite_stat1` present for all), the true LogEst
// `wherePathSolver` path cost for driver selection. `None` if the graph is
// not connected from this driver (a cross-product step).
//
// The greedy *ordering* of the inners is unchanged (coarse cost, matching the
// corpus-verified "seekable tables pulled inner" heuristic). Only the DRIVER
// choice becomes LogEst-cost-aware: with stats sqlite drives the table it
// scans the fewest rows of, so a large declared-first table no longer wins by
// default. The LogEst total is `LogEstAdd`-accumulated exactly as
// `wherePathSolver`: seed `rUnsort = scanRun(driver)`, path rows `= scanOut`,
// then per inner `rUnsort = LogEstAdd(seekRun + pathRows, rUnsort)` and
// `pathRows += seekOut`.
let place_from =
|driver: usize| -> Option<(Vec<(usize, Option<usize>)>, u64, Option<i16>)> {
let mut placed = alloc::vec![false; n];
placed[driver] = true;
let mut order: Vec<(usize, Option<usize>)> = alloc::vec![(driver, None)];
// Accumulated left columns, in placement order, for the seek helpers.
let mut left_columns: Vec<ColumnInfo> = metas[driver].columns.clone();
let mut total: u64 = 0;
// LogEst path accumulation for the driver's scan (None if no stats
// for the driver ⇒ the whole LogEst total is unavailable → driver
// selection stays on the coarse key, byte-identical to before).
let driver_name = &trefs[driver].name;
let mut log_state: Option<(i16, i16)> = self.join_scan_cost(driver_name);
while order.len() < n {
// Among unplaced tables reachable by an edge to a placed table,
// pick the cheapest access; tie-break by earliest declared table.
let mut best: Option<(u64, usize, usize)> = None; // (cost, table, join_idx)
for e in &edges {
let (placed_side, cand) = if placed[e.a] && !placed[e.b] {
(e.a, e.b)
} else if placed[e.b] && !placed[e.a] {
(e.b, e.a)
} else {
continue;
};
let _ = placed_side;
// Synthesize an INNER `Join` whose inner is the CANDIDATE table
// (carrying this edge's `ON`), evaluated against the accumulated
// left columns, to reuse the exact seek predicates. The original
// `from.joins[e.join_idx].table` may be the OTHER endpoint (when
// this edge is being traversed from the opposite side), so the
// table must be swapped in rather than reused as-is.
let cand_join = Join {
kind: JoinKind::Inner,
table: trefs[cand].clone(),
on: from.joins[e.join_idx].on.clone(),
natural: false,
using: Vec::new(),
};
// rowid seek `0` (cheapest) < secondary-index / clustered-PK
// seek `1` < materialised scan `2`.
let cost = if self.rowid_join_seek(&cand_join, &left_columns).is_some() {
0
} else if self.index_join_seek(&cand_join, &left_columns).is_some()
|| self
.without_rowid_pk_join_seek(&cand_join, &left_columns)
.is_some()
{
1
} else {
2
};
// Weight so a cheaper inner strictly dominates regardless of
// declared position; tie-break to the earliest declared table.
let key = (cost, cand as u64);
match best {
Some((bc, bt, _)) if (bc, bt as u64) <= key => {}
_ => best = Some((cost, cand, e.join_idx)),
}
}
let (cost, cand, join_idx) = best?; // not connected → decline
total += cost;
// Fold the placed inner into the LogEst path cost (if still live).
// The inner's join column is the endpoint of this edge on `cand`;
// resolve it to a local column index for the seek-cost estimate.
if let Some((r_unsort, path_rows)) = log_state {
// Resolve `cand`'s join-column local index from the edge's ON.
let cand_local = ntable_edge_local(
&from.joins[join_idx],
cand,
&block_start,
&declared_cols,
);
log_state = match cand_local.and_then(|local| {
self.join_seek_cost(&trefs[cand].name, &metas[cand], local)
}) {
Some((seek_run, seek_out)) => {
let new_unsort =
logest_add(seek_run.saturating_add(path_rows), r_unsort);
Some((new_unsort, path_rows.saturating_add(seek_out)))
}
// A non-seekable / stats-less inner: the LogEst total is
// unavailable, so fall back to the coarse driver key.
None => None,
};
}
placed[cand] = true;
left_columns.extend(metas[cand].columns.iter().cloned());
order.push((cand, Some(join_idx)));
}
Some((order, total, log_state.map(|(run, _)| run)))
};
// Search every driver; keep the least-cost placement. The primary key is the
// LogEst path cost WHEN available for every driver (so the smallest-scan
// driver wins, matching sqlite with stats); otherwise the coarse total. The
// earliest driver (declaration order) breaks ties, a deterministic choice
// that matches sqlite's `wherePathSolver` incumbent-keeps-on-tie rule.
// Precompute all placements so we can tell whether EVERY driver has a LogEst
// cost (mixing LogEst and coarse across drivers would be incomparable).
let placements: Vec<(usize, Vec<(usize, Option<usize>)>, u64, Option<i16>)> = (0..n)
.filter_map(|driver| {
place_from(driver).map(|(order, coarse, logcost)| (driver, order, coarse, logcost))
})
.collect();
let use_logest = !placements.is_empty() && placements.iter().all(|p| p.3.is_some());
let mut best: Option<(i64, u64, Vec<(usize, Option<usize>)>)> = None;
for (_, order, coarse, logcost) in placements {
// Primary comparison key: LogEst path cost (if usable) else the coarse
// total lifted into the same slot; the coarse total is the secondary key
// so a LogEst tie still prefers the historical (cheaper-inner) ordering.
let primary: i64 = if use_logest {
logcost.expect("checked all Some") as i64
} else {
coarse as i64
};
match &best {
Some((bp, bc, _)) if (*bp, *bc) <= (primary, coarse) => {}
_ => best = Some((primary, coarse, order)),
}
}
let (_, _, order) = best?;
// If the least-cost order IS the declaration order, leave the join to the
// ordinary declaration-order path (identical execution, no remap needed).
if order.iter().map(|&(t, _)| t).eq(0..n) {
return None;
}
// Build the permuted `FromClause`: driver as `first`, then each placed table
// as an INNER join carrying its connecting `ON`.
let clone_tref = |t: usize| trefs[t].clone();
let reordered = FromClause {
first: clone_tref(order[0].0),
joins: order[1..]
.iter()
.map(|&(t, ji)| {
let ji = ji.expect("non-driver carries a join edge");
Join {
kind: JoinKind::Inner,
table: clone_tref(t),
on: from.joins[ji].on.clone(),
natural: false,
using: Vec::new(),
}
})
.collect(),
};
// `remap[declared_slot] = execution_slot`. The execution layout is the
// tables in placement order; compute each placed table's execution block
// start, then map declared slot → execution slot column-by-column.
let mut exec_block_start = alloc::vec![0usize; n];
let mut acc = 0usize;
for &(t, _) in &order {
exec_block_start[t] = acc;
acc += metas[t].columns.len();
}
let mut remap: Vec<usize> = Vec::with_capacity(declared_cols.len());
for (t, meta) in metas.iter().enumerate() {
for local in 0..meta.columns.len() {
remap.push(exec_block_start[t] + local);
}
}
// The table permutation (placement position → DECLARED table index) — this is
// exactly the nested-loop `loop_order` the VDBE compiler needs to reproduce
// the reorder. Plus whether EVERY inner (non-driver) is joined via a ≤1-match
// seek on ITS side — its rowid IPK or a single-column UNIQUE index — so the
// combined row count and order are fixed by the driver's scan alone (letting
// the VDBE's scan+filter reproduce the tree-walker's seek order). Any inner
// that can multi-match (a non-unique / composite index) makes this `false`, so
// the VDBE path stays deferred while the tree-walker still owns the reorder.
let perm: Vec<usize> = order.iter().map(|&(t, _)| t).collect();
let all_inners_single_match = order[1..].iter().all(|&(t, ji)| {
let Some(ji) = ji else { return false };
let Some(local) = ntable_edge_local(&from.joins[ji], t, &block_start, &declared_cols)
else {
return false;
};
metas[t].ipk == Some(local)
|| self.indexes_of(&trefs[t].name).is_ok_and(|ixs| {
ixs.iter()
.any(|ix| ix.unique && ix.cols.len() == 1 && ix.cols[0] == local)
})
});
Some((reordered, remap, perm, all_inners_single_match))
}
/// Whether the chosen secondary index on `from.first` COVERS the query — every
/// `from.first` column the query needs is stored in the index (its key columns
/// plus the always-present rowid) — so the seek reads only the index b-tree and
/// sqlite renders `USING COVERING INDEX` (else `USING INDEX`). Used by the EQP
/// emitter in lockstep with the executor swap (the executor still reads the
/// table row, but the plan *label* must match sqlite's cost model). Conservative:
/// any construct whose `from.first`-column footprint we cannot enumerate exactly
/// (correlated subquery / EXISTS / IN-SELECT, window/aggregate `FILTER`/`OVER`,
/// a generated column on `from.first`) makes it report *not* covering, which is
/// the safe `USING INDEX` render.
/// Whether a forward inner-seek's index `idx` (on the inner table
/// `inner_meta`, named/aliased `inner_names`) COVERS every column of that
/// table the query references anywhere — the label SQLite renders as `USING
/// COVERING INDEX` rather than `USING INDEX` for a join seek. Conservative:
/// any subquery in a scanned clause, a generated inner column, or a qualified
/// inner reference to an unknown column makes it `false` (so it renders the
/// plain `INDEX` and never over-claims `COVERING` vs the oracle).
fn join_seek_index_covers(
&self,
sel: &Select,
from: &FromClause,
inner_names: &[&str],
inner_meta: &TableMeta,
idx: &IndexMeta,
) -> bool {
if inner_meta.generated.iter().any(|g| g.is_some()) {
return false;
}
let covered = |ci: usize| idx.cols.contains(&ci) || inner_meta.ipk == Some(ci);
let is_inner = |t: &str| {
inner_names
.iter()
.any(|n| !n.is_empty() && n.eq_ignore_ascii_case(t))
};
// Collect every column reference (and note any subquery, which we cannot
// resolve against the inner table) across the clauses that may reference it.
let mut refs: Vec<(Option<String>, String)> = Vec::new();
let mut has_subquery = false;
let mut collect = |node: &Expr| match node {
Expr::Column { table, column, .. } => refs.push((table.clone(), column.clone())),
Expr::Subquery(_) | Expr::Exists { .. } | Expr::InSelect { .. } => has_subquery = true,
_ => {}
};
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
window::visit(expr, &mut collect);
}
}
if let Some(w) = &sel.where_clause {
window::visit(w, &mut collect);
}
for j in &from.joins {
if let Some(on) = &j.on {
window::visit(on, &mut collect);
}
}
for t in &sel.order_by {
window::visit(&t.expr, &mut collect);
}
for gexpr in &sel.group_by {
window::visit(gexpr, &mut collect);
}
if let Some(h) = &sel.having {
window::visit(h, &mut collect);
}
if has_subquery {
return false;
}
// A wildcard that expands the inner table needs every inner column covered.
let all_covered = || (0..inner_meta.columns.len()).all(covered);
for rc in &sel.columns {
match rc {
ResultColumn::Wildcard if !all_covered() => return false,
ResultColumn::TableWildcard(t) if is_inner(t) && !all_covered() => return false,
_ => {}
}
}
// Every inner-owned column reference must be held by the index.
for (t, col) in &refs {
let in_inner = inner_meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(col));
let is_rowid = matches!(
col.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
);
// A qualified ref names its table; an unqualified ref is the inner's
// only when its name is an inner column (a same-named column of another
// table would make the reference ambiguous, i.e. an invalid query).
let inner_owned = match t.as_deref() {
Some(tt) => is_inner(tt),
None => in_inner.is_some(),
};
if inner_owned {
match in_inner {
Some(ci) if !covered(ci) => return false,
// A qualified inner ref to a rowid alias is covered; any other
// unresolved inner column defeats the covering claim.
None if !is_rowid => return false,
_ => {}
}
}
}
true
}
fn index_swap_covers(
&self,
sel: &Select,
from: &FromClause,
first_meta: &TableMeta,
second_meta: &TableMeta,
idx: &IndexMeta,
) -> bool {
// A generated column on `from.first` can never be proven covered.
if first_meta.generated.iter().any(|g| g.is_some()) {
return false;
}
let first_names: [&str; 2] = [&from.first.name, from.first.alias.as_deref().unwrap_or("")];
let second_names: [&str; 2] = [
&from.joins[0].table.name,
from.joins[0].table.alias.as_deref().unwrap_or(""),
];
let idx_covers = |ci: usize| idx.cols.contains(&ci) || first_meta.ipk == Some(ci);
let is_first = |t: &str| {
first_names
.iter()
.any(|n| !n.is_empty() && n.eq_ignore_ascii_case(t))
};
let is_second = |t: &str| {
second_names
.iter()
.any(|n| !n.is_empty() && n.eq_ignore_ascii_case(t))
};
// Resolve one column reference; return `Some(false)` when it names an
// uncovered `from.first` column, `Some(true)` when it is covered or belongs
// to the second table / rowid, and `None` when we cannot decide (bail).
let resolve = |table: Option<&str>, column: &str| -> Option<bool> {
let in_first = first_meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column));
let in_second = second_meta
.columns
.iter()
.any(|c| c.name.eq_ignore_ascii_case(column));
match table {
Some(t) if is_first(t) => match in_first {
Some(ci) => Some(idx_covers(ci)),
None => {
// `first.rowid`/`_rowid_`/`oid` is covered (rowid always in idx).
if matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) {
Some(true)
} else {
None
}
}
},
Some(t) if is_second(t) => Some(true),
Some(_) => None, // unknown qualifier
None => {
// Unqualified: covered if it is not a `from.first` column, or a
// covered one. Ambiguous (in both) → still fine as long as the
// first-table copy is covered.
match in_first {
Some(ci) => Some(idx_covers(ci)),
None => {
if in_second {
Some(true)
} else if matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) {
// Bare rowid is ambiguous across two tables; bail.
None
} else {
Some(true)
}
}
}
}
}
};
// Walk an expression; `false` means "found an uncovered/undecidable ref".
fn walk(e: &Expr, resolve: &dyn Fn(Option<&str>, &str) -> Option<bool>) -> bool {
match e {
Expr::Literal(_) | Expr::Parameter(_) => true,
Expr::Column { table, column, .. } => {
resolve(table.as_deref(), column) == Some(true)
}
Expr::Unary { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. }
| Expr::Paren(expr) => walk(expr, resolve),
Expr::Binary { left, right, .. } => walk(left, resolve) && walk(right, resolve),
Expr::Between {
expr, low, high, ..
} => walk(expr, resolve) && walk(low, resolve) && walk(high, resolve),
Expr::InList { expr, list, .. } => {
walk(expr, resolve) && list.iter().all(|x| walk(x, resolve))
}
Expr::RowValue(items) => items.iter().all(|x| walk(x, resolve)),
Expr::Function {
args, filter, over, ..
} => over.is_none() && filter.is_none() && args.iter().all(|x| walk(x, resolve)),
Expr::Case {
operand,
when_then,
else_result,
} => {
operand.as_deref().map(|o| walk(o, resolve)).unwrap_or(true)
&& when_then
.iter()
.all(|(w, t)| walk(w, resolve) && walk(t, resolve))
&& else_result
.as_deref()
.map(|x| walk(x, resolve))
.unwrap_or(true)
}
// Any subquery / EXISTS / IN-SELECT: cannot enumerate its
// `from.first` footprint here — decline (report not-covering).
Expr::Subquery(_) | Expr::Exists { .. } | Expr::InSelect { .. } => false,
}
}
// A wildcard over `from.first` references *all* its columns.
let all_first_covered = (0..first_meta.columns.len()).all(idx_covers);
for rc in &sel.columns {
match rc {
ResultColumn::Wildcard => {
if !all_first_covered {
return false;
}
}
ResultColumn::TableWildcard(t) => {
if is_first(t) && !all_first_covered {
return false;
}
}
ResultColumn::Expr { expr, .. } => {
if !walk(expr, &resolve) {
return false;
}
}
}
}
// The join `ON`, the WHERE, GROUP BY / HAVING, and ORDER BY all reference
// `from.first` too.
if let Some(on) = from.joins[0].on.as_ref()
&& !walk(on, &resolve)
{
return false;
}
if let Some(w) = sel.where_clause.as_ref()
&& !walk(w, &resolve)
{
return false;
}
if !sel.group_by.iter().all(|e| walk(e, &resolve)) {
return false;
}
if let Some(h) = sel.having.as_ref()
&& !walk(h, &resolve)
{
return false;
}
if !sel.order_by.iter().all(|t| walk(&t.expr, &resolve)) {
return false;
}
let _ = idx;
true
}
/// Execute the reordered two-table join decided by
/// [`two_table_index_inner_swap`](Self::two_table_index_inner_swap): scan the
/// SECOND table as the driver and, for each driver row, seek `from.first` by its
/// leading secondary index to the driver row's join value. Output rows come out
/// in the second table's scan order (matching sqlite), but the produced columns
/// and every row stay in DECLARED order `[first cols, second cols]`, so `SELECT *`
/// / `t.*` expansion and the projection see the same layout as the unreordered
/// join. A non-unique index may fan out to several `from.first` rows per driver
/// row; each is emitted in the index's order (matching sqlite). The full `ON` is
/// re-evaluated on each assembled row (superset invariant).
#[allow(clippy::too_many_arguments)] // cohesive: the swap's inputs + the query
#[allow(clippy::too_many_arguments)]
fn exec_two_table_index_inner_swap(
&self,
sel: &Select,
from: &FromClause,
first_columns: &[ColumnInfo],
driver_join_local: usize,
first_meta: &TableMeta,
idx: &IndexMeta,
params: &Params,
with_rowid: bool,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
let join = &from.joins[0];
let encoding = self.backend.source().header().text_encoding;
// Driver = the second table, scanned in its natural (rowid) order — or in a
// covering secondary index's key order when one holds every second-table
// column the query needs (matching sqlite's covering-index driver scan). When
// `with_rowid`, the driver source appends its own trailing hidden rowid column.
let (driver_columns, driver_rows) =
self.resolve_join_scan_source_rowid(sel, from, &join.table, params, with_rowid)?;
// `first_columns` carries `from.first`'s trailing hidden rowid column when
// `with_rowid`; the seeked `rid` supplies its value. The seek payload decodes
// only the user columns.
let first_user_width = first_columns.iter().filter(|c| !c.hidden).count();
// Declared output layout: `[first user cols, first rowid?, second user cols,
// second rowid?]` — matching `fold_joins_rowid`'s per-table interleaving.
let mut out_columns = first_columns.to_vec();
out_columns.extend(driver_columns.iter().cloned());
let on = join.on.as_ref();
let lead = idx.cols[0];
let coll = idx.collations[0];
let src = self.backend.source();
let mut cur = TableCursor::new(self.backend.source(), first_meta.root);
let mut joined: Vec<Vec<Value>> = Vec::new();
for driver in &driver_rows {
// A NULL driver key never equi-joins; skip the seek (no inner match).
if matches!(driver[driver_join_local], Value::Null) {
continue;
}
// Coerce the key to the leading column's affinity, mirroring the forward
// `exec_index_join_seek` so the index comparison is identical.
let key = [first_meta.columns[lead]
.affinity
.coerce(driver[driver_join_local].clone())];
let colls = [coll];
let rowids =
crate::btree::index_seek_rowids(src, idx.root, &key, &colls, idx.seek_descs())?;
for rid in rowids {
if cur.seek(rid)? {
let mut first_row =
self.decode_full_row(first_meta, rid, &cur.payload()?, encoding)?;
debug_assert_eq!(first_row.len(), first_user_width);
// Assemble in DECLARED order: first table's user row, its hidden
// rowid (the seeked `rid`), then the driver's row (whose own hidden
// rowid already trails it).
if with_rowid {
first_row.push(Value::Integer(rid));
}
let mut combined = first_row;
combined.extend(driver.iter().cloned());
let keep = match on {
Some(on) => {
let ctx = row_ctx(&combined, &out_columns, None, params);
eval::truth(&eval::eval(on, &ctx)?) == Some(true)
}
None => true,
};
if keep {
joined.push(combined);
}
}
}
}
Ok((out_columns, joined))
}
/// Execute one rowid-seek join (decided by [`rowid_join_seek`](Self::rowid_join_seek)):
/// for each outer row, coerce its join column to an integer rowid, seek the
/// inner table's b-tree, and combine. The full `ON` is re-evaluated on the
/// fetched row so results are identical to the materialize/hash path. INNER
/// drops an outer row with no inner match; LEFT NULL-extends it.
#[allow(clippy::too_many_arguments)]
fn exec_rowid_join_seek(
&self,
join: &Join,
columns: &[ColumnInfo],
rows: &[Vec<Value>],
outer_col: usize,
inner_meta: &TableMeta,
params: &Params,
with_rowid: bool,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
let encoding = self.backend.source().header().text_encoding;
let mut new_columns = columns.to_vec();
new_columns.extend(inner_meta.columns.iter().cloned());
// The inner is a plain rowid base table; contribute its hidden rowid slot
// when the query needs per-table rowids.
if with_rowid {
let label = join.table.alias.as_deref().unwrap_or(&join.table.name);
new_columns.push(hidden_rowid_col(label, Some(self.db_label(DbRef::Main))));
}
let n_jcols = new_columns.len() - columns.len();
let on = join.on.as_ref();
let is_left = matches!(join.kind, JoinKind::Left);
let mut cur = TableCursor::new(self.backend.source(), inner_meta.root);
let mut joined: Vec<Vec<Value>> = Vec::new();
for left in rows {
// Coerce the outer join value to a candidate rowid. A NULL (or any
// value that isn't an exact integer) never equi-joins; the `ON`
// re-eval below rejects a spurious truncation (e.g. `2.5` → 2).
let key = &left[outer_col];
let candidate = match key {
Value::Integer(i) => Some(*i),
Value::Real(_) | Value::Text(_) => match eval::to_number(key) {
Value::Integer(i) => Some(i),
Value::Real(r) if r == (r as i64) as f64 => Some(r as i64),
_ => None,
},
Value::Null | Value::Blob(_) => None,
};
let mut matched = false;
if let Some(rid) = candidate
&& cur.seek(rid)?
{
let inner = self.decode_full_row(inner_meta, rid, &cur.payload()?, encoding)?;
let mut combined = left.clone();
combined.extend(inner);
if with_rowid {
combined.push(Value::Integer(rid));
}
let keep = match on {
Some(on) => {
let ctx = row_ctx(&combined, &new_columns, None, params);
eval::truth(&eval::eval(on, &ctx)?) == Some(true)
}
None => true,
};
if keep {
joined.push(combined);
matched = true;
}
}
// LEFT: emit the outer row NULL-extended when nothing matched.
if !matched && is_left {
let mut combined = left.clone();
combined.extend(core::iter::repeat_n(Value::Null, n_jcols));
joined.push(combined);
}
}
Ok((new_columns, joined))
}
/// Whether `tref` is a table-valued function whose argument list references a
/// column of the already-materialised outer sources (`columns`) — a LATERAL /
/// correlated TVF that must be re-evaluated per outer row.
fn is_correlated_tvf(&self, tref: &TableRef, columns: &[ColumnInfo]) -> bool {
let Some(args) = &tref.tvf_args else {
return false;
};
let mut correlated = false;
for a in args {
window::visit(a, &mut |e| {
if let Expr::Column { table, column, .. } = e
&& columns.iter().any(|c| {
!c.hidden
&& c.name.eq_ignore_ascii_case(column)
&& table
.as_deref()
.is_none_or(|t| c.table.eq_ignore_ascii_case(t))
})
{
correlated = true;
}
});
}
correlated
}
/// Fold a LATERAL / correlated table-valued function inner source onto the
/// materialised outer rows: for each outer row, evaluate the TVF's arguments
/// (which reference the outer columns) into constants and materialise the
/// function with them, cross-joining its rows onto that outer row. An `ON`
/// predicate gates each pair; a `LEFT JOIN` null-pads an outer row that
/// produced no inner rows (or none satisfying `ON`). The TVF's hidden columns
/// (`json`/`root`/`arg`/`schema`/`rowid`) are dropped from the join output, as
/// for a non-correlated TVF source. Returns the widened columns and joined rows.
fn exec_lateral_tvf_join(
&self,
join: &Join,
columns: &[ColumnInfo],
rows: &[Vec<Value>],
params: &Params,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
let on = join.on.as_ref();
let is_left = matches!(join.kind, JoinKind::Left);
let mut new_columns: Option<Vec<ColumnInfo>> = None;
let mut inner_width = 0usize;
let mut joined: Vec<Vec<Value>> = Vec::new();
for left in rows {
// Evaluate the correlated arguments against this outer row, then
// materialise the TVF with those now-constant arguments.
let sub = {
let ctx = row_ctx(left, columns, None, params).with_subqueries(self);
let mut t = join.table.clone();
if let Some(args) = &mut t.tvf_args {
for a in args.iter_mut() {
*a = value_to_literal_expr(eval::eval(a, &ctx)?);
}
}
t
};
let (cinfos, tvf_out) = self.tvf_rows(&sub, params)?;
if new_columns.is_none() {
// Keep the TVF's hidden columns (`rowid`/`json`/`root`/…): they are
// resolvable by name (e.g. `j.rowid`) but excluded from `*`, exactly
// as a non-correlated TVF source contributes them.
inner_width = cinfos.len();
let mut nc = columns.to_vec();
nc.extend(cinfos.iter().cloned());
new_columns = Some(nc);
}
let nc = new_columns.as_ref().unwrap();
let mut matched = false;
for inner in &tvf_out {
let mut combined = left.clone();
combined.extend(inner.iter().cloned());
let keep = match on {
Some(on) => {
let ctx = row_ctx(&combined, nc, None, params).with_subqueries(self);
eval::truth(&eval::eval(on, &ctx)?) == Some(true)
}
None => true,
};
if keep {
joined.push(combined);
matched = true;
}
}
if !matched && is_left {
let mut combined = left.clone();
combined.extend(core::iter::repeat_n(Value::Null, inner_width));
joined.push(combined);
}
}
// With no outer rows the loop never established the column layout; derive it
// from a NULL-substituted materialisation so the outer query still resolves
// the inner columns over zero rows.
let new_columns = match new_columns {
Some(nc) => nc,
None => {
let mut t = join.table.clone();
if let Some(args) = &mut t.tvf_args {
for a in args.iter_mut() {
*a = Expr::Literal(Literal::Null);
}
}
let (cinfos, _) = self.tvf_rows(&t, params)?;
let mut nc = columns.to_vec();
nc.extend(cinfos);
nc
}
};
Ok((new_columns, joined))
}
fn resolve_join_source(
&self,
tref: &TableRef,
params: &Params,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
self.resolve_join_source_rowid(tref, params, false)
}
/// Like [`resolve_join_source`](Self::resolve_join_source), but when
/// `with_rowid` is set and the source is a plain rowid base table (in any
/// database), appends a trailing *hidden* column named `rowid` (tagged with
/// the table's alias/name) carrying each row's integer rowid. This lets a
/// table-qualified rowid alias (`t.rowid`/`t._rowid_`/`t.oid`) resolve
/// per-table in a join (a joined row otherwise carries no single rowid). A
/// `WITHOUT ROWID` table and any non-base source (view/CTE/derived/TVF/vtab)
/// contribute no such column, so `t.rowid` there still errors like sqlite.
fn resolve_join_source_rowid(
&self,
tref: &TableRef,
params: &Params,
with_rowid: bool,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
if tref.tvf_args.is_some() || self.is_bare_tvf(tref) {
return self.tvf_rows(tref, params);
}
if let Some(sub) = &tref.subquery {
return self.run_subquery_source(sub, tref.alias.as_deref(), params);
}
if let Some((cols, rows)) = self.lookup_cte(&tref.name, tref.alias.as_deref()) {
return Ok((cols, rows.into_iter().map(|r| r.values).collect()));
}
if let Some((cols, rows)) = self.try_view(&tref.name, tref.alias.as_deref(), params)? {
return Ok((cols, rows.into_iter().map(|r| r.values).collect()));
}
if tref.schema.is_none() {
// In a join, the WHERE may reference other tables, so no pushdown here
// (full scan + the join's re-applied WHERE keeps it correct).
if let Some((cols, rows)) =
self.try_virtual_table(&tref.name, tref.alias.as_deref(), None)?
{
return Ok((cols, rows.into_iter().map(|r| r.values).collect()));
}
}
// Cross-database join source: an explicit qualifier (`aux.t`) picks the
// database; an unqualified name may be shadowed by a temp table. Either
// way a non-main source is materialized through its own backend.
let db = match tref.schema.as_deref() {
Some(_) => self.resolve_db_or_missing(tref.schema.as_deref(), &tref.name, "table")?,
None => self.unqualified_db(&tref.name),
};
if db != DbRef::Main {
self.guard_qualified_temp(db, tref.schema.as_deref(), &tref.name)?;
if let Some((cols, input)) =
self.scan_db_view(db, &tref.name, tref.alias.as_deref(), params)?
{
return Ok((cols, input.into_iter().map(|r| r.values).collect()));
}
let (cols, input) = self
.scan_db_table(db, &tref.name, tref.alias.as_deref())
.map_err(|e| Self::qualify_missing(tref.schema.as_deref(), &tref.name, e))?;
return Ok((cols, input.into_iter().map(|r| r.values).collect()));
}
let mut meta = self
.table_meta(&tref.name, tref.alias.as_deref())
.map_err(|e| Self::qualify_missing(tref.schema.as_deref(), &tref.name, e))?;
// A main-database base-table join source: stamp the `main` origin so the
// `*`-wildcard ambiguity check distinguishes it from a same-named column
// in another database (`SELECT * FROM t, aux.t`).
let db_label = self.db_label(DbRef::Main);
for col in &mut meta.columns {
col.schema = Some(db_label.clone());
}
// A `WITHOUT ROWID` table has no rowid to contribute; a rowid table gets a
// trailing hidden `rowid` column when the query needs per-table rowids.
if meta.without_rowid || !with_rowid {
let rows = if meta.without_rowid {
self.scan_without_rowid(&meta)?
} else {
self.scan_table(&meta)?
.into_iter()
.map(|(_, v)| v)
.collect()
};
return Ok((meta.columns, rows));
}
let mut columns = meta.columns.clone();
let label = tref.alias.as_deref().unwrap_or(&tref.name);
columns.push(hidden_rowid_col(label, Some(self.db_label(DbRef::Main))));
let rows = self
.scan_table(&meta)?
.into_iter()
.map(|(rowid, mut v)| {
v.push(Value::Integer(rowid));
v
})
.collect();
Ok((columns, rows))
}
/// Resolve a join source that will be fully SCANNED (the outer driver, or a
/// materialised inner of an INNER/CROSS join) — like
/// [`resolve_join_source_rowid`](Self::resolve_join_source_rowid), but when the
/// source is a plain rowid base table for which [`join_scan_covering_index`]
/// picks a covering index, its rows are returned in that index's key order
/// instead of rowid order. This makes an unordered join's output row order match
/// sqlite's covering-index scan. `sel`/`from` provide the query's full column
/// footprint for the covering decision. Falls straight through to
/// [`resolve_join_source_rowid`](Self::resolve_join_source_rowid) for any source
/// that is not a covering-scannable base table.
///
/// Carries the same hidden per-table rowid option as
/// [`resolve_join_source_rowid`](Self::resolve_join_source_rowid). When
/// `with_rowid` is set the covering-index-order reorder is skipped (it would
/// need to carry rowids in index order); the plain rowid-order scan — which
/// appends the hidden rowid — is used instead. This only affects an
/// unordered join's row *order* in the rare covering-scan + qualified-rowid
/// combination, and any explicit `ORDER BY` re-sorts identically.
fn resolve_join_scan_source_rowid(
&self,
sel: &Select,
from: &FromClause,
tref: &TableRef,
params: &Params,
with_rowid: bool,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
// Only a plain `main` base table can carry a secondary index to walk; every
// other source short-circuits to the ordinary resolver. (The covering helper
// re-checks all of this, but skipping the meta lookup here keeps the common
// path cheap and avoids a spurious `table_meta` error for derived sources.)
if !with_rowid
&& tref.subquery.is_none()
&& tref.tvf_args.is_none()
&& !self.is_bare_tvf(tref)
&& tref.schema.is_none()
&& self.lookup_cte(&tref.name, tref.alias.as_deref()).is_none()
&& !self.is_view(&tref.name)
&& self.unqualified_db(&tref.name) == DbRef::Main
&& let Ok(meta) = self.table_meta(&tref.name, tref.alias.as_deref())
&& let Some(idx) = self.join_scan_covering_index(sel, from, tref, &meta)
{
// Reuse `resolve_join_source` to obtain the correctly-stamped
// ColumnInfo (schema origin, alias), then replace the rowid-order
// rows with the covering-index-order ones.
let (columns, _) = self.resolve_join_source(tref, params)?;
let rows = self.scan_table_via_index(&meta, &idx)?;
return Ok((columns, rows));
}
self.resolve_join_source_rowid(tref, params, with_rowid)
}
/// When the driver (`from.first`) of an INNER join carries a single `rowid = <int
/// const>` equality on its own INTEGER PRIMARY KEY in the `WHERE`, sqlite drives
/// the join by seeking that one row — `SEARCH <driver> USING INTEGER PRIMARY KEY
/// (rowid=?)` — instead of scanning the whole table. Returns that rowid value so
/// the executor and the EQP emitter stay in lockstep. Scoped tightly: a plain
/// `main` rowid base-table driver, no cost-based swap / N-table reorder (those own
/// the driver choice), no covering index on the driver (rendered as a covering
/// SCAN), no `INDEXED BY` hint. The driver's own rowid predicate references only
/// the driver, so seeking it is safe — the join's re-applied `WHERE` is a superset.
fn join_first_rowid_seek(
&self,
sel: &Select,
from: &FromClause,
params: &Params,
) -> Option<i64> {
// A `rowid = <const>` seek on `from.first` is a single-row access — the most
// selective plan — so it takes PRECEDENCE over the cost-based index-inner
// swap (sqlite drives the rowid seek there, not the swap). The swap's
// executor and EQP defer to this when it fires, so it is deliberately NOT
// gated out by `two_table_index_inner_swap`. The rowid-*inner*-swap and the
// N-table reorder still own the driver, so those still gate it out.
if from.joins.is_empty()
|| self.two_table_rowid_inner_swap(from).is_some()
|| self.ntable_join_order(sel, from).is_some()
{
return None;
}
let tref = &from.first;
if tref.subquery.is_some()
|| tref.tvf_args.is_some()
|| self.is_bare_tvf(tref)
|| tref.schema.is_some()
|| tref.index_hint.is_some()
|| self.lookup_cte(&tref.name, tref.alias.as_deref()).is_some()
|| self.is_view(&tref.name)
|| self.unqualified_db(&tref.name) != DbRef::Main
{
return None;
}
let meta = self.table_meta(&tref.name, tref.alias.as_deref()).ok()?;
if meta.without_rowid {
return None;
}
let ipk = meta.ipk?;
if self
.join_scan_covering_index(sel, from, tref, &meta)
.is_some()
{
return None;
}
let where_expr = sel.where_clause.as_ref()?;
let mut eqs: Vec<(usize, Value)> = Vec::new();
collect_eq_constraints(where_expr, &meta.columns, params, &mut eqs);
eqs.iter().find_map(|(c, v)| match v {
Value::Integer(rid) if *c == ipk => Some(*rid),
_ => None,
})
}
/// Produce the join driver's rows by seeking the single `rowid` — the executor
/// half of [`join_first_rowid_seek`]. Zero or one row, stamped and (optionally)
/// rowid-carrying exactly like [`resolve_join_source_rowid`](Self::resolve_join_source_rowid),
/// so the fold that consumes it is unchanged.
fn resolve_join_driver_rowid_seek(
&self,
tref: &TableRef,
rid: i64,
with_rowid: bool,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<Value>>)> {
let mut meta = self.table_meta(&tref.name, tref.alias.as_deref())?;
let db_label = self.db_label(DbRef::Main);
for col in &mut meta.columns {
col.schema = Some(db_label.clone());
}
let mut columns = meta.columns.clone();
let label = tref.alias.as_deref().unwrap_or(&tref.name);
if with_rowid {
columns.push(hidden_rowid_col(label, Some(db_label.clone())));
}
let encoding = self.backend.source().header().text_encoding;
let mut cur = TableCursor::new(self.backend.source(), meta.root);
cur.seek(rid)?;
let mut rows: Vec<Vec<Value>> = Vec::new();
if cur.is_valid() && cur.rowid()? == rid {
let mut row = self.decode_full_row(&meta, rid, &cur.payload()?, encoding)?;
if with_rowid {
row.push(Value::Integer(rid));
}
rows.push(row);
}
Ok((columns, rows))
}
/// The secondary-index analogue of [`join_first_rowid_seek`]: when the driver
/// (`from.first`) of an INNER join carries an equality on the sole column of a
/// single-column secondary index (with matching collation), sqlite seeks that
/// index — `SEARCH <driver> USING INDEX <idx> (<col>=?)` — instead of scanning.
/// Returns `(index name, column name)` for the EQP emitter. This is EQP-only: a
/// single-column equality's matches all share that key value, so they arrive in
/// rowid order — identical to the executor's full-scan + re-applied-WHERE order —
/// no execution change is needed. Scoped like the rowid case, and to an
/// *unambiguous* single-candidate index (a multi-column index would reorder the
/// matches by its trailing columns; two candidates would need the cost model).
fn join_first_index_seek(
&self,
sel: &Select,
from: &FromClause,
params: &Params,
) -> Option<(String, String)> {
if from.joins.is_empty()
|| self.two_table_rowid_inner_swap(from).is_some()
|| self.two_table_index_inner_swap(from).is_some()
|| self.ntable_join_order(sel, from).is_some()
|| self.join_first_rowid_seek(sel, from, params).is_some()
{
return None;
}
let tref = &from.first;
if tref.subquery.is_some()
|| tref.tvf_args.is_some()
|| self.is_bare_tvf(tref)
|| tref.schema.is_some()
|| tref.index_hint.is_some()
|| self.lookup_cte(&tref.name, tref.alias.as_deref()).is_some()
|| self.is_view(&tref.name)
|| self.unqualified_db(&tref.name) != DbRef::Main
{
return None;
}
let meta = self.table_meta(&tref.name, tref.alias.as_deref()).ok()?;
if meta.without_rowid
|| self
.join_scan_covering_index(sel, from, tref, &meta)
.is_some()
{
return None;
}
let where_expr = sel.where_clause.as_ref()?;
let mut eqs: Vec<(usize, Value, crate::value::Collation)> = Vec::new();
collect_eq_constraints_coll(where_expr, &meta.columns, params, &mut eqs);
if eqs.is_empty() {
return None;
}
let indexes = self.indexes_of(&tref.name).ok()?;
let is_candidate = |idx: &IndexMeta| -> bool {
idx.cols.len() == 1
&& idx.partial.is_none()
&& idx.key_exprs.is_none()
&& eqs.iter().any(|(c, _, coll)| {
*c == idx.cols[0]
&& *coll == idx.collations.first().copied().unwrap_or_default()
})
};
let mut candidates = indexes.iter().filter(|idx| is_candidate(idx));
let idx = candidates.next()?;
if candidates.next().is_some() {
return None; // ambiguous — leave it to the (unmodelled) cost decision
}
Some((idx.name.clone(), meta.columns[idx.cols[0]].name.clone()))
}
/// Scan a `WITHOUT ROWID` table's clustered index b-tree, decoding each entry
/// (stored PK-first) back into declared column order.
/// Scan a table's rows (declared column order) independent of its storage
/// kind: rowid tables via [`scan_table`](Self::scan_table) (rowids dropped),
/// WITHOUT ROWID (index-organized) tables via
/// [`scan_without_rowid`](Self::scan_without_rowid). Used by the storage-kind
/// agnostic foreign-key existence checks, where either side may be WITHOUT
/// ROWID (a rowid `TableCursor` misreads an index-organized b-tree's pages as
/// table-leaf pages: `table-leaf cell on non-table-leaf page`).
fn scan_rows(&self, meta: &TableMeta) -> Result<Vec<Vec<Value>>> {
if meta.without_rowid {
self.scan_without_rowid(meta)
} else {
Ok(self.scan_table(meta)?.into_iter().map(|(_, r)| r).collect())
}
}
fn scan_without_rowid(&self, meta: &TableMeta) -> Result<Vec<Vec<Value>>> {
let encoding = self.backend.source().header().text_encoding;
let mut cur = IndexCursor::new(self.backend.source(), meta.root);
let params = Params::default();
let mut out = Vec::new();
while let Some(payload) = cur.next()? {
let storage = decode_record(&payload, encoding)?;
let mut row = unpermute_row(meta, storage);
self.compute_generated(meta, &mut row, ¶ms)?;
out.push(row);
}
Ok(out)
}
/// Build a row (declared order) from an INSERT's column list + value exprs,
/// applying defaults and affinity. Shared by the WITHOUT ROWID insert path.
fn build_insert_row(
&self,
meta: &TableMeta,
target: &[usize],
row_exprs: &[Expr],
params: &Params,
) -> Result<Vec<Value>> {
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let mut values: Vec<Value> = meta
.defaults
.iter()
.map(|d| match d {
Some(e) => eval::eval(e, &ctx),
None => Ok(Value::Null),
})
.collect::<Result<_>>()?;
for (i, e) in row_exprs.iter().enumerate() {
if meta.is_generated(target[i]) {
return Err(Error::Error(format!(
"cannot INSERT into generated column \"{}\"",
meta.columns[target[i]].name
)));
}
values[target[i]] = eval::eval(e, &ctx)?;
}
apply_column_affinity(meta, &mut values);
self.materialize_generated(meta, &mut values, params)?;
self.check_strict_types(meta, &values)?;
Ok(values)
}
/// INSERT into a WITHOUT ROWID (PK-clustered) table.
/// Find the existing WITHOUT ROWID rows that collide with `values` on the
/// PRIMARY KEY / a UNIQUE constraint, as `(existing, collide)` where `collide`
/// indexes into `existing` — the shape the insert conflict handling expects.
///
/// Fast path: detect a collision by SEEKING each uniqueness source — the
/// clustered PRIMARY KEY b-tree plus every unique index (the automatic indexes
/// of the inline `UNIQUE` sets and any standalone `CREATE UNIQUE INDEX`, all
/// maintained incrementally as rows are inserted) — instead of scanning the
/// whole table on every row (O(log n) vs O(n) per row → O(n²)). With no
/// collision — the common bulk-insert case — it returns empty without
/// materializing the table. On an actual collision it falls back to the full
/// scan so REPLACE/upsert see the complete `existing` they rewrite from.
///
/// `can_seek` must be false once a REPLACE / upsert DO UPDATE has rewritten the
/// clustered table this statement: that leaves the incrementally-maintained
/// indexes stale until the end-of-statement rebuild, so a seek could miss a
/// collision — fall back to the authoritative scan of the (always-current)
/// clustered table instead.
fn wr_find_collisions(
&self,
table: &str,
meta: &TableMeta,
values: &[Value],
params: &Params,
indexes: &[IndexMeta],
can_seek: bool,
) -> Result<(Vec<Vec<Value>>, Vec<usize>)> {
if can_seek && !self.wr_seek_collision(meta, values, params, indexes)? {
return Ok((Vec::new(), Vec::new())); // no collision — the fast path
}
// Authoritative full scan: a collision was found (REPLACE needs the whole
// `existing`), or the indexes can't be trusted after a rewrite this
// statement. The scan reads the clustered table, which is always current.
let existing = self.scan_without_rowid(meta)?;
let mut collide = Vec::new();
for (i, r) in existing.iter().enumerate() {
if unique_match(meta, r, values)
|| self.wr_index_collision(table, meta, r, values, params)?
{
collide.push(i);
}
}
Ok((existing, collide))
}
/// Whether `values` collides with an existing WITHOUT ROWID row on the PRIMARY
/// KEY or a UNIQUE constraint, decided purely by b-tree SEEKS: the clustered PK
/// b-tree for the PK, and each unique index (`indexes`, incrementally
/// maintained) by its leading key columns. A NULL key term or an excluding
/// partial predicate can't collide. See [`wr_find_collisions`] for when this
/// may be trusted (`can_seek`).
fn wr_seek_collision(
&self,
meta: &TableMeta,
values: &[Value],
params: &Params,
indexes: &[IndexMeta],
) -> Result<bool> {
let src = self.backend.source();
// 1. Duplicate PRIMARY KEY: the clustered table b-tree is keyed PK-first.
let pk_len = meta.pk_len;
let realified = realify_columns_for_storage(meta, values);
let pk_key: Vec<Value> = meta.storage_order[..pk_len]
.iter()
.map(|&c| realified[c].clone())
.collect();
let pk_colls = wr_storage_collations(meta)[..pk_len].to_vec();
let pk_descs = meta.pk_descs().to_vec();
if !crate::btree::index_seek_records(src, meta.root, &pk_key, &pk_colls, &pk_descs)?
.is_empty()
{
return Ok(true);
}
// 2. Each UNIQUE index — the automatic indexes of the inline UNIQUE sets
// plus standalone unique indexes — seeked by this row's leading key
// columns; any existing entry there is a uniqueness violation.
for idx in indexes.iter().filter(|i| i.unique) {
if !self.row_in_index(idx, meta, values, None, params)? {
continue;
}
let key = self.index_key_values(idx, meta, values, 0, params)?;
if key.iter().any(|v| matches!(v, Value::Null)) {
continue;
}
if !crate::btree::index_seek_records(
src,
idx.root,
&key,
&idx.collations,
idx.seek_descs(),
)?
.is_empty()
{
return Ok(true);
}
}
Ok(false)
}
/// Insert one row's entries into every secondary index of a WITHOUT ROWID
/// table, incrementally — mirroring the per-index key construction of
/// [`rebuild_wr_indexes`](Self::rebuild_wr_indexes) exactly (trailing-PK
/// dedup, comparison collations, DESC flags, partial-index predicate) so the
/// result is byte-identical to a full rebuild, one row at a time.
fn wr_insert_row_indexes(
&mut self,
meta: &TableMeta,
indexes: &[IndexMeta],
values: &[Value],
params: &Params,
) -> Result<()> {
if indexes.is_empty() {
return Ok(());
}
let pk_cols = meta.storage_order[..meta.pk_len].to_vec();
let realified = realify_columns_for_storage(meta, values);
// Precompute each included index's key + comparison metadata before taking
// the writer borrow (a partial index this row is not in adds no entry).
type PlannedEntry = (u32, Vec<u8>, Vec<crate::value::Collation>, Vec<bool>);
let mut planned: Vec<PlannedEntry> = Vec::new();
for idx in indexes {
if !self.row_in_index(idx, meta, values, None, params)? {
continue;
}
let (trailing_pk, trailing_colls, trailing_descs) =
wr_trailing_pk(&idx.cols, &idx.collations, &pk_cols, meta);
let mut key_colls = idx.collations.clone();
key_colls.extend(trailing_colls);
let mut descs = idx.seek_descs().to_vec();
wr_extend_descs(&mut descs, &idx.collations, &trailing_descs);
let key = wr_index_key(&idx.cols, &trailing_pk, &realified);
planned.push((idx.root, key, key_colls, descs));
}
let w = self.backend.writer()?;
for (root, key, colls, descs) in &planned {
insert_index(w, *root, key, colls, descs)?;
}
Ok(())
}
fn exec_insert_without_rowid(
&mut self,
ins: &Insert,
meta: &TableMeta,
rows: &[Vec<Expr>],
is_default_values: bool,
params: &Params,
) -> Result<usize> {
let n_cols = meta.columns.len();
let target: Vec<usize> = if ins.columns.is_empty() {
// Non-generated columns only (see exec_insert): a bare INSERT into a
// WITHOUT ROWID table with generated columns must not expect a value
// for the computed columns.
(0..n_cols).filter(|&i| !meta.is_generated(i)).collect()
} else {
ins.columns
.iter()
.map(|name| {
meta.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
.ok_or_else(|| {
Error::Error(format!("table {} has no column named {name}", ins.table))
})
})
.collect::<Result<_>>()?
};
let pk = &meta.storage_order[..meta.pk_len];
// Secondary indexes are maintained incrementally per inserted row (below);
// only a REPLACE / upsert rewrite — which rebuilds the clustered table but
// not its indexes — forces the full `rebuild_wr_indexes` at statement end.
// This keeps a bulk WITHOUT ROWID load O(n·log n) instead of rebuilding
// every index on every statement (O(n²) across many single-row inserts).
let secondary_indexes = self.indexes_of(&ins.table)?;
let mut did_rewrite = false;
let mut affected = 0;
for row_exprs in rows {
if !is_default_values && row_exprs.len() != target.len() {
return Err(insert_count_mismatch(
&ins.table,
!ins.columns.is_empty(),
target.len(),
row_exprs.len(),
));
}
let values = self.build_insert_row(meta, &target, row_exprs, params)?;
// PRIMARY KEY / NOT NULL / CHECK constraints. `INSERT OR IGNORE`
// skips a violating row; any other policy lets the error propagate.
{
let r = (|| {
// PRIMARY KEY columns are implicitly NOT NULL in a WITHOUT
// ROWID table.
for &c in pk {
if matches!(values[c], Value::Null) {
return Err(Error::Constraint(format!(
"NOT NULL constraint failed: {}.{}",
meta.columns[c].table, meta.columns[c].name
)));
}
}
check_not_null(meta, &values)?;
self.check_constraints(meta, &values, None, params)
})();
match r {
Ok(()) => {}
Err(Error::Constraint(_)) if ins.on_conflict == OnConflict::Ignore => continue,
Err(e) => return Err(e),
}
}
// Reject a duplicate primary key, an inline UNIQUE constraint, or a
// standalone UNIQUE index (incl. partial). Collect colliding rows so
// REPLACE can rebuild without them. Seeks each uniqueness source
// instead of scanning the whole table on every row (see
// `wr_find_collisions`); `!did_rewrite` guards that the incrementally
// maintained indexes are still current.
let (existing, collide) = self.wr_find_collisions(
&ins.table,
meta,
&values,
params,
&secondary_indexes,
!did_rewrite,
)?;
if !collide.is_empty() {
// An `ON CONFLICT … DO …` upsert clause intercepts the conflict when
// it targets the constraint the row collides on (a bare `ON CONFLICT`
// absorbs any collision). WITHOUT ROWID rows have no rowid, so the
// target row is identified by position in the scanned set.
let mut matched = None;
for up in &ins.upsert {
if let Some(ci) = wr_upsert_target(meta, up, &existing, &collide, &values) {
matched = Some((up, ci));
break;
}
}
if let Some((up, ci)) = matched {
match &up.action {
UpsertAction::Nothing => continue, // skip the conflicting row
UpsertAction::Update {
assignments,
where_clause,
} => {
if self.wr_upsert_do_update(
&ins.table,
meta,
existing,
ci,
&values,
assignments,
where_clause.as_ref(),
&ins.returning,
params,
)? {
affected += 1;
did_rewrite = true; // rewrote the clustered table
}
continue;
}
}
}
match ins.on_conflict {
oc @ (OnConflict::Abort | OnConflict::Fail | OnConflict::Rollback) => {
let m = self.wr_conflict_message(
&ins.table,
meta,
&existing[collide[0]],
&values,
params,
)?;
return Err(self.conflict_error(oc, &m));
}
OnConflict::Ignore => continue,
OnConflict::Replace => {
// Rebuild without the conflicting row(s), then insert.
// Record each removed row as a session DELETE (a same-PK
// REPLACE coalesces DELETE+INSERT into an UPDATE; a
// different-PK conflict yields a DELETE + this INSERT).
if self.session.borrow().is_some() {
for &ci in &collide {
let old = existing[ci].clone();
self.record_session_change(
&ins.table,
meta,
crate::session::ChangeOp::Delete,
0,
Some(&old),
None,
);
}
}
let kept: Vec<Vec<Value>> = existing
.into_iter()
.enumerate()
.filter(|(i, _)| !collide.contains(i))
.map(|(_, r)| r)
.collect();
self.rewrite_without_rowid(meta, kept.into_iter())?;
did_rewrite = true;
}
}
}
// This row (as a child) must reference an existing parent — the same
// check the rowid INSERT path runs. Any FK parent may itself be
// WITHOUT ROWID; `check_fk_child` scans it storage-kind-agnostically.
self.check_fk_child(&ins.table, meta, &values)?;
let record = encode_record(&permute_row(meta, &values));
let scolls = wr_storage_collations(meta);
// WITHOUT ROWID clustered PK insert: order the b-tree by the PK's
// declared per-column directions (`&[]` when all-ascending). Every
// seek/scan on `meta.root` passes the same slice (the per-root
// consistency invariant).
insert_index(
self.backend.writer()?,
meta.root,
&record,
&scolls,
meta.pk_descs(),
)?;
// Maintain the secondary indexes incrementally for this new row. After
// a rewrite the incremental state is stale, so we stop and let the
// statement-end `rebuild_wr_indexes` restore all indexes from the table.
if !did_rewrite {
self.wr_insert_row_indexes(meta, &secondary_indexes, &values, params)?;
}
self.record_session_change(
&ins.table,
meta,
crate::session::ChangeOp::Insert,
0,
None,
Some(&values),
);
if !ins.returning.is_empty() {
self.collect_returning(&ins.returning, meta, &values, None, params)?;
}
affected += 1;
}
// Only a rewrite (REPLACE / upsert DO UPDATE) invalidates the incremental
// index maintenance done above; otherwise the indexes are already current.
if affected > 0 && did_rewrite {
self.rebuild_wr_indexes(meta, &ins.table)?;
}
Ok(affected)
}
/// Apply an `ON CONFLICT … DO UPDATE` action to the scanned WITHOUT ROWID row
/// at position `target` in `existing`. `proposed` is the row the `INSERT` would
/// have added, exposed to the `SET`/`WHERE` expressions as the `excluded`
/// pseudo-table. Rewrites the clustered table with the edited row in place
/// (indexes are rebuilt by the caller once the statement completes); returns
/// whether a row was actually updated (the optional `WHERE` can veto).
#[allow(clippy::too_many_arguments)]
fn wr_upsert_do_update(
&mut self,
table: &str,
meta: &TableMeta,
existing: Vec<Vec<Value>>,
target: usize,
proposed: &[Value],
assignments: &[(String, Expr)],
where_clause: Option<&Expr>,
returning: &[ResultColumn],
params: &Params,
) -> Result<bool> {
let old_row = existing[target].clone();
// Column scope for the SET/WHERE expressions: the target table's columns,
// then the same columns again under the `excluded` table label.
let mut cols: Vec<ColumnInfo> = meta.columns.clone();
cols.extend(meta.columns.iter().map(|c| ColumnInfo {
name: c.name.clone(),
table: String::from("excluded"),
affinity: c.affinity,
collation: c.collation,
schema: None,
hidden: false,
}));
let mut new_row = old_row.clone();
{
let mut combined = old_row.clone();
combined.extend_from_slice(proposed);
let ctx = EvalCtx {
row: &combined,
columns: &cols,
rowid: None,
params,
anon_counter: core::cell::Cell::new(0),
subqueries: None,
}
.with_subqueries(self);
if let Some(w) = where_clause
&& eval::truth(&eval::eval(w, &ctx)?) != Some(true)
{
return Ok(false);
}
for (col, e) in assignments {
let pos = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(col))
.ok_or_else(|| Error::Error(format!("no such column: {col}")))?;
if meta.is_generated(pos) {
return Err(Error::Error(format!(
"cannot UPDATE generated column \"{col}\""
)));
}
new_row[pos] = eval::eval(e, &ctx)?;
}
}
apply_column_affinity(meta, &mut new_row);
self.materialize_generated(meta, &mut new_row, params)?;
// PRIMARY KEY columns are implicitly NOT NULL in a WITHOUT ROWID table.
for &c in &meta.storage_order[..meta.pk_len] {
if matches!(new_row[c], Value::Null) {
return Err(Error::Constraint(format!(
"NOT NULL constraint failed: {}.{}",
meta.columns[c].table, meta.columns[c].name
)));
}
}
check_not_null(meta, &new_row)?;
self.check_strict_types(meta, &new_row)?;
self.check_constraints(meta, &new_row, None, params)?;
// The updated row must not collide with any OTHER existing row on the PK,
// an inline UNIQUE, or a standalone unique index.
for (i, r) in existing.iter().enumerate() {
if i == target {
continue;
}
if unique_match(meta, r, &new_row)
|| self.wr_index_collision(table, meta, r, &new_row, params)?
{
let m = self.wr_conflict_message(table, meta, r, &new_row, params)?;
return Err(Error::Constraint(m));
}
}
self.record_session_change(
table,
meta,
crate::session::ChangeOp::Update,
0,
Some(&old_row),
Some(&new_row),
);
let mut rebuilt = existing;
rebuilt[target] = new_row.clone();
self.rewrite_without_rowid(meta, rebuilt.into_iter())?;
if !returning.is_empty() {
self.collect_returning(returning, meta, &new_row, None, params)?;
}
Ok(true)
}
/// DELETE from a WITHOUT ROWID table: keep non-matching rows, rebuild.
fn exec_delete_without_rowid(
&mut self,
del: &Delete,
meta: &TableMeta,
params: &Params,
) -> Result<usize> {
let all = self.scan_without_rowid(meta)?;
let mut kept = Vec::new();
// Deleted rows are held so their referential actions can fire after the
// table is rewritten without them (mirroring the rowid path, which
// removes the parent row first, then enforces — so a `SET DEFAULT` that
// names the just-deleted key correctly sees it gone).
let mut victims: Vec<Vec<Value>> = Vec::new();
for row in all {
let keep = match &del.where_clause {
Some(p) => {
let ctx = row_ctx(&row, &meta.columns, None, params).with_subqueries(self);
eval::truth(&eval::eval(p, &ctx)?) != Some(true)
}
None => false,
};
if keep {
kept.push(row);
} else {
if !del.returning.is_empty() {
self.collect_returning(&del.returning, meta, &row, None, params)?;
}
self.record_session_change(
&del.table,
meta,
crate::session::ChangeOp::Delete,
0,
Some(&row),
None,
);
victims.push(row);
}
}
let deleted = victims.len();
if deleted > 0 {
self.rewrite_without_rowid(meta, kept.into_iter())?;
self.rebuild_wr_indexes(meta, &del.table)?;
// This table may be a parent: propagate the deletes to referencing
// children (CASCADE / SET NULL / SET DEFAULT / RESTRICT). A referenced
// child may itself be WITHOUT ROWID — `enforce_parent_change` →
// `apply_fk_action` dispatches on the child's storage kind.
if self.foreign_keys {
for old in &victims {
self.enforce_parent_change(&del.table, old, None, params)?;
}
// A cascade may have emptied leaves in rowid child tables.
self.drain_cascade_compact()?;
}
}
Ok(deleted)
}
/// UPDATE a WITHOUT ROWID table: recompute matching rows, rebuild.
fn exec_update_without_rowid(
&mut self,
upd: &Update,
meta: &TableMeta,
params: &Params,
) -> Result<usize> {
// UPDATE … FROM: materialize the extra tables once (mirrors the rowid
// path). Each target row joins to the first FROM-row combination passing
// WHERE, and that row's columns are visible to SET/WHERE.
let from_data: Option<(Vec<ColumnInfo>, Vec<Vec<Value>>)> = match &upd.from {
Some(fc) => {
// `*` marks all source columns as needed (see the rowid UPDATE …
// FROM path): without it, `scan_source` may satisfy the scan from a
// narrow covering index and drop the columns the SET/WHERE reference.
let synth = Select {
ctes: Vec::new(),
compound: Vec::new(),
distinct: false,
columns: alloc::vec![ResultColumn::Wildcard],
from: Some(fc.clone()),
where_clause: None,
group_by: Vec::new(),
having: None,
window_defs: Vec::new(),
order_by: Vec::new(),
limit: None,
offset: None,
values_rows: 0,
};
let (cols, rows) = self.scan_source(&synth, params)?;
Some((cols, rows.into_iter().map(|r| r.values).collect()))
}
None => None,
};
let combined_columns: Vec<ColumnInfo> = match &from_data {
Some((cols, _)) => meta.columns.iter().chain(cols).cloned().collect(),
None => Vec::new(),
};
let all = self.scan_without_rowid(meta)?;
// `out` starts as the original rows and is updated in place, in scan
// order. SQLite updates a WITHOUT ROWID table one row at a time and checks
// uniqueness immediately after each write, so a *transient* duplicate — one
// that exists mid-statement even if the final rows are all distinct (e.g.
// swapping two UNIQUE values) — is rejected. Checking each new row against
// the current `out` state (earlier matches already updated, later ones
// still original) reproduces that; a batch check of only the final state
// would miss it.
let mut out = all.clone();
let mut affected = 0;
// RETURNING rows are held back until the update fully succeeds, so an
// aborted UPDATE emits nothing.
let mut returned: Vec<Vec<Value>> = Vec::new();
for i in 0..all.len() {
// Match the row (and, under FROM, capture the joined row that satisfies
// WHERE — those columns feed the SET expressions).
let (matches, matched_from) = match &from_data {
Some((_, from_rows)) => {
// When several FROM rows match one target row, SQLite's WITHOUT
// ROWID path updates the clustered row once per match, so the
// LAST matching row wins (sqlite documents multi-match as
// arbitrarily chosen; the recommended single-match case is
// unaffected). This differs from the rowid path's first-match.
let mut mf = None;
for fr in from_rows {
let mut combined = all[i].clone();
combined.extend_from_slice(fr);
let ok = match &upd.where_clause {
Some(p) => {
let ctx = row_ctx(&combined, &combined_columns, None, params)
.with_subqueries(self);
eval::truth(&eval::eval(p, &ctx)?) == Some(true)
}
None => true,
};
if ok {
mf = Some(fr.clone());
}
}
(mf.is_some(), mf)
}
None => {
let m = match &upd.where_clause {
Some(p) => {
let ctx =
row_ctx(&all[i], &meta.columns, None, params).with_subqueries(self);
eval::truth(&eval::eval(p, &ctx)?) == Some(true)
}
None => true,
};
(m, None)
}
};
if !matches {
continue;
}
// Assignments are simultaneous: evaluate every SET expression against
// the original row (extended with the matched FROM row), not the
// progressively-mutated one.
let original = all[i].clone();
let mut row = original.clone();
let (eval_row, eval_cols): (Vec<Value>, &[ColumnInfo]) = match &matched_from {
Some(fr) => {
let mut c = original.clone();
c.extend_from_slice(fr);
(c, &combined_columns)
}
None => (original.clone(), &meta.columns),
};
for (col, expr) in &upd.assignments {
let pos = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(col))
.ok_or_else(|| Error::Error(format!("no such column: {col}")))?;
if meta.is_generated(pos) {
return Err(Error::Error(format!(
"cannot UPDATE generated column \"{col}\""
)));
}
let ctx = row_ctx(&eval_row, eval_cols, None, params).with_subqueries(self);
row[pos] = eval::eval(expr, &ctx)?;
}
if !upd.row_assignments.is_empty() {
let ctx = row_ctx(&eval_row, eval_cols, None, params).with_subqueries(self);
self.apply_row_subquery_assignments(
&upd.row_assignments,
eval_cols,
Some(meta),
&ctx,
&mut row,
)?;
}
apply_column_affinity(meta, &mut row);
self.materialize_generated(meta, &mut row, params)?;
// PRIMARY KEY columns are implicitly NOT NULL in a WITHOUT ROWID table
// (a NULL would corrupt the clustered key); sqlite rejects an UPDATE
// that nulls one.
for &c in &meta.storage_order[..meta.pk_len] {
if matches!(row[c], Value::Null) {
return Err(Error::Constraint(format!(
"NOT NULL constraint failed: {}.{}",
meta.columns[c].table, meta.columns[c].name
)));
}
}
check_not_null(meta, &row)?;
self.check_strict_types(meta, &row)?;
self.check_constraints(meta, &row, None, params)?;
// Immediate uniqueness check against the current state of every OTHER
// row (see the note on `out` above): reject transient duplicates.
for (j, other) in out.iter().enumerate() {
if j == i {
continue;
}
if unique_match(meta, &row, other)
|| self.wr_index_collision(&upd.table, meta, &row, other, params)?
{
let m = self.wr_conflict_message(&upd.table, meta, &row, other, params)?;
return Err(Error::Constraint(m));
}
}
// Foreign keys (same as the rowid UPDATE path): this row as a child
// must still point at an existing parent, and as a parent it must
// propagate a referenced-key change to its children. Enforced before
// the deferred whole-table rewrite, so `parent_has_key` (used by a
// child's SET DEFAULT re-check) still sees this table's pre-update key.
self.check_fk_child(&upd.table, meta, &row)?;
if self.foreign_keys {
self.enforce_parent_change(&upd.table, &original, Some(&row), params)?;
}
if !upd.returning.is_empty() {
returned.push(row.clone());
}
self.record_session_change(
&upd.table,
meta,
crate::session::ChangeOp::Update,
0,
Some(&original),
Some(&row),
);
out[i] = row;
affected += 1;
}
for r in &returned {
self.collect_returning(&upd.returning, meta, r, None, params)?;
}
if affected > 0 {
self.rewrite_without_rowid(meta, out.into_iter())?;
self.rebuild_wr_indexes(meta, &upd.table)?;
// A cascade to a rowid child may have emptied its leaves.
if self.foreign_keys {
self.drain_cascade_compact()?;
}
}
Ok(affected)
}
/// Replace a WITHOUT ROWID table's entire contents with `rows` (declared
/// order), re-encoding each into PK-first storage order.
fn rewrite_without_rowid(
&mut self,
meta: &TableMeta,
rows: impl Iterator<Item = Vec<Value>>,
) -> Result<()> {
let records: Vec<Vec<u8>> = rows
.map(|r| encode_record(&permute_row(meta, &r)))
.collect();
let scolls = wr_storage_collations(meta);
let descs = meta.pk_descs().to_vec();
let w = self.backend.writer()?;
clear_index(w, meta.root)?;
for rec in &records {
// WITHOUT ROWID clustered PK: honour the PK's per-column directions
// (same slice as every other insert/seek on this root).
insert_index(w, meta.root, rec, &scolls, &descs)?;
}
Ok(())
}
/// Rebuild every secondary index of a `WITHOUT ROWID` table from its current
/// rows, keying entries by (indexed cols, PK cols).
fn rebuild_wr_indexes(&mut self, meta: &TableMeta, table: &str) -> Result<()> {
let indexes = self.indexes_of(table)?;
if indexes.is_empty() {
return Ok(());
}
let rows = self.scan_without_rowid(meta)?;
let pk_cols = meta.storage_order[..meta.pk_len].to_vec();
// Precompute partial-index membership before the writer borrow.
let mut keep: Vec<Vec<usize>> = Vec::with_capacity(indexes.len());
for idx in &indexes {
let mut ks = Vec::new();
for (i, row) in rows.iter().enumerate() {
if self.row_in_index(idx, meta, row, None, &Params::default())? {
ks.push(i);
}
}
keep.push(ks);
}
let w = self.backend.writer()?;
for (idx, ks) in indexes.iter().zip(&keep) {
// SQLite dedups PK columns already in the key (same collation); the
// key bytes, comparison collations, and DESC flags must all reflect
// that trailing-PK shape (see `wr_trailing_pk`).
let (trailing_pk, trailing_colls, trailing_descs) =
wr_trailing_pk(&idx.cols, &idx.collations, &pk_cols, meta);
let mut key_colls = idx.collations.clone();
key_colls.extend(trailing_colls);
let mut descs = idx.seek_descs().to_vec();
wr_extend_descs(&mut descs, &idx.collations, &trailing_descs);
clear_index(w, idx.root)?;
for &i in ks {
insert_index(
w,
idx.root,
&wr_index_key(
&idx.cols,
&trailing_pk,
&realify_columns_for_storage(meta, &rows[i]),
),
&key_colls,
&descs,
)?;
}
}
Ok(())
}
/// Scan a whole table into `(rowid, column values)`.
/// Whether the named table currently holds no rows (handles both rowid and
/// WITHOUT ROWID storage).
fn table_is_empty(&self, table: &str) -> Result<bool> {
let meta = self.table_meta(table, None)?;
if meta.without_rowid {
Ok(self.scan_without_rowid(&meta)?.is_empty())
} else {
Ok(self.scan_table(&meta)?.is_empty())
}
}
/// Resolve a `schema.` qualifier to a database: `None`/`main` → `Main`;
/// `temp`/`temporary` → `Temp`; an attached name → `Attached(index)`; an
/// unknown name is an error.
fn resolve_db(&self, schema: Option<&str>) -> Result<DbRef> {
match schema {
// An unqualified name resolves against the active `main` slot — which,
// during a write to a non-main target, is the swapped-in target. (An
// unqualified name that also lives in the original main is the separate
// Track-E residual, left as-is.)
None => Ok(DbRef::Main),
Some(s) if s.eq_ignore_ascii_case("main") => Ok(self.apply_swap(DbRef::Main)),
Some(s) if s.eq_ignore_ascii_case("temp") || s.eq_ignore_ascii_case("temporary") => {
Ok(self.apply_swap(DbRef::Temp))
}
Some(s) => self
.attached
.iter()
.position(|d| d.name.eq_ignore_ascii_case(s))
.map(DbRef::Attached)
.map(|r| self.apply_swap(r))
.ok_or_else(|| Error::Error(alloc::format!("unknown database {s}"))),
}
}
/// During a write to a non-main target (`swap_active`), that database is
/// physically in the active `main` slot and the original main is in the
/// target's swapped-out slot — but the qualifier→slot lookup above is unchanged.
/// Exchange the two so a *qualified* reference resolves to the right physical
/// database: `main` → the target's old slot, the target's own name → `main`.
/// A no-op outside a live swap, or for any other database.
fn apply_swap(&self, raw: DbRef) -> DbRef {
match self.swap_active.get() {
None | Some(DbRef::Main) => raw,
Some(DbRef::Attached(i)) => match raw {
DbRef::Main => DbRef::Attached(i),
DbRef::Attached(j) if j == i => DbRef::Main,
other => other,
},
Some(DbRef::Temp) => match raw {
DbRef::Main => DbRef::Temp,
DbRef::Temp => DbRef::Main,
other => other,
},
}
}
/// Resolve the database for a *table reference* (a query/DML/`DROP`/`ALTER`
/// target), where SQLite reports an unknown schema qualifier as the
/// referenced object being missing (`no such table: bad.t`) rather than
/// `unknown database bad` — it reserves the latter for the `CREATE` forms,
/// whose qualifier names a creation target rather than an object to look up.
/// An unqualified name resolves like [`resolve_db`](Self::resolve_db) does
/// for a bare target (a temp table can shadow `main`). `noun` is the object
/// kind (`table`/`view`/`index`/`trigger`).
fn resolve_db_or_missing(&self, schema: Option<&str>, name: &str, noun: &str) -> Result<DbRef> {
match schema {
None => Ok(self.unqualified_db(name)),
Some(q) => self
.resolve_db(schema)
.map_err(|_| Error::Error(alloc::format!("no such {noun}: {q}.{name}"))),
}
}
/// Re-attach an explicit schema qualifier to a `no such <kind>: <name>` error
/// for a *known* database (`SELECT … FROM main.nope` → `no such table:
/// main.nope`, not the bare `no such table: nope`). SQLite echoes the
/// qualifier as written; the low-level lookups only know the bare object
/// name, so the resolving call wraps its result with this. Noun-agnostic: it
/// preserves whatever kind word the deep error produced (`table`/`view`/
/// `index`/`trigger`) and only injects the qualifier. A no-op when the
/// reference was unqualified, or when the error is not exactly this object's
/// missing-object message (so an unrelated `no such column: …` is untouched).
fn qualify_missing(schema: Option<&str>, name: &str, e: Error) -> Error {
let Some(q) = schema else { return e };
if let Error::Error(m) = &e
&& let Some(prefix) = m.strip_suffix(&alloc::format!(": {name}"))
&& prefix.starts_with("no such ")
{
return Error::Error(alloc::format!("{prefix}: {q}.{name}"));
}
e
}
/// A `temp.`-qualified read is only resolvable once the temp database has
/// been materialized (by a temp write). Until then SQLite reports the name
/// as missing (the temp schema simply holds no such table) — without this
/// guard a read would reach [`db_parts`](Self::db_parts) and panic.
fn guard_qualified_temp(&self, db: DbRef, qualifier: Option<&str>, name: &str) -> Result<()> {
if db == DbRef::Temp && self.temp_db.is_none() {
return Err(Error::Error(alloc::format!(
"no such table: {}.{}",
qualifier.unwrap_or("temp"),
name
)));
}
Ok(())
}
/// The schema catalog and backend for a resolved database. `Temp` requires
/// the temp database to exist (created by [`ensure_temp`](Self::ensure_temp)).
fn db_parts(&self, db: DbRef) -> (&Schema, &Backend) {
match db {
DbRef::Main => (&self.schema, &self.backend),
DbRef::Temp => {
let t = self.temp_db.as_ref().expect("temp db exists");
(&t.schema, &t.backend)
}
DbRef::Attached(i) => (&self.attached[i].schema, &self.attached[i].backend),
}
}
/// The database an *unqualified* table name resolves to: the `temp` database
/// when it holds the table (temp shadows main), else `main`.
fn unqualified_db(&self, name: &str) -> DbRef {
if let Some(t) = &self.temp_db
&& t.schema.table(name).is_some()
{
return DbRef::Temp;
}
// Inside a cross-database view read, unqualified names resolve in the
// view's own database (when it has the table) before falling back to
// main; nested subqueries inherit this via the shared cell.
let def = self.read_default.get();
if def != DbRef::Main {
let (schema, _) = self.db_parts(def);
if schema.table(name).is_some() {
return def;
}
}
// The active `main` schema wins next — SQLite resolves an unqualified name
// main-first.
if self.schema.table(name).is_some() {
return DbRef::Main;
}
// Then attached databases, in attach order (SQLite's `main → temp →
// attached` search). This lets `SELECT … FROM s` find a table living only
// in an attached database; and, because a cross-database write swaps the
// original `main` into the target's attached slot, it also lets a subquery
// inside `UPDATE/DELETE aux.t …` resolve a `main` table while the write
// targets `aux` (ROADMAP Track E). A name present in *both* the active db
// and an attached one still binds to the active db, above.
for (i, d) in self.attached.iter().enumerate() {
if d.schema.table(name).is_some() {
return DbRef::Attached(i);
}
}
DbRef::Main
}
/// The database name (`main`/`temp`/an attached name) a [`DbRef`] denotes —
/// the spelling a three-part `schema.table.column` qualifier must match.
fn db_label(&self, r: DbRef) -> alloc::string::String {
match r {
DbRef::Main => "main".into(),
DbRef::Temp => "temp".into(),
DbRef::Attached(i) => self.attached[i].name.clone(),
}
}
/// The `<db>.<table>` / `*.<alias>` prefix SQLite uses when naming an ambiguous
/// column surfaced by `*` expansion of an unaliased self-join. `name` is the
/// offending source's effective name (alias, else table name). A base table
/// is qualified by the database it resolves to (`main.t`, a temp table that
/// shadows it → `temp.t`, an attached `aux.t`); a derived table (subquery) or a
/// CTE has no database, so SQLite uses `*` (`*.x`). Falls back to the bare name
/// if no FROM source matches (no real self-join can reach the caller then).
fn wildcard_source_qualifier(&self, sel: &Select, name: &str) -> alloc::string::String {
let sources = sel
.from
.iter()
.flat_map(|f| core::iter::once(&f.first).chain(f.joins.iter().map(|j| &j.table)));
for tr in sources {
let eff = tr.alias.as_deref().unwrap_or(&tr.name);
if !eff.eq_ignore_ascii_case(name) {
continue;
}
// A subquery, or an unqualified name bound to a CTE, has no database.
if tr.subquery.is_some()
|| (tr.schema.is_none()
&& sel
.ctes
.iter()
.any(|c| c.name.eq_ignore_ascii_case(&tr.name)))
{
return alloc::format!("*.{name}");
}
let db = match &tr.schema {
Some(s) => self
.resolve_db(Some(s))
.map_or_else(|_| s.clone(), |r| self.db_label(r)),
None => self.db_label(self.unqualified_db(&tr.name)),
};
return alloc::format!("{db}.{name}");
}
name.into()
}
/// The database a `DELETE`/`UPDATE` target resolves to: the explicit `schema.`
/// qualifier verbatim, else the database an unqualified name binds to (a temp
/// table shadows main). Used to validate a three-part column qualifier in the
/// statement's `WHERE`/`SET`.
fn dml_target_db(&self, schema: Option<&str>, _table: &str) -> alloc::string::String {
match schema {
Some(s) => alloc::string::String::from(s),
// `write_target` was resolved before any swap, so its label is the
// target's real database even while the target sits in the active
// `main` slot (where `unqualified_db` would mislabel a temp/attached
// target as `main`).
None => self.db_label(self.write_target.get()),
}
}
/// Create the `temp` database if it does not yet exist (a fresh in-memory
/// database, like an attachment).
fn ensure_temp(&mut self) -> Result<()> {
if self.temp_db.is_some() {
return Ok(());
}
let vfs = crate::vfs::memory::MemoryVfs::new();
let f = vfs.open("temp", OpenFlags::READ_WRITE_CREATE)?;
let mut db = WritePager::create(f, None, 4096)?;
db.commit()?;
let backend = Backend::Write(Box::new(db));
let schema = Schema::read(backend.source())?;
self.temp_db = Some(AttachedDb {
name: "temp".into(),
file: String::new(),
backend,
schema,
});
Ok(())
}
/// Materialize a rowid table from a non-main database into `(columns, rows)`
/// — the cross-database read path (C3/C4). Reads through that database's own
/// backend, so its page numbers resolve correctly.
fn scan_db_table(
&self,
db: DbRef,
name: &str,
alias: Option<&str>,
) -> Result<(Vec<ColumnInfo>, Vec<InputRow>)> {
let (schema, backend) = self.db_parts(db);
let mut meta = self.table_meta_in(schema, name, alias)?;
// Stamp each base-table column with its database of origin so the
// `*`-wildcard ambiguity check can tell `main.t.a` from `aux.t.a`.
let db_label = self.db_label(db);
for col in &mut meta.columns {
col.schema = Some(db_label.clone());
}
let source = backend.source();
let encoding = source.header().text_encoding;
// WITHOUT ROWID: walk the clustered index b-tree (records stored
// PK-first) and decode each entry back into declared column order.
if meta.without_rowid {
let params = Params::default();
let mut rows = Vec::new();
let mut cur = IndexCursor::new(source, meta.root);
while let Some(payload) = cur.next()? {
let storage = decode_record(&payload, encoding)?;
let mut values = unpermute_row(&meta, storage);
self.compute_generated(&meta, &mut values, ¶ms)?;
rows.push(InputRow {
values,
rowid: None,
});
}
return Ok((meta.columns, rows));
}
let mut rows = Vec::new();
let mut cur = TableCursor::new(source, meta.root);
let mut ok = cur.first()?;
while ok {
let rowid = cur.rowid()?;
let values = self.decode_full_row(&meta, rowid, &cur.payload()?, encoding)?;
rows.push(InputRow {
values,
rowid: Some(rowid),
});
ok = cur.next()?;
}
Ok((meta.columns, rows))
}
/// Read a view from a non-main database: run its body with unqualified
/// table names resolving in that database (via `read_default`, restored
/// afterwards). Returns `None` when `name` is not a view in `db`, so the
/// caller can fall back to reading it as a table.
fn scan_db_view(
&self,
db: DbRef,
name: &str,
alias: Option<&str>,
params: &Params,
) -> Result<Option<(Vec<ColumnInfo>, Vec<InputRow>)>> {
use crate::schema::ObjectType;
let (schema, _) = self.db_parts(db);
let obj = match schema
.objects()
.iter()
.find(|o| o.obj_type == ObjectType::View && o.name.eq_ignore_ascii_case(name))
{
Some(o) => o.clone(),
None => return Ok(None),
};
let sql = obj
.sql
.as_deref()
.ok_or_else(|| Error::Corrupt("view has no CREATE statement".into()))?;
let Statement::CreateView(cv) = sql::parse_one(sql)? else {
return Err(Error::Corrupt("schema sql is not CREATE VIEW".into()));
};
// Resolve the view body's unqualified names in `db`; restore on the way
// out (even on error) so an outer query's resolution is unaffected.
let prev = self.read_default.get();
self.read_default.set(db);
let run = self.run_select(&cv.select, params);
self.read_default.set(prev);
let result = run?;
// Declared `(c1, …)` view columns must match the body's column count
// (reported on use, like sqlite); see `try_view`.
if !cv.columns.is_empty() && cv.columns.len() != result.columns.len() {
return Err(Error::Error(format!(
"expected {} columns for '{name}' but got {}",
cv.columns.len(),
result.columns.len()
)));
}
let label = alias.unwrap_or(name).to_string();
let names = if cv.columns.is_empty() {
result.columns.clone()
} else {
cv.columns.clone()
};
// NOTE: a temp/attached view column's affinity/collation still defaults to
// BLOB/BINARY — `subquery_column_origins` resolves base columns through the
// main schema only, so it cannot see a temp/attached base table. The
// common main-database case is handled in `try_view`.
let columns: Vec<ColumnInfo> = names
.into_iter()
.map(|n| ColumnInfo {
name: n,
table: label.clone(),
affinity: eval::Affinity::Blob,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
})
.collect();
let rows = result
.rows
.into_iter()
.map(|values| InputRow {
values,
rowid: None,
})
.collect();
Ok(Some((columns, rows)))
}
/// The `dbstat` eponymous read-only virtual table: one row per b-tree page
/// (plus one per overflow page), reporting SQLite-compatible per-page storage
/// statistics (`name, path, pageno, pagetype, ncell, payload, unused,
/// mx_payload, pgoffset, pgsize`). Byte-compatible with SQLite's dbstat
/// extension: `unused` is derived from the page header's free-space pointer,
/// fragmented-bytes count, and freeblock chain; `payload` sums the locally
/// stored cell bytes; `mx_payload` is the largest total cell payload. The
/// `path` strings use SQLite's `/<hex-child>/` and `+<hex-overflow>` format.
/// The `sqlite_dbpage` read-only virtual table: one row per database page,
/// `(pgno INTEGER, data BLOB)`, where `data` is the page's raw bytes (page 1
/// includes the 100-byte file header). Read access only (sqlite's `dbpage` is
/// also writable; that is `dbpage-2`). `src` is the page source of the target
/// database (`main`, an attached, or `temp`).
fn scan_dbpage(
&self,
src: &dyn PageSource,
alias: Option<&str>,
) -> Result<(Vec<ColumnInfo>, Vec<InputRow>)> {
use eval::Affinity::{Blob, Integer};
let label = alias.unwrap_or("sqlite_dbpage").to_string();
let col = |name: &str, affinity| ColumnInfo {
name: String::from(name),
table: label.clone(),
affinity,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
};
let columns = alloc::vec![col("pgno", Integer), col("data", Blob)];
let count = src.page_count();
let mut rows: Vec<InputRow> = Vec::with_capacity(count as usize);
for pgno in 1..=count {
let page = src.page(pgno)?;
rows.push(InputRow {
values: alloc::vec![
Value::Integer(pgno as i64),
Value::Blob(page.data().to_vec()),
],
rowid: Some(pgno as i64),
});
}
Ok((columns, rows))
}
/// Whether a DML target names the eponymous *writable* `sqlite_dbpage` vtab.
/// Only the unqualified form (targeting the active `main` database) is a write
/// target — a schema-qualified `aux.sqlite_dbpage` write is rare and left to
/// the normal path (matching the read side's main-default). A real table of
/// that name (none can normally exist — `sqlite_` is reserved) shadows it.
fn is_dbpage_write_target(&self, schema: Option<&str>, table: &str) -> bool {
schema.is_none()
&& table.eq_ignore_ascii_case("sqlite_dbpage")
&& self.schema.table(table).is_none()
}
/// The columns of the `sqlite_dbpage` vtab, `(pgno INTEGER, data BLOB)`.
fn dbpage_columns(&self) -> Vec<ColumnInfo> {
use eval::Affinity::{Blob, Integer};
let col = |name: &str, affinity| ColumnInfo {
name: String::from(name),
table: String::from("sqlite_dbpage"),
affinity,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
};
alloc::vec![col("pgno", Integer), col("data", Blob)]
}
/// `UPDATE sqlite_dbpage SET data = <blob> WHERE pgno = …` — overwrite the raw
/// bytes of each matching page (SQLite's `dbpageUpdate`, minus the defensive-mode
/// gate graphite has no equivalent for). Assigning `pgno` is rejected ("cannot
/// insert" — a page cannot be relocated), and the assigned value must be a blob
/// exactly one page in size ("bad page value"). `RETURNING`/`FROM` are not
/// meaningful here and are rejected.
fn exec_dbpage_update(&mut self, upd: &Update, params: &Params) -> Result<usize> {
if !upd.returning.is_empty() || upd.from.is_some() || !upd.row_assignments.is_empty() {
return Err(Error::Unsupported("RETURNING / FROM on sqlite_dbpage"));
}
// Only `data` may be assigned; touching `pgno` moves a page, which SQLite's
// xUpdate reports as "cannot insert".
for (col, _) in &upd.assignments {
if col.eq_ignore_ascii_case("pgno") {
return Err(Error::Error("cannot insert".into()));
}
if !col.eq_ignore_ascii_case("data") {
return Err(Error::Error(format!("no such column: {col}")));
}
}
let cols = self.dbpage_columns();
let page_size = self.backend.source().header().page_size as usize;
let count = self.backend.source().page_count();
// First pass (reads only): find the pages WHERE selects and compute their
// new bytes; then a second pass writes them (so the read borrow is dropped
// before the writer borrow, and a mid-loop failure changes nothing).
let mut writes: Vec<(u32, Vec<u8>)> = Vec::new();
for pgno in 1..=count {
let data = self.backend.source().page(pgno)?.data().to_vec();
let row = alloc::vec![Value::Integer(pgno as i64), Value::Blob(data)];
let selected = match &upd.where_clause {
Some(w) => {
let ctx = row_ctx(&row, &cols, Some(pgno as i64), params).with_subqueries(self);
eval::truth(&eval::eval(w, &ctx)?) == Some(true)
}
None => true,
};
if !selected {
continue;
}
let mut new_data = row[1].clone();
for (_, expr) in &upd.assignments {
let ctx = row_ctx(&row, &cols, Some(pgno as i64), params).with_subqueries(self);
new_data = eval::eval(expr, &ctx)?;
}
match new_data {
Value::Blob(b) if b.len() == page_size => writes.push((pgno, b)),
_ => return Err(Error::Error("bad page value".into())),
}
}
let n = writes.len();
let w = self.backend.writer()?;
for (pgno, bytes) in writes {
w.write_page(pgno, bytes)?;
}
Ok(n)
}
fn scan_dbstat(
&self,
schema: &Schema,
src: &dyn PageSource,
alias: Option<&str>,
) -> Result<(Vec<ColumnInfo>, Vec<InputRow>)> {
use crate::btree::page::{BtreePage, PageType};
use eval::Affinity::{Integer, Text};
let label = alias.unwrap_or("dbstat").to_string();
let col = |name: &str, affinity| ColumnInfo {
name: String::from(name),
table: label.clone(),
affinity,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
};
let columns = alloc::vec![
col("name", Text),
col("path", Text),
col("pageno", Integer),
col("pagetype", Text),
col("ncell", Integer),
col("payload", Integer),
col("unused", Integer),
col("mx_payload", Integer),
col("pgoffset", Integer),
col("pgsize", Integer),
];
let usable = src.usable_size();
let page_size = src.header().page_size as i64;
let be16 = |d: &[u8], off: usize| u16::from_be_bytes([d[off], d[off + 1]]) as usize;
// The b-trees to walk: `sqlite_schema` (page 1) first, then every object
// that owns a root page (tables and indexes), in catalog order.
let mut btrees: Vec<(String, u32)> = alloc::vec![(String::from("sqlite_schema"), 1)];
for obj in schema.objects() {
if obj.rootpage != 0 {
btrees.push((obj.name.clone(), obj.rootpage));
}
}
let mut rows: Vec<InputRow> = Vec::new();
for (name, root) in btrees {
// Pre-order DFS; child order does not affect per-page stats.
let mut stack = alloc::vec![(root, String::from("/"))];
while let Some((pgno, path)) = stack.pop() {
let page = src.page(pgno)?;
let bp = BtreePage::parse(page)?;
let data = bp.data();
let body = if pgno == 1 { 100 } else { 0 };
let ncell = bp.num_cells();
let is_leaf = bp.page_type().is_leaf();
let nhdr = body + if is_leaf { 8 } else { 12 };
let ptype = if is_leaf { "leaf" } else { "internal" };
let mut payload = 0i64;
let mut mx = 0i64;
// SQLite's dbstat reports an overflow page's `pgoffset` as the
// offset of the *previously visited* page (the owning leaf for a
// chain's first page, the prior chain page after) — an off-by-one
// in its statSizeAndOffset. `prev_pgno` reproduces that lag; it
// starts at the leaf and carries across this page's cells.
let mut prev_pgno = pgno;
// Sum local payload and emit overflow-page rows.
for i in 0..ncell {
let pl = match bp.page_type() {
PageType::LeafTable => bp.table_leaf_cell(i, usable)?.payload,
PageType::LeafIndex | PageType::InteriorIndex => {
bp.index_cell(i, usable)?.payload
}
// Interior-table cells carry no payload.
PageType::InteriorTable => continue,
};
payload += pl.local_len as i64;
mx = mx.max(pl.total_len as i64);
// Walk this cell's overflow chain, one row per overflow page.
let mut ovfl = pl.overflow;
let mut remaining = pl.total_len - pl.local_len;
let mut iovfl = 0usize;
while ovfl != 0 {
let opage = src.page(ovfl)?;
let odata = opage.data();
let next = u32::from_be_bytes([odata[0], odata[1], odata[2], odata[3]]);
let cap = usable - 4;
let (opayload, ounused) = if remaining <= cap {
(remaining as i64, (cap - remaining) as i64)
} else {
(cap as i64, 0)
};
rows.push(InputRow {
values: alloc::vec![
Value::Text(name.clone().into()),
Value::Text(alloc::format!("{path}{i:03x}+{iovfl:06x}").into()),
Value::Integer(ovfl as i64),
Value::Text(String::from("overflow").into()),
Value::Integer(0),
Value::Integer(opayload),
Value::Integer(ounused),
Value::Integer(0),
Value::Integer((prev_pgno as i64 - 1) * page_size),
Value::Integer(page_size),
],
rowid: None,
});
remaining = remaining.saturating_sub(cap);
iovfl += 1;
prev_pgno = ovfl;
ovfl = next;
}
}
// Free space: (cell-content-area-start - header - cell-pointer
// array) + fragmented free bytes + the freeblock chain.
let cc = match be16(data, body + 5) {
0 => 65536,
n => n,
};
let mut unused = cc as i64 - nhdr as i64 - 2 * ncell as i64 + data[body + 7] as i64;
let mut fb = be16(data, body + 1);
while fb != 0 && fb + 4 <= data.len() {
unused += be16(data, fb + 2) as i64;
fb = be16(data, fb);
}
rows.push(InputRow {
values: alloc::vec![
Value::Text(name.clone().into()),
Value::Text(path.clone().into()),
Value::Integer(pgno as i64),
Value::Text(String::from(ptype).into()),
Value::Integer(ncell as i64),
Value::Integer(payload),
Value::Integer(unused),
Value::Integer(mx),
Value::Integer((pgno as i64 - 1) * page_size),
Value::Integer(page_size),
],
rowid: None,
});
// Descend into children of an interior page.
if !is_leaf {
for i in 0..=ncell {
let child = bp.child_pointer(i)?;
if child != 0 {
stack.push((child, alloc::format!("{path}{i:03x}/")));
}
}
}
}
}
Ok((columns, rows))
}
/// The `fts5vocab` virtual table: a read-only view over another FTS5 table's
/// vocabulary. `args` is the `USING fts5vocab(...)` list; `vocab_name`/`alias`
/// label the result. Tokenizes the referenced table's documents (with the
/// same `fts5_tokenize` used for indexing) and aggregates per the requested
/// form — `row` (term, doc, cnt), `col` (term, col, doc, cnt), or `instance`
/// (term, doc, col, offset) — byte-compatible with SQLite's fts5vocab.
#[cfg(feature = "fts5")]
fn scan_fts5vocab(
&self,
args: &[String],
vocab_name: &str,
alias: Option<&str>,
) -> Result<(Vec<ColumnInfo>, Vec<InputRow>)> {
use alloc::collections::{BTreeMap, BTreeSet};
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let (ft_name, form) = crate::vtab::fts5vocab_args(&arg_refs)?;
let label = alias.unwrap_or(vocab_name).to_string();
let colnames: &[&str] = match form.as_str() {
"row" => &["term", "doc", "cnt"],
"col" => &["term", "col", "doc", "cnt"],
_ => &["term", "doc", "col", "offset"],
};
let columns: Vec<ColumnInfo> = colnames
.iter()
.map(|n| ColumnInfo {
name: String::from(*n),
table: label.clone(),
affinity: eval::Affinity::Blob,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
})
.collect();
// The referenced FTS5 table: its column names + documents (the persistent
// `<ft>_data` backing table holds one row per document, column-ordered).
let (ft_module, ft_args, ft_schema) = self.vtab_meta(&ft_name)?;
if !ft_module.eq_ignore_ascii_case("fts5") {
return Err(Error::Error(format!("no such fts5 table: {ft_name}")));
}
let ft_cols = ft_schema.columns;
// Tokenize with the referenced table's own tokenizer (porter / diacritics).
let ft_refs: Vec<&str> = ft_args.iter().map(String::as_str).collect();
let ft_tok = crate::vtab::fts5_tok_config(&ft_refs);
// Documents live in `<ft>_content` (sqlite's layout): `(id, c0, c1, …)`.
// Drop the leading `id` so `vals` is the column-ordered document.
let bmeta = self.table_meta(&format!("{ft_name}_content"), None)?;
let docs: Vec<(i64, Vec<Value>)> = self
.scan_table(&bmeta)?
.into_iter()
.map(|(rowid, mut vals)| {
if !vals.is_empty() {
vals.remove(0);
}
(rowid, vals)
})
.collect();
// FTS5 columns store text; coerce other stored types the way SQLite does
// (NULL/blob contribute no tokens).
let to_text = |v: &Value| -> Option<String> {
match v {
Value::Text(s) => Some(s.as_str().to_string()),
Value::Integer(i) => Some(i.to_string()),
Value::Real(r) => Some(eval::format_real(*r)),
Value::Null | Value::Blob(_) => None,
}
};
let mut rows: Vec<InputRow> = Vec::new();
match form.as_str() {
"row" => {
// term → (distinct documents, total occurrences)
let mut map: BTreeMap<String, (BTreeSet<i64>, i64)> = BTreeMap::new();
for (rowid, vals) in &docs {
for v in vals.iter().take(ft_cols.len()) {
if let Some(t) = to_text(v) {
for tok in crate::vtab::fts5_tokenize(&t, ft_tok) {
let e = map.entry(tok).or_default();
e.0.insert(*rowid);
e.1 += 1;
}
}
}
}
for (term, (ds, cnt)) in map {
rows.push(InputRow {
values: alloc::vec![
Value::Text(term.into()),
Value::Integer(ds.len() as i64),
Value::Integer(cnt),
],
rowid: None,
});
}
}
"col" => {
// (term, column index) → (distinct documents, total occurrences)
let mut map: BTreeMap<(String, usize), (BTreeSet<i64>, i64)> = BTreeMap::new();
for (rowid, vals) in &docs {
for (ci, v) in vals.iter().take(ft_cols.len()).enumerate() {
if let Some(t) = to_text(v) {
for tok in crate::vtab::fts5_tokenize(&t, ft_tok) {
let e = map.entry((tok, ci)).or_default();
e.0.insert(*rowid);
e.1 += 1;
}
}
}
}
for ((term, ci), (ds, cnt)) in map {
rows.push(InputRow {
values: alloc::vec![
Value::Text(term.into()),
Value::Text(ft_cols[ci].clone().into()),
Value::Integer(ds.len() as i64),
Value::Integer(cnt),
],
rowid: None,
});
}
}
_ => {
// instance: one row per token occurrence (term, doc, col, offset),
// offset being the 0-based token position within that column.
let mut insts: Vec<(String, i64, usize, i64)> = Vec::new();
for (rowid, vals) in &docs {
for (ci, v) in vals.iter().take(ft_cols.len()).enumerate() {
if let Some(t) = to_text(v) {
for (off, tok) in crate::vtab::fts5_tokenize(&t, ft_tok)
.into_iter()
.enumerate()
{
insts.push((tok, *rowid, ci, off as i64));
}
}
}
}
insts.sort();
for (term, rowid, ci, off) in insts {
rows.push(InputRow {
values: alloc::vec![
Value::Text(term.into()),
Value::Integer(rowid),
Value::Text(ft_cols[ci].clone().into()),
Value::Integer(off),
],
rowid: None,
});
}
}
}
Ok((columns, rows))
}
/// Read a SQLite-format R-Tree's entries by walking its `<name>_node` b-tree
/// of nodes. Each node blob is a 2-byte BE depth (meaningful in the root) +
/// 2-byte BE cell count, then cells of an 8-byte BE rowid (leaf) / child
/// node-number (interior) followed by `n_coords` 4-byte BE coordinates (f32
/// for `rtree`, i32 for `rtree_i32`). Yields one `InputRow` per leaf entry:
/// `[id, coord0, …]`. The traversal collects a superset; `run_core` re-applies
/// the full WHERE.
fn scan_rtree_nodes(
&self,
name: &str,
n_coords: usize,
integer: bool,
bbox: &[(usize, ConstraintOp, f64)],
) -> Result<Vec<InputRow>> {
use alloc::collections::BTreeMap;
let node_meta = self.table_meta(&format!("{name}_node"), None)?;
let mut nodes: BTreeMap<i64, Vec<u8>> = BTreeMap::new();
for (nodeno, vals) in self.scan_table(&node_meta)? {
// `<name>_node` is `(nodeno INTEGER PRIMARY KEY, data)`; the blob is
// the `data` column (the first value is the rowid/nodeno itself).
if let Some(Value::Blob(b)) = vals.into_iter().find(|v| matches!(v, Value::Blob(_))) {
nodes.insert(nodeno, b);
}
}
let cell_size = 8 + n_coords * 4;
// Read coordinate `j` (0-based) of the cell whose 8-byte key starts at `off`.
let coord_at = |blob: &[u8], off: usize, j: usize| -> f64 {
let p = off + 8 + j * 4;
let b: [u8; 4] = blob[p..p + 4].try_into().expect("4 bytes");
if integer {
f64::from(i32::from_be_bytes(b))
} else {
f64::from(f32::from_be_bytes(b))
}
};
// Spatial pushdown: a subtree's stored cell is the MBR of its entries —
// `[lo, hi]` per dimension — so a constraint on either coordinate column of
// dimension `d` can be satisfied by some entry only if the MBR overlaps it.
// The on-disk MBR is a superset (f32 rounds min down / max up), so this
// prune never drops a matching entry; `run_core` re-applies the full WHERE,
// making the visited rows a correct superset. Constraints whose dimension
// can't possibly be satisfied prune the whole subtree.
let subtree_matches = |blob: &[u8], off: usize| -> bool {
bbox.iter().all(|&(ci, op, v)| {
let d = ci / 2;
let lo = coord_at(blob, off, 2 * d);
let hi = coord_at(blob, off, 2 * d + 1);
match op {
ConstraintOp::Ge => hi >= v,
ConstraintOp::Gt => hi > v,
ConstraintOp::Le => lo <= v,
ConstraintOp::Lt => lo < v,
ConstraintOp::Eq => lo <= v && v <= hi,
_ => true,
}
})
};
let mut out = Vec::new();
let Some(root) = nodes.get(&1) else {
return Ok(out);
};
if root.len() < 4 {
return Ok(out);
}
// The root header's depth field is the tree height; descend that many
// levels to reach the leaves.
let depth = i64::from(u16::from_be_bytes([root[0], root[1]]));
let mut stack: Vec<(i64, i64)> = alloc::vec![(1, depth)];
while let Some((nodeno, level)) = stack.pop() {
let Some(blob) = nodes.get(&nodeno) else {
continue;
};
if blob.len() < 4 {
continue;
}
let ncell = u16::from_be_bytes([blob[2], blob[3]]) as usize;
for i in 0..ncell {
let off = 4 + i * cell_size;
if off + cell_size > blob.len() {
break;
}
let key = i64::from_be_bytes(blob[off..off + 8].try_into().expect("8 bytes"));
if level > 0 {
// Interior cell: the 8-byte field is a child node number. Skip
// the whole subtree when its MBR can't satisfy the constraints.
if !bbox.is_empty() && !subtree_matches(blob, off) {
continue;
}
stack.push((key, level - 1));
continue;
}
// Leaf cell: the 8-byte field is the entry's rowid.
let mut row = Vec::with_capacity(1 + n_coords);
row.push(Value::Integer(key));
for c in 0..n_coords {
let p = off + 8 + c * 4;
let b: [u8; 4] = blob[p..p + 4].try_into().expect("4 bytes");
row.push(if integer {
Value::Integer(i64::from(i32::from_be_bytes(b)))
} else {
Value::Real(f64::from(f32::from_be_bytes(b)))
});
}
out.push(InputRow {
values: row,
rowid: Some(key),
});
}
}
Ok(out)
}
/// The fixed R-Tree node size for this database's page size.
fn rtree_node_size_for(&self, n_coord: usize) -> usize {
rtree_node_size(n_coord, self.backend.source().header().page_size as usize)
}
/// The current entries of an R-Tree as `(rowid, coords)` cells (via the M1
/// node reader; coords come back as the stored f32/i32 values widened to f64).
fn rtree_entries(&self, name: &str, n_coord: usize, integer: bool) -> Result<Vec<RtreeCell>> {
Ok(self
.scan_rtree_nodes(name, n_coord, integer, &[])?
.into_iter()
.map(|r| {
let key = match r.values.first() {
Some(Value::Integer(i)) => *i,
_ => 0,
};
let coords = r.values[1..1 + n_coord]
.iter()
.map(|v| match v {
Value::Integer(i) => *i as f64,
Value::Real(f) => *f,
_ => 0.0,
})
.collect();
RtreeCell { key, coords }
})
.collect())
}
/// Replace an R-Tree's three shadow tables with a freshly bulk-built tree.
fn rtree_write_build(&mut self, name: &str, build: &RtreeBuild) -> Result<()> {
self.rtree_write_build_aux(name, build, &alloc::collections::BTreeMap::new())
}
/// Replace an R-Tree's shadow tables with a freshly bulk-built tree, writing
/// per-rowid auxiliary column values into `_rowid`'s extra `a0..aN` columns
/// (the geopoly layout). An empty `aux` map yields the plain-rtree
/// `_rowid(rowid, nodeno)` rows unchanged, so this is byte-identical to the
/// no-aux path for plain rtree.
fn rtree_write_build_aux(
&mut self,
name: &str,
build: &RtreeBuild,
aux: &alloc::collections::BTreeMap<i64, Vec<Value>>,
) -> Result<()> {
let node_t = sql::print::ident(&format!("{name}_node"));
let rowid_t = sql::print::ident(&format!("{name}_rowid"));
let parent_t = sql::print::ident(&format!("{name}_parent"));
let pv = |vals: Vec<Value>| Params {
positional: vals,
named: Vec::new(),
};
self.execute(&format!("DELETE FROM {node_t}"))?;
self.execute(&format!("DELETE FROM {rowid_t}"))?;
self.execute(&format!("DELETE FROM {parent_t}"))?;
for (nodeno, blob) in &build.nodes {
self.execute_params(
&format!("INSERT INTO {node_t} VALUES(?1,?2)"),
&pv(alloc::vec![
Value::Integer(*nodeno),
Value::Blob(blob.clone())
]),
)?;
}
for (rowid, nodeno) in &build.rowids {
let mut vals = alloc::vec![Value::Integer(*rowid), Value::Integer(*nodeno)];
let mut placeholders = String::from("?1,?2");
if let Some(a) = aux.get(rowid) {
for (k, v) in a.iter().enumerate() {
vals.push(v.clone());
placeholders.push_str(&format!(",?{}", k + 3));
}
}
self.execute_params(
&format!("INSERT INTO {rowid_t} VALUES({placeholders})"),
&pv(vals),
)?;
}
for (child, parent) in &build.parents {
self.execute_params(
&format!("INSERT INTO {parent_t} VALUES(?1,?2)"),
&pv(alloc::vec![Value::Integer(*child), Value::Integer(*parent)]),
)?;
}
Ok(())
}
/// Create an R-Tree's storage: the `_node`/`_rowid`/`_parent` shadow tables
/// (byte-compatible with SQLite) plus an empty root node. When the R-Tree
/// declares `n_aux` auxiliary (`+col`) columns, `_rowid` is widened with one
/// `aK` column per aux column (`rowid,nodeno,a0,…,a(n_aux-1)`) — exactly
/// SQLite's `rtree.c` shadow-table schema — so the aux values persist in a
/// stock-`sqlite3`-readable file. `n_aux == 0` yields the plain
/// `_rowid(rowid,nodeno)` layout byte-for-byte.
fn rtree_create_storage(
&mut self,
name: &str,
n_coord: usize,
integer: bool,
n_aux: usize,
) -> Result<()> {
// SQLite generates the shadow-table schema with no space after the commas
// (`(nodeno INTEGER PRIMARY KEY,data)`); match that so `sqlite_master.sql`
// is byte-identical.
let mut rowid_cols = String::from("rowid INTEGER PRIMARY KEY,nodeno");
for k in 0..n_aux {
rowid_cols.push_str(&format!(",a{k}"));
}
for (suffix, cols) in [
("_node", "nodeno INTEGER PRIMARY KEY,data".to_string()),
("_rowid", rowid_cols),
(
"_parent",
"nodeno INTEGER PRIMARY KEY,parentnode".to_string(),
),
] {
let sql = format!(
"CREATE TABLE {}({cols})",
sql::print::ident(&format!("{name}{suffix}"))
);
let Statement::CreateTable(ct) = sql::parse_one(&sql)? else {
unreachable!("constructed a CREATE TABLE")
};
self.exec_create_table(&ct, &sql)?;
}
let build = rtree_bulk_build(
Vec::new(),
n_coord,
integer,
self.rtree_node_size_for(n_coord),
);
self.rtree_write_build(name, &build)
}
/// Apply inserts/deletes to an R-Tree that has auxiliary (`+col`) columns:
/// rebuild the node tree from the surviving coordinate cells and rewrite
/// `_rowid` with each survivor's aux values in `a0..aN`. This mirrors
/// [`Self::geopoly_apply`] (which is the 2-D, `_shape`-in-`a0` special case),
/// generalized to any `n_coord`/`integer`. Each insert carries its coordinate
/// cell and its aux tuple `[a0, a1, …]` (the trailing `values[1+n_coord..]`).
fn rtree_apply_aux(
&mut self,
name: &str,
n_coord: usize,
integer: bool,
inserts: Vec<(RtreeCell, Vec<Value>)>,
deletes: &[i64],
) -> Result<()> {
let mut entries = self.rtree_entries(name, n_coord, integer)?;
let mut aux = self.geopoly_read_aux(name)?;
let removed: alloc::collections::BTreeSet<i64> = deletes
.iter()
.copied()
.chain(inserts.iter().map(|(c, _)| c.key))
.collect();
entries.retain(|c| !removed.contains(&c.key));
for r in &removed {
aux.remove(r);
}
for (cell, a) in inserts {
aux.insert(cell.key, a);
entries.push(cell);
}
let build = rtree_bulk_build(entries, n_coord, integer, self.rtree_node_size_for(n_coord));
self.rtree_write_build_aux(name, &build, &aux)
}
/// Scan an aux-column R-Tree, pruning candidate subtrees by `bbox` and
/// yielding one row per surviving entry as `[id, coord0, …, a0, a1, …]` — the
/// coordinates read from the byte-compatible node tree, the aux values joined
/// in from `_rowid`. The bbox prune is a superset, so `run_core` re-applies
/// the exact `WHERE`.
fn scan_rtree_aux(
&self,
name: &str,
n_coord: usize,
integer: bool,
bbox: &[(usize, ConstraintOp, f64)],
) -> Result<Vec<InputRow>> {
let aux = self.geopoly_read_aux(name)?;
let candidates = self.scan_rtree_nodes(name, n_coord, integer, bbox)?;
let mut out = Vec::with_capacity(candidates.len());
for r in candidates {
let Some(rowid) = r.rowid else { continue };
let mut values = r.values; // [id, coord0, …, coord(n_coord-1)]
values.extend(aux.get(&rowid).cloned().unwrap_or_default());
out.push(InputRow {
values,
rowid: Some(rowid),
});
}
Ok(out)
}
/// Apply inserts and/or a delete to an R-Tree by rebuilding its node tree
/// (read all entries, apply, bulk-build, rewrite). `inserts` carry coords
/// already rounded to the conservative f32/i32 form.
fn rtree_apply(
&mut self,
name: &str,
n_coord: usize,
integer: bool,
inserts: Vec<RtreeCell>,
deletes: &[i64],
) -> Result<()> {
let mut entries = self.rtree_entries(name, n_coord, integer)?;
let removed: alloc::collections::BTreeSet<i64> = deletes
.iter()
.copied()
.chain(inserts.iter().map(|c| c.key))
.collect();
entries.retain(|c| !removed.contains(&c.key));
entries.extend(inserts);
let build = rtree_bulk_build(entries, n_coord, integer, self.rtree_node_size_for(n_coord));
self.rtree_write_build(name, &build)
}
/// Create a geopoly table's storage: SQLite's byte-compatible
/// `_node`/`_parent` shadow tables plus a `_rowid` table EXTENDED with one
/// `aK` aux column per stored value (`a0` = the `_shape` BLOB, `a1..aN` = the
/// `n_user` user columns), and an empty root node. The R-Tree indexes a 2-D
/// bounding box, so `n_coord` is fixed at 4 (minX, maxX, minY, maxY).
fn geopoly_create_storage(&mut self, name: &str, n_user: usize) -> Result<()> {
// `_rowid(rowid INTEGER PRIMARY KEY,nodeno,a0,a1,…,aN)` — no space after the
// commas, matching SQLite's shadow-table schema byte-for-byte.
let mut rowid_cols = String::from("rowid INTEGER PRIMARY KEY,nodeno");
for k in 0..=n_user {
rowid_cols.push_str(&format!(",a{k}"));
}
for (suffix, cols) in [
("_node", "nodeno INTEGER PRIMARY KEY,data".to_string()),
("_rowid", rowid_cols),
(
"_parent",
"nodeno INTEGER PRIMARY KEY,parentnode".to_string(),
),
] {
let sql = format!(
"CREATE TABLE {}({cols})",
sql::print::ident(&format!("{name}{suffix}"))
);
let Statement::CreateTable(ct) = sql::parse_one(&sql)? else {
unreachable!("constructed a CREATE TABLE")
};
self.exec_create_table(&ct, &sql)?;
}
let build = rtree_bulk_build(Vec::new(), 4, false, self.rtree_node_size_for(4));
self.rtree_write_build(name, &build)
}
/// Read a geopoly table's stored aux columns as a `rowid -> [a0, a1, …]` map.
fn geopoly_read_aux(
&self,
name: &str,
) -> Result<alloc::collections::BTreeMap<i64, Vec<Value>>> {
let meta = self.table_meta(&format!("{name}_rowid"), None)?;
let mut out = alloc::collections::BTreeMap::new();
for (rowid, vals) in self.scan_table(&meta)? {
// `scan_table` yields every declared column (the `rowid` IPK filled
// from the rowid, then `nodeno`, then the `aK` aux columns), so drop
// the leading `rowid` and `nodeno` and keep the `aK` values.
let aux: Vec<Value> = vals.into_iter().skip(2).collect();
out.insert(rowid, aux);
}
Ok(out)
}
/// Apply inserts/deletes to a geopoly table: rebuild the node tree from the
/// surviving bbox cells and rewrite `_rowid` (with aux) accordingly. Each
/// insert carries its bbox cell and its aux values `[a0, a1, …]`.
fn geopoly_apply(
&mut self,
name: &str,
inserts: Vec<(RtreeCell, Vec<Value>)>,
deletes: &[i64],
) -> Result<()> {
// geopoly is the 2-D (`n_coord == 4`), float, `_shape`-in-`a0` special
// case of an aux-column R-Tree.
self.rtree_apply_aux(name, 4, false, inserts, deletes)
}
/// Scan a geopoly table, pruning candidate subtrees by `bbox` (query-polygon
/// bounds, as `(coord-column, op, value)` triples over the 4 bbox coords).
/// Yields one row per surviving entry: `[a0 (_shape), a1, …]` with its rowid.
/// The prune is a superset (the on-disk MBR rounds out), so `run_core` safely
/// re-applies the exact `WHERE`.
fn scan_geopoly(
&self,
name: &str,
bbox: &[(usize, ConstraintOp, f64)],
) -> Result<Vec<InputRow>> {
let aux = self.geopoly_read_aux(name)?;
let candidates = self.scan_rtree_nodes(name, 4, false, bbox)?;
let mut out = Vec::with_capacity(candidates.len());
for r in candidates {
let Some(rowid) = r.rowid else { continue };
let values = aux.get(&rowid).cloned().unwrap_or_default();
out.push(InputRow {
values,
rowid: Some(rowid),
});
}
Ok(out)
}
/// Derive the bounding-box prune for a geopoly query from a `WHERE` clause:
/// each top-level `geopoly_overlap(_shape, Q)` / `geopoly_within(_shape, Q)`
/// conjunct contributes Q's bounding box as four `(coord, op, value)` triples
/// requiring the stored MBR to overlap Q's box (`minX ≤ Qmaxx`, `maxX ≥
/// Qminx`, `minY ≤ Qmaxy`, `maxY ≥ Qminy`). This is a valid superset for both
/// predicates (containment implies overlap), so `run_core` re-applying the
/// exact function never drops or admits a wrong row. Returns `None` when no
/// such usable conjunct is present (a full scan).
fn geopoly_query_bbox(
&self,
where_expr: &Expr,
_columns: &[ColumnInfo],
params: &Params,
) -> Option<Vec<(usize, ConstraintOp, f64)>> {
let mut conjuncts: Vec<&Expr> = Vec::new();
and_conjuncts(where_expr, &mut conjuncts);
let mut out: Vec<(usize, ConstraintOp, f64)> = Vec::new();
for e in conjuncts {
let Expr::Function { name, args, .. } = e else {
continue;
};
if !(name.eq_ignore_ascii_case("geopoly_overlap")
|| name.eq_ignore_ascii_case("geopoly_within"))
|| args.len() != 2
{
continue;
}
// The first argument must be this table's `_shape` column; the second a
// constant polygon we can evaluate now (a column reference errors in the
// rowless context and is skipped, leaving a safe full scan).
if !matches!(&args[0], Expr::Column { column, .. } if column.eq_ignore_ascii_case("_shape"))
{
continue;
}
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let Ok(q) = eval::eval(&args[1], &ctx) else {
continue;
};
let Some(poly) = crate::geopoly::parse_value(&q) else {
continue;
};
let (qmnx, qmxx, qmny, qmxy) = poly.bbox_coords();
out.push((0, ConstraintOp::Le, f64::from(qmxx))); // minX ≤ Qmaxx
out.push((1, ConstraintOp::Ge, f64::from(qmnx))); // maxX ≥ Qminx
out.push((2, ConstraintOp::Le, f64::from(qmxy))); // minY ≤ Qmaxy
out.push((3, ConstraintOp::Ge, f64::from(qmny))); // maxY ≥ Qminy
}
(!out.is_empty()).then_some(out)
}
/// The `(idxNum, idxStr)` geopoly's `xBestIndex` reports for `EXPLAIN QUERY
/// PLAN`, matching sqlite: a rowid equality wins (`1`,`rowid`), then a
/// `geopoly_overlap` (`2`,`rtree`), then a `geopoly_within` (`3`,`rtree`),
/// else a full scan (`4`,`fullscan`).
fn geopoly_eqp_plan(&self, sel: &Select, params: &Params) -> (i32, &'static str) {
let Some(where_expr) = sel.where_clause.as_ref() else {
return (4, "fullscan");
};
let mut conjuncts: Vec<&Expr> = Vec::new();
and_conjuncts(where_expr, &mut conjuncts);
let is_rowid = |e: &Expr| {
matches!(e, Expr::Column { column, .. }
if matches!(column.to_ascii_lowercase().as_str(), "rowid" | "_rowid_" | "oid"))
};
let mut has_overlap = false;
let mut has_within = false;
for e in &conjuncts {
match e {
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} if is_rowid(left) || is_rowid(right) => {
let _ = params;
return (1, "rowid");
}
Expr::Function { name, args, .. }
if args.len() == 2
&& matches!(&args[0], Expr::Column { column, .. } if column.eq_ignore_ascii_case("_shape")) =>
{
if name.eq_ignore_ascii_case("geopoly_overlap") {
has_overlap = true;
} else if name.eq_ignore_ascii_case("geopoly_within") {
has_within = true;
}
}
_ => {}
}
}
if has_overlap {
(2, "rtree")
} else if has_within {
(3, "rtree")
} else {
(4, "fullscan")
}
}
/// After a write to an FTS5 table, rebuild its inverted index from the
/// updated `<name>_content` documents. A no-op for every other module — and for
/// an external-content fts5 table, whose index is (re)built only by the explicit
/// `rebuild` command (direct DML on such a table is rejected up front, so this
/// is never reached for it).
fn fts5_maybe_rebuild(&mut self, module_name: &str, table: &str) -> Result<()> {
#[cfg(feature = "fts5")]
if module_name.eq_ignore_ascii_case("fts5") {
let args = self.vtab_meta(table)?.1;
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if crate::vtab::fts5_no_local_content(&arg_refs) {
// No-local-content tables maintain their index by direct-DML posting
// deltas (`fts5_rebuild_from_gpost`), not a bulk rebuild from a
// content copy.
return Ok(());
}
// In AUTOCOMMIT, a single INSERT statement is its own transaction, so
// sqlite appends exactly one new level-0 segment for its new rows —
// reproduce that incrementally (byte-identical multi-segment layout).
if !self.in_tx && self.open_savepoints == 0 {
if self.fts5_incremental_write(table)? {
return Ok(());
}
return self.fts5_rebuild_index(table);
}
// Inside an explicit BEGIN/SAVEPOINT the whole transaction's postings
// flush as ONE level-0 segment at commit (SQLite accumulates them in an
// in-memory hash and writes them at `xSync`/`xCommit`). So DON'T touch
// the segment index (`_data`/`_idx`/`_docsize`) per statement — the new
// document rows are already in `<name>_content` (via the vtab update),
// which is enough for in-transaction `MATCH` (served by the content scan
// while a txn is open) and for the single flush at commit. Just record
// that this table needs flushing when the transaction finalizes. An
// INSERT keeps any existing rebuild flag (a prior delete/update in the
// same transaction is what forces the rebuild) but never sets it.
self.fts5_txn_dirty
.entry(String::from(table))
.or_insert(false);
return Ok(());
}
let _ = (module_name, table);
Ok(())
}
/// Flush the self-content `fts5` tables dirtied inside the current transaction
/// to their segment index, matching SQLite's flush of the accumulated
/// in-memory postings. Called at two kinds of boundary:
///
/// * `is_final = true` — COMMIT / outermost-RELEASE (SQLite's `xSync`). Every
/// dirty table is flushed and the pending set cleared. Insert-only tables
/// append one level-0 segment (byte-identical to sqlite); tables flagged for
/// rebuild (a delete/update touched them) are rebuilt once from live
/// `<name>_content` — a single consolidated rebuild rather than one per
/// statement (correct + integrity-clean, though not byte-identical to
/// sqlite's incremental tombstone segments).
/// * `is_final = false` — just before a nested `SAVEPOINT` opens (SQLite's
/// `xSavepoint`, which flushes the hash so a later `ROLLBACK TO` can discard
/// cleanly). Only insert-only tables are flushed incrementally here (so each
/// pre-savepoint batch becomes its own segment, matching sqlite); the pending
/// set is kept — rebuild-flagged tables and any spanning append are deferred
/// to the final flush. The appended segment lands outside the new savepoint,
/// so `ROLLBACK TO` leaves it intact exactly as sqlite does.
///
/// A no-op when nothing is dirty (the common non-fts5 commit) or the feature is
/// off. Runs *before* the pager commit so the segment is part of the same
/// durable transaction.
#[cfg(feature = "fts5")]
fn fts5_flush_txn(&mut self, is_final: bool) -> Result<()> {
if self.fts5_txn_dirty.is_empty() {
return Ok(());
}
let tables: Vec<(String, bool)> = self
.fts5_txn_dirty
.iter()
.map(|(t, r)| (t.clone(), *r))
.collect();
for (table, needs_rebuild) in tables {
// The table may have been dropped within the transaction; skip if it
// is no longer a self-content fts5 vtab.
let Ok((module, args, _)) = self.vtab_meta(&table) else {
continue;
};
if !module.eq_ignore_ascii_case("fts5") {
continue;
}
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if crate::vtab::fts5_no_local_content(&arg_refs) {
continue;
}
// SAVEPOINT-involved (written under an open savepoint, or reached at a
// savepoint-boundary flush, `!is_final`): mirror SQLite's `xSavepoint`,
// which flushes the pending postings to disk as a level-0 segment at each
// savepoint open (before the pager savepoint, so `ROLLBACK TO` reverts
// only later segments) and again at `xSync`. Replay the CURRENT pending
// op-log (the ops since the last flush) into its batches, write each as a
// segment, then CLEAR the op-log so the next boundary/commit flush only
// emits the new ops. The `dirty` rebuild flag is irrelevant here — the
// batch flush carries inserts AND tombstones uniformly — so it is not
// consulted; `fts5_flush_batch` and the tombstone-preserving merge honor
// the `prefixes` list, so prefix tables work too.
let sp_mode = !is_final || self.fts5_txn_sp_used.contains(&table);
if sp_mode {
if !is_final {
self.fts5_txn_sp_used.insert(table.clone());
}
if self.fts5_txn_sp_bail.contains(&table) {
// An earlier flush declined; the whole index is rebuilt once at
// commit from the live corpus.
if is_final {
self.fts5_rebuild_index(&table)?;
}
continue;
}
let ops = self.fts5_txn_ops.get(&table).cloned().unwrap_or_default();
if !ops.is_empty() {
let batches = Self::fts5_txn_simulate_batches(&ops);
if self.fts5_flush_txn_batches(&table, &batches)? {
// Consumed — start the next flush's op-log fresh.
self.fts5_txn_ops.insert(table.clone(), Vec::new());
} else {
// Decline: defer to a single consolidated rebuild at commit.
// The partial batches already written are discarded by the
// rebuild (which wipes the shadow tables first).
self.fts5_txn_sp_bail.insert(table.clone());
if is_final {
self.fts5_rebuild_index(&table)?;
}
}
}
continue;
}
// Plain transaction (no savepoint; main OR prefix index): replay the
// ordered op log through sqlite's flush-boundary logic and emit one
// level-0 segment per batch. Prefix tables take the same path —
// `fts5_flush_batch` and the tombstone-preserving merge both honor the
// `prefixes` list.
let ops = self.fts5_txn_ops.get(&table).cloned().unwrap_or_default();
let batches = Self::fts5_txn_simulate_batches(&ops);
if batches.len() <= 1 && !needs_rebuild {
// A single pure-insert batch (monotonic rowids) is the common case —
// take the proven incremental-append path unchanged.
if !self.fts5_incremental_write(&table)? {
self.fts5_rebuild_index(&table)?;
}
continue;
}
// Multiple segments (a rowid regression / re-write) and/or tombstones:
// flush each batch as its own segment. On any decline, fall back to the
// consolidated rebuild (which wipes the shadow tables first, discarding
// whatever partial batches were written).
if !self.fts5_flush_txn_batches(&table, &batches)? {
self.fts5_rebuild_index(&table)?;
}
}
if is_final {
self.fts5_txn_dirty.clear();
self.fts5_txn_ops.clear();
self.fts5_txn_sp_used.clear();
self.fts5_txn_sp_bail.clear();
}
Ok(())
}
/// Replay an fts5 transaction's ordered write log through SQLite's
/// `sqlite3Fts5IndexBeginWrite` flush-boundary logic, partitioning it into the
/// batches that each become one level-0 segment. A new batch begins whenever the
/// next write's rowid REGRESSES below the current write rowid, or re-writes the
/// current rowid after a non-delete (SQLite flushes the in-memory hash before
/// recording such a write). The 1 MiB hash-overflow trigger
/// (`p->pConfig->nHashSize`) is not modeled — a single transaction that large is
/// out of scope for these boundary cases and would still be correct (it merely
/// consolidates into fewer segments; verified shapes stay well under it).
///
/// Each `Update` op is expanded to SQLite's delete-then-insert pair for the same
/// rowid (which never flushes between the two), so a delete followed by a
/// re-insert of the same rowid collapses into one `old + new` batch entry.
#[cfg(feature = "fts5")]
fn fts5_txn_simulate_batches(ops: &[Fts5TxnOp]) -> Vec<Vec<Fts5BatchEntry>> {
// Flatten to SQLite's per-`BeginWrite` events: (rowid, is_delete, values).
struct Ev {
rowid: i64,
del: bool,
values: Vec<Value>,
}
let mut events: Vec<Ev> = Vec::new();
for op in ops {
match op {
Fts5TxnOp::Insert { rowid, values } => events.push(Ev {
rowid: *rowid,
del: false,
values: values.clone(),
}),
Fts5TxnOp::Delete { rowid, old_values } => events.push(Ev {
rowid: *rowid,
del: true,
values: old_values.clone(),
}),
Fts5TxnOp::Update {
rowid,
old_values,
new_values,
} => {
events.push(Ev {
rowid: *rowid,
del: true,
values: old_values.clone(),
});
events.push(Ev {
rowid: *rowid,
del: false,
values: new_values.clone(),
});
}
}
}
// Partition into batches at each flush boundary.
let mut raw_batches: Vec<Vec<Ev>> = Vec::new();
let mut cur: Vec<Ev> = Vec::new();
let mut i_write_rowid: i64 = 0;
let mut prev_del = false;
for ev in events {
if !cur.is_empty()
&& (ev.rowid < i_write_rowid || (ev.rowid == i_write_rowid && !prev_del))
{
raw_batches.push(core::mem::take(&mut cur));
}
i_write_rowid = ev.rowid;
prev_del = ev.del;
cur.push(ev);
}
if !cur.is_empty() {
raw_batches.push(cur);
}
// Collapse each batch's events into one entry per rowid: the first delete
// sets `old_values`, an insert sets `new_values` (delete-then-insert of the
// same rowid within a batch is an update).
raw_batches
.into_iter()
.map(|batch| {
let mut order: Vec<i64> = Vec::new();
let mut map: alloc::collections::BTreeMap<i64, Fts5BatchEntry> =
alloc::collections::BTreeMap::new();
for ev in batch {
let entry = map.entry(ev.rowid).or_insert_with(|| {
order.push(ev.rowid);
Fts5BatchEntry {
rowid: ev.rowid,
old_values: None,
new_values: None,
}
});
if ev.del {
if entry.old_values.is_none() {
entry.old_values = Some(ev.values);
}
} else {
entry.new_values = Some(ev.values);
}
}
order.into_iter().map(|r| map.remove(&r).unwrap()).collect()
})
.collect()
}
/// Flush a transaction's replayed `batches` (each one level-0 segment) for the
/// non-prefix self-content fts5 table `name`. Returns `Ok(false)` if any batch
/// hits a shape the incremental writer declines (a spanning doclist, an
/// all-empty tombstone batch, an unexpected crisis cascade) — the caller then
/// rebuilds the whole index (which clears the shadow tables first, so any
/// partial batches already written are discarded).
#[cfg(feature = "fts5")]
fn fts5_flush_txn_batches(
&mut self,
name: &str,
batches: &[Vec<Fts5BatchEntry>],
) -> Result<bool> {
for (i, batch) in batches.iter().enumerate() {
let is_last = i + 1 == batches.len();
if !self.fts5_flush_batch(name, batch, is_last)? {
return Ok(false);
}
}
Ok(true)
}
/// Append ONE level-0 segment for a single flushed batch of `entries`
/// (tombstones for `old_values`, insert postings for `new_values`), then run
/// automerge + crisismerge exactly like `fts5FlushOneHash`. This generalizes
/// [`Self::fts5_incremental_delete`] to also carry pure inserts, so it
/// reproduces SQLite's per-flush segment for the delete/update and
/// out-of-order-rowid transaction shapes. Non-prefix (main index) only.
///
/// `is_last` selects when the whole-corpus AVERAGES record is (re)written — only
/// the final batch's value persists, and it must equal the post-transaction live
/// corpus, so writing it once at the end matches SQLite's committed state.
/// Returns `Ok(false)` to signal a fallback to the bulk rebuild.
#[cfg(feature = "fts5")]
fn fts5_flush_batch(
&mut self,
name: &str,
entries: &[Fts5BatchEntry],
is_last: bool,
) -> Result<bool> {
use crate::fts5_index::{self, IdxRow, Posting, SegStructure};
use alloc::collections::{BTreeMap, BTreeSet};
if entries.is_empty() {
return Ok(true);
}
let (_module, args, schema) = self.vtab_meta(name)?;
let ncols = schema.columns.len();
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
// Prefix-configured tables append a prefix-aware segment: `build_segment_block`
// derives the prefix postings/tombstones from the main terms, and the
// tombstone-preserving merge services the FULL key stream.
let prefixes = crate::vtab::fts5_prefix_lengths(&arg_refs);
let tok = crate::vtab::fts5_tok_config(&arg_refs);
// Build the appended segment's ascending term stream: tombstone every old
// document's terms, then overlay each new document's insert postings (a term
// shared by an update's old and new row keeps `del = true` with the new
// positions — SQLite's re-write size field is `content_len*2 + 1`).
let mut term_map: BTreeMap<Vec<u8>, BTreeMap<i64, Posting>> = BTreeMap::new();
let mut new_doc_sizes: Vec<(i64, Vec<u64>)> = Vec::new();
let mut deleted_rowids: BTreeSet<i64> = BTreeSet::new();
for entry in entries {
let rowid = entry.rowid;
if let Some(old_values) = &entry.old_values {
deleted_rowids.insert(rowid);
for c in 0..ncols {
let text = match old_values.get(c) {
Some(v) if !matches!(v, Value::Null) => eval::to_text(v),
_ => String::new(),
};
for tk in crate::vtab::fts5_tokenize(&text, tok) {
term_map
.entry(tk.as_bytes().to_vec())
.or_default()
.entry(rowid)
.or_insert(Posting {
rowid,
cols: alloc::vec![Vec::new(); ncols],
del: true,
});
}
}
}
if let Some(new_values) = &entry.new_values {
let mut sizes = alloc::vec![0u64; ncols];
let mut per_term: BTreeMap<Vec<u8>, Vec<Vec<u32>>> = BTreeMap::new();
for (c, size) in sizes.iter_mut().enumerate() {
let text = match new_values.get(c) {
Some(v) if !matches!(v, Value::Null) => eval::to_text(v),
_ => String::new(),
};
let toks = crate::vtab::fts5_tokenize(&text, tok);
*size = toks.len() as u64;
for (pos, tk) in toks.iter().enumerate() {
per_term
.entry(tk.as_bytes().to_vec())
.or_insert_with(|| alloc::vec![Vec::new(); ncols])[c]
.push(pos as u32);
}
}
for (key, cols) in per_term {
let by_rowid = term_map.entry(key).or_default();
match by_rowid.get_mut(&rowid) {
Some(existing) => existing.cols = cols,
None => {
by_rowid.insert(
rowid,
Posting {
rowid,
cols,
del: false,
},
);
}
}
}
new_doc_sizes.push((rowid, sizes));
}
}
let terms: Vec<(Vec<u8>, Vec<Posting>)> = term_map
.into_iter()
.map(|(term, per_doc)| (term, per_doc.into_values().collect()))
.collect();
if terms.is_empty() {
// An all-empty batch (only NULL/empty documents) would still write a
// structurally distinct segment in sqlite; fall back to stay exact.
return Ok(false);
}
// Read the current STRUCTURE record (fresh from disk — the previous batch
// persisted it), or start empty for a fresh index.
let struct_blob = self
.query(&format!(
"SELECT block FROM {} WHERE id={}",
sql::print::ident(&format!("{name}_data")),
fts5_index::STRUCTURE_ROWID
))?
.rows
.into_iter()
.next()
.and_then(|r| match r.into_iter().next() {
Some(Value::Blob(b)) => Some(b),
_ => None,
});
let mut structure = match &struct_blob {
Some(b) => match SegStructure::parse(b) {
Some(s) => s,
None => return Ok(false),
},
None => SegStructure {
cookie: 0,
write_counter: 0,
levels: Vec::new(),
},
};
let segid = structure.allocate_segid();
let block = fts5_index::build_segment_block(
&terms,
&new_doc_sizes,
4050,
segid,
&prefixes,
tok.detail,
);
if block.data.iter().any(|(id, _)| (*id & (1 << 36)) != 0) {
return Ok(false); // spanning (doclist-index) segment — out of scope
}
structure.append_level0(segid, block.n_leaves);
let q = |s: &str| sql::print::ident(s);
let pv = |vals: Vec<Value>| Params {
positional: vals,
named: Vec::new(),
};
let data_t = q(&format!("{name}_data"));
let idx_t = q(&format!("{name}_idx"));
// Persist the block, then run automerge + crisismerge as real %_data merges
// (the tombstone-preserving reader reproduces sqlite's key annihilation).
for (id, block_bytes) in &block.data {
self.execute_params(
&format!("INSERT INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![
Value::Integer(*id),
Value::Blob(block_bytes.clone())
]),
)?;
}
for IdxRow { segid, term, pgno } in &block.idx {
self.execute_params(
&format!("INSERT INTO {idx_t} VALUES(?1,?2,?3)"),
&pv(alloc::vec![
Value::Integer(*segid),
Value::Blob(term.clone()),
Value::Integer(*pgno)
]),
)?;
}
if !self.fts5_automerge(name, &mut structure, block.n_leaves, ncols, tok, &prefixes)? {
return Ok(false);
}
if !self.fts5_crisismerge(name, &mut structure, ncols, tok, &prefixes)? {
return Ok(false);
}
// Structure record (id 10).
self.execute_params(
&format!("INSERT OR REPLACE INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![
Value::Integer(fts5_index::STRUCTURE_ROWID),
Value::Blob(structure.encode())
]),
)?;
// AVERAGES (id 1): the whole live corpus. Only the last batch's value has to
// match sqlite's committed state, so write it once at the end.
if is_last {
let docs = self.fts5_load_documents(name, &schema.columns, &arg_refs)?;
let (_all_terms, col_totals, _all_sizes) = self.fts5_tokenize_docs(&docs, ncols, tok);
let avg = fts5_index::encode_averages_full(docs.len() as u64, &col_totals);
self.execute_params(
&format!("INSERT OR REPLACE INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![
Value::Integer(fts5_index::AVERAGES_ROWID),
Value::Blob(avg)
]),
)?;
}
// `_docsize`: drop each tombstoned rowid's old row, then write the new one
// for inserts/updates.
let docsize_t = q(&format!("{name}_docsize"));
for rid in &deleted_rowids {
self.execute_params(
&format!("DELETE FROM {docsize_t} WHERE id=?1"),
&pv(alloc::vec![Value::Integer(*rid)]),
)?;
}
for (rowid, sz) in fts5_index::build_docsize(&new_doc_sizes) {
self.execute_params(
&format!("INSERT INTO {docsize_t} VALUES(?1,?2)"),
&pv(alloc::vec![Value::Integer(rowid), Value::Blob(sz)]),
)?;
}
Ok(true)
}
/// Discard the pending in-transaction fts5 flush set on ROLLBACK / ROLLBACK TO
/// the outermost savepoint: nothing was written to the segment index during
/// the transaction, so there is nothing to undo, and the reverted
/// `<name>_content` (pager-managed) already reflects the rollback. A no-op
/// when the feature is off.
#[cfg(feature = "fts5")]
fn fts5_discard_txn(&mut self) {
self.fts5_txn_dirty.clear();
self.fts5_txn_ops.clear();
self.fts5_txn_sp_used.clear();
self.fts5_txn_sp_bail.clear();
}
/// `ROLLBACK TO <sp>` discards the in-memory pending postings (SQLite's fts5
/// `xRollbackTo`, which resets the pending-terms hash), while the pager reverts
/// the on-disk segments written after the savepoint AND the `<name>_content`
/// rows. The per-table op-log holds exactly the ops made since the last flush
/// (the most recent savepoint-open boundary or `BEGIN`), so clearing it discards
/// the right suffix — every op flushed at a deeper savepoint boundary was
/// written to disk after this savepoint and is reverted by the pager. The
/// `dirty`/`sp_used` bookkeeping is kept: the table stays SAVEPOINT-involved so
/// the commit flush still (re)writes its averages from the reverted corpus. A
/// no-op when the feature is off.
#[cfg(feature = "fts5")]
fn fts5_rollback_to_txn(&mut self) {
for ops in self.fts5_txn_ops.values_mut() {
ops.clear();
}
}
/// Handle an fts5 special-command INSERT whose first column is the hidden
/// table-named command column: `INSERT INTO t(t, …) VALUES('<cmd>', …)`.
/// Returns `Ok(Some(n))` when the row(s) were a recognized command (and thus
/// fully handled, no row inserted), or `Ok(None)` to fall through to a normal
/// insert (SQLite treats a non-command value in the command column as an error
/// via the usual path).
///
/// Recognized commands:
/// * `rebuild` — rebuild the index from the content source (self/external).
/// * `optimize` — no-op (graphite already writes a single compacted segment).
/// * `delete` — `('delete', <rowid>, <old col values…>)`: subtract the supplied
/// tokens' postings for `<rowid>` (contentless/external only).
/// * `delete-all` — clear the whole index (contentless/external only).
/// * `rank` — `('rank', '<rankfunc>')`: set the table's default ranking
/// function (dispatched by the `(t, rank)` column list; see
/// [`Self::fts5_rank_command`]).
///
/// `delete`/`delete-all` on a self-content table, and unknown commands, are a
/// hard error (matching SQLite's `SQL logic error` rejection).
#[cfg(feature = "fts5")]
fn fts5_special_command(
&mut self,
ins: &Insert,
rows: &[Vec<Expr>],
params: &Params,
arg_refs: &[&str],
) -> Result<Option<usize>> {
// The command is the value in the first (table-named) column of each row.
let cmd_of = |row: &[Expr]| -> Result<String> {
let ctx = EvalCtx::rowless(params).with_subqueries(self);
Ok(eval::to_text(&eval::eval(&row[0], &ctx)?))
};
// A single-column write `t(t)` is a maintenance command; `t(t, rowid, cols)`
// is a `delete`; `t(t, rank)` is a config command. Peek the first row's
// command word to decide.
let first = match rows.first() {
Some(r) if !r.is_empty() => cmd_of(r)?,
_ => return Ok(None),
};
// The `rank` configuration command: `INSERT INTO t(t, rank) VALUES('rank',
// '<rankfunc>')` sets the table's default ranking function — written through
// the table-named column plus a second `rank` column carrying the function
// string. (Other `(t, rank)` commands — the segment-tuning config words —
// fall through to the no-op arm below.)
if first == "rank" && ins.columns.len() == 2 && ins.columns[1].eq_ignore_ascii_case("rank")
{
return self.fts5_rank_command(ins, rows, params);
}
let no_local = crate::vtab::fts5_no_local_content(arg_refs);
match first.as_str() {
// Segment-tuning config values (`t(t, rank)` form) and the flush/merge
// maintenance commands have no effect on graphite's index: every write
// bulk-rebuilds a single compacted segment, so there is nothing to
// auto-merge, page-size, or flush. `integrity-check` verifies the index,
// which graphite keeps consistent by construction, so it always passes.
// SQLite accepts all of these silently, so no-op and report success.
"merge" | "flush" | "integrity-check" | "automerge" | "usermerge" | "crisismerge"
| "pgsz" | "hashsize" | "deletemerge" | "secure-delete" => Ok(Some(0)),
"rebuild" | "optimize" => {
// All rows must be maintenance commands (mixed with a row insert is
// not a valid form).
for row in rows {
if !matches!(cmd_of(row)?.as_str(), "rebuild" | "optimize") {
return Ok(None);
}
}
let has_rebuild = rows
.iter()
.any(|r| cmd_of(r).map(|c| c == "rebuild").unwrap_or(false));
if !no_local {
if has_rebuild {
self.fts5_rebuild_index(&ins.table)?;
}
} else if has_rebuild && crate::vtab::fts5_external_content(arg_refs).is_some() {
// External rebuild: clear the private posting state and re-derive
// it from the content table, so a subsequent direct write layers
// on top of the content-derived postings — matching SQLite.
// (A contentless `rebuild` is a no-op; `optimize` is always one.)
self.fts5_rebuild_index_external_to_gpost(&ins.table)?;
}
Ok(Some(0))
}
"delete-all" => {
if !no_local {
// SQLite names no table in this message.
return Err(Error::Error(
"'delete-all' may only be used with a contentless or \
external content fts5 table"
.into(),
));
}
let q = |s: &str| sql::print::ident(s);
self.execute(&format!(
"DELETE FROM {}",
q(&format!("{}_gpost", ins.table))
))?;
self.execute(&format!(
"DELETE FROM {}",
q(&format!("{}_docsize", ins.table))
))?;
self.fts5_rebuild_from_gpost(&ins.table)?;
Ok(Some(0))
}
"delete" => {
if !no_local {
// SQLite rejects `'delete'` on a self-content table with a bare
// `SQL logic error` (no descriptive text).
return Err(Error::Error("SQL logic error".into()));
}
self.fts5_apply_delete_command(ins, rows, params, arg_refs)
}
// Any other value written to the table-named command column is an
// unrecognized command — SQLite reports a bare `SQL logic error` (not a
// "no such column" over the hidden command column).
_ => Err(Error::Error("SQL logic error".into())),
}
}
/// The `rank` configuration command: `INSERT INTO t(t, rank) VALUES('rank',
/// '<rankfunc>')` stores `<rankfunc>` (e.g. `bm25(10.0)`) in the `_config`
/// shadow under key `rank`, so a later bare `rank` column / `ORDER BY rank`
/// evaluates that weighted function instead of the default `bm25()`. Matching
/// SQLite's `fts5SpecialCommand` / `sqlite3Fts5ConfigSetValue`:
///
/// * the first column value must be the literal `'rank'` (else it is not this
/// command — a plain unknown-command reject, `SQL logic error`);
/// * a `NULL` rank value is rejected (`SQL logic error`) — reset is via the
/// value `'bm25()'` (stored verbatim; empty weights ⇒ default behaviour);
/// * the value must parse as `name(args)` ([`crate::vtab::fts5_parse_rank`]),
/// else `SQL logic error`. The *function's* validity (does `name` exist,
/// right arity) is NOT checked here — SQLite stores any well-formed string
/// and only errors when a query actually evaluates `rank` (so
/// `'nosuchfunc()'` sets fine and fails at `SELECT rank`).
///
/// The value is upserted (last row wins for a multi-row command).
#[cfg(feature = "fts5")]
fn fts5_rank_command(
&mut self,
ins: &Insert,
rows: &[Vec<Expr>],
params: &Params,
) -> Result<Option<usize>> {
let mut chosen: Option<String> = None;
for row in rows {
if row.len() != 2 {
return Ok(None); // not the `(t, rank)` two-value shape
}
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let cmd = eval::eval(&row[0], &ctx)?;
// The command column must hold the literal 'rank'.
if !eval::to_text(&cmd).eq_ignore_ascii_case("rank") {
return Ok(None);
}
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let val = eval::eval(&row[1], &ctx)?;
// A NULL rank value is a hard error (SQLite: SQL logic error). Reset is
// via the string 'bm25()', not NULL.
let s = match val {
Value::Null => {
return Err(Error::Error("SQL logic error".into()));
}
v => eval::to_text(&v),
};
// Validate the shape (name(args)); the function's own validity is
// deferred to query time, matching SQLite.
if crate::vtab::fts5_parse_rank(&s).is_none() {
return Err(Error::Error("SQL logic error".into()));
}
chosen = Some(s);
}
let Some(value) = chosen else {
return Ok(None);
};
self.fts5_config_set(&ins.table, "rank", &value)?;
Ok(Some(0))
}
/// Upsert one `(k, v)` row into an fts5 table's `_config` shadow. Deletes any
/// existing row for `k` first (the shadow is `WITHOUT ROWID` keyed on `k`),
/// then inserts the new value, so a re-`rank` overwrites in place.
#[cfg(feature = "fts5")]
fn fts5_config_set(&mut self, table: &str, k: &str, v: &str) -> Result<()> {
let config = sql::print::ident(&format!("{table}_config"));
self.execute_params(
&format!("DELETE FROM {config} WHERE k = ?1"),
&Params {
positional: alloc::vec![Value::Text(k.into())],
named: Vec::new(),
},
)?;
self.execute_params(
&format!("INSERT INTO {config} VALUES(?1, ?2)"),
&Params {
positional: alloc::vec![Value::Text(k.into()), Value::Text(v.into())],
named: Vec::new(),
},
)?;
Ok(())
}
/// The configured default rank function of an fts5 table: the `_config` `rank`
/// row's value, parsed into `(function-name, weights)`. `None` when unset (or
/// unparseable — a well-formed string is guaranteed by `fts5_rank_command`, so
/// this only skips a legacy/foreign value). The weights are the numeric
/// argument list (`bm25(10.0)` ⇒ `[10.0]`, `bm25()` ⇒ `[]`), evaluated as
/// `SELECT <args>` exactly like SQLite's `fts5CursorFirst`.
#[cfg(feature = "fts5")]
fn fts5_config_rank(&self, table: &str) -> Option<(String, Vec<f64>)> {
let config = sql::print::ident(&format!("{table}_config"));
let res = self
.query(&format!("SELECT v FROM {config} WHERE k = 'rank'"))
.ok()?;
let value = match res.rows.first()?.first()? {
Value::Text(s) => s.clone(),
_ => return None,
};
let (name, args) = crate::vtab::fts5_parse_rank(&value)?;
// Evaluate the argument list into f64 weights via `SELECT <args>` — the
// same path SQLite uses to turn `zRankArgs` into rank-function arguments.
let weights = if args.trim().is_empty() {
Vec::new()
} else {
let row = self.query(&format!("SELECT {args}")).ok()?;
row.rows
.first()?
.iter()
.map(eval::to_f64)
.collect::<Vec<f64>>()
};
Some((name, weights))
}
/// Apply one or more `('delete', <rowid>, <old col values…>)` command rows to a
/// no-local-content table: subtract the supplied tokens' postings for each rowid
/// from the private posting state, then rebuild the segment index once. The
/// column layout mirrors the write column list `t(t, rowid, <fts cols…>)`.
#[cfg(feature = "fts5")]
fn fts5_apply_delete_command(
&mut self,
ins: &Insert,
rows: &[Vec<Expr>],
params: &Params,
_arg_refs: &[&str],
) -> Result<Option<usize>> {
let (_m, _args, schema) = self.vtab_meta(&ins.table)?;
let ncols = schema.columns.len();
// Resolve the write column list (after the leading command column) onto
// (rowid marker | declared fts5 column position).
// ins.columns = [table-name, then rowid/_rowid_/oid and/or fts cols…].
let col_names = &schema.columns;
let target: Vec<Option<usize>> = ins.columns[1..]
.iter()
.map(
|name| match col_names.iter().position(|c| c.eq_ignore_ascii_case(name)) {
Some(p) => Ok(Some(p)),
None if matches!(
name.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) =>
{
Ok(None)
}
None => Err(Error::Error(format!("no such column: {name}"))),
},
)
.collect::<Result<_>>()?;
for row in rows {
// row[0] is the 'delete' literal; the rest align with `target`.
if row.len() != ins.columns.len() {
return Err(Error::Error(format!(
"{} values for {} columns",
row.len(),
ins.columns.len()
)));
}
let mut values = alloc::vec![Value::Null; ncols];
let mut rowid = None;
for (j, expr) in row[1..].iter().enumerate() {
let ctx = EvalCtx::rowless(params).with_subqueries(self);
let v = eval::eval(expr, &ctx)?;
match target[j] {
Some(col) => values[col] = v,
None => rowid = Some(eval::to_i64(&v)),
}
}
// No rowid supplied ⇒ SQLite treats the missing rowid as NULL and the
// delete is a no-op that touches no postings; mirror that (rid absent).
if let Some(rid) = rowid {
self.fts5_gpost_apply(&ins.table, rid, &values, true)?;
}
}
self.fts5_rebuild_from_gpost(&ins.table)?;
Ok(Some(rows.len()))
}
/// Rebuild an external-content table's private posting state (`_gpost`) from its
/// content table: clear `_gpost`/`_docsize`, then apply each content document as
/// an insert. Used by the external `rebuild` command so later direct writes
/// compose with the content-derived postings.
#[cfg(feature = "fts5")]
fn fts5_rebuild_index_external_to_gpost(&mut self, name: &str) -> Result<()> {
let (_m, args, schema) = self.vtab_meta(name)?;
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let q = |s: &str| sql::print::ident(s);
self.execute(&format!("DELETE FROM {}", q(&format!("{name}_gpost"))))?;
self.execute(&format!("DELETE FROM {}", q(&format!("{name}_docsize"))))?;
let docs = self.fts5_load_documents(name, &schema.columns, &arg_refs)?;
for (rowid, values) in docs {
self.fts5_gpost_apply(name, rowid, &values, false)?;
}
self.fts5_rebuild_from_gpost(name)
}
/// Create an FTS5 table's storage: SQLite's shadow tables
/// (`_content`/`_docsize`/`_config`/`_idx`/`_data`) instead of graphite's
/// generic `<name>_data` store, so a graphite-written FTS5 table is readable
/// (and `MATCH`-able) by stock sqlite. `_content` holds the documents (same
/// `(id, c0, c1, …)` shape graphite already reads); the inverted index in
/// `_data`/`_idx` is rebuilt from `_content` on every write.
///
/// An **external-content** table (`external = true`) stores no document copy,
/// so its `_content` shadow is omitted — exactly matching sqlite's layout (four
/// shadow tables: `_config`/`_docsize`/`_idx`/`_data`).
///
/// A **contentless** table (`content=''`) keeps no document copy either, but
/// unlike external content it has no source table to rebuild from: its index is
/// maintained by direct-DML posting deltas kept in the graphite-private
/// `<name>_gpost` shadow (see `fts5_gpost_apply` / `fts5_rebuild_from_gpost`).
/// External-content tables also carry `<name>_gpost` so direct writes index the
/// *supplied* text (not the content table's), matching SQLite's trigger contract.
#[cfg(feature = "fts5")]
fn fts5_create_storage(&mut self, name: &str, ncols: usize, no_local: bool) -> Result<()> {
let content_cols: Vec<String> = (0..ncols).map(|c| format!("c{c}")).collect();
let q = |s: &str| sql::print::ident(s);
let content_def = (!no_local).then(|| {
(
format!("{name}_content"),
format!("id INTEGER PRIMARY KEY, {}", content_cols.join(", ")),
"",
)
});
// The private posting-state shadow for a no-local-content table: one row per
// (rowid, column, term) with the term's positions (a varint list). The
// segment index (`_data`/`_idx`) is rebuilt from these rows after every
// direct write — this is how graphite reproduces SQLite's incremental
// (per-(rowid,term) last-write-wins, union across inserts, subtract-on-delete)
// contentless/external write semantics with its single-segment bulk writer.
// A normal rowid table (NOT `WITHOUT ROWID`, so `scan_table` can read it via
// the table btree) with a UNIQUE index over (rid, col, term) so
// `INSERT OR REPLACE` overwrites a term's positions in place.
let gpost_def = no_local.then(|| {
(
format!("{name}_gpost"),
"rid, col, term BLOB, pos BLOB, UNIQUE(rid, col, term)".to_string(),
"",
)
});
let defs = [
(
format!("{name}_docsize"),
"id INTEGER PRIMARY KEY, sz BLOB".to_string(),
"",
),
(
format!("{name}_config"),
"k PRIMARY KEY, v".to_string(),
" WITHOUT ROWID",
),
(
format!("{name}_idx"),
"segid, term, pgno, PRIMARY KEY(segid, term)".to_string(),
" WITHOUT ROWID",
),
(
format!("{name}_data"),
"id INTEGER PRIMARY KEY, block BLOB".to_string(),
"",
),
];
// SQLite's fts5 names its shadow tables with a *single*-quoted string
// (its `CREATE TABLE '%q_data'(…)` idiom), not the double-quoted identifier
// form. Match that in the stored schema so `sqlite_master.sql` is
// byte-identical (graphite's parser accepts a quoted-string object name).
let qs = |s: &str| format!("'{}'", s.replace('\'', "''"));
for (tname, cols, tail) in content_def
.iter()
.chain(gpost_def.iter())
.chain(defs.iter())
{
let sql = format!("CREATE TABLE {}({cols}){tail}", qs(tname));
let Statement::CreateTable(ct) = sql::parse_one(&sql)? else {
unreachable!("constructed a CREATE TABLE")
};
self.exec_create_table(&ct, &sql)?;
}
// The configuration version row, then the empty segment index. The vtab's
// own schema row is not inserted yet, so write the initial `_data` rows
// directly (the index is rebuilt from `_content` on the first write).
self.execute_params(
&format!(
"INSERT INTO {} VALUES('version', 4)",
q(&format!("{name}_config"))
),
&Params::default(),
)?;
let seg = crate::fts5_index::build_segment(
&[],
0,
&alloc::vec![0u64; ncols],
&[],
4050,
0,
crate::fts5_index::Fts5Detail::Full,
);
let data_t = q(&format!("{name}_data"));
for (id, block) in &seg.data {
self.execute_params(
&format!("INSERT INTO {data_t} VALUES(?1,?2)"),
&Params {
positional: alloc::vec![Value::Integer(*id), Value::Blob(block.clone())],
named: Vec::new(),
},
)?;
}
Ok(())
}
/// Load the documents of an fts5 table as `(rowid, [fts5 col values…])`, the
/// fts5 column values in declared order (no leading id).
///
/// For an **external-content** table (`content='<tbl>'`), this scans the named
/// content table and, for each row, projects the fts5 columns by NAME and reads
/// the fts5 rowid from the `content_rowid` column. An fts5 column absent from the
/// content table is a hard error (`no such column: T.<col>`), and a missing
/// content table is `no such table: main.<tbl>` — both matching SQLite's
/// `rebuild`. Otherwise the documents come from this table's own `<name>_content`
/// shadow (`id, c0, c1, …`), with the leading `id` dropped.
#[cfg(feature = "fts5")]
fn fts5_load_documents(
&self,
name: &str,
columns: &[String],
arg_refs: &[&str],
) -> Result<Vec<(i64, Vec<Value>)>> {
if let Some((content, rowid_col)) = crate::vtab::fts5_external_content(arg_refs) {
let cmeta = self
.table_meta(&content, None)
.map_err(|_| Error::Error(format!("no such table: main.{content}")))?;
// Map each fts5 column to the content table's column position by name.
let col_pos: Vec<usize> = columns
.iter()
.map(|c| {
cmeta
.columns
.iter()
.position(|cc| cc.name.eq_ignore_ascii_case(c))
.ok_or_else(|| Error::Error(format!("no such column: T.{c}")))
})
.collect::<Result<_>>()?;
// The content_rowid column: `rowid`/`_rowid_`/`oid` or the IPK column all
// resolve to the row's actual rowid; any other named column supplies the
// fts5 rowid from its (integer) value.
let use_rowid = matches!(
rowid_col.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
) || cmeta
.ipk
.is_some_and(|i| cmeta.columns[i].name.eq_ignore_ascii_case(&rowid_col));
let rid_pos = if use_rowid {
None
} else {
Some(
cmeta
.columns
.iter()
.position(|cc| cc.name.eq_ignore_ascii_case(&rowid_col))
.ok_or_else(|| Error::Error(format!("no such column: T.{rowid_col}")))?,
)
};
let mut docs = Vec::new();
for (rowid, values) in self.scan_table(&cmeta)? {
let rid = match rid_pos {
None => rowid,
Some(p) => eval::to_i64(&values[p]),
};
let doc: Vec<Value> = col_pos.iter().map(|&p| values[p].clone()).collect();
docs.push((rid, doc));
}
return Ok(docs);
}
// Self-content: the `<name>_content` shadow holds `(id, c0, c1, …)`.
let cmeta = self.table_meta(&format!("{name}_content"), None)?;
let docs = self
.scan_table(&cmeta)?
.into_iter()
.map(|(rowid, mut values)| {
if !values.is_empty() {
values.remove(0);
}
(rowid, values)
})
.collect();
Ok(docs)
}
/// Rebuild an FTS5 table's `%_data`/`%_idx`/`%_docsize` from the documents in
/// `<name>_content` (a bulk rebuild, like the R-Tree). Tokenizes each column
/// with the table's tokenizer and writes a byte-compatible segment index.
#[cfg(feature = "fts5")]
/// Try to service a self-content fts5 write INCREMENTALLY — appending a fresh
/// level-0 segment for this transaction's new documents (and crisis-merging
/// when a level reaches 16 segments), byte-identical to sqlite's
/// `fts5FlushOneHash` path — instead of the single-segment bulk rebuild.
///
/// Returns `Ok(true)` when it fully handled the write, or `Ok(false)` to fall
/// back to [`fts5_rebuild_index`]. It only takes the incremental path for the
/// PURE-INSERT case (new rowids added, none removed or edited): the delta is
/// exactly the content rows not yet present in `_docsize`. Deletes/updates
/// (which sqlite services with tombstones — not yet ported) fall back, as does
/// a prefix-indexed table with a spanning (dlidx) segment or any structurally
/// surprising state, so the result is never wrong — at worst it is today's
/// single compacted segment.
#[cfg(feature = "fts5")]
fn fts5_incremental_write(&mut self, name: &str) -> Result<bool> {
use crate::fts5_index::{self, IdxRow, SegStructure};
let (_module, args, schema) = self.vtab_meta(name)?;
let ncols = schema.columns.len();
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
// Prefix indexes append a prefix-aware level-0 segment per write just like
// the main index: `build_segment_block` and the crisis merge both take the
// `prefixes` list, and the read path already unions the prefix doclists
// across segments. Verified byte-identical to sqlite (single, multi-segment,
// and crisis-merge shapes). A *spanning* (dlidx) segment is still guarded
// below, so an over-long prefix doclist falls back rather than mis-writing.
let prefixes = crate::vtab::fts5_prefix_lengths(&arg_refs);
let tok = crate::vtab::fts5_tok_config(&arg_refs);
// Current live documents (content) and the set already in the index
// (`_docsize` has one row per indexed doc).
let docs = self.fts5_load_documents(name, &schema.columns, &arg_refs)?;
let live: alloc::collections::BTreeSet<i64> = docs.iter().map(|(r, _)| *r).collect();
let indexed: alloc::collections::BTreeSet<i64> = self
.query(&format!(
"SELECT id FROM {}",
sql::print::ident(&format!("{name}_docsize"))
))?
.rows
.iter()
.filter_map(|r| r.first().map(eval::to_i64))
.collect();
// Any indexed doc that is no longer live is a DELETE or an UPDATE
// (update = delete+insert of the same rowid). Both need tombstone
// semantics — fall back to the bulk rebuild.
if indexed.iter().any(|id| !live.contains(id)) {
return Ok(false);
}
// The new documents to append (content rows not yet indexed), in rowid
// order — exactly one transaction's worth in autocommit.
let new_docs: Vec<(i64, Vec<Value>)> = docs
.iter()
.filter(|(r, _)| !indexed.contains(r))
.cloned()
.collect();
if new_docs.is_empty() {
// Nothing changed the index (e.g. a re-INSERT of existing rows is a
// constraint error handled elsewhere); leave the index untouched.
return Ok(true);
}
// Read the current STRUCTURE record and running averages, or start empty.
let struct_blob = self
.query(&format!(
"SELECT block FROM {} WHERE id={}",
sql::print::ident(&format!("{name}_data")),
fts5_index::STRUCTURE_ROWID
))?
.rows
.into_iter()
.next()
.and_then(|r| match r.into_iter().next() {
Some(Value::Blob(b)) => Some(b),
_ => None,
});
let mut structure = match &struct_blob {
Some(b) => match SegStructure::parse(b) {
Some(s) => s,
None => return Ok(false), // unrecognized record → safe rebuild
},
None => SegStructure {
cookie: 0,
write_counter: 0,
levels: Vec::new(),
},
};
// Build the appended segment for JUST the new docs, with a fresh segid.
let segid = structure.allocate_segid();
let (terms, _new_totals, new_doc_sizes) = self.fts5_tokenize_docs(&new_docs, ncols, tok);
let block = fts5_index::build_segment_block(
&terms,
&new_doc_sizes,
4050,
segid,
&prefixes,
tok.detail,
);
// A segment with a doclist-index (spanning) page — `%_data` rowid with the
// dlidx bit (1<<36) set — falls back to the bulk rebuild. A probe showed the
// append itself is byte-identical to sqlite for the simple two-segment span,
// but the shape needs ~8000+ docs in one transaction to arise and the crisis
// interaction is costly to verify exhaustively; kept as the correct fallback.
if block.data.iter().any(|(id, _)| (*id & (1 << 36)) != 0) {
return Ok(false);
}
structure.append_level0(segid, block.n_leaves);
let q = |s: &str| sql::print::ident(s);
let pv = |vals: Vec<Value>| Params {
positional: vals,
named: Vec::new(),
};
let data_t = q(&format!("{name}_data"));
let idx_t = q(&format!("{name}_idx"));
// Both main and prefix tables take the faithful incremental-merge path:
// persist the level-0 block, then run automerge + crisismerge as real
// `%_data` merges (byte-identical to sqlite). For prefix tables the merge
// reads/rewrites the FULL keys (main `'0'` + prefix `'1'`/`'2'`… streams
// together) via `merge_segments_keepdel_full` / `build_merged_segment_block_full`,
// so a level-0 crisis merges only that level's segments into a NEW segment at
// the next level — keeping earlier merged segments intact — exactly like a
// double crisis cascade in sqlite (which produces two level-1 segments, not
// one collapsed rebuild).
//
// Persist the level-0 block so the merges read it uniformly from %_data.
for (id, block_bytes) in &block.data {
self.execute_params(
&format!("INSERT INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![
Value::Integer(*id),
Value::Blob(block_bytes.clone())
]),
)?;
}
for IdxRow { segid, term, pgno } in &block.idx {
self.execute_params(
&format!("INSERT INTO {idx_t} VALUES(?1,?2,?3)"),
&pv(alloc::vec![
Value::Integer(*segid),
Value::Blob(term.clone()),
Value::Integer(*pgno)
]),
)?;
}
if !self.fts5_automerge(name, &mut structure, block.n_leaves, ncols, tok, &prefixes)? {
return Ok(false);
}
if !self.fts5_crisismerge(name, &mut structure, ncols, tok, &prefixes)? {
return Ok(false);
}
// Global averages: nRow + per-column token totals over the WHOLE live
// corpus (sqlite keeps this running; recompute from all live docs).
let (_all_terms, col_totals, _all_sizes) = self.fts5_tokenize_docs(&docs, ncols, tok);
// Averages (id 1): nRow + per-column token totals over the whole corpus.
let avg = fts5_index::encode_averages(docs.len() as u64, &col_totals);
self.execute_params(
&format!("INSERT OR REPLACE INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![
Value::Integer(fts5_index::AVERAGES_ROWID),
Value::Blob(avg)
]),
)?;
// Structure record (id 10).
self.execute_params(
&format!("INSERT OR REPLACE INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![
Value::Integer(fts5_index::STRUCTURE_ROWID),
Value::Blob(structure.encode())
]),
)?;
// The level-0 block's `%_data`/`%_idx` rows were already persisted above (the
// merges read them back uniformly); a crisis/automerge may since have rewritten
// them into a merged segment, but nothing to append here.
// `_docsize` gains one row per new document (merges leave docsize rows
// untouched — they are per-doc, not per-segment).
let docsize_t = q(&format!("{name}_docsize"));
for (rowid, sz) in &block.docsize {
self.execute_params(
&format!("INSERT INTO {docsize_t} VALUES(?1,?2)"),
&pv(alloc::vec![Value::Integer(*rowid), Value::Blob(sz.clone())]),
)?;
}
Ok(true)
}
/// Read a segment's leaf `%_data` blobs (`pgno_first..=pgno_last`) in page
/// order. Used by the incremental-merge machinery to reconstruct a level's
/// input segments. `None` if any leaf row is missing (a corrupt/unexpected
/// shape → the caller bails to the bulk rebuild).
#[cfg(feature = "fts5")]
fn fts5_read_segment_leaves(
&mut self,
name: &str,
seg: &crate::fts5_index::StructSeg,
) -> Result<Option<Vec<Vec<u8>>>> {
use crate::fts5_index;
let mut out: Vec<Vec<u8>> = Vec::new();
for pgno in seg.pgno_first..=seg.pgno_last {
let rid = fts5_index::segment_leaf_rowid(seg.segid, pgno);
let blob = self
.query(&format!(
"SELECT block FROM {} WHERE id={}",
sql::print::ident(&format!("{name}_data")),
rid
))?
.rows
.into_iter()
.next()
.and_then(|r| match r.into_iter().next() {
Some(Value::Blob(b)) => Some(b),
_ => None,
});
match blob {
Some(b) => out.push(b),
None => return Ok(None),
}
}
Ok(Some(out))
}
/// Port of `fts5IndexMergeLevel` for the ATOMIC (non-partial) case: merge ALL
/// segments of input level `i_lvl` into ONE fresh-segid segment at `i_lvl+1`,
/// in term+rowid order, newest-segment-wins, with sqlite's key-annihilation.
/// Rewrites the affected `%_data`/`%_idx` rows and mutates `structure`.
///
/// Returns `Ok(Some(n_leaves))` (leaves written to the merged segment) on
/// success, or `Ok(None)` to bail the whole incremental write to the bulk
/// rebuild (an unservable segment shape, or a merge whose output would exceed
/// the incremental page budget `n_rem` — the partial-merge case this atomic
/// port does not reproduce). `n_rem` is the remaining page budget; a merge is
/// only performed if the input level's total leaf count fits within it.
#[cfg(feature = "fts5")]
#[allow(clippy::too_many_arguments)]
fn fts5_merge_level(
&mut self,
name: &str,
structure: &mut crate::fts5_index::SegStructure,
i_lvl: usize,
n_rem: i64,
ncols: usize,
tok: crate::vtab::Fts5Tok,
prefixes: &[usize],
) -> Result<Option<i64>> {
use crate::fts5_index::{self, IdxRow, StructLevel, StructSeg};
// Allocate the output segid over the CURRENT structure (before removing
// the input segments) — matches sqlite's fts5AllocateSegid ordering.
let out_segid = structure.allocate_segid();
// Ensure the output level exists.
if i_lvl + 1 >= structure.levels.len() {
structure.levels.push(StructLevel {
n_merge: 0,
segs: Vec::new(),
});
}
// bOldest: the (about-to-be-added) output segment is the ONLY segment on
// the LAST level. sqlite tests `pLvlOut->nSeg==1 && nLevel==iLvl+2` AFTER
// adding the empty output segment — equivalently the output level is
// currently empty and is the last level.
let b_oldest =
structure.levels[i_lvl + 1].segs.is_empty() && structure.levels.len() == i_lvl + 2;
// Read the input segments' leaves, OLDEST first (structure/aSeg order).
let input_segs: Vec<StructSeg> = structure.levels[i_lvl].segs.clone();
// Refuse a merge whose input would overflow the page budget: that is the
// incremental/partial-merge case (nMerge>0), which this atomic port does
// not reproduce byte-for-byte. Falling back keeps the index correct.
let input_leaves: i64 = input_segs.iter().map(|s| s.size()).sum();
if input_leaves > n_rem {
return Ok(None);
}
let mut owned: Vec<Vec<Vec<u8>>> = Vec::with_capacity(input_segs.len());
for seg in &input_segs {
match self.fts5_read_segment_leaves(name, seg)? {
Some(leaves) => owned.push(leaves),
None => return Ok(None),
}
}
let seg_leaves: Vec<Vec<&[u8]>> = owned
.iter()
.map(|s| s.iter().map(|l| l.as_slice()).collect())
.collect();
// Prefix-configured tables keep the main `'0'` and prefix `'1'`/`'2'`… term
// streams in ONE segment, so they read/merge/rewrite the FULL keys together;
// the main index uses the `'0'`-stripped reader/writer.
let block = if prefixes.is_empty() {
let terms = match fts5_index::merge_segments_keepdel(&seg_leaves, b_oldest, tok.detail)
{
Some(t) => t,
None => return Ok(None), // unservable (dlidx/interior) → rebuild
};
fts5_index::build_merged_segment_block(&terms, 4050, out_segid, tok.detail)
} else {
let terms =
match fts5_index::merge_segments_keepdel_full(&seg_leaves, b_oldest, tok.detail) {
Some(t) => t,
None => return Ok(None), // unservable (dlidx/interior) → rebuild
};
fts5_index::build_merged_segment_block_full(&terms, 4050, out_segid, tok.detail)
};
let _ = ncols;
let q = |s: &str| sql::print::ident(s);
let pv = |vals: Vec<Value>| Params {
positional: vals,
named: Vec::new(),
};
let data_t = q(&format!("{name}_data"));
let idx_t = q(&format!("{name}_idx"));
// Remove every input segment's `%_data` rows (leaf + any dlidx pages, i.e.
// the whole segid<<37 range) and its `%_idx` rows.
for seg in &input_segs {
let lo = fts5_index::segment_leaf_rowid(seg.segid, 0);
let hi = fts5_index::segment_leaf_rowid(seg.segid + 1, 0);
self.execute(&format!("DELETE FROM {data_t} WHERE id>={lo} AND id<{hi}"))?;
self.execute(&format!("DELETE FROM {idx_t} WHERE segid={}", seg.segid))?;
}
// Write the merged segment's rows.
for (id, block_bytes) in &block.data {
self.execute_params(
&format!("INSERT INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![
Value::Integer(*id),
Value::Blob(block_bytes.clone())
]),
)?;
}
for IdxRow { segid, term, pgno } in &block.idx {
self.execute_params(
&format!("INSERT INTO {idx_t} VALUES(?1,?2,?3)"),
&pv(alloc::vec![
Value::Integer(*segid),
Value::Blob(term.clone()),
Value::Integer(*pgno)
]),
)?;
}
// Update the structure: clear the input level, add the merged segment to
// the output level (unless it is empty — an all-annihilated merge yields
// pgnoLast==0, which sqlite drops).
structure.levels[i_lvl].segs.clear();
structure.levels[i_lvl].n_merge = 0;
if block.n_leaves > 0 {
structure.levels[i_lvl + 1].segs.push(StructSeg {
segid: out_segid,
pgno_first: 1,
pgno_last: block.n_leaves,
});
}
Ok(Some(block.n_leaves))
}
/// Port of `fts5IndexMerge`: perform up to `n_rem` pages of merge work,
/// repeatedly merging the level with the most segments (>= `n_min`) into the
/// next level and promoting, until no level qualifies or the budget is spent.
/// For content (non-`contentless_delete`) tables `fts5IndexFindDeleteMerge`
/// always returns -1, so the sole merge trigger is the segment count.
///
/// Returns `Ok(false)` to bail the whole incremental write to the bulk rebuild.
#[cfg(feature = "fts5")]
#[allow(clippy::too_many_arguments)]
fn fts5_index_merge(
&mut self,
name: &str,
structure: &mut crate::fts5_index::SegStructure,
mut n_rem: i64,
n_min: i64,
ncols: usize,
tok: crate::vtab::Fts5Tok,
prefixes: &[usize],
) -> Result<bool> {
while n_rem > 0 {
// Select the input level: the one already merging (nMerge>0, taken
// first), else the level with the most segments.
let mut i_best: isize = 0;
let mut n_best: i64 = 0;
for i_lvl in 0..structure.levels.len() {
let lvl = &structure.levels[i_lvl];
if lvl.n_merge != 0 {
if lvl.n_merge > n_best {
i_best = i_lvl as isize;
n_best = n_min;
}
break;
}
if (lvl.segs.len() as i64) > n_best {
n_best = lvl.segs.len() as i64;
i_best = i_lvl as isize;
}
}
// fts5IndexFindDeleteMerge is a no-op for content tables → -1.
if n_best < n_min {
break;
}
if i_best < 0 {
break;
}
let written = match self.fts5_merge_level(
name,
structure,
i_best as usize,
n_rem,
ncols,
tok,
prefixes,
)? {
Some(w) => w,
None => return Ok(false),
};
n_rem -= written;
if structure.levels[i_best as usize].n_merge == 0 {
structure.promote_after_merge(i_best as usize + 1);
}
}
Ok(true)
}
/// Port of `fts5IndexAutomerge` (the `fts5FlushOneHash` tail). Called AFTER the
/// level-0 segment has been appended to `structure` (so `write_counter` already
/// includes this write's `n_leaf`) AND after that segment's `%_data`/`%_idx`
/// rows have been persisted (so [`Self::fts5_merge_level`] reads it uniformly).
/// Computes the work quanta unlocked by crossing a `FTS5_WORK_UNIT` (64-leaf)
/// boundary and runs the incremental merge. Returns `Ok(false)` to bail the
/// whole write to the bulk rebuild (a merge over an unservable/oversized shape).
#[cfg(feature = "fts5")]
fn fts5_automerge(
&mut self,
name: &str,
structure: &mut crate::fts5_index::SegStructure,
n_leaf: i64,
ncols: usize,
tok: crate::vtab::Fts5Tok,
prefixes: &[usize],
) -> Result<bool> {
const WORK_UNIT: i64 = 64;
const AUTOMERGE: i64 = 4;
// nWork = (wc/64) - ((wc - nLeaf)/64), with wc already advanced by nLeaf.
let wc = structure.write_counter as i64;
let n_work = (wc / WORK_UNIT) - ((wc - n_leaf) / WORK_UNIT);
if n_work <= 0 {
return Ok(true); // no work this write
}
let n_rem = WORK_UNIT * n_work * structure.levels.len() as i64;
self.fts5_index_merge(name, structure, n_rem, AUTOMERGE, ncols, tok, prefixes)
}
/// Port of `fts5IndexCrisismerge`: while a level holds >= `FTS5_DEFAULT_CRISISMERGE`
/// (16) segments, merge it FULLY into the next level (no page budget — sqlite
/// passes `pnRem=0`) and promote. Reads/rewrites `%_data`/`%_idx` via
/// [`Self::fts5_merge_level`]. Returns `Ok(false)` to bail to the bulk rebuild.
#[cfg(feature = "fts5")]
fn fts5_crisismerge(
&mut self,
name: &str,
structure: &mut crate::fts5_index::SegStructure,
ncols: usize,
tok: crate::vtab::Fts5Tok,
prefixes: &[usize],
) -> Result<bool> {
const CRISIS: usize = 16;
let mut i_lvl = 0;
while i_lvl < structure.levels.len() && structure.levels[i_lvl].segs.len() >= CRISIS {
// Crisis merges are unbounded (`i64::MAX` budget → never partial).
if self
.fts5_merge_level(name, structure, i_lvl, i64::MAX, ncols, tok, prefixes)?
.is_none()
{
return Ok(false);
}
structure.promote_after_merge(i_lvl + 1);
i_lvl += 1;
}
Ok(true)
}
/// Try to service a self-content fts5 DELETE (and DELETE-then-INSERT of an
/// UPDATE) INCREMENTALLY — appending ONE fresh level-0 segment that carries the
/// deleted documents' terms as DELETE markers (sqlite's tombstone: a poslist
/// written `size2 = 1`) and, for an UPDATE, the new documents' insert postings
/// merged into the same term stream — byte-identical to sqlite's
/// `fts5FlushOneHash` delete path — instead of the single-segment bulk rebuild.
///
/// `changes` is `(rowid, old_values, new_values?)`: `new_values = None` is a
/// pure delete; `Some(v)` is an UPDATE (delete the old row's terms, insert the
/// new row's terms under the same rowid). `old_values`/`new_values` are the
/// fts5 column values in declared order.
///
/// Returns `Ok(true)` when it fully handled the write, or `Ok(false)` to fall
/// back to [`fts5_rebuild_index`]. Only the autocommit, non-prefix, single-leaf
/// (non-spanning) case is taken; anything structurally surprising bails so the
/// index is never wrong — at worst it is today's single compacted segment.
#[cfg(feature = "fts5")]
fn fts5_incremental_delete(&mut self, name: &str, changes: &[Fts5Change]) -> Result<bool> {
use crate::fts5_index::{self, IdxRow, Posting, SegStructure};
use alloc::collections::{BTreeMap, BTreeSet};
if changes.is_empty() {
return Ok(true);
}
let (_module, args, schema) = self.vtab_meta(name)?;
let ncols = schema.columns.len();
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
// Prefix-configured tables append a prefix-aware tombstone/mixed segment:
// `build_segment_block` derives the prefix delete markers from the main
// terms (`merge_prefix_postings` propagates the `del` flag), and the
// tombstone-preserving merge reader/writer service the FULL key stream. A
// spanning (dlidx) segment or an unservable merge shape still falls back to
// the bulk rebuild below.
let prefixes = crate::vtab::fts5_prefix_lengths(&arg_refs);
let tok = crate::vtab::fts5_tok_config(&arg_refs);
// detail=none/column tables append a DETAIL-AWARE tombstone/mixed segment
// too: `poslist`/`build_segment_block` and the tombstone-preserving merge
// readers all take `tok.detail`, so a delete marker is encoded per mode
// (detail=none → a positionless `0x00`; detail=column → the column-marker
// poslist with the delete flag) exactly like sqlite's `fts5FlushOneHash`
// delete path. No mode is special-cased here.
// The current live corpus (post-mutation content) drives the averages and
// the docsize set; the segment we append is derived purely from `changes`.
let docs = self.fts5_load_documents(name, &schema.columns, &arg_refs)?;
// Build the merged term map for the appended segment: each deleted (old)
// document contributes DELETE markers for every term it contained, and each
// updated document also contributes INSERT postings for its new terms. A
// `(term, rowid)` pair present as BOTH (a term the old and new row share)
// collapses to the INSERT posting (sqlite writes the live entry, no
// tombstone), matching a hash flush where the last write for a docid wins.
let mut term_map: BTreeMap<Vec<u8>, BTreeMap<i64, Posting>> = BTreeMap::new();
let mut new_doc_sizes: Vec<(i64, Vec<u64>)> = Vec::new();
let mut deleted_rowids: BTreeSet<i64> = BTreeSet::new();
for (rowid, old_values, new_values) in changes {
deleted_rowids.insert(*rowid);
// DELETE markers for the OLD document's terms (positionless, del=true).
for c in 0..ncols {
let text = match old_values.get(c) {
Some(v) if !matches!(v, Value::Null) => eval::to_text(v),
_ => String::new(),
};
for tk in crate::vtab::fts5_tokenize(&text, tok) {
let key = tk.as_bytes().to_vec();
term_map
.entry(key)
.or_default()
.entry(*rowid)
.or_insert(Posting {
rowid: *rowid,
cols: alloc::vec![Vec::new(); ncols],
del: true,
});
}
}
// INSERT postings for the NEW document's terms (UPDATE only). A new
// term overrides any delete marker recorded above for the same
// `(term, rowid)`; a term the new row keeps needs its live positions.
if let Some(new_values) = new_values {
let mut sizes = alloc::vec![0u64; ncols];
// Accumulate this new doc's per-(term,col) positions first, so a
// term appearing in several columns produces one posting.
let mut per_term: BTreeMap<Vec<u8>, Vec<Vec<u32>>> = BTreeMap::new();
for (c, size) in sizes.iter_mut().enumerate() {
let text = match new_values.get(c) {
Some(v) if !matches!(v, Value::Null) => eval::to_text(v),
_ => String::new(),
};
let toks = crate::vtab::fts5_tokenize(&text, tok);
*size = toks.len() as u64;
for (pos, tk) in toks.iter().enumerate() {
per_term
.entry(tk.as_bytes().to_vec())
.or_insert_with(|| alloc::vec![Vec::new(); ncols])[c]
.push(pos as u32);
}
}
for (key, cols) in per_term {
let by_rowid = term_map.entry(key).or_default();
// If this `(term, rowid)` already has a DELETE marker (the old
// row contained the term), keep `del = true` and attach the new
// positions — sqlite's hash keeps `bDel` set for a docid that
// was deleted then re-written, so its size field is
// `content_len*2 + 1`. A term new to this rowid inserts clean.
match by_rowid.get_mut(rowid) {
Some(existing) => existing.cols = cols,
None => {
by_rowid.insert(
*rowid,
Posting {
rowid: *rowid,
cols,
del: false,
},
);
}
}
}
new_doc_sizes.push((*rowid, sizes));
}
}
// The appended segment's ascending term stream.
let terms: Vec<(Vec<u8>, Vec<Posting>)> = term_map
.into_iter()
.map(|(term, per_doc)| (term, per_doc.into_values().collect()))
.collect();
if terms.is_empty() {
// Deleting rows that contributed no tokens (all-NULL/empty docs): the
// segment would be empty, which sqlite still writes but with a
// structurally different (0-term) shape; fall back to stay exact.
return Ok(false);
}
// Read the current STRUCTURE record, or bail if unrecognized.
let struct_blob = self
.query(&format!(
"SELECT block FROM {} WHERE id={}",
sql::print::ident(&format!("{name}_data")),
fts5_index::STRUCTURE_ROWID
))?
.rows
.into_iter()
.next()
.and_then(|r| match r.into_iter().next() {
Some(Value::Blob(b)) => Some(b),
_ => None,
});
let mut structure = match &struct_blob {
Some(b) => match SegStructure::parse(b) {
Some(s) => s,
None => return Ok(false),
},
None => return Ok(false), // no index to tombstone against → rebuild
};
// Build the appended tombstone/mixed segment with a fresh segid.
let segid = structure.allocate_segid();
let block = fts5_index::build_segment_block(
&terms,
&new_doc_sizes,
4050,
segid,
&prefixes,
tok.detail,
);
// A spanning (doclist-index) segment is out of this slice; fall back so we
// never write a subtly wrong index.
if block.data.iter().any(|(id, _)| (*id & (1 << 36)) != 0) {
return Ok(false);
}
structure.append_level0(segid, block.n_leaves);
let q = |s: &str| sql::print::ident(s);
let pv = |vals: Vec<Value>| Params {
positional: vals,
named: Vec::new(),
};
let data_t = q(&format!("{name}_data"));
let idx_t = q(&format!("{name}_idx"));
// Persist the appended tombstone/mixed block, then run automerge +
// crisismerge as real `%_data` merges. The tombstone-aware merge reads every
// input posting (delete markers included) and reproduces sqlite's
// key-annihilation exactly: a tombstone is annihilated only when the output
// is the OLDEST segment (`bOldest`), otherwise it is preserved to shadow the
// un-merged higher levels — correct regardless of `bOldest`.
for (id, block_bytes) in &block.data {
self.execute_params(
&format!("INSERT INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![
Value::Integer(*id),
Value::Blob(block_bytes.clone())
]),
)?;
}
for IdxRow { segid, term, pgno } in &block.idx {
self.execute_params(
&format!("INSERT INTO {idx_t} VALUES(?1,?2,?3)"),
&pv(alloc::vec![
Value::Integer(*segid),
Value::Blob(term.clone()),
Value::Integer(*pgno)
]),
)?;
}
if !self.fts5_automerge(name, &mut structure, block.n_leaves, ncols, tok, &prefixes)? {
return Ok(false);
}
if !self.fts5_crisismerge(name, &mut structure, ncols, tok, &prefixes)? {
return Ok(false);
}
// Global averages over the WHOLE live corpus (nRow + per-column totals).
let (_all_terms, col_totals, _all_sizes) = self.fts5_tokenize_docs(&docs, ncols, tok);
// Averages (id 1). Unlike a fresh empty index (which carries an EMPTY
// averages record), a table deleted down to zero rows via tombstones keeps
// the record present as `nRow=0` followed by per-column zeros — sqlite only
// omits it before the first document is ever written. So encode it
// unconditionally here (0 docs → `00 00…`).
let avg = fts5_index::encode_averages_full(docs.len() as u64, &col_totals);
self.execute_params(
&format!("INSERT OR REPLACE INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![
Value::Integer(fts5_index::AVERAGES_ROWID),
Value::Blob(avg)
]),
)?;
// Structure record (id 10).
self.execute_params(
&format!("INSERT OR REPLACE INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![
Value::Integer(fts5_index::STRUCTURE_ROWID),
Value::Blob(structure.encode())
]),
)?;
// The appended block was already persisted before the merges (which may
// have rewritten it), so there is nothing more to write here.
// `_docsize`: delete each mutated rowid's old row, then (for UPDATEs) write
// the new one. A pure delete leaves the rowid absent.
let docsize_t = q(&format!("{name}_docsize"));
for rid in &deleted_rowids {
self.execute_params(
&format!("DELETE FROM {docsize_t} WHERE id=?1"),
&pv(alloc::vec![Value::Integer(*rid)]),
)?;
}
for (rowid, sz) in fts5_index::build_docsize(&new_doc_sizes) {
self.execute_params(
&format!("INSERT INTO {docsize_t} VALUES(?1,?2)"),
&pv(alloc::vec![Value::Integer(rowid), Value::Blob(sz)]),
)?;
}
Ok(true)
}
/// Tokenize `docs` (each `(rowid, [fts5 col values in declared order])`) into
/// the inverted-index inputs the segment builder consumes: the ascending
/// `terms` (term bytes → per-doc postings), the per-column total token counts,
/// and the per-document `(rowid, per-column token counts)`. Shared by the bulk
/// rebuild and the incremental level-0 append.
#[cfg(feature = "fts5")]
fn fts5_tokenize_docs(
&self,
docs: &[(i64, Vec<Value>)],
ncols: usize,
tok: crate::vtab::Fts5Tok,
) -> crate::fts5_index::TokenizedDocs {
use crate::fts5_index::Posting;
use alloc::collections::BTreeMap;
let mut index: BTreeMap<Vec<u8>, BTreeMap<i64, Vec<Vec<u32>>>> = BTreeMap::new();
let mut col_totals = alloc::vec![0u64; ncols];
let mut doc_sizes: Vec<(i64, Vec<u64>)> = Vec::new();
for (rowid, values) in docs {
let mut sizes = alloc::vec![0u64; ncols];
for c in 0..ncols {
let text = match values.get(c) {
Some(v) if !matches!(v, Value::Null) => eval::to_text(v),
_ => String::new(),
};
let toks = crate::vtab::fts5_tokenize(&text, tok);
sizes[c] = toks.len() as u64;
col_totals[c] += toks.len() as u64;
for (pos, tk) in toks.iter().enumerate() {
index
.entry(tk.as_bytes().to_vec())
.or_default()
.entry(*rowid)
.or_insert_with(|| alloc::vec![Vec::new(); ncols])[c]
.push(pos as u32);
}
}
doc_sizes.push((*rowid, sizes));
}
let terms: Vec<(Vec<u8>, Vec<Posting>)> = index
.into_iter()
.map(|(term, per_doc)| {
let postings = per_doc
.into_iter()
.map(|(rowid, cols)| Posting {
rowid,
cols,
del: false,
})
.collect();
(term, postings)
})
.collect();
(terms, col_totals, doc_sizes)
}
#[cfg(feature = "fts5")]
fn fts5_rebuild_index(&mut self, name: &str) -> Result<()> {
use crate::fts5_index::{self, IdxRow};
let (_module, args, schema) = self.vtab_meta(name)?;
let ncols = schema.columns.len();
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let tok = crate::vtab::fts5_tok_config(&arg_refs);
// `docs`: each document as `(rowid, [fts5 col values…])` — the fts5 column
// values in declared order (NO leading id). For an external-content table
// this reads from the named content table, keyed by its `content_rowid`;
// otherwise from this table's own `<name>_content` shadow.
let docs = self.fts5_load_documents(name, &schema.columns, &arg_refs)?;
let (terms, col_totals, doc_sizes) = self.fts5_tokenize_docs(&docs, ncols, tok);
let prefixes = crate::vtab::fts5_prefix_lengths(&arg_refs);
let seg = fts5_index::build_segment_prefixed(
&terms,
docs.len() as u64,
&col_totals,
&doc_sizes,
4050,
0,
&prefixes,
tok.detail,
);
let q = |s: &str| sql::print::ident(s);
let pv = |vals: Vec<Value>| Params {
positional: vals,
named: Vec::new(),
};
self.execute(&format!("DELETE FROM {}", q(&format!("{name}_data"))))?;
self.execute(&format!("DELETE FROM {}", q(&format!("{name}_idx"))))?;
self.execute(&format!("DELETE FROM {}", q(&format!("{name}_docsize"))))?;
let data_t = q(&format!("{name}_data"));
for (id, block) in &seg.data {
self.execute_params(
&format!("INSERT INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![Value::Integer(*id), Value::Blob(block.clone())]),
)?;
}
let idx_t = q(&format!("{name}_idx"));
for IdxRow { segid, term, pgno } in &seg.idx {
self.execute_params(
&format!("INSERT INTO {idx_t} VALUES(?1,?2,?3)"),
&pv(alloc::vec![
Value::Integer(*segid),
Value::Blob(term.clone()),
Value::Integer(*pgno)
]),
)?;
}
let docsize_t = q(&format!("{name}_docsize"));
for (rowid, sz) in &seg.docsize {
self.execute_params(
&format!("INSERT INTO {docsize_t} VALUES(?1,?2)"),
&pv(alloc::vec![Value::Integer(*rowid), Value::Blob(sz.clone())]),
)?;
}
Ok(())
}
/// Apply a direct-DML posting delta to a no-local-content (contentless or
/// external) fts5 table's private `<name>_gpost` state, then rebuild its
/// segment index from the updated postings. This reproduces SQLite's
/// incremental contentless/external write semantics:
///
/// * **INSERT** (`op = Insert`): tokenize each supplied column; for every term
/// that occurs, `INSERT OR REPLACE` its `(rid, col, term) → positions` row.
/// A term already present for that `(rid, col)` from an earlier write is
/// overwritten (last-write-wins per term); terms not in this write are left
/// untouched (union across writes). The `_docsize` row is set to this
/// write's per-column token counts (SQLite stores the latest insert's sizes).
/// * **DELETE** (`op = Delete`): tokenize each supplied column and REMOVE those
/// `(rid, col, term)` rows — subtracting exactly the supplied tokens'
/// postings for that rowid (SQLite trusts the caller-supplied old text; a
/// wrong term subtracts the wrong posting, matching SQLite). The `_docsize`
/// row for the rowid is removed.
///
/// `values` are the fts5 column values in declared order (`ncols` long).
#[cfg(feature = "fts5")]
fn fts5_gpost_apply(
&mut self,
name: &str,
rowid: i64,
values: &[Value],
delete: bool,
) -> Result<()> {
let (_m, args, schema) = self.vtab_meta(name)?;
let ncols = schema.columns.len();
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let tok = crate::vtab::fts5_tok_config(&arg_refs);
let q = |s: &str| sql::print::ident(s);
let pv = |vals: Vec<Value>| Params {
positional: vals,
named: Vec::new(),
};
let gpost_t = q(&format!("{name}_gpost"));
let docsize_t = q(&format!("{name}_docsize"));
let mut sizes = alloc::vec![0u64; ncols];
for (c, size) in sizes.iter_mut().enumerate() {
let text = match values.get(c) {
Some(v) if !matches!(v, Value::Null) => eval::to_text(v),
_ => String::new(),
};
let toks = crate::vtab::fts5_tokenize(&text, tok);
*size = toks.len() as u64;
// Positions per distinct term in this column (ascending), varint-encoded.
let mut per_term: alloc::collections::BTreeMap<Vec<u8>, Vec<u32>> =
alloc::collections::BTreeMap::new();
for (pos, t) in toks.iter().enumerate() {
per_term
.entry(t.as_bytes().to_vec())
.or_default()
.push(pos as u32);
}
for (term, positions) in per_term {
if delete {
self.execute_params(
&format!("DELETE FROM {gpost_t} WHERE rid=?1 AND col=?2 AND term=?3"),
&pv(alloc::vec![
Value::Integer(rowid),
Value::Integer(c as i64),
Value::Blob(term),
]),
)?;
} else {
let mut posbuf = Vec::new();
for &p in &positions {
let mut b = [0u8; 9];
let n = crate::util::varint::encode(p as u64, &mut b);
posbuf.extend_from_slice(&b[..n]);
}
self.execute_params(
&format!("INSERT OR REPLACE INTO {gpost_t} VALUES(?1,?2,?3,?4)"),
&pv(alloc::vec![
Value::Integer(rowid),
Value::Integer(c as i64),
Value::Blob(term),
Value::Blob(posbuf),
]),
)?;
}
}
}
// Maintain `_docsize`: DELETE removes the row; INSERT sets this write's sizes.
self.execute_params(
&format!("DELETE FROM {docsize_t} WHERE id=?1"),
&pv(alloc::vec![Value::Integer(rowid)]),
)?;
if !delete {
let mut sz = Vec::new();
for &s in &sizes {
let mut b = [0u8; 9];
let n = crate::util::varint::encode(s, &mut b);
sz.extend_from_slice(&b[..n]);
}
self.execute_params(
&format!("INSERT INTO {docsize_t} VALUES(?1,?2)"),
&pv(alloc::vec![Value::Integer(rowid), Value::Blob(sz)]),
)?;
}
Ok(())
}
/// Remove every posting for `rowid` from a no-local-content table's `_gpost`
/// (and its `_docsize` row), regardless of term. Used by the `'delete-all'`
/// command (clear the whole index) via a per-rowid sweep is not needed —
/// `'delete-all'` truncates `_gpost` directly — but this is the per-row form a
/// future UPDATE-old-side could use. Currently unused beyond delete-all's bulk
/// clear, so delete-all is handled inline in `exec_vtab_insert`.
///
/// Rebuild the `_data`/`_idx`/`_docsize` segment index of a no-local-content
/// fts5 table from the accumulated `<name>_gpost` postings. Groups the
/// `(rid, col, term)` rows into the `term → postings` shape and calls the same
/// bulk `build_segment` writer used by the content rebuild, so the on-disk index
/// is byte-compatible and `sqlite3`-readable/-MATCHable.
#[cfg(feature = "fts5")]
fn fts5_rebuild_from_gpost(&mut self, name: &str) -> Result<()> {
use crate::fts5_index::{self, IdxRow, Posting};
use alloc::collections::BTreeMap;
let (_m, args, schema) = self.vtab_meta(name)?;
let ncols = schema.columns.len();
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let prefixes = crate::vtab::fts5_prefix_lengths(&arg_refs);
// term -> rowid -> per-column positions, gathered from `_gpost`.
let mut index: BTreeMap<Vec<u8>, BTreeMap<i64, Vec<Vec<u32>>>> = BTreeMap::new();
let mut col_totals = alloc::vec![0u64; ncols];
let gpost_meta = self.table_meta(&format!("{name}_gpost"), None)?;
for (_rk, row) in self.scan_table(&gpost_meta)? {
// Columns: rid, col, term, pos (WITHOUT ROWID → declared order).
let rid = eval::to_i64(&row[0]);
let col = eval::to_i64(&row[1]) as usize;
let term = match &row[2] {
Value::Blob(b) => b.clone(),
Value::Text(s) => s.as_bytes().to_vec(),
v => eval::to_text(v).into_bytes(),
};
let posbytes = match &row[3] {
Value::Blob(b) => b.clone(),
Value::Text(s) => s.as_bytes().to_vec(),
v => eval::to_text(v).into_bytes(),
};
if col >= ncols {
continue;
}
let mut positions = Vec::new();
let mut off = 0usize;
while off < posbytes.len() {
let Some((v, n)) = crate::util::varint::decode(&posbytes[off..]) else {
break;
};
positions.push(v as u32);
off += n;
}
col_totals[col] += positions.len() as u64;
let cols = index
.entry(term)
.or_default()
.entry(rid)
.or_insert_with(|| alloc::vec![Vec::new(); ncols]);
cols[col] = positions;
}
// Per-document sizes come from `_docsize` (kept current by `fts5_gpost_apply`
// / delete-all), so bm25's average-length statistics match the live state.
let docsize_meta = self.table_meta(&format!("{name}_docsize"), None)?;
let mut doc_sizes: Vec<(i64, Vec<u64>)> = Vec::new();
for (rid, row) in self.scan_table(&docsize_meta)? {
let sz = match row.get(1) {
Some(Value::Blob(b)) => b.clone(),
_ => Vec::new(),
};
let mut sizes = alloc::vec![0u64; ncols];
let mut off = 0usize;
for s in sizes.iter_mut() {
let Some((v, n)) = crate::util::varint::decode(&sz[off..]) else {
break;
};
*s = v;
off += n;
}
doc_sizes.push((rid, sizes));
}
doc_sizes.sort_by_key(|(r, _)| *r);
let n_docs = doc_sizes.len() as u64;
let terms: Vec<(Vec<u8>, Vec<Posting>)> = index
.into_iter()
.map(|(term, per_doc)| {
let postings = per_doc
.into_iter()
.map(|(rowid, cols)| Posting {
rowid,
cols,
del: false,
})
.collect();
(term, postings)
})
.collect();
let seg = fts5_index::build_segment_prefixed(
&terms,
n_docs,
&col_totals,
&doc_sizes,
4050,
0,
&prefixes,
crate::vtab::fts5_detail(&arg_refs),
);
let q = |s: &str| sql::print::ident(s);
let pv = |vals: Vec<Value>| Params {
positional: vals,
named: Vec::new(),
};
// `_data`/`_idx` are fully regenerated; `_docsize` is authoritative in the
// shadow already (do NOT clear it — it holds the live per-doc sizes).
self.execute(&format!("DELETE FROM {}", q(&format!("{name}_data"))))?;
self.execute(&format!("DELETE FROM {}", q(&format!("{name}_idx"))))?;
let data_t = q(&format!("{name}_data"));
for (id, block) in &seg.data {
self.execute_params(
&format!("INSERT INTO {data_t} VALUES(?1,?2)"),
&pv(alloc::vec![Value::Integer(*id), Value::Blob(block.clone())]),
)?;
}
let idx_t = q(&format!("{name}_idx"));
for IdxRow { segid, term, pgno } in &seg.idx {
self.execute_params(
&format!("INSERT INTO {idx_t} VALUES(?1,?2,?3)"),
&pv(alloc::vec![
Value::Integer(*segid),
Value::Blob(term.clone()),
Value::Integer(*pgno)
]),
)?;
}
Ok(())
}
fn scan_table(&self, meta: &TableMeta) -> Result<Vec<(i64, Vec<Value>)>> {
let encoding = self.backend.source().header().text_encoding;
let mut rows = Vec::new();
let mut cur = TableCursor::new(self.backend.source(), meta.root);
let mut ok = cur.first()?;
while ok {
let rowid = cur.rowid()?;
let values = self.decode_full_row(meta, rowid, &cur.payload()?, encoding)?;
rows.push((rowid, values));
ok = cur.next()?;
}
Ok(rows)
}
/// Decode a stored row into full column values: pad missing trailing columns
/// with their `DEFAULT` (or NULL), and fill the INTEGER PRIMARY KEY column
/// from the rowid. This is how `ALTER TABLE ADD COLUMN` defaults show up for
/// rows written before the column existed.
fn decode_full_row(
&self,
meta: &TableMeta,
rowid: i64,
payload: &[u8],
encoding: crate::format::TextEncoding,
) -> Result<Vec<Value>> {
let record = decode_record(payload, encoding)?;
let n = meta.columns.len();
let mut values = alloc::vec![Value::Null; n];
let p = Params::default();
// Map stored record values onto declared columns, skipping VIRTUAL
// generated columns (which occupy no record slot). A record shorter than
// the stored-column count means columns added by ALTER use their default.
let mut ri = 0usize;
for (i, def) in meta.defaults.iter().enumerate() {
// A corrupt schema can leave `defaults` longer than the declared
// column count (`values` is sized to `meta.columns`); stop rather
// than index past the row so a malformed database errors/degrades
// gracefully instead of panicking.
if i >= n {
break;
}
if meta.is_virtual(i) {
continue;
}
if ri < record.len() {
values[i] = record[ri].clone();
} else if let Some(e) = def {
values[i] = eval::eval(e, &EvalCtx::rowless(&p))?;
}
ri += 1;
}
promote_real_columns(meta, &mut values);
if let Some(ipk) = meta.ipk
&& ipk < n
{
values[ipk] = Value::Integer(rowid);
}
self.compute_generated(meta, &mut values, &p)?;
Ok(values)
}
/// Fill in the VIRTUAL generated columns of `values` (computed on read).
/// STORED generated columns are read back from the record, not recomputed.
fn compute_generated(
&self,
meta: &TableMeta,
values: &mut [Value],
params: &Params,
) -> Result<()> {
if meta.generated.iter().all(|g| g.is_none()) {
return Ok(());
}
self.eval_generated_in_order(meta, values, params, false)
}
/// Materialize all generated columns (STORED and VIRTUAL) into `values`,
/// applied on the write path so CHECK/UNIQUE/indexes see their values.
fn materialize_generated(
&self,
meta: &TableMeta,
values: &mut [Value],
params: &Params,
) -> Result<()> {
if meta.generated.iter().all(|g| g.is_none()) {
return Ok(());
}
self.eval_generated_in_order(meta, values, params, true)
}
/// Evaluate generated columns in dependency order, so a generated column may
/// reference another declared *later* in the table (SQLite resolves these
/// forward references). `recompute_stored` distinguishes the write path
/// (all generated columns) from the read path (VIRTUAL only — STORED values
/// are already materialized in `values` from the record). Cycles are rejected
/// at CREATE (see `generated_column_loop`); the busy check here is a guard.
fn eval_generated_in_order(
&self,
meta: &TableMeta,
values: &mut [Value],
params: &Params,
recompute_stored: bool,
) -> Result<()> {
let n = meta.columns.len();
// Which generated columns this pass evaluates.
let eval_set: Vec<bool> = (0..n)
.map(|i| match &meta.generated[i] {
Some((_, stored)) => recompute_stored || !*stored,
None => false,
})
.collect();
// Edges to the generated columns each expression references, in source
// order (so cycle naming matches SQLite — the column whose expression
// closes the cycle is the one reported).
let mut deps: Vec<Vec<usize>> = alloc::vec![Vec::new(); n];
for i in 0..n {
if !eval_set[i] {
continue;
}
let (expr, _) = meta.generated[i].as_ref().expect("eval_set => generated");
window::visit(expr, &mut |node| {
if let Expr::Column {
table: None,
schema: None,
column,
..
} = node
&& let Some(j) = meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column))
&& eval_set[j]
{
deps[i].push(j);
}
});
}
// Post-order DFS: 0 = unvisited, 1 = in-progress, 2 = done.
let mut state = alloc::vec![0u8; n];
for i in 0..n {
if eval_set[i] && state[i] == 0 {
self.eval_generated_dfs(i, meta, values, params, &deps, &mut state)?;
}
}
Ok(())
}
/// One node of the generated-column dependency DFS: evaluate every
/// referenced generated column first, then this one.
fn eval_generated_dfs(
&self,
i: usize,
meta: &TableMeta,
values: &mut [Value],
params: &Params,
deps: &[Vec<usize>],
state: &mut [u8],
) -> Result<()> {
state[i] = 1;
for k in 0..deps[i].len() {
let j = deps[i][k];
match state[j] {
1 => {
return Err(Error::Error(format!(
"generated column loop on \"{}\"",
meta.columns[i].name
)));
}
0 => self.eval_generated_dfs(j, meta, values, params, deps, state)?,
_ => {}
}
}
let (expr, _) = meta.generated[i].as_ref().expect("eval_set => generated");
let ctx = row_ctx(values, &meta.columns, None, params).with_subqueries(self);
let v = eval::eval(expr, &ctx)?;
values[i] = meta.columns[i].affinity.coerce(v);
state[i] = 2;
Ok(())
}
/// Encode a table record from `values`, omitting VIRTUAL generated columns
/// (not stored) and nulling the rowid-aliased `INTEGER PRIMARY KEY`.
fn encode_table_record(&self, meta: &TableMeta, values: &[Value]) -> Vec<u8> {
// A whole-number real in a REAL-affinity column stores with the compact
// integer serial type (SQLite's MEM_IntReal); `promote_real_columns` reads
// it back as REAL.
let realified = realify_columns_for_storage(meta, values);
let stored: Vec<Value> = (0..meta.columns.len())
.filter(|&i| !meta.is_virtual(i))
.map(|i| {
if Some(i) == meta.ipk {
Value::Null
} else {
realified[i].clone()
}
})
.collect();
encode_record(&stored)
}
/// Non-aggregated projection: one output row per input row.
fn eval_simple(
&self,
sel: &Select,
columns: &[ColumnInfo],
rows: Vec<InputRow>,
params: &Params,
) -> Result<(Vec<String>, Vec<OutRow>)> {
let labels = self.output_labels(sel, columns);
// An `ORDER BY` *expression* (not a bare alias/ordinal, which
// `resolve_order_index` handles) may reference a SELECT-output alias —
// `SELECT a AS x … ORDER BY x+0`. SQLite resolves the name to the
// computed output value, with a real input column of the same name
// taking precedence. Pre-build the augmented column list (base columns
// first, then the output labels) once; the per-row values are appended
// below.
let order_needs_output = sel
.order_by
.iter()
.any(|t| resolve_order_index(&t.expr, &labels, sel.columns.len()).is_none());
let aug_cols: Vec<ColumnInfo> = if order_needs_output {
let mut c = columns.to_vec();
for label in &labels {
c.push(ColumnInfo {
name: label.clone(),
table: String::new(),
affinity: eval::Affinity::Blob,
collation: crate::value::Collation::Binary,
schema: None,
hidden: false,
});
}
c
} else {
Vec::new()
};
let mut out = Vec::with_capacity(rows.len());
for r in &rows {
let ctx = r.ctx(columns, params).with_subqueries(self);
let mut values = Vec::new();
for col in &sel.columns {
project_column(col, columns, &ctx, &mut values)?;
}
// ORDER BY: resolve by position/alias against the output, else
// evaluate against the input row (allows ordering by unselected
// cols) — augmented with the output columns so an expression may
// also reference a SELECT-output alias (base columns still win).
let mut sort_keys = Vec::new();
if !sel.order_by.is_empty() {
let aug_row;
let octx;
let octx = if order_needs_output {
let mut aug_vals = r.values.clone();
aug_vals.extend(values.iter().cloned());
aug_row = InputRow {
values: aug_vals,
rowid: r.rowid,
};
octx = aug_row.ctx(&aug_cols, params).with_subqueries(self);
&octx
} else {
&ctx
};
for term in &sel.order_by {
match resolve_order_index(&term.expr, &labels, values.len()) {
Some(idx) => sort_keys.push(values[idx].clone()),
None => sort_keys.push(eval::eval(&term.expr, octx)?),
}
}
}
out.push(OutRow { values, sort_keys });
}
Ok((labels, out))
}
/// Aggregated/grouped projection.
fn eval_aggregated(
&self,
sel: &Select,
columns: &[ColumnInfo],
rows: Vec<InputRow>,
params: &Params,
) -> Result<(Vec<String>, Vec<OutRow>)> {
// Expand any `*` / `table.*` into explicit column references so the
// bare-column rule below applies to them (SQLite allows `SELECT *,
// count(*) …`, each bare column taking the representative row's value).
let expanded;
let sel = if sel
.columns
.iter()
.any(|c| matches!(c, ResultColumn::Wildcard | ResultColumn::TableWildcard(_)))
{
expanded = expand_agg_wildcards(sel, columns);
&expanded
} else {
sel
};
// Resolve a positional `GROUP BY N` (an integer literal) to the N-th
// output column's expression, matching sqlite — `GROUP BY 1` groups by
// the first result column, not by the constant 1. (Range was already
// validated upstream by `check_positional_terms`.)
let group_by: Vec<Expr> = sel
.group_by
.iter()
.map(|g| {
// A positional `GROUP BY N` — including the signed / parenthesized /
// `COLLATE`-wrapped forms SQLite folds (`GROUP BY +1`) — names the
// N-th output column.
if let Some(n) = positional_int(g)
&& let Some(ResultColumn::Expr { expr, .. }) = usize::try_from(n)
.ok()
.filter(|&n| n >= 1)
.and_then(|n| sel.columns.get(n - 1))
{
return expr.clone();
}
g.clone()
})
.collect();
// Partition rows into groups (first-seen order), comparing each grouping
// key under its column collation.
let group_colls: Vec<crate::value::Collation> = {
let cctx = row_ctx(&[], columns, None, params);
group_by
.iter()
.map(|g| eval::key_collation(g, &cctx))
.collect()
};
let mut group_keys: Vec<Vec<Value>> = Vec::new();
let mut groups: Vec<Vec<usize>> = Vec::new();
for (i, r) in rows.iter().enumerate() {
let ctx = r.ctx(columns, params).with_subqueries(self);
let mut key = Vec::new();
for g in &group_by {
key.push(eval::eval(g, &ctx)?);
}
match group_keys
.iter()
.position(|k| rows_equal_coll(k, &key, &group_colls))
{
Some(idx) => groups[idx].push(i),
None => {
group_keys.push(key);
groups.push(alloc::vec![i]);
}
}
}
// No GROUP BY but aggregates present => a single group over all rows
// (which yields one row even when there are zero input rows).
if sel.group_by.is_empty() {
groups = alloc::vec![(0..rows.len()).collect()];
} else {
// SQLite emits grouped rows ordered by the GROUP BY keys (ascending,
// under each key's collation, NULLs first) — its grouping is done via
// a sort. An explicit ORDER BY re-sorts later; with none, this is the
// order. Reorder the groups (and their keys) to match.
let mut order: Vec<usize> = (0..groups.len()).collect();
order.sort_by(|&i, &j| {
for (k, coll) in group_colls.iter().enumerate() {
let ord =
crate::value::cmp_values_coll(&group_keys[i][k], &group_keys[j][k], *coll);
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
core::cmp::Ordering::Equal
});
let mut sorted = Vec::with_capacity(groups.len());
for i in order {
sorted.push(core::mem::take(&mut groups[i]));
}
groups = sorted;
}
let labels = self.output_labels(sel, columns);
// SQLite's bare-column rule: with exactly one min()/max(), bare columns
// come from the row achieving that extreme (else the group's first row).
let minmax = single_minmax_arg(sel);
let mut out = Vec::new();
for group in &groups {
// Representative row context for bare column references.
let repr_idx = match &minmax {
Some((is_max, arg)) => {
self.argextreme_row(group, columns, &rows, arg, *is_max, params)?
}
None => group.first().copied(),
};
let repr = repr_idx.map(|i| &rows[i]);
let empty = InputRow {
values: alloc::vec![Value::Null; columns.len()],
rowid: None,
};
let repr_ctx = repr
.unwrap_or(&empty)
.ctx(columns, params)
.with_subqueries(self);
// Compute the output row, substituting aggregate calls with values.
let mut values = Vec::new();
for col in &sel.columns {
let ResultColumn::Expr { expr, .. } = col else {
unreachable!("wildcards rejected above")
};
let substituted =
self.substitute_aggregates(expr, columns, &rows, group, params)?;
values.push(eval::eval(&substituted, &repr_ctx)?);
}
// HAVING (aggregate-aware). It may reference SELECT-output aliases, so
// evaluate against a context that also exposes the output columns by
// their labels (table columns still take precedence).
if let Some(having) = &sel.having {
let h = self.substitute_aggregates(having, columns, &rows, group, params)?;
let mut aug_cols = columns.to_vec();
for label in &labels {
aug_cols.push(ColumnInfo {
name: label.clone(),
table: String::new(),
affinity: eval::Affinity::Blob,
collation: crate::value::Collation::Binary,
schema: None,
hidden: false,
});
}
let mut aug_vals = repr.unwrap_or(&empty).values.clone();
aug_vals.extend(values.iter().cloned());
let aug_row = InputRow {
values: aug_vals,
rowid: repr.and_then(|r| r.rowid),
};
let actx = aug_row.ctx(&aug_cols, params).with_subqueries(self);
if eval::truth(&eval::eval(&h, &actx)?) != Some(true) {
continue;
}
}
// Sort keys (aggregate-aware) for ORDER BY.
let mut sort_keys = Vec::new();
for term in &sel.order_by {
if let Some(idx) = resolve_order_index(&term.expr, &labels, values.len()) {
sort_keys.push(values[idx].clone());
} else {
// An ORDER BY *expression* may reference a SELECT-output
// alias (`SELECT count(*) AS c … ORDER BY c+0`); resolve it
// to the computed output value, base columns taking
// precedence, just like HAVING above.
let s =
self.substitute_aggregates(&term.expr, columns, &rows, group, params)?;
let mut aug_cols = columns.to_vec();
for label in &labels {
aug_cols.push(ColumnInfo {
name: label.clone(),
table: String::new(),
affinity: eval::Affinity::Blob,
collation: crate::value::Collation::Binary,
schema: None,
hidden: false,
});
}
let mut aug_vals = repr.unwrap_or(&empty).values.clone();
aug_vals.extend(values.iter().cloned());
let aug_row = InputRow {
values: aug_vals,
rowid: repr.and_then(|r| r.rowid),
};
let actx = aug_row.ctx(&aug_cols, params).with_subqueries(self);
sort_keys.push(eval::eval(&s, &actx)?);
}
}
out.push(OutRow { values, sort_keys });
}
Ok((labels, out))
}
/// Replace every aggregate call (an aggregate function with no `OVER`) inside
/// `e` — including ones nested in window-function arguments and in a window's
/// `PARTITION BY` / `ORDER BY` — with a reference to a synthetic `__aggN`
/// column, recording each original aggregate expression in `aggs` (its index
/// = N). The rewritten expression has no aggregates, only window functions and
/// column references, so it evaluates against the per-group rows that carry the
/// materialized aggregate values.
fn extract_aggregates(&self, e: &Expr, aggs: &mut Vec<Expr>) -> Expr {
let is_agg = matches!(e, Expr::Function { name, args, star, over: None, .. }
if func::is_aggregate_call(name, args.len(), *star)
|| self.aggregates.contains_key(&name.to_ascii_lowercase()));
if is_agg {
let idx = aggs.len();
aggs.push(e.clone());
return Expr::Column {
schema: None,
table: None,
column: alloc::format!("__agg{idx}"),
quoted: false,
span: Span::none(),
};
}
match e {
Expr::Function {
name,
distinct,
args,
star,
filter,
order_by,
over,
..
} => {
let new_args = args
.iter()
.map(|a| self.extract_aggregates(a, aggs))
.collect();
let new_filter = filter
.as_ref()
.map(|f| Box::new(self.extract_aggregates(f, aggs)));
// Recurse into the window spec's PARTITION/ORDER expressions, which
// may themselves contain aggregates (`row_number() OVER (ORDER BY
// sum(v))`).
let new_over = over.as_ref().map(|spec| {
let mut s = spec.clone();
s.partition_by = spec
.partition_by
.iter()
.map(|p| self.extract_aggregates(p, aggs))
.collect();
s.order_by = spec
.order_by
.iter()
.map(|t| OrderTerm {
expr: self.extract_aggregates(&t.expr, aggs),
descending: t.descending,
nulls_first: t.nulls_first,
})
.collect();
s
});
Expr::Function {
name: name.clone(),
distinct: *distinct,
args: new_args,
star: *star,
filter: new_filter,
order_by: order_by.clone(),
over: new_over,
span: Span::none(),
}
}
Expr::Binary { op, left, right } => Expr::Binary {
op: *op,
left: Box::new(self.extract_aggregates(left, aggs)),
right: Box::new(self.extract_aggregates(right, aggs)),
},
Expr::Unary { op, expr } => Expr::Unary {
op: *op,
expr: Box::new(self.extract_aggregates(expr, aggs)),
},
Expr::Paren(x) => Expr::Paren(Box::new(self.extract_aggregates(x, aggs))),
Expr::Cast { expr, type_name } => Expr::Cast {
expr: Box::new(self.extract_aggregates(expr, aggs)),
type_name: type_name.clone(),
},
Expr::Collate { expr, collation } => Expr::Collate {
expr: Box::new(self.extract_aggregates(expr, aggs)),
collation: collation.clone(),
},
Expr::IsNull { expr, negated } => Expr::IsNull {
expr: Box::new(self.extract_aggregates(expr, aggs)),
negated: *negated,
},
Expr::Between {
expr,
low,
high,
negated,
} => Expr::Between {
expr: Box::new(self.extract_aggregates(expr, aggs)),
low: Box::new(self.extract_aggregates(low, aggs)),
high: Box::new(self.extract_aggregates(high, aggs)),
negated: *negated,
},
Expr::InList {
expr,
list,
negated,
candidate_affinity,
} => Expr::InList {
expr: Box::new(self.extract_aggregates(expr, aggs)),
list: list
.iter()
.map(|x| self.extract_aggregates(x, aggs))
.collect(),
negated: *negated,
candidate_affinity: candidate_affinity.clone(),
},
Expr::Case {
operand,
when_then,
else_result,
} => Expr::Case {
operand: operand
.as_ref()
.map(|o| Box::new(self.extract_aggregates(o, aggs))),
when_then: when_then
.iter()
.map(|(w, t)| {
(
self.extract_aggregates(w, aggs),
self.extract_aggregates(t, aggs),
)
})
.collect(),
else_result: else_result
.as_ref()
.map(|x| Box::new(self.extract_aggregates(x, aggs))),
},
Expr::RowValue(items) => Expr::RowValue(
items
.iter()
.map(|x| self.extract_aggregates(x, aggs))
.collect(),
),
// Literals, columns, parameters, and subqueries pass through (a
// subquery's own aggregates belong to that subquery's scope).
other => other.clone(),
}
}
/// Evaluate a query that combines `GROUP BY`/aggregates with window functions.
/// SQLite applies window functions *after* grouping — each window operates on
/// the post-aggregation rows, and an aggregate inside a window argument or
/// spec is the group's aggregate. We materialize each group into one row
/// carrying its aggregate values (as `__aggN` columns), rewrite the query to
/// reference those columns, apply `HAVING`, run the windows over the grouped
/// rows, then project. Returns `(labels, rows)` like the other eval paths.
fn eval_windowed_aggregate(
&self,
sel: &Select,
columns: &[ColumnInfo],
rows: Vec<InputRow>,
params: &Params,
) -> Result<(Vec<String>, Vec<OutRow>)> {
// `*` over a grouped+windowed query is rare and would need representative-
// row expansion alongside the synthetic columns; defer it (errors as
// before) rather than risk a wrong column set.
if sel
.columns
.iter()
.any(|c| matches!(c, ResultColumn::Wildcard | ResultColumn::TableWildcard(_)))
{
return Err(Error::Unsupported(
"SELECT * with window functions over GROUP BY",
));
}
// Output labels reflect the ORIGINAL expressions (e.g. the verbatim
// `sum(sum(v)) OVER ()`), so compute them before any rewrite.
let labels = self.output_labels(sel, columns);
// --- Partition rows into groups (mirrors eval_aggregated). ---
let group_by: Vec<Expr> = sel
.group_by
.iter()
.map(|g| {
// A positional `GROUP BY N` — including the signed / parenthesized /
// `COLLATE`-wrapped forms SQLite folds (`GROUP BY +1`) — names the
// N-th output column.
if let Some(n) = positional_int(g)
&& let Some(ResultColumn::Expr { expr, .. }) = usize::try_from(n)
.ok()
.filter(|&n| n >= 1)
.and_then(|n| sel.columns.get(n - 1))
{
return expr.clone();
}
g.clone()
})
.collect();
let group_colls: Vec<crate::value::Collation> = {
let cctx = row_ctx(&[], columns, None, params);
group_by
.iter()
.map(|g| eval::key_collation(g, &cctx))
.collect()
};
let mut group_keys: Vec<Vec<Value>> = Vec::new();
let mut groups: Vec<Vec<usize>> = Vec::new();
for (i, r) in rows.iter().enumerate() {
let ctx = r.ctx(columns, params).with_subqueries(self);
let mut key = Vec::new();
for g in &group_by {
key.push(eval::eval(g, &ctx)?);
}
match group_keys
.iter()
.position(|k| rows_equal_coll(k, &key, &group_colls))
{
Some(idx) => groups[idx].push(i),
None => {
group_keys.push(key);
groups.push(alloc::vec![i]);
}
}
}
if sel.group_by.is_empty() {
groups = alloc::vec![(0..rows.len()).collect()];
} else {
let mut order: Vec<usize> = (0..groups.len()).collect();
order.sort_by(|&i, &j| {
for (k, coll) in group_colls.iter().enumerate() {
let ord =
crate::value::cmp_values_coll(&group_keys[i][k], &group_keys[j][k], *coll);
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
core::cmp::Ordering::Equal
});
let mut sorted = Vec::with_capacity(groups.len());
for i in order {
sorted.push(core::mem::take(&mut groups[i]));
}
groups = sorted;
}
// --- Rewrite the query so each aggregate becomes a `__aggN` column. ---
let mut aggs: Vec<Expr> = Vec::new();
let mut rsel = sel.clone();
for col in &mut rsel.columns {
if let ResultColumn::Expr { expr, .. } = col {
*expr = self.extract_aggregates(expr, &mut aggs);
}
}
if let Some(h) = rsel.having.take() {
rsel.having = Some(self.extract_aggregates(&h, &mut aggs));
}
for t in &mut rsel.order_by {
t.expr = self.extract_aggregates(&t.expr, &mut aggs);
}
// Named WINDOW definitions (`WINDOW w AS (ORDER BY sum(v))`) referenced via
// `OVER w` carry their PARTITION/ORDER expressions here, not in the call's
// own spec, so rewrite their aggregates too.
for (_, ws) in &mut rsel.window_defs {
for p in &mut ws.partition_by {
*p = self.extract_aggregates(p, &mut aggs);
}
for t in &mut ws.order_by {
t.expr = self.extract_aggregates(&t.expr, &mut aggs);
}
}
// --- Augment the column set with one synthetic column per aggregate. ---
let mut cols: Vec<ColumnInfo> = columns.to_vec();
for i in 0..aggs.len() {
cols.push(ColumnInfo {
name: alloc::format!("__agg{i}"),
table: String::new(),
affinity: eval::Affinity::Blob,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
});
}
// --- One grouped row per group: representative base values ++ aggregate
// values (computed over the group via the existing machinery). ---
let empty = InputRow {
values: alloc::vec![Value::Null; columns.len()],
rowid: None,
};
let mut grows: Vec<InputRow> = Vec::with_capacity(groups.len());
for group in &groups {
let repr_idx = group.first().copied();
let repr = repr_idx.map(|i| &rows[i]).unwrap_or(&empty);
let repr_ctx = repr.ctx(columns, params).with_subqueries(self);
let mut vals = repr.values.clone();
for agg in &aggs {
let sub = self.substitute_aggregates(agg, columns, &rows, group, params)?;
vals.push(eval::eval(&sub, &repr_ctx)?);
}
grows.push(InputRow {
values: vals,
rowid: repr_idx.and_then(|i| rows[i].rowid),
});
}
// --- HAVING (now over the grouped rows; references `__aggN`). ---
if let Some(having) = &rsel.having {
let mut kept = Vec::with_capacity(grows.len());
for r in grows {
let ctx = r.ctx(&cols, params).with_subqueries(self);
if eval::truth(&eval::eval(having, &ctx)?) == Some(true) {
kept.push(r);
}
}
grows = kept;
}
// --- Window functions over the grouped rows, then project. ---
let mut wcols = cols;
let mut win_sel = self.apply_windows(&rsel, &mut wcols, &mut grows, params)?;
// Absent an explicit ORDER BY, match sqlite's window-induced row order
// (the first window's PARTITION BY + ORDER BY) over the grouped rows. The
// keys come from `rsel`, whose window specs reference the aggregate
// columns (`__aggN`), so `ORDER BY sum(x)` sorts by the group's aggregate.
// The windowed-aggregate path produces its rows here (finish_from_rows
// only sorts when the *original* query named an ORDER BY), so apply the
// implicit order locally.
let synth_order = if win_sel.order_by.is_empty() {
self.window_output_order(&rsel)?
} else {
None
};
if let Some(order) = &synth_order {
win_sel.order_by = order.clone();
}
let mut out = Vec::with_capacity(grows.len());
for r in &grows {
let ctx = r.ctx(&wcols, params).with_subqueries(self);
let mut values = Vec::new();
for col in &win_sel.columns {
project_column(col, &wcols, &ctx, &mut values)?;
}
let mut sort_keys = Vec::new();
for term in &win_sel.order_by {
match resolve_order_index(&term.expr, &labels, values.len()) {
Some(idx) => sort_keys.push(values[idx].clone()),
None => sort_keys.push(eval::eval(&term.expr, &ctx)?),
}
}
out.push(OutRow { values, sort_keys });
}
if let Some(order) = &synth_order {
let octx = row_ctx(&[], &wcols, None, params);
let colls: Vec<crate::value::Collation> = order
.iter()
.map(|t| eval::key_collation(&t.expr, &octx))
.collect();
out.sort_by(|a, b| {
for (i, term) in order.iter().enumerate() {
let o = cmp_order(
&a.sort_keys[i],
&b.sort_keys[i],
term.descending,
term.nulls_first,
colls[i],
);
if o != core::cmp::Ordering::Equal {
return o;
}
}
core::cmp::Ordering::Equal
});
}
Ok((labels, out))
}
/// The index (into `rows`) of the group member achieving the maximum (or
/// minimum) value of `arg`, ignoring NULLs; falls back to the group's first
/// row when every value is NULL. Implements SQLite's bare-column min/max rule.
fn argextreme_row(
&self,
group: &[usize],
columns: &[ColumnInfo],
rows: &[InputRow],
arg: &Expr,
is_max: bool,
params: &Params,
) -> Result<Option<usize>> {
let mut best: Option<(usize, Value)> = None;
for &i in group {
let ctx = rows[i].ctx(columns, params).with_subqueries(self);
let v = eval::eval(arg, &ctx)?;
if matches!(v, Value::Null) {
continue;
}
let take = match &best {
None => true,
Some((_, bv)) => {
let ord = eval::compare(&v, bv);
if is_max {
ord == core::cmp::Ordering::Greater
} else {
ord == core::cmp::Ordering::Less
}
}
};
if take {
best = Some((i, v));
}
}
Ok(best.map(|(i, _)| i).or_else(|| group.first().copied()))
}
/// Replace aggregate function calls in `expr` with their computed values for
/// the given group, returning an aggregate-free expression.
fn substitute_aggregates(
&self,
expr: &Expr,
columns: &[ColumnInfo],
rows: &[InputRow],
group: &[usize],
params: &Params,
) -> Result<Expr> {
Ok(match expr {
Expr::Function {
name,
distinct,
args,
star,
filter,
order_by,
over: None,
..
} if func::is_aggregate_call(name, args.len(), *star)
|| self.aggregates.contains_key(&name.to_ascii_lowercase()) =>
{
// `FILTER (WHERE …)` narrows the group's rows before aggregating.
let filtered;
let group = match filter {
Some(pred) => {
filtered = self.filter_group(pred, columns, rows, group, params)?;
&filtered[..]
}
None => group,
};
let v = self.compute_aggregate(
name, *distinct, args, *star, order_by, columns, rows, group, params,
)?;
let lit = Expr::Literal(value_to_literal(v));
// The JSON aggregates emit a value carrying SQLite's JSON subtype.
// Substitution to a bare literal would drop that, so an enclosing
// json_quote/json_array/json_object would re-quote it. Re-wrap in
// json() (idempotent on valid JSON) so the subtype marker — which
// func::produces_json keys off the expression — survives.
if matches!(
name.to_ascii_lowercase().as_str(),
"json_group_array" | "json_group_object"
) {
Expr::Function {
name: String::from("json"),
distinct: false,
args: alloc::vec![lit],
star: false,
filter: None,
order_by: Vec::new(),
over: None,
span: Span::none(),
}
} else {
lit
}
}
Expr::Function {
name,
distinct,
args,
star,
filter,
order_by,
over,
..
} => {
let mut new_args = Vec::with_capacity(args.len());
for a in args {
new_args.push(self.substitute_aggregates(a, columns, rows, group, params)?);
}
Expr::Function {
name: name.clone(),
distinct: *distinct,
args: new_args,
star: *star,
filter: filter.clone(),
order_by: order_by.clone(),
over: over.clone(),
span: Span::none(),
}
}
Expr::Binary { op, left, right } => Expr::Binary {
op: *op,
left: Box::new(self.substitute_aggregates(left, columns, rows, group, params)?),
right: Box::new(self.substitute_aggregates(right, columns, rows, group, params)?),
},
Expr::Unary { op, expr } => Expr::Unary {
op: *op,
expr: Box::new(self.substitute_aggregates(expr, columns, rows, group, params)?),
},
Expr::Paren(e) => Expr::Paren(Box::new(
self.substitute_aggregates(e, columns, rows, group, params)?,
)),
Expr::Cast { expr, type_name } => Expr::Cast {
expr: Box::new(self.substitute_aggregates(expr, columns, rows, group, params)?),
type_name: type_name.clone(),
},
Expr::IsNull { expr, negated } => Expr::IsNull {
expr: Box::new(self.substitute_aggregates(expr, columns, rows, group, params)?),
negated: *negated,
},
Expr::Between {
expr,
low,
high,
negated,
} => Expr::Between {
expr: Box::new(self.substitute_aggregates(expr, columns, rows, group, params)?),
low: Box::new(self.substitute_aggregates(low, columns, rows, group, params)?),
high: Box::new(self.substitute_aggregates(high, columns, rows, group, params)?),
negated: *negated,
},
Expr::InList {
expr,
list,
negated,
candidate_affinity,
} => {
let mut new_list = Vec::with_capacity(list.len());
for e in list {
new_list.push(self.substitute_aggregates(e, columns, rows, group, params)?);
}
Expr::InList {
expr: Box::new(self.substitute_aggregates(expr, columns, rows, group, params)?),
list: new_list,
negated: *negated,
candidate_affinity: candidate_affinity.clone(),
}
}
Expr::Case {
operand,
when_then,
else_result,
} => {
let operand = match operand {
Some(o) => Some(Box::new(
self.substitute_aggregates(o, columns, rows, group, params)?,
)),
None => None,
};
let mut new_wt = Vec::with_capacity(when_then.len());
for (w, t) in when_then {
new_wt.push((
self.substitute_aggregates(w, columns, rows, group, params)?,
self.substitute_aggregates(t, columns, rows, group, params)?,
));
}
let else_result = match else_result {
Some(e) => Some(Box::new(
self.substitute_aggregates(e, columns, rows, group, params)?,
)),
None => None,
};
Expr::Case {
operand,
when_then: new_wt,
else_result,
}
}
Expr::Collate { expr, collation } => Expr::Collate {
expr: Box::new(self.substitute_aggregates(expr, columns, rows, group, params)?),
collation: collation.clone(),
},
Expr::RowValue(items) => {
let mut new_items = Vec::with_capacity(items.len());
for it in items {
new_items.push(self.substitute_aggregates(it, columns, rows, group, params)?);
}
Expr::RowValue(new_items)
}
// Literals, columns, parameters, and subqueries are left as-is
// (a subquery's own aggregates belong to that subquery).
other => other.clone(),
})
}
/// The subset of `group`'s row indices for which `pred` (an aggregate
/// `FILTER (WHERE …)`) evaluates true.
fn filter_group(
&self,
pred: &Expr,
columns: &[ColumnInfo],
rows: &[InputRow],
group: &[usize],
params: &Params,
) -> Result<Vec<usize>> {
let mut out = Vec::new();
for &i in group {
let ctx = rows[i].ctx(columns, params).with_subqueries(self);
if eval::truth(&eval::eval(pred, &ctx)?) == Some(true) {
out.push(i);
}
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
fn compute_aggregate(
&self,
name: &str,
distinct: bool,
args: &[Expr],
star: bool,
order_by: &[OrderTerm],
columns: &[ColumnInfo],
rows: &[InputRow],
group: &[usize],
params: &Params,
) -> Result<Value> {
let lname = name.to_ascii_lowercase();
// Arity guards, ordered to match SQLite's error precedence.
//
// 1. Upper bound first, for the builtin aggregates (a registered UDAF
// carries its own). SQLite rejects too many arguments ("wrong number
// of arguments"): `sum(1,2)`, `avg(1,2)`, `count(1,2)` are all errors.
// The two-argument forms are `group_concat`/`string_agg` and the
// `json[b]_group_object` pair; every other builtin aggregate takes one.
if !self.aggregates.contains_key(&lname) {
let max_args = match lname.as_str() {
"group_concat" | "string_agg" | "json_group_object" | "jsonb_group_object" => 2,
_ => 1,
};
if args.len() > max_args {
return Err(Error::Error(format!(
"wrong number of arguments to function {lname}()"
)));
}
// `string_agg` requires its separator — exactly two arguments —
// unlike its `group_concat` alias whose separator is optional. This
// lower bound sits with the upper one (before the DISTINCT guard) so
// that `string_agg(DISTINCT x)` reports "wrong number of arguments"
// rather than the DISTINCT message, matching sqlite.
if lname == "string_agg" && args.len() < 2 {
return Err(Error::Error(format!(
"wrong number of arguments to function {lname}()"
)));
}
}
// 2. A DISTINCT aggregate must have exactly one argument. This is checked
// *after* the upper bound (so `count(DISTINCT 1,2)`/`sum(DISTINCT 1,2)`
// still report the arity error) but *before* the lower-bound guards
// below (so `count(DISTINCT)`, whose 0-arg form is otherwise valid as
// `count(*)`, reports this rather than "wrong number of arguments").
// `group_concat(DISTINCT a,b)` — within its 2-arg upper bound — lands
// here too. The scalar 2-arg `min`/`max` never reach this path.
if distinct && !star && args.len() != 1 {
return Err(Error::Error(
"DISTINCT aggregates must have exactly one argument".into(),
));
}
// 3. Lower bound: every aggregate but `count` needs at least one
// argument, and `json_group_object` needs two. Without this we would
// index `args[…]` out of bounds and panic (e.g. `group_concat()`).
// `count()` with no arguments is accepted as a synonym for `count(*)`,
// matching SQLite (it counts every row).
if !star && args.is_empty() && lname != "count" {
return Err(Error::Error(format!(
"wrong number of arguments to function {lname}()"
)));
}
if (lname == "json_group_object" || lname == "jsonb_group_object") && args.len() < 2 {
return Err(Error::Error(format!(
"wrong number of arguments to function {lname}()"
)));
}
// An `ORDER BY` inside the aggregate (`group_concat(x ORDER BY y)`) sorts
// the group's rows before the values are gathered.
let ordered_group;
let group = if order_by.is_empty() {
group
} else {
let mut g = group.to_vec();
let mut err = None;
g.sort_by(|&a, &b| {
for term in order_by {
let ca = rows[a].ctx(columns, params).with_subqueries(self);
let cb = rows[b].ctx(columns, params).with_subqueries(self);
let (va, vb) = match (eval::eval(&term.expr, &ca), eval::eval(&term.expr, &cb))
{
(Ok(x), Ok(y)) => (x, y),
(Err(e), _) | (_, Err(e)) => {
err.get_or_insert(e);
return core::cmp::Ordering::Equal;
}
};
let coll = eval::key_collation(&term.expr, &ca);
let ord = cmp_order(&va, &vb, term.descending, term.nulls_first, coll);
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
core::cmp::Ordering::Equal
});
if let Some(e) = err {
return Err(e);
}
ordered_group = g;
&ordered_group[..]
};
// JSON aggregates build their result directly from the (NULL-inclusive,
// possibly multi-argument) per-row values, so they bypass the NULL-
// stripping single-value collection used by the other aggregates.
if lname == "json_group_array" || lname == "jsonb_group_array" {
// Each element's JSON subtype is decided per row from the argument's
// source expression (`carries_json_subtype`, honoring a single-path
// `json_extract` that yields a container), so a `json_extract`-of-a-
// structure element embeds as JSON rather than quoting its text.
let mut items = Vec::new();
let mut seen: Vec<Value> = Vec::new();
for &i in group {
let ctx = rows[i].ctx(columns, params).with_subqueries(self);
let v = eval::eval(&args[0], &ctx)?;
// `json_group_array(DISTINCT x)` dedupes the values (first-seen
// order), like other DISTINCT aggregates, before serializing.
if distinct
&& seen.iter().any(|s| {
crate::value::cmp_values_coll(s, &v, crate::value::Collation::default())
== core::cmp::Ordering::Equal
})
{
continue;
}
let subtype = args
.first()
.is_some_and(|e| func::carries_json_subtype(e, &ctx));
if distinct {
seen.push(v.clone());
}
items.push(func::arg_to_json_with_subtype(&v, subtype));
}
let arr = json::Json::Array(items);
return Ok(if lname.starts_with("jsonb") {
Value::Blob(arr.to_jsonb())
} else {
Value::Text(arr.serialize().into())
});
}
if lname == "json_group_object" || lname == "jsonb_group_object" {
let mut pairs = Vec::new();
for &i in group {
let ctx = rows[i].ctx(columns, params).with_subqueries(self);
let k = eval::eval(&args[0], &ctx)?;
let v = eval::eval(&args[1], &ctx)?;
let subtype = args
.get(1)
.is_some_and(|e| func::carries_json_subtype(e, &ctx));
pairs.push((
eval::to_text(&k),
None,
func::arg_to_json_with_subtype(&v, subtype),
));
}
let obj = json::Json::Object(pairs);
return Ok(if lname.starts_with("jsonb") {
Value::Blob(obj.to_jsonb())
} else {
Value::Text(obj.serialize().into())
});
}
// `geopoly_group_bbox` folds the axis-aligned bounding box over every
// non-NULL polygon in the group (the union of each polygon's bbox),
// returning the enclosing CCW rectangle as a geopoly BLOB. A group with
// no valid polygon (all NULL / empty) yields NULL.
if lname == "geopoly_group_bbox" {
let mut acc: Option<(f32, f32, f32, f32)> = None;
for &i in group {
let ctx = rows[i].ctx(columns, params).with_subqueries(self);
let v = eval::eval(&args[0], &ctx)?;
// Each row contributes a bounding box (the polygon's, or an
// all-zero box for the SQLite "rc OK but no polygon" case);
// `Skip` rows leave the accumulator untouched, matching sqlite.
let (mnx, mxx, mny, mxy) = match crate::geopoly::bbox_step(&v) {
crate::geopoly::BBoxStep::Poly(p) => p.bbox_coords(),
crate::geopoly::BBoxStep::ZeroBox => (0.0, 0.0, 0.0, 0.0),
crate::geopoly::BBoxStep::Skip => continue,
};
acc = Some(match acc {
None => (mnx, mxx, mny, mxy),
Some((amnx, amxx, amny, amxy)) => (
if mnx < amnx { mnx } else { amnx },
if mxx > amxx { mxx } else { amxx },
if mny < amny { mny } else { amny },
if mxy > amxy { mxy } else { amxy },
),
});
}
return Ok(match acc {
Some((mnx, mxx, mny, mxy)) => {
Value::Blob(crate::geopoly::GeoPoly::from_bbox(mnx, mxx, mny, mxy).to_blob())
}
None => Value::Null,
});
}
// Gather the (non-NULL for most) argument values across the group.
let mut vals: Vec<Value> = Vec::new();
let mut count_rows = 0usize; // for count(*)
for &i in group {
count_rows += 1;
// `count(*)` and the no-argument `count()` only tally rows; there is
// no argument expression to evaluate.
if star || args.is_empty() {
continue;
}
let ctx = rows[i].ctx(columns, params).with_subqueries(self);
let v = eval::eval(&args[0], &ctx)?;
if !matches!(v, Value::Null) {
vals.push(v);
}
}
if distinct {
let coll = if star || args.is_empty() {
crate::value::Collation::default()
} else {
let cctx = row_ctx(&[], columns, None, params);
eval::key_collation(&args[0], &cctx)
};
dedup_values(&mut vals, coll);
}
// `min`/`max` compare under the argument's collation (e.g. a NOCASE
// column), not plain BINARY.
let arg_coll = if args.is_empty() {
crate::value::Collation::default()
} else {
let cctx = row_ctx(&[], columns, None, params);
eval::key_collation(&args[0], &cctx)
};
Ok(match lname.as_str() {
"count" => {
// `count(*)` and the no-argument `count()` count every row; the
// one-argument `count(X)` counts the non-NULL values.
if star || args.is_empty() {
Value::Integer(count_rows as i64)
} else {
Value::Integer(vals.len() as i64)
}
}
"sum" => eval::sum_values(&vals)?,
"total" => Value::Real(eval::total_value(&vals)),
"avg" => match eval::avg_value(&vals) {
Some(r) => Value::Real(r),
None => Value::Null,
},
"min" => vals
.into_iter()
.reduce(|a, b| {
if crate::value::cmp_values_coll(&b, &a, arg_coll) == core::cmp::Ordering::Less
{
b
} else {
a
}
})
.unwrap_or(Value::Null),
"max" => vals
.into_iter()
.reduce(|a, b| {
if crate::value::cmp_values_coll(&b, &a, arg_coll)
== core::cmp::Ordering::Greater
{
b
} else {
a
}
})
.unwrap_or(Value::Null),
// `string_agg` is SQLite's standard-SQL alias for `group_concat`.
"group_concat" | "string_agg" => {
if vals.is_empty() {
Value::Null
} else {
let sep = if args.len() >= 2 {
let ctx = EvalCtx::rowless(params);
eval::to_text(&eval::eval(&args[1], &ctx)?)
} else {
",".to_string()
};
let parts: Vec<String> = vals.iter().map(eval::to_text).collect();
Value::Text(parts.join(&sep).into())
}
}
_ => {
// A user-defined aggregate registered via
// `register_aggregate_function`: build a fresh accumulator, step
// it over the group's evaluated argument values, then finalize.
if let Some(factory) = self.aggregates.get(&lname) {
let mut acc = factory();
let mut seen: Vec<Vec<Value>> = Vec::new();
for &i in group {
let ctx = rows[i].ctx(columns, params).with_subqueries(self);
let vals: Vec<Value> = args
.iter()
.map(|a| eval::eval(a, &ctx))
.collect::<Result<_>>()?;
if distinct {
if seen.contains(&vals) {
continue;
}
seen.push(vals.clone());
}
acc.step(&vals)?;
}
return acc.finalize();
}
return Err(Error::Error(format!("no such function: {name}")));
}
})
}
/// An aggregate function in the *result columns* (not HAVING). This is what
/// makes a query an aggregate query for the purpose of permitting a HAVING
/// clause — an aggregate appearing only inside HAVING does not count.
fn has_result_aggregate(&self, sel: &Select) -> bool {
// Recognize both built-in and user-registered aggregate names.
let is_agg = |name: &str, n: usize, star: bool| {
func::is_aggregate_call(name, n, star)
|| self.aggregates.contains_key(&name.to_ascii_lowercase())
};
sel.columns.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_contains_agg(expr, &is_agg),
_ => false,
})
}
fn has_aggregate(&self, sel: &Select) -> bool {
if self.has_result_aggregate(sel) {
return true;
}
// Recognize both built-in and user-registered aggregate names.
let is_agg = |name: &str, n: usize, star: bool| {
func::is_aggregate_call(name, n, star)
|| self.aggregates.contains_key(&name.to_ascii_lowercase())
};
sel.having
.as_ref()
.is_some_and(|h| expr_contains_agg(h, &is_agg))
}
/// Whether any result-column aggregate is *order-sensitive* — its value depends
/// on the order rows are folded in (`group_concat` / `string_agg` and the JSON
/// aggregates preserve element order) — or is a user-registered aggregate of
/// unknown order-dependence. Uses a whitelist of the definitively
/// order-*independent* aggregates (`count`/`sum`/`total`/`avg`/`min`/`max`), so
/// anything else is treated conservatively as order-sensitive. Used to decide
/// whether a cost-based join swap/reorder is irrelevant to a bare aggregate (an
/// order-independent one is invariant to the join drive order, so the VDBE's
/// identity-order fold is correct without modelling the reorder).
fn select_has_order_sensitive_aggregate(&self, sel: &Select) -> bool {
let bad = |name: &str, n: usize, star: bool| -> bool {
let is_agg = func::is_aggregate_call(name, n, star)
|| self.aggregates.contains_key(&name.to_ascii_lowercase());
is_agg
&& !matches!(
name.to_ascii_lowercase().as_str(),
"count" | "sum" | "total" | "avg" | "min" | "max"
)
};
sel.columns.iter().any(|rc| match rc {
ResultColumn::Expr { expr, .. } => expr_contains_agg(expr, &bad),
_ => false,
})
}
/// An aggregate appearing inside a result-column window function's `OVER`
/// spec (`PARTITION BY` / `ORDER BY`), e.g. `row_number() OVER (ORDER BY
/// sum(a))`. SQLite computes such a query as a single aggregate group that
/// feeds the window, so it must route through the windowed-aggregate path
/// even without a GROUP BY or a plain result aggregate. It is deliberately
/// *not* counted by [`Self::has_result_aggregate`]: an over-spec aggregate
/// does not make the query an aggregate one for HAVING-validity.
fn has_over_spec_aggregate(&self, sel: &Select) -> bool {
let is_agg = |name: &str, n: usize, star: bool| {
func::is_aggregate_call(name, n, star)
|| self.aggregates.contains_key(&name.to_ascii_lowercase())
};
let mut found = false;
for c in &sel.columns {
let ResultColumn::Expr { expr, .. } = c else {
continue;
};
window::visit(expr, &mut |n| {
if let Expr::Function {
over: Some(spec), ..
} = n
&& (spec
.partition_by
.iter()
.any(|e| expr_contains_agg(e, &is_agg))
|| spec
.order_by
.iter()
.any(|o| expr_contains_agg(&o.expr, &is_agg)))
{
found = true;
}
});
}
found
}
fn output_labels(&self, sel: &Select, columns: &[ColumnInfo]) -> Vec<String> {
let mut labels = Vec::new();
for col in &sel.columns {
match col {
ResultColumn::Wildcard => {
for c in columns.iter().filter(|c| !c.hidden) {
labels.push(c.name.clone());
}
}
// `t.*` names only that table's columns (by owning-table qualifier),
// matching the projected data — over a join a bare `*` lists every
// column but `t.*` must not.
ResultColumn::TableWildcard(t) => {
for c in columns
.iter()
.filter(|c| !c.hidden && c.table.eq_ignore_ascii_case(t))
{
labels.push(c.name.clone());
}
}
ResultColumn::Expr {
expr,
alias,
source,
} => {
labels.push(result_column_label(expr, alias, source));
}
}
}
labels
}
fn table_meta(&self, name: &str, alias: Option<&str>) -> Result<TableMeta> {
self.table_meta_in(&self.schema, name, alias)
}
/// Like [`table_meta`](Self::table_meta) but resolving `name` in an explicit
/// schema catalog (the `main` schema or an attached database's).
fn table_meta_in(&self, schema: &Schema, name: &str, alias: Option<&str>) -> Result<TableMeta> {
// The schema catalog itself is queryable as `sqlite_schema` /
// `sqlite_master` (a 5-column rowid table rooted at page 1).
if is_main_schema_table(name) {
return Ok(schema_table_meta(alias.unwrap_or(name)));
}
let obj = schema
.table(name)
.ok_or_else(|| Error::Error(alloc::format!("no such table: {name}")))?;
let sql = obj
.sql
.as_ref()
.ok_or_else(|| Error::Corrupt("table has no CREATE statement".into()))?;
let Statement::CreateTable(ct) = sql::parse_one(sql)? else {
return Err(Error::Corrupt("schema sql is not CREATE TABLE".into()));
};
let table_label = alias.unwrap_or(name).to_string();
let columns: Vec<ColumnInfo> = ct
.columns
.iter()
.map(|c| ColumnInfo {
name: c.name.clone(),
table: table_label.clone(),
affinity: eval::Affinity::from_type(c.type_name.as_deref()),
collation: column_collation(c),
schema: None,
hidden: false,
})
.collect();
let defaults: Vec<Option<Expr>> = ct
.columns
.iter()
.map(|c| {
c.constraints.iter().find_map(|k| match k {
ColumnConstraint::Default(e, _) => Some(e.clone()),
_ => None,
})
})
.collect();
// A WITHOUT ROWID table has no rowid, so `INTEGER PRIMARY KEY` is an
// ordinary column there (no rowid aliasing).
let ipk = if ct.without_rowid {
None
} else {
find_integer_primary_key(&ct)
};
// `None` = nullable; `Some(action)` = NOT NULL with that conflict action.
let not_null: Vec<Option<OnConflict>> = ct
.columns
.iter()
.enumerate()
.map(|(i, c)| {
// The INTEGER PRIMARY KEY (rowid alias) is implicitly NOT NULL.
if Some(i) == ipk {
return Some(OnConflict::Abort);
}
c.constraints.iter().find_map(|k| match k {
ColumnConstraint::NotNull(oc) => Some(*oc),
_ => None,
})
})
.collect();
// Generated columns: `… AS (expr) [STORED|VIRTUAL]`.
let generated: Vec<Option<(Expr, bool)>> = ct
.columns
.iter()
.map(|c| {
c.constraints.iter().find_map(|k| match k {
ColumnConstraint::Generated { expr, stored } => Some((expr.clone(), *stored)),
_ => None,
})
})
.collect();
// CHECK constraints (column-level + table-level); each is evaluated
// against the full row on INSERT/UPDATE.
let mut checks: Vec<(Expr, Option<String>)> = Vec::new();
for col in &ct.columns {
for k in &col.constraints {
if let ColumnConstraint::Check(e, label) = k {
checks.push((e.clone(), label.clone()));
}
}
}
for tc in &ct.constraints {
if let TableConstraint::Check(e, label) = tc {
checks.push((e.clone(), label.clone()));
}
}
// UNIQUE / PRIMARY KEY column sets that must be unique (the rowid IPK is
// handled separately). Order matches SQLite's auto-index numbering.
let unique = collect_unique_sets(&ct, ipk);
// WITHOUT ROWID: derive the PK-first storage order.
let (without_rowid, storage_order, pk_len, pk_descending) = if ct.without_rowid {
let pk_dir = primary_key_positions_dir(&ct);
if pk_dir.is_empty() {
return Err(Error::Error(format!(
"PRIMARY KEY missing on table {}",
ct.name
)));
}
let pk: Vec<usize> = pk_dir.iter().map(|(p, _)| *p).collect();
let pk_descending: Vec<bool> = pk_dir.iter().map(|(_, d)| *d).collect();
// Storage order: PK columns first, then the remaining *stored*
// columns (VIRTUAL generated columns are never written).
let mut order = pk.clone();
for (i, g) in generated.iter().enumerate() {
let is_virtual = matches!(g, Some((_, false)));
if !pk.contains(&i) && !is_virtual {
order.push(i);
}
}
let pk_len = pk.len();
(true, order, pk_len, pk_descending)
} else {
(false, Vec::new(), 0, Vec::new())
};
// STRICT tables: record each column's rigid type for write-time checking,
// and give `ANY` columns no affinity (values stored exactly as supplied).
let strict_types: Option<Vec<(StrictType, String)>> = if ct.strict {
let mut v = Vec::with_capacity(columns.len());
for c in &ct.columns {
let st = strict_column_type(c.type_name.as_deref()).unwrap_or(StrictType::Any);
let decl = c.type_name.clone().unwrap_or_default();
v.push((st, decl));
}
Some(v)
} else {
None
};
let mut columns = columns;
if let Some(st) = &strict_types {
for (col, (ty, _)) in columns.iter_mut().zip(st) {
if *ty == StrictType::Any {
col.affinity = eval::Affinity::Blob; // ANY: store as-is
}
}
}
Ok(TableMeta {
root: obj.rootpage,
columns,
defaults,
not_null,
checks,
unique,
ipk,
generated,
without_rowid,
storage_order,
pk_len,
pk_descending,
strict_types,
autoincrement: ipk.is_some_and(|i| {
ct.columns[i].constraints.iter().any(|k| {
matches!(
k,
ColumnConstraint::PrimaryKey {
autoincrement: true,
..
}
)
})
}),
})
}
/// Enforce a `STRICT` table's column types against a row whose affinity has
/// already been applied. NULL always passes; otherwise the stored value's
/// storage class must match the column's rigid type (`ANY` accepts anything).
/// `INT`/`REAL` columns accept their numeric class after affinity coercion
/// (an integer in a `REAL` column has been turned into a real already).
fn check_strict_types(&self, meta: &TableMeta, values: &[Value]) -> Result<()> {
let Some(stypes) = &meta.strict_types else {
return Ok(());
};
for (i, (st, decl)) in stypes.iter().enumerate() {
let v = &values[i];
let ok = matches!(
(st, v),
(_, Value::Null)
| (StrictType::Any, _)
| (StrictType::Int, Value::Integer(_))
| (StrictType::Real, Value::Real(_))
| (StrictType::Text, Value::Text(_))
| (StrictType::Blob, Value::Blob(_))
);
if !ok {
let class = match v {
Value::Integer(_) => "INT",
Value::Real(_) => "REAL",
Value::Text(_) => "TEXT",
Value::Blob(_) => "BLOB",
Value::Null => unreachable!(),
};
return Err(Error::Constraint(format!(
"cannot store {class} value in {decl} column {}.{}",
meta.columns[i].table, meta.columns[i].name
)));
}
}
Ok(())
}
/// Evaluate CHECK constraints against a fully-built row (with the IPK column
/// holding the rowid). A constraint fails only when it evaluates to false;
/// NULL (unknown) passes, matching SQLite.
fn check_constraints(
&self,
meta: &TableMeta,
values: &[Value],
rowid: Option<i64>,
params: &Params,
) -> Result<()> {
// `PRAGMA ignore_check_constraints = ON` suppresses CHECK enforcement on
// INSERT/UPDATE (NOT NULL, UNIQUE, and foreign keys are unaffected — those
// are enforced elsewhere). Off by default, matching SQLite.
if self.ignore_check_constraints {
return Ok(());
}
for (expr, label) in &meta.checks {
let ctx = row_ctx(values, &meta.columns, rowid, params).with_subqueries(self);
if eval::truth(&eval::eval(expr, &ctx)?) == Some(false) {
let msg = match label {
Some(l) => alloc::format!("CHECK constraint failed: {l}"),
None => String::from("CHECK constraint failed"),
};
return Err(Error::Constraint(msg));
}
}
Ok(())
}
}
/// A live b-tree cursor over a single rowid table, presented to the VDBE as
/// cursor 0's [`vdbe::Cursor0Source`] (B5b-2 / B8). Each `Rewind` / `Next`
/// advances the underlying [`TableCursor`] and decodes exactly one row on demand
/// (via [`Connection::decode_full_row`]), so a `SELECT … FROM t [WHERE …]` streams
/// rows straight from storage instead of materializing the whole table up front.
/// The decoded row is cached in `current` between the `Column` reads of one loop
/// iteration; when the scan appends a hidden rowid (`has_rowid`), it is pushed as
/// the trailing value exactly as the materialized path does.
struct LiveScanCursor<'a> {
conn: &'a Connection,
meta: &'a TableMeta,
encoding: crate::format::TextEncoding,
has_rowid: bool,
cur: TableCursor<'a>,
/// The current decoded row (empty before the first `Rewind` or past EOF).
current: Vec<Value>,
}
impl<'a> LiveScanCursor<'a> {
fn new(conn: &'a Connection, meta: &'a TableMeta, has_rowid: bool) -> LiveScanCursor<'a> {
let encoding = conn.backend.source().header().text_encoding;
LiveScanCursor {
conn,
meta,
encoding,
has_rowid,
cur: TableCursor::new(conn.backend.source(), meta.root),
current: Vec::new(),
}
}
/// Decode the row at the current cursor position into `current`, appending the
/// hidden trailing rowid when the scan carries one.
fn load_current(&mut self) -> Result<()> {
let rowid = self.cur.rowid()?;
let mut values =
self.conn
.decode_full_row(self.meta, rowid, &self.cur.payload()?, self.encoding)?;
if self.has_rowid {
values.push(Value::Integer(rowid));
}
self.current = values;
Ok(())
}
}
impl vdbe::Cursor0Source for LiveScanCursor<'_> {
fn rewind(&mut self) -> Result<bool> {
if self.cur.first()? {
self.load_current()?;
Ok(true)
} else {
self.current = Vec::new();
Ok(false)
}
}
fn advance(&mut self) -> Result<bool> {
if self.cur.next()? {
self.load_current()?;
Ok(true)
} else {
self.current = Vec::new();
Ok(false)
}
}
fn column(&self, col: usize) -> Value {
self.current.get(col).cloned().unwrap_or(Value::Null)
}
}
/// A [`vdbe::Cursor0Source`] streaming a `WITHOUT ROWID` table's rows one at a
/// time from its index-organized b-tree (primary-key order), the live-scan analog
/// of [`Connection::scan_without_rowid`]'s materialized read (B5b-2). Each row is
/// decoded, un-permuted back to declared column order, and has its generated
/// columns computed — identical to the materialized path, so the streamed result
/// (and its order) matches the tree-walker and SQLite. There is no hidden rowid
/// slot: a `WITHOUT ROWID` table exposes no `rowid` (a reference to one makes the
/// compiler bail to the materialized path, which errors the same way).
struct WithoutRowidLiveCursor<'a> {
conn: &'a Connection,
meta: &'a TableMeta,
encoding: crate::format::TextEncoding,
cur: IndexCursor<'a>,
/// The current decoded row (empty before the first `Rewind` or past EOF).
current: Vec<Value>,
}
impl<'a> WithoutRowidLiveCursor<'a> {
fn new(conn: &'a Connection, meta: &'a TableMeta) -> WithoutRowidLiveCursor<'a> {
let encoding = conn.backend.source().header().text_encoding;
WithoutRowidLiveCursor {
conn,
meta,
encoding,
cur: IndexCursor::new(conn.backend.source(), meta.root),
current: Vec::new(),
}
}
/// Decode the record `payload` into the current row (un-permuted to declared
/// column order, with generated columns computed).
fn load(&mut self, payload: &[u8]) -> Result<()> {
let storage = decode_record(payload, self.encoding)?;
let mut row = unpermute_row(self.meta, storage);
self.conn
.compute_generated(self.meta, &mut row, &Params::default())?;
self.current = row;
Ok(())
}
}
impl vdbe::Cursor0Source for WithoutRowidLiveCursor<'_> {
fn rewind(&mut self) -> Result<bool> {
// The `IndexCursor` starts before the first entry, so the first `next`
// positions at (and yields) the first row.
match self.cur.next()? {
Some(payload) => {
self.load(&payload)?;
Ok(true)
}
None => {
self.current = Vec::new();
Ok(false)
}
}
}
fn advance(&mut self) -> Result<bool> {
match self.cur.next()? {
Some(payload) => {
self.load(&payload)?;
Ok(true)
}
None => {
self.current = Vec::new();
Ok(false)
}
}
}
fn column(&self, col: usize) -> Value {
self.current.get(col).cloned().unwrap_or(Value::Null)
}
}
/// The [`vdbe::SubqueryEval`] callback for the live single-table scan (B5c-2): it
/// re-evaluates a *correlated* subquery per outer row by pushing that row as an
/// outer frame and re-running the subquery through the tree-walker — the exact
/// same mechanism the tree-walker uses for its own correlated subqueries
/// (`with_outer_frame` → `run_select`), so the value matches the tree-walker and
/// SQLite. The subquery's own body re-enters `run_core` with a non-empty
/// `outer_scope`, so it uses the tree-walker (not a nested VDBE), and an
/// outer-qualified reference resolves against this frame via `resolve_outer`.
struct LiveSubqueryEval<'a> {
conn: &'a Connection,
/// Column metadata for the outer scan's visible columns (index-aligned with
/// the current cursor row's leading slots), tagged with the table qualifier.
columns: &'a [ColumnInfo],
/// The cursor row index of the hidden trailing rowid (== number of visible
/// columns), when the scan carries one.
rowid_index: Option<usize>,
}
impl LiveSubqueryEval<'_> {
/// Read the current outer row (visible column values + optional rowid) from the
/// live cursor and run `body` with that row pushed as an outer frame. Restores
/// the frame on every exit, mirroring [`Connection::with_outer_frame`].
fn with_frame<T>(
&self,
cur: &dyn vdbe::Cursor0Source,
body: impl FnOnce() -> Result<T>,
) -> Result<T> {
let row: Vec<Value> = (0..self.columns.len()).map(|i| cur.column(i)).collect();
let rowid = self.rowid_index.and_then(|i| match cur.column(i) {
Value::Integer(r) => Some(r),
_ => None,
});
self.conn.outer_scope.borrow_mut().push(OuterFrame {
columns: self.columns.to_vec(),
row,
rowid,
});
let out = body();
self.conn.outer_scope.borrow_mut().pop();
out
}
}
impl vdbe::SubqueryEval for LiveSubqueryEval<'_> {
fn scalar(&self, sel: &Select, cur: &dyn vdbe::Cursor0Source) -> Result<Value> {
self.with_frame(cur, || {
let params = Params::default();
let r = self.conn.run_select(sel, ¶ms)?;
// A scalar subquery must yield exactly one column (SQLite rejects
// `(SELECT 1, 2)` at prepare); mirror the tree-walker's `scalar`.
if r.columns.len() > 1 {
return Err(Error::Error(alloc::format!(
"sub-select returns {} columns - expected 1",
r.columns.len()
)));
}
Ok(r.rows
.first()
.and_then(|row| row.first())
.cloned()
.unwrap_or(Value::Null))
})
}
fn exists(&self, sel: &Select, cur: &dyn vdbe::Cursor0Source) -> Result<bool> {
self.with_frame(cur, || {
let params = Params::default();
Ok(!self.conn.run_select(sel, ¶ms)?.rows.is_empty())
})
}
}
struct TableMeta {
root: u32,
columns: Vec<ColumnInfo>,
/// Per-column `DEFAULT` expression, if declared (aligned with `columns`).
defaults: Vec<Option<Expr>>,
/// Per-column `NOT NULL` flag (aligned with `columns`).
/// `None` = nullable; `Some(action)` = `NOT NULL` with its `ON CONFLICT` action.
not_null: Vec<Option<OnConflict>>,
/// CHECK constraint expressions (column-level and table-level).
/// CHECK constraints with their error-message label (name or source text).
checks: Vec<(Expr, Option<String>)>,
/// Column-index sets that must be UNIQUE (excludes the rowid IPK), each with
/// its declared `ON CONFLICT` action (default `Abort`) and per-column `DESC`
/// flags (aligned with the column positions; `true` = descending). The `DESC`
/// flags order the auto-created `sqlite_autoindex_*` b-tree.
unique: Vec<(Vec<usize>, OnConflict, Vec<bool>)>,
ipk: Option<usize>,
/// Per-column generated-column spec `(expr, stored)`, if the column is
/// `… AS (expr) [STORED|VIRTUAL]`. `VIRTUAL` (stored = false) columns are not
/// written to disk; `STORED` ones are. Aligned with `columns`.
generated: Vec<Option<(Expr, bool)>>,
/// `true` for a `WITHOUT ROWID` table (stored as a PK-clustered index b-tree
/// rather than a rowid table b-tree).
without_rowid: bool,
/// For a `WITHOUT ROWID` table, the on-disk column order: PRIMARY KEY columns
/// first (in key order), then the remaining columns in declared order. Empty
/// for ordinary rowid tables. `pk_len` is how many leading entries are PK.
storage_order: Vec<usize>,
pk_len: usize,
/// For a `WITHOUT ROWID` table, each PRIMARY KEY column's declared `DESC` flag,
/// aligned with `storage_order[..pk_len]` (`true` = descending). The clustered
/// b-tree is ordered by the PK honouring these directions, so every insert and
/// every seek/scan on `root` passes this same slice to the index writer/reader
/// (via [`TableMeta::pk_descs`]) — the per-root consistency invariant. Empty
/// for an ordinary rowid table.
pk_descending: Vec<bool>,
/// For a `STRICT` table, each column's rigid type and its declared type name
/// (aligned with `columns`); `None` for an ordinary table. Drives write-time
/// type checking.
strict_types: Option<Vec<(StrictType, String)>>,
/// `true` when the `INTEGER PRIMARY KEY` is declared `AUTOINCREMENT`: assigned
/// rowids never reuse a value below the high-water mark persisted in
/// `sqlite_sequence`, matching SQLite.
autoincrement: bool,
}
/// Return a copy of `sel` with any `*` / `table.*` result column expanded to
/// explicit table-qualified column references drawn from `columns`. Used by the
/// aggregate path so bare wildcards follow the same representative-row rule as
/// named bare columns.
/// Rewrite `e`, replacing every reference to a *left*-table column with `NULL`
/// (used to build the anti-join arm of a FULL-join seek, where the left side is
/// null-padded). A column is a left column when it is qualified with one of
/// `a_quals`, or is unqualified and not one of the right table's `b_cols`.
/// Returns `None` for a shape the rewriter does not handle (a subquery, row
/// value, windowed/filtered/ordered aggregate, …), so the caller defers.
fn null_out_a_columns(e: &Expr, a_quals: &[String], b_cols: &[String]) -> Option<Expr> {
use sql::ast::Expr as E;
let null = || E::Literal(sql::ast::Literal::Null);
let rw = |x: &Expr| null_out_a_columns(x, a_quals, b_cols);
Some(match e {
E::Literal(_) | E::Parameter(_) => e.clone(),
E::Column { table, column, .. } => {
let is_left = match table {
Some(t) => a_quals.iter().any(|q| q.eq_ignore_ascii_case(t)),
None => !b_cols.iter().any(|c| c.eq_ignore_ascii_case(column)),
};
if is_left { null() } else { e.clone() }
}
E::Unary { op, expr } => E::Unary {
op: *op,
expr: Box::new(rw(expr)?),
},
E::Binary { op, left, right } => E::Binary {
op: *op,
left: Box::new(rw(left)?),
right: Box::new(rw(right)?),
},
E::Paren(i) => E::Paren(Box::new(rw(i)?)),
E::Cast { expr, type_name } => E::Cast {
expr: Box::new(rw(expr)?),
type_name: type_name.clone(),
},
E::Collate { expr, collation } => E::Collate {
expr: Box::new(rw(expr)?),
collation: collation.clone(),
},
E::IsNull { expr, negated } => E::IsNull {
expr: Box::new(rw(expr)?),
negated: *negated,
},
E::Function {
name,
distinct,
args,
star,
filter,
order_by,
over,
..
} => {
if over.is_some() || filter.is_some() || !order_by.is_empty() {
return None;
}
let mut new_args = Vec::with_capacity(args.len());
for a in args {
new_args.push(rw(a)?);
}
E::Function {
name: name.clone(),
distinct: *distinct,
args: new_args,
star: *star,
filter: None,
order_by: Vec::new(),
over: None,
span: Span::none(),
}
}
E::Between {
expr,
low,
high,
negated,
} => E::Between {
expr: Box::new(rw(expr)?),
low: Box::new(rw(low)?),
high: Box::new(rw(high)?),
negated: *negated,
},
E::InList {
expr,
list,
negated,
candidate_affinity,
} => {
let mut new_list = Vec::with_capacity(list.len());
for x in list {
new_list.push(rw(x)?);
}
E::InList {
expr: Box::new(rw(expr)?),
list: new_list,
negated: *negated,
candidate_affinity: candidate_affinity.clone(),
}
}
E::Case {
operand,
when_then,
else_result,
} => {
let operand = match operand {
Some(o) => Some(Box::new(rw(o)?)),
None => None,
};
let mut wt = Vec::with_capacity(when_then.len());
for (w, t) in when_then {
wt.push((rw(w)?, rw(t)?));
}
let else_result = match else_result {
Some(x) => Some(Box::new(rw(x)?)),
None => None,
};
E::Case {
operand,
when_then: wt,
else_result,
}
}
// Subqueries, row values, and anything else are not rewritten.
_ => return None,
})
}
fn expand_agg_wildcards(sel: &Select, columns: &[ColumnInfo]) -> Select {
let col_ref = |c: &ColumnInfo| ResultColumn::Expr {
expr: Expr::Column {
schema: None,
table: Some(c.table.clone()),
column: c.name.clone(),
quoted: false,
span: Span::none(),
},
alias: None,
source: None,
};
let mut new_cols = Vec::new();
for col in &sel.columns {
match col {
ResultColumn::Wildcard => {
new_cols.extend(columns.iter().filter(|c| !c.hidden).map(&col_ref))
}
ResultColumn::TableWildcard(t) => new_cols.extend(
columns
.iter()
.filter(|c| !c.hidden && c.table.eq_ignore_ascii_case(t))
.map(&col_ref),
),
other => new_cols.push(other.clone()),
}
}
let mut s = sel.clone();
s.columns = new_cols;
s
}
/// If `sel`'s WHERE/GROUP BY/HAVING reference any SELECT-list alias that is not
/// shadowed by a real input column, return a copy of `sel` with those alias
/// references replaced by their defining expressions (SQLite resolves aliases in
/// these clauses, with real columns winning). Returns `None` when no rewrite is
/// needed, so the common path clones nothing.
fn alias_substituted(sel: &Select, columns: &[ColumnInfo]) -> Option<Select> {
// Explicit `AS` aliases that don't collide with a real input column name.
let mut aliases: Vec<(String, Expr)> = Vec::new();
for c in &sel.columns {
if let ResultColumn::Expr {
expr,
alias: Some(name),
..
} = c
&& !columns
.iter()
.any(|col| col.name.eq_ignore_ascii_case(name))
&& !aliases.iter().any(|(a, _)| a.eq_ignore_ascii_case(name))
{
aliases.push((name.clone(), expr.clone()));
}
}
if aliases.is_empty() {
return None;
}
// Only rewrite if a clause actually references one of those aliases.
let mentions = |e: &Expr| -> bool {
let mut found = false;
window::visit(e, &mut |n| {
if let Expr::Column {
table: None,
column,
..
} = n
&& aliases.iter().any(|(a, _)| a.eq_ignore_ascii_case(column))
{
found = true;
}
});
found
};
let used = sel.where_clause.as_ref().is_some_and(&mentions)
|| sel.group_by.iter().any(&mentions)
|| sel.having.as_ref().is_some_and(&mentions);
if !used {
return None;
}
let mut out = sel.clone();
let apply = |e: &mut Expr| {
for (name, repl) in &aliases {
let target = Expr::Column {
schema: None,
table: None,
column: name.clone(),
quoted: false,
span: Span::none(),
};
window::replace_expr(e, &target, repl);
}
};
if let Some(w) = &mut out.where_clause {
apply(w);
}
for g in &mut out.group_by {
apply(g);
}
if let Some(h) = &mut out.having {
apply(h);
}
Some(out)
}
/// Wrap a runtime [`Value`] as a literal [`Expr`], so rows produced by an
/// `INSERT … SELECT` can flow through the ordinary VALUES insert path.
/// SQLite's two distinct INSERT value-count error messages. With an explicit
/// column list it reports `{n_vals} values for {n_cols} columns`; for a bare
/// `INSERT` (implicit column list, including `INSERT … SELECT`) it reports
/// `table {table} has {n_cols} columns but {n_vals} values were supplied`, where
/// `n_cols` is the number of (non-generated) target columns.
fn insert_count_mismatch(
table: &str,
explicit_columns: bool,
n_cols: usize,
n_vals: usize,
) -> Error {
if explicit_columns {
Error::Error(alloc::format!("{n_vals} values for {n_cols} columns"))
} else {
Error::Error(alloc::format!(
"table {table} has {n_cols} columns but {n_vals} values were supplied"
))
}
}
fn value_to_literal_expr(v: Value) -> Expr {
Expr::Literal(match v {
Value::Null => Literal::Null,
Value::Integer(i) => Literal::Integer(i),
Value::Real(r) => Literal::Real(r),
Value::Text(s) => Literal::Str(s.as_str().to_string()),
Value::Blob(b) => Literal::Blob(b),
})
}
/// Whether `name` refers to the main schema catalog table, which SQLite exposes
/// under both the modern `sqlite_schema` and the historical `sqlite_master`.
fn is_main_schema_table(name: &str) -> bool {
name.eq_ignore_ascii_case("sqlite_schema") || name.eq_ignore_ascii_case("sqlite_master")
}
/// Whether SQLite exposes the pragma `bare` (the name after the `pragma_` prefix)
/// as an eponymous table-valued function — i.e. whether `SELECT * FROM
/// pragma_<bare>` is a valid `FROM` source rather than `no such table`.
///
/// SQLite builds a `pragma_<name>` virtual table for every *result-returning*
/// pragma it knows, with a handful of statement-only exceptions
/// (`wal_checkpoint`, `mmap_size`, …) whose TVF form is rejected. This is the set
/// of pragmas graphite both implements (in `run_pragma`) and SQLite 3.50.4
/// exposes — keep it in lockstep with `run_pragma`'s arms. An unrecognized name
/// (a typo, or a real pragma graphite does not implement) is not a TVF either.
/// Collect a top-level `<label-qualified-or-bare> <col> = <constant>` equality
/// out of a `WHERE` predicate (descending through `AND` and parentheses), used to
/// drive a bare eponymous table-valued function from `WHERE arg=…` / `json=…`.
/// Only the first match is taken; the constant must be a literal or bound
/// parameter (so it evaluates without row context). See
/// [`Connection::push_bare_tvf_args`].
fn collect_tvf_eq(e: &Expr, label: &str, col: &str, out: &mut Option<Expr>) {
if out.is_some() {
return;
}
match e {
Expr::Paren(inner) => collect_tvf_eq(inner, label, col, out),
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => {
collect_tvf_eq(left, label, col, out);
collect_tvf_eq(right, label, col, out);
}
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => {
for (side, other) in [(left, right), (right, left)] {
if is_tvf_hidden_col(side, label, col) && is_const_arg(other) {
*out = Some((**other).clone());
return;
}
}
}
_ => {}
}
}
/// Whether `e` names the hidden column `col` of a pragma TVF labelled `label`
/// (either bare `arg` or `label.arg`, case-insensitive, unquoted or not).
fn is_tvf_hidden_col(e: &Expr, label: &str, col: &str) -> bool {
matches!(
e,
Expr::Column { table, column, .. }
if column.eq_ignore_ascii_case(col)
&& table.as_deref().is_none_or(|t| t.eq_ignore_ascii_case(label))
)
}
/// Whether `e` is a constant the pragma-TVF pushdown may consume as an argument:
/// a literal or a bound parameter (both evaluate without a current row).
fn is_const_arg(e: &Expr) -> bool {
match e {
Expr::Literal(_) | Expr::Parameter(_) => true,
// A signed / bit-negated constant (`-2`, `+3`, `~0`) or a parenthesized one
// is still row-independent, so `WHERE step = -2` drives the pushdown.
Expr::Unary { expr, .. } | Expr::Paren(expr) => is_const_arg(expr),
_ => false,
}
}
/// The pragmas graphite implements, spelled and ordered as sqlite's
/// `PRAGMA pragma_list` reports them (alphabetically). This is graphite's own
/// supported set — a subset of sqlite's `aPragmaName[]` — not a copy of a
/// particular sqlite build's list. Keep it in step with `run_pragma`'s arms.
const PRAGMA_LIST: &[&str] = &[
"analysis_limit",
"application_id",
"auto_vacuum",
"automatic_index",
"busy_timeout",
"cache_size",
"case_sensitive_like",
"cell_size_check",
"checkpoint_fullfsync",
"collation_list",
"compile_options",
"count_changes",
"data_version",
"database_list",
"defer_foreign_keys",
"empty_result_callbacks",
"encoding",
"foreign_key_check",
"foreign_key_list",
"foreign_keys",
"freelist_count",
"full_column_names",
"fullfsync",
"function_list",
"hard_heap_limit",
"ignore_check_constraints",
"incremental_vacuum",
"index_info",
"index_list",
"index_xinfo",
"integrity_check",
"journal_mode",
"journal_size_limit",
"legacy_alter_table",
"legacy_file_format",
"locking_mode",
"max_page_count",
"mmap_size",
"module_list",
"optimize",
"page_count",
"page_size",
"pragma_list",
"query_only",
"quick_check",
"read_uncommitted",
"recursive_triggers",
"reverse_unordered_selects",
"schema_version",
"secure_delete",
"short_column_names",
"soft_heap_limit",
"synchronous",
"table_info",
"table_list",
"table_xinfo",
"temp_store",
"threads",
"user_version",
"wal_autocheckpoint",
"wal_checkpoint",
"writable_schema",
];
/// The virtual-table modules graphite makes available — its built-in registry
/// (`VTabRegistry::with_builtins`) plus the eponymous table-valued modules the
/// executor resolves directly in a `FROM` clause. sqlite's `PRAGMA module_list`
/// reports whichever modules its build registered; this is graphite's honest
/// equivalent (fts5 is feature-gated), sorted alphabetically for determinism.
fn module_list_names() -> alloc::vec::Vec<&'static str> {
let mut names = alloc::vec![
"dbstat",
"generate_series",
"geopoly",
"json_each",
"json_tree",
"rtree",
"rtree_i32",
"series",
"sqlite_dbpage",
];
#[cfg(feature = "fts5")]
{
names.push("fts5");
names.push("fts5vocab");
}
names.sort_unstable();
names
}
/// graphite's real compile-time options — the optional capabilities actually
/// built into this binary. Reported by `PRAGMA compile_options` using sqlite's
/// recognizable `ENABLE_*` spellings, but the *content* reflects graphite's own
/// feature set (never a copy of a particular sqlite build's list). Alphabetical.
fn compile_option_names() -> alloc::vec::Vec<&'static str> {
let mut opts = alloc::vec![
"ENABLE_DBSTAT_VTAB",
"ENABLE_GEOPOLY",
"ENABLE_JSON1",
"ENABLE_MATH_FUNCTIONS",
"ENABLE_RTREE",
];
#[cfg(feature = "fts5")]
opts.push("ENABLE_FTS5");
#[cfg(feature = "unicode")]
opts.push("ENABLE_ICU");
opts.sort_unstable();
opts
}
fn pragma_has_tvf(bare: &str) -> bool {
// Names checked case-insensitively; `bare` arrives lowercased from the caller
// but normalize defensively.
const TVF_PRAGMAS: &[&str] = &[
"analysis_limit",
"application_id",
"auto_vacuum",
"automatic_index",
"busy_timeout",
"cache_size",
"cell_size_check",
"checkpoint_fullfsync",
"collation_list",
"compile_options",
"data_version",
"database_list",
"encoding",
"foreign_key_check",
"foreign_key_list",
"foreign_keys",
"freelist_count",
"fullfsync",
"function_list",
"hard_heap_limit",
"ignore_check_constraints",
"index_info",
"index_list",
"index_xinfo",
"integrity_check",
"journal_mode",
"journal_size_limit",
"locking_mode",
"max_page_count",
"module_list",
"optimize",
"page_count",
"page_size",
"pragma_list",
"query_only",
"quick_check",
"read_uncommitted",
"recursive_triggers",
"schema_version",
"secure_delete",
"short_column_names",
"synchronous",
"table_info",
"table_list",
"table_xinfo",
"temp_store",
"user_version",
];
let lname = bare.to_ascii_lowercase();
TVF_PRAGMAS.contains(&lname.as_str())
}
/// Whether `name` is the temp-database catalog (`sqlite_temp_schema` /
/// `sqlite_temp_master`), which reads the `temp` database's `sqlite_master`.
fn is_temp_schema_table(name: &str) -> bool {
name.eq_ignore_ascii_case("sqlite_temp_schema")
|| name.eq_ignore_ascii_case("sqlite_temp_master")
}
/// Reject a direct DML write to a schema catalog, as SQLite does (the catalog is
/// maintained by DDL, not by `INSERT`/`UPDATE`/`DELETE`). Covers both the main
/// catalog (`sqlite_master` / `sqlite_schema`) and the temp catalog
/// (`sqlite_temp_master` / `sqlite_temp_schema`); SQLite spells each canonically
/// in the message regardless of the alias written and rejects it before the
/// table-existence check (so a temp catalog with no temp database still errors
/// `table sqlite_temp_master may not be modified`, not `no such table`).
fn reject_schema_write(table: &str) -> Result<()> {
if let Some(display) = schema_catalog_display_name(table) {
return Err(Error::Error(alloc::format!(
"table {display} may not be modified"
)));
}
Ok(())
}
/// The canonical spelling SQLite uses in `table <X> may not be …` messages for
/// the schema catalog (`sqlite_master`, or `sqlite_temp_master` for the temp
/// catalog), regardless of how the alias was written. `None` for everything else.
fn schema_catalog_display_name(name: &str) -> Option<&'static str> {
if is_main_schema_table(name) {
Some("sqlite_master")
} else if is_temp_schema_table(name) {
Some("sqlite_temp_master")
} else {
None
}
}
/// SQLite accepts `ORDER BY` on an UPDATE/DELETE only as a companion to `LIMIT`
/// (the update/delete-limit extension): the order picks *which* rows the limit
/// keeps. An `ORDER BY` with no `LIMIT` is therefore meaningless and rejected at
/// prepare time with `ORDER BY without LIMIT on <VERB>`. This fires after the
/// target's existence / view / vtab checks but before column resolution, so a
/// bogus `ORDER BY` or `SET` column never shadows it. `verb` is `"UPDATE"` or
/// `"DELETE"`.
fn reject_order_by_without_limit(
order_by: &[OrderTerm],
limit: Option<&Expr>,
verb: &str,
) -> Result<()> {
if !order_by.is_empty() && limit.is_none() {
return Err(Error::Error(alloc::format!(
"ORDER BY without LIMIT on {verb}"
)));
}
Ok(())
}
/// A synthetic [`TableMeta`] for the schema catalog (`sqlite_schema`): the
/// 5-column rowid table physically rooted at page 1. Read-only — writes are
/// rejected before reaching here.
fn schema_table_meta(label: &str) -> TableMeta {
let col = |n: &str, aff: eval::Affinity| ColumnInfo {
name: n.to_string(),
table: label.to_string(),
affinity: aff,
collation: crate::value::Collation::default(),
schema: None,
hidden: false,
};
let columns = alloc::vec![
col("type", eval::Affinity::Text),
col("name", eval::Affinity::Text),
col("tbl_name", eval::Affinity::Text),
col("rootpage", eval::Affinity::Integer),
col("sql", eval::Affinity::Text),
];
let n = columns.len();
TableMeta {
root: crate::schema::SCHEMA_ROOT_PAGE,
columns,
defaults: alloc::vec![None; n],
not_null: alloc::vec![None; n],
checks: Vec::new(),
unique: Vec::new(),
ipk: None,
generated: alloc::vec![None; n],
without_rowid: false,
storage_order: Vec::new(),
pk_len: 0,
pk_descending: Vec::new(),
strict_types: None,
autoincrement: false,
}
}
/// The first column reference in `e` that names neither a column in `known` nor
/// Validate the explicit `COLLATE <name>`s in `sel` that are actually CONSUMED
/// for ordering/comparison (sqlite errors "no such collation sequence" there, but
/// not on an unused projection COLLATE). Covers comparisons, `ORDER BY`/
/// `GROUP BY`/`DISTINCT` keys, `IN`/`BETWEEN`, `CASE x WHEN`, and `min`/`max`.
/// Nested subqueries are not walked here — they validate themselves when run.
fn validate_used_collations(sel: &Select) -> Result<()> {
for (_, arm) in &sel.compound {
validate_used_collations(arm)?;
}
if let Some(w) = &sel.where_clause {
consumed_collations(w)?;
}
if let Some(h) = &sel.having {
consumed_collations(h)?;
}
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
consumed_collations(on)?;
}
}
}
for t in &sel.order_by {
top_collation(&t.expr)?;
consumed_collations(&t.expr)?;
}
for g in &sel.group_by {
top_collation(g)?;
consumed_collations(g)?;
}
for c in &sel.columns {
if let ResultColumn::Expr { expr, .. } = c {
if sel.distinct {
top_collation(expr)?;
}
consumed_collations(expr)?;
}
}
Ok(())
}
/// SQLite rejects a column reference that matches columns from two different
/// FROM sources — "ambiguous column name". `columns` is this query block's
/// resolved column list; a NATURAL/USING join already coalesces its shared
/// column to a single entry there, so a plain count over `columns` excludes
/// them. A bare name matching 2+ entries, or a `t.col` whose qualifier matches
/// 2+ entries (an unaliased self-join), is ambiguous. A result-set wildcard over
/// an unaliased self-join is ambiguous too — two entries then share *both* name
/// and qualifier, which even `*` cannot tell apart. Nested subqueries validate
/// their own references when they run, so this neither descends into them nor
/// considers the outer scope. `qualify_wildcard` maps an offending wildcard
/// source's effective name to the `<db>.<table>` / `*.<alias>` origin prefix
/// SQLite prints (the bare name suffices for callers that ignore the message).
fn validate_unambiguous_columns(
sel: &Select,
columns: &[ColumnInfo],
qualify_wildcard: &dyn Fn(&str) -> alloc::string::String,
) -> Result<()> {
let mut ambiguous: Option<String> = None;
vdbe_block_exprs(sel, &mut |e| {
window::visit(e, &mut |sub| {
if ambiguous.is_some() {
return;
}
if let Expr::Column {
schema,
table,
column,
..
} = sub
{
let n = columns
.iter()
.filter(|c| {
// Hidden per-table rowid slots never count toward ambiguity
// (a real `rowid` column plus the hidden one is not a clash).
!c.hidden
&& c.name.eq_ignore_ascii_case(column)
&& table
.as_deref()
.is_none_or(|t| c.table.eq_ignore_ascii_case(t))
// A three-part `db.table.column` reference distinguishes
// two same-named tables in different databases, so a
// schema qualifier narrows the count by origin database
// (an unknown origin — derived/CTE/synthetic — matches
// any qualifier, staying conservative). Matches the
// `column_resolves_scoped` rule.
&& schema.as_deref().is_none_or(|s| {
c.schema.as_deref().is_none_or(|cs| cs.eq_ignore_ascii_case(s))
})
})
.count();
if n >= 2 {
// SQLite names the offending column exactly as written: a
// three-part `schema.table.column`, a `table.column`, or a
// bare `column`.
let name = match (schema, table) {
(Some(s), Some(t)) => alloc::format!("{s}.{t}.{column}"),
(_, Some(t)) => alloc::format!("{t}.{column}"),
_ => column.clone(),
};
ambiguous = Some(alloc::format!("ambiguous column name: {name}"));
}
}
});
});
if let Some(msg) = ambiguous {
return Err(Error::Error(msg));
}
// A result-set wildcard (`*` / `t.*`) over an unaliased self-join: two
// columns then carry the same name *and* qualifier, so even `*` cannot
// disambiguate them (`SELECT * FROM z, z`).
let has_wildcard = sel
.columns
.iter()
.any(|c| matches!(c, ResultColumn::Wildcard | ResultColumn::TableWildcard(_)));
if has_wildcard {
for (i, a) in columns.iter().enumerate() {
if a.hidden {
continue;
}
if let Some(b) = columns[i + 1..].iter().find(|b| {
!b.hidden
&& a.name.eq_ignore_ascii_case(&b.name)
&& a.table.eq_ignore_ascii_case(&b.table)
// Two same-named, same-table columns are only ambiguous when
// they share a database of origin: `SELECT * FROM t, aux.t`
// keeps `main.t.a` and `aux.t.a` distinct.
&& match (&a.schema, &b.schema) {
(Some(x), Some(y)) => x.eq_ignore_ascii_case(y),
(None, None) => true,
_ => false,
}
}) {
// SQLite qualifies a `*`-expanded ambiguous column by its source's
// origin: `<db>.<table>` for a real table (`main.t.a`), or `*.<alias>`
// for a derived table / CTE that has no database (`*.x.a`).
return Err(Error::Error(alloc::format!(
"ambiguous column name: {}.{}",
qualify_wildcard(&b.table),
b.name
)));
}
}
}
Ok(())
}
/// Collect the immediately-nested subquery `SELECT`s of `e` (scalar `(SELECT …)`,
/// `EXISTS`, and `IN (SELECT …)`), descending through ordinary sub-expressions but
/// NOT into the collected subqueries' own bodies — each is recursed into
/// separately, with its own scope. Lifetime-preserving (unlike `window::visit`) so
/// the borrowed `&Select`s outlive the walk.
/// The expression of each `RETURNING` result column (skipping `*` / `tbl.*`
/// wildcards, which carry no `Expr`). Borrowed, so the refs outlive the call.
fn returning_exprs(returning: &[ResultColumn]) -> Vec<&Expr> {
returning
.iter()
.filter_map(|c| match c {
ResultColumn::Expr { expr, .. } => Some(expr),
_ => None,
})
.collect()
}
/// Visit every column reference in `e` that resolves in this query's own `FROM`
/// scope, calling `f(table_qualifier, column_name)` for each. Deliberately does
/// not descend into a `Subquery`/`Exists`/`InSelect` body: a name there binds in
/// that subquery's scope (with this query merely an outer fallback), so it must
/// not be checked against this query's column list. Used by
/// [`Executor::validate_columns_exist`] for an eager "no such column" check.
/// Whether a (`table`-qualified or bare) `column` reference resolves against
/// `cols` — a name match, with the table also matching when qualified. A rowid
/// alias and the date/time keyword pseudo-columns resolve without appearing in
/// `cols`. Used by the IN/scalar-subquery arity gates to confirm a body is
/// column-clean before reporting an arity mismatch (so a `no such column`, which
/// SQLite reports first, is never masked).
fn column_resolves(cols: &[ColumnInfo], table: Option<&str>, column: &str) -> bool {
if matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "oid" | "_rowid_" | "current_date" | "current_time" | "current_timestamp"
) {
return true;
}
cols.iter().any(|c| {
c.name.eq_ignore_ascii_case(column) && table.is_none_or(|t| c.table.eq_ignore_ascii_case(t))
})
}
/// Schema-aware sibling of [`column_resolves`] for the correlated-subquery body
/// check ([`Executor::validate_subquery_body_columns`]): a three-part
/// `schema.table.column` reference must also match a candidate column's database
/// of origin (`ColumnInfo::schema`). A candidate whose origin is unknown
/// (`schema: None` — a derived/CTE/subquery/synthetic source) matches any
/// qualifier, so the check stays conservative and never raises a spurious
/// `no such column` on a valid reference into such a source.
fn column_resolves_scoped(
cols: &[ColumnInfo],
schema: Option<&str>,
table: Option<&str>,
column: &str,
) -> bool {
let schema_ok = |c: &ColumnInfo| {
schema.is_none_or(|s| {
c.schema
.as_deref()
.is_none_or(|cs| cs.eq_ignore_ascii_case(s))
})
};
if matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "oid" | "_rowid_" | "current_date" | "current_time" | "current_timestamp"
) {
// A bare date/time keyword (or rowid alias) always resolves; a qualified
// one still needs an in-scope source matching the qualifier.
let Some(t) = table else {
return true;
};
return cols
.iter()
.any(|c| c.table.eq_ignore_ascii_case(t) && schema_ok(c));
}
cols.iter().any(|c| {
c.name.eq_ignore_ascii_case(column)
&& table.is_none_or(|t| c.table.eq_ignore_ascii_case(t))
&& schema_ok(c)
})
}
/// SQLite's `sqlite3LogEstAdd(a, b)` — the LogEst of the sum of two values whose
/// LogEsts are `a` and `b` (i.e. `LogEst(2^(a/10) + 2^(b/10))`). Ported verbatim
/// from `where.c` so index-seek and full-scan costs add exactly as SQLite's.
fn logest_add(a: i16, b: i16) -> i16 {
const X: [i16; 32] = [
10, 10, 9, 9, 8, 8, 7, 7, 7, 6, 6, 6, 5, 5, 5, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2,
2, 2,
];
if a >= b {
if a > b + 49 {
a
} else if a > b + 31 {
a + 1
} else {
a + X[(a - b) as usize]
}
} else if b > a + 49 {
b
} else if b > a + 31 {
b + 1
} else {
b + X[(b - a) as usize]
}
}
/// SQLite's `estLog(N)` — an estimate of `log2(N)` in LogEst units, used to price
/// the cost of one binary-search seek into an index/table (`where.c`).
fn est_log(n: i16) -> i16 {
if n <= 10 { 0 } else { logest(n as u64) - 33 }
}
/// SQLite's `sqlite3LogEst` — an integer approximation of `10*log2(x)`, the unit
/// the query planner costs rows and row-widths in. Ported verbatim so a covering
/// index's estimated width can be compared exactly the way SQLite does.
fn logest(mut x: u64) -> i16 {
const A: [i16; 8] = [0, 2, 3, 5, 6, 7, 8, 9];
let mut y: i16 = 40;
if x < 8 {
if x < 2 {
return 0;
}
while x < 8 {
y -= 10;
x <<= 1;
}
} else {
while x > 255 {
y += 40;
x >>= 4;
}
while x > 15 {
y += 10;
x >>= 1;
}
}
A[(x & 7) as usize] + y - 10
}
/// The estimated per-column size SQLite records (`estimateTableWidth` via
/// `sqlite3AffinityType`), scaled so an integer/real/numeric or untyped column is
/// `1`. A `TEXT`/`BLOB`/`CLOB`/`CHAR` with no size is `5`; a sized `VARCHAR(k)` /
/// `CHAR(k)` / `BLOB(k)` is `k/4 + 1` (capped at 255). Only TEXT/BLOB-affinity
/// columns carry a size; numeric affinities are always `1`.
/// SQLite's `sqlite3IndexAffinityOk` for an equi-join `inner_col = outer_col`
/// index seek: the comparison affinity of two columns is NUMERIC when either side
/// is numeric (else BLOB); a NUMERIC comparison can only use a numeric-affinity
/// index column (a text/blob-stored index cannot be numerically seeked), while a
/// BLOB comparison always can. Returns whether the index seek is sound; when it is
/// not, the caller declines the seek (scanning + affinity-correct filtering
/// instead), matching sqlite — otherwise the raw-key seek would drop real matches
/// (e.g. an INTEGER key seeking an untyped index that stores its values as text).
fn index_seek_affinity_ok(outer: eval::Affinity, inner: eval::Affinity) -> bool {
use eval::Affinity::{Integer, Numeric, Real};
let is_numeric = |a| matches!(a, Numeric | Integer | Real);
if is_numeric(outer) || is_numeric(inner) {
is_numeric(inner)
} else {
true
}
}
fn col_szest(type_name: Option<&str>) -> u32 {
let Some(t) = type_name else { return 1 };
if t.trim().is_empty() {
return 1;
}
let up = t.to_ascii_uppercase();
// The first unsigned integer literal in `s`, if any.
fn first_uint(s: &str) -> Option<u32> {
let start = s.find(|c: char| c.is_ascii_digit())?;
let end = s[start..]
.find(|c: char| !c.is_ascii_digit())
.map(|e| start + e)
.unwrap_or(s.len());
s[start..end].parse().ok()
}
let v: u32 = match eval::Affinity::from_type(Some(t)) {
// A size for a text column sits after the "CHAR" token (`VARCHAR(k)`,
// `CHAR(k)`); a bare `TEXT`/`CLOB` carries none → 16 (→ szEst 5).
eval::Affinity::Text => up
.rfind("CHAR")
.and_then(|p| first_uint(&up[p + 4..]))
.unwrap_or(16),
// A `BLOB(k)` size sits immediately after "BLOB("; a bare `BLOB` → 16.
eval::Affinity::Blob => match up.find("BLOB") {
Some(p) if up[p + 4..].starts_with('(') => first_uint(&up[p + 4..]).unwrap_or(16),
_ => 16,
},
_ => 0,
};
(v / 4 + 1).min(255)
}
/// The hidden per-table rowid column contributed by a base rowid table in a join
/// (see [`Connection::resolve_join_source_rowid`]). Named
/// `rowid`, tagged with the table's alias/name, INTEGER affinity, BINARY
/// collation; `hidden` so `*`/`t.*` expansion and column-count skip it. A
/// table-qualified rowid alias resolves to it in `EvalCtx::resolve_column`.
fn hidden_rowid_col(table: &str, schema: Option<String>) -> ColumnInfo {
ColumnInfo {
name: alloc::string::String::from("rowid"),
table: table.to_string(),
schema,
affinity: eval::Affinity::Integer,
collation: crate::value::Collation::Binary,
hidden: true,
}
}
fn walk_shallow_columns(e: &Expr, f: &mut impl FnMut(Option<&str>, Option<&str>, &str, bool)) {
match e {
Expr::Column {
schema,
table,
column,
quoted,
..
} => f(schema.as_deref(), table.as_deref(), column, *quoted),
Expr::Unary { expr, .. } => walk_shallow_columns(expr, f),
Expr::Binary { left, right, .. } => {
walk_shallow_columns(left, f);
walk_shallow_columns(right, f);
}
Expr::Function {
args,
filter,
order_by,
..
} => {
for a in args {
walk_shallow_columns(a, f);
}
if let Some(flt) = filter {
walk_shallow_columns(flt, f);
}
for t in order_by {
walk_shallow_columns(&t.expr, f);
}
}
Expr::IsNull { expr, .. } => walk_shallow_columns(expr, f),
Expr::InList { expr, list, .. } => {
walk_shallow_columns(expr, f);
for a in list {
walk_shallow_columns(a, f);
}
}
// The tested expression of `x [NOT] IN (SELECT …)` is a shallow column of
// *this* scope (the subquery body is validated separately); visit it so a
// bad LHS (`nope IN (SELECT …)`) is caught, like `InList`'s LHS.
Expr::InSelect { expr, .. } => walk_shallow_columns(expr, f),
Expr::Between {
expr, low, high, ..
} => {
walk_shallow_columns(expr, f);
walk_shallow_columns(low, f);
walk_shallow_columns(high, f);
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
walk_shallow_columns(o, f);
}
for (w, t) in when_then {
walk_shallow_columns(w, f);
walk_shallow_columns(t, f);
}
if let Some(el) = else_result {
walk_shallow_columns(el, f);
}
}
Expr::Cast { expr, .. } => walk_shallow_columns(expr, f),
Expr::Collate { expr, .. } => walk_shallow_columns(expr, f),
Expr::Paren(inner) => walk_shallow_columns(inner, f),
Expr::RowValue(items) => {
for it in items {
walk_shallow_columns(it, f);
}
}
_ => {}
}
}
/// Reject any column reference in a *scopeless* expression — a `LIMIT` or
/// `OFFSET`, which SQLite evaluates with no table columns in scope (not even a
/// correlated outer column). The first shallow column reference is therefore
/// `no such column: NAME`, reported ahead of any aggregate-misuse or
/// unknown-function error the same expression would otherwise raise. A nested
/// `SELECT` has its own scope and is not descended (so `LIMIT (SELECT …)` and a
/// scalar/`IN` subquery limit are untouched).
fn reject_scopeless_column_ref(e: &Expr) -> Result<()> {
let mut err: Option<Error> = None;
walk_shallow_columns(e, &mut |schema, table, column, quoted| {
if err.is_none() {
err = Some(eval::no_such_column(schema, table, column, quoted));
}
});
match err {
Some(e) => Err(e),
None => Ok(()),
}
}
/// Reject a `FROM`-less query that projects a wildcard, as SQLite does at prepare
/// time: a bare `*` with no `FROM` is `no tables specified`, and a qualified
/// `X.*` is `no such table: X` (the qualifier can name no source). The tree-walker
/// would instead expand the wildcard to zero columns and return a row, silently
/// accepting it. SQLite gives this the highest resolution precedence — it wins
/// over a missing `LIMIT` column, a wrong-arity aggregate, and a compound
/// column-count mismatch — so this runs first, before any other check.
///
/// Walks the whole query tree from the outermost level: each compound arm, every
/// derived-table subquery in a `FROM`, and every expression-position subquery
/// (scalar / `EXISTS` / `IN (SELECT)`) is checked. A `CTE` definition is *not*
/// descended — SQLite analyzes a CTE lazily, so an unreferenced `WITH c AS
/// (SELECT *)` is accepted.
fn reject_fromless_wildcard(sel: &Select) -> Result<()> {
if sel.from.is_none() {
for c in &sel.columns {
match c {
ResultColumn::Wildcard => {
return Err(Error::Error("no tables specified".into()));
}
ResultColumn::TableWildcard(q) => {
return Err(Error::Error(alloc::format!("no such table: {q}")));
}
ResultColumn::Expr { .. } => {}
}
}
}
for (_, arm) in &sel.compound {
reject_fromless_wildcard(arm)?;
}
if let Some(from) = &sel.from {
if let Some(sub) = &from.first.subquery {
reject_fromless_wildcard(sub)?;
}
for j in &from.joins {
if let Some(sub) = &j.table.subquery {
reject_fromless_wildcard(sub)?;
}
}
}
let mut targets: Vec<&Expr> = Vec::new();
for c in &sel.columns {
if let ResultColumn::Expr { expr, .. } = c {
targets.push(expr);
}
}
if let Some(w) = &sel.where_clause {
targets.push(w);
}
if let Some(h) = &sel.having {
targets.push(h);
}
for g in &sel.group_by {
targets.push(g);
}
for t in &sel.order_by {
targets.push(&t.expr);
}
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
targets.push(on);
}
}
}
let mut subs: Vec<&Select> = Vec::new();
for e in targets {
collect_subselects(e, &mut subs);
}
for sub in subs {
reject_fromless_wildcard(sub)?;
}
Ok(())
}
fn collect_subselects<'a>(e: &'a Expr, out: &mut Vec<&'a Select>) {
match e {
Expr::Subquery(s) => out.push(s),
Expr::Exists { select, .. } => out.push(select),
Expr::InSelect { select, expr, .. } => {
out.push(select);
collect_subselects(expr, out);
}
Expr::Unary { expr, .. } => collect_subselects(expr, out),
Expr::Binary { left, right, .. } => {
collect_subselects(left, out);
collect_subselects(right, out);
}
Expr::Function { args, .. } => {
for a in args {
collect_subselects(a, out);
}
}
Expr::IsNull { expr, .. } => collect_subselects(expr, out),
Expr::InList { expr, list, .. } => {
collect_subselects(expr, out);
for a in list {
collect_subselects(a, out);
}
}
Expr::Between {
expr, low, high, ..
} => {
collect_subselects(expr, out);
collect_subselects(low, out);
collect_subselects(high, out);
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
collect_subselects(o, out);
}
for (w, t) in when_then {
collect_subselects(w, out);
collect_subselects(t, out);
}
if let Some(el) = else_result {
collect_subselects(el, out);
}
}
Expr::Cast { expr, .. } => collect_subselects(expr, out),
Expr::Collate { expr, .. } => collect_subselects(expr, out),
Expr::Paren(inner) => collect_subselects(inner, out),
_ => {}
}
}
/// True if any column reference anywhere in `sel` carries a `schema.` qualifier (a
/// three-part `schema.table.column`). The VDBE fast path resolves columns by
/// table/name only and ignores the database qualifier, so it would silently accept
/// a *wrong* qualifier (`bad.t.col` reading `t.col`). Such a query must defer to
/// the tree-walker, which validates the qualifier against the source's actual
/// database (`no such column: schema.table.column` on a mismatch).
fn select_has_schema_qualified_column(sel: &Select) -> bool {
fn gather<'a>(e: &'a Expr, hit: &mut bool, subs: &mut Vec<&'a Select>) {
walk_shallow_columns(e, &mut |schema, _t, _c, _q| {
if schema.is_some() {
*hit = true;
}
});
collect_subselects(e, subs);
}
let mut hit = false;
let mut subs: Vec<&Select> = Vec::new();
for c in &sel.columns {
if let ResultColumn::Expr { expr, .. } = c {
gather(expr, &mut hit, &mut subs);
}
}
if let Some(from) = &sel.from {
for src in core::iter::once(&from.first).chain(from.joins.iter().map(|j| &j.table)) {
if let Some(sub) = &src.subquery {
subs.push(sub);
}
}
for j in &from.joins {
if let Some(on) = &j.on {
gather(on, &mut hit, &mut subs);
}
}
}
if let Some(w) = &sel.where_clause {
gather(w, &mut hit, &mut subs);
}
for g in &sel.group_by {
gather(g, &mut hit, &mut subs);
}
if let Some(h) = &sel.having {
gather(h, &mut hit, &mut subs);
}
for o in &sel.order_by {
gather(&o.expr, &mut hit, &mut subs);
}
for cte in &sel.ctes {
subs.push(&cte.select);
}
for (_, operand) in &sel.compound {
subs.push(operand);
}
hit || subs.into_iter().any(select_has_schema_qualified_column)
}
/// Whether `sel` references a *table-qualified* rowid alias anywhere in its own
/// clauses (`t.rowid` / `t._rowid_` / `t.oid`). Used to decide, for a join, that
/// each base table must contribute its rowid as a hidden tagged column so the
/// qualified reference resolves per-table (a joined row carries no single rowid).
/// Only this level's clauses are inspected — a nested subquery has its own FROM
/// scope and resolves its own rowids independently.
fn select_references_qualified_rowid(sel: &Select) -> bool {
let mut hit = false;
let mut check = |e: &Expr| {
walk_shallow_columns(e, &mut |_schema, table, column, _quoted| {
if table.is_some() && eval::is_rowid_alias(column) {
hit = true;
}
});
};
for c in &sel.columns {
if let ResultColumn::Expr { expr, .. } = c {
check(expr);
}
}
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
check(on);
}
}
}
if let Some(w) = &sel.where_clause {
check(w);
}
for g in &sel.group_by {
check(g);
}
if let Some(h) = &sel.having {
check(h);
}
for o in &sel.order_by {
check(&o.expr);
}
hit
}
/// Resolve each direct column reference in `sel`'s own clauses against a stack of
/// scopes (innermost first; `scopes[0]` is `sel`'s own FROM columns, the rest are
/// enclosing queries) and return the first name that is ambiguous — i.e. matches
/// 2+ columns in the *nearest* scope that resolves it, mirroring how SQLite binds
/// a name to the innermost scope containing it. A `None` scope (columns that could
/// not be determined statically) stops the walk for that reference: the name might
/// bind there, so we never guess past it — this keeps the check free of false
/// positives. Only this level's own expressions are inspected; nested subqueries
/// are walked separately with their own scope pushed.
fn first_ambiguous_in_scopes(sel: &Select, scopes: &[Option<Vec<ColumnInfo>>]) -> Option<String> {
let mut found: Option<String> = None;
vdbe_block_exprs(sel, &mut |e| {
window::visit(e, &mut |node| {
if found.is_some() {
return;
}
if let Expr::Column { table, column, .. } = node {
for scope in scopes {
let Some(cols) = scope else {
// Unknown scope: the name may bind here — stop, don't guess.
break;
};
let n = cols
.iter()
.filter(|c| {
c.name.eq_ignore_ascii_case(column)
&& table
.as_deref()
.is_none_or(|t| c.table.eq_ignore_ascii_case(t))
})
.count();
if n >= 1 {
// Resolved in this scope; ambiguous iff 2+ here.
if n >= 2 {
found = Some(alloc::format!("ambiguous column name: {column}"));
}
break;
}
}
}
});
});
found
}
/// Validate the top-level explicit `COLLATE` (through redundant parens) of an
/// expression used directly as a comparison/ordering key.
fn top_collation(e: &Expr) -> Result<()> {
match e {
Expr::Collate { collation, expr } => {
if crate::value::resolve_collation_name(collation).is_none() {
return Err(Error::Error(format!(
"no such collation sequence: {collation}"
)));
}
top_collation(expr)
}
Expr::Paren(inner) => top_collation(inner),
_ => Ok(()),
}
}
/// Walk `e`, validating the `COLLATE` of each operand that lands in a
/// collation-consuming position (comparison/`BETWEEN`/`IN`/`CASE x WHEN`/
/// `min`/`max`). A `COLLATE` elsewhere (arithmetic, `||`, an ordinary function
/// argument, a bare projection) is not consumed and so is left unvalidated, as in
/// sqlite. Nested subqueries are not descended into.
fn consumed_collations(e: &Expr) -> Result<()> {
match e {
Expr::Binary { op, left, right } => {
if matches!(
op,
BinaryOp::Eq
| BinaryOp::NotEq
| BinaryOp::Lt
| BinaryOp::LtEq
| BinaryOp::Gt
| BinaryOp::GtEq
) {
top_collation(left)?;
top_collation(right)?;
}
consumed_collations(left)?;
consumed_collations(right)?;
}
Expr::Between {
expr, low, high, ..
} => {
top_collation(expr)?;
top_collation(low)?;
top_collation(high)?;
consumed_collations(expr)?;
consumed_collations(low)?;
consumed_collations(high)?;
}
Expr::InList { expr, list, .. } => {
top_collation(expr)?;
consumed_collations(expr)?;
for it in list {
top_collation(it)?;
consumed_collations(it)?;
}
}
Expr::InSelect { expr, .. } => {
top_collation(expr)?;
consumed_collations(expr)?;
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
// `CASE x WHEN y` compares x to each y.
top_collation(o)?;
consumed_collations(o)?;
for (w, t) in when_then {
top_collation(w)?;
consumed_collations(w)?;
consumed_collations(t)?;
}
} else {
for (w, t) in when_then {
consumed_collations(w)?;
consumed_collations(t)?;
}
}
if let Some(er) = else_result {
consumed_collations(er)?;
}
}
Expr::Function { name, args, .. } => {
// min()/max() (scalar or aggregate) compare their arguments.
let lname = name.to_ascii_lowercase();
if matches!(lname.as_str(), "min" | "max") {
for a in args {
top_collation(a)?;
}
}
for a in args {
consumed_collations(a)?;
}
}
Expr::Unary { expr, .. }
| Expr::Paren(expr)
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. } => consumed_collations(expr)?,
Expr::RowValue(items) => {
for it in items {
consumed_collations(it)?;
}
}
_ => {}
}
Ok(())
}
/// The first explicit `COLLATE <name>` in `e` whose name is not a known
/// collating sequence (BINARY/NOCASE/RTRIM) — for rejecting it at `CREATE INDEX`,
/// where sqlite errors "no such collation sequence" rather than using BINARY.
fn unknown_collation(e: &Expr) -> Option<&str> {
match e {
Expr::Collate { expr, collation } => {
if crate::value::resolve_collation_name(collation).is_none() {
Some(collation)
} else {
unknown_collation(expr)
}
}
Expr::Binary { left, right, .. } => {
unknown_collation(left).or_else(|| unknown_collation(right))
}
Expr::Unary { expr, .. }
| Expr::Paren(expr)
| Expr::Cast { expr, .. }
| Expr::IsNull { expr, .. } => unknown_collation(expr),
Expr::Function { args, .. } => args.iter().find_map(unknown_collation),
_ => None,
}
}
/// (when `allow_rowid`) a rowid alias — the unknown column SQLite rejects at
/// A generated column may reference other (generated or plain) columns of its
/// table; a *cycle* among the generated columns is rejected at CREATE with
/// `generated column loop on "X"`. The named column is the one whose expression
/// closes the cycle (references an already in-progress generated column), with
/// generated columns visited in declaration order — matching SQLite. Returns the
/// looping column's name, or `None` when the generated columns are acyclic.
fn generated_column_loop(columns: &[ColumnDef]) -> Option<String> {
let n = columns.len();
// Per column: the generated expression (if any) and the indices of the
// generated columns it references, in source order.
let gen_expr: Vec<Option<&Expr>> = columns
.iter()
.map(|c| {
c.constraints.iter().find_map(|k| match k {
ColumnConstraint::Generated { expr, .. } => Some(expr),
_ => None,
})
})
.collect();
let mut deps: Vec<Vec<usize>> = alloc::vec![Vec::new(); n];
for (i, expr) in gen_expr.iter().enumerate() {
let Some(expr) = expr else { continue };
window::visit(expr, &mut |node| {
if let Expr::Column {
table: None,
schema: None,
column,
..
} = node
&& let Some(j) = columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column))
&& gen_expr[j].is_some()
{
deps[i].push(j);
}
});
}
// Post-order DFS over the generated columns: 0 = unvisited, 1 = in-progress,
// 2 = done. A reference to an in-progress column closes a cycle, named for
// the column being visited.
fn dfs(i: usize, names: &[ColumnDef], deps: &[Vec<usize>], state: &mut [u8]) -> Option<String> {
state[i] = 1;
for k in 0..deps[i].len() {
let j = deps[i][k];
match state[j] {
1 => return Some(names[i].name.clone()),
0 => {
if let Some(name) = dfs(j, names, deps, state) {
return Some(name);
}
}
_ => {}
}
}
state[i] = 2;
None
}
let mut state = alloc::vec![0u8; n];
for i in 0..n {
if gen_expr[i].is_some()
&& state[i] == 0
&& let Some(name) = dfs(i, columns, &deps, &mut state)
{
return Some(name);
}
}
None
}
/// `CREATE` in a CHECK constraint or generated-column expression. Generated
/// columns may not reference the rowid (`allow_rowid=false`); a CHECK may.
/// The first column reference in `e` that is *not* resolvable against `known`,
/// or `None` if every reference resolves. A `table.` qualifier must name
/// `self_table` (the object being defined); a qualifier that names anything else
/// makes the whole `qualifier.column` an unknown column even when a bare column
/// of that name exists — matching SQLite, which reports the qualified name. A
/// correctly-qualified-but-unknown column is likewise reported qualified
/// (`self_table.nope`). With `self_table = None` every qualifier is foreign, so
/// any reference is "unknown" (used to reject a column inside a constant
/// `DEFAULT`).
fn unknown_column_ref(
e: &Expr,
known: &[String],
allow_rowid: bool,
self_table: Option<&str>,
) -> Option<String> {
let mut bad: Option<String> = None;
window::visit(e, &mut |n| {
if let Expr::Column { table, column, .. } = n {
if bad.is_some() {
return;
}
let foreign_qualifier = table
.as_ref()
.is_some_and(|q| self_table.is_none_or(|t| !t.eq_ignore_ascii_case(q)));
let resolves = !foreign_qualifier
&& (known.iter().any(|c| c.eq_ignore_ascii_case(column))
|| (allow_rowid && eval::is_rowid_alias(column)));
if !resolves {
bad = Some(match table {
Some(q) => alloc::format!("{q}.{column}"),
None => column.clone(),
});
}
}
});
bad
}
/// Whether `e` contains a `table.column` reference whose qualifier names
/// `self_table` and whose column resolves — the form SQLite forbids in a
/// generated-column or index expression with `the "." operator prohibited …`.
fn has_resolved_dotted_ref(
e: &Expr,
known: &[String],
allow_rowid: bool,
self_table: &str,
) -> bool {
let mut found = false;
window::visit(e, &mut |n| {
if let Expr::Column {
table: Some(q),
column,
..
} = n
&& q.eq_ignore_ascii_case(self_table)
&& (known.iter().any(|c| c.eq_ignore_ascii_case(column))
|| (allow_rowid && eval::is_rowid_alias(column)))
{
found = true;
}
});
found
}
/// The first column reference in an `ON CONFLICT …` predicate or `DO UPDATE`
/// value/`WHERE` that does not resolve, or `None` if all resolve. A reference
/// resolves to the target table (bare, `table.`-qualified, or — when present —
/// `db.table.`-qualified where `db` names the target's database) and, when
/// `allow_excluded` is set (a `DO UPDATE` SET/WHERE), to the `excluded`
/// pseudo-table (which is never schema-qualified). The conflict-target `WHERE`
/// (a partial-index predicate) passes `allow_excluded = false`. Rowid aliases
/// are accepted under every valid qualifier, matching sqlite. The bad reference
/// is reported with whatever qualifier parts it was written with.
fn upsert_expr_unknown_column(
e: &Expr,
known: &[String],
table: &str,
target_db: &str,
allow_excluded: bool,
) -> Option<String> {
let mut bad: Option<String> = None;
window::visit(e, &mut |n| {
if let Expr::Column {
schema,
table: q,
column,
..
} = n
{
if bad.is_some() {
return;
}
let known_col = known.iter().any(|c| c.eq_ignore_ascii_case(column))
|| eval::is_rowid_alias(column);
let resolves = match (schema, q) {
(None, None) => known_col,
(None, Some(qual)) => {
(qual.eq_ignore_ascii_case(table)
|| (allow_excluded && qual.eq_ignore_ascii_case("excluded")))
&& known_col
}
// A three-part `db.table.col` resolves only when `db` names the
// target's database and `table` names the target — `excluded` can
// never carry a database part.
(Some(sch), Some(qual)) => {
sch.eq_ignore_ascii_case(target_db)
&& qual.eq_ignore_ascii_case(table)
&& known_col
}
(Some(_), None) => false,
};
if !resolves {
bad = Some(match (schema, q) {
(Some(s), Some(qq)) => alloc::format!("{s}.{qq}.{column}"),
(_, Some(qq)) => alloc::format!("{qq}.{column}"),
_ => column.clone(),
});
}
}
});
bad
}
/// Validate every column reference in an `INSERT … ON CONFLICT … DO …` clause
/// against the target table, in sqlite's resolution order, so an unknown column
/// is rejected (`no such column: …`) rather than silently ignored. Per clause:
/// (1) the conflict-target columns, (2) the conflict-target `WHERE` (a partial-
/// index predicate — table columns + rowid only, no `excluded`), then for a
/// `DO UPDATE` (3) the assignment value expressions, (4) the assigned (target)
/// columns, and (5) the update `WHERE`; (3)–(5) may also use `excluded`.
fn validate_upsert_columns(
meta: &TableMeta,
table: &str,
target_db: &str,
upserts: &[Upsert],
) -> Result<()> {
if upserts.is_empty() {
return Ok(());
}
let known: Vec<String> = meta.columns.iter().map(|c| c.name.clone()).collect();
let is_known =
|c: &str| known.iter().any(|k| k.eq_ignore_ascii_case(c)) || eval::is_rowid_alias(c);
for up in upserts {
for col in &up.target {
if !is_known(col) {
return Err(Error::Error(alloc::format!("no such column: {col}")));
}
}
// The conflict-target WHERE is a partial-index predicate — target columns
// (and a three-part db qualifier) only, never `excluded`.
if let Some(w) = &up.target_where
&& let Some(c) = upsert_expr_unknown_column(w, &known, table, target_db, false)
{
return Err(Error::Error(alloc::format!("no such column: {c}")));
}
if let UpsertAction::Update {
assignments,
where_clause,
} = &up.action
{
for (_, val) in assignments {
if let Some(c) = upsert_expr_unknown_column(val, &known, table, target_db, true) {
return Err(Error::Error(alloc::format!("no such column: {c}")));
}
}
for (col, _) in assignments {
if !is_known(col) {
return Err(Error::Error(alloc::format!("no such column: {col}")));
}
}
if let Some(w) = where_clause
&& let Some(c) = upsert_expr_unknown_column(w, &known, table, target_db, true)
{
return Err(Error::Error(alloc::format!("no such column: {c}")));
}
}
}
Ok(())
}
/// Whether `e` contains a subquery (scalar `(SELECT …)`, `EXISTS`, or `IN
/// (SELECT …)`) anywhere — SQLite forbids these in CHECK constraints and
/// generated-column expressions.
fn expr_has_subquery(e: &Expr) -> bool {
let mut found = false;
window::visit(e, &mut |n| {
if matches!(
n,
Expr::Subquery(_) | Expr::Exists { .. } | Expr::InSelect { .. }
) {
found = true;
}
});
found
}
/// Whether every *table-qualified* column reference in `e` is qualified by
/// `bind` (case-insensitive). An unqualified column always passes; a column
/// qualified by any other name fails, and with `bind == None` *any* qualifier
/// fails. Used when flattening a derived table / CTE into its inner body for
/// EXPLAIN QUERY PLAN: an unqualified name (or the derived source's own alias /
/// CTE name `bind`, which is then stripped) resolves against the flattened base
/// table; any other qualifier would not, so the caller declines.
fn all_qualifiers_match(e: &Expr, bind: Option<&str>) -> bool {
let mut ok = true;
window::visit(e, &mut |n| {
if let Expr::Column { table: Some(q), .. } = n
&& !bind.is_some_and(|b| q.eq_ignore_ascii_case(b))
{
ok = false;
}
});
ok
}
/// Whether every bare column reference in `e` names one of `names`
/// (case-insensitive). Used when flattening a derived table / CTE: an outer
/// projection / predicate may only reference columns the source actually outputs
/// — otherwise SQLite raises `no such column`, so the caller declines rather than
/// mis-resolve the name against the flattened base table.
fn all_column_names_in(e: &Expr, names: &[String]) -> bool {
let mut ok = true;
window::visit(e, &mut |n| {
if let Expr::Column { column, .. } = n
&& !names.iter().any(|nm| nm.eq_ignore_ascii_case(column))
{
ok = false;
}
});
ok
}
/// Apply `f` to every `Column` node in `e` in place. Mirrors `window::visit`'s
/// expression recursion but mutably; does not descend into nested `SELECT`s (the
/// derived-flatten gate excludes any subquery in the merged clauses). Used to
/// strip a derived source's own qualifier (`s.a` → `a`) and to map a derived
/// output name back to its base column (`aa` → `a`) before a flatten merge.
fn visit_columns_mut(e: &mut Expr, f: &mut impl FnMut(&mut Expr)) {
if matches!(e, Expr::Column { .. }) {
f(e);
return;
}
match e {
Expr::Unary { expr, .. } => visit_columns_mut(expr, f),
Expr::Binary { left, right, .. } => {
visit_columns_mut(left, f);
visit_columns_mut(right, f);
}
Expr::Function { args, .. } => {
for a in args {
visit_columns_mut(a, f);
}
}
Expr::IsNull { expr, .. } => visit_columns_mut(expr, f),
Expr::InList { expr, list, .. } => {
visit_columns_mut(expr, f);
for a in list {
visit_columns_mut(a, f);
}
}
Expr::Between {
expr, low, high, ..
} => {
visit_columns_mut(expr, f);
visit_columns_mut(low, f);
visit_columns_mut(high, f);
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
visit_columns_mut(o, f);
}
for (w, t) in when_then {
visit_columns_mut(w, f);
visit_columns_mut(t, f);
}
if let Some(el) = else_result {
visit_columns_mut(el, f);
}
}
Expr::Cast { expr, .. } => visit_columns_mut(expr, f),
Expr::Paren(inner) => visit_columns_mut(inner, f),
Expr::RowValue(items) => {
for it in items {
visit_columns_mut(it, f);
}
}
Expr::Collate { expr, .. } => visit_columns_mut(expr, f),
_ => {}
}
}
/// Drop a `bind`-qualified column's table qualifier in place (`s.a` → `a`) and
/// remap a derived output name to its base column via `rename` (`aa` → `a` for an
/// inner `a AS aa`), so the column resolves against the flattened base table after
/// a derived-table / CTE merge. `rename` pairs are `(output_name, base_column)`.
fn rewrite_flattened_column(e: &mut Expr, bind: Option<&str>, rename: &[(String, String)]) {
visit_columns_mut(e, &mut |c| {
if let Expr::Column { table, column, .. } = c {
if let Some(b) = bind
&& table.as_deref().is_some_and(|t| t.eq_ignore_ascii_case(b))
{
*table = None;
}
if let Some((_, base)) = rename.iter().find(|(o, _)| o.eq_ignore_ascii_case(column)) {
*column = base.clone();
}
}
});
}
/// Collect, in pre-order, every scalar `(SELECT …)` subquery appearing directly
/// in `e` (NOT descending into a subquery body — those are a separate numbering
/// concern), preserving `e`'s lifetime so the bodies can be re-planned. Returns
/// `false` if any `EXISTS` / `IN (SELECT)` form is present — those render as
/// different (`CORRELATED` / `LIST SUBQUERY` + bloom-filter) nodes the caller
/// does not model. Used by [`Self::eqp_where_scalar_subqueries`].
fn collect_where_scalar_subqueries<'a>(e: &'a Expr, out: &mut Vec<&'a Select>) -> bool {
match e {
Expr::Subquery(body) => {
out.push(body.as_ref());
true
}
Expr::Exists { .. } | Expr::InSelect { .. } => false,
Expr::Unary { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Paren(expr)
| Expr::Collate { expr, .. } => collect_where_scalar_subqueries(expr, out),
Expr::Binary { left, right, .. } => {
collect_where_scalar_subqueries(left, out)
&& collect_where_scalar_subqueries(right, out)
}
Expr::Function { args, .. } | Expr::RowValue(args) => {
args.iter().all(|a| collect_where_scalar_subqueries(a, out))
}
Expr::InList { expr, list, .. } => {
collect_where_scalar_subqueries(expr, out)
&& list.iter().all(|a| collect_where_scalar_subqueries(a, out))
}
Expr::Between {
expr, low, high, ..
} => {
collect_where_scalar_subqueries(expr, out)
&& collect_where_scalar_subqueries(low, out)
&& collect_where_scalar_subqueries(high, out)
}
Expr::Case {
operand,
when_then,
else_result,
} => {
operand
.as_ref()
.is_none_or(|o| collect_where_scalar_subqueries(o, out))
&& when_then.iter().all(|(w, t)| {
collect_where_scalar_subqueries(w, out)
&& collect_where_scalar_subqueries(t, out)
})
&& else_result
.as_ref()
.is_none_or(|el| collect_where_scalar_subqueries(el, out))
}
_ => true,
}
}
/// Collect every `[NOT] IN (SELECT …)` in `e` as `(body, negated, operand)`, setting
/// `other` if any scalar `(SELECT …)` / `EXISTS` is present. Does NOT descend into a
/// subquery body (that is the body's own plan). Lifetime-preserving (mirrors
/// [`collect_where_scalar_subqueries`]) so the caller can hold the borrowed refs.
fn collect_in_selects<'a>(
e: &'a Expr,
ins: &mut Vec<(&'a Select, bool, &'a Expr)>,
other: &mut bool,
) {
match e {
Expr::Subquery(_) | Expr::Exists { .. } => *other = true,
Expr::InSelect {
expr,
select,
negated,
} => {
ins.push((select.as_ref(), *negated, expr.as_ref()));
collect_in_selects(expr, ins, other);
}
Expr::Unary { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Paren(expr)
| Expr::Collate { expr, .. } => collect_in_selects(expr, ins, other),
Expr::Binary { left, right, .. } => {
collect_in_selects(left, ins, other);
collect_in_selects(right, ins, other);
}
Expr::Function { args, .. } | Expr::RowValue(args) => {
for a in args {
collect_in_selects(a, ins, other);
}
}
Expr::InList { expr, list, .. } => {
collect_in_selects(expr, ins, other);
for a in list {
collect_in_selects(a, ins, other);
}
}
Expr::Between {
expr, low, high, ..
} => {
collect_in_selects(expr, ins, other);
collect_in_selects(low, ins, other);
collect_in_selects(high, ins, other);
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
collect_in_selects(o, ins, other);
}
for (w, t) in when_then {
collect_in_selects(w, ins, other);
collect_in_selects(t, ins, other);
}
if let Some(el) = else_result {
collect_in_selects(el, ins, other);
}
}
_ => {}
}
}
/// The single `[NOT] IN (SELECT …)` subquery in `e`, as `(body, negated, operand)`,
/// but only when it is the *sole* subquery anywhere in `e` (any scalar `(SELECT …)`,
/// `EXISTS`, or a second `IN (SELECT)` returns `None`, so the shared subquery-id
/// counter stays a clean `1`). Used to render a `LIST SUBQUERY 1` + `CREATE BLOOM
/// FILTER` node.
fn single_where_in_select(e: &Expr) -> Option<(&Select, bool, &Expr)> {
let mut ins = Vec::new();
let mut other = false;
collect_in_selects(e, &mut ins, &mut other);
if other || ins.len() != 1 {
return None;
}
ins.into_iter().next()
}
/// Reconstruct the `sql` text stored in `sqlite_schema` for a `CREATE` statement
/// the way SQLite does (`sqlite3EndTable` / `sqlite3CreateIndex`): a regenerated
/// `CREATE <TYPE> ` head — which drops `TEMP`/`IF NOT EXISTS` and normalises the
/// prefix whitespace to single spaces — followed by the *verbatim* source from
/// the object-name token onward, with the trailing statement terminator (`;`) and
/// any surrounding whitespace removed. `prefix` is the regenerated head ending in
/// a space (e.g. `"CREATE TABLE "`, `"CREATE UNIQUE INDEX "`). Falls back to the
/// trailing-trimmed `sql_text` if the name token cannot be located.
fn canonical_schema_sql(prefix: &str, sql_text: &str) -> String {
let trimmed = sql_text.trim_end();
let trimmed = trimmed.strip_suffix(';').unwrap_or(trimmed).trim_end();
match schema_sql_name_offset(sql_text) {
Some(start) if start <= trimmed.len() => format!("{prefix}{}", &trimmed[start..]),
_ => trimmed.to_string(),
}
}
/// Byte offset of the object-name token in a `CREATE …` statement: skips
/// `CREATE`, any `TEMP`/`TEMPORARY`/`UNIQUE` modifier, the object-type keyword,
/// and an optional `IF NOT EXISTS`. Returns `None` if the head doesn't match.
fn schema_sql_name_offset(sql_text: &str) -> Option<usize> {
use crate::sql::token::{Token, tokenize};
let toks = tokenize(sql_text).ok()?;
let kw = |t: &Token, k: &str| matches!(t, Token::Word(w) if w.eq_ignore_ascii_case(k));
let mut i = 0;
if !kw(&toks.get(i)?.token, "CREATE") {
return None;
}
i += 1;
while matches!(&toks.get(i)?.token, Token::Word(w)
if ["TEMP", "TEMPORARY", "UNIQUE"].iter().any(|k| w.eq_ignore_ascii_case(k)))
{
i += 1;
}
if !matches!(&toks.get(i)?.token, Token::Word(w)
if ["TABLE", "INDEX", "VIEW", "TRIGGER"].iter().any(|k| w.eq_ignore_ascii_case(k)))
{
return None;
}
i += 1;
if kw(&toks.get(i)?.token, "IF")
&& toks.get(i + 1).is_some_and(|t| kw(&t.token, "NOT"))
&& toks.get(i + 2).is_some_and(|t| kw(&t.token, "EXISTS"))
{
i += 3;
}
// A schema qualifier (`CREATE TABLE aux.t …`) is dropped from the stored SQL —
// the catalog row lives in that schema already, so the bare object name is
// canonical. Skip the `schema .` pair and point at the real name token.
if toks
.get(i + 1)
.is_some_and(|t| matches!(t.token, Token::Dot))
{
i += 2;
}
Some(toks.get(i)?.start)
}
/// Whether any clause of a `FROM`-less `SELECT` contains a subquery. Used by
/// `EXPLAIN QUERY PLAN`: a constant-row select with a subquery gets extra
/// `SCALAR`/`LIST SUBQUERY` (and bloom-filter) nodes from sqlite that we don't
/// model, so we only render the bare `SCAN CONSTANT ROW` when there are none.
/// True when any row of a `VALUES` clause carries a subquery. SQLite renders a
/// subquery-free multi-row `VALUES` as a single `SCAN N-ROW VALUES CLAUSE` node,
/// but a row holding a subquery switches it to the plural `SCAN N CONSTANT ROWS`
/// phrasing plus interposed `SCALAR`/`LIST SUBQUERY` nodes we do not model — so
/// such a clause declines. `value_arm_count` is how many leading compound arms
/// (besides the head) are extra rows of the clause.
fn values_clause_has_subquery(sel: &Select, value_arm_count: usize) -> bool {
let row_has = |cols: &[ResultColumn]| {
cols.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
})
};
row_has(&sel.columns)
|| sel.compound[..value_arm_count]
.iter()
.any(|(_, arm)| row_has(&arm.columns))
}
fn select_no_from_has_subquery(sel: &Select) -> bool {
sel.columns.iter().any(|c| match c {
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
}) || sel.where_clause.as_ref().is_some_and(expr_has_subquery)
|| sel.group_by.iter().any(expr_has_subquery)
|| sel.having.as_ref().is_some_and(expr_has_subquery)
|| sel.order_by.iter().any(|t| expr_has_subquery(&t.expr))
|| sel.limit.as_ref().is_some_and(expr_has_subquery)
|| sel.offset.as_ref().is_some_and(expr_has_subquery)
}
/// The verbatim name of the first built-in aggregate call in `e` (a plain
/// aggregate, not a windowed `… OVER (…)`), or `None`. SQLite rejects an
/// aggregate in a CHECK or generated-column expression at `CREATE` with "misuse
/// of aggregate function NAME()", preserving the name's case as written.
/// `min`/`max` count as aggregates only at arity one (the two-arg forms are
/// scalar); `count(*)` carries `star`. (For an expression with several
/// aggregates SQLite names one of them per its own resolver walk; reporting the
/// first found here still rejects with the right message form.)
fn first_aggregate_call_name(e: &Expr) -> Option<String> {
let mut found: Option<String> = None;
window::visit(e, &mut |n| {
if found.is_some() {
return;
}
if let Expr::Function {
name,
args,
star,
over,
..
} = n
&& over.is_none()
&& func::is_aggregate_call(name, args.len(), *star)
{
found = Some(name.clone());
}
});
found
}
/// Whether `sel` is an *aggregate query* — one in which aggregate functions are
/// valid somewhere (the result columns or `HAVING`). SQLite uses this to pick the
/// wording when an aggregate is misused in a clause that forbids it: a misuse in
/// an aggregate query reads `misuse of aggregate: f()`, otherwise `misuse of
/// aggregate function f()`. A `GROUP BY`/`HAVING` makes it aggregate, as does an
/// aggregate in any result column. An aggregate in `ORDER BY` does NOT — sqlite
/// resolves (and rejects) the `WHERE` before it considers `ORDER BY`.
fn select_is_aggregate_query(sel: &Select) -> bool {
!sel.group_by.is_empty()
|| sel.having.is_some()
|| sel.columns.iter().any(|rc| match rc {
ResultColumn::Expr { expr, .. } => first_aggregate_call_name(expr).is_some(),
_ => false,
})
}
/// Reject an aggregate function used in a clause that forbids it (a `WHERE`, an
/// `UPDATE` assignment, …), in sqlite's two wordings — `misuse of aggregate: f()`
/// inside an aggregate query, `misuse of aggregate function f()` otherwise. The
/// walk stops at subquery boundaries (an aggregate inside a nested `SELECT`
/// belongs to that query level), so a legitimate `WHERE x IN (SELECT sum(y) …)`
/// is untouched.
fn reject_misused_aggregate(e: &Expr, aggregate_query: bool) -> Result<()> {
match first_aggregate_call_name(e) {
Some(name) if aggregate_query => Err(Error::Error(alloc::format!(
"misuse of aggregate: {name}()"
))),
Some(name) => Err(Error::Error(alloc::format!(
"misuse of aggregate function {name}()"
))),
None => Ok(()),
}
}
/// The lowercased name of the first window function call (any call carrying an
/// `OVER` clause) in `e`, or `None`. Like the aggregate walk, this stops at
/// subquery boundaries, so a window function inside a nested `SELECT` belongs to
/// that query level and is not reported here.
fn first_window_call_name(e: &Expr) -> Option<String> {
let mut found: Option<String> = None;
window::visit(e, &mut |n| {
if found.is_some() {
return;
}
if let Expr::Function { name, over, .. } = n
&& over.is_some()
{
found = Some(name.to_ascii_lowercase());
}
});
found
}
/// Reject a window function used in a clause that forbids it. Window functions
/// are valid only in the result columns and `ORDER BY` of their query; in a
/// `WHERE`, `GROUP BY`, `HAVING`, or any `UPDATE`/`DELETE` expression they are a
/// misuse. SQLite rejects this at prepare time (so it errors even over an
/// empty/fully-filtered table, which graphite's lazy per-row evaluator would
/// otherwise silently accept) with a single wording, unlike the aggregate case.
fn reject_misused_window(e: &Expr) -> Result<()> {
match first_window_call_name(e) {
Some(name) => Err(Error::Error(alloc::format!(
"misuse of window function {name}()"
))),
None => Ok(()),
}
}
/// The built-in window-only functions (ranking + value functions). These exist
/// solely as window functions, so `OVER` is mandatory; everything else that may
/// carry `OVER` must be an aggregate.
fn is_builtin_window_function(lname: &str) -> bool {
builtin_window_arity(lname).is_some()
}
/// The `(min, max)` argument count for each built-in ranking/value window
/// function, or `None` if `lname` is not one. The membership doubles as
/// [`is_builtin_window_function`]; the arity drives both the `OVER`-clause
/// evaluator's arity guard and the prepare-time misuse check.
fn builtin_window_arity(lname: &str) -> Option<(usize, usize)> {
match lname {
"row_number" | "rank" | "dense_rank" | "percent_rank" | "cume_dist" => Some((0, 0)),
"ntile" | "first_value" | "last_value" => Some((1, 1)),
"nth_value" => Some((2, 2)),
"lag" | "lead" => Some((1, 3)),
_ => None,
}
}
/// Reject a built-in window-only function (`row_number`, `rank`, `lag`, …) used
/// without an `OVER` clause. These exist solely as window functions, so calling
/// one as a plain scalar is `misuse of window function NAME()` in SQLite. The
/// scalar evaluator already reports this per row, but only when a row is reached;
/// over an empty (or fully filtered) table the call is never evaluated, so the
/// error must also be raised at prepare time to match SQLite. A wrong argument
/// count is diagnosed first (`ntile()` → `wrong number of arguments to function
/// ntile()`), matching SQLite's order. The walk stops at subquery boundaries, so
/// a window call inside a nested `SELECT` belongs to that query level.
fn reject_window_without_over(e: &Expr) -> Result<()> {
let mut err: Option<Error> = None;
window::visit(e, &mut |n| {
if err.is_some() {
return;
}
if let Expr::Function {
name,
args,
over: None,
..
} = n
{
let lname = name.to_ascii_lowercase();
if let Some((lo, hi)) = builtin_window_arity(&lname) {
err = Some(if args.len() < lo || args.len() > hi {
Error::Error(alloc::format!(
"wrong number of arguments to function {lname}()"
))
} else {
Error::Error(alloc::format!("misuse of window function {lname}()"))
});
}
}
});
err.map_or(Ok(()), Err)
}
/// Reject a function call carrying `OVER` that is neither a built-in window
/// function nor an aggregate. SQLite allows `OVER` only on those two kinds; a
/// plain scalar (`abs(x) OVER ()`, `coalesce(a,b) OVER ()`) — and the *scalar*
/// multi-argument forms of `min`/`max` (`max(a,b) OVER ()`, where the one-arg
/// form is the aggregate) — are rejected at prepare time as `NAME() may not be
/// used as a window function`. `is_agg` decides aggregate-ness (builtins plus
/// any registered user aggregate). An *unknown* name, though, is reported as
/// `no such function: NAME` ahead of the window-misuse wording (SQLite resolves
/// the name before classifying the `OVER`), so `is_known_scalar` distinguishes
/// the two. The walk stops at subquery boundaries.
fn reject_invalid_window_function(
e: &Expr,
is_agg: &dyn Fn(&str, usize, bool) -> bool,
is_known_scalar: &dyn Fn(&str, usize, bool) -> bool,
) -> Result<()> {
// (name, whether it exists as a scalar function)
let mut found: Option<(String, bool)> = None;
window::visit(e, &mut |n| {
if found.is_some() {
return;
}
if let Expr::Function {
name,
args,
star,
over: Some(_),
..
} = n
{
let lname = name.to_ascii_lowercase();
if !is_builtin_window_function(&lname) && !is_agg(name, args.len(), *star) {
found = Some((name.clone(), is_known_scalar(name, args.len(), *star)));
}
}
});
match found {
Some((name, true)) => Err(Error::Error(alloc::format!(
"{name}() may not be used as a window function"
))),
Some((name, false)) => Err(Error::Error(alloc::format!("no such function: {name}"))),
None => Ok(()),
}
}
/// Reject a `*` argument on any function but `count`. SQLite accepts the `*`
/// wildcard form only for `count(*)`; every other call — aggregate or scalar —
/// gets `wrong number of arguments to function NAME()` at prepare time (even
/// over an empty input), e.g. `sum(*)`, `min(*)`, `group_concat(*)`, `abs(*)`.
/// The walk stops at subquery boundaries (each nested query validates itself).
fn reject_star_argument(e: &Expr) -> Result<()> {
let mut found: Option<String> = None;
window::visit(e, &mut |n| {
if found.is_some() {
return;
}
if let Expr::Function {
name, star: true, ..
} = n
&& !name.eq_ignore_ascii_case("count")
{
found = Some(name.clone());
}
});
match found {
Some(name) => Err(Error::Error(alloc::format!(
"wrong number of arguments to function {name}()"
))),
None => Ok(()),
}
}
/// Reject an invalid `likelihood(X, prob)` call at prepare time. SQLite checks,
/// during analysis, that `likelihood` has exactly two arguments and that the
/// probability is a floating-point literal in `0.0..=1.0` (`exprProbability` in
/// `expr.c`), so both errors fire even when no row is produced (an empty or
/// fully-filtered table); graphite's evaluator only caught them per row. Only
/// the plain-scalar form is checked here — a `likelihood(…) OVER (…)` is left to
/// the window-misuse path. The walk stops at subquery boundaries (each nested
/// query validates itself).
fn reject_invalid_likelihood(e: &Expr) -> Result<()> {
let mut err: Option<Error> = None;
window::visit(e, &mut |n| {
if err.is_some() {
return;
}
if let Expr::Function {
name,
args,
over: None,
..
} = n
{
if !name.eq_ignore_ascii_case("likelihood") {
return;
}
if args.len() != 2 {
err = Some(Error::Error(
"wrong number of arguments to function likelihood()".into(),
));
} else if !func::likelihood_prob_is_valid(&args[1]) {
err = Some(Error::Error(
"second argument to likelihood() must be a constant between 0.0 and 1.0".into(),
));
}
}
});
err.map_or(Ok(()), Err)
}
/// Reject an aggregate function whose argument contains another aggregate or a
/// window function. SQLite forbids nesting aggregates: the argument of an
/// aggregate is resolved with `NC_InAggFunc` set, so a nested aggregate is a
/// `misuse of aggregate function NAME()` and a nested window is a `misuse of
/// window function NAME()`. It rejects both during analysis, so they fire even
/// over an empty/fully-filtered table (where graphite's lazy evaluator used to
/// silently produce a value — `count(sum(a))` returned 0 instead of erroring).
///
/// Only the *plain* aggregate form establishes this context: a `sum(a) OVER (…)`
/// is a window, not a nesting site, and a scalar wrapper (`abs(count(*))`,
/// `max(sum(a), 1)`) is fine — the nesting must be inside an aggregate's own
/// argument. When an argument holds both a nested aggregate and a nested window,
/// SQLite names whichever its resolver reaches last in source order, so the
/// inner scan keeps the last hit it sees. The walk stops at subquery boundaries
/// (a nested `SELECT` is a separate query level with its own aggregate context).
fn reject_nested_aggregate_arg(e: &Expr) -> Result<()> {
let mut err: Option<Error> = None;
window::visit(e, &mut |n| {
if err.is_some() {
return;
}
if let Expr::Function {
name,
args,
star,
over: None,
..
} = n
{
if !func::is_aggregate_call(name, args.len(), *star) {
return;
}
// Scan this aggregate's arguments for a nested aggregate or window
// call; the last one seen in source order is the one SQLite names.
let mut hit: Option<(bool, String)> = None;
for a in args {
window::visit(a, &mut |m| {
if let Expr::Function {
name: inner_name,
args: inner_args,
star: inner_star,
over: inner_over,
..
} = m
{
if inner_over.is_some() {
hit = Some((true, inner_name.to_ascii_lowercase()));
} else if func::is_aggregate_call(inner_name, inner_args.len(), *inner_star)
{
hit = Some((false, inner_name.clone()));
}
}
});
}
if let Some((is_window, inner_name)) = hit {
err = Some(Error::Error(if is_window {
alloc::format!("misuse of window function {inner_name}()")
} else {
alloc::format!("misuse of aggregate function {inner_name}()")
}));
}
}
});
err.map_or(Ok(()), Err)
}
/// Reject a window function nested inside *another* window function's
/// definition — its arguments, its `FILTER` predicate, or its `OVER`
/// specification (`PARTITION BY` / `ORDER BY` / frame bounds). SQLite forbids
/// this at prepare time as `misuse of window function <inner>()` (an ordinary
/// aggregate in the same spots is fine — `OVER (ORDER BY count(*))` is legal),
/// firing even over an empty table where graphite's lazy evaluator silently
/// accepted it. The walk stops at subquery boundaries (each subquery validates
/// its own windows). The inner aggregate-argument case
/// (`sum(row_number() OVER ())`, where the outer is a *plain* aggregate) is
/// handled by [`reject_nested_aggregate_arg`]; this covers the case where the
/// outer call is itself windowed.
fn reject_window_in_window(e: &Expr) -> Result<()> {
let mut err: Option<Error> = None;
window::visit(e, &mut |n| {
if err.is_some() {
return;
}
let Expr::Function {
filter,
order_by,
over: Some(spec),
args,
..
} = n
else {
return;
};
// Collect every sub-expression that belongs to this window call's
// definition (arguments, FILTER, aggregate ORDER BY, and the OVER spec),
// then scan each for a nested window-function node.
let mut parts: Vec<&Expr> = Vec::new();
for a in args {
parts.push(a);
}
if let Some(f) = filter {
parts.push(f);
}
for o in order_by {
parts.push(&o.expr);
}
windowspec_parts(spec, &mut parts);
if let Some(name) = nested_window_name(&parts) {
err = Some(Error::Error(alloc::format!(
"misuse of window function {name}()"
)));
}
});
err.map_or(Ok(()), Err)
}
/// The `PARTITION BY` / `ORDER BY` sub-expressions of a window specification,
/// appended to `out`. Frame-bound offsets are deliberately excluded: a window
/// function there is not this misuse but the ordinary "frame offset must be a
/// non-negative integer/number" path, which SQLite evaluates lazily (so it does
/// not fire over an empty partition).
fn windowspec_parts<'a>(spec: &'a WindowSpec, out: &mut Vec<&'a Expr>) {
for p in &spec.partition_by {
out.push(p);
}
for o in &spec.order_by {
out.push(&o.expr);
}
}
/// The name of a window function found anywhere within `parts`, if any.
fn nested_window_name(parts: &[&Expr]) -> Option<String> {
let mut hit: Option<String> = None;
for p in parts {
window::visit(p, &mut |m| {
if let Expr::Function {
name,
over: Some(_),
..
} = m
{
hit = Some(name.to_ascii_lowercase());
}
});
}
hit
}
/// Reject a window function nested inside a `WINDOW name AS (…)` definition's
/// specification — the named-window form of [`reject_window_in_window`].
fn reject_window_in_windowspec(spec: &WindowSpec) -> Result<()> {
let mut parts: Vec<&Expr> = Vec::new();
windowspec_parts(spec, &mut parts);
match nested_window_name(&parts) {
Some(name) => Err(Error::Error(alloc::format!(
"misuse of window function {name}()"
))),
None => Ok(()),
}
}
/// Reject a `FILTER (WHERE …)` clause attached to a non-aggregate function.
/// `FILTER` restricts which rows an aggregate consumes, so it is meaningful only
/// on an aggregate (or aggregate window) call; SQLite rejects it on a plain
/// scalar function — `abs(x) FILTER(WHERE …)` — at prepare time, in every
/// position, naming the function as written. `is_agg` decides aggregate-ness
/// (builtins plus any registered user aggregate), so a `FILTER` on a user
/// aggregate stays legal. Window calls (`over.is_some()`) are left to the
/// window-validation path. The walk stops at subquery boundaries.
fn reject_filter_on_non_aggregate(
e: &Expr,
is_agg: &dyn Fn(&str, usize, bool) -> bool,
) -> Result<()> {
let mut found: Option<String> = None;
window::visit(e, &mut |n| {
if found.is_some() {
return;
}
if let Expr::Function {
name,
args,
star,
filter,
over,
..
} = n
&& filter.is_some()
&& over.is_none()
&& !is_agg(name, args.len(), *star)
{
found = Some(name.clone());
}
});
match found {
Some(name) => Err(Error::Error(alloc::format!(
"FILTER may not be used with non-aggregate {name}()"
))),
None => Ok(()),
}
}
/// Reject an aggregate or window function used inside a `FILTER (WHERE …)`
/// predicate. SQLite resolves the filter as an ordinary boolean expression that
/// may not itself aggregate, so a nested aggregate (`count(*) FILTER (WHERE
/// sum(a)>0)`) is `misuse of aggregate function NAME()` and a nested window call
/// (`… FILTER (WHERE rank()>0)`) is `misuse of window function NAME()`, both
/// raised at prepare time — where graphite's lazy per-row evaluator would
/// otherwise run the filter and silently return a value over an empty/filtered
/// table. The carrier is checked only when it is *not* itself windowed: SQLite
/// accepts `count(*) FILTER (…) OVER ()`, so an `over: Some(_)` carrier is exempt.
/// The inner call is reported in source order, classified the same way the misuse
/// checks classify a bare call (an `OVER` clause or a window-only builtin →
/// window; otherwise an aggregate). A missing column inside the filter is caught
/// earlier by column validation, so it still wins.
fn reject_aggregate_in_filter(e: &Expr, is_agg: &dyn Fn(&str, usize, bool) -> bool) -> Result<()> {
let mut err: Option<Error> = None;
window::visit(e, &mut |n| {
if err.is_some() {
return;
}
if let Expr::Function {
filter: Some(f),
over: None,
..
} = n
{
window::visit(f, &mut |m| {
if err.is_some() {
return;
}
if let Expr::Function {
name,
args,
star,
over,
..
} = m
{
let lname = name.to_ascii_lowercase();
if over.is_some() || is_builtin_window_function(&lname) {
err = Some(Error::Error(alloc::format!(
"misuse of window function {lname}()"
)));
} else if is_agg(name, args.len(), *star) {
err = Some(Error::Error(alloc::format!(
"misuse of aggregate function {lname}()"
)));
}
}
});
}
});
err.map_or(Ok(()), Err)
}
/// Whether `e` calls a non-deterministic function — one that can return a
/// different value for the same inputs. SQLite prohibits these in contexts that
/// must be reproducible (index expressions, generated columns): an index built
/// over `random()` would never match a recomputed probe. Only the unambiguous
/// per-call-varying builtins are flagged here.
fn expr_is_nondeterministic(e: &Expr) -> bool {
let mut found = false;
window::visit(e, &mut |n| {
if let Expr::Function { name, .. } = n
&& matches!(
name.to_ascii_lowercase().as_str(),
"random" | "randomblob" | "last_insert_rowid" | "changes" | "total_changes"
)
{
found = true;
}
});
found
}
/// The rigid column type of a `STRICT` table column.
#[derive(Clone, Copy, PartialEq, Eq)]
enum StrictType {
Int,
Real,
Text,
Blob,
Any,
}
/// The `STRICT` rigid type for a declared type name, or `None` if the name is
/// not one of the six allowed (`INT`/`INTEGER`/`REAL`/`TEXT`/`BLOB`/`ANY`) — in
/// which case a `STRICT` table rejects the `CREATE`.
fn strict_column_type(type_name: Option<&str>) -> Option<StrictType> {
let t = type_name?.trim();
if t.eq_ignore_ascii_case("INT") || t.eq_ignore_ascii_case("INTEGER") {
Some(StrictType::Int)
} else if t.eq_ignore_ascii_case("REAL") {
Some(StrictType::Real)
} else if t.eq_ignore_ascii_case("TEXT") {
Some(StrictType::Text)
} else if t.eq_ignore_ascii_case("BLOB") {
Some(StrictType::Blob)
} else if t.eq_ignore_ascii_case("ANY") {
Some(StrictType::Any)
} else {
None
}
}
impl TableMeta {
/// Whether column `i` is a VIRTUAL generated column (computed, never stored).
fn is_virtual(&self, i: usize) -> bool {
matches!(self.generated[i], Some((_, false)))
}
/// Whether column `i` is generated (STORED or VIRTUAL).
fn is_generated(&self, i: usize) -> bool {
self.generated[i].is_some()
}
/// Per-column `DESC` flags for the clustered PRIMARY KEY b-tree (`root`),
/// aligned with `storage_order[..pk_len]`. Handed to the b-tree index
/// writer/reader at *every* insert and seek/scan on `root`, so the on-disk
/// order matches SQLite and stays self-consistent. An all-ascending PK
/// returns `&[]` (the writer's "no-op" case), keeping such tables
/// byte-for-byte unchanged.
fn pk_descs(&self) -> &[bool] {
if self.pk_descending.iter().any(|&d| d) {
&self.pk_descending
} else {
&[]
}
}
/// The stored direction (`true` = descending) of the column at position `i`
/// in `storage_order`: a PK column (`i < pk_len`) carries its declared
/// `DESC`; trailing non-PK columns are stored ascending.
fn storage_desc(&self, i: usize) -> bool {
self.pk_descending.get(i).copied().unwrap_or(false)
}
}
/// An index's b-tree root and the table column positions it covers.
/// A planner decision to satisfy `ORDER BY` by scanning a secondary index in key
/// order (B0), shared by `scan_source`, `run_core`, and `eqp_access`.
/// Apply `f` to each expression the VDBE actually compiles for a single-block
/// query: projections, `WHERE`, `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT`/`OFFSET`
/// and join `ON`s. (Not CTEs/compound/subqueries — the VDBE bails on those.)
fn vdbe_block_exprs<'a>(sel: &'a Select, f: &mut impl FnMut(&'a Expr)) {
for c in &sel.columns {
if let ResultColumn::Expr { expr, .. } = c {
f(expr);
}
}
sel.where_clause.iter().for_each(&mut *f);
sel.group_by.iter().for_each(&mut *f);
sel.having.iter().for_each(&mut *f);
for t in &sel.order_by {
f(&t.expr);
}
sel.limit.iter().for_each(&mut *f);
sel.offset.iter().for_each(&mut *f);
if let Some(from) = &sel.from {
for j in &from.joins {
if let Some(on) = &j.on {
f(on);
}
}
}
}
/// Mutable counterpart of [`vdbe_block_exprs`].
fn vdbe_block_exprs_mut(sel: &mut Select, f: &mut impl FnMut(&mut Expr)) {
for c in &mut sel.columns {
if let ResultColumn::Expr { expr, .. } = c {
f(expr);
}
}
sel.where_clause.iter_mut().for_each(&mut *f);
sel.group_by.iter_mut().for_each(&mut *f);
sel.having.iter_mut().for_each(&mut *f);
for t in &mut sel.order_by {
f(&mut t.expr);
}
sel.limit.iter_mut().for_each(&mut *f);
sel.offset.iter_mut().for_each(&mut *f);
if let Some(from) = &mut sel.from {
for j in &mut from.joins {
if let Some(on) = &mut j.on {
f(on);
}
}
}
}
/// Substitute bound parameters into the expressions the VDBE compiles so a
/// PARAMETERIZED query can run on the (otherwise param-less) VDBE engine.
/// Returns the rewritten `Select`, or `None` to leave the query to the
/// tree-walker when an ANONYMOUS `?` is present — its index is assigned at eval
/// time (`EvalCtx::anon_counter`, affected by AND/OR short-circuit), so a static
/// substitution could diverge — or when those expressions hold no explicit
/// (`?N`/`:name`) parameter to substitute.
fn substitute_params(sel: &Select, params: &Params) -> Option<Select> {
use crate::sql::token::Param;
let mut anon = false;
let mut explicit: Vec<Param> = Vec::new();
vdbe_block_exprs(sel, &mut |e| {
window::visit(e, &mut |x| {
if let Expr::Parameter(p) = x {
if matches!(p, Param::Anonymous) {
anon = true;
} else if !explicit.contains(p) {
explicit.push(p.clone());
}
}
});
});
if anon || explicit.is_empty() {
return None;
}
let mut out = sel.clone();
for p in &explicit {
let v = match p {
Param::Numbered(n) => params
.positional
.get((*n as usize).checked_sub(1)?)?
.clone(),
Param::Named(name) => params
.named
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.clone())?,
Param::Anonymous => return None,
};
let target = Expr::Parameter(p.clone());
let repl = Expr::Literal(value_to_literal(v));
vdbe_block_exprs_mut(&mut out, &mut |e| window::replace_expr(e, &target, &repl));
}
Some(out)
}
struct OrderIndexScan {
/// The index name (for `EXPLAIN QUERY PLAN`).
name: String,
/// Root page of the index b-tree.
root: u32,
/// Collations of the index's columns (for the b-tree walk).
colls: Vec<crate::value::Collation>,
/// Table-column index of each index column (record layout `cols…, rowid`).
cols: Vec<usize>,
/// `ORDER BY … DESC` (the ascending scan is reversed).
descending: bool,
/// The index holds every column the query references (B2): rows can be built
/// from index records without touching the table b-tree.
covering: bool,
/// Number of trailing `ORDER BY` terms the index walk does NOT order (because
/// they change direction): the walk yields the uniform leading prefix, then
/// the caller still sorts. 0 means the walk fully satisfies the ORDER BY (no
/// sort). Only set (>0) for the NON-covering mixed-direction case — the
/// covered mixed case is handled by `covering_scan` + `scan_order_prefix`.
sorted_suffix: usize,
}
struct IndexMeta {
/// The index name (as in `sqlite_schema`), used by `ANALYZE`.
name: String,
root: u32,
cols: Vec<usize>,
/// Collating sequence for each indexed column (aligned with `cols`).
collations: Vec<crate::value::Collation>,
/// `DESC` flag for each indexed column (aligned with `cols`). Only meaningful
/// for a plain column index (`key_exprs.is_none()`); empty otherwise. Used to
/// reason about whether the index's implicit trailing-rowid order lines up with
/// an `ORDER BY` walk (the rowid is always stored ascending).
descending: Vec<bool>,
/// `CREATE INDEX … WHERE <predicate>` — a partial index only stores rows for
/// which the predicate is true. `None` for a full index.
partial: Option<Expr>,
/// For an expression index (`CREATE INDEX … (lower(x))`), the per-term key
/// expressions evaluated against each row to form the key. `None` for an
/// ordinary column index (which uses `cols`).
key_exprs: Option<Vec<Expr>>,
/// `true` for a `UNIQUE` index (or an automatic UNIQUE/PK index). Drives
/// uniqueness enforcement for standalone/partial/expression indexes, which
/// the inline-constraint `TableMeta::unique` sets do not cover.
unique: bool,
/// `true` for an automatic UNIQUE/PK index (no backing `CREATE INDEX` SQL).
/// Its `descending` flags are reconstructed from the source constraint's
/// per-column ASC/DESC (see `collect_unique_sets`), so they are trustworthy
/// and honoured by both the insert and the seek paths.
is_auto: bool,
}
impl IndexMeta {
/// Per-column `DESC` flags to hand the b-tree index writer/reader. A regular
/// named column index (`key_exprs.is_none()`) carries its columns' directions
/// directly; an automatic UNIQUE/PK index (`is_auto`) has its `descending`
/// reconstructed from the constraint (see `collect_unique_sets`), so both are
/// trustworthy. Only an *expression* index (`key_exprs.is_some()`) has no
/// per-column direction model, so it returns `&[]`, which the writer treats as
/// all-ascending — keeping the insert side and the seek side self-consistent.
/// An empty slice is the "no-op" case, so a plain all-ascending index is
/// byte-for-byte unchanged.
fn seek_descs(&self) -> &[bool] {
if self.key_exprs.is_none() {
&self.descending
} else {
&[]
}
}
}
impl Connection {
/// Run `body` with `outer`'s row pushed as a correlation frame, then pop it
/// (even on error). The subquery runs with the outer query's parameters.
fn with_outer_frame<T>(
&self,
outer: &EvalCtx,
body: impl FnOnce(&Params) -> Result<T>,
) -> Result<T> {
self.outer_scope.borrow_mut().push(OuterFrame {
columns: outer.columns.to_vec(),
row: outer.row.to_vec(),
rowid: outer.rowid,
});
let params_ptr = outer.params;
let out = body(params_ptr);
self.outer_scope.borrow_mut().pop();
out
}
}
impl eval::Subqueries for Connection {
fn last_insert_rowid(&self) -> i64 {
self.last_insert_rowid.get()
}
fn changes(&self) -> i64 {
self.changes.get()
}
fn total_changes(&self) -> i64 {
self.total_changes.get()
}
fn case_sensitive_like(&self) -> bool {
self.case_sensitive_like
}
fn next_random(&self) -> i64 {
// SplitMix64: advance a 64-bit counter by the golden-ratio increment,
// then avalanche. Good distribution, tiny state, no_std-friendly, and
// works from any seed (including 0).
let s = self.rng_state.get().wrapping_add(0x9E37_79B9_7F4A_7C15);
self.rng_state.set(s);
let mut z = s;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
z as i64
}
fn call_udf(&self, name: &str, args: &[Value]) -> Option<Result<Value>> {
self.functions.get(name).map(|f| f(args))
}
#[cfg(feature = "fts5")]
fn fts5_bm25(&self, rowid: i64, weights: &[f64]) -> Option<f64> {
let cell = self.fts5_rank.borrow();
let (corpus, index) = cell.as_ref()?.bm25.as_ref()?;
Some(corpus.score(*index.get(&rowid)?, weights))
}
#[cfg(feature = "fts5")]
fn fts5_rank(&self, rowid: i64) -> Option<Result<f64>> {
let cell = self.fts5_rank.borrow();
let ctx = cell.as_ref()?;
let (corpus, index) = ctx.bm25.as_ref()?;
let doc = *index.get(&rowid)?;
// No configured rank ⇒ the default `bm25()` (all-1.0 weights).
let Some((name, weights)) = ctx.rank.as_ref() else {
return Some(Ok(corpus.score(doc, &[])));
};
// A configured non-`bm25` function is unsupported; SQLite would look it up
// as an fts5 auxiliary function and fail at query time. graphite ships only
// `bm25`, so mirror SQLite's `no such function: <name>`.
if !name.eq_ignore_ascii_case("bm25") {
return Some(Err(Error::Error(format!("no such function: {name}"))));
}
Some(Ok(corpus.score(doc, weights)))
}
#[cfg(feature = "fts5")]
fn fts5_highlight(&self, col: usize, text: &str, open: &str, close: &str) -> Option<String> {
let cell = self.fts5_rank.borrow();
let ctx = cell.as_ref()?;
// An `UNINDEXED` column carries no matches, so it is returned verbatim.
if ctx.col_names.get(col).is_some_and(|n| !ctx.col_indexed(n)) {
return Some(String::from(text));
}
Some(crate::vtab::fts5_highlight(
&ctx.query,
&ctx.col_names,
ctx.scope.as_deref(),
col,
text,
ctx.tok,
open,
close,
))
}
#[cfg(feature = "fts5")]
fn fts5_indexed_columns(&self, table: &str) -> Option<Vec<String>> {
let (module, args, _) = self.vtab_meta(table).ok()?;
if !module.eq_ignore_ascii_case("fts5") {
return None;
}
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
Some(crate::vtab::fts5_indexed_columns(&refs))
}
#[cfg(feature = "fts5")]
fn fts5_contentless_match(&self, table: &str, query: &str, rowid: i64) -> Option<bool> {
let (module, args, _) = self.vtab_meta(table).ok()?;
if !module.eq_ignore_ascii_case("fts5") {
return None;
}
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
if !crate::vtab::fts5_no_local_content(&refs) {
return None; // self-content keeps the indexed text → use the row-text path
}
// Consult the inverted index: a no-local-content row (contentless or external)
// matches iff its rowid is in the routed doclist for `query`. This is
// authoritative because the index is built from the caller-SUPPLIED text,
// which for external content can diverge from the content table's columns —
// re-checking against the content-table row text would give the wrong answer.
// An unroutable shape falls back to `None` for external (the content-text
// path still applies) but is a non-match for contentless (no text at all).
match self.fts5_index_match_rowids(table, &refs, query) {
Ok(Some(rowids)) => Some(rowids.contains(&rowid)),
_ if crate::vtab::fts5_is_contentless(&refs) => Some(false),
_ => None,
}
}
#[cfg(feature = "fts5")]
fn fts5_is_contentless_table(&self, table: &str) -> bool {
let Ok((module, args, _)) = self.vtab_meta(table) else {
return false;
};
if !module.eq_ignore_ascii_case("fts5") {
return false;
}
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
crate::vtab::fts5_is_contentless(&refs)
}
#[cfg(feature = "fts5")]
fn fts5_tok(&self, table: &str) -> crate::vtab::Fts5Tok {
let Ok((module, args, _)) = self.vtab_meta(table) else {
return crate::vtab::Fts5Tok::default();
};
if !module.eq_ignore_ascii_case("fts5") {
return crate::vtab::Fts5Tok::default();
}
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
crate::vtab::fts5_tok_config(&refs)
}
#[cfg(feature = "fts5")]
fn fts5_snippet(
&self,
col: i64,
cols: &[String],
open: &str,
close: &str,
ellipsis: &str,
ntokens: usize,
) -> Option<String> {
let cell = self.fts5_rank.borrow();
let ctx = cell.as_ref()?;
Some(crate::vtab::fts5_snippet(
&ctx.query,
&ctx.col_names,
ctx.scope.as_deref(),
col,
cols,
ctx.indexed.as_deref(),
ctx.tok,
open,
close,
ellipsis,
ntokens,
))
}
fn scalar(&self, select: &Select, outer: &EvalCtx) -> Result<Value> {
self.with_outer_frame(outer, |params| {
let r = self.run_select(select, params)?;
// A scalar subquery must yield exactly one column; sqlite rejects
// `(SELECT 1, 2)` ("sub-select returns 2 columns - expected 1") rather
// than silently taking the first. (Row-value / `IN` subqueries use the
// separate `rows`/`column` paths and may have several columns.)
if r.columns.len() > 1 {
return Err(Error::Error(alloc::format!(
"sub-select returns {} columns - expected 1",
r.columns.len()
)));
}
Ok(r.rows
.first()
.and_then(|row| row.first())
.cloned()
.unwrap_or(Value::Null))
})
}
fn column(&self, select: &Select, outer: &EvalCtx) -> Result<Vec<Value>> {
self.with_outer_frame(outer, |params| {
let r = self.run_select(select, params)?;
Ok(r.rows
.into_iter()
.map(|mut row| {
if row.is_empty() {
Value::Null
} else {
row.swap_remove(0)
}
})
.collect())
})
}
fn column_affinity(&self, select: &Select) -> Option<eval::Affinity> {
self.row_column_affinities(select)
.into_iter()
.next()
.flatten()
}
fn row_column_affinities(&self, select: &Select) -> Vec<Option<eval::Affinity>> {
// Each output column's affinity: a column inherits its declared affinity,
// a computed expression has none. Derived from the FROM sources' column
// metadata (no rows needed for the affinity itself).
let params = Params::default();
let Ok((columns, _)) = self.scan_source(select, ¶ms) else {
return Vec::new();
};
let ctx = row_ctx(&[], &columns, None, ¶ms);
let mut out = Vec::new();
for col in &select.columns {
match col {
ResultColumn::Expr { expr, .. } => out.push(eval::expr_affinity(expr, &ctx)),
ResultColumn::Wildcard => out.extend(
columns
.iter()
.filter(|c| !c.hidden)
.map(|c| Some(c.affinity)),
),
ResultColumn::TableWildcard(t) => out.extend(
columns
.iter()
.filter(|c| !c.hidden && c.table.eq_ignore_ascii_case(t))
.map(|c| Some(c.affinity)),
),
}
}
out
}
fn rows(&self, select: &Select, outer: &EvalCtx) -> Result<Vec<Vec<Value>>> {
self.with_outer_frame(outer, |params| Ok(self.run_select(select, params)?.rows))
}
fn exists(&self, select: &Select, outer: &EvalCtx) -> Result<bool> {
self.with_outer_frame(outer, |params| {
Ok(!self.run_select(select, params)?.rows.is_empty())
})
}
fn resolve_outer(&self, table: Option<&str>, name: &str) -> Option<Value> {
let scope = self.outer_scope.borrow();
for frame in scope.iter().rev() {
// A rowid alias, optionally qualified by the frame's label (e.g.
// `NEW.rowid`/`OLD.rowid` in a trigger, or `t.rowid` in a correlated
// subquery). A real column of that name in the frame wins.
if eval::is_rowid_alias(name) {
let qualifies = match table {
None => true,
Some(t) => frame
.columns
.iter()
.any(|c| c.table.eq_ignore_ascii_case(t)),
};
let has_real = frame.columns.iter().any(|c| {
c.name.eq_ignore_ascii_case(name)
&& table.is_none_or(|t| c.table.eq_ignore_ascii_case(t))
});
if qualifies
&& !has_real
&& let Some(r) = frame.rowid
{
return Some(Value::Integer(r));
}
}
for (i, col) in frame.columns.iter().enumerate() {
let name_ok = col.name.eq_ignore_ascii_case(name);
let table_ok = table.is_none_or(|t| col.table.eq_ignore_ascii_case(t));
if name_ok && table_ok {
return Some(frame.row[i].clone());
}
}
}
None
}
fn resolve_outer_affinity(&self, table: Option<&str>, name: &str) -> Option<eval::Affinity> {
let scope = self.outer_scope.borrow();
for frame in scope.iter().rev() {
// A correlated rowid alias carries INTEGER affinity (mirrors the value
// path above); a real column of that name in the frame still wins.
if eval::is_rowid_alias(name) {
let qualifies = table.is_none_or(|t| {
frame
.columns
.iter()
.any(|c| c.table.eq_ignore_ascii_case(t))
});
let has_real = frame.columns.iter().any(|c| {
c.name.eq_ignore_ascii_case(name)
&& table.is_none_or(|t| c.table.eq_ignore_ascii_case(t))
});
if qualifies && !has_real && frame.rowid.is_some() {
return Some(eval::Affinity::Integer);
}
}
for col in &frame.columns {
if col.name.eq_ignore_ascii_case(name)
&& table.is_none_or(|t| col.table.eq_ignore_ascii_case(t))
{
return Some(col.affinity);
}
}
}
None
}
}
/// Whether a value is the given text (used to match `sqlite_schema` columns).
fn is_text(v: &Value, s: &str) -> bool {
matches!(v, Value::Text(t) if t == s)
}
/// The declared column type as `PRAGMA table_info` reports it. SQLite stores one
/// of its *standard* type names (`sqlite3StdType`: `ANY`, `BLOB`, `INT`,
/// `INTEGER`, `REAL`, `TEXT`) by a shared uppercase spelling, so a column declared
/// `InTeGeR` (or `integer`, or bare ` int `, whose token span excludes the
/// surrounding whitespace) is reported as the canonical uppercase form. Any other
/// type text is preserved verbatim: a length spec (`VARCHAR(5)`, `INT(3)`), a
/// multi-word or non-standard name (`mediumint`, `numeric`), or — matching SQLite,
/// which compares the *stored* string without trimming — a quoted type whose
/// content carries interior whitespace (`"integer "` stays `integer `).
fn canonical_type_name(decl: &str) -> alloc::string::String {
for std in ["ANY", "BLOB", "INT", "INTEGER", "REAL", "TEXT"] {
if decl.eq_ignore_ascii_case(std) {
return alloc::string::String::from(std);
}
}
alloc::string::String::from(decl)
}
/// Collect `column = constant` equalities from the top-level `AND` conjuncts of a
/// `WHERE` clause, as `(column index, constant value)` pairs. Used to drive
/// index selection; non-equality and non-constant terms are ignored (the full
/// `WHERE` is still applied afterward).
/// Does `select` (across all its compound arms) read from a source named `name`?
fn references_name(select: &Select, name: &str) -> bool {
if references_name_select(select, name) {
return true;
}
select
.compound
.iter()
.any(|(_, s)| references_name_select(s, name))
}
/// Does this single `SELECT` arm read from a source named `name` (first table or
/// any joined table)?
fn references_name_select(select: &Select, name: &str) -> bool {
let Some(from) = &select.from else {
return false;
};
if from.first.name.eq_ignore_ascii_case(name) {
return true;
}
from.joins
.iter()
.any(|j| j.table.name.eq_ignore_ascii_case(name))
}
/// Collect (lowercased) every source name referenced anywhere in `select` — its
/// `FROM`/joins (descending into derived subqueries, join `ON` predicates and
/// TVF arguments), every clause expression's nested subqueries, and each compound
/// arm — but **not** its own `WITH` definitions (a nested `WITH` is a separate
/// scope). Used to decide which of an outer `WITH`'s CTEs are actually reachable:
/// SQLite never semantically analyzes an unused CTE, so a bad column/table inside
/// one is not an error, and graphite must skip materializing it likewise. The walk
/// over-approximates (it does not model alias shadowing), which is safe here — it
/// can only keep a CTE that could have been dropped, never drop a referenced one.
fn collect_source_names(select: &Select, out: &mut alloc::vec::Vec<alloc::string::String>) {
collect_source_names_arm(select, out);
for (_, s) in &select.compound {
collect_source_names_arm(s, out);
}
}
/// Restores the CTE environment to a saved depth when dropped. The VDBE path
/// materializes a whole-query `WITH` for the duration of source scanning (so a
/// `FROM` reference to a CTE can be pulled from the environment), and this guard
/// truncates it back on *every* exit — including the `?` early-returns scattered
/// through the source-scan branches — without an explicit `truncate` on each.
struct CteEnvGuard<'a> {
env: &'a core::cell::RefCell<alloc::vec::Vec<CteBinding>>,
base: usize,
}
impl core::ops::Drop for CteEnvGuard<'_> {
fn drop(&mut self) {
self.env.borrow_mut().truncate(self.base);
}
}
/// Like [`collect_source_names`], but for a nested subquery that opens its own
/// scope: any name it binds in its own `WITH` shadows an outer CTE of the same
/// name, so a reference to it does not reach our scope and is dropped. (The
/// compound arms of `select` share `select`'s `WITH`, so they are not a new scope
/// — that splitting is already handled inside `collect_source_names`.)
fn collect_scoped(select: &Select, out: &mut alloc::vec::Vec<alloc::string::String>) {
let mut inner = alloc::vec::Vec::new();
collect_source_names(select, &mut inner);
out.extend(
inner
.into_iter()
.filter(|n| !select.ctes.iter().any(|c| c.name.eq_ignore_ascii_case(n))),
);
}
fn collect_source_names_arm(select: &Select, out: &mut alloc::vec::Vec<alloc::string::String>) {
if let Some(from) = &select.from {
collect_tableref_sources(&from.first, out);
for j in &from.joins {
collect_tableref_sources(&j.table, out);
if let Some(on) = &j.on {
collect_expr_sources(on, out);
}
}
}
for c in &select.columns {
if let ResultColumn::Expr { expr, .. } = c {
collect_expr_sources(expr, out);
}
}
if let Some(w) = &select.where_clause {
collect_expr_sources(w, out);
}
for g in &select.group_by {
collect_expr_sources(g, out);
}
if let Some(h) = &select.having {
collect_expr_sources(h, out);
}
for (_, spec) in &select.window_defs {
collect_windowspec_sources(spec, out);
}
for t in &select.order_by {
collect_expr_sources(&t.expr, out);
}
if let Some(l) = &select.limit {
collect_expr_sources(l, out);
}
if let Some(o) = &select.offset {
collect_expr_sources(o, out);
}
}
fn collect_tableref_sources(tr: &TableRef, out: &mut alloc::vec::Vec<alloc::string::String>) {
if !tr.name.is_empty() {
out.push(tr.name.to_ascii_lowercase());
}
if let Some(sub) = &tr.subquery {
collect_scoped(sub, out);
}
if let Some(args) = &tr.tvf_args {
for a in args {
collect_expr_sources(a, out);
}
}
}
fn collect_windowspec_sources(spec: &WindowSpec, out: &mut alloc::vec::Vec<alloc::string::String>) {
for p in &spec.partition_by {
collect_expr_sources(p, out);
}
for t in &spec.order_by {
collect_expr_sources(&t.expr, out);
}
}
/// Walk `e` exhaustively, collecting source names from every nested `SELECT`
/// (scalar subquery, `EXISTS`, `IN (SELECT …)`). Exhaustive over `Expr` so a CTE
/// referenced only inside an obscure position (a `FILTER`, a window `ORDER BY`, a
/// row value) is still detected — missing one would wrongly drop a used CTE.
fn collect_expr_sources(e: &Expr, out: &mut alloc::vec::Vec<alloc::string::String>) {
match e {
Expr::Literal(_) | Expr::Parameter(_) | Expr::Column { .. } => {}
Expr::Unary { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Paren(expr)
| Expr::Collate { expr, .. } => collect_expr_sources(expr, out),
Expr::Binary { left, right, .. } => {
collect_expr_sources(left, out);
collect_expr_sources(right, out);
}
Expr::Function {
args,
filter,
order_by,
over,
..
} => {
for a in args {
collect_expr_sources(a, out);
}
if let Some(f) = filter {
collect_expr_sources(f, out);
}
for t in order_by {
collect_expr_sources(&t.expr, out);
}
if let Some(spec) = over {
collect_windowspec_sources(spec, out);
}
}
Expr::InList { expr, list, .. } => {
collect_expr_sources(expr, out);
for a in list {
collect_expr_sources(a, out);
}
}
Expr::Between {
expr, low, high, ..
} => {
collect_expr_sources(expr, out);
collect_expr_sources(low, out);
collect_expr_sources(high, out);
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
collect_expr_sources(o, out);
}
for (w, t) in when_then {
collect_expr_sources(w, out);
collect_expr_sources(t, out);
}
if let Some(el) = else_result {
collect_expr_sources(el, out);
}
}
Expr::RowValue(items) => {
for it in items {
collect_expr_sources(it, out);
}
}
Expr::Subquery(s) | Expr::Exists { select: s, .. } => collect_scoped(s, out),
Expr::InSelect { expr, select, .. } => {
collect_expr_sources(expr, out);
collect_scoped(select, out);
}
}
}
/// The reachability core behind [`used_cte_mask`]: given the source names the
/// consuming statement refers to directly (`seeds`), mark which `ctes` are
/// reachable, closing transitively (a used CTE pulls in siblings it names). Used
/// by both the `SELECT` path (seeds from the query body) and the `UPDATE`/
/// `DELETE` paths (seeds from the statement's `SET`/`FROM`/`WHERE`/`ORDER BY`/
/// `RETURNING`), so an unreferenced leading `WITH` CTE is never analyzed — a bad
/// column or table inside it is not an error, matching SQLite.
fn cte_mask_from_seeds(seeds: &[alloc::string::String], ctes: &[Cte]) -> alloc::vec::Vec<bool> {
let names: alloc::vec::Vec<alloc::string::String> =
ctes.iter().map(|c| c.name.to_ascii_lowercase()).collect();
let mut used = alloc::vec![false; ctes.len()];
let mut stack: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
let mark =
|refs: &[alloc::string::String], used: &mut [bool], stack: &mut alloc::vec::Vec<usize>| {
for r in refs {
if let Some(i) = names.iter().position(|n| n == r)
&& !used[i]
{
used[i] = true;
stack.push(i);
}
}
};
mark(seeds, &mut used, &mut stack);
while let Some(i) = stack.pop() {
// A CTE body opens its own scope: a nested `WITH` inside it shadows an outer
// sibling of the same name, so use the scope-aware collector here too.
let mut refs = alloc::vec::Vec::new();
collect_scoped(&ctes[i].select, &mut refs);
mark(&refs, &mut used, &mut stack);
}
used
}
/// Collect the source names an `UPDATE` body references — its `SET` values, the
/// row-value-subquery assignments, the `… FROM` sources and join `ON`, the
/// `WHERE`/`ORDER BY`/`LIMIT`/`OFFSET`, and the `RETURNING` projection. Feeds
/// [`cte_mask_from_seeds`] so an unreferenced leading `WITH` CTE can be skipped.
/// Must stay exhaustive over expression-bearing fields: a missed reference would
/// wrongly drop a CTE the statement uses, yielding a spurious `no such table`.
fn update_cte_seeds(upd: &Update) -> alloc::vec::Vec<alloc::string::String> {
let mut out = alloc::vec::Vec::new();
for (_, e) in &upd.assignments {
collect_expr_sources(e, &mut out);
}
for (_, sel) in &upd.row_assignments {
collect_scoped(sel, &mut out);
}
if let Some(from) = &upd.from {
collect_tableref_sources(&from.first, &mut out);
for j in &from.joins {
collect_tableref_sources(&j.table, &mut out);
if let Some(on) = &j.on {
collect_expr_sources(on, &mut out);
}
}
}
if let Some(w) = &upd.where_clause {
collect_expr_sources(w, &mut out);
}
for t in &upd.order_by {
collect_expr_sources(&t.expr, &mut out);
}
if let Some(l) = &upd.limit {
collect_expr_sources(l, &mut out);
}
if let Some(o) = &upd.offset {
collect_expr_sources(o, &mut out);
}
for c in &upd.returning {
if let ResultColumn::Expr { expr, .. } = c {
collect_expr_sources(expr, &mut out);
}
}
out
}
/// Collect the source names a `DELETE` body references (its `WHERE`/`ORDER BY`/
/// `LIMIT`/`OFFSET` and `RETURNING`). The `DELETE` counterpart of
/// [`update_cte_seeds`]; see it for the exhaustiveness requirement.
fn delete_cte_seeds(del: &Delete) -> alloc::vec::Vec<alloc::string::String> {
let mut out = alloc::vec::Vec::new();
if let Some(w) = &del.where_clause {
collect_expr_sources(w, &mut out);
}
for t in &del.order_by {
collect_expr_sources(&t.expr, &mut out);
}
if let Some(l) = &del.limit {
collect_expr_sources(l, &mut out);
}
if let Some(o) = &del.offset {
collect_expr_sources(o, &mut out);
}
for c in &del.returning {
if let ResultColumn::Expr { expr, .. } = c {
collect_expr_sources(expr, &mut out);
}
}
out
}
/// Collect the source names an `INSERT` body references — the `SELECT` source or
/// the subqueries inside its `VALUES` expressions, plus any `ON CONFLICT … DO
/// UPDATE` assignments/`WHERE` and `RETURNING` expressions. Feeds
/// [`cte_mask_from_seeds`] so an unreferenced leading `WITH` CTE is never
/// analyzed (a bad column/table inside it is not an error, matching SQLite).
fn insert_cte_seeds(ins: &Insert) -> alloc::vec::Vec<alloc::string::String> {
let mut out = alloc::vec::Vec::new();
match &ins.source {
InsertSource::Select(sel) => collect_scoped(sel, &mut out),
InsertSource::Values(rows) => {
for row in rows {
for e in row {
collect_expr_sources(e, &mut out);
}
}
}
InsertSource::DefaultValues => {}
}
for up in &ins.upsert {
if let UpsertAction::Update {
assignments,
where_clause,
} = &up.action
{
for (_, e) in assignments {
collect_expr_sources(e, &mut out);
}
if let Some(w) = where_clause {
collect_expr_sources(w, &mut out);
}
}
}
for c in &ins.returning {
if let ResultColumn::Expr { expr, .. } = c {
collect_expr_sources(expr, &mut out);
}
}
out
}
/// Count the FROM-clause references (the leading table plus every joined table)
/// to a source named `name` in a single `SELECT` arm. SQLite lets a recursive
/// CTE's recursive term name the recursive table only once in its FROM, so a
/// count above 1 is the `multiple references to recursive table` error.
fn from_reference_count(select: &Select, name: &str) -> usize {
let Some(from) = &select.from else {
return 0;
};
let mut n = usize::from(from.first.name.eq_ignore_ascii_case(name));
n += from
.joins
.iter()
.filter(|j| j.table.name.eq_ignore_ascii_case(name))
.count();
n
}
/// Is `e` a bare column reference (ignoring transparent `(…)`/`COLLATE` wrappers)?
/// A scalar subquery projecting one is only foldable with care — it would carry
/// that column's affinity/collation, which a plain literal does not — so the
/// scalar fold excludes this case.
fn is_bare_column_expr(e: &Expr) -> bool {
match e {
Expr::Column { .. } => true,
Expr::Paren(inner) | Expr::Collate { expr: inner, .. } => is_bare_column_expr(inner),
_ => false,
}
}
/// The canonical SQL type name whose declared-type affinity rule
/// (`Affinity::from_type`) round-trips back to `aff` — so a folded bare-column
/// `IN (SELECT col)` can carry the candidate column's affinity through the AST as
/// a type-name `String` (keeping `ast.rs` free of `eval` types).
fn affinity_type_name(aff: eval::Affinity) -> alloc::string::String {
match aff {
eval::Affinity::Integer => "INTEGER",
eval::Affinity::Text => "TEXT",
eval::Affinity::Real => "REAL",
eval::Affinity::Numeric => "NUMERIC",
eval::Affinity::Blob => "BLOB",
}
.into()
}
/// Compare two ordering-key vectors with per-position `descending` flags
/// (missing flags default to ascending).
/// Compare two key tuples lexicographically, each position under its own
/// collation (`colls[i]`, defaulting to `BINARY` past the end) and `DESC` flag
/// (`desc[i]`, defaulting to ascending). Used by the window machinery so a
/// `PARTITION BY`/`ORDER BY … COLLATE NOCASE` orders and groups peers the way
/// the collation dictates, matching sqlite.
fn cmp_keys_coll(
a: &[Value],
b: &[Value],
desc: &[bool],
colls: &[crate::value::Collation],
) -> core::cmp::Ordering {
use core::cmp::Ordering;
for (i, (x, y)) in a.iter().zip(b).enumerate() {
let o = cmp_order(
x,
y,
desc.get(i).copied().unwrap_or(false),
None,
colls
.get(i)
.copied()
.unwrap_or(crate::value::Collation::Binary),
);
if o != Ordering::Equal {
return o;
}
}
Ordering::Equal
}
/// Like [`cmp_keys_coll`], but also honors each key's explicit `NULLS
/// FIRST`/`LAST` (`nulls[i]`; `None` ⇒ SQLite's default placement). Used by the
/// window-partition sort, where a `NULLS FIRST`/`LAST` on the window `ORDER BY`
/// must move NULLs off their default end.
fn cmp_keys_coll_nulls(
a: &[Value],
b: &[Value],
desc: &[bool],
nulls: &[Option<bool>],
colls: &[crate::value::Collation],
) -> core::cmp::Ordering {
use core::cmp::Ordering;
for (i, (x, y)) in a.iter().zip(b).enumerate() {
let o = cmp_order(
x,
y,
desc.get(i).copied().unwrap_or(false),
nulls.get(i).copied().flatten(),
colls
.get(i)
.copied()
.unwrap_or(crate::value::Collation::Binary),
);
if o != Ordering::Equal {
return o;
}
}
Ordering::Equal
}
/// Compare two `ORDER BY` key values honoring `DESC` and NULL placement. NULL
/// ordering follows the explicit `NULLS FIRST`/`LAST` when given, else SQLite's
/// default (NULLs first under `ASC`, last under `DESC`); the non-NULL comparison
/// uses the column collation and is reversed by `DESC`.
pub(crate) fn cmp_order(
a: &Value,
b: &Value,
descending: bool,
nulls_first: Option<bool>,
coll: crate::value::Collation,
) -> core::cmp::Ordering {
use core::cmp::Ordering;
let a_null = matches!(a, Value::Null);
let b_null = matches!(b, Value::Null);
let nulls_first = nulls_first.unwrap_or(!descending);
match (a_null, b_null) {
(true, true) => Ordering::Equal,
(true, false) => {
if nulls_first {
Ordering::Less
} else {
Ordering::Greater
}
}
(false, true) => {
if nulls_first {
Ordering::Greater
} else {
Ordering::Less
}
}
(false, false) => {
let ord = crate::value::cmp_values_coll(a, b, coll);
if descending { ord.reverse() } else { ord }
}
}
}
/// The `[start, end)` frame indices (into the ordered partition) for position
/// `p`, given peer-group ids `gid` and the window `spec`.
///
/// With no explicit frame: the whole partition when there is no `ORDER BY`, else
/// `UNBOUNDED PRECEDING` through the current row's last peer — SQLite's default.
/// `ROWS` frames use physical offsets; `RANGE`/`GROUPS` use peer-group offsets.
/// Resolve a window function's `OVER name` (or `OVER (name …)`) reference against
/// the query's `WINDOW name AS (…)` definitions, returning a clone of `wexpr`
/// whose spec is the effective one. A spec with no `base_name` is returned as-is.
fn resolve_window_ref(wexpr: &Expr, defs: &[(String, WindowSpec)]) -> Result<Expr> {
let Expr::Function {
name,
distinct,
args,
star,
filter,
order_by,
over: Some(spec),
..
} = wexpr
else {
return Ok(wexpr.clone());
};
let Some(base) = &spec.base_name else {
return Ok(wexpr.clone());
};
let def = defs
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(base))
.map(|(_, s)| s)
.ok_or_else(|| Error::Error(alloc::format!("no such window: {base}")))?;
// A *parenthesized* base reference (`OVER (base …)`) may extend the base only
// where the base leaves room: it cannot add a PARTITION BY, cannot add an
// ORDER BY when the base already has one, and cannot supply/override a frame
// when the base already carries one — SQLite's `sqlite3WindowChain`, checked
// in that order. The bare `OVER base` form uses the base verbatim and is
// exempt.
if spec.base_parenthesized {
let zerr = if !spec.partition_by.is_empty() {
Some("PARTITION clause")
} else if !def.order_by.is_empty() && !spec.order_by.is_empty() {
Some("ORDER BY clause")
} else if def.frame.is_some() {
Some("frame specification")
} else {
None
};
if let Some(zerr) = zerr {
return Err(Error::Error(alloc::format!(
"cannot override {zerr} of window: {base}"
)));
}
}
// The named window provides PARTITION BY; the referencing use may add ORDER BY
// and a frame when the base omits them.
let effective = WindowSpec {
partition_by: def.partition_by.clone(),
order_by: if spec.order_by.is_empty() {
def.order_by.clone()
} else {
spec.order_by.clone()
},
frame: spec.frame.clone().or_else(|| def.frame.clone()),
base_name: None,
base_parenthesized: false,
};
Ok(Expr::Function {
name: name.clone(),
distinct: *distinct,
args: args.clone(),
star: *star,
filter: filter.clone(),
order_by: order_by.clone(),
over: Some(effective),
span: Span::none(),
})
}
/// Emit one `json_each`/`json_tree` row for `node`. `key` is the member name /
/// array index (None for a top-level scalar or the `json_tree` root);
/// `fullkey`/`path` are the element's path and its parent's. `id` is the node's
/// byte offset within the document's JSONB encoding (an object member is
/// numbered by its *key* node, matching SQLite).
fn json_emit_node(
node: &crate::exec::json::Json,
key: Option<Value>,
fullkey: &str,
path: &str,
id: i64,
parent: Option<i64>,
rows: &mut Vec<Vec<Value>>,
) {
use crate::exec::json::Json;
let is_container = matches!(node, Json::Object(_) | Json::Array(_));
let value = node.to_sql();
let atom = if is_container {
Value::Null
} else {
value.clone()
};
rows.push(alloc::vec![
key.unwrap_or(Value::Null),
value,
Value::Text(String::from(node.type_name()).into()),
atom,
Value::Integer(id),
parent.map(Value::Integer).unwrap_or(Value::Null),
Value::Text(String::from(fullkey).into()),
Value::Text(String::from(path).into()),
]);
}
/// `json_each`: emit a row for each *direct* child of `root` (or a single row for
/// a scalar root). `root_path` is the document path `root` sits at (`"$"`, or the
/// `json_each(x, path)` argument), used as the prefix of each child's `fullkey`.
/// `base_off` is `root`'s byte offset within the document's JSONB and `base_id`
/// the id `root` itself would carry; each child's id is its own JSONB byte
/// offset (an object member's *key* node). `json_each` never recurses, so every
/// row's `parent` is NULL.
fn json_each_children(
root: &crate::exec::json::Json,
root_path: &str,
base_off: i64,
base_id: i64,
rows: &mut Vec<Vec<Value>>,
) {
use crate::exec::json::Json;
let body = base_off + root.jsonb_header_bytes() as i64;
match root {
Json::Object(members) => {
let mut at = body;
for (k, kraw, v) in members {
let key_off = at;
let klen = crate::exec::json::str_prov_jsonb_len(k, kraw) as i64;
let fullkey = crate::exec::json::push_path_key_prov(root_path, k, kraw);
json_emit_node(
v,
Some(Value::Text(k.clone().into())),
&fullkey,
root_path,
key_off,
None,
rows,
);
at += klen + v.jsonb_len() as i64;
}
}
Json::Array(items) => {
let mut at = body;
for (i, v) in items.iter().enumerate() {
let fullkey = alloc::format!("{root_path}[{i}]");
json_emit_node(
v,
Some(Value::Integer(i as i64)),
&fullkey,
root_path,
at,
None,
rows,
);
at += v.jsonb_len() as i64;
}
}
scalar => {
json_emit_node(scalar, None, root_path, root_path, base_id, None, rows);
}
}
}
/// Promote each integer-serialized value sitting in a REAL-affinity column back
/// to a real, as SQLite does when reading a column: an integer-valued real is
/// stored using an integer serial type (the `MEM_IntReal` space optimization), so
/// `100.0` in a `REAL` column is on disk as the integer `100`, and reading it must
/// realify it (`typeof` = `real`, renders/compares as a float). Only strict REAL
/// affinity promotes; NUMERIC keeps integers. Applied at every point a stored
/// record — table row or covering-index record — is mapped onto declared columns.
fn promote_real_columns(meta: &TableMeta, values: &mut [Value]) {
for (i, col) in meta.columns.iter().enumerate() {
if col.affinity == eval::Affinity::Real
&& let Value::Integer(n) = values[i]
{
values[i] = Value::Real(n as f64);
}
}
}
/// The i64 SQLite stores a REAL value as, on disk, when it can (the write side of
/// [`promote_real_columns`], modelling `MEM_IntReal`): a REAL-affinity column
/// holding a whole-number real encodes with the compact integer serial type, not
/// an 8-byte float. This is `sqlite3VdbeIntegerAffinity`: the real must round-trip
/// through i64 exactly, and the integer must be neither `i64::MIN` nor `i64::MAX`
/// (ticket #3922 — those bounds are excluded to keep overflow arithmetic safe).
/// The value reads back as REAL via `promote_real_columns`, so this is a pure
/// storage-encoding choice with no effect on results.
fn real_intreal_i64(r: f64) -> Option<i64> {
if !r.is_finite() {
return None;
}
let ix = r as i64;
(r == ix as f64 && ix > i64::MIN && ix < i64::MAX).then_some(ix)
}
/// Substitute the compact integer serial encoding for a whole-number real in each
/// REAL-affinity column, so a written record byte-matches SQLite (which stores
/// such values as `MEM_IntReal`). Returns a copy; the in-memory row is untouched.
fn realify_columns_for_storage(meta: &TableMeta, values: &[Value]) -> Vec<Value> {
let mut out = values.to_vec();
for (i, col) in meta.columns.iter().enumerate() {
if col.affinity == eval::Affinity::Real
&& let Value::Real(r) = out[i]
&& let Some(ix) = real_intreal_i64(r)
{
out[i] = Value::Integer(ix);
}
}
out
}
/// Split a `json_tree` root path (`$.a.b`, `$.a[2]`, `$[0]`) into the parent path
/// reported in the `path` column and the final component reported as the root
/// `key`. The bare root `$` yields `("$", None)`.
///
/// This is a port of SQLite's `jsonEachPathLength`: the split is placed at the
/// last `.`/`[` whose prefix resolves to a container whose *first* child is
/// exactly the target node (`target_id` is that node's reported `id`). Only then
/// does the trailing component become the key; otherwise the whole suffix after
/// `$.` becomes the key and `path` collapses to `$`. So `json_tree(J,'$.b[0]')`
/// reports `key=0, path=$.b`, but `json_tree(J,'$.b[2]')` — where `b[2]` is *not*
/// the array's first element — reports `key='b[2]', path=$`, matching SQLite's
/// (quirky but authoritative) behaviour.
fn split_json_path(
root: &crate::exec::json::Json,
path: &str,
target_id: i64,
) -> (alloc::string::String, Option<Value>) {
let bytes = path.as_bytes();
let mut j = bytes.len();
while j > 1 {
j -= 1;
if (bytes[j] == b'[' || bytes[j] == b'.')
&& let Some((node, voff, _)) = crate::exec::json::navigate_with_offset(root, &path[..j])
&& voff as i64 + node.jsonb_header_bytes() as i64 == target_id
{
break;
}
}
if j >= bytes.len() {
return (String::from(path), None); // the bare `$` root
}
let parent = String::from(&path[..j]);
let key = if bytes[j] == b'[' {
// SQLite reads the leading integer with `sqlite3Atoi64`, which stops at
// the `]` — so a collapsed `$[1].c` yields key `1`, not the whole suffix.
let n: i64 = path[j + 1..]
.bytes()
.take_while(u8::is_ascii_digit)
.fold(0, |acc, b| acc * 10 + (b - b'0') as i64);
Value::Integer(n)
} else {
// `bytes[j] == '.'`: SQLite strips a surrounding pair of quotes only when
// the character right after the `.` is a `"` (taking `n-3` bytes); any
// other suffix — including a collapsed multi-segment tail like `b."x y"` —
// is the raw remainder verbatim.
let rest = &path[j + 1..];
let name = if rest.as_bytes().first() == Some(&b'"') && rest.len() >= 2 {
&rest[1..rest.len() - 1]
} else {
rest
};
Value::Text(String::from(name).into())
};
(parent, Some(key))
}
/// A node's location in the document's JSONB blob, threaded through the
/// `json_tree` walk: `value_off` is the node's own element offset, and `id` is
/// the offset SQLite reports for it — its *key* node when the node is an object
/// member, else `value_off` itself.
#[derive(Clone, Copy)]
struct JsonbPos {
value_off: i64,
id: i64,
}
/// `json_tree`: emit `node` then recurse depth-first into its children. Each
/// child's id is its own JSONB byte offset (computed via [`JsonbPos`]), and its
/// `parent` is this node's id.
fn json_tree_walk(
node: &crate::exec::json::Json,
key: Option<Value>,
fullkey: &str,
path: &str,
pos: JsonbPos,
parent: Option<i64>,
rows: &mut Vec<Vec<Value>>,
) {
use crate::exec::json::Json;
json_emit_node(node, key, fullkey, path, pos.id, parent, rows);
let body = pos.value_off + node.jsonb_header_bytes() as i64;
match node {
Json::Object(members) => {
let mut at = body;
for (k, kraw, v) in members {
let key_off = at;
let val_off = at + crate::exec::json::str_prov_jsonb_len(k, kraw) as i64;
let child = crate::exec::json::push_path_key_prov(fullkey, k, kraw);
json_tree_walk(
v,
Some(Value::Text(k.clone().into())),
&child,
fullkey,
JsonbPos {
value_off: val_off,
id: key_off,
},
Some(pos.id),
rows,
);
at = val_off + v.jsonb_len() as i64;
}
}
Json::Array(items) => {
let mut at = body;
for (i, v) in items.iter().enumerate() {
let child = alloc::format!("{fullkey}[{i}]");
json_tree_walk(
v,
Some(Value::Integer(i as i64)),
&child,
fullkey,
JsonbPos {
value_off: at,
id: at,
},
Some(pos.id),
rows,
);
at += v.jsonb_len() as i64;
}
}
_ => {}
}
}
/// A window frame whose `<offset> PRECEDING/FOLLOWING` bounds have been evaluated
/// to numbers. SQLite accepts any constant expression as a frame offset and
/// validates it at run time (once the partition has a row); this is the resolved
/// form, computed once per partition by [`resolve_frame`].
struct ResolvedFrame {
mode: FrameMode,
start: ResolvedBound,
end: ResolvedBound,
}
/// A frame bound with its offset already evaluated (see [`ResolvedFrame`]).
enum ResolvedBound {
UnboundedPreceding,
Preceding(f64),
CurrentRow,
Following(f64),
UnboundedFollowing,
}
/// Whether `e` is a constant offset expression: a literal, or operators applied
/// to constants. SQLite allows arbitrary constants as frame offsets (`(1+1)`,
/// `2.0`) but rejects anything that reads a row — a column, a function call, a
/// subquery — with the same "must be a non-negative integer/number" message.
fn is_const_offset_expr(e: &Expr) -> bool {
match e {
Expr::Literal(_) => true,
Expr::Unary { expr, .. }
| Expr::Paren(expr)
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. } => is_const_offset_expr(expr),
Expr::Binary { left, right, .. } => {
is_const_offset_expr(left) && is_const_offset_expr(right)
}
_ => false,
}
}
/// Evaluate a constant frame offset to a non-negative number. `ROWS`/`GROUPS`
/// require a non-negative integer; `RANGE` allows a non-negative number. SQLite
/// applies numeric affinity, so `'2'` and `'2.0'` work but `'2x'`, a blob, or
/// NULL error.
fn eval_frame_offset(e: &Expr, mode: FrameMode, is_start: bool) -> Result<f64> {
let bad = || {
let pos = if is_start { "starting" } else { "ending" };
let kind = if mode == FrameMode::Range {
"number"
} else {
"integer"
};
Error::Error(alloc::format!(
"frame {pos} offset must be a non-negative {kind}"
))
};
if !is_const_offset_expr(e) {
return Err(bad());
}
let raw = const_value(e, &Params::default()).ok_or_else(bad)?;
match (mode, eval::Affinity::Numeric.coerce(raw)) {
// RANGE: any non-negative number (fractions allowed).
(FrameMode::Range, Value::Integer(n)) if n >= 0 => Ok(n as f64),
(FrameMode::Range, Value::Real(r)) if r.is_finite() && r >= 0.0 => Ok(r),
// ROWS/GROUPS: a non-negative integer (an integral real is accepted too).
(_, Value::Integer(n)) if n >= 0 => Ok(n as f64),
(_, Value::Real(r)) if r.is_finite() && r >= 0.0 && r == crate::util::float::trunc(r) => {
Ok(r)
}
_ => Err(bad()),
}
}
/// Evaluate every offset in `frame` once, validating it (see [`eval_frame_offset`]).
fn resolve_frame(frame: &WindowFrame) -> Result<ResolvedFrame> {
let resolve = |b: &FrameBound, is_start: bool| -> Result<ResolvedBound> {
Ok(match b {
FrameBound::UnboundedPreceding => ResolvedBound::UnboundedPreceding,
FrameBound::CurrentRow => ResolvedBound::CurrentRow,
FrameBound::UnboundedFollowing => ResolvedBound::UnboundedFollowing,
FrameBound::Preceding(e) => {
ResolvedBound::Preceding(eval_frame_offset(e, frame.mode, is_start)?)
}
FrameBound::Following(e) => {
ResolvedBound::Following(eval_frame_offset(e, frame.mode, is_start)?)
}
})
};
Ok(ResolvedFrame {
mode: frame.mode,
start: resolve(&frame.start, true)?,
end: resolve(&frame.end, false)?,
})
}
fn frame_bounds(
p: usize,
m: usize,
gid: &[usize],
frame: Option<&ResolvedFrame>,
order_by_empty: bool,
ovals: &[Value],
desc: bool,
) -> (usize, usize) {
let Some(frame) = frame else {
if order_by_empty {
return (0, m);
}
// Default: UNBOUNDED PRECEDING .. CURRENT ROW (peers included).
let mut e = p + 1;
while e < m && gid[e] == gid[p] {
e += 1;
}
return (0, e);
};
let (start, end) = match frame.mode {
FrameMode::Rows => (
row_bound(&frame.start, p, m, true),
row_bound(&frame.end, p, m, false),
),
// RANGE with a numeric offset bounds the frame by the ORDER BY *value*
// (within `value ± n`); CURRENT ROW / UNBOUNDED still use peer groups.
FrameMode::Range
if !ovals.is_empty()
&& (matches!(
frame.start,
ResolvedBound::Preceding(_) | ResolvedBound::Following(_)
) || matches!(
frame.end,
ResolvedBound::Preceding(_) | ResolvedBound::Following(_)
)) =>
{
(
range_value_bound(&frame.start, p, m, gid, ovals, desc, true),
range_value_bound(&frame.end, p, m, gid, ovals, desc, false),
)
}
FrameMode::Range | FrameMode::Groups => (
group_bound(&frame.start, p, m, gid, true),
group_bound(&frame.end, p, m, gid, false),
),
};
let start = start.min(m);
(start, end.min(m).max(start))
}
/// A `RANGE` frame bound measured by the ORDER BY value: the frame includes rows
/// whose value is within `[value - start_n, value + end_n]` (signs flipped for a
/// `DESC` ordering). `CURRENT ROW` and `UNBOUNDED` fall back to peer-group edges.
/// Falls back to peer-group edges if the current value is not numeric.
fn range_value_bound(
b: &ResolvedBound,
p: usize,
m: usize,
gid: &[usize],
ovals: &[Value],
desc: bool,
is_start: bool,
) -> usize {
// A NULL current value has no numeric range: NULLs form their own peer group,
// so a PRECEDING/FOLLOWING offset collapses to the current (NULL) peer group
// rather than spanning into the adjacent value groups — matching sqlite.
// (UNBOUNDED bounds are handled below and stay unbounded.)
if matches!(
b,
ResolvedBound::CurrentRow | ResolvedBound::Preceding(_) | ResolvedBound::Following(_)
) && matches!(ovals[p], Value::Null)
{
return group_bound(&ResolvedBound::CurrentRow, p, m, gid, is_start);
}
let val = eval::to_f64(&ovals[p]);
// The frame edge as an ORDER BY value. Under ASC, PRECEDING subtracts and
// FOLLOWING adds; under DESC the sequence decreases so the signs flip.
let threshold = match b {
ResolvedBound::UnboundedPreceding => return 0,
ResolvedBound::UnboundedFollowing => return m,
ResolvedBound::CurrentRow => val,
ResolvedBound::Preceding(n) => {
if desc {
val + *n
} else {
val - *n
}
}
ResolvedBound::Following(n) => {
if desc {
val - *n
} else {
val + *n
}
}
};
// Values run ascending (ASC) or descending (DESC) across positions. The frame
// is the contiguous span of rows on the inclusive side of `threshold`.
let inside = |vk: f64, edge: f64| if desc { vk >= edge } else { vk <= edge };
if is_start {
// First row at/after the start edge.
(0..m)
.find(|&k| {
!matches!(ovals[k], Value::Null) && {
let vk = eval::to_f64(&ovals[k]);
if desc {
vk <= threshold
} else {
vk >= threshold
}
}
})
.unwrap_or(m)
} else {
// One past the last *non-NULL* row at/before the end edge. NULL rows are
// never in a numeric range frame, so trailing NULLs (which sort last under
// DESC) must not extend the end — track the last in-frame row instead of
// defaulting to `m`.
let mut e = 0;
for (k, ov) in ovals.iter().enumerate().take(m) {
if matches!(ov, Value::Null) {
continue;
}
if inside(eval::to_f64(ov), threshold) {
e = k + 1;
} else {
break;
}
}
e
}
}
/// A `ROWS` frame bound as an index; `is_start` selects inclusive-start vs
/// exclusive-end semantics.
fn row_bound(b: &ResolvedBound, p: usize, m: usize, is_start: bool) -> usize {
match (b, is_start) {
(ResolvedBound::UnboundedPreceding, _) => 0,
(ResolvedBound::UnboundedFollowing, _) => m,
(ResolvedBound::CurrentRow, true) => p,
(ResolvedBound::CurrentRow, false) => p + 1,
(ResolvedBound::Preceding(n), true) => p.saturating_sub(*n as usize),
(ResolvedBound::Preceding(n), false) => (p + 1).saturating_sub(*n as usize),
(ResolvedBound::Following(n), true) => (p + *n as usize).min(m),
(ResolvedBound::Following(n), false) => (p + 1 + *n as usize).min(m),
}
}
/// A `RANGE`/`GROUPS` frame bound, measured in peer groups.
fn group_bound(b: &ResolvedBound, p: usize, m: usize, gid: &[usize], is_start: bool) -> usize {
let maxg = if m == 0 { 0 } else { gid[m - 1] as i64 };
let target = |g: i64| -> i64 { gid[p] as i64 + g };
// First ordered index of peer-group `g` (clamped: below 0 -> 0, above max -> m).
let first_of = |g: i64| -> usize {
if g < 0 {
0
} else if g > maxg {
m
} else {
(0..m).find(|&i| gid[i] as i64 == g).unwrap_or(m)
}
};
// One past the last ordered index of peer-group `g` (same clamping).
let after_last_of = |g: i64| -> usize {
if g < 0 {
0
} else if g > maxg {
m
} else {
(0..m)
.rev()
.find(|&i| gid[i] as i64 == g)
.map_or(0, |i| i + 1)
}
};
match (b, is_start) {
(ResolvedBound::UnboundedPreceding, _) => 0,
(ResolvedBound::UnboundedFollowing, _) => m,
(ResolvedBound::CurrentRow, true) => first_of(target(0)),
(ResolvedBound::CurrentRow, false) => after_last_of(target(0)),
(ResolvedBound::Preceding(n), true) => first_of(target(-(*n as i64))),
(ResolvedBound::Preceding(n), false) => after_last_of(target(-(*n as i64))),
(ResolvedBound::Following(n), true) => first_of(target(*n as i64)),
(ResolvedBound::Following(n), false) => after_last_of(target(*n as i64)),
}
}
/// The 1-based `ntile` bucket for ordered position `p` of `m` rows split into
/// `buckets` groups (earlier groups absorb the remainder).
fn ntile_bucket(p: usize, m: usize, buckets: i64) -> i64 {
let buckets = (buckets.max(1) as usize).min(m.max(1));
let size = m / buckets;
let rem = m % buckets;
let big = rem * (size + 1);
if p < big {
(p / (size + 1)) as i64 + 1
} else {
(rem + (p - big) / size.max(1)) as i64 + 1
}
}
/// Evaluate an aggregate window function over a frame of per-row argument
/// values, matching `compute_aggregate`'s numeric semantics.
fn window_aggregate(lname: &str, star: bool, frame: &[&Vec<Value>]) -> Result<Value> {
let mut vals: Vec<Value> = Vec::new();
for row in frame {
if star {
continue;
}
if let Some(v) = row.first()
&& !matches!(v, Value::Null)
{
vals.push(v.clone());
}
}
Ok(match lname {
"count" => {
if star {
Value::Integer(frame.len() as i64)
} else {
Value::Integer(vals.len() as i64)
}
}
"sum" => eval::sum_values(&vals)?,
"total" => Value::Real(eval::total_value(&vals)),
"avg" => match eval::avg_value(&vals) {
Some(r) => Value::Real(r),
None => Value::Null,
},
"min" => vals
.into_iter()
.reduce(|a, b| {
if eval::compare(&b, &a) == core::cmp::Ordering::Less {
b
} else {
a
}
})
.unwrap_or(Value::Null),
"max" => vals
.into_iter()
.reduce(|a, b| {
if eval::compare(&b, &a) == core::cmp::Ordering::Greater {
b
} else {
a
}
})
.unwrap_or(Value::Null),
"group_concat" | "string_agg" => {
if vals.is_empty() {
Value::Null
} else {
// The optional 2nd argument is the separator (default ","), the
// same for every row of the frame.
let sep = frame
.first()
.and_then(|r| r.get(1))
.map(eval::to_text)
.unwrap_or_else(|| String::from(","));
let parts: Vec<String> = vals.iter().map(eval::to_text).collect();
Value::Text(parts.join(&sep).into())
}
}
_ => return Err(Error::Unsupported("window function")),
})
}
/// Dedupe rows in place, preserving first-occurrence order.
fn dedup_rows(rows: &mut Vec<Vec<Value>>) {
let mut seen: Vec<Vec<Value>> = Vec::new();
rows.retain(|row| {
if seen.iter().any(|s| rows_equal(s, row)) {
false
} else {
seen.push(row.clone());
true
}
});
}
/// A `PRAGMA name = value` argument as text (a bare keyword like `WAL` or a
/// quoted string).
fn pragma_text(e: &Expr) -> String {
match e {
Expr::Column { column, .. } => column.clone(),
Expr::Literal(Literal::Str(s)) => s.clone(),
_ => String::new(),
}
}
/// Parse the leading integer of a `PRAGMA name = value` text argument the way
/// SQLite's `sqlite3Atoi` does: an optional sign, then either a `0x` hex run or
/// a decimal run, taking the leading prefix and stopping at the first character
/// that does not fit. Unlike a `CAST … AS INTEGER` it does *not* skip leading
/// whitespace, so `' 7 '` is `0`. A purely non-numeric token (e.g. `abc`) is `0`.
fn pragma_atoi(s: &str) -> i64 {
let b = s.as_bytes();
let mut i = 0;
let neg = match b.first() {
Some(b'-') => {
i = 1;
true
}
Some(b'+') => {
i = 1;
false
}
_ => false,
};
let mut v: i64 = 0;
if b.len() > i + 1 && b[i] == b'0' && (b[i + 1] | 0x20) == b'x' {
i += 2;
while i < b.len() {
let d = match b[i] {
d @ b'0'..=b'9' => d - b'0',
d @ b'a'..=b'f' => d - b'a' + 10,
d @ b'A'..=b'F' => d - b'A' + 10,
_ => break,
};
v = v.wrapping_mul(16).wrapping_add(d as i64);
i += 1;
}
} else {
while i < b.len() && b[i].is_ascii_digit() {
v = v.wrapping_mul(10).wrapping_add((b[i] - b'0') as i64);
i += 1;
}
}
if neg { v.wrapping_neg() } else { v }
}
/// Interpret a header-cookie `PRAGMA` argument (`user_version`, `application_id`)
/// as SQLite does: an integer token, never a SQL expression. A bare identifier
/// or a string is run through [`pragma_atoi`] (so `abc` is `0`, not the
/// `no such column` error a general expression evaluation would raise); a
/// genuine numeric literal or expression keeps its evaluated integer value.
fn pragma_header_int(e: &Expr, params: &Params) -> Result<u32> {
let v: i64 = match e {
Expr::Column { column, .. } => pragma_atoi(column),
Expr::Literal(Literal::Str(s)) => pragma_atoi(s),
_ => eval::to_i64(&eval::eval(e, &EvalCtx::rowless(params))?),
};
Ok(v as u32)
}
/// Interpret a `PRAGMA name = value` argument as a boolean, accepting
/// `1`/`0`, `on`/`off`, `yes`/`no`, `true`/`false`.
fn pragma_truth(e: &Expr, params: &Params) -> bool {
match e {
Expr::Column { column, .. } => {
matches!(column.to_ascii_lowercase().as_str(), "on" | "yes" | "true")
}
Expr::Literal(Literal::Str(s)) => {
matches!(s.to_ascii_lowercase().as_str(), "on" | "yes" | "true" | "1")
}
_ => eval::eval(e, &EvalCtx::rowless(params))
.map(|v| eval::to_i64(&v) != 0)
.unwrap_or(false),
}
}
/// The EXPLAIN QUERY PLAN display label for a table reference: SQLite names the
/// scan by its *alias* alone when one is present (`SCAN x`, not `SCAN t AS x`),
/// else by the table name. The lone exception is the bare `count(*)` covering-index
/// optimization, which SQLite labels with the table name even when aliased — that
/// caller passes the name directly rather than this label.
fn eqp_label(t: &TableRef) -> String {
match &t.alias {
Some(a) => a.clone(),
None => t.name.clone(),
}
}
/// Whether every column the `WHERE` expression references is covered by the
/// index (`idx_cols`) or is the rowid — the seek-covering precondition. Walks the
/// expression tree and returns `false` the moment it finds an uncovered column,
/// an unknown column name that is not a rowid alias, or a construct whose columns
/// can't be enumerated locally (a scalar subquery / `EXISTS` / `IN (SELECT …)`),
/// so the caller conservatively falls back to the table-fetch path.
/// Is a partial index's predicate guaranteed by a top-level conjunct of the
/// `WHERE`? Always true for a non-partial index.
fn partial_pred_guaranteed(idx: &IndexMeta, where_expr: &Expr) -> bool {
match &idx.partial {
None => true,
Some(pred) => {
let mut conjuncts = Vec::new();
and_conjuncts(where_expr, &mut conjuncts);
conjuncts.iter().any(|c| expr_eq_modulo_parens(c, pred))
}
}
}
/// Find a conjunct `<key_expr> IN (const, …)` (walking top-level `AND`s) and
/// return the evaluated list values — the expression-index analogue of
/// [`find_in_constraint`].
fn find_expr_in_values(key_expr: &Expr, e: &Expr, params: &Params) -> Option<Vec<Value>> {
match e {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => find_expr_in_values(key_expr, left, params)
.or_else(|| find_expr_in_values(key_expr, right, params)),
Expr::Paren(inner) => find_expr_in_values(key_expr, inner, params),
Expr::InList {
expr,
list,
negated: false,
..
} => {
if list.is_empty() || !expr_eq_modulo_parens(expr, key_expr) {
return None;
}
let mut vals = Vec::with_capacity(list.len());
for item in list {
vals.push(const_value(item, params)?);
}
Some(vals)
}
_ => None,
}
}
/// The executor's [`VTabStore`] implementation: a persistent virtual table's
/// backing `<vtab>_data` regular table, read/written through the normal table
/// machinery. Built (with the module taken out of the registry, so
/// `&mut Connection` doesn't alias the borrowed module) for one `update` call.
struct ExecVTabStore<'a> {
conn: &'a mut Connection,
backing: &'a str,
/// The backing table leads with an `INTEGER PRIMARY KEY` `id` column (FTS5's
/// `_content`), stored as a NULL placeholder serial (the rowid is the b-tree
/// key). Module values are the columns after `id`, so prepend a NULL on write
/// and drop the leading value on read.
ipk_prefix: bool,
}
impl VTabStore for ExecVTabStore<'_> {
fn rows(&self) -> Result<Vec<(i64, Vec<Value>)>> {
let meta = self.conn.table_meta(self.backing, None)?;
let mut rows = self.conn.scan_table(&meta)?;
if self.ipk_prefix {
for (_, values) in &mut rows {
if !values.is_empty() {
values.remove(0);
}
}
}
Ok(rows)
}
fn put(&mut self, rowid: i64, values: &[Value]) -> Result<()> {
let root = self.conn.table_meta(self.backing, None)?.root;
let payload = if self.ipk_prefix {
let mut row = alloc::vec![Value::Null];
row.extend_from_slice(values);
encode_record(&row)
} else {
encode_record(values)
};
let w = self.conn.backend.writer()?;
// Replace semantics: drop any existing row, then insert.
crate::btree::delete_table(w, root, rowid)?;
crate::btree::insert_table(w, root, rowid, &payload)?;
Ok(())
}
fn delete(&mut self, rowid: i64) -> Result<()> {
let root = self.conn.table_meta(self.backing, None)?.root;
let w = self.conn.backend.writer()?;
crate::btree::delete_table(w, root, rowid)?;
Ok(())
}
}
/// Flip a comparison operator for a swapped operand order: `a < b` ⇔ `b > a`.
/// Non-ordering operators are returned unchanged.
fn mirror_comparison(op: BinaryOp) -> BinaryOp {
match op {
BinaryOp::Lt => BinaryOp::Gt,
BinaryOp::LtEq => BinaryOp::GtEq,
BinaryOp::Gt => BinaryOp::Lt,
BinaryOp::GtEq => BinaryOp::LtEq,
other => other,
}
}
fn where_cols_covered(e: &Expr, meta: &TableMeta, idx_cols: &[usize]) -> bool {
let covered = |ci: usize| idx_cols.contains(&ci) || meta.ipk == Some(ci);
match e {
Expr::Literal(_) | Expr::Parameter(_) => true,
Expr::Column { column, .. } => match meta
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(column))
{
Some(ci) => covered(ci),
None => matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
),
},
Expr::Unary { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. }
| Expr::Paren(expr) => where_cols_covered(expr, meta, idx_cols),
Expr::Binary { left, right, .. } => {
where_cols_covered(left, meta, idx_cols) && where_cols_covered(right, meta, idx_cols)
}
Expr::Between {
expr, low, high, ..
} => {
where_cols_covered(expr, meta, idx_cols)
&& where_cols_covered(low, meta, idx_cols)
&& where_cols_covered(high, meta, idx_cols)
}
Expr::InList { expr, list, .. } => {
where_cols_covered(expr, meta, idx_cols)
&& list.iter().all(|x| where_cols_covered(x, meta, idx_cols))
}
Expr::RowValue(items) => items.iter().all(|x| where_cols_covered(x, meta, idx_cols)),
Expr::Function {
args, filter, over, ..
} => {
over.is_none()
&& filter.is_none()
&& args.iter().all(|x| where_cols_covered(x, meta, idx_cols))
}
Expr::Case {
operand,
when_then,
else_result,
} => {
operand
.as_deref()
.map(|o| where_cols_covered(o, meta, idx_cols))
.unwrap_or(true)
&& when_then.iter().all(|(w, t)| {
where_cols_covered(w, meta, idx_cols) && where_cols_covered(t, meta, idx_cols)
})
&& else_result
.as_deref()
.map(|x| where_cols_covered(x, meta, idx_cols))
.unwrap_or(true)
}
// A subquery may read other tables/columns we can't enumerate here; bail.
Expr::Subquery(_) | Expr::Exists { .. } | Expr::InSelect { .. } => false,
}
}
/// Gather the virtual-table constraints to offer `best_index` from a query's
/// `WHERE`, plus, in lockstep, each constraint's bound right-hand [`Value`].
///
/// Walks the top-level `AND` conjuncts looking for `col <op> const` comparisons
/// (and `BETWEEN`, expanded to a `>=`/`<=` pair) where `col` is one of this
/// table's `columns` and the other side is row-independent. The returned
/// `(constraints, values)` vectors are parallel: `values[i]` is the evaluated
/// bound of `constraints[i]`. Only the comparison *shape* goes to the module (as
/// SQLite does); the values are held back and handed to `filter` per the plan's
/// `argv_index`.
/// Whether a WHERE clause contains a `rowid = <const>` term (rowid/`_rowid_`/`oid`)
/// in its `AND` tree — used to report FTS5's `INDEX 0:=` rowid-lookup plan.
#[cfg(feature = "fts5")]
fn fts5_rowid_eq(expr: &Expr, params: &Params) -> bool {
let is_rowid = |e: &Expr| {
matches!(e, Expr::Column { column, .. }
if matches!(column.to_ascii_lowercase().as_str(), "rowid" | "_rowid_" | "oid"))
};
match expr {
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => {
(is_rowid(left) && const_value(right, params).is_some())
|| (is_rowid(right) && const_value(left, params).is_some())
}
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => fts5_rowid_eq(left, params) || fts5_rowid_eq(right, params),
Expr::Paren(e) => fts5_rowid_eq(e, params),
_ => false,
}
}
fn collect_vtab_constraints(
sel: &Select,
columns: &[ColumnInfo],
params: &Params,
) -> (Vec<IndexConstraint>, Vec<Value>) {
let mut constraints = Vec::new();
let mut values = Vec::new();
let Some(where_expr) = &sel.where_clause else {
return (constraints, values);
};
let mut conjuncts = Vec::new();
and_conjuncts(where_expr, &mut conjuncts);
let mut push = |col: usize, op: ConstraintOp, v: Value| {
constraints.push(IndexConstraint {
column: col,
op,
usable: true,
});
values.push(v);
};
for c in conjuncts {
match c {
Expr::Binary { op, left, right }
if matches!(
op,
BinaryOp::Eq | BinaryOp::Lt | BinaryOp::LtEq | BinaryOp::Gt | BinaryOp::GtEq
) =>
{
if let (Some(ci), Some(v)) = (col_index(left, columns), const_value(right, params))
{
if let Some(cop) = binop_to_constraint(*op) {
push(ci, cop, v);
}
} else if let (Some(ci), Some(v)) =
(col_index(right, columns), const_value(left, params))
&& let Some(cop) = binop_to_constraint(flip_cmp(*op))
{
push(ci, cop, v);
}
}
Expr::Between {
expr,
low,
high,
negated: false,
} => {
if let Some(ci) = col_index(expr, columns) {
if let Some(v) = const_value(low, params) {
push(ci, ConstraintOp::Ge, v);
}
if let Some(v) = const_value(high, params) {
push(ci, ConstraintOp::Le, v);
}
}
}
_ => {}
}
}
(constraints, values)
}
/// Map a comparison [`BinaryOp`] to a vtab [`ConstraintOp`], or `None` for a
/// non-comparison operator.
fn binop_to_constraint(op: BinaryOp) -> Option<ConstraintOp> {
Some(match op {
BinaryOp::Eq => ConstraintOp::Eq,
BinaryOp::Lt => ConstraintOp::Lt,
BinaryOp::LtEq => ConstraintOp::Le,
BinaryOp::Gt => ConstraintOp::Gt,
BinaryOp::GtEq => ConstraintOp::Ge,
_ => return None,
})
}
/// Order the bound constraint `values` by the plan's 1-based `argv_index`, the
/// argument vector handed to [`crate::vtab::VTabModule::filter`].
///
/// `argv_index[i]` is the position (1-based) the module wants `values[i]` passed
/// at, or `0` to drop it. A robust pass: collect `(pos, value)` for every nonzero
/// entry, sort by `pos`, and emit the values. Gaps or duplicate positions are
/// tolerated (the module decides what its own positions mean).
fn order_vtab_argv(plan: &IndexPlan, values: &[Value]) -> Vec<Value> {
let mut slots: Vec<(u32, Value)> = plan
.argv_index
.iter()
.zip(values.iter())
.filter(|(pos, _)| **pos != 0)
.map(|(pos, v)| (*pos, v.clone()))
.collect();
slots.sort_by_key(|(pos, _)| *pos);
slots.into_iter().map(|(_, v)| v).collect()
}
/// Like [`collect_eq_constraints`] but recording each equality's *effective*
/// collation (an explicit `COLLATE`, else the column's declared collation) and
/// WITHOUT the column-collation gate, so a `b = 'x' COLLATE NOCASE` is emitted even
/// when `b` is `BINARY`. Used by collation-aware index selection
/// ([`Connection::choose_seek_index`]), which matches an equality to an index only
/// when their collations agree — letting a `NOCASE` index serve a `NOCASE`
/// comparison (B9j). The `IS` arm mirrors `collect_eq_constraints` (column
/// collation; `IS` takes no `COLLATE`).
fn collect_eq_constraints_coll(
e: &Expr,
columns: &[ColumnInfo],
params: &Params,
out: &mut Vec<(usize, Value, crate::value::Collation)>,
) {
match e {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => {
collect_eq_constraints_coll(left, columns, params, out);
collect_eq_constraints_coll(right, columns, params, out);
}
Expr::Paren(inner) => collect_eq_constraints_coll(inner, columns, params, out),
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => {
let eff =
|ci: usize, val: &Expr| explicit_collation(val).unwrap_or(columns[ci].collation);
if let (Some(ci), Some(v)) = (col_index(left, columns), const_value(right, params)) {
out.push((ci, v, eff(ci, right)));
} else if let (Some(ci), Some(v)) =
(col_index(right, columns), const_value(left, params))
{
out.push((ci, v, eff(ci, left)));
}
}
Expr::Binary {
op: BinaryOp::Is,
left,
right,
} => {
if let (Some(ci), Some(v)) = (col_index(left, columns), const_value(right, params)) {
if !matches!(v, Value::Null) {
out.push((ci, v, columns[ci].collation));
}
} else if let (Some(ci), Some(v)) =
(col_index(right, columns), const_value(left, params))
&& !matches!(v, Value::Null)
{
out.push((ci, v, columns[ci].collation));
}
}
_ => {}
}
}
fn collect_eq_constraints(
e: &Expr,
columns: &[ColumnInfo],
params: &Params,
out: &mut Vec<(usize, Value)>,
) {
match e {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => {
collect_eq_constraints(left, columns, params, out);
collect_eq_constraints(right, columns, params, out);
}
Expr::Paren(inner) => collect_eq_constraints(inner, columns, params, out),
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => {
// An explicit `COLLATE` on the value operand sets the comparison's
// collation; an index whose column collation differs cannot serve the
// seek (its key order is for a different collation), so SQLite scans —
// e.g. `b = 'x' COLLATE NOCASE` over a BINARY index on `b`. Emit the
// equality only when the comparison collation matches the column's, so the
// seek (and its rowid-order ORDER BY credit) stays sound.
let collation_ok = |ci: usize, val: &Expr| {
explicit_collation(val).is_none_or(|c| c == columns[ci].collation)
};
if let (Some(ci), Some(v)) = (col_index(left, columns), const_value(right, params)) {
if collation_ok(ci, right) {
out.push((ci, v));
}
} else if let (Some(ci), Some(v)) =
(col_index(right, columns), const_value(left, params))
&& collation_ok(ci, left)
{
out.push((ci, v));
}
}
// `col IS <non-null const>` selects exactly the rows `col = <const>` does (a
// NULL `col` makes `IS` false, same as `=`), and SQLite's `IS` behaves
// identically to `=` for non-NULL operands — so it seeks the same index key.
// A NULL operand is the `col IS NULL` NULL-key seek (handled by
// `collect_isnull_cols`), so it is excluded here.
Expr::Binary {
op: BinaryOp::Is,
left,
right,
} => {
if let (Some(ci), Some(v)) = (col_index(left, columns), const_value(right, params)) {
if !matches!(v, Value::Null) {
out.push((ci, v));
}
} else if let (Some(ci), Some(v)) =
(col_index(right, columns), const_value(left, params))
&& !matches!(v, Value::Null)
{
out.push((ci, v));
}
}
_ => {}
}
}
/// Collect the columns constrained by a top-level `col IS NULL` conjunct. This
/// is a *seekable* equality against a NULL index key (NULLs sort first in the
/// b-tree and `cmp_values` treats `NULL == NULL` as equal, so an index seek on a
/// NULL key finds exactly the NULL-keyed entries) — distinct from `col = NULL`,
/// which is never true and is left to bail. `col IS NOT NULL` (`negated`) is not
/// seekable (sqlite scans), so it is skipped. Kept separate from
/// [`collect_eq_constraints`] so the rowid/INTEGER-PRIMARY-KEY fast paths, which
/// must *not* fire for `rowid IS NULL` (sqlite scans there), never see it.
fn collect_isnull_cols(e: &Expr, columns: &[ColumnInfo], out: &mut Vec<usize>) {
match e {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => {
collect_isnull_cols(left, columns, out);
collect_isnull_cols(right, columns, out);
}
Expr::Paren(inner) => collect_isnull_cols(inner, columns, out),
Expr::IsNull {
expr,
negated: false,
} => {
if let Some(ci) = col_index(expr, columns) {
out.push(ci);
}
}
_ => {}
}
}
/// Columns constrained `col IS NOT NULL` by the top-level `AND` conjuncts of a
/// `WHERE` — the complement of [`collect_isnull_cols`] (`negated: true`). Such a
/// column selects every non-NULL key, i.e. a `col > NULL` lower-bounded range;
/// sqlite seeks an index for it only when that index is *covering* (a near-
/// full-table non-covering seek loses to a plain scan), which is exactly the gate
/// `try_isnotnull_covering` applies.
fn collect_isnotnull_cols(e: &Expr, columns: &[ColumnInfo], out: &mut Vec<usize>) {
match e {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => {
collect_isnotnull_cols(left, columns, out);
collect_isnotnull_cols(right, columns, out);
}
Expr::Paren(inner) => collect_isnotnull_cols(inner, columns, out),
Expr::IsNull {
expr,
negated: true,
} => {
if let Some(ci) = col_index(expr, columns) {
out.push(ci);
}
}
_ => {}
}
}
/// Strip redundant outer parentheses from an expression, so structural
/// comparison ignores grouping (`(active = 1)` ≡ `active = 1`).
fn unparen(e: &Expr) -> &Expr {
let mut cur = e;
while let Expr::Paren(inner) = cur {
cur = inner;
}
cur
}
/// Two expressions are equal modulo redundant parentheses. Used to match a
/// partial-index predicate (or an expression-index key) against a query's
/// `WHERE` structurally — this is the conservative rule (no general implication),
/// so it only recurses through `Paren`; everything else uses derived `PartialEq`.
fn expr_eq_modulo_parens(a: &Expr, b: &Expr) -> bool {
unparen(a) == unparen(b)
}
/// Collect the top-level `AND` conjuncts of `e` (descending through `Paren` and
/// `AND` nodes), pushing each non-`AND` leaf as a borrowed reference.
fn and_conjuncts<'e>(e: &'e Expr, out: &mut Vec<&'e Expr>) {
match unparen(e) {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => {
and_conjuncts(left, out);
and_conjuncts(right, out);
}
other => out.push(other),
}
}
/// A hash-join bucket key. Over-keying (one value yielding several keys) is safe:
/// the join's full `ON` predicate is re-evaluated on every candidate, so extra
/// keys only cost comparisons — they never drop a real match.
#[derive(PartialEq, Eq, PartialOrd, Ord)]
enum JoinKey {
/// Numeric value, keyed by canonical `f64` bits (so `5` and `5.0` collide).
Num(u64),
/// Text value (exact bytes).
Text(String),
/// Blob value.
Blob(Vec<u8>),
}
/// Canonical bits for a number, normalizing `-0.0` to `0.0` so the two compare
/// equal (as they do in SQL).
fn num_bits(f: f64) -> u64 {
(if f == 0.0 { 0.0 } else { f }).to_bits()
}
/// The set of hash-join keys a value participates in. A numeric value keys by its
/// number *and* its text form; text that parses as a number keys by both too — so
/// affinity-driven cross-type equality (`5 = '5'`) never misses (the `ON` re-eval
/// rejects the spurious ones). `NULL` keys nothing (it never equi-joins).
fn join_keys_of(v: &Value) -> Vec<JoinKey> {
match v {
Value::Null => Vec::new(),
Value::Integer(i) => alloc::vec![
JoinKey::Num(num_bits(*i as f64)),
JoinKey::Text(i.to_string())
],
Value::Real(r) => {
alloc::vec![
JoinKey::Num(num_bits(*r)),
JoinKey::Text(eval::format_real(*r))
]
}
Value::Text(s) => {
let mut keys = alloc::vec![JoinKey::Text(s.as_str().to_string())];
match eval::to_number(&Value::Text(s.clone())) {
Value::Integer(i) => keys.push(JoinKey::Num(num_bits(i as f64))),
Value::Real(r) => keys.push(JoinKey::Num(num_bits(r))),
_ => {}
}
keys
}
Value::Blob(b) => alloc::vec![JoinKey::Blob(b.clone())],
}
}
/// Promote a comma join's filtering equality from `WHERE` into its `ON`, so the
/// common `FROM a, b WHERE a.x = b.y` pattern can use the same hash/index seek
/// path (and EXPLAIN QUERY PLAN node) as `a JOIN b ON a.x = b.y`. The equality is
/// *copied*, not moved — it stays in `WHERE` — so the result is unchanged: the
/// `ON` is a subset of `WHERE`, applied redundantly. Only a qualified
/// `t.col = u.col` equality linking the joined table to an already-introduced one
/// is promoted. Returns the rewritten `Select`, or `None` if nothing applied.
fn promote_comma_join_ons(sel: &Select, tables: &[(String, Vec<String>)]) -> Option<Select> {
let from = sel.from.as_ref()?;
let where_clause = sel.where_clause.as_ref()?;
let promotable = |j: &Join| {
j.on.is_none() && !j.natural && j.using.is_empty() && matches!(j.kind, JoinKind::Inner)
};
if !from.joins.iter().any(promotable) {
return None;
}
let mut conjuncts: Vec<&Expr> = Vec::new();
and_conjuncts(where_clause, &mut conjuncts);
let label = |t: &TableRef| t.alias.clone().unwrap_or_else(|| t.name.clone());
let mut available: Vec<String> = alloc::vec![label(&from.first)];
let mut new_joins = from.joins.clone();
let mut changed = false;
for (i, join) in from.joins.iter().enumerate() {
let jlabel = label(&join.table);
if promotable(join)
&& let Some(cond) = conjuncts
.iter()
.find_map(|c| eligible_join_equi(c, &jlabel, &available, tables))
{
new_joins[i].on = Some(cond);
changed = true;
}
available.push(jlabel);
}
if !changed {
return None;
}
let mut new_sel = sel.clone();
new_sel.from = Some(FromClause {
first: from.first.clone(),
joins: new_joins,
});
Some(new_sel)
}
/// An `A.x = B.y` equality whose two columns belong to table `jlabel` and to some
/// earlier (`available`) table — eligible to become a comma join's `ON`. Each side
/// is resolved to its owning table: a qualified `t.x` directly, an *unqualified*
/// `x` via `tables` (the unique source owning a column of that name, ambiguous or
/// unknown → decline). Returns the cloned equality (enclosing parens stripped).
fn eligible_join_equi(
c: &Expr,
jlabel: &str,
available: &[String],
tables: &[(String, Vec<String>)],
) -> Option<Expr> {
let mut c = c;
while let Expr::Paren(inner) = c {
c = inner;
}
let (l, r) = match c {
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => (left.as_ref(), right.as_ref()),
_ => return None,
};
let lt = resolve_col_table(l, tables)?;
let rt = resolve_col_table(r, tables)?;
let here = |t: &str| t.eq_ignore_ascii_case(jlabel);
let earlier = |t: &str| available.iter().any(|a| a.eq_ignore_ascii_case(t));
if (here(<) && earlier(&rt)) || (here(&rt) && earlier(<)) {
Some(c.clone())
} else {
None
}
}
/// The owning-table label of a column reference. A qualified `t.col` yields `t`
/// directly (its existence is not re-checked, matching the pre-existing qualified
/// path). An unqualified `col` is resolved against `tables` (label → column names):
/// the unique source owning a column of that name, or `None` when zero or more than
/// one own it (unknown / ambiguous — SQLite would itself reject the ambiguous case).
fn resolve_col_table(e: &Expr, tables: &[(String, Vec<String>)]) -> Option<String> {
let mut e = e;
while let Expr::Paren(inner) = e {
e = inner;
}
match e {
Expr::Column { table: Some(t), .. } => Some(t.clone()),
Expr::Column {
table: None,
column,
..
} => {
let mut found: Option<&str> = None;
for (lbl, cols) in tables {
if cols.iter().any(|c| c.eq_ignore_ascii_case(column)) {
if found.is_some() {
return None;
}
found = Some(lbl);
}
}
found.map(String::from)
}
_ => None,
}
}
/// Extract a single equi-join `left.col = right.col` from the top-level `AND`
/// conjuncts of an `ON` predicate, returning `(left column index, right column
/// index within the joined table)`. Both columns must use `BINARY` collation
/// (otherwise text equality is collation-sensitive and a hash on exact bytes
/// could miss a match — fall back to the nested loop). `cols` is the combined
/// left+right column list; `left_width` is the number of left columns.
fn join_equi_cols(on: &Expr, cols: &[ColumnInfo], left_width: usize) -> Option<(usize, usize)> {
match on {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => join_equi_cols(left, cols, left_width)
.or_else(|| join_equi_cols(right, cols, left_width)),
Expr::Paren(inner) => join_equi_cols(inner, cols, left_width),
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => {
let a = col_index(left, cols)?;
let b = col_index(right, cols)?;
let binary = |i: usize| cols[i].collation == crate::value::Collation::Binary;
let (l, r) = if a < left_width && b >= left_width {
(a, b)
} else if b < left_width && a >= left_width {
(b, a)
} else {
return None;
};
if binary(l) && binary(r) {
Some((l, r - left_width))
} else {
None
}
}
_ => None,
}
}
/// Flatten a top-level `OR` chain into its disjuncts (unwrapping parentheses),
/// e.g. `a OR (b OR c)` → `[a, b, c]`. A non-`OR` expression yields itself.
fn flatten_or<'a>(e: &'a Expr, out: &mut Vec<&'a Expr>) {
match e {
Expr::Binary {
op: BinaryOp::Or,
left,
right,
} => {
flatten_or(left, out);
flatten_or(right, out);
}
Expr::Paren(inner) => flatten_or(inner, out),
other => out.push(other),
}
}
/// A single `column = const` equality leaf (either operand order, descending
/// through redundant parens), returning the column index and the constant. A NULL
/// constant is rejected: `col = NULL` is never true and is not a usable seek key.
fn eq_col_const(e: &Expr, columns: &[ColumnInfo], params: &Params) -> Option<(usize, Value)> {
let Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} = unparen(e)
else {
return None;
};
let (ci, v) =
if let (Some(ci), Some(v)) = (col_index(left, columns), const_value(right, params)) {
(ci, v)
} else if let (Some(ci), Some(v)) = (col_index(right, columns), const_value(left, params)) {
(ci, v)
} else {
return None;
};
if matches!(v, Value::Null) {
return None;
}
Some((ci, v))
}
/// Find a top-level `column IN (const, const, …)` conjunct (not `NOT IN`, all
/// list entries constant), returning the column index and the constant values.
/// Used to drive per-value index seeks; only the first such term is returned.
///
/// A same-column equality `OR`-chain (`c = a OR c = b OR …`, every disjunct a bare
/// equality on the *same* column) is recognised as the equivalent `c IN (a, b, …)`,
/// since sqlite plans the two identically — one index seek, not a `MULTI-INDEX OR`.
/// A mixed-column chain (`a = 1 OR b = 2`) or any non-equality disjunct declines.
fn find_in_constraint(
e: &Expr,
columns: &[ColumnInfo],
params: &Params,
) -> Option<(usize, Vec<Value>)> {
match e {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => find_in_constraint(left, columns, params)
.or_else(|| find_in_constraint(right, columns, params)),
Expr::Paren(inner) => find_in_constraint(inner, columns, params),
Expr::InList {
expr,
list,
negated: false,
..
} => {
let ci = col_index(expr, columns)?;
if list.is_empty() {
return None;
}
let mut vals = Vec::with_capacity(list.len());
for item in list {
vals.push(const_value(item, params)?);
}
Some((ci, vals))
}
// `c = a OR c = b OR …`: collapse a same-column equality chain to an IN-list.
Expr::Binary {
op: BinaryOp::Or, ..
} => {
let mut disjuncts: Vec<&Expr> = Vec::new();
flatten_or(e, &mut disjuncts);
let mut col: Option<usize> = None;
let mut vals = Vec::with_capacity(disjuncts.len());
for d in disjuncts {
let (ci, v) = eq_col_const(d, columns, params)?;
match col {
None => col = Some(ci),
Some(c) if c == ci => {}
// A different column means this is a genuine multi-index OR.
Some(_) => return None,
}
vals.push(v);
}
Some((col?, vals))
}
_ => None,
}
}
// ─── R-Tree byte-compatible on-disk node format (D3c) ───────────────────────
//
// SQLite stores an R-Tree as a b-tree of fixed-size nodes in `<name>_node`
// (`nodeno INTEGER PRIMARY KEY, data`), with `<name>_rowid` (rowid → leaf nodeno)
// and `<name>_parent` (node → parent node) maps. A node blob is: 2-byte BE depth
// (the tree height; meaningful only in the root, nodeno 1, else 0) + 2-byte BE
// cell count, then cells, zero-padded to the node size. Each cell is an 8-byte BE
// key (leaf: rowid; interior: child nodeno) followed by `n_coord` 4-byte BE
// coordinates (f32 for `rtree`, i32 for `rtree_i32`), laid out per dimension as
// (min, max).
//
// graphite reuses its M1 reader to get the current entries, applies the
// insert/delete, then BULK-REBUILDS a valid tree and rewrites the three shadow
// tables. SQLite reads any structurally-valid R-Tree (rtreecheck does not require
// a particular shape), so a simple balanced bulk build is byte-readable without
// reproducing SQLite's incremental quadratic-split tree shape.
/// One R-Tree entry / cell: an 8-byte key (rowid or child nodeno) and `2*nDim`
/// coordinates as f64 (exact for both the f32 and i32 on-disk forms).
#[derive(Clone)]
struct RtreeCell {
key: i64,
coords: Vec<f64>,
}
/// The fixed node size SQLite uses: `min(page_size - 64, 4 + 51*cell_size)`,
/// `cell_size = 8 + n_coord*4`, `51 = RTREE_MAXCELLS`.
fn rtree_node_size(n_coord: usize, page_size: usize) -> usize {
let cell = 8 + n_coord * 4;
page_size.saturating_sub(64).min(4 + 51 * cell)
}
/// Encode one node to its zero-padded blob. `is_root` puts the tree `depth` in
/// the header; non-root nodes carry 0 there.
fn rtree_encode_node(
cells: &[RtreeCell],
n_coord: usize,
is_root: bool,
depth: u16,
integer: bool,
node_size: usize,
) -> Vec<u8> {
let mut b = alloc::vec![0u8; node_size];
b[0..2].copy_from_slice(&(if is_root { depth } else { 0 }).to_be_bytes());
b[2..4].copy_from_slice(&(cells.len() as u16).to_be_bytes());
let cell_size = 8 + n_coord * 4;
for (i, c) in cells.iter().enumerate() {
let off = 4 + i * cell_size;
b[off..off + 8].copy_from_slice(&c.key.to_be_bytes());
for (d, &v) in c.coords.iter().enumerate() {
let p = off + 8 + d * 4;
let bytes = if integer {
(v as i32).to_be_bytes()
} else {
(v as f32).to_be_bytes()
};
b[p..p + 4].copy_from_slice(&bytes);
}
}
b
}
/// The bounding box (per-dimension min/max, in coordinate-column order) of a set
/// of cells: union of their boxes.
fn rtree_union(cells: &[RtreeCell], n_coord: usize) -> Vec<f64> {
let mut bb = alloc::vec![0.0f64; n_coord];
for (ci, c) in cells.iter().enumerate() {
for (d, slot) in bb.iter_mut().enumerate() {
let v = c.coords.get(d).copied().unwrap_or(0.0);
if ci == 0 {
*slot = v;
} else if d % 2 == 0 {
*slot = slot.min(v); // a `min` coordinate column
} else {
*slot = slot.max(v); // a `max` coordinate column
}
}
}
bb
}
/// A bulk-built R-Tree, ready to write to the shadow tables.
struct RtreeBuild {
/// `(nodeno, encoded blob)` for every node.
nodes: Vec<(i64, Vec<u8>)>,
/// `(rowid, leaf nodeno)` for every entry.
rowids: Vec<(i64, i64)>,
/// `(child nodeno, parent nodeno)` for every non-root node.
parents: Vec<(i64, i64)>,
}
/// Bulk-build a balanced R-Tree from `entries`. The root is always nodeno 1.
fn rtree_bulk_build(
entries: Vec<RtreeCell>,
n_coord: usize,
integer: bool,
node_size: usize,
) -> RtreeBuild {
let max_cells = ((node_size - 4) / (8 + n_coord * 4)).max(1);
// Empty tree: a single empty leaf root.
if entries.is_empty() {
return RtreeBuild {
nodes: alloc::vec![(
1,
rtree_encode_node(&[], n_coord, true, 0, integer, node_size)
)],
rowids: Vec::new(),
parents: Vec::new(),
};
}
// Build levels bottom-up. A node is its list of cells; an interior cell's key
// is a placeholder index into the child level, resolved to a nodeno later.
// levels[0] = leaves; cells there carry the real rowid keys.
let mut levels: Vec<Vec<Vec<RtreeCell>>> = Vec::new();
levels.push(entries.chunks(max_cells).map(<[_]>::to_vec).collect());
while levels.last().map_or(0, Vec::len) > 1 {
let child_level = levels.len() - 1;
let children = &levels[child_level];
// Each parent cell summarizes one child: key = child index (placeholder).
let parent_cells: Vec<RtreeCell> = (0..children.len())
.map(|idx| RtreeCell {
key: idx as i64,
coords: rtree_union(&children[idx], n_coord),
})
.collect();
levels.push(parent_cells.chunks(max_cells).map(<[_]>::to_vec).collect());
}
let root_level = levels.len() - 1;
let depth = root_level as u16;
// Assign node numbers: the root (top level, node 0) is 1; everything else
// follows. Record nodeno for each (level, node-index).
let mut nodeno_of: alloc::collections::BTreeMap<(usize, usize), i64> =
alloc::collections::BTreeMap::new();
nodeno_of.insert((root_level, 0), 1);
let mut next = 2i64;
for level in (0..levels.len()).rev() {
for idx in 0..levels[level].len() {
nodeno_of.entry((level, idx)).or_insert_with(|| {
let n = next;
next += 1;
n
});
}
}
let mut nodes = Vec::new();
let mut rowids = Vec::new();
let mut parents = Vec::new();
for level in 0..levels.len() {
let is_leaf = level == 0;
for (idx, cells) in levels[level].iter().enumerate() {
let nodeno = nodeno_of[&(level, idx)];
let is_root = level == root_level;
// Resolve interior placeholder keys to child nodenos, and record the
// parent + rowid maps.
let resolved: Vec<RtreeCell> = cells
.iter()
.map(|c| {
if is_leaf {
rowids.push((c.key, nodeno));
c.clone()
} else {
let child = nodeno_of[&(level - 1, c.key as usize)];
parents.push((child, nodeno));
RtreeCell {
key: child,
coords: c.coords.clone(),
}
}
})
.collect();
nodes.push((
nodeno,
rtree_encode_node(&resolved, n_coord, is_root, depth, integer, node_size),
));
}
}
RtreeBuild {
nodes,
rowids,
parents,
}
}
/// Build a leaf cell from an R-Tree INSERT's column values `[id, c0, c1, …]`,
/// rounding each coordinate to the conservative f32 form (min columns down, max
/// columns up — SQLite's rtreeValueDown/Up) or clamping to i32 for `rtree_i32`.
/// Rejects a coordinate pair with `min > max`, like SQLite.
fn rtree_cell_from_values(
rowid: i64,
values: &[Value],
n_coord: usize,
integer: bool,
table: &str,
args: &[&str],
) -> Result<RtreeCell> {
// Round each coordinate the way `rtree.c`'s `rtreeUpdate` stores it before
// any validation: for the float rtree each min (even coordinate index)
// toward −∞ and each max (odd index) toward +∞ as an f32; for `rtree_i32`
// truncate toward zero into the signed 32-bit range.
let coords: Vec<f64> = (0..n_coord)
.map(|d| {
let v = values.get(1 + d).map_or(0.0, crate::vtab::coord_f64);
if integer {
(v as i64).clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as f64
} else if d % 2 == 0 {
crate::vtab::round_min_f32(v)
} else {
crate::vtab::round_max_f32(v)
}
})
.collect();
// Validate `min <= max` on the *stored* (rounded) coordinates — sqlite
// compares the rounded values, so a pair that rounds to the same f32 (e.g.
// `1.000000000001` vs `1.0`) is accepted. The message names the first
// failing pair's columns from the `USING rtree(…)` argument list, byte-for-
// byte `rtreeConstraintError` (`rtree constraint failed: <t>.(<min><=<max>)`).
for d in 0..n_coord / 2 {
if coords[2 * d] > coords[2 * d + 1] {
return Err(crate::vtab::rtree_constraint_error(
Some(table),
args,
1 + 2 * d,
));
}
}
Ok(RtreeCell { key: rowid, coords })
}
/// Turn a geopoly INSERT/UPDATE row (`[_shape, user1, …]`) into a bbox cell plus
/// the aux tuple `[a0, a1, …]` stored in `_rowid`, mirroring SQLite's
/// `geopolyUpdate`:
///
/// * a valid polygon (`_shape` is a geopoly BLOB or GeoJSON text) → `a0` is the
/// normalized geopoly BLOB, the bbox is the polygon's exact f32 bounds (no
/// directional rounding — the vertices are already f32), and `a1..aN` are the
/// user column values;
/// * text that never opens a `[` ring (e.g. `''`) → stored verbatim as `a0` with
/// an all-zero bbox (SQLite's rc-OK-but-no-polygon path);
/// * anything else (NULL, a number, a malformed BLOB, or a bracket-opened but
/// malformed ring) → the error SQLite raises,
/// `_shape does not contain a valid polygon`.
fn geopoly_row_cell(rowid: i64, values: &[Value]) -> Result<(RtreeCell, Vec<Value>)> {
let shape = values.first().cloned().unwrap_or(Value::Null);
let (coords, a0) = match crate::geopoly::bbox_step(&shape) {
crate::geopoly::BBoxStep::Poly(p) => {
let (mnx, mxx, mny, mxy) = p.bbox_coords();
(
alloc::vec![
f64::from(mnx),
f64::from(mxx),
f64::from(mny),
f64::from(mxy)
],
Value::Blob(p.to_blob()),
)
}
crate::geopoly::BBoxStep::ZeroBox => (alloc::vec![0.0, 0.0, 0.0, 0.0], shape),
crate::geopoly::BBoxStep::Skip => {
return Err(Error::Error(String::from(
"_shape does not contain a valid polygon",
)));
}
};
let mut aux = alloc::vec![a0];
aux.extend(values.iter().skip(1).cloned());
Ok((RtreeCell { key: rowid, coords }, aux))
}
/// Whether `e` is a `rowid` / `_rowid_` / `oid` reference (case-insensitive,
/// optionally table-qualified) that is NOT shadowed by a real column of that
/// name — i.e. it denotes the table's rowid, seekable directly in the table
/// b-tree whether or not the table has an explicit INTEGER PRIMARY KEY column.
fn is_rowid_ref(e: &Expr, columns: &[ColumnInfo]) -> bool {
matches!(e, Expr::Column { column, .. }
if matches!(column.to_ascii_lowercase().as_str(), "rowid" | "_rowid_" | "oid")
&& !columns.iter().any(|c| c.name.eq_ignore_ascii_case(column)))
}
/// Whether `e` denotes the table's rowid: either a `rowid`/`_rowid_`/`oid` alias
/// (not shadowed by a real column) or the explicit INTEGER PRIMARY KEY column
/// itself, which *is* the rowid. Both seek the table b-tree directly by rowid.
fn is_rowid_or_ipk(e: &Expr, columns: &[ColumnInfo], ipk: Option<usize>) -> bool {
is_rowid_ref(e, columns) || (ipk.is_some() && col_index(e, columns) == ipk)
}
/// The candidate rowids of a pure `rowid = a OR rowid = b OR …` equality chain
/// (descending through `Paren`/`Or`), or `None` if any leaf is not a bare rowid
/// equality. Deliberately rejects `IN`-list / range / unbounded leaves: sqlite only
/// collapses an all-equality OR-chain into one rowid seek, keeping any other leaf as
/// its own MULTI-INDEX OR branch. Used by [`rowid_seek_constraint`]'s `Or` arm.
fn rowid_eq_or_chain(
e: &Expr,
columns: &[ColumnInfo],
ipk: Option<usize>,
params: &Params,
) -> Option<Vec<i64>> {
match e {
Expr::Paren(inner) => rowid_eq_or_chain(inner, columns, ipk, params),
Expr::Binary {
op: BinaryOp::Or,
left,
right,
} => {
let mut l = rowid_eq_or_chain(left, columns, ipk, params)?;
let r = rowid_eq_or_chain(right, columns, ipk, params)?;
l.extend(r);
Some(l)
}
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => {
let other = if is_rowid_or_ipk(left, columns, ipk) {
right
} else if is_rowid_or_ipk(right, columns, ipk) {
left
} else {
return None;
};
Some(alloc::vec![eval::to_i64(&const_value(other, params)?)])
}
_ => None,
}
}
/// Detect a `rowid = const` equality or `rowid IN (list)` in `where_expr` — where
/// `rowid` is the rowid alias (not shadowed by a real column) *or* the explicit
/// INTEGER PRIMARY KEY column — returning the candidate rowids to seek directly in
/// the table b-tree. `run_core` re-applies the full WHERE, so a non-integer literal
/// (`rowid = 5.5`) is a harmless superset.
fn rowid_seek_constraint(
where_expr: &Expr,
columns: &[ColumnInfo],
ipk: Option<usize>,
params: &Params,
) -> Option<Vec<i64>> {
match where_expr {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => rowid_seek_constraint(left, columns, ipk, params)
.or_else(|| rowid_seek_constraint(right, columns, ipk, params)),
// A `rowid = a OR rowid = b OR …` chain seeks the union of the per-disjunct
// rowids. Every disjunct must be a bare rowid *equality* (`rowid_eq_or_chain`
// rejects an `IN`, range, or unbounded leaf): sqlite collapses an all-equality
// OR-chain into a single rowid seek but keeps an `IN`-list disjunct as its own
// MULTI-INDEX OR branch, so matching that boundary keeps the EQP byte-exact.
Expr::Binary {
op: BinaryOp::Or,
left,
right,
} => {
let mut l = rowid_eq_or_chain(left, columns, ipk, params)?;
let r = rowid_eq_or_chain(right, columns, ipk, params)?;
l.extend(r);
Some(l)
}
Expr::Paren(inner) => rowid_seek_constraint(inner, columns, ipk, params),
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => {
let other = if is_rowid_or_ipk(left, columns, ipk) {
right
} else if is_rowid_or_ipk(right, columns, ipk) {
left
} else {
return None;
};
Some(alloc::vec![eval::to_i64(&const_value(other, params)?)])
}
Expr::InList {
expr,
list,
negated: false,
..
} if is_rowid_or_ipk(expr, columns, ipk) && !list.is_empty() => {
let mut out = Vec::with_capacity(list.len());
for item in list {
out.push(eval::to_i64(&const_value(item, params)?));
}
Some(out)
}
_ => None,
}
}
/// A per-column range constraint gathered from `WHERE`: optional lower and upper
/// bounds, each `(value, inclusive)`.
#[derive(Default, Clone)]
struct RangeBound {
lower: Option<(Value, bool)>,
upper: Option<(Value, bool)>,
}
/// Fold one comparison `column <op> value` into a [`RangeBound`]. Overwriting an
/// existing bound is safe: the index range scan only needs to return a superset
/// (the full `WHERE` is re-applied), and either of two bounds on the same side
/// yields a valid superset.
fn apply_bound(b: &mut RangeBound, op: BinaryOp, v: Value) {
match op {
BinaryOp::Gt => b.lower = Some((v, false)),
BinaryOp::GtEq => b.lower = Some((v, true)),
BinaryOp::Lt => b.upper = Some((v, false)),
BinaryOp::LtEq => b.upper = Some((v, true)),
_ => {}
}
}
/// The comparison with its operands swapped (`a < b` ⇔ `b > a`).
fn flip_cmp(op: BinaryOp) -> BinaryOp {
match op {
BinaryOp::Lt => BinaryOp::Gt,
BinaryOp::LtEq => BinaryOp::GtEq,
BinaryOp::Gt => BinaryOp::Lt,
BinaryOp::GtEq => BinaryOp::LtEq,
other => other,
}
}
/// The `[lo, hi)` byte-range a fixed-prefix `GLOB` pattern seeks: `'abc*'` matches
/// exactly the strings `>= 'abc'` and `< 'abd'`. The literal prefix is the run before
/// the first GLOB metacharacter (`*`, `?`, `[`); an empty prefix (a leading wildcard)
/// is unseekable → `None`. The upper bound increments the last byte `< 0xFF` and drops
/// trailing `0xFF` bytes (so it dominates every string starting with the prefix); if
/// every byte is `0xFF`, or the increment is not valid UTF-8, there is no upper bound
/// (`hi = None`) and the seek runs from `lo` to the end — still a valid superset.
fn glob_prefix_range(pat: &str) -> Option<(String, Option<String>)> {
let prefix: String = pat
.chars()
.take_while(|&c| c != '*' && c != '?' && c != '[')
.collect();
if prefix.is_empty() {
return None;
}
let mut hi = prefix.clone().into_bytes();
loop {
match hi.last().copied() {
Some(0xFF) => {
hi.pop();
}
Some(b) => {
*hi.last_mut().unwrap() = b + 1;
break;
}
None => break,
}
}
let hi = if hi.is_empty() {
None
} else {
String::from_utf8(hi).ok()
};
Some((prefix, hi))
}
/// A range on the table's rowid expressed through a `rowid`/`_rowid_`/`oid` alias
/// (`… AND rowid>?`) — the column-name range collector resolves the INTEGER PRIMARY
/// KEY by its declared name, so the bare-alias spelling needs this separate walk.
/// Returns the folded `RangeBound`, or `None` when no such bound is present. The
/// alias must not be shadowed by a real column of that name.
fn rowid_alias_range(e: &Expr, meta: &TableMeta, params: &Params) -> Option<RangeBound> {
fn is_rowid(x: &Expr, meta: &TableMeta) -> bool {
matches!(x, Expr::Column { column, .. }
if is_rowid_alias(column)
&& !meta.columns.iter().any(|c| c.name.eq_ignore_ascii_case(column)))
}
fn walk(e: &Expr, meta: &TableMeta, params: &Params, out: &mut RangeBound, found: &mut bool) {
match e {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => {
walk(left, meta, params, out, found);
walk(right, meta, params, out, found);
}
Expr::Paren(inner) => walk(inner, meta, params, out, found),
Expr::Binary { op, left, right }
if matches!(
op,
BinaryOp::Lt | BinaryOp::LtEq | BinaryOp::Gt | BinaryOp::GtEq
) =>
{
if is_rowid(left, meta) {
if let Some(v) = const_value(right, params) {
apply_bound(out, *op, v);
*found = true;
}
} else if is_rowid(right, meta)
&& let Some(v) = const_value(left, params)
{
apply_bound(out, flip_cmp(*op), v);
*found = true;
}
}
Expr::Between {
expr,
low,
high,
negated: false,
} if is_rowid(expr, meta) => {
if let Some(v) = const_value(low, params) {
apply_bound(out, BinaryOp::GtEq, v);
*found = true;
}
if let Some(v) = const_value(high, params) {
apply_bound(out, BinaryOp::LtEq, v);
*found = true;
}
}
_ => {}
}
}
let mut b = RangeBound::default();
let mut found = false;
walk(e, meta, params, &mut b, &mut found);
found.then_some(b)
}
/// Collect per-column range bounds (`<`/`<=`/`>`/`>=`/`BETWEEN`) from the
/// top-level `AND` conjuncts of `WHERE`, keyed by column index. Drives an index
/// range scan; non-range and non-constant terms are ignored (the full `WHERE` is
/// re-applied afterward).
/// The *effective* collation of a single-bound range comparison on column `col`
/// within `e` (an explicit `COLLATE` on the bound, else the column's declared
/// collation), or `None`. `BETWEEN`/`GLOB` return the column's collation (their
/// bounds keep the column-collation gate). Used by collation-aware range index
/// selection to match a `> 'x' COLLATE NOCASE` bound to a `NOCASE` index (B9j).
fn range_collation(
e: &Expr,
columns: &[ColumnInfo],
col: usize,
) -> Option<crate::value::Collation> {
match e {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => range_collation(left, columns, col).or_else(|| range_collation(right, columns, col)),
Expr::Paren(inner) => range_collation(inner, columns, col),
Expr::Binary {
op: BinaryOp::Lt | BinaryOp::LtEq | BinaryOp::Gt | BinaryOp::GtEq,
left,
right,
} => {
if col_index(left, columns) == Some(col) {
Some(explicit_collation(right).unwrap_or(columns[col].collation))
} else if col_index(right, columns) == Some(col) {
Some(explicit_collation(left).unwrap_or(columns[col].collation))
} else {
None
}
}
Expr::Between {
expr,
negated: false,
..
} if col_index(expr, columns) == Some(col) => Some(columns[col].collation),
Expr::Binary {
op: BinaryOp::Glob,
left,
..
} if col_index(left, columns) == Some(col) => Some(columns[col].collation),
_ => None,
}
}
/// Like [`collect_range_constraints`] but WITHOUT the column-collation gate on the
/// single `<`/`>`/`<=`/`>=` bounds (their collation is recovered by
/// [`range_collation`]). `BETWEEN`/`GLOB` — whose bounds each carry their own
/// collation — keep the gated per-bound behaviour, so a mixed-collation `BETWEEN`
/// still selects only the column-collation index. Used by collation-aware range
/// index selection (B9j).
fn collect_range_constraints_coll(
e: &Expr,
columns: &[ColumnInfo],
params: &Params,
out: &mut alloc::collections::BTreeMap<usize, RangeBound>,
) {
match e {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => {
collect_range_constraints_coll(left, columns, params, out);
collect_range_constraints_coll(right, columns, params, out);
}
Expr::Paren(inner) => collect_range_constraints_coll(inner, columns, params, out),
Expr::Binary { op, left, right }
if matches!(
op,
BinaryOp::Lt | BinaryOp::LtEq | BinaryOp::Gt | BinaryOp::GtEq
) =>
{
if let (Some(ci), Some(v)) = (col_index(left, columns), const_value(right, params)) {
apply_bound(out.entry(ci).or_default(), *op, v);
} else if let (Some(ci), Some(v)) =
(col_index(right, columns), const_value(left, params))
{
apply_bound(out.entry(ci).or_default(), flip_cmp(*op), v);
}
}
// `BETWEEN` / `GLOB` keep the gated per-bound handling — delegate.
Expr::Between { .. }
| Expr::Binary {
op: BinaryOp::Glob, ..
} => collect_range_constraints(e, columns, params, out),
_ => {}
}
}
fn collect_range_constraints(
e: &Expr,
columns: &[ColumnInfo],
params: &Params,
out: &mut alloc::collections::BTreeMap<usize, RangeBound>,
) {
match e {
Expr::Binary {
op: BinaryOp::And,
left,
right,
} => {
collect_range_constraints(left, columns, params, out);
collect_range_constraints(right, columns, params, out);
}
Expr::Paren(inner) => collect_range_constraints(inner, columns, params, out),
Expr::Binary { op, left, right }
if matches!(
op,
BinaryOp::Lt | BinaryOp::LtEq | BinaryOp::Gt | BinaryOp::GtEq
) =>
{
// A bound whose value carries an explicit `COLLATE` differing from the
// column's collation cannot seek that column's index (its keys order for a
// different collation), so SQLite ignores it for the seek — same rule as
// the equality collector.
let coll_ok = |ci: usize, val: &Expr| {
explicit_collation(val).is_none_or(|c| c == columns[ci].collation)
};
if let (Some(ci), Some(v)) = (col_index(left, columns), const_value(right, params)) {
if coll_ok(ci, right) {
apply_bound(out.entry(ci).or_default(), *op, v);
}
} else if let (Some(ci), Some(v)) =
(col_index(right, columns), const_value(left, params))
&& coll_ok(ci, left)
{
apply_bound(out.entry(ci).or_default(), flip_cmp(*op), v);
}
}
Expr::Between {
expr,
low,
high,
negated: false,
} => {
if let Some(ci) = col_index(expr, columns) {
let coll_ok =
|val: &Expr| explicit_collation(val).is_none_or(|c| c == columns[ci].collation);
let b = out.entry(ci).or_default();
if let (Some(v), true) = (const_value(low, params), coll_ok(low)) {
apply_bound(b, BinaryOp::GtEq, v);
}
if let (Some(v), true) = (const_value(high, params), coll_ok(high)) {
apply_bound(b, BinaryOp::LtEq, v);
}
}
}
// `col GLOB 'prefix*'` (SQLite's GLOB is always case-sensitive / byte-based)
// seeks the `[prefix, prefix⁺)` range on a BINARY index — the index orders keys
// by byte, matching GLOB. A NOCASE/RTRIM column can't serve it, so gate on the
// column's collation being BINARY. Superset-safe: `run_core` re-applies GLOB.
Expr::Binary {
op: BinaryOp::Glob,
left,
right,
} => {
if let (Some(ci), Some(Value::Text(pat))) =
(col_index(left, columns), const_value(right, params))
&& columns[ci].collation == crate::value::Collation::Binary
&& let Some((lo, hi)) = glob_prefix_range(&pat)
{
let b = out.entry(ci).or_default();
apply_bound(b, BinaryOp::GtEq, Value::Text(lo.into()));
if let Some(hi) = hi {
apply_bound(b, BinaryOp::Lt, Value::Text(hi.into()));
}
}
}
_ => {}
}
}
/// The column index a bare/qualified column expression resolves to, if any.
fn col_index(e: &Expr, columns: &[ColumnInfo]) -> Option<usize> {
// A parenthesized column (`(a) = 2`) is the same column for seek purposes, so
// unwrap any `Paren` wrappers first — SQLite seeks it exactly as the bare form.
let mut e = e;
while let Expr::Paren(inner) = e {
e = inner;
}
if let Expr::Column { table, column, .. } = e {
columns.iter().position(|c| {
c.name.eq_ignore_ascii_case(column)
&& table
.as_deref()
.is_none_or(|t| c.table.eq_ignore_ascii_case(t))
})
} else {
None
}
}
/// Resolve, for the N-table join-order cost model, the *local* column index within
/// candidate table `cand` of the join column the `join`'s top-level `=` `ON` binds
/// to it. `block_start[t]` is table `t`'s column-block start in `declared_cols`.
/// Returns `None` when the `ON` is not a single `=` of two resolvable columns or
/// neither side belongs to `cand` (the caller then abandons its LogEst estimate).
fn ntable_edge_local(
join: &Join,
cand: usize,
block_start: &[usize],
declared_cols: &[ColumnInfo],
) -> Option<usize> {
let mut on = join.on.as_ref()?;
while let Expr::Paren(inner) = on {
on = inner;
}
let (l, r) = match on {
Expr::Binary {
op: BinaryOp::Eq,
left,
right,
} => (
col_index(left, declared_cols)?,
col_index(right, declared_cols)?,
),
_ => return None,
};
let start = block_start[cand];
let end = block_start
.get(cand + 1)
.copied()
.unwrap_or(declared_cols.len());
let in_cand = |g: usize| g >= start && g < end;
if in_cand(l) {
Some(l - start)
} else if in_cand(r) {
Some(r - start)
} else {
None
}
}
/// The explicit collation a top-level `COLLATE` wrapper applies to `e` (the seek-
/// relevant collation of a comparison operand), or `None` when the value carries no
/// explicit `COLLATE` (so the comparison uses the column's own collation).
fn explicit_collation(e: &Expr) -> Option<crate::value::Collation> {
match e {
Expr::Collate { collation, .. } => crate::value::resolve_collation_name(collation),
Expr::Paren(inner) => explicit_collation(inner),
_ => None,
}
}
/// Evaluate `e` as a constant (no column references), or `None` if it depends on
/// a row.
fn const_value(e: &Expr, params: &Params) -> Option<Value> {
eval::eval(e, &EvalCtx::rowless(params)).ok()
}
/// Whether `name` is one of SQLite's rowid aliases (`rowid`, `_rowid_`, `oid`),
/// case-insensitively — usable as a column name only when no real column shadows it.
fn is_rowid_alias(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"rowid" | "_rowid_" | "oid"
)
}
/// Coerce each value to its column's type affinity (SQLite storage affinity).
fn apply_column_affinity(meta: &TableMeta, values: &mut [Value]) {
for (i, v) in values.iter_mut().enumerate() {
let taken = core::mem::replace(v, Value::Null);
*v = meta.columns[i].affinity.coerce(taken);
}
}
/// Enforce declared `NOT NULL` column constraints over a fully-built row.
fn check_not_null(meta: &TableMeta, values: &[Value]) -> Result<()> {
for (i, v) in values.iter().enumerate() {
if meta.not_null[i].is_some() && matches!(v, Value::Null) {
return Err(Error::Constraint(format!(
"NOT NULL constraint failed: {}.{}",
meta.columns[i].table, meta.columns[i].name
)));
}
}
Ok(())
}
/// Build an index key record: the indexed column values followed by the trailing
/// rowid (which makes every index key unique and supports lookups).
fn index_key(cols: &[usize], values: &[Value], rowid: i64) -> Vec<u8> {
let mut key: Vec<Value> = cols.iter().map(|&p| values[p].clone()).collect();
key.push(Value::Integer(rowid));
encode_record(&key)
}
/// Build the `sqlite_stat1` `stat` string for an index over `rows`: `nRow`
/// followed by, for each leftmost prefix length `K`, an estimate of how many
/// rows an equality query on the first `K` columns matches. Matching SQLite's
/// `statGet`, the estimate for `D` distinct prefixes is `I = (nRow + D - 1) / D`
/// (i.e. `nRow/D` rounded up), except that an `I` of exactly 2 is pulled back to
/// 1 when it is barely above 1.0 (`nRow*10 <= D*11`). Collation-aware.
fn index_stat_string(
cols: &[usize],
colls: &[crate::value::Collation],
rows: &[Vec<Value>],
) -> String {
let n = rows.len() as u64;
let mut tuples: Vec<Vec<Value>> = rows
.iter()
.map(|r| cols.iter().map(|&c| r[c].clone()).collect())
.collect();
tuples.sort_by(|a, b| stat_prefix_cmp(a, b, colls, cols.len()));
let mut s = alloc::format!("{n}");
for k in 1..=cols.len() {
let mut distinct = 1u64; // n > 0 guaranteed by the caller
for w in tuples.windows(2) {
if stat_prefix_cmp(&w[0], &w[1], colls, k) != core::cmp::Ordering::Equal {
distinct += 1;
}
}
let mut avg = n.div_ceil(distinct);
if avg == 2 && n * 10 <= distinct * 11 {
avg = 1;
}
s.push(' ');
s.push_str(&avg.to_string());
}
s
}
/// Compare the leftmost `len` columns of two index tuples under per-column
/// collations (used to count distinct prefixes for `ANALYZE`).
fn stat_prefix_cmp(
a: &[Value],
b: &[Value],
colls: &[crate::value::Collation],
len: usize,
) -> core::cmp::Ordering {
for i in 0..len {
let coll = colls.get(i).copied().unwrap_or_default();
let ord = crate::value::cmp_values_coll(&a[i], &b[i], coll);
if ord != core::cmp::Ordering::Equal {
return ord;
}
}
core::cmp::Ordering::Equal
}
/// Which scanned row (index into `existing`) an `ON CONFLICT … DO …` clause
/// targets on a WITHOUT ROWID table, or `None` when the clause's target does not
/// match this collision. A bare `ON CONFLICT` (no target) absorbs the first
/// collision; `ON CONFLICT(cols)` matches the colliding row that shares those
/// exact columns (NULLs never match — a NULL key is distinct). The WITHOUT ROWID
/// analogue of [`Connection::upsert_target_row`], keyed by scan position rather
/// than rowid.
fn wr_upsert_target(
meta: &TableMeta,
up: &Upsert,
existing: &[Vec<Value>],
collide: &[usize],
values: &[Value],
) -> Option<usize> {
if up.target.is_empty() {
return collide.first().copied();
}
let target_cols: Vec<usize> = up
.target
.iter()
.map(|name| {
meta.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
})
.collect::<Option<Vec<usize>>>()?;
collide.iter().copied().find(|&ci| {
target_cols.iter().all(|&c| {
!matches!(values[c], Value::Null)
&& crate::value::cmp_values_coll(
&existing[ci][c],
&values[c],
meta.columns[c].collation,
)
.is_eq()
})
})
}
fn unique_match(meta: &TableMeta, a: &[Value], b: &[Value]) -> bool {
meta.unique.iter().any(|(set, _, _)| {
set.iter().all(|&c| {
!matches!(a[c], Value::Null)
&& !matches!(b[c], Value::Null)
&& crate::value::cmp_values_coll(&a[c], &b[c], meta.columns[c].collation).is_eq()
})
})
}
/// Whether building a UNIQUE index over `tuples` (the indexed key values of each
/// included row, with the trailing rowid / PK suffix excluded) would be violated
/// by the existing rows: two of them share an all-non-NULL key under the index
/// collations `colls`. SQLite treats any index key containing a NULL as distinct,
/// so such rows never conflict. O(n log n) — runs once at `CREATE UNIQUE INDEX`.
fn unique_index_conflict(tuples: &[Vec<Value>], colls: &[crate::value::Collation]) -> bool {
use core::cmp::Ordering;
let mut idx: Vec<usize> = tuples
.iter()
.enumerate()
.filter(|(_, t)| !t.iter().any(|v| matches!(v, Value::Null)))
.map(|(i, _)| i)
.collect();
let key_cmp = |a: usize, b: usize| -> Ordering {
for (k, &coll) in colls.iter().enumerate() {
let o = crate::value::cmp_values_coll(&tuples[a][k], &tuples[b][k], coll);
if o != Ordering::Equal {
return o;
}
}
Ordering::Equal
};
idx.sort_by(|&a, &b| key_cmp(a, b));
idx.windows(2)
.any(|w| key_cmp(w[0], w[1]) == Ordering::Equal)
}
/// SQLite's UNIQUE-violation message for two WITHOUT ROWID rows that collide on
/// an inline `UNIQUE`/`PRIMARY KEY` set (`UNIQUE constraint failed: t.a[, t.b]`),
/// or the bare message when the collision is on a standalone unique index.
fn wr_unique_message(meta: &TableMeta, a: &[Value], b: &[Value]) -> String {
meta.unique
.iter()
.find(|(set, _, _)| {
set.iter().all(|&c| {
!matches!(a[c], Value::Null)
&& !matches!(b[c], Value::Null)
&& crate::value::cmp_values_coll(&a[c], &b[c], meta.columns[c].collation)
.is_eq()
})
})
.map(|(set, _, _)| {
let cols = set
.iter()
.map(|&i| alloc::format!("{}.{}", meta.columns[i].table, meta.columns[i].name))
.collect::<Vec<_>>()
.join(", ");
alloc::format!("UNIQUE constraint failed: {cols}")
})
.unwrap_or_else(|| String::from("UNIQUE constraint failed"))
}
/// An index record for a `WITHOUT ROWID` table: the indexed columns followed by
/// the table's *trailing* PRIMARY KEY columns (which make the entry unique), as
/// SQLite does. `trailing_pk` is the PK column list already deduplicated against
/// the index key columns (see [`wr_trailing_pk`]): a PK column that is also an
/// index key column with the *same* collation is not repeated, matching SQLite's
/// `isDupColumn` logic in `sqlite3CreateIndex`. Repeating it would produce a key
/// shape (`a, c, a, b`) that SQLite never writes, so the resulting index fails
/// `PRAGMA integrity_check` and is unreadable by SQLite.
fn wr_index_key(cols: &[usize], trailing_pk: &[usize], values: &[Value]) -> Vec<u8> {
let mut key: Vec<Value> = cols.iter().map(|&p| values[p].clone()).collect();
key.extend(trailing_pk.iter().map(|&p| values[p].clone()));
encode_record(&key)
}
/// The trailing PRIMARY KEY columns appended to a `WITHOUT ROWID` secondary
/// index key, with their collations and stored DESC directions — SQLite's
/// PK-append dedup from `sqlite3CreateIndex`/`isDupColumn`. A PK column that is
/// already one of the index's key columns *with the same collation* is dropped
/// (it is already in the key); one that overlaps a key column but under a
/// different collation is kept. The kept PK columns preserve PK key order and
/// carry the PK's per-column collation and DESC (matching how SQLite reloads the
/// index's sort order from the PK on schema load).
///
/// `idx_cols`/`idx_colls` are the index's own key columns and their collations;
/// `pk_cols` is `storage_order[..pk_len]`; `meta` supplies PK collations/descs.
fn wr_trailing_pk(
idx_cols: &[usize],
idx_colls: &[crate::value::Collation],
pk_cols: &[usize],
meta: &TableMeta,
) -> (Vec<usize>, Vec<crate::value::Collation>, Vec<bool>) {
let mut cols = Vec::new();
let mut colls = Vec::new();
let mut descs = Vec::new();
for (i, &pc) in pk_cols.iter().enumerate() {
let pk_coll = meta.columns[pc].collation;
// isDupColumn: same column *and* same collation ⇒ already in the key.
let dup = idx_cols
.iter()
.zip(idx_colls.iter())
.any(|(&c, &coll)| c == pc && coll == pk_coll);
if dup {
continue;
}
cols.push(pc);
colls.push(pk_coll);
descs.push(meta.pk_descending.get(i).copied().unwrap_or(false));
}
(cols, colls, descs)
}
/// Extend a WITHOUT ROWID secondary index's per-key-column DESC flags with the
/// trailing PK columns' directions, for handing to the b-tree writer/reader.
///
/// The b-tree treats an empty `descs` as all-ascending, so when neither the
/// index columns nor the trailing PK columns are DESC we keep `descs` empty
/// (the byte-for-byte no-op case). Only when some column is DESC do we
/// materialize a full-length vector: the index columns' own directions
/// (`idx.seek_descs()`, which is empty ⇒ all-ascending for `idx_colls.len()`
/// columns) followed by the trailing PK directions.
fn wr_extend_descs(
descs: &mut Vec<bool>,
idx_colls: &[crate::value::Collation],
trailing_descs: &[bool],
) {
if trailing_descs.iter().all(|&d| !d) {
// No DESC trailing PK column: the trailing part is ascending, so just
// reuse the index columns' own flags (possibly empty ⇒ all-ascending).
return;
}
if descs.is_empty() {
// The index columns were all ascending (empty slice); pad them out so
// the trailing DESC flags line up with the right key positions.
descs.extend(core::iter::repeat_n(false, idx_colls.len()));
}
descs.extend_from_slice(trailing_descs);
}
#[derive(Clone)]
struct InputRow {
values: Vec<Value>,
rowid: Option<i64>,
}
impl InputRow {
fn ctx<'a>(&'a self, columns: &'a [ColumnInfo], params: &'a Params) -> EvalCtx<'a> {
EvalCtx {
row: &self.values,
columns,
rowid: self.rowid,
params,
anon_counter: core::cell::Cell::new(0),
subqueries: None,
}
}
}
/// Build an evaluation context for a standalone `(values, rowid)` row.
fn row_ctx<'a>(
values: &'a [Value],
columns: &'a [ColumnInfo],
rowid: Option<i64>,
params: &'a Params,
) -> EvalCtx<'a> {
EvalCtx {
row: values,
columns,
rowid,
params,
anon_counter: core::cell::Cell::new(0),
subqueries: None,
}
}
/// The conventional `<path>-journal` companion file name.
fn journal_path(path: &str) -> String {
let mut p = String::from(path);
p.push_str("-journal");
p
}
/// The conventional `<path>-wal` companion file name.
fn wal_path(path: &str) -> String {
let mut p = String::from(path);
p.push_str("-wal");
p
}
struct OutRow {
values: Vec<Value>,
sort_keys: Vec<Value>,
}
/// Output column labels for a `RETURNING` projection (mirrors a `SELECT` list:
/// `*`/`tbl.*` expand to table column names, expressions use their alias or a
/// derived label).
fn returning_labels(returning: &[ResultColumn], columns: &[ColumnInfo]) -> Vec<String> {
let mut labels = Vec::new();
for col in returning {
match col {
ResultColumn::Wildcard => {
for c in columns {
labels.push(c.name.clone());
}
}
ResultColumn::TableWildcard(t) => {
for c in columns {
if c.table.eq_ignore_ascii_case(t) {
labels.push(c.name.clone());
}
}
}
ResultColumn::Expr {
expr,
alias,
source,
} => {
labels.push(result_column_label(expr, alias, source));
}
}
}
labels
}
fn project_column(
col: &ResultColumn,
columns: &[ColumnInfo],
ctx: &EvalCtx,
out: &mut Vec<Value>,
) -> Result<()> {
match col {
ResultColumn::Wildcard => {
// Hidden columns (e.g. `json_each`'s `json`/`root`) are resolvable
// by name but excluded from `*`; the row carries a value per column.
for (i, c) in columns.iter().enumerate() {
if !c.hidden {
out.push(ctx.row[i].clone());
}
}
}
ResultColumn::TableWildcard(table) => {
for (i, c) in columns.iter().enumerate() {
if !c.hidden && c.table.eq_ignore_ascii_case(table) {
out.push(ctx.row[i].clone());
}
}
}
ResultColumn::Expr { expr, .. } => {
out.push(eval::eval(expr, ctx)?);
}
}
Ok(())
}
/// If `expr` is a positional reference — a (possibly negated) integer literal,
/// optionally wrapped in parentheses or a `COLLATE` clause — return its signed
/// value. SQLite reads such a term in `GROUP BY` / `ORDER BY` as a 1-based output
/// column index; an expression like `1+1` is *not* positional. Used only for
/// range validation: in-range resolution still goes through
/// [`resolve_order_index`].
fn positional_int(expr: &Expr) -> Option<i64> {
match expr {
Expr::Literal(Literal::Integer(n)) => Some(*n),
Expr::Unary {
op: UnaryOp::Negate,
expr,
} => match expr.as_ref() {
Expr::Literal(Literal::Integer(n)) => Some(n.wrapping_neg()),
_ => None,
},
// Unary `+` is a SQLite no-op the parser folds away, so `+2` resolves to
// positional 2 (verified: `ORDER BY +2` errors with 1 output column).
Expr::Unary {
op: UnaryOp::Identity,
expr,
} => positional_int(expr),
Expr::Collate { expr, .. } | Expr::Paren(expr) => positional_int(expr),
_ => None,
}
}
/// Whether an `ORDER BY` term's explicit `NULLS FIRST`/`LAST` is *redundant* — i.e.
/// it requests exactly the null placement a uniform-direction index/storage walk
/// already produces, so the term orders identically to one with no `NULLS` clause.
///
/// A forward index/PK walk yields NULLs first (they sort lowest); a reversed
/// (`DESC`) walk yields them last. SQLite's defaults match: `ASC` ⇒ `NULLS FIRST`,
/// `DESC` ⇒ `NULLS LAST` — i.e. `nulls_first == !descending`. So an explicit clause
/// equal to that default is a no-op the order-detection paths can treat exactly
/// like a bare term (no sorter, no EQP temp-b-tree). The *opposite* placement
/// (`ASC NULLS LAST` / `DESC NULLS FIRST`) is NOT produced by a single walk — SQLite
/// serves it with a two-pass index scan we don't model — so it is not redundant and
/// the callers still decline.
fn redundant_nulls(term: &OrderTerm) -> bool {
match term.nulls_first {
None => true,
Some(nf) => nf != term.descending,
}
}
/// The effective sort-key expression of an `ORDER BY` term, as the order-detection
/// paths should see it. SQLite resolves a 1-based positional ordinal (`ORDER BY 2`)
/// and a bare output alias (`SELECT a AS x … ORDER BY x`) to the underlying
/// result-column expression *before* planning, so a scan that already yields that
/// column in order needs no sorter. This returns that underlying expression when the
/// term is such an ordinal or alias and the named result column is a plain
/// expression; otherwise it returns the term unchanged (a directly-written column,
/// or an ordinal/alias landing on a wildcard or out-of-range slot, for which the
/// callers fall back to their own matching). `columns` is the projection — pass the
/// wildcard-expanded form ([`order_projection`]) so an ordinal over `SELECT *`
/// resolves to the column it names. The returned reference borrows from `columns`
/// or from `e`, so it is valid for as long as both are.
fn order_key_expr<'a>(columns: &'a [ResultColumn], e: &'a Expr) -> &'a Expr {
// Positional ordinal → the n-th result column's expression.
if let Some(n) = positional_int(e) {
if let Ok(i) = usize::try_from(n)
&& let Some(i) = i.checked_sub(1)
&& let Some(ResultColumn::Expr { expr, .. }) = columns.get(i)
{
return expr;
}
return e;
}
// Bare output alias → the matching result column's expression. SQLite resolves
// `ORDER BY` against output column names first, so an alias that shadows a table
// column still means the projected expression.
if let Expr::Column {
schema: None,
table: None,
column,
..
} = e
&& let Some(ResultColumn::Expr { expr, .. }) = columns.iter().find(|rc| {
matches!(rc, ResultColumn::Expr { alias: Some(a), .. } if a.eq_ignore_ascii_case(column))
}) {
return expr;
}
e
}
/// The projection as the order-detection paths should see it for ordinal
/// resolution: a `*` / `table.*` wildcard expanded in place into one synthetic
/// unqualified column reference per non-hidden table column (declared order).
/// SQLite resolves a positional `ORDER BY` ordinal against the *expanded* output
/// list before planning, so `SELECT * FROM t ORDER BY 1` orders by the first table
/// column and an index on it can serve the sort with no sorter. Returns the
/// projection borrowed unchanged when it holds no wildcard, so the common case
/// clones nothing. The synthetic references are unqualified (`table: None`) to
/// match a directly-written bare `ORDER BY col` — these single-table paths resolve
/// the name against the one table regardless.
fn order_projection<'a>(
columns: &'a [ResultColumn],
table_cols: &[ColumnInfo],
) -> Cow<'a, [ResultColumn]> {
if !columns
.iter()
.any(|c| matches!(c, ResultColumn::Wildcard | ResultColumn::TableWildcard(_)))
{
return Cow::Borrowed(columns);
}
let col_ref = |c: &ColumnInfo| ResultColumn::Expr {
expr: Expr::Column {
schema: None,
table: None,
column: c.name.clone(),
quoted: false,
span: Span::none(),
},
alias: None,
source: None,
};
let mut out = Vec::with_capacity(columns.len());
for c in columns {
match c {
ResultColumn::Wildcard => {
out.extend(table_cols.iter().filter(|c| !c.hidden).map(&col_ref))
}
ResultColumn::TableWildcard(t) => out.extend(
table_cols
.iter()
.filter(|c| !c.hidden && c.table.eq_ignore_ascii_case(t))
.map(&col_ref),
),
other => out.push(other.clone()),
}
}
Cow::Owned(out)
}
/// Whether a projection has the exact shape `values_core` produces for a desugared
/// multi-row `VALUES`: every column is a bare expression auto-aliased `column1`,
/// `column2`, … in order, with no source span. Used to tell a real `VALUES` from
/// an explicit FROM-less `SELECT … UNION ALL SELECT …` when reporting a
/// column-count mismatch.
fn is_values_projection(cols: &[ResultColumn]) -> bool {
!cols.is_empty()
&& cols.iter().enumerate().all(|(i, c)| {
matches!(
c,
ResultColumn::Expr { alias: Some(a), source: None, .. }
if *a == alloc::format!("column{}", i + 1)
)
})
}
/// SQLite's `%r` ordinal: `1`→`1st`, `2`→`2nd`, `3`→`3rd`, others `th`, with
/// `11`/`12`/`13` always `th`.
fn ordinal(n: usize) -> alloc::string::String {
let suffix = if (11..=13).contains(&(n % 100)) {
"th"
} else {
match n % 10 {
1 => "st",
2 => "nd",
3 => "rd",
_ => "th",
}
};
alloc::format!("{n}{suffix}")
}
/// Reject any `GROUP BY` / `ORDER BY` positional term that falls outside
/// `1..=ncols`, byte-matching SQLite's
/// `<ordinal> <clause> term out of range - should be between 1 and <ncols>`.
/// The ordinal is the offending term's 1-based position *within its clause*
/// (counting non-positional terms too). `ncols` is the output-column count.
/// SQLite resolves `ORDER BY` before `GROUP BY`, so when both clauses have an
/// out-of-range term the `ORDER BY` one is reported.
fn check_positional_terms(group_by: &[Expr], order_by: &[OrderTerm], ncols: usize) -> Result<()> {
for (i, t) in order_by.iter().enumerate() {
if let Some(n) = positional_int(&t.expr)
&& (n < 1 || (n as u64) > ncols as u64)
{
return Err(Error::Error(alloc::format!(
"{} ORDER BY term out of range - should be between 1 and {ncols}",
ordinal(i + 1),
)));
}
}
for (i, g) in group_by.iter().enumerate() {
if let Some(n) = positional_int(g)
&& (n < 1 || (n as u64) > ncols as u64)
{
return Err(Error::Error(alloc::format!(
"{} GROUP BY term out of range - should be between 1 and {ncols}",
ordinal(i + 1),
)));
}
}
Ok(())
}
/// Apply SQLite's `OP_MustBeInt` to a `LIMIT`/`OFFSET` value: it must be an
/// integer, or a real / fully-numeric text string that is exactly integer-valued
/// and in range. A non-integral real (`1.9`), text with trailing garbage
/// (`'2abc'`), NULL, or a blob is a `datatype mismatch` error — SQLite does not
/// silently truncate or treat NULL as zero here.
fn must_be_int(v: Value) -> Result<i64> {
fn real_exact(r: f64) -> Result<i64> {
if r.is_finite()
&& r == crate::util::float::trunc(r)
&& r >= i64::MIN as f64
&& r < 9_223_372_036_854_775_808.0
{
Ok(r as i64)
} else {
Err(Error::Error("datatype mismatch".into()))
}
}
match v {
Value::Integer(i) => Ok(i),
Value::Real(r) => real_exact(r),
Value::Text(s) => {
let t = s.trim();
if let Ok(i) = t.parse::<i64>() {
Ok(i)
} else if let Ok(r) = t.parse::<f64>() {
real_exact(r)
} else {
Err(Error::Error("datatype mismatch".into()))
}
}
Value::Null | Value::Blob(_) => Err(Error::Error("datatype mismatch".into())),
}
}
/// Resolve an `ORDER BY` term to an output-column index when it refers to one:
/// a positive integer literal `N` (1-based position), or a bare column name that
/// matches a result-column label/alias. Returns `None` for general expressions,
/// which are evaluated against the row instead.
fn resolve_order_index(expr: &Expr, labels: &[String], ncols: usize) -> Option<usize> {
// A (possibly signed / parenthesized / `COLLATE`-wrapped) integer literal is a
// 1-based positional reference: SQLite folds the unary sign, so `ORDER BY +2`
// is position 2 just like `ORDER BY 2`. Resolve it the same way a bare literal
// is (`positional_int` recognizes exactly these wrapped-integer-literal forms;
// it returns `None` for `+col`/`(col)`, which fall through to the alias match).
if let Some(n) = positional_int(expr) {
let idx = usize::try_from(n).ok()?.checked_sub(1)?;
return (idx < ncols).then_some(idx);
}
match expr {
Expr::Column {
table: None,
column,
..
} => labels.iter().position(|l| l.eq_ignore_ascii_case(column)),
// `ORDER BY <alias> COLLATE …` (or a parenthesized term) still resolves to
// the output column; the explicit collation is applied by the sort
// comparison via `order_collations`/`key_collation`.
Expr::Collate { expr, .. } | Expr::Paren(expr) => resolve_order_index(expr, labels, ncols),
_ => None,
}
}
/// Invoke `f(is_max, arg)` for each plain (non-window) single-argument `min()` /
/// `max()` aggregate call in `expr`. Used to detect SQLite's bare-column rule:
/// a query with exactly one `min`/`max` takes bare columns from the extreme row.
fn for_each_minmax(expr: &Expr, f: &mut dyn FnMut(bool, &Expr)) {
match expr {
Expr::Function {
over: Some(_),
args,
..
} => {
for a in args {
for_each_minmax(a, f);
}
}
Expr::Function {
name,
args,
star: false,
..
} => {
if args.len() == 1 {
let l = name.to_ascii_lowercase();
if l == "min" || l == "max" {
f(l == "max", &args[0]);
}
}
for a in args {
for_each_minmax(a, f);
}
}
Expr::Function { args, .. } => {
for a in args {
for_each_minmax(a, f);
}
}
Expr::Binary { left, right, .. } => {
for_each_minmax(left, f);
for_each_minmax(right, f);
}
Expr::Unary { expr, .. }
| Expr::Paren(expr)
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. } => for_each_minmax(expr, f),
Expr::Between {
expr, low, high, ..
} => {
for_each_minmax(expr, f);
for_each_minmax(low, f);
for_each_minmax(high, f);
}
Expr::InList { expr, list, .. } => {
for_each_minmax(expr, f);
for l in list {
for_each_minmax(l, f);
}
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
for_each_minmax(o, f);
}
for (w, t) in when_then {
for_each_minmax(w, f);
for_each_minmax(t, f);
}
if let Some(e) = else_result {
for_each_minmax(e, f);
}
}
_ => {}
}
}
/// Whether an outer query over a materialized co-routine (a recursive CTE's
/// `SCAN c`) is the single `min()`/`max()` shape SQLite serves as a one-end
/// `SEARCH` rather than a `SCAN` — the same min/max optimization as for a base
/// table, but with no index detail since a co-routine has none.
///
/// Returns `Some(arg_distinct)` when the result columns hold *exactly one*
/// aggregate and it is a single-argument `min`/`max` (scalar wrappers like
/// `abs(min(a))`/`max(a)+1` and additional plain columns are allowed; only the
/// projection is inspected, so the caller must already have excluded
/// `GROUP BY`/`HAVING`/`DISTINCT`). `arg_distinct` is the call's `DISTINCT` flag:
/// `min(DISTINCT x)` makes SQLite interpose a `USE TEMP B-TREE FOR min(DISTINCT)`
/// node that graphite does not render, so the caller declines that sub-case.
/// `None` for any other shape (no aggregate, a second aggregate, a
/// window/filter/ordered call) — the access stays a plain `SCAN`.
fn coroutine_outer_minmax(sel: &Select) -> Option<bool> {
let mut agg_count = 0usize;
let mut minmax_count = 0usize;
let mut arg_distinct = false;
let mut disqualified = false;
for rc in &sel.columns {
let ResultColumn::Expr { expr, .. } = rc else {
return None;
};
window::visit(expr, &mut |node| {
if let Expr::Function {
name,
distinct,
args,
star,
filter,
order_by,
over,
..
} = node
{
if over.is_some() || filter.is_some() || !order_by.is_empty() {
disqualified = true;
return;
}
if func::is_aggregate_call(name, args.len(), *star) {
agg_count += 1;
if !*star
&& args.len() == 1
&& (name.eq_ignore_ascii_case("min") || name.eq_ignore_ascii_case("max"))
{
minmax_count += 1;
arg_distinct = *distinct;
}
}
}
});
}
if disqualified || agg_count != 1 || minmax_count != 1 {
return None;
}
Some(arg_distinct)
}
/// If a grouped query references exactly one `min()`/`max()` aggregate (anywhere
/// in its result columns, `HAVING`, or `ORDER BY`), return `(is_max, arg)`: bare
/// columns then take their values from the row achieving that extreme, per
/// SQLite. `min(a,b)`/`max(a,b)` (scalar, 2-arg) and window forms don't qualify.
fn single_minmax_arg(sel: &Select) -> Option<(bool, Expr)> {
let mut hits: Vec<(bool, Expr)> = Vec::new();
let mut collect =
|e: &Expr| for_each_minmax(e, &mut |is_max, arg| hits.push((is_max, arg.clone())));
for col in &sel.columns {
if let ResultColumn::Expr { expr, .. } = col {
collect(expr);
}
}
if let Some(h) = &sel.having {
collect(h);
}
for term in &sel.order_by {
collect(&term.expr);
}
if hits.len() == 1 { hits.pop() } else { None }
}
/// Whether `expr` contains an aggregate-function call, using a caller-supplied
/// predicate to decide whether a function name (with its arg count / `*` flag) is
/// an aggregate — so `has_aggregate` can recognize built-in *and* user-registered
/// aggregate functions. A window call (`f(…) OVER (…)`) is not itself an aggregate.
/// Per-query FTS5 state for the aux columns/functions, built by `run_core` for a
/// `MATCH` query over a single `fts5` table and read by `rank`/`bm25()`/
/// `highlight()` during projection and `ORDER BY`.
#[cfg(feature = "fts5")]
struct Fts5QueryCtx {
/// The fts5 table's column names.
col_names: Vec<String>,
/// The literal `MATCH` query string.
query: String,
/// A `col MATCH …` operand column (whole-query scope), if any.
scope: Option<String>,
/// The searchable (indexed) column names — every column except those declared
/// `UNINDEXED`. `None` when all columns are indexed (the common case).
indexed: Option<Vec<String>>,
/// The table's resolved tokenizer config (Porter stemming + `remove_diacritics`
/// level), so `highlight()`/`snippet()` fold exactly like the indexed docs.
tok: crate::vtab::Fts5Tok,
/// The bm25 corpus + rowid→document-index map — present only when `rank` /
/// `bm25()` is referenced (`highlight()` needs only the query, not the corpus).
bm25: Option<(
crate::vtab::Fts5Bm25,
alloc::collections::BTreeMap<i64, usize>,
)>,
/// The table's configured default ranking function `(name, weights)` from the
/// `_config` `rank` row (set by `INSERT INTO t(t, rank) VALUES('rank', …)`), or
/// `None` for the built-in default `bm25()` (all-1.0 weights). Consulted by the
/// bare `rank` column / `ORDER BY rank`, not by an explicit `bm25(t, …)` call.
rank: Option<(String, Vec<f64>)>,
}
#[cfg(feature = "fts5")]
impl Fts5QueryCtx {
/// Whether `col` is searchable (not `UNINDEXED`).
fn col_indexed(&self, col: &str) -> bool {
self.indexed
.as_ref()
.is_none_or(|cols| cols.iter().any(|n| n.eq_ignore_ascii_case(col)))
}
}
/// Restores [`Connection::fts5_rank`] when a `run_core` invocation ends, so a
/// nested query's FTS5 state never leaks into the caller (or vice versa).
#[cfg(feature = "fts5")]
struct Fts5RankGuard<'a> {
conn: &'a Connection,
prev: Option<Fts5QueryCtx>,
}
#[cfg(feature = "fts5")]
impl core::ops::Drop for Fts5RankGuard<'_> {
fn drop(&mut self) {
*self.conn.fts5_rank.borrow_mut() = self.prev.take();
}
}
/// Whether an expression references one of `names` as an unqualified column (the
/// FTS5 `rank` column) or as a function call (`bm25(…)`, `highlight(…)`, …).
#[cfg(feature = "fts5")]
fn expr_mentions_any(expr: &Expr, names: &[&str]) -> bool {
let rec = |e: &Expr| expr_mentions_any(e, names);
match expr {
Expr::Column {
table: None,
column,
..
} => names.iter().any(|n| column.eq_ignore_ascii_case(n)),
Expr::Function { name, args, .. } => {
names.iter().any(|n| name.eq_ignore_ascii_case(n)) || args.iter().any(rec)
}
Expr::Binary { left, right, .. } => rec(left) || rec(right),
Expr::Unary { expr, .. } | Expr::Paren(expr) => rec(expr),
Expr::IsNull { expr, .. } => rec(expr),
Expr::Between {
expr, low, high, ..
} => rec(expr) || rec(low) || rec(high),
Expr::InList { expr, list, .. } => rec(expr) || list.iter().any(rec),
Expr::Case {
operand,
when_then,
else_result,
} => {
operand.as_deref().is_some_and(rec)
|| when_then.iter().any(|(w, t)| rec(w) || rec(t))
|| else_result.as_deref().is_some_and(rec)
}
Expr::Cast { expr, .. } => rec(expr),
_ => false,
}
}
/// Whether a SELECT's projection, `ORDER BY`, or `HAVING` references any of
/// `names` — the cheap gate before building FTS5 query state.
#[cfg(feature = "fts5")]
fn select_mentions(sel: &Select, names: &[&str]) -> bool {
sel.columns
.iter()
.any(|c| matches!(c, ResultColumn::Expr { expr, .. } if expr_mentions_any(expr, names)))
|| sel
.order_by
.iter()
.any(|t| expr_mentions_any(&t.expr, names))
|| sel
.having
.as_ref()
.is_some_and(|h| expr_mentions_any(h, names))
}
fn expr_contains_agg(expr: &Expr, is_agg: &dyn Fn(&str, usize, bool) -> bool) -> bool {
let rec = |e: &Expr| expr_contains_agg(e, is_agg);
match expr {
// A window function (`f(…) OVER (…)`) is not a plain aggregate, even when
// `f` is an aggregate name; only its arguments might contain aggregates.
// (An aggregate in the `OVER` spec routes through the windowed-aggregate
// path via `has_over_spec_aggregate`, but does *not* make the query an
// aggregate one for HAVING-validity — so it is deliberately not counted
// here, matching SQLite.)
Expr::Function {
over: Some(_),
args,
..
} => args.iter().any(rec),
Expr::Function {
name, args, star, ..
} => is_agg(name, args.len(), *star) || args.iter().any(rec),
Expr::Binary { left, right, .. } => rec(left) || rec(right),
Expr::Unary { expr, .. } | Expr::Paren(expr) => rec(expr),
Expr::IsNull { expr, .. } => rec(expr),
Expr::Between {
expr, low, high, ..
} => rec(expr) || rec(low) || rec(high),
Expr::InList { expr, list, .. } => rec(expr) || list.iter().any(rec),
Expr::Case {
operand,
when_then,
else_result,
} => {
operand.as_deref().is_some_and(rec)
|| when_then.iter().any(|(w, t)| rec(w) || rec(t))
|| else_result.as_deref().is_some_and(rec)
}
Expr::Cast { expr, .. } => rec(expr),
// `COLLATE` is transparent to aggregate classification: `sum(a) COLLATE
// binary` is an aggregate result column. A `RowValue` is deliberately not
// descended — an aggregate inside a row value in a result/HAVING position
// is `row value misused` in SQLite regardless, so classifying it as an
// aggregate query would not match and risks an unrelated divergence.
Expr::Collate { expr, .. } => rec(expr),
_ => false,
}
}
/// Combine two compound-query operand row sets per the operator.
fn apply_compound(
op: CompoundOp,
left: Vec<Vec<Value>>,
right: Vec<Vec<Value>>,
colls: &[crate::value::Collation],
) -> Vec<Vec<Value>> {
// Set comparison uses the left SELECT's per-column collations (SQLite).
let eq = |a: &[Value], b: &[Value]| rows_equal_coll(a, b, colls);
// Deduplicate, keeping the *last* occurrence's representation: when two rows
// are equal but differ in type (e.g. `1` vs `1.0`), SQLite's compound dedup
// keeps the later one (`SELECT 1 UNION SELECT 1.0` yields `1.0`).
let dedup = |rows: Vec<Vec<Value>>| -> Vec<Vec<Value>> {
let mut seen: Vec<Vec<Value>> = Vec::new();
for r in rows {
match seen.iter().position(|s| eq(s, &r)) {
Some(i) => seen[i] = r,
None => seen.push(r),
}
}
seen
};
match op {
CompoundOp::UnionAll => {
let mut out = left;
out.extend(right);
out
}
CompoundOp::Union => {
let mut out = left;
out.extend(right);
dedup(out)
}
CompoundOp::Intersect => dedup(
left.into_iter()
.filter(|l| right.iter().any(|r| eq(l, r)))
.collect(),
),
CompoundOp::Except => dedup(
left.into_iter()
.filter(|l| !right.iter().any(|r| eq(l, r)))
.collect(),
),
}
}
fn rows_equal(a: &[Value], b: &[Value]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| eval::compare(x, y) == core::cmp::Ordering::Equal)
}
/// Like [`rows_equal`] but comparing column `i` under collation `colls[i]`
/// (missing entries default to `BINARY`).
fn rows_equal_coll(a: &[Value], b: &[Value], colls: &[crate::value::Collation]) -> bool {
a.len() == b.len()
&& a.iter().zip(b).enumerate().all(|(i, (x, y))| {
let c = colls.get(i).copied().unwrap_or_default();
crate::value::cmp_values_coll(x, y, c) == core::cmp::Ordering::Equal
})
}
fn dedup_values(vals: &mut Vec<Value>, coll: crate::value::Collation) {
let mut seen: Vec<Value> = Vec::new();
vals.retain(|v| {
if seen
.iter()
.any(|s| crate::value::cmp_values_coll(s, v, coll) == core::cmp::Ordering::Equal)
{
false
} else {
seen.push(v.clone());
true
}
});
}
fn value_to_literal(v: Value) -> Literal {
match v {
Value::Null => Literal::Null,
Value::Integer(i) => Literal::Integer(i),
Value::Real(r) => Literal::Real(r),
Value::Text(s) => Literal::Str(s.as_str().to_string()),
Value::Blob(b) => Literal::Blob(b),
}
}
/// A list of `(column name, declared type)` pairs — a resolved column set for
/// `view_table_info` (the type is `None` for an expression column).
type NamedColumns = Vec<(String, Option<String>)>;
/// A column's inherited `(affinity, collating sequence)` — what a derived-table
/// column takes from its origin column (see `subquery_column_origins`).
type ColOrigin = (eval::Affinity, crate::value::Collation);
/// The column headers for `PRAGMA table_info` / `table_xinfo`.
fn table_info_columns(extended: bool) -> Vec<String> {
let mut c: Vec<String> = ["cid", "name", "type", "notnull", "dflt_value", "pk"]
.iter()
.map(|s| String::from(*s))
.collect();
if extended {
c.push(String::from("hidden"));
}
c
}
/// Rename every reference to column `old` (of table `table`) to `new` within an
/// expression — both unqualified (`old`) and table-qualified (`table.old`) forms.
/// Used to keep CHECK / generated / DEFAULT expressions valid across an
/// `ALTER TABLE … RENAME COLUMN`. (CHECK/generated/default forbid subqueries, so
/// the non-recursing `replace_expr` covers them.)
fn rename_column_ref(e: &mut Expr, table: &str, old: &str, new: &str) {
window::replace_expr(
e,
&Expr::Column {
schema: None,
table: None,
column: String::from(old),
quoted: false,
span: Span::none(),
},
&Expr::Column {
schema: None,
table: None,
column: String::from(new),
quoted: false,
span: Span::none(),
},
);
window::replace_expr(
e,
&Expr::Column {
schema: None,
table: Some(String::from(table)),
column: String::from(old),
quoted: false,
span: Span::none(),
},
&Expr::Column {
schema: None,
table: Some(String::from(table)),
column: String::from(new),
quoted: false,
span: Span::none(),
},
);
}
/// Whether a `table.`-qualifier names the trigger pseudo-tables `NEW`/`OLD`.
fn is_new_old_qualifier(q: &str) -> bool {
q.eq_ignore_ascii_case("new") || q.eq_ignore_ascii_case("old")
}
/// Replace every `NEW.col` / `OLD.col` reference (and `NEW.*`/`OLD.*`) in `e` with
/// a `NULL` literal, recursing into nested subqueries. Used when probing a
/// trigger body for post-`RENAME COLUMN` breakage without firing it: the trigger's
/// `NEW`/`OLD` rows have no value in a static probe, but they always bind to the
/// trigger's own (renamed) table and are validated by the rename propagation
/// itself, so neutralising them to `NULL` leaves only the real base-table
/// references — the ones a broken rename would leave dangling — to resolve.
fn neutralize_new_old_expr(e: &mut Expr) {
match e {
Expr::Column { table: Some(t), .. } if is_new_old_qualifier(t) => {
*e = Expr::Literal(Literal::Null);
}
// A body `SELECT RAISE(…)` has an effect (abort/ignore the firing row); a
// static probe must never trigger it, so neutralise the call to NULL. Its
// arguments are a keyword + a message string, never a base-table column.
Expr::Function { name, .. } if name.eq_ignore_ascii_case("raise") => {
*e = Expr::Literal(Literal::Null);
}
Expr::Literal(_) | Expr::Parameter(_) | Expr::Column { .. } => {}
Expr::Unary { expr, .. } => neutralize_new_old_expr(expr),
Expr::Binary { left, right, .. } => {
neutralize_new_old_expr(left);
neutralize_new_old_expr(right);
}
Expr::Function {
args,
filter,
order_by,
over,
..
} => {
for a in args {
neutralize_new_old_expr(a);
}
if let Some(f) = filter {
neutralize_new_old_expr(f);
}
for ot in order_by {
neutralize_new_old_expr(&mut ot.expr);
}
if let Some(w) = over {
for p in &mut w.partition_by {
neutralize_new_old_expr(p);
}
for ot in &mut w.order_by {
neutralize_new_old_expr(&mut ot.expr);
}
}
}
Expr::IsNull { expr, .. } => neutralize_new_old_expr(expr),
Expr::InList { expr, list, .. } => {
neutralize_new_old_expr(expr);
for a in list {
neutralize_new_old_expr(a);
}
}
Expr::Between {
expr, low, high, ..
} => {
neutralize_new_old_expr(expr);
neutralize_new_old_expr(low);
neutralize_new_old_expr(high);
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
neutralize_new_old_expr(o);
}
for (w, t) in when_then {
neutralize_new_old_expr(w);
neutralize_new_old_expr(t);
}
if let Some(el) = else_result {
neutralize_new_old_expr(el);
}
}
Expr::Cast { expr, .. } => neutralize_new_old_expr(expr),
Expr::Paren(inner) => neutralize_new_old_expr(inner),
Expr::RowValue(items) => {
for it in items {
neutralize_new_old_expr(it);
}
}
Expr::Collate { expr, .. } => neutralize_new_old_expr(expr),
Expr::Subquery(sel) => neutralize_new_old_select(sel),
Expr::Exists { select, .. } => neutralize_new_old_select(select),
Expr::InSelect { expr, select, .. } => {
neutralize_new_old_expr(expr);
neutralize_new_old_select(select);
}
}
}
/// [`neutralize_new_old_expr`] over every expression a `Select` reaches (columns,
/// `FROM` subqueries/joins, `WHERE`/`GROUP BY`/`HAVING`/`ORDER BY`/`LIMIT`, CTE
/// bodies, and compound arms).
fn neutralize_new_old_select(sel: &mut Select) {
for cte in &mut sel.ctes {
neutralize_new_old_select(&mut cte.select);
}
for (_, arm) in &mut sel.compound {
neutralize_new_old_select(arm);
}
for rc in &mut sel.columns {
match rc {
ResultColumn::Expr { expr, .. } => neutralize_new_old_expr(expr),
ResultColumn::TableWildcard(t) if is_new_old_qualifier(t) => {
*rc = ResultColumn::Expr {
expr: Expr::Literal(Literal::Null),
alias: None,
source: None,
};
}
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => {}
}
}
if let Some(from) = &mut sel.from {
neutralize_new_old_ref(&mut from.first);
for j in &mut from.joins {
neutralize_new_old_ref(&mut j.table);
if let Some(on) = &mut j.on {
neutralize_new_old_expr(on);
}
}
}
for e in sel.where_clause.iter_mut() {
neutralize_new_old_expr(e);
}
for e in &mut sel.group_by {
neutralize_new_old_expr(e);
}
for e in sel.having.iter_mut() {
neutralize_new_old_expr(e);
}
for ot in &mut sel.order_by {
neutralize_new_old_expr(&mut ot.expr);
}
for e in sel.limit.iter_mut() {
neutralize_new_old_expr(e);
}
for e in sel.offset.iter_mut() {
neutralize_new_old_expr(e);
}
}
fn neutralize_new_old_ref(tr: &mut TableRef) {
if let Some(sq) = &mut tr.subquery {
neutralize_new_old_select(sq);
}
if let Some(args) = &mut tr.tvf_args {
for e in args {
neutralize_new_old_expr(e);
}
}
}
/// Build name-resolution probe `SELECT`s for a trigger body, so a `RENAME COLUMN`
/// that leaves a dangling base-table reference in the trigger can be detected (and
/// rejected) without firing it.
///
/// Only the trigger body's *real* `SELECT` ASTs are probed — the source of an
/// `INSERT … SELECT` and a body `SELECT` step — cloned verbatim (with `NEW`/`OLD`/
/// `RAISE` neutralised) and run through the ordinary resolver. Probing the real AST
/// is what keeps the error byte-identical to SQLite's: graphite resolves the exact
/// same select structure SQLite would, so a broken derived/`USING`/CTE source
/// reports the same `no such column` / `cannot join using` detail. Reconstructing a
/// probe from `UPDATE`/`DELETE`/`VALUES`/`WHEN` fields instead risks resolving in a
/// different order than SQLite (whose partial rewrite dangles a different
/// reference), so those are deliberately left unprobed — a break reached only
/// through them is a documented residual (the same shape the DROP COLUMN dependency
/// check also leaves unrejected), never a *wrong* rejection.
fn trigger_probe_selects(ct: &CreateTrigger) -> Vec<Select> {
let mut out: Vec<Select> = Vec::new();
for stmt in &ct.body {
match stmt {
Statement::Insert(ins) => {
if let InsertSource::Select(sel) = &ins.source {
let mut s = (**sel).clone();
// The INSERT's own `WITH` CTEs are in scope for its source.
let mut ctes = ins.ctes.clone();
ctes.append(&mut s.ctes);
s.ctes = ctes;
out.push(s);
}
}
Statement::Select(sel) => out.push(sel.clone()),
_ => {}
}
}
for s in &mut out {
neutralize_new_old_select(s);
}
out
}
/// Whether a resolution-probe error detail indicates a genuine `RENAME COLUMN`
/// breakage that names the renamed column `old`. SQLite reports exactly two
/// classes for such a break — `no such column: <ref>` and
/// `cannot join using column <c> - column not present in both tables` — and the
/// offending identifier is the renamed column. Restricting rejection to these
/// classes (and requiring `old` to appear as a whole identifier) keeps a
/// probe-reshaping artifact from ever being mistaken for a real break.
fn trigger_break_detail(detail: &str, old: &str) -> bool {
let d = detail.to_ascii_lowercase();
let is_break = d.starts_with("no such column:") || d.starts_with("cannot join using column");
if !is_break {
return false;
}
let o = old.to_ascii_lowercase();
d.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|tok| tok == o)
}
/// Rename every reference to table `old` → `new` throughout a `Select`: its
/// `FROM` table references and every table-qualified `old.col` / `old.*`, recursing
/// into subqueries, CTE bodies, and compound parts. Used to keep a dependent view
/// body valid across `ALTER TABLE … RENAME TO`. A same-level CTE named `old`
/// shadows the table, so `FROM old`/`old.*` there is left alone.
fn rename_table_in_select(sel: &mut Select, old: &str, new: &str) {
let shadowed = sel.ctes.iter().any(|c| c.name.eq_ignore_ascii_case(old));
for cte in &mut sel.ctes {
rename_table_in_select(&mut cte.select, old, new);
}
if let Some(from) = &mut sel.from {
rename_table_in_ref(&mut from.first, old, new, shadowed);
for j in &mut from.joins {
rename_table_in_ref(&mut j.table, old, new, shadowed);
if let Some(on) = &mut j.on {
rename_table_in_expr(on, old, new);
}
}
}
for rc in &mut sel.columns {
match rc {
ResultColumn::Expr { expr, .. } => rename_table_in_expr(expr, old, new),
ResultColumn::TableWildcard(t) if !shadowed && t.eq_ignore_ascii_case(old) => {
*t = String::from(new);
}
_ => {}
}
}
if let Some(w) = &mut sel.where_clause {
rename_table_in_expr(w, old, new);
}
for e in &mut sel.group_by {
rename_table_in_expr(e, old, new);
}
if let Some(h) = &mut sel.having {
rename_table_in_expr(h, old, new);
}
for t in &mut sel.order_by {
rename_table_in_expr(&mut t.expr, old, new);
}
for (_, ws) in &mut sel.window_defs {
rename_table_in_window(ws, old, new);
}
if let Some(e) = &mut sel.limit {
rename_table_in_expr(e, old, new);
}
if let Some(e) = &mut sel.offset {
rename_table_in_expr(e, old, new);
}
for (_, comp) in &mut sel.compound {
rename_table_in_select(comp, old, new);
}
}
/// Rename a table reference within a `FROM` source (recursing into a derived
/// subquery). A real table named `old` (not schema-qualified, not shadowed by a
/// same-level CTE) is repointed to `new`.
fn rename_table_in_ref(tref: &mut TableRef, old: &str, new: &str, shadowed: bool) {
if let Some(sub) = &mut tref.subquery {
rename_table_in_select(sub, old, new);
} else if tref.schema.is_none() && !shadowed && tref.name.eq_ignore_ascii_case(old) {
tref.name = String::from(new);
}
}
/// Rename `old` → `new` in a window spec's `PARTITION BY` / `ORDER BY` expressions.
fn rename_table_in_window(ws: &mut WindowSpec, old: &str, new: &str) {
for e in &mut ws.partition_by {
rename_table_in_expr(e, old, new);
}
for t in &mut ws.order_by {
rename_table_in_expr(&mut t.expr, old, new);
}
}
/// Rename a table qualifier `old.col` → `new.col` throughout an expression,
/// recursing into every sub-expression and nested subquery.
fn rename_table_in_expr(e: &mut Expr, old: &str, new: &str) {
match e {
Expr::Column { table: Some(t), .. } if t.eq_ignore_ascii_case(old) => {
*t = String::from(new)
}
Expr::Column { .. } | Expr::Literal(_) | Expr::Parameter(_) => {}
Expr::Unary { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Paren(expr)
| Expr::Collate { expr, .. } => rename_table_in_expr(expr, old, new),
Expr::Binary { left, right, .. } => {
rename_table_in_expr(left, old, new);
rename_table_in_expr(right, old, new);
}
Expr::Function {
args,
filter,
order_by,
over,
..
} => {
for a in args {
rename_table_in_expr(a, old, new);
}
if let Some(f) = filter {
rename_table_in_expr(f, old, new);
}
for t in order_by {
rename_table_in_expr(&mut t.expr, old, new);
}
if let Some(w) = over {
rename_table_in_window(w, old, new);
}
}
Expr::InList { expr, list, .. } => {
rename_table_in_expr(expr, old, new);
for a in list {
rename_table_in_expr(a, old, new);
}
}
Expr::Between {
expr, low, high, ..
} => {
rename_table_in_expr(expr, old, new);
rename_table_in_expr(low, old, new);
rename_table_in_expr(high, old, new);
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
rename_table_in_expr(o, old, new);
}
for (w, t) in when_then {
rename_table_in_expr(w, old, new);
rename_table_in_expr(t, old, new);
}
if let Some(el) = else_result {
rename_table_in_expr(el, old, new);
}
}
Expr::RowValue(items) => {
for i in items {
rename_table_in_expr(i, old, new);
}
}
Expr::Subquery(s) => rename_table_in_select(s, old, new),
Expr::Exists { select, .. } => rename_table_in_select(select, old, new),
Expr::InSelect { expr, select, .. } => {
rename_table_in_expr(expr, old, new);
rename_table_in_select(select, old, new);
}
}
}
/// Whether `sel` binds the name `alias` as one of its own `FROM` sources or
/// CTEs — in which case a `DELETE`/`UPDATE` target alias of the same name is
/// *shadowed* inside it (the reference is the inner source, not the outer
/// target), so the alias rewrite must not descend into it.
fn select_binds_name(sel: &Select, alias: &str) -> bool {
if sel.ctes.iter().any(|c| c.name.eq_ignore_ascii_case(alias)) {
return true;
}
let binds = |tr: &TableRef| match &tr.alias {
Some(a) => a.eq_ignore_ascii_case(alias),
None => tr.subquery.is_none() && tr.name.eq_ignore_ascii_case(alias),
};
match &sel.from {
Some(from) => binds(&from.first) || from.joins.iter().any(|j| binds(&j.table)),
None => false,
}
}
/// Whether an `alias.column` reference resolves against the target's columns.
/// `cols` is `None` for a view/vtab target (whose column set is not fetched
/// here) — treat any name as resolvable then (best-effort: the alias is rewritten
/// and resolution is left to the downstream path). The rowid pseudo-columns are
/// always resolvable on a rowid table.
fn alias_col_resolvable(cols: Option<&[ColumnInfo]>, column: &str) -> bool {
match cols {
None => true,
Some(cs) => {
matches!(
column.to_ascii_lowercase().as_str(),
"rowid" | "oid" | "_rowid_"
) || cs.iter().any(|c| c.name.eq_ignore_ascii_case(column))
}
}
}
/// Apply a DML target-table `AS alias` to an expression in a `SET`/`WHERE`/
/// `ORDER BY` clause: rewrite each `alias.col` qualifier to the real `table`
/// name (the executor labels the target's columns with their real table name,
/// so the rewritten reference resolves). Two references are rejected as
/// `no such column` at this prepare-time step instead, regardless of the table's
/// row count: a reference through the now-hidden real name (`table.col`), and an
/// `alias.col` naming a column the target does not have (kept alias-qualified in
/// the message, exactly as SQLite reports it). Descends into every
/// sub-expression and nested subquery — a correlated `SET`/`WHERE` subquery may
/// use the alias — except one that re-binds the alias as its own `FROM`
/// source/CTE (handled in [`rewrite_target_alias_select`]). Other qualifiers
/// (`UPDATE … FROM` sources, `OLD`/`NEW`) are never touched.
fn rewrite_target_alias_expr(
e: &mut Expr,
alias: &str,
table: &str,
cols: Option<&[ColumnInfo]>,
err: &mut Option<Error>,
) {
match e {
Expr::Column {
schema,
table: Some(t),
column,
quoted,
..
} => {
if t.eq_ignore_ascii_case(alias) {
if alias_col_resolvable(cols, column) {
*t = String::from(table);
} else if err.is_none() {
// A missing column keeps the alias qualifier in the message.
*err = Some(eval::no_such_column(
None,
Some(t.as_str()),
column,
*quoted,
));
}
} else if t.eq_ignore_ascii_case(table) && err.is_none() {
*err = Some(eval::no_such_column(
schema.as_deref(),
Some(t.as_str()),
column,
*quoted,
));
}
}
Expr::Column { .. } | Expr::Literal(_) | Expr::Parameter(_) => {}
Expr::Unary { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Paren(expr)
| Expr::Collate { expr, .. } => rewrite_target_alias_expr(expr, alias, table, cols, err),
Expr::Binary { left, right, .. } => {
rewrite_target_alias_expr(left, alias, table, cols, err);
rewrite_target_alias_expr(right, alias, table, cols, err);
}
Expr::Function {
args,
filter,
order_by,
over,
..
} => {
for a in args {
rewrite_target_alias_expr(a, alias, table, cols, err);
}
if let Some(f) = filter {
rewrite_target_alias_expr(f, alias, table, cols, err);
}
for o in order_by {
rewrite_target_alias_expr(&mut o.expr, alias, table, cols, err);
}
if let Some(w) = over {
rewrite_target_alias_window(w, alias, table, cols, err);
}
}
Expr::InList { expr, list, .. } => {
rewrite_target_alias_expr(expr, alias, table, cols, err);
for a in list {
rewrite_target_alias_expr(a, alias, table, cols, err);
}
}
Expr::Between {
expr, low, high, ..
} => {
rewrite_target_alias_expr(expr, alias, table, cols, err);
rewrite_target_alias_expr(low, alias, table, cols, err);
rewrite_target_alias_expr(high, alias, table, cols, err);
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
rewrite_target_alias_expr(o, alias, table, cols, err);
}
for (w, t) in when_then {
rewrite_target_alias_expr(w, alias, table, cols, err);
rewrite_target_alias_expr(t, alias, table, cols, err);
}
if let Some(el) = else_result {
rewrite_target_alias_expr(el, alias, table, cols, err);
}
}
Expr::RowValue(items) => {
for i in items {
rewrite_target_alias_expr(i, alias, table, cols, err);
}
}
Expr::Subquery(s) => rewrite_target_alias_select(s, alias, table, cols, err),
Expr::Exists { select, .. } => rewrite_target_alias_select(select, alias, table, cols, err),
Expr::InSelect { expr, select, .. } => {
rewrite_target_alias_expr(expr, alias, table, cols, err);
rewrite_target_alias_select(select, alias, table, cols, err);
}
}
}
/// Apply the DML target alias rewrite to a window spec's `PARTITION BY`/
/// `ORDER BY` expressions (a window function here is a misuse rejected later,
/// but the rewrite still descends for completeness).
fn rewrite_target_alias_window(
ws: &mut WindowSpec,
alias: &str,
table: &str,
cols: Option<&[ColumnInfo]>,
err: &mut Option<Error>,
) {
for e in &mut ws.partition_by {
rewrite_target_alias_expr(e, alias, table, cols, err);
}
for t in &mut ws.order_by {
rewrite_target_alias_expr(&mut t.expr, alias, table, cols, err);
}
}
/// Apply the DML target alias rewrite throughout a (correlated) subquery. A
/// subquery that re-binds the alias name as its own `FROM` source/CTE shadows
/// the outer target and is left untouched; otherwise every expression-bearing
/// clause is rewritten, recursing into further nested subqueries.
fn rewrite_target_alias_select(
sel: &mut Select,
alias: &str,
table: &str,
cols: Option<&[ColumnInfo]>,
err: &mut Option<Error>,
) {
if select_binds_name(sel, alias) {
return;
}
for cte in &mut sel.ctes {
rewrite_target_alias_select(&mut cte.select, alias, table, cols, err);
}
if let Some(from) = &mut sel.from {
if let Some(sub) = &mut from.first.subquery {
rewrite_target_alias_select(sub, alias, table, cols, err);
}
for j in &mut from.joins {
if let Some(sub) = &mut j.table.subquery {
rewrite_target_alias_select(sub, alias, table, cols, err);
}
if let Some(on) = &mut j.on {
rewrite_target_alias_expr(on, alias, table, cols, err);
}
}
}
for rc in &mut sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
rewrite_target_alias_expr(expr, alias, table, cols, err);
}
}
if let Some(w) = &mut sel.where_clause {
rewrite_target_alias_expr(w, alias, table, cols, err);
}
for e in &mut sel.group_by {
rewrite_target_alias_expr(e, alias, table, cols, err);
}
if let Some(h) = &mut sel.having {
rewrite_target_alias_expr(h, alias, table, cols, err);
}
for t in &mut sel.order_by {
rewrite_target_alias_expr(&mut t.expr, alias, table, cols, err);
}
for (_, ws) in &mut sel.window_defs {
rewrite_target_alias_window(ws, alias, table, cols, err);
}
if let Some(e) = &mut sel.limit {
rewrite_target_alias_expr(e, alias, table, cols, err);
}
if let Some(e) = &mut sel.offset {
rewrite_target_alias_expr(e, alias, table, cols, err);
}
for (_, comp) in &mut sel.compound {
rewrite_target_alias_select(comp, alias, table, cols, err);
}
}
/// Resolve an `UPDATE … AS alias` target alias in place: rewrite alias-qualified
/// `SET`/`WHERE`/`ORDER BY` (including row-value subquery assignments) references
/// to the real table name and reject a reference through the hidden real name (or
/// a missing aliased column). `cols` is the target's column metadata (`None` for
/// a view/vtab target). `RETURNING` is intentionally left alone — SQLite resolves
/// it against the real table name, not the alias. No-op when no alias was written.
fn resolve_update_alias(u: &mut Update, cols: Option<&[ColumnInfo]>) -> Result<()> {
let Some(alias) = u.alias.clone() else {
return Ok(());
};
let table = u.table.clone();
let mut err = None;
for (_, e) in &mut u.assignments {
rewrite_target_alias_expr(e, &alias, &table, cols, &mut err);
}
for (_, s) in &mut u.row_assignments {
rewrite_target_alias_select(s, &alias, &table, cols, &mut err);
}
if let Some(w) = &mut u.where_clause {
rewrite_target_alias_expr(w, &alias, &table, cols, &mut err);
}
for t in &mut u.order_by {
rewrite_target_alias_expr(&mut t.expr, &alias, &table, cols, &mut err);
}
match err {
Some(e) => Err(e),
None => Ok(()),
}
}
/// Resolve a `DELETE FROM … AS alias` target alias in place (see
/// [`resolve_update_alias`]); `WHERE`/`ORDER BY` only.
fn resolve_delete_alias(d: &mut Delete, cols: Option<&[ColumnInfo]>) -> Result<()> {
let Some(alias) = d.alias.clone() else {
return Ok(());
};
let table = d.table.clone();
let mut err = None;
if let Some(w) = &mut d.where_clause {
rewrite_target_alias_expr(w, &alias, &table, cols, &mut err);
}
for t in &mut d.order_by {
rewrite_target_alias_expr(&mut t.expr, &alias, &table, cols, &mut err);
}
match err {
Some(e) => Err(e),
None => Ok(()),
}
}
/// Split a `;`-separated SQL script into trimmed statement slices for
/// [`Connection::execute_batch`]. Reuses the tokenizer, so string literals and
/// `--`/`/* */` comments never split a statement, and tracks `BEGIN…END` /
/// `CASE…END` nesting so a `;` inside a trigger body or `CASE` expression is not
/// a boundary. A leading `BEGIN` (transaction control) does not open a block —
/// only a mid-statement one (e.g. `CREATE TRIGGER … BEGIN`) does. Comment-only
/// and empty segments are dropped.
fn split_sql_script(sql: &str) -> Vec<&str> {
let toks = match sql::token::tokenize(sql) {
Ok(t) => t,
// Let the caller surface the real parse error on the whole input.
Err(_) => return alloc::vec![sql.trim()],
};
let mut out = Vec::new();
let mut depth: u32 = 0;
let mut seg_start = 0usize;
let mut seen = false;
for sp in &toks {
match &sp.token {
sql::token::Token::Semicolon if depth == 0 => {
if seen {
out.push(sql[seg_start..sp.start].trim());
}
seg_start = sp.end;
seen = false;
}
sql::token::Token::Word(w) => {
match w.to_ascii_uppercase().as_str() {
"BEGIN" if seen => depth += 1,
"CASE" => depth += 1,
"END" => depth = depth.saturating_sub(1),
_ => {}
}
seen = true;
}
_ => seen = true,
}
}
if seen {
out.push(sql[seg_start..].trim());
}
out
}
/// The text stored in `sqlite_master.sql` for a DDL statement: the source from
/// its first real token (skipping leading comments and whitespace) to the trimmed
/// end. SQLite records the schema text from the `CREATE` keyword onward, so an
/// inter-statement `-- comment` preceding the statement is not captured.
fn ddl_text(sql: &str) -> &str {
match sql::token::tokenize(sql) {
Ok(toks) if !toks.is_empty() => sql[toks[0].start..].trim_end(),
_ => sql.trim(),
}
}
/// Whether a statement opens a write transaction against a database, and so is
/// refused under `PRAGMA query_only = ON`. This mirrors SQLite, which blocks
/// every DML and schema change plus `VACUUM` and `ANALYZE` (the latter writes
/// `sqlite_stat1`), while letting `SELECT`, `PRAGMA`, `ATTACH`/`DETACH`, and
/// transaction/savepoint control through. `REINDEX` is intentionally excluded:
/// graphite models it as a no-op (indexes are kept current on every write), so
/// it never opens a write transaction here — the lone residual versus SQLite,
/// which blocks a `REINDEX` that would actually rebuild an existing index.
fn statement_writes_db(stmt: &Statement) -> bool {
matches!(
stmt,
Statement::Insert(_)
| Statement::Update(_)
| Statement::Delete(_)
| Statement::CreateTable(_)
| Statement::CreateIndex(_)
| Statement::CreateView(_)
| Statement::CreateVirtualTable(_)
| Statement::CreateTrigger(_)
| Statement::Drop(_)
| Statement::Alter(_)
| Statement::Vacuum { .. }
| Statement::Analyze(_)
)
}
/// The `sqlite_` name prefix is reserved for SQLite's own catalog objects
/// (`sqlite_sequence`, `sqlite_stat1`, the implicit `sqlite_autoindex_*`, …).
/// A user `CREATE TABLE/INDEX/VIEW/TRIGGER/VIRTUAL TABLE` — or an
/// `ALTER … RENAME TO` — that names a new object with this prefix is rejected
/// (case-insensitively), preserving the name exactly as the user wrote it.
/// Internal catalog creations call the `exec_create_*` helpers directly and so
/// bypass this check, which only guards the user statement-dispatch path.
fn reject_reserved_name(name: &str) -> Result<()> {
if name.len() >= 7 && name[..7].eq_ignore_ascii_case("sqlite_") {
return Err(Error::Error(format!(
"object name reserved for internal use: {name}"
)));
}
Ok(())
}
/// Does this stored `CREATE VIEW` body reference table `name`? Used to decide
/// whether an `ALTER TABLE name RENAME TO` must rewrite the view to stay valid —
/// we parse and run the table-rename walker against a sentinel and see if it
/// touched anything, so unrelated views are left byte-for-byte untouched.
/// Whether a `SELECT` references base table `name` anywhere (FROM/joins/
/// subqueries/CTEs/compound) — detected by probe-renaming it to a sentinel and
/// checking the AST changed (reuses `rename_table_in_select`'s full walk).
fn select_reads_table(sel: &Select, name: &str) -> bool {
let mut probe = sel.clone();
rename_table_in_select(
&mut probe,
name,
"\u{1}\u{1}graphite_rename_probe\u{1}\u{1}",
);
probe != *sel
}
/// Whether expression `e` references table `name` — only reachable through a
/// nested subquery's `FROM` (a bare expression has no table source). Used for a
/// trigger's `WHEN` clause, which can carry a correlated/uncorrelated subquery
/// over the renamed table. Mirrors [`select_reads_table`]'s probe-rename trick.
fn expr_reads_table(e: &Expr, name: &str) -> bool {
let mut probe = e.clone();
rename_table_in_expr(
&mut probe,
name,
"\u{1}\u{1}graphite_rename_probe\u{1}\u{1}",
);
probe != *e
}
/// Whether a `FROM` clause references table `name` — as a named source or join,
/// a derived-subquery / TVF-argument source, or inside a join's `ON` predicate.
/// The rename rewrite is a whole-text token pass, so this only needs to detect
/// *any* reference, not locate it. (Used for an `UPDATE … FROM` trigger body,
/// whose `FROM` can reach the renamed table through a subquery the way a plain
/// `SELECT`'s `FROM` can.)
fn from_refs_table(f: &FromClause, name: &str) -> bool {
let tref = |tr: &crate::sql::ast::TableRef| -> bool {
tr.name.eq_ignore_ascii_case(name)
|| tr
.subquery
.as_ref()
.is_some_and(|s| select_reads_table(s, name))
|| tr
.tvf_args
.as_ref()
.is_some_and(|args| args.iter().any(|e| expr_reads_table(e, name)))
};
tref(&f.first)
|| f.joins
.iter()
.any(|j| tref(&j.table) || j.on.as_ref().is_some_and(|e| expr_reads_table(e, name)))
}
/// Qualify a trigger body's `no such table: X` error with the trigger's schema.
///
/// SQLite compiles a trigger program in its own schema, so an unqualified table
/// reference that resolves to nothing is reported schema-qualified — a `main`
/// trigger says `no such table: main.nope`. A *temp* trigger's names resolve
/// across all schemas, so its error stays bare; any already-qualified name (one
/// containing a `.`) is likewise left untouched.
/// Should a FROM-less trigger-body `SELECT` projection be skipped by the
/// up-front `eval`-based resolution pass? `eval` validates a projection by
/// evaluating it, which is wrong for two node kinds: a `RAISE(…)` (the evaluator
/// has no `RAISE` support — it is handled by [`Connection::eval_raise_expr`]) and
/// an aggregate / window-function call (valid over the zero rows of a FROM-less
/// `SELECT` — `SELECT count(*)` is `0`, not a `misuse of aggregate` error). Skip
/// any expression containing one (not descending into nested `SELECT`s, which
/// resolve themselves).
fn trigger_select_skip_eval(e: &Expr) -> bool {
let mut skip = false;
window::visit(e, &mut |n| {
if let Expr::Function {
name,
args,
star,
over,
..
} = n
&& (name.eq_ignore_ascii_case("raise")
|| over.is_some()
|| is_builtin_window_function(&name.to_ascii_lowercase())
|| func::is_aggregate_call(name, args.len(), *star))
{
skip = true;
}
});
skip
}
fn qualify_trigger_missing_table(e: Error, schema: Option<&str>) -> Error {
let Some(sch) = schema else { return e };
if sch.eq_ignore_ascii_case("temp") {
return e;
}
if let Error::Error(msg) = &e
&& let Some(name) = msg.strip_prefix("no such table: ")
&& !name.contains('.')
{
return Error::Error(format!("no such table: {sch}.{name}"));
}
e
}
/// Whether a `CREATE TRIGGER` references table `name` — either it is attached to
/// it (`ON name`) or a body statement targets/reads it. Used to decide whether a
/// `RENAME TABLE` must rewrite the renamed name inside the trigger's stored text.
fn trigger_uses_table(trigger_sql: &str, name: &str) -> bool {
let Ok(Statement::CreateTrigger(ct)) = sql::parse_one(trigger_sql) else {
return false;
};
if ct.table.eq_ignore_ascii_case(name) {
return true;
}
// The `WHEN` guard can reach the renamed table through a subquery, even when
// no body statement does — SQLite rewrites those references too.
if ct.when.as_ref().is_some_and(|w| expr_reads_table(w, name)) {
return true;
}
ct.body.iter().any(|s| stmt_reads_table(s, name))
}
/// Whether a trigger-body statement references table `name` anywhere — as a
/// target, a `FROM`/`USING` source, or inside any nested expression subquery
/// (a `WHERE`/`SET`/`VALUES`/`RETURNING`/upsert clause). Used by
/// [`trigger_uses_table`] to decide whether a `RENAME TABLE` must rewrite the
/// renamed name in the trigger's stored text; the rewrite itself is a whole-text
/// token pass, so this only needs to detect *any* reference, not locate it.
fn stmt_reads_table(s: &Statement, name: &str) -> bool {
let ex = |e: &Expr| expr_reads_table(e, name);
let exo = |e: &Option<Expr>| e.as_ref().is_some_and(|e| expr_reads_table(e, name));
let rc = |c: &ResultColumn| matches!(c, ResultColumn::Expr { expr, .. } if expr_reads_table(expr, name));
match s {
Statement::Select(sel) => select_reads_table(sel, name),
Statement::Insert(i) => {
i.table.eq_ignore_ascii_case(name)
|| i.ctes.iter().any(|c| select_reads_table(&c.select, name))
|| match &i.source {
InsertSource::Values(rows) => rows.iter().flatten().any(ex),
InsertSource::Select(sel) => select_reads_table(sel, name),
InsertSource::DefaultValues => false,
}
|| i.upsert.iter().any(|u| {
exo(&u.target_where)
|| matches!(&u.action,
UpsertAction::Update { assignments, where_clause }
if assignments.iter().any(|(_, e)| ex(e)) || exo(where_clause))
})
|| i.returning.iter().any(rc)
}
Statement::Update(u) => {
u.table.eq_ignore_ascii_case(name)
|| u.ctes.iter().any(|c| select_reads_table(&c.select, name))
|| u.from.as_ref().is_some_and(|f| from_refs_table(f, name))
|| u.assignments.iter().any(|(_, e)| ex(e))
|| u.row_assignments
.iter()
.any(|(_, s)| select_reads_table(s, name))
|| exo(&u.where_clause)
|| u.order_by.iter().any(|o| ex(&o.expr))
|| exo(&u.limit)
|| exo(&u.offset)
|| u.returning.iter().any(rc)
}
Statement::Delete(d) => {
d.table.eq_ignore_ascii_case(name)
|| d.ctes.iter().any(|c| select_reads_table(&c.select, name))
|| exo(&d.where_clause)
|| d.order_by.iter().any(|o| ex(&o.expr))
|| exo(&d.limit)
|| exo(&d.offset)
|| d.returning.iter().any(rc)
}
_ => false,
}
}
fn view_uses_table(view_sql: &str, name: &str) -> bool {
match sql::parse_one(view_sql) {
Ok(Statement::CreateView(cv)) => {
let mut probe = cv.select.clone();
rename_table_in_select(
&mut probe,
name,
"\u{1}\u{1}graphite_rename_probe\u{1}\u{1}",
);
*probe != *cv.select
}
_ => false,
}
}
/// Rewrite stored DDL text, repointing every bare or double-quoted identifier
/// token equal to `old` (case-insensitively) to the already-rendered `rendered`
/// text while preserving all other source text — whitespace, comments, and
/// string/blob literals (which tokenize as `Str`/`Blob`, never identifiers, so
/// their contents are never touched). This mirrors SQLite's text-preserving
/// rename rather than reprinting from the AST. `rendered` is the replacement as
/// it should appear (a table rename passes the double-quoted name; a column
/// rename passes the new name bare or quoted exactly as the user wrote it).
/// Rewrite a foreign-key parent-column reference after the parent's column is
/// renamed: in `sql` (another table's `CREATE`), rename `old` → `rendered` but
/// only inside a `REFERENCES <parent>(…)` column list — so a child column that
/// happens to share the old name is left untouched. Used for cross-object
/// `ALTER TABLE … RENAME COLUMN` propagation into foreign keys.
fn rewrite_fk_parent_column(sql: &str, parent: &str, old: &str, rendered: &str) -> String {
use sql::token::Token;
let toks = match sql::token::tokenize(sql) {
Ok(t) => t,
Err(_) => return String::from(sql),
};
let is_word = |t: &Token, w: &str| matches!(t, Token::Word(x) | Token::Ident(x) if x.eq_ignore_ascii_case(w));
let mut spans: Vec<(usize, usize)> = Vec::new();
let mut i = 0;
while i < toks.len() {
// `REFERENCES <parent> ( … )` — rename `old` within the column list.
if is_word(&toks[i].token, "references")
&& toks.get(i + 1).is_some_and(|p| is_word(&p.token, parent))
&& toks
.get(i + 2)
.is_some_and(|l| matches!(l.token, Token::LParen))
{
let mut m = i + 3;
while m < toks.len() && !matches!(toks[m].token, Token::RParen) {
if is_word(&toks[m].token, old) {
spans.push((toks[m].start, toks[m].end));
}
m += 1;
}
i = m;
continue;
}
i += 1;
}
if spans.is_empty() {
return String::from(sql);
}
let mut out = String::new();
let mut cursor = 0;
for (s, e) in spans {
out.push_str(&sql[cursor..s]);
out.push_str(rendered);
cursor = e;
}
out.push_str(&sql[cursor..]);
out
}
/// For a `CREATE VIEW` whose `SELECT` draws from exactly one source — the
/// renamed `table`, with no joins, subqueries, CTEs, or compound parts — return
/// the qualifiers under which that table's columns can appear (its name plus any
/// alias) so a column rename can be applied by a token rewrite. Returns `None`
/// when a rewrite could be unsafe (multi-source, a subquery that could reach
/// another table, the renamed column's name collides with the table or an alias)
/// — those views are left unchanged, the remaining scope-aware A-rn3 work.
fn view_single_source_column_quals(view_sql: &str, table: &str, old: &str) -> Option<Vec<String>> {
let Ok(Statement::CreateView(cv)) = sql::parse_one(view_sql) else {
return None;
};
let sel = &cv.select;
if !sel.ctes.is_empty() || !sel.compound.is_empty() {
return None;
}
// A column named the same as its table would make the table-name token in
// `FROM <table>` indistinguishable from a column reference — bail.
if old.eq_ignore_ascii_case(table) {
return None;
}
let from = sel.from.as_ref()?;
if !from.joins.is_empty() || from.first.subquery.is_some() || from.first.tvf_args.is_some() {
return None;
}
if !from.first.name.eq_ignore_ascii_case(table) {
return None;
}
let mut quals = alloc::vec![table.to_string()];
if let Some(a) = &from.first.alias {
if a.eq_ignore_ascii_case(old) {
return None; // alias collides with the renamed column name
}
quals.push(a.clone());
}
// Any subquery could reference another table (breaking the single-source
// guarantee); a result-column alias equal to `old` would be wrongly renamed.
for rc in &sel.columns {
if let ResultColumn::Expr { expr, alias, .. } = rc {
if expr_has_subquery(expr) {
return None;
}
if alias
.as_deref()
.is_some_and(|a| a.eq_ignore_ascii_case(old))
{
return None;
}
}
}
let mut clean = true;
for e in sel
.where_clause
.iter()
.chain(sel.group_by.iter())
.chain(sel.having.iter())
{
clean &= !expr_has_subquery(e);
}
for t in &sel.order_by {
clean &= !expr_has_subquery(&t.expr);
}
if !clean {
return None;
}
Some(quals)
}
/// A-rn (subquery extension): column-rename rewrite for a SINGLE-source view
/// whose body may contain *expression* subqueries (a scalar `(SELECT …)`,
/// `EXISTS (SELECT …)`, or `x IN (SELECT …)`) — but where the renamed `table` is
/// the *only* table referenced anywhere, at every nesting level. SQLite rewrites
/// every reference to the renamed column (bare and qualified, inside the
/// subqueries too), so this returns the qualifiers (`table` plus every alias
/// bound to it across all levels) for a full `rewrite_bare = true` token
/// rewrite. Bails (→ `None`, leaving the view untouched) on anything that breaks
/// the single-source guarantee or that a token rewrite can't safely handle: a
/// CTE/compound anywhere, any join, any derived subquery/TVF in a `FROM`, any
/// `FROM` naming another table, a table alias equal to `old`, a result-column
/// alias equal to `old` (at any level), or `old == table`.
fn view_only_table_quals(view_sql: &str, table: &str, old: &str) -> Option<Vec<String>> {
let Ok(Statement::CreateView(cv)) = sql::parse_one(view_sql) else {
return None;
};
if old.eq_ignore_ascii_case(table) {
return None;
}
let mut quals = alloc::vec![table.to_string()];
if validate_view_select_only_table(&cv.select, table, old, &mut quals) {
Some(quals)
} else {
None
}
}
/// Recursive worker for [`view_only_table_quals`]: checks that `sel` and every
/// nested expression subquery reference only `table`, accumulating the renamed
/// table's qualifiers (its name plus every alias bound to it). Returns `false`
/// to bail.
fn validate_view_select_only_table(
sel: &Select,
table: &str,
old: &str,
quals: &mut Vec<String>,
) -> bool {
if !sel.ctes.is_empty() || !sel.compound.is_empty() {
return false;
}
// A `FROM`, when present, must be exactly the renamed base table — no joins,
// no derived subquery/TVF, no other table. (A `FROM`-less subquery is fine.)
if let Some(from) = &sel.from {
if !from.joins.is_empty() || from.first.subquery.is_some() || from.first.tvf_args.is_some()
{
return false;
}
if !from.first.name.eq_ignore_ascii_case(table) {
return false;
}
if let Some(a) = &from.first.alias {
if a.eq_ignore_ascii_case(old) {
return false; // alias collides with the renamed column name
}
if !quals.iter().any(|q| q.eq_ignore_ascii_case(a)) {
quals.push(a.clone());
}
}
}
// A result-column alias equal to `old` can't be told apart from a real
// column reference by a token rewrite — bail (at every nesting level).
for rc in &sel.columns {
if let ResultColumn::Expr { alias: Some(a), .. } = rc
&& a.eq_ignore_ascii_case(old)
{
return false;
}
}
// Recurse into every nested expression subquery; each must, in turn,
// reference only the renamed table.
let mut subs: Vec<&Select> = Vec::new();
for e in view_select_exprs(sel) {
collect_immediate_subselects(e, &mut subs);
}
subs.into_iter()
.all(|s| validate_view_select_only_table(s, table, old, quals))
}
/// Every top-level expression of `sel` (result columns, `WHERE`/`GROUP`/
/// `HAVING`/`ORDER`/`LIMIT`/`OFFSET`, and named-window specs) — used to find the
/// expression subqueries nested directly within `sel`.
fn view_select_exprs(sel: &Select) -> Vec<&Expr> {
let mut v: Vec<&Expr> = Vec::new();
for rc in &sel.columns {
if let ResultColumn::Expr { expr, .. } = rc {
v.push(expr);
}
}
if let Some(e) = &sel.where_clause {
v.push(e);
}
for e in &sel.group_by {
v.push(e);
}
if let Some(e) = &sel.having {
v.push(e);
}
for t in &sel.order_by {
v.push(&t.expr);
}
if let Some(e) = &sel.limit {
v.push(e);
}
if let Some(e) = &sel.offset {
v.push(e);
}
for (_, spec) in &sel.window_defs {
windowspec_parts(spec, &mut v);
}
v
}
/// Push the `Select` of every expression subquery found *directly* within `e`
/// (a scalar `(SELECT …)`, `EXISTS`, or `IN (SELECT …)`) into `out`, descending
/// through all sub-expressions — including a function's `FILTER`/`ORDER BY`/
/// `OVER` parts — but *not* into the collected subqueries themselves (the caller
/// recurses into those).
fn collect_immediate_subselects<'a>(e: &'a Expr, out: &mut Vec<&'a Select>) {
match e {
Expr::Subquery(s) => out.push(s),
Expr::Exists { select, .. } => out.push(select),
Expr::InSelect { expr, select, .. } => {
collect_immediate_subselects(expr, out);
out.push(select);
}
Expr::Unary { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Paren(expr)
| Expr::Collate { expr, .. } => collect_immediate_subselects(expr, out),
Expr::Binary { left, right, .. } => {
collect_immediate_subselects(left, out);
collect_immediate_subselects(right, out);
}
Expr::Function {
args,
filter,
order_by,
over,
..
} => {
for a in args {
collect_immediate_subselects(a, out);
}
if let Some(f) = filter {
collect_immediate_subselects(f, out);
}
for t in order_by {
collect_immediate_subselects(&t.expr, out);
}
if let Some(spec) = over {
let mut parts: Vec<&Expr> = Vec::new();
windowspec_parts(spec, &mut parts);
for p in parts {
collect_immediate_subselects(p, out);
}
}
}
Expr::InList { expr, list, .. } => {
collect_immediate_subselects(expr, out);
for a in list {
collect_immediate_subselects(a, out);
}
}
Expr::Between {
expr, low, high, ..
} => {
collect_immediate_subselects(expr, out);
collect_immediate_subselects(low, out);
collect_immediate_subselects(high, out);
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
collect_immediate_subselects(o, out);
}
for (w, t) in when_then {
collect_immediate_subselects(w, out);
collect_immediate_subselects(t, out);
}
if let Some(el) = else_result {
collect_immediate_subselects(el, out);
}
}
Expr::RowValue(items) => {
for it in items {
collect_immediate_subselects(it, out);
}
}
Expr::Literal(_) | Expr::Parameter(_) | Expr::Column { .. } => {}
}
}
/// A-rn3: column-rename rewrite plan for a MULTI-source view (a join of plain
/// base tables). Returns `(quals, rewrite_bare)`: SQLite always renames a
/// `<renamed-table>.old` reference (so `quals` is the renamed table's name +
/// alias), and renames a *bare* `old` only when that column name is unique across
/// all the join's sources (else a bare `old` would be ambiguous — an invalid view
/// anyway). Bails (→ None, leaving the view untouched) on any subquery/CTE/
/// compound, a NATURAL/USING join, a non-base-table source, the renamed table
/// appearing other than exactly once, or a result alias colliding with `old`.
/// `table_cols` maps each base table's name to its column names.
fn view_multi_source_quals(
view_sql: &str,
table: &str,
old: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
) -> Option<(Vec<String>, bool)> {
let Ok(Statement::CreateView(cv)) = sql::parse_one(view_sql) else {
return None;
};
let sel = &cv.select;
if !sel.ctes.is_empty() || !sel.compound.is_empty() || old.eq_ignore_ascii_case(table) {
return None;
}
let from = sel.from.as_ref()?;
if from.joins.is_empty() {
return None; // single-source is handled separately
}
// Collect every source; each must be a plain base table (no subquery/tvf/
// schema-qualifier), and a NATURAL/USING join's column coalescing is bailed.
let mut srcs: Vec<(String, Option<String>)> = Vec::new();
let mut push = |tr: &crate::sql::ast::TableRef| -> bool {
if tr.subquery.is_some() || tr.tvf_args.is_some() || tr.schema.is_some() {
return false;
}
// A source table named or aliased `old` would have its FROM token wrongly
// renamed by this prover's whole-text `All` rewrite; bail to the span-precise
// scope-aware path (`view_global_unique_quals`).
if tr.name.eq_ignore_ascii_case(old)
|| tr
.alias
.as_deref()
.is_some_and(|a| a.eq_ignore_ascii_case(old))
{
return false;
}
srcs.push((tr.name.clone(), tr.alias.clone()));
true
};
if !push(&from.first) {
return None;
}
for j in &from.joins {
if j.natural || !j.using.is_empty() || !push(&j.table) {
return None;
}
}
// The renamed table must be a source exactly once; its name+alias qualify it.
let renamed: Vec<&(String, Option<String>)> = srcs
.iter()
.filter(|(n, _)| n.eq_ignore_ascii_case(table))
.collect();
if renamed.len() != 1 {
return None;
}
let mut quals = alloc::vec![renamed[0].0.clone()];
if let Some(a) = &renamed[0].1 {
if a.eq_ignore_ascii_case(old) {
return None;
}
quals.push(a.clone());
}
// `old` is safe to rename as a bare reference only if exactly one source has a
// column of that name. Every source must be a known base table.
let has_old = |name: &str| -> Option<bool> {
let cols = table_cols
.iter()
.find(|(t, _)| t.eq_ignore_ascii_case(name))
.map(|(_, c)| c)?;
Some(cols.iter().any(|c| c.eq_ignore_ascii_case(old)))
};
let mut count = 0usize;
for (n, _) in &srcs {
if has_old(n)? {
count += 1;
}
}
let rewrite_bare = count == 1;
// A subquery anywhere could reach another table (breaking the analysis); a
// result alias equal to `old` would be wrongly renamed.
for rc in &sel.columns {
if let ResultColumn::Expr { expr, alias, .. } = rc
&& (expr_has_subquery(expr)
|| alias
.as_deref()
.is_some_and(|a| a.eq_ignore_ascii_case(old)))
{
return None;
}
}
for e in sel
.where_clause
.iter()
.chain(sel.group_by.iter())
.chain(sel.having.iter())
{
if expr_has_subquery(e) {
return None;
}
}
for t in &sel.order_by {
if expr_has_subquery(&t.expr) {
return None;
}
}
Some((quals, rewrite_bare))
}
/// A-rn3 (global-uniqueness extension): a column-rename rewrite plan for a view
/// whose body reaches the renamed `table` only through a *nested* expression
/// subquery — the top-level `FROM` may be an unrelated base table, so neither the
/// single-source nor the join (`view_multi_source_quals`) prover applies. Walks
/// every base-table source at every nesting level; if the renamed column name is
/// unique across all of them, a bare `old` (in any scope) can resolve only to the
/// renamed table, so SQLite renames it and so can we. Returns `(quals,
/// rewrite_bare)` like [`view_multi_source_quals`], or `None` (leave the view
/// untouched) on any shape a token rewrite can't prove safe: a CTE/compound
/// anywhere, a derived/TVF source, a NATURAL/USING join, a source named or
/// aliased `old`, a result alias `old`, `old == table`, an unknown source table,
/// or the renamed table never appearing.
fn view_global_unique_quals(
view_sql: &str,
table: &str,
old: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
) -> Option<(Vec<String>, BareRewrite)> {
let Ok(Statement::CreateView(cv)) = sql::parse_one(view_sql) else {
return None;
};
if old.eq_ignore_ascii_case(table) {
return None;
}
let mut srcs: Vec<(String, Option<String>)> = Vec::new();
if !collect_select_base_sources(&cv.select, old, &mut srcs) {
return None;
}
// Fast path: when the rename is globally unambiguous (`rewrite_bare`), a bare
// `old` anywhere can resolve only to the renamed table, so the whole-text
// rewrite is complete and correct.
//
// When `old` is owned by more than one source table the flat collection can't
// tell whether a bare ref belongs to the renamed table or another scope's
// table. Rather than bail outright we run a *scope-aware* pass
// ([`scope_bare_old_decision`]): if every bare `old` in the body binds
// (innermost-scope-first) to the renamed table we can still `rewrite_bare`
// safely; if none does, only the qualified `renamed.old` refs rewrite; only a
// genuinely *mixed* body (some bare `old` binding to the renamed table and
// some to another) still needs per-ref spans, so that one bails. (A-rn3-edge.)
// With a CTE present, force the scope-aware decision even when the flat check
// says "globally unique": the fast path would rewrite an outer reference to a
// CTE's renamed output column, but SQLite rejects that (the CTE's exposed name
// changed), so it must stay unresolved and bail rather than be blindly renamed.
let cte = select_needs_scope_aware(&cv.select, old);
match global_unique_plan(&srcs, table, old, table_cols) {
Some((quals, true)) if !cte => Some((quals, BareRewrite::All)),
Some((quals, _)) => {
scope_bare_old_decision(&cv.select, table, old, table_cols).map(|rb| (quals, rb))
}
None => None,
}
}
/// Recursively gather every base-table source `(name, alias)` reachable from
/// `sel` and its nested *expression* subqueries (a scalar `(SELECT …)`, `EXISTS`,
/// or `IN (SELECT …)`), for [`view_global_unique_quals`]. Returns `false` to bail
/// on any shape a token rewrite can't safely reason about: a CTE/compound, a
/// derived subquery / TVF / schema-qualified source in a `FROM`, a NATURAL/USING
/// join (column coalescing), a source named or aliased exactly `old` (its token
/// would be wrongly rewritten), or a result-column alias equal to `old`.
/// Whether `sel` uses a `WITH` CTE **or** a derived-table (`FROM (SELECT …)`)
/// source anywhere the RENAME COLUMN rewrite would traverse (this select, a
/// compound arm, a `FROM` subquery, an expression subquery, or a CTE body). When
/// true, the whole-text `BareRewrite::All` fast path is unsafe — an outer
/// reference to a CTE's or derived table's renamed output column must be resolved
/// per-scope (bailed, or left, per its provenance) rather than blindly rewritten —
/// so the caller forces the scope-aware decision instead.
fn select_needs_scope_aware(sel: &Select, old: &str) -> bool {
if !sel.ctes.is_empty() {
return true;
}
// A result-column alias equal to `old` (or a source table named/aliased `old`)
// means a token that spells `old` is NOT a bound column reference — the whole-
// text `All` fast path would wrongly rewrite it, so force the span-precise
// scope-aware decision.
if sel.columns.iter().any(
|rc| matches!(rc, ResultColumn::Expr { alias: Some(a), .. } if a.eq_ignore_ascii_case(old)),
) {
return true;
}
if let Some(from) = &sel.from {
let src_named_old = |tr: &crate::sql::ast::TableRef| {
tr.name.eq_ignore_ascii_case(old)
|| tr
.alias
.as_deref()
.is_some_and(|a| a.eq_ignore_ascii_case(old))
};
if src_named_old(&from.first) || from.joins.iter().any(|j| src_named_old(&j.table)) {
return true;
}
// A derived-table source (subquery in FROM) needs scope-aware resolution:
// an outer reference to its output column must be classified by provenance.
if from.first.subquery.is_some() || from.joins.iter().any(|j| j.table.subquery.is_some()) {
return true;
}
}
if sel
.compound
.iter()
.any(|(_, arm)| select_needs_scope_aware(arm, old))
{
return true;
}
let mut subs: Vec<&Select> = Vec::new();
for e in view_select_exprs(sel) {
collect_immediate_subselects(e, &mut subs);
}
subs.iter().any(|s| select_needs_scope_aware(s, old))
}
fn collect_select_base_sources(
sel: &Select,
old: &str,
srcs: &mut Vec<(String, Option<String>)>,
) -> bool {
collect_select_base_sources_ctx(sel, old, srcs, &[])
}
/// Inner form of [`collect_select_base_sources`] that also carries the CTE names
/// visible from enclosing selects (`outer_ctes`), so a reference to an outer CTE
/// in a nested compound arm or subquery `FROM` is recognised as a CTE (skipped)
/// rather than mistaken for an unknown base table.
fn collect_select_base_sources_ctx(
sel: &Select,
old: &str,
srcs: &mut Vec<(String, Option<String>)>,
outer_ctes: &[String],
) -> bool {
// CTEs: each `WITH` body is an independent source scope (recurse it); a
// reference to a visible CTE *name* in a `FROM` is not a base table, so it is
// skipped below. A CTE named exactly `old` would confuse the token rewrite, so
// bail. (A later fast-path gate forces scope-aware resolution whenever a CTE is
// present, so an outer reference to a CTE's renamed output column stays
// unresolvable and the whole object bails — matching SQLite's reject-and-leave-
// unchanged rather than a stored-SQL divergence.)
let mut cte_names: Vec<String> = outer_ctes.to_vec();
cte_names.extend(sel.ctes.iter().map(|c| c.name.clone()));
for cte in &sel.ctes {
if cte.name.eq_ignore_ascii_case(old) {
return false;
}
if !collect_select_base_sources_ctx(&cte.select, old, srcs, &cte_names) {
return false;
}
}
// Compound (`UNION`/`INTERSECT`/`EXCEPT`): each arm is an independent scope, so
// recurse them (the scope-aware pass then resolves each arm's bare `old` to its
// own table). A compound-level `ORDER BY` binds to the FIRST arm's OUTPUT
// column; since the first arm IS this (main) select and the `ORDER BY` is
// resolved in its `FROM` scope, a bare `old` ordering key that is a projected
// column of the renamed table rewrites correctly alongside the arm's own ref
// (an alias or another table's column is left, and a term matching no output
// column can't be a valid stored compound). Only a desugared multi-row `VALUES`
// clause (no real base sources) bails here.
if !sel.compound.is_empty() && sel.values_rows != 0 {
return false;
}
for (_, arm) in &sel.compound {
if !collect_select_base_sources_ctx(arm, old, srcs, &cte_names) {
return false;
}
}
if let Some(from) = &sel.from {
let mut sources: Vec<(&crate::sql::ast::TableRef, bool, bool)> =
alloc::vec![(&from.first, false, false)];
for j in &from.joins {
sources.push((&j.table, j.natural, !j.using.is_empty()));
}
for (tr, natural, using) in sources {
// NATURAL / USING joins coalesce columns — a token rewrite can't reason
// about them.
if natural || using || tr.tvf_args.is_some() || tr.schema.is_some() {
return false;
}
if let Some(subq) = &tr.subquery {
// A derived table (`FROM (SELECT …) alias`): recurse its body for
// base sources; the alias is an *output* name, not a base source
// (like a CTE). A derived source aliased exactly `old` would confuse
// the token rewrite, so bail.
if tr
.alias
.as_deref()
.is_some_and(|a| a.eq_ignore_ascii_case(old))
{
return false;
}
if !collect_select_base_sources_ctx(subq, old, srcs, &cte_names) {
return false;
}
continue;
}
// A reference to one of this select's CTE names is not a base source.
if cte_names.iter().any(|c| c.eq_ignore_ascii_case(&tr.name)) {
continue;
}
// A source table named or aliased `old` is still a real base source. The
// scope-aware path (forced by `select_needs_scope_aware` when a source is
// named `old`) is span-precise: it rewrites only bound column-ref
// occurrences, never this `FROM` token, so it no longer needs to bail.
srcs.push((tr.name.clone(), tr.alias.clone()));
}
}
// A result-column alias equal to `old` (`SELECT b AS a, …`) used to bail here
// because the whole-text `All` rewrite would wrongly rename the alias token. The
// scope-aware decision is now span-precise (rewrites only bound column-ref
// occurrences, never the alias), and `select_needs_scope_aware` forces that path
// when an alias equals `old`, so this no longer needs to bail.
let mut subs: Vec<&Select> = Vec::new();
for e in view_select_exprs(sel) {
collect_immediate_subselects(e, &mut subs);
}
subs.into_iter()
.all(|s| collect_select_base_sources_ctx(s, old, srcs, &cte_names))
}
/// Build the column-rename plan from every base-table source `(name, alias)`
/// collected across all nesting levels of an object: the renamed `table` must
/// appear at least once; `quals` is its name plus every alias bound to it; and a
/// bare `old` is rewritable only when exactly one *distinct* source table owns a
/// column of that name (so a bare reference is globally unambiguous). Every source
/// must be a known base table in `table_cols`; an unknown one bails (`None`).
fn global_unique_plan(
srcs: &[(String, Option<String>)],
table: &str,
old: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
) -> Option<(Vec<String>, bool)> {
if !srcs.iter().any(|(n, _)| n.eq_ignore_ascii_case(table)) {
return None;
}
let mut quals: Vec<String> = alloc::vec![table.to_string()];
for (n, a) in srcs {
if n.eq_ignore_ascii_case(table)
&& let Some(a) = a
&& !quals.iter().any(|q| q.eq_ignore_ascii_case(a))
{
quals.push(a.clone());
}
}
let has_old = |name: &str| -> Option<bool> {
let cols = table_cols
.iter()
.find(|(t, _)| t.eq_ignore_ascii_case(name))
.map(|(_, c)| c)?;
Some(cols.iter().any(|c| c.eq_ignore_ascii_case(old)))
};
let mut seen: Vec<String> = Vec::new();
let mut count = 0usize;
for (n, _) in srcs {
if seen.iter().any(|s| s.eq_ignore_ascii_case(n)) {
continue;
}
seen.push(n.clone());
if has_old(n)? {
count += 1;
}
}
Some((quals, count == 1))
}
/// Scope-aware decision for a RENAME COLUMN over a view/trigger body in which the
/// renamed column name `old` is owned by more than one base-table source (so the
/// flat [`global_unique_plan`] can't prove a whole-text `rewrite_bare` safe).
///
/// Returns `Some(true)` when *every* bare `old` reference in the body binds — by
/// the usual innermost-scope-first rule — to the renamed `table`, so rewriting
/// every bare `old` token is correct; `Some(false)` when *no* bare `old` binds to
/// the renamed table, so only the qualified `table.old` refs need rewriting (the
/// bare tokens belong to another scope and must be left alone); and `None` when
/// the body is *mixed* (some bare `old` binds to the renamed table and some to
/// another) — that case can only be rewritten per-occurrence with source spans,
/// so the caller bails and leaves the object untouched. `None` is also returned
/// for any reference that can't be resolved unambiguously (e.g. two sources in a
/// single scope own `old`, which SQLite itself rejects as ambiguous).
/// Which *bare* (unqualified) `old` occurrences a token rewrite should rename.
/// Qualified `qual.old` refs are always handled separately via the `quals` list;
/// this only governs the bare tokens.
#[derive(Debug, Clone, PartialEq)]
enum BareRewrite {
/// Rewrite no bare occurrence (they belong to another scope's table).
None,
/// Rewrite every bare occurrence (globally unambiguous, or the whole body's
/// bare `old` binds to the renamed table).
All,
/// Rewrite only the bare occurrences beginning at these source byte offsets —
/// the A-rn3-edge *mixed* case, where some bare `old` bind to the renamed
/// table and some to another, disambiguated per-occurrence by span.
At(Vec<u32>),
}
impl BareRewrite {
/// The whole-body decision the older provers express as a bool: `true` →
/// rewrite every bare `old`, `false` → rewrite none.
fn from_bool(b: bool) -> Self {
if b {
BareRewrite::All
} else {
BareRewrite::None
}
}
}
fn scope_bare_old_decision(
sel: &Select,
table: &str,
old: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
) -> Option<BareRewrite> {
let mut owners: Vec<(String, Span)> = Vec::new();
let mut scopes: Vec<Vec<(String, Option<String>)>> = Vec::new();
if !collect_bare_old_owners(sel, old, table, table_cols, &mut scopes, &mut owners, &[]) {
return None;
}
if !owners.iter().any(|(o, _)| o.eq_ignore_ascii_case(table)) {
return Some(BareRewrite::None); // no bare ref binds to the renamed table
}
// Rewrite exactly the bare occurrences that bind to the renamed table, located
// by their parsed source span — never a whole-text `All`. Span precision means a
// token that merely *spells* `old` but is not a bound column reference — a
// result-column alias (`SELECT b AS a`), or a source table named `old` — is left
// untouched, matching sqlite. Every renamed-binding occurrence must carry a real
// span (view bodies are parsed from stored text, so they do); a synthetic one
// can't be targeted, so bail.
let mut spans: Vec<u32> = Vec::new();
for (owner, span) in &owners {
if owner.eq_ignore_ascii_case(table) {
match span.0 {
Some((start, _)) => spans.push(start),
None => return None,
}
}
}
Some(BareRewrite::At(spans))
}
/// Walk every column reference that belongs to `e`'s *own* scope: descend through
/// all scalar sub-expressions — including the left-hand operand of an
/// `x IN (SELECT …)` and the `PARTITION BY`/`ORDER BY` of an inline `OVER (…)`,
/// which live in the current scope — but stop at a nested `SELECT` (scalar
/// subquery / `EXISTS` / `IN (SELECT …)`), whose columns belong to that
/// subquery's own scope and are resolved separately. The `match` is exhaustive on
/// purpose (no `_` arm) so a newly added [`Expr`] variant that could hide a bare
/// column forces this to be revisited rather than silently under-counted.
fn walk_own_scope_columns(e: &Expr, f: &mut impl FnMut(Option<&str>, &str, Span)) {
match e {
Expr::Column {
table,
column,
span,
..
} => f(table.as_deref(), column, *span),
Expr::Unary { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Paren(expr)
| Expr::Collate { expr, .. }
| Expr::InSelect { expr, .. } => walk_own_scope_columns(expr, f),
Expr::Binary { left, right, .. } => {
walk_own_scope_columns(left, f);
walk_own_scope_columns(right, f);
}
Expr::Function {
args,
filter,
order_by,
over,
..
} => {
for a in args {
walk_own_scope_columns(a, f);
}
if let Some(flt) = filter {
walk_own_scope_columns(flt, f);
}
for t in order_by {
walk_own_scope_columns(&t.expr, f);
}
if let Some(spec) = over {
let mut parts: Vec<&Expr> = Vec::new();
windowspec_parts(spec, &mut parts);
for p in parts {
walk_own_scope_columns(p, f);
}
}
}
Expr::InList { expr, list, .. } => {
walk_own_scope_columns(expr, f);
for a in list {
walk_own_scope_columns(a, f);
}
}
Expr::Between {
expr, low, high, ..
} => {
walk_own_scope_columns(expr, f);
walk_own_scope_columns(low, f);
walk_own_scope_columns(high, f);
}
Expr::Case {
operand,
when_then,
else_result,
} => {
if let Some(o) = operand {
walk_own_scope_columns(o, f);
}
for (w, t) in when_then {
walk_own_scope_columns(w, f);
walk_own_scope_columns(t, f);
}
if let Some(el) = else_result {
walk_own_scope_columns(el, f);
}
}
Expr::RowValue(items) => {
for it in items {
walk_own_scope_columns(it, f);
}
}
Expr::Literal(_) | Expr::Parameter(_) | Expr::Subquery(_) | Expr::Exists { .. } => {}
}
}
/// The single base source of a CTE body that owns a column named `old`, if
/// exactly one does (used to resolve an unaliased `SELECT old FROM …` projection's
/// provenance). Only plain base-table sources are considered.
fn cte_body_single_owner(
body: &Select,
old: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
) -> Option<String> {
let from = body.from.as_ref()?;
let mut owner: Option<String> = None;
for tr in core::iter::once(&from.first).chain(from.joins.iter().map(|j| &j.table)) {
if tr.subquery.is_some() || tr.tvf_args.is_some() {
continue;
}
let owns = table_cols
.iter()
.find(|(t, _)| t.eq_ignore_ascii_case(&tr.name))
.is_some_and(|(_, cols)| cols.iter().any(|c| c.eq_ignore_ascii_case(old)));
if owns {
if owner.is_some() {
return None;
}
owner = Some(tr.name.clone());
}
}
owner
}
/// How a bare column named `old` referencing a CTE `cte` should be treated when
/// `renamed`'s `old` column is renamed:
/// - `None` — the CTE does not expose an output column named `old` (not the owner);
/// - `Some(true)` — it exposes `old` as an *unaliased* projection of the renamed
/// table's `old` column (or a `*`/`tbl.*` that might), so the rename changes the
/// CTE's exposed name and a consumer reference breaks → the object must bail
/// (matching SQLite, which rejects such a rename);
/// - `Some(false)` — it exposes `old` but from a different column/table or via a
/// fixed name (explicit column list, alias), so the reference is unaffected and
/// left as-is.
fn cte_old_owner(
cte: &crate::sql::ast::Cte,
old: &str,
renamed: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
) -> Option<bool> {
if !cte.columns.is_empty() {
// An explicit column list fixes the exposed names, so a base rename never
// changes them.
return cte
.columns
.iter()
.any(|c| c.eq_ignore_ascii_case(old))
.then_some(false);
}
body_exposes_old(&cte.select, old, renamed, table_cols)
}
/// The same output-column-provenance decision as [`cte_old_owner`], but for a
/// query `body` referenced with no explicit column list — used for a *derived
/// table* (`FROM (SELECT …) alias`), which SQLite treats like an anonymous CTE.
/// See [`cte_old_owner`] for the `None`/`Some(true)`/`Some(false)` meanings.
fn body_exposes_old(
body: &Select,
old: &str,
renamed: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
) -> Option<bool> {
for rc in &body.columns {
match rc {
// `*` / `tbl.*` may expose the renamed column under its own name — be
// conservative and bail (safe: the object is left byte-unchanged).
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => return Some(true),
ResultColumn::Expr { expr, alias, .. } => {
let out_name = match alias {
Some(a) => Some(a.as_str()),
None => match expr {
Expr::Column { column, .. } => Some(column.as_str()),
_ => None,
},
};
if out_name.is_some_and(|n| n.eq_ignore_ascii_case(old)) {
if alias.is_none()
&& let Expr::Column {
table: ct, column, ..
} = expr
&& column.eq_ignore_ascii_case(old)
{
let from_renamed = match ct {
Some(t) => t.eq_ignore_ascii_case(renamed),
None => cte_body_single_owner(body, old, table_cols)
.is_some_and(|t| t.eq_ignore_ascii_case(renamed)),
};
return Some(from_renamed);
}
// Exposed under a fixed alias or via an expression → unaffected.
return Some(false);
}
}
}
}
None
}
/// Resolve a bare column named `old` against the scope stack (outermost first,
/// innermost last), returning the base table that owns it. The innermost scope
/// with a source owning a column `old` wins (SQLite's binding rule, including
/// correlation into an outer query). A scope source that is a visible CTE
/// (`exposed_ctes`, name → "bails") is handled specially: if it exposes `old` from
/// the renamed table unaliased the whole resolution bails (`None`); otherwise the
/// reference is to an unaffected CTE column and resolves to a synthetic non-renamed
/// owner (left as-is). Returns `None` if a single scope has two sources owning
/// `old` (ambiguous — SQLite errors) or no scope owns it.
fn resolve_bare_owner(
old: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
scopes: &[Vec<(String, Option<String>)>],
exposed_ctes: &[(String, bool)],
) -> Option<String> {
// A synthetic owner name that can never equal a real (renamed) table — marks a
// reference to an unaffected CTE output column, which must be left as-is.
const CTE_LEAVE: &str = "\u{1}cte-leave";
let owns = |name: &str| -> bool {
table_cols
.iter()
.find(|(t, _)| t.eq_ignore_ascii_case(name))
.is_some_and(|(_, cols)| cols.iter().any(|c| c.eq_ignore_ascii_case(old)))
};
for scope in scopes.iter().rev() {
let mut owner: Option<String> = None;
for (name, _alias) in scope {
// A visible CTE shadows a same-named base table (SQLite's rule). A CTE
// that exposes `old` from the renamed table unaliased bails; otherwise
// the reference is to an unaffected CTE column (left as-is).
let this: Option<String> = if let Some((_, bails)) = exposed_ctes
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
{
if *bails {
return None;
}
Some(String::from(CTE_LEAVE))
} else if owns(name) {
Some(name.clone())
} else {
None
};
if let Some(o) = this {
if owner.is_some() {
return None; // two sources own `old` in one scope → ambiguous
}
owner = Some(o);
}
}
if let Some(o) = owner {
return Some(o);
}
}
None
}
/// Resolve every bare `old` reference in `exprs` against the active scope stack
/// `scopes` (own-scope columns only; nested `SELECT`s in the exprs are recursed
/// into separately with their `FROM` pushed onto `scopes`), pushing each resolved
/// owning table into `owners`. Returns `false` (bail) on any bare `old` that
/// can't be resolved unambiguously. This is the shared core used both for a
/// `SELECT`'s own expressions and for a trigger statement's `SET`/`WHERE`/… lists.
fn resolve_exprs_bare_owners(
exprs: &[&Expr],
old: &str,
renamed: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
scopes: &mut Vec<Vec<(String, Option<String>)>>,
owners: &mut Vec<(String, Span)>,
exposed_ctes: &[(String, bool)],
) -> bool {
let mut ok = true;
for e in exprs {
walk_own_scope_columns(e, &mut |tbl, col, span| {
if ok && tbl.is_none() && col.eq_ignore_ascii_case(old) {
match resolve_bare_owner(old, table_cols, scopes, exposed_ctes) {
Some(owner) => owners.push((owner, span)),
None => ok = false,
}
}
});
}
if !ok {
return false;
}
let mut subs: Vec<&Select> = Vec::new();
for e in exprs {
collect_immediate_subselects(e, &mut subs);
}
for s in subs {
if !collect_bare_old_owners(s, old, renamed, table_cols, scopes, owners, exposed_ctes) {
return false;
}
}
true
}
/// Walk `sel` and every nested expression subquery, pushing the owning table of
/// each bare `old` reference into `owners`. `scopes` is the active scope stack;
/// this select's `FROM` sources are pushed while its own expressions and their
/// nested subqueries are visited, then popped. Returns `false` (bail) on any bare
/// `old` that can't be resolved unambiguously. CTE, compound-arm, and
/// derived-table sources are handled here (via [`cte_old_owner`]/[`body_exposes_old`]
/// provenance); the shape has already been vetted by [`collect_select_base_sources`]
/// (which bails TVF/NATURAL/USING).
fn collect_bare_old_owners(
sel: &Select,
old: &str,
renamed: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
scopes: &mut Vec<Vec<(String, Option<String>)>>,
owners: &mut Vec<(String, Span)>,
exposed_ctes: &[(String, bool)],
) -> bool {
// Make this select's CTEs visible (name → "exposes the renamed column's `old`
// unaliased", which must bail) for resolving its own FROM refs, body, compound
// arms, and nested subqueries — plus any enclosing CTEs already in scope.
let mut visible = exposed_ctes.to_vec();
for cte in &sel.ctes {
if let Some(bails) = cte_old_owner(cte, old, renamed, table_cols) {
visible.push((cte.name.clone(), bails));
}
}
// CTE bodies are independent scopes defined before this select's own `FROM`:
// resolve each against the enclosing `scopes` only.
for cte in &sel.ctes {
if !collect_bare_old_owners(
&cte.select,
old,
renamed,
table_cols,
scopes,
owners,
&visible,
) {
return false;
}
}
// Derived-table sources are visible only within *this* select (unlike CTEs,
// which propagate to nested scopes). Keep their provenance entries in a
// select-local list used only for this select's own reference resolution; the
// propagated `visible` carries CTEs alone, so a sibling compound arm or a
// nested subquery never sees (and never mis-resolves against) this select's
// derived tables.
let mut local = visible.clone();
let mut scope: Vec<(String, Option<String>)> = Vec::new();
if let Some(from) = &sel.from {
let mut refs: Vec<&crate::sql::ast::TableRef> = alloc::vec![&from.first];
refs.extend(from.joins.iter().map(|j| &j.table));
for (i, tr) in refs.iter().enumerate() {
if let Some(subq) = &tr.subquery {
// A derived table (`FROM (SELECT …) alias`): resolve refs *inside*
// its body against the enclosing scopes (it is not correlated to
// this select's own FROM), then record it — keyed by its alias (a
// synthetic key when unaliased) with its output-column provenance —
// so an outer reference to its column is classified like a CTE's
// (bail if it exposes the renamed `old` unaliased, else left as an
// unaffected non-renamed owner).
if !collect_bare_old_owners(
subq, old, renamed, table_cols, scopes, owners, &visible,
) {
return false;
}
let key = tr
.alias
.clone()
.unwrap_or_else(|| alloc::format!("\u{2}d{i}"));
if let Some(bails) = body_exposes_old(subq, old, renamed, table_cols) {
local.push((key.clone(), bails));
}
scope.push((key, tr.alias.clone()));
} else {
scope.push((tr.name.clone(), tr.alias.clone()));
}
}
}
scopes.push(scope);
let ok = resolve_exprs_bare_owners(
&view_select_exprs(sel),
old,
renamed,
table_cols,
scopes,
owners,
&local,
);
scopes.pop();
if !ok {
return false;
}
// Each compound arm is an independent scope: recurse it (its own `FROM` is
// pushed for the duration, and any outer `scopes` stay available for a
// correlated arm). The compound `ORDER BY` is handled in the main select's
// scope above.
for (_, arm) in &sel.compound {
if !collect_bare_old_owners(arm, old, renamed, table_cols, scopes, owners, &visible) {
return false;
}
}
true
}
/// Trigger counterpart of [`scope_bare_old_decision`]: decides, across a whole
/// `CREATE TRIGGER` (its `WHEN` guard and every body statement), which bare `old`
/// references bind to the renamed `table` — `BareRewrite::All` (every bare one),
/// `BareRewrite::None` (only qualified refs), or `BareRewrite::At(offsets)` (the
/// mixed body: exactly the occurrences at those source offsets). Returns `None`
/// only when a reference can't be resolved (leaving the trigger byte-identical).
/// The body's shape has already been vetted by
/// [`collect_trigger_stmt_base_sources`].
fn scope_bare_old_decision_trigger(
ct: &crate::sql::ast::CreateTrigger,
table: &str,
old: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
) -> Option<BareRewrite> {
let mut owners: Vec<(String, Span)> = Vec::new();
// WHEN guard: its subqueries have their own FROM scopes (a bare `old` directly
// in the guard has no table scope and would bail — conservative).
if let Some(w) = &ct.when {
let mut scopes: Vec<Vec<(String, Option<String>)>> = Vec::new();
if !resolve_exprs_bare_owners(&[w], old, table, table_cols, &mut scopes, &mut owners, &[]) {
return None;
}
}
for stmt in &ct.body {
if !collect_trigger_stmt_bare_owners(stmt, old, table, table_cols, &mut owners) {
return None;
}
}
// Same per-occurrence-span resolution as the view path
// ([`scope_bare_old_decision`]): a uniform body rewrites all-or-none; a mixed
// body rewrites exactly the bare occurrences (located by source offset) that
// bind to the renamed table. The offsets index the stored trigger SQL, which
// is what `rewrite_column_tokens` re-tokenizes.
let renamed = owners.iter().any(|(o, _)| o.eq_ignore_ascii_case(table));
let other = owners.iter().any(|(o, _)| !o.eq_ignore_ascii_case(table));
if renamed && other {
let mut spans: Vec<u32> = Vec::new();
for (owner, span) in &owners {
if owner.eq_ignore_ascii_case(table) {
match span.0 {
Some((start, _)) => spans.push(start),
None => return None,
}
}
}
Some(BareRewrite::At(spans))
} else if renamed {
Some(BareRewrite::All)
} else {
Some(BareRewrite::None)
}
}
/// Resolve the bare `old` references of a single trigger body statement, honouring
/// each statement's scope: an `INSERT … SELECT`/`VALUES`-subquery/`SELECT` body has
/// only the (sub)query's own `FROM` in scope, while an `UPDATE`/`DELETE`'s
/// `SET`/`WHERE`/… expressions resolve against the written *target* table (plus any
/// nested-subquery scopes). Mirrors [`collect_trigger_stmt_base_sources`]'s shape
/// handling; the shapes here were already vetted by it.
fn collect_trigger_stmt_bare_owners(
stmt: &Statement,
old: &str,
renamed: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
owners: &mut Vec<(String, Span)>,
) -> bool {
use crate::sql::ast::InsertSource;
match stmt {
Statement::Select(sel) => {
let mut scopes: Vec<Vec<(String, Option<String>)>> = Vec::new();
collect_bare_old_owners(sel, old, renamed, table_cols, &mut scopes, owners, &[])
}
Statement::Insert(i) => match &i.source {
InsertSource::DefaultValues => true,
InsertSource::Values(rows) => {
let exprs: Vec<&Expr> = rows.iter().flatten().collect();
let mut scopes: Vec<Vec<(String, Option<String>)>> = Vec::new();
resolve_exprs_bare_owners(
&exprs,
old,
renamed,
table_cols,
&mut scopes,
owners,
&[],
)
}
InsertSource::Select(sel) => {
let mut scopes: Vec<Vec<(String, Option<String>)>> = Vec::new();
collect_bare_old_owners(sel, old, renamed, table_cols, &mut scopes, owners, &[])
}
},
Statement::Update(u) => {
let mut scopes: Vec<Vec<(String, Option<String>)>> =
alloc::vec![alloc::vec![(u.table.clone(), u.alias.clone())]];
let mut exprs: Vec<&Expr> = Vec::new();
for (_, e) in &u.assignments {
exprs.push(e);
}
exprs.extend(u.where_clause.as_ref());
exprs.extend(u.order_by.iter().map(|t| &t.expr));
exprs.extend(u.limit.as_ref());
exprs.extend(u.offset.as_ref());
resolve_exprs_bare_owners(&exprs, old, renamed, table_cols, &mut scopes, owners, &[])
}
Statement::Delete(d) => {
let mut scopes: Vec<Vec<(String, Option<String>)>> =
alloc::vec![alloc::vec![(d.table.clone(), d.alias.clone())]];
let mut exprs: Vec<&Expr> = Vec::new();
exprs.extend(d.where_clause.as_ref());
exprs.extend(d.order_by.iter().map(|t| &t.expr));
exprs.extend(d.limit.as_ref());
exprs.extend(d.offset.as_ref());
resolve_exprs_bare_owners(&exprs, old, renamed, table_cols, &mut scopes, owners, &[])
}
_ => false,
}
}
/// Recursively gather every base-table source `(name, alias)` reachable from a
/// trigger *body* statement and its nested expression subqueries, for
/// [`trigger_global_unique_quals`]. Each `INSERT`/`UPDATE`/`DELETE` contributes
/// its written target table plus every base source of any `SELECT` it runs (an
/// `INSERT … SELECT`, or a subquery in a `WHERE`/`SET`/`VALUES`). Returns `false`
/// to bail on any shape a token rewrite can't safely reason about: a
/// schema-qualified / RETURNING / upsert / CTE / `UPDATE … FROM` / row-value
/// assignment statement, a target named or aliased exactly `old`, or any
/// unprovable nested `SELECT` (handled by [`collect_select_base_sources`]).
/// Collect the base sources of an `UPDATE … SET … FROM <sources>` clause into
/// `srcs`, the same treatment [`collect_select_base_sources_ctx`] gives a
/// `SELECT`'s `FROM`: each plain table pushes `(name, alias)` and a derived
/// `(SELECT …)` source recurses. A `NATURAL`/`USING` join, table-valued function,
/// schema-qualified source, or any source named/aliased exactly `old` bails
/// (returns `false`) — leave the trigger byte-identical rather than risk a wrong
/// token rewrite, consistent with the target-table `push_target` collision guard.
fn collect_fromclause_base_sources(
from: &crate::sql::ast::FromClause,
old: &str,
srcs: &mut Vec<(String, Option<String>)>,
) -> bool {
let mut sources: Vec<(&crate::sql::ast::TableRef, bool, bool)> =
alloc::vec![(&from.first, false, false)];
for j in &from.joins {
sources.push((&j.table, j.natural, !j.using.is_empty()));
}
for (tr, natural, using) in sources {
if natural || using || tr.tvf_args.is_some() || tr.schema.is_some() {
return false;
}
if let Some(subq) = &tr.subquery {
if tr
.alias
.as_deref()
.is_some_and(|a| a.eq_ignore_ascii_case(old))
{
return false;
}
if !collect_select_base_sources(subq, old, srcs) {
return false;
}
continue;
}
if tr.name.eq_ignore_ascii_case(old)
|| tr
.alias
.as_deref()
.is_some_and(|a| a.eq_ignore_ascii_case(old))
{
return false;
}
srcs.push((tr.name.clone(), tr.alias.clone()));
}
true
}
fn collect_trigger_stmt_base_sources(
stmt: &Statement,
old: &str,
srcs: &mut Vec<(String, Option<String>)>,
) -> bool {
// Push a written target table (with optional alias), bailing if its name or
// alias collides with `old` (its token would be wrongly rewritten).
fn push_target(
name: &str,
alias: Option<&str>,
old: &str,
srcs: &mut Vec<(String, Option<String>)>,
) -> bool {
if name.eq_ignore_ascii_case(old) || alias.is_some_and(|a| a.eq_ignore_ascii_case(old)) {
return false;
}
srcs.push((name.to_string(), alias.map(|a| a.to_string())));
true
}
// Collect base sources of every immediate subquery in `exprs`.
fn collect_expr_subs(
exprs: &[&Expr],
old: &str,
srcs: &mut Vec<(String, Option<String>)>,
) -> bool {
let mut subs: Vec<&Select> = Vec::new();
for e in exprs {
collect_immediate_subselects(e, &mut subs);
}
subs.into_iter()
.all(|s| collect_select_base_sources(s, old, srcs))
}
match stmt {
Statement::Select(sel) => collect_select_base_sources(sel, old, srcs),
Statement::Insert(i) => {
if i.schema.is_some()
|| !i.returning.is_empty()
|| !i.ctes.is_empty()
|| !push_target(&i.table, None, old, srcs)
{
return false;
}
// `ON CONFLICT … DO UPDATE SET … [WHERE …]` (and a partial-index target
// `WHERE`) may nest subqueries reading other tables; collect their base
// sources so a renamed/dropped column reached only through the upsert
// clause is rewritten / reported. The conflict-target and DO-UPDATE `SET`
// targets are write targets (skipped for DROP detection downstream).
for up in &i.upsert {
let mut up_exprs: Vec<&Expr> = Vec::new();
up_exprs.extend(up.target_where.as_ref());
if let crate::sql::ast::UpsertAction::Update {
assignments,
where_clause,
} = &up.action
{
for (_, e) in assignments {
up_exprs.push(e);
}
up_exprs.extend(where_clause.as_ref());
}
if !collect_expr_subs(&up_exprs, old, srcs) {
return false;
}
}
match &i.source {
InsertSource::DefaultValues => true,
InsertSource::Values(rows) => {
let exprs: Vec<&Expr> = rows.iter().flatten().collect();
collect_expr_subs(&exprs, old, srcs)
}
InsertSource::Select(sel) => collect_select_base_sources(sel, old, srcs),
}
}
Statement::Update(u) => {
if u.schema.is_some()
|| !u.returning.is_empty()
|| !u.ctes.is_empty()
|| !push_target(&u.table, u.alias.as_deref(), old, srcs)
{
return false;
}
// Row-assignment subqueries `SET (c1,c2,…) = (SELECT … FROM other)`: the
// subquery is a readable source, so collect its base tables. Bail if a
// target column-list names `old` — its bare token inside the `(…)` group
// is not a skippable `col=` write target, so leaving the trigger
// untouched is safer than misjudging the drop/rename there.
for (targets, sub) in &u.row_assignments {
if targets.iter().any(|t| t.eq_ignore_ascii_case(old))
|| !collect_select_base_sources(sub, old, srcs)
{
return false;
}
}
// `UPDATE … SET … FROM <sources>` (SQLite extension): the joined tables
// are additional readable sources, so a qualified `<src>.old` or a
// globally-unique bare `old` in the `SET`/`WHERE` binds to one of them.
// Collect them (a wrong-shape `FROM` bails), fixing a false-accept where
// a trigger-body `UPDATE u SET z = t.c FROM t` referenced a since-dropped
// `t.c`, and the matching missed RENAME COLUMN rewrite.
if let Some(from) = &u.from
&& !collect_fromclause_base_sources(from, old, srcs)
{
return false;
}
let mut exprs: Vec<&Expr> = Vec::new();
for (_, e) in &u.assignments {
exprs.push(e);
}
exprs.extend(u.where_clause.as_ref());
exprs.extend(u.order_by.iter().map(|t| &t.expr));
exprs.extend(u.limit.as_ref());
exprs.extend(u.offset.as_ref());
collect_expr_subs(&exprs, old, srcs)
}
Statement::Delete(d) => {
if d.schema.is_some()
|| !d.returning.is_empty()
|| !d.ctes.is_empty()
|| !push_target(&d.table, d.alias.as_deref(), old, srcs)
{
return false;
}
let mut exprs: Vec<&Expr> = Vec::new();
exprs.extend(d.where_clause.as_ref());
exprs.extend(d.order_by.iter().map(|t| &t.expr));
exprs.extend(d.limit.as_ref());
exprs.extend(d.offset.as_ref());
collect_expr_subs(&exprs, old, srcs)
}
_ => false,
}
}
/// Whether a trigger's `WHEN` guard or any body statement uses a `WITH` CTE (in a
/// statement's own select or a nested subquery). Mirrors [`select_needs_scope_aware`]
/// for the trigger shape; used to force scope-aware RENAME COLUMN resolution.
fn trigger_contains_cte(ct: &crate::sql::ast::CreateTrigger, old: &str) -> bool {
use crate::sql::ast::InsertSource;
let expr_has_cte = |e: &Expr| -> bool {
let mut subs: Vec<&Select> = Vec::new();
collect_immediate_subselects(e, &mut subs);
subs.iter().any(|s| select_needs_scope_aware(s, old))
};
if ct.when.as_ref().is_some_and(&expr_has_cte) {
return true;
}
for stmt in &ct.body {
let has = match stmt {
Statement::Select(s) => select_needs_scope_aware(s, old),
Statement::Insert(i) => match &i.source {
InsertSource::Select(s) => select_needs_scope_aware(s, old),
InsertSource::Values(rows) => rows.iter().flatten().any(&expr_has_cte),
InsertSource::DefaultValues => false,
},
Statement::Update(u) => {
u.assignments.iter().any(|(_, e)| expr_has_cte(e))
|| u.where_clause.as_ref().is_some_and(&expr_has_cte)
|| u.order_by.iter().any(|t| expr_has_cte(&t.expr))
|| u.limit.as_ref().is_some_and(&expr_has_cte)
|| u.offset.as_ref().is_some_and(&expr_has_cte)
}
Statement::Delete(d) => {
d.where_clause.as_ref().is_some_and(&expr_has_cte)
|| d.order_by.iter().any(|t| expr_has_cte(&t.expr))
|| d.limit.as_ref().is_some_and(&expr_has_cte)
|| d.offset.as_ref().is_some_and(&expr_has_cte)
}
_ => false,
};
if has {
return true;
}
}
false
}
/// The trigger counterpart of [`view_global_unique_quals`]: a `CREATE TRIGGER`
/// whose `WHEN` guard and body reach the renamed `table` only through base-table
/// sources (its own target tables and nested-subquery `FROM`s), with the renamed
/// column name unique across all of them — so a bare `old` resolves unambiguously
/// to the renamed table everywhere and a whole-text token rewrite is complete and
/// correct. Returns `None` (leave the trigger byte-identical) on anything outside
/// that provably-safe, globally-unique shape — never a partial rewrite. When the
/// trigger is attached to the renamed table, `NEW`/`OLD` are added as qualifiers
/// (they bind to the renamed table's row).
fn trigger_global_unique_quals(
trigger_sql: &str,
table: &str,
old: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
) -> Option<(Vec<String>, BareRewrite)> {
let Ok(Statement::CreateTrigger(ct)) = sql::parse_one(trigger_sql) else {
return None;
};
if old.eq_ignore_ascii_case(table)
|| old.eq_ignore_ascii_case("new")
|| old.eq_ignore_ascii_case("old")
{
return None;
}
let mut srcs: Vec<(String, Option<String>)> = Vec::new();
// The `WHEN` guard's subqueries are base-table sources too.
if let Some(w) = &ct.when {
let mut subs: Vec<&Select> = Vec::new();
collect_immediate_subselects(w, &mut subs);
if !subs
.into_iter()
.all(|s| collect_select_base_sources(s, old, &mut srcs))
{
return None;
}
}
if ct.body.is_empty() {
return None;
}
for stmt in &ct.body {
if !collect_trigger_stmt_base_sources(stmt, old, &mut srcs) {
return None;
}
}
// Globally-unique fast path, else the scope-aware fallback (A-rn3-edge) — see
// [`view_global_unique_quals`] / [`scope_bare_old_decision_trigger`] for the
// rationale; a genuinely mixed body still bails untouched. As for views, a CTE
// anywhere forces scope-aware so an outer reference to a CTE's renamed output
// column stays unresolved and bails rather than being blindly rewritten.
let cte = trigger_contains_cte(&ct, old);
let (mut quals, bare) = match global_unique_plan(&srcs, table, old, table_cols) {
Some((q, true)) if !cte => (q, BareRewrite::All),
Some((q, _)) => (
q,
scope_bare_old_decision_trigger(&ct, table, old, table_cols)?,
),
None => return None,
};
if ct.table.eq_ignore_ascii_case(table) {
quals.push(String::from("NEW"));
quals.push(String::from("OLD"));
}
Some((quals, bare))
}
/// Whether a `SELECT` references at most the single source `table` (its `FROM`,
/// if any, is exactly `table` with no alias, joins, subquery source, CTEs,
/// compound parts, or any subquery expression). Conservative: a `false` result
/// just means "don't token-rewrite", never corruption.
fn select_single_source_ok(sel: &Select, table: &str) -> bool {
if !sel.ctes.is_empty() || !sel.compound.is_empty() {
return false;
}
if let Some(from) = &sel.from
&& (!from.joins.is_empty()
|| from.first.subquery.is_some()
|| from.first.tvf_args.is_some()
|| from.first.alias.is_some()
|| !from.first.name.eq_ignore_ascii_case(table))
{
return false;
}
let mut ok = true;
for rc in &sel.columns {
if let ResultColumn::Expr { expr, alias, .. } = rc {
ok &= !expr_has_subquery(expr) && alias.is_none();
}
}
for e in sel
.where_clause
.iter()
.chain(sel.group_by.iter())
.chain(sel.having.iter())
{
ok &= !expr_has_subquery(e);
}
for t in &sel.order_by {
ok &= !expr_has_subquery(&t.expr);
}
ok
}
/// Whether every expression subquery nested *directly* within `e` references
/// only the renamed `table` (reusing the view validator, which recurses through
/// further nesting and accumulates each subquery's `FROM` alias into `quals`).
/// A `FROM`-less subquery (`(SELECT 1)`) trivially qualifies.
fn expr_subqueries_only_table(e: &Expr, table: &str, old: &str, quals: &mut Vec<String>) -> bool {
let mut subs: Vec<&Select> = Vec::new();
collect_immediate_subselects(e, &mut subs);
subs.into_iter()
.all(|s| validate_view_select_only_table(s, table, old, quals))
}
/// Whether a single trigger-body statement targets only `table` and any
/// subqueries it nests reference only `table` — the imperative analog of
/// `validate_view_select_only_table` for `INSERT`/`UPDATE`/`DELETE` (and a bare
/// `SELECT`). Accumulates nested-subquery aliases into `quals` so the caller can
/// token-rewrite every reference, bare and `<alias>.`-qualified alike.
fn trigger_stmt_only_table(
stmt: &Statement,
table: &str,
old: &str,
quals: &mut Vec<String>,
) -> bool {
match stmt {
Statement::Select(sel) => validate_view_select_only_table(sel, table, old, quals),
Statement::Insert(i) => {
if i.schema.is_some()
|| !i.returning.is_empty()
|| !i.upsert.is_empty()
|| !i.table.eq_ignore_ascii_case(table)
{
return false;
}
match &i.source {
InsertSource::DefaultValues => true,
InsertSource::Values(rows) => rows.iter().all(|r| {
r.iter()
.all(|e| expr_subqueries_only_table(e, table, old, quals))
}),
InsertSource::Select(sel) => {
validate_view_select_only_table(sel, table, old, quals)
}
}
}
Statement::Update(u) => {
if u.schema.is_some()
|| u.from.is_some()
|| !u.returning.is_empty()
|| !u.table.eq_ignore_ascii_case(table)
|| !u.row_assignments.is_empty()
{
return false;
}
u.assignments
.iter()
.all(|(_, e)| expr_subqueries_only_table(e, table, old, quals))
&& u.where_clause
.as_ref()
.is_none_or(|e| expr_subqueries_only_table(e, table, old, quals))
&& u.order_by
.iter()
.all(|t| expr_subqueries_only_table(&t.expr, table, old, quals))
&& u.limit
.as_ref()
.is_none_or(|e| expr_subqueries_only_table(e, table, old, quals))
&& u.offset
.as_ref()
.is_none_or(|e| expr_subqueries_only_table(e, table, old, quals))
}
Statement::Delete(d) => {
if d.schema.is_some() || !d.returning.is_empty() || !d.table.eq_ignore_ascii_case(table)
{
return false;
}
d.where_clause
.as_ref()
.is_none_or(|e| expr_subqueries_only_table(e, table, old, quals))
&& d.order_by
.iter()
.all(|t| expr_subqueries_only_table(&t.expr, table, old, quals))
&& d.limit
.as_ref()
.is_none_or(|e| expr_subqueries_only_table(e, table, old, quals))
&& d.offset
.as_ref()
.is_none_or(|e| expr_subqueries_only_table(e, table, old, quals))
}
_ => false,
}
}
/// For a `CREATE TRIGGER` ON the renamed `table` whose body and `WHEN` target
/// only that table — every body statement targets `table` and draws from at most
/// `table`, including inside any nested expression subquery — return the
/// qualifiers under which the renamed column can appear (`table`, `NEW`, `OLD`,
/// plus every nested-subquery `FROM` alias) so a column rename can be
/// token-rewritten. Returns `None` (leave the trigger unchanged) on anything
/// outside this provably-safe shape — the cross-object / scope-aware remainder.
fn trigger_single_source_quals(trigger_sql: &str, table: &str, old: &str) -> Option<Vec<String>> {
let Ok(Statement::CreateTrigger(ct)) = sql::parse_one(trigger_sql) else {
return None;
};
// Only triggers attached to the renamed table (so NEW/OLD are its rows). A
// column named like the table or like the NEW/OLD aliases is ambiguous.
if !ct.table.eq_ignore_ascii_case(table)
|| old.eq_ignore_ascii_case(table)
|| old.eq_ignore_ascii_case("new")
|| old.eq_ignore_ascii_case("old")
{
return None;
}
let mut quals = alloc::vec![table.to_string(), String::from("NEW"), String::from("OLD"),];
if !ct
.when
.as_ref()
.is_none_or(|e| expr_subqueries_only_table(e, table, old, &mut quals))
{
return None;
}
for stmt in &ct.body {
if !trigger_stmt_only_table(stmt, table, old, &mut quals) {
return None;
}
}
Some(quals)
}
/// Whether `trigger_sql`'s body+WHEN reference `table` as their ONLY base table
/// (every body statement targets/reads just `table`, no other table, no
/// subquery, no alias/CTE/compound) — regardless of which table the trigger is
/// attached to. When true, every bare and `table.`-qualified column reference in
/// the body binds to `table`, so a rename can be token-rewritten safely. Used for
/// a trigger on ANOTHER table whose body reads/writes the renamed table (the
/// cross-object case `trigger_single_source_quals` does not cover, since that
/// one also rewrites `NEW`/`OLD`, which here belong to the trigger's own table).
/// Conservative: any construct it cannot prove single-source makes it `false`.
fn trigger_body_single_source_over(trigger_sql: &str, table: &str, old: &str) -> bool {
let Ok(Statement::CreateTrigger(ct)) = sql::parse_one(trigger_sql) else {
return false;
};
// `old` colliding with NEW/OLD would make a bare-vs-pseudo-column ambiguous.
if old.eq_ignore_ascii_case("new") || old.eq_ignore_ascii_case("old") {
return false;
}
if ct.when.as_ref().is_some_and(expr_has_subquery) {
return false;
}
for stmt in &ct.body {
let safe = match stmt {
Statement::Select(sel) => select_single_source_ok(sel, table),
Statement::Insert(i) => {
i.schema.is_none()
&& i.returning.is_empty()
&& i.upsert.is_empty()
&& i.table.eq_ignore_ascii_case(table)
&& match &i.source {
InsertSource::DefaultValues => true,
InsertSource::Values(rows) => {
!rows.iter().any(|r| r.iter().any(expr_has_subquery))
}
InsertSource::Select(sel) => select_single_source_ok(sel, table),
}
}
Statement::Update(u) => {
u.schema.is_none()
&& u.from.is_none()
&& u.returning.is_empty()
&& u.table.eq_ignore_ascii_case(table)
&& u.row_assignments.is_empty()
&& !u.assignments.iter().any(|(_, e)| expr_has_subquery(e))
&& !u.where_clause.as_ref().is_some_and(expr_has_subquery)
}
Statement::Delete(d) => {
d.schema.is_none()
&& d.returning.is_empty()
&& d.table.eq_ignore_ascii_case(table)
&& !d.where_clause.as_ref().is_some_and(expr_has_subquery)
}
_ => false,
};
if !safe {
return false;
}
}
// Require at least one statement (an empty body has nothing to rewrite).
!ct.body.is_empty()
}
/// Whether `trigger_sql` is a trigger attached to `table`, with `old` not an
/// ambiguous name (the table itself or the `NEW`/`OLD` aliases). When true, the
/// trigger's `NEW.old` / `OLD.old` references unambiguously bind to `table`'s
/// renamed column — safe to rewrite even when the body touches other tables
/// (unlike [`trigger_single_source_quals`], which also needs bare refs to resolve).
fn trigger_on_renamed_table(trigger_sql: &str, table: &str, old: &str) -> bool {
matches!(sql::parse_one(trigger_sql), Ok(Statement::CreateTrigger(ct))
if ct.table.eq_ignore_ascii_case(table)
&& !old.eq_ignore_ascii_case(table)
&& !old.eq_ignore_ascii_case("new")
&& !old.eq_ignore_ascii_case("old"))
}
/// Scan `sql`'s tokens for the *first* column reference that binds to `old`
/// under the same rules as [`rewrite_column_tokens`] (`quals` are the in-scope
/// table names / aliases; `rewrite_bare` allows an unqualified `old`). Returns
/// the exact source text of that reference — bare `old`, or `<qual>.old` (e.g.
/// `t.c`, `x.c`, `NEW.c`) — which is what SQLite echoes in an `error in … after
/// drop column: no such column: …` message. When `skip_update_of` is set, a
/// column named in a trigger's `UPDATE OF <list>` clause is ignored: that clause
/// only *triggers on* the column, so dropping it does not break the trigger in
/// SQLite (even though a RENAME would rewrite the name there). Used by
/// [`view_drop_break_ref`] / [`trigger_drop_break_ref`] to decide whether an
/// `ALTER TABLE … DROP COLUMN` leaves a dependent unresolvable.
fn first_bound_column_ref(
sql: &str,
quals: &[String],
old: &str,
rewrite_bare: bool,
skip_write_targets: bool,
) -> Option<String> {
use sql::token::Token;
let toks = sql::token::tokenize(sql).ok()?;
let kw = |t: &Token, k: &str| matches!(t, Token::Word(w) if w.eq_ignore_ascii_case(k));
let mut skip = alloc::vec![false; toks.len()];
if skip_write_targets {
let mut j = 0usize;
while j < toks.len() {
// `UPDATE OF <col>[, ...] ON` — the fires-on column list.
if j + 1 < toks.len() && kw(&toks[j].token, "update") && kw(&toks[j + 1].token, "of") {
let mut k = j + 2;
while k < toks.len() && !kw(&toks[k].token, "on") {
skip[k] = true;
k += 1;
}
j = k;
continue;
}
// `INSERT INTO <name>[. <name>] ( col, ... )` — the target column list
// (the parenthesised group immediately after the table name).
if kw(&toks[j].token, "into") {
let mut k = j + 1;
if matches!(
toks.get(k).map(|t| &t.token),
Some(Token::Word(_) | Token::Ident(_))
) {
k += 1;
if matches!(toks.get(k).map(|t| &t.token), Some(Token::Dot)) {
k += 2;
}
if matches!(toks.get(k).map(|t| &t.token), Some(Token::LParen)) {
let mut depth = 0i32;
while k < toks.len() {
match &toks[k].token {
Token::LParen => depth += 1,
Token::RParen => depth -= 1,
_ => {}
}
skip[k] = true;
if depth == 0 {
break;
}
k += 1;
}
j = k + 1;
continue;
}
}
}
// `SET <target> = ...[, <target> = ...]` — each assignment's left
// side. The region runs from `SET` to the next depth-0 `WHERE`/`FROM`/
// `;`. A bare column immediately followed by `=` is a target.
if kw(&toks[j].token, "set") {
let mut k = j + 1;
let mut depth = 0i32;
while k < toks.len() {
match &toks[k].token {
Token::LParen => depth += 1,
Token::RParen => depth -= 1,
Token::Semicolon => break,
_ if depth == 0
&& (kw(&toks[k].token, "where") || kw(&toks[k].token, "from")) =>
{
break;
}
Token::Word(_) | Token::Ident(_)
if matches!(toks.get(k + 1).map(|t| &t.token), Some(Token::Eq)) =>
{
skip[k] = true;
}
_ => {}
}
k += 1;
}
j = k;
continue;
}
j += 1;
}
}
for (i, sp) in toks.iter().enumerate() {
if skip[i] {
continue;
}
let hit =
matches!(&sp.token, Token::Word(w) | Token::Ident(w) if w.eq_ignore_ascii_case(old));
if !hit {
continue;
}
// A function name (`old(`) is never a column reference.
if toks
.get(i + 1)
.is_some_and(|n| matches!(n.token, Token::LParen))
{
continue;
}
let after_dot = i > 0 && matches!(toks[i - 1].token, Token::Dot);
if after_dot {
let qual_ok = i >= 2
&& matches!(&toks[i - 2].token, Token::Word(q) | Token::Ident(q)
if quals.iter().any(|t| t.eq_ignore_ascii_case(q)));
if !qual_ok {
continue;
}
return Some(sql[toks[i - 2].start..sp.end].to_string());
} else if !rewrite_bare {
continue;
}
return Some(sql[sp.start..sp.end].to_string());
}
None
}
/// If dropping `col` from `table` would leave this view's body unable to resolve
/// a column, return the exact source text of the first reference that binds to
/// the dropped column (for SQLite's `error in view … after drop column: no such
/// column: …` message). Reuses the RENAME COLUMN binding provers: if a rename
/// *would* rewrite a reference, that reference provably resolves to the dropped
/// column, so the drop breaks the view. Conservative — a body the provers cannot
/// bind (subqueries the rename path declines, ambiguous bare refs) yields `None`,
/// so the drop is allowed (matching graphite's prior behavior, never a false
/// rejection). A `SELECT *` body carries no column token and so never breaks,
/// exactly as in SQLite.
fn view_drop_break_ref(
vsql: &str,
table: &str,
col: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
) -> Option<String> {
let (quals, bare) = if let Some(q) = view_single_source_column_quals(vsql, table, col) {
(q, true)
} else if let Some(q) = view_only_table_quals(vsql, table, col) {
(q, true)
} else if let Some(p) = view_multi_source_quals(vsql, table, col, table_cols) {
p
} else {
// Any bare occurrence that would be rewritten (whole-body `All` or a
// per-occurrence `At`) means a bare ref binds to the dropped column, so
// the drop breaks the view.
let (q, br) = view_global_unique_quals(vsql, table, col, table_cols)?;
(q, !matches!(br, BareRewrite::None))
};
first_bound_column_ref(vsql, &quals, col, bare, false)
}
/// The trigger counterpart of [`view_drop_break_ref`]: returns the first body /
/// `WHEN` reference that binds to `table`'s dropped `col` (e.g. `NEW.c`, `OLD.c`,
/// or a bare `c`), for SQLite's `error in trigger … after drop column: …`. A
/// column appearing only in the `UPDATE OF` list does not count (SQLite allows
/// that drop). Same conservative reuse of the RENAME COLUMN provers.
fn trigger_drop_break_ref(
tsql: &str,
table: &str,
col: &str,
table_cols: &alloc::collections::BTreeMap<String, Vec<String>>,
) -> Option<String> {
let (quals, bare) = if let Some(q) = trigger_single_source_quals(tsql, table, col) {
(q, true)
} else if let Some((q, br)) = trigger_global_unique_quals(tsql, table, col, table_cols) {
// Any bare occurrence that would be rewritten (whole-body `All` or a
// per-occurrence `At`) means a bare ref binds to the dropped column.
(q, !matches!(br, BareRewrite::None))
} else if trigger_on_renamed_table(tsql, table, col) {
(alloc::vec![String::from("NEW"), String::from("OLD")], false)
} else if trigger_body_single_source_over(tsql, table, col) {
(alloc::vec![table.to_string()], true)
} else {
return None;
};
first_bound_column_ref(tsql, &quals, col, bare, true)
}
/// Token-rewrite a column rename in DDL where every reference to `old` is known
/// to belong to one of `quals` (a single-source object's table name / aliases):
/// rename a qualified `<q>.old` whose qualifier `q` is in `quals`, preserving all
/// other text. When `rewrite_bare` is true, an unqualified `old` ident is also
/// renamed (safe only when every bare reference provably resolves to the renamed
/// table — i.e. a single-source object); when false, only qualified references
/// are touched (e.g. a multi-source trigger where only `NEW.old`/`OLD.old` are
/// provably the renamed column). A function name (`old(`) and a column tail
/// qualified by anything else are left intact.
fn rewrite_column_tokens(
sql: &str,
quals: &[String],
old: &str,
rendered: &str,
bare: BareRewrite,
) -> String {
use sql::token::Token;
let toks = match sql::token::tokenize(sql) {
Ok(t) => t,
Err(_) => return String::from(sql),
};
// SQLite never renames a name inside a foreign key's *parent* column list —
// `REFERENCES other(col)` names the parent table's column, not this one. Mark
// every token inside a `REFERENCES <name>( … )` group whose `<name>` is not in
// `quals` (i.e. not a self-reference to the renamed table) so a bare `old`
// there is left intact. A self-FK `REFERENCES <thistable>(old)` keeps the
// parent name in `quals`, so it is *not* marked and still renames, like SQLite.
let mut in_foreign_ref = alloc::vec![false; toks.len()];
{
let mut j = 0usize;
while j < toks.len() {
if matches!(&toks[j].token, Token::Word(w) if w.eq_ignore_ascii_case("references")) {
let name_is_self = toks.get(j + 1).is_some_and(|n| {
matches!(&n.token, Token::Word(q) | Token::Ident(q)
if quals.iter().any(|t| t.eq_ignore_ascii_case(q)))
});
if !name_is_self && matches!(toks.get(j + 2).map(|t| &t.token), Some(Token::LParen))
{
let mut depth = 0i32;
let mut k = j + 2;
while k < toks.len() {
match &toks[k].token {
Token::LParen => depth += 1,
Token::RParen => depth -= 1,
_ => {}
}
in_foreign_ref[k] = true;
if depth == 0 {
break;
}
k += 1;
}
j = k;
}
}
j += 1;
}
}
let mut out = String::new();
let mut cursor = 0usize;
for (i, sp) in toks.iter().enumerate() {
let hit =
matches!(&sp.token, Token::Word(w) | Token::Ident(w) if w.eq_ignore_ascii_case(old));
if !hit {
continue;
}
// A function name (`old(`) is never a column reference.
if toks
.get(i + 1)
.is_some_and(|n| matches!(n.token, Token::LParen))
{
continue;
}
// Inside a foreign table's `REFERENCES name(…)` parent column list: leave
// the parent's column name untouched.
if in_foreign_ref[i] {
continue;
}
let after_dot = i > 0 && matches!(toks[i - 1].token, Token::Dot);
if after_dot {
// Rename only `<qualifier>.old` where the qualifier is the table or an
// alias; leave any other `x.old` untouched.
let qual_ok = i >= 2
&& matches!(&toks[i - 2].token, Token::Word(q) | Token::Ident(q)
if quals.iter().any(|t| t.eq_ignore_ascii_case(q)));
if !qual_ok {
continue;
}
} else {
// A bare reference: rewrite per the caller's policy. `None` skips all
// (another scope owns them); `All` rewrites every one (single-source
// or globally unambiguous); `At` rewrites only the occurrences whose
// source offset was proven to bind to the renamed table (mixed scope).
match &bare {
BareRewrite::None => continue,
BareRewrite::All => {}
BareRewrite::At(offsets) => {
if !offsets.contains(&(sp.start as u32)) {
continue;
}
}
}
}
out.push_str(&sql[cursor..sp.start]);
// SQLite preserves each occurrence's own quoting: a token written
// double-quoted stays double-quoted (`"a"` → `"aa"`) even when the new
// name was typed bare. `rendered` already carries the typed style, so
// only a quoted occurrence whose replacement isn't already quoted needs
// to be force-quoted.
if matches!(&sp.token, Token::Ident(_)) && !rendered.starts_with('"') {
out.push('"');
out.push_str(rendered);
out.push('"');
} else {
out.push_str(rendered);
}
cursor = sp.end;
}
out.push_str(&sql[cursor..]);
out
}
fn rewrite_ident_tokens(sql: &str, old: &str, rendered: &str) -> String {
let toks = match sql::token::tokenize(sql) {
Ok(t) => t,
Err(_) => return String::from(sql),
};
let mut out = String::new();
let mut cursor = 0usize;
for (i, sp) in toks.iter().enumerate() {
let hit = matches!(
&sp.token,
sql::token::Token::Word(w) | sql::token::Token::Ident(w) if w.eq_ignore_ascii_case(old)
);
if !hit {
continue;
}
// A token equal to the table name is only a *table reference* worth
// renaming when it is neither a column-name tail (`x.old`) nor a function
// name (`old(`). Skipping those keeps a like-named column or function
// (e.g. a table named `count` vs the `count()` function) intact.
let after_dot = i > 0 && matches!(toks[i - 1].token, sql::token::Token::Dot);
let before_lparen = toks
.get(i + 1)
.is_some_and(|n| matches!(n.token, sql::token::Token::LParen));
// `INSERT INTO old(col-list)` reads as `old(` but is a table reference
// with a column list, not a function call — so the `before_lparen` guard
// must not skip a token that immediately follows `INTO`.
let after_into = i > 0
&& matches!(&toks[i - 1].token,
sql::token::Token::Word(w) if w.eq_ignore_ascii_case("into"));
if after_dot || (before_lparen && !after_into) {
continue;
}
out.push_str(&sql[cursor..sp.start]);
out.push_str(rendered);
cursor = sp.end;
}
out.push_str(&sql[cursor..]);
out
}
/// Replace the table-name token that follows the `anchor` keyword (`TABLE` for a
/// `CREATE TABLE`, `ON` for a `CREATE INDEX`) with `new` (double-quoted, as
/// SQLite does), preserving the rest of the text verbatim — so a `RENAME TO`
/// keeps the original formatting rather than reprinting from the AST. Returns the
/// input unchanged if the name token can't be located.
fn rename_table_token_after(sql: &str, anchor: &str, new: &str) -> String {
use sql::token::Token;
let toks = match sql::token::tokenize(sql) {
Ok(t) => t,
Err(_) => return String::from(sql),
};
let kw = |t: &Token, k: &str| matches!(t, Token::Word(w) if w.eq_ignore_ascii_case(k));
let mut i = 0;
while i < toks.len() && !kw(&toks[i].token, anchor) {
i += 1;
}
i += 1;
// Optional `IF NOT EXISTS` (only after TABLE).
if i + 2 < toks.len()
&& kw(&toks[i].token, "if")
&& kw(&toks[i + 1].token, "not")
&& kw(&toks[i + 2].token, "exists")
{
i += 3;
}
// Optional `schema.` qualifier before the table name.
if i + 1 < toks.len() && matches!(toks[i + 1].token, Token::Dot) {
i += 2;
}
let Some(sp) = toks.get(i) else {
return String::from(sql);
};
let mut out = String::with_capacity(sql.len() + new.len());
out.push_str(&sql[..sp.start]);
out.push_str(&sql::print::ident(new));
out.push_str(&sql[sp.end..]);
out
}
/// Rewrite the target of every `REFERENCES <old>` clause in a `CREATE TABLE`
/// text to `new` (double-quoted), preserving the rest verbatim — so an
/// `ALTER TABLE … RENAME TO` updates the foreign keys of OTHER tables (and any
/// self-reference) that point at the renamed table, as SQLite does. Only the
/// table-name token immediately after `REFERENCES` is touched, so references to
/// other tables — and a column that happens to share the old name — are left
/// intact. (SQLite forbids a schema qualifier after `REFERENCES`, so the target
/// is always a single bare/quoted name.)
fn rewrite_fk_references(sql: &str, old: &str, new: &str) -> String {
use sql::token::Token;
let toks = match sql::token::tokenize(sql) {
Ok(t) => t,
Err(_) => return String::from(sql),
};
let mut out = String::new();
let mut cursor = 0usize;
for (i, sp) in toks.iter().enumerate() {
if !matches!(&sp.token, Token::Word(w) if w.eq_ignore_ascii_case("references")) {
continue;
}
let Some(target) = toks.get(i + 1) else {
continue;
};
if matches!(&target.token, Token::Word(w) | Token::Ident(w) if w.eq_ignore_ascii_case(old))
{
out.push_str(&sql[cursor..target.start]);
out.push_str(&sql::print::ident(new));
cursor = target.end;
}
}
out.push_str(&sql[cursor..]);
out
}
/// Insert `, <col_text>` before the column-list's closing paren of a `CREATE
/// TABLE` statement's text, preserving everything else verbatim — how SQLite
/// records an `ADD COLUMN`. The new column is inserted after the last column
/// definition but *before* any table-level constraints (`CHECK`, `PRIMARY KEY`,
/// …), exactly where SQLite puts it. Returns `None` if the list can't be located.
fn append_column_to_create(sql: &str, col_text: &str) -> Option<String> {
use sql::token::Token;
let toks = sql::token::tokenize(sql).ok()?;
let open = toks.iter().position(|t| matches!(t.token, Token::LParen))?;
let mut depth = 0i32;
let mut close = None;
let mut seps = Vec::new();
for (i, sp) in toks.iter().enumerate().skip(open) {
match sp.token {
Token::LParen => depth += 1,
Token::RParen => {
depth -= 1;
if depth == 0 {
close = Some(i);
break;
}
}
Token::Comma if depth == 1 => seps.push(i),
_ => {}
}
}
let close = close?;
// Top-level segment boundaries: the opener, each top-level comma, then the
// closer. A segment is a *table constraint* when its first (unquoted) token is
// a constraint keyword — the new column must precede the first such segment.
let mut bounds = alloc::vec![open];
bounds.extend_from_slice(&seps);
bounds.push(close);
let is_constraint = |i: usize| {
matches!(toks.get(i).map(|t| &t.token), Some(Token::Word(w)) if matches!(
w.to_ascii_uppercase().as_str(),
"CONSTRAINT" | "PRIMARY" | "UNIQUE" | "CHECK" | "FOREIGN"
))
};
// Position just before the first table-constraint segment (i.e. the comma
// that separates it from the preceding column), or the closing paren if none.
let pos = (1..bounds.len() - 1)
.find(|&j| is_constraint(bounds[j] + 1))
.map_or(toks[close].start, |j| toks[bounds[j]].start);
let mut out = String::with_capacity(sql.len() + col_text.len() + 2);
out.push_str(&sql[..pos]);
out.push_str(", ");
out.push_str(col_text.trim());
out.push_str(&sql[pos..]);
Some(out)
}
/// Remove the column named `col` (and one adjacent comma) from a `CREATE TABLE`
/// statement's text, preserving everything else verbatim — how SQLite records a
/// `DROP COLUMN`. Returns `None` if the column or list can't be located.
fn drop_column_from_create(sql: &str, col: &str) -> Option<String> {
use sql::token::Token;
let toks = sql::token::tokenize(sql).ok()?;
let open = toks.iter().position(|t| matches!(t.token, Token::LParen))?;
// The matching close of the column list, and the top-level comma separators.
let mut depth = 0i32;
let mut close = None;
let mut seps = Vec::new();
for (i, sp) in toks.iter().enumerate().skip(open) {
match sp.token {
Token::LParen => depth += 1,
Token::RParen => {
depth -= 1;
if depth == 0 {
close = Some(i);
break;
}
}
Token::Comma if depth == 1 => seps.push(i),
_ => {}
}
}
let close = close?;
// Segment boundaries: the opener, each top-level comma, then the closer. The
// first token after each boundary begins a column def or table constraint.
let mut bounds = alloc::vec![open];
bounds.extend_from_slice(&seps);
bounds.push(close);
let n = bounds.len() - 1; // number of segments
let is_named = |i: usize| {
matches!(&toks.get(i).map(|t| &t.token),
Some(Token::Word(w) | Token::Ident(w)) if w.eq_ignore_ascii_case(col))
};
let j = (0..n).find(|&j| bounds[j] + 1 < bounds[j + 1] && is_named(bounds[j] + 1))?;
let (del_start, del_end) = if j < n - 1 {
// Not the last segment: drop it and the comma that follows.
(toks[bounds[j] + 1].start, toks[bounds[j + 1] + 1].start)
} else {
// The last segment: drop the comma that precedes it through its last token.
(toks[bounds[j]].start, toks[close - 1].end)
};
let mut out = String::with_capacity(sql.len());
out.push_str(&sql[..del_start]);
out.push_str(&sql[del_end..]);
Some(out)
}
/// Best-effort label for an unaliased result expression.
fn expr_label(expr: &Expr) -> String {
match expr {
Expr::Column { column, .. } => column.clone(),
Expr::Literal(Literal::Integer(i)) => i.to_string(),
Expr::Literal(Literal::Str(s)) => s.clone(),
Expr::Function { name, .. } => name.clone(),
Expr::Paren(e) => expr_label(e),
_ => "expr".to_string(),
}
}
/// The name of a result column, matching SQLite: an `AS` alias wins; a bare
/// column reference uses the column name; any other expression is named after
/// its verbatim source span (`SELECT a+b` → `a+b`), falling back to
/// [`expr_label`] when no span was captured (synthetic columns).
fn result_column_label(expr: &Expr, alias: &Option<String>, source: &Option<String>) -> String {
if let Some(a) = alias {
return a.clone();
}
match expr {
Expr::Column { column, .. } => column.clone(),
_ => source.clone().unwrap_or_else(|| expr_label(expr)),
}
}
/// Detect an `INTEGER PRIMARY KEY` rowid alias column (must be declared exactly
/// `INTEGER`, per SQLite — `INT PRIMARY KEY` does not alias the rowid).
/// The collating sequences for a `WITHOUT ROWID` table's stored columns, in
/// on-disk (PK-first) order — used to order its clustered b-tree.
fn wr_storage_collations(meta: &TableMeta) -> Vec<crate::value::Collation> {
meta.storage_order
.iter()
.map(|&c| meta.columns[c].collation)
.collect()
}
/// The declared collating sequence of a column (`COLLATE name`), `BINARY` if
/// none or unrecognized.
fn column_collation(col: &ColumnDef) -> crate::value::Collation {
col.constraints
.iter()
.find_map(|c| match c {
ColumnConstraint::Collate(name) => crate::value::resolve_collation_name(name),
_ => None,
})
.unwrap_or_default()
}
/// The UNIQUE / non-rowid PRIMARY KEY column-index sets of a table, in
/// declaration order (column-level constraints first, in column order, then
/// table-level constraints). This is exactly the order SQLite numbers its
/// `sqlite_autoindex_<table>_<n>` automatic indexes.
fn collect_unique_sets(
ct: &CreateTable,
ipk: Option<usize>,
) -> Vec<(Vec<usize>, OnConflict, Vec<bool>)> {
let col_pos = |name: &str| {
ct.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
};
// Each unique set carries its declared `ON CONFLICT` action (default `Abort`),
// applied when an INSERT/UPDATE without its own `OR <action>` violates it, and
// its per-column `DESC` flags (aligned with the positions) that order the
// auto-created `sqlite_autoindex_*` b-tree.
let mut unique: Vec<(Vec<usize>, OnConflict, Vec<bool>)> = Vec::new();
for (i, c) in ct.columns.iter().enumerate() {
for k in &c.constraints {
match k {
// A column-level `UNIQUE` has no direction syntax (always ASC).
ColumnConstraint::Unique(oc) => {
unique.push((alloc::vec![i], *oc, alloc::vec![false]))
}
// A column-level `PRIMARY KEY [ASC|DESC]` on a non-rowid-alias
// column builds an auto UNIQUE index honouring the direction.
ColumnConstraint::PrimaryKey {
on_conflict,
descending,
..
} if Some(i) != ipk => {
unique.push((alloc::vec![i], *on_conflict, alloc::vec![*descending]))
}
_ => {}
}
}
}
for tc in &ct.constraints {
let (cols, oc): (Vec<(&str, bool)>, OnConflict) = match tc {
TableConstraint::Unique(n, oc) => {
(n.iter().map(|(nm, d)| (nm.as_str(), *d)).collect(), *oc)
}
TableConstraint::PrimaryKey(n, oc) => {
(n.iter().map(|(nm, d)| (nm.as_str(), *d)).collect(), *oc)
}
_ => continue,
};
let idxs: Option<Vec<usize>> = cols.iter().map(|(n, _)| col_pos(n)).collect();
if let Some(set) = idxs {
// Skip a single-column PK that is the rowid alias.
if !(set.len() == 1 && Some(set[0]) == ipk) {
let descs: Vec<bool> = cols.iter().map(|(_, d)| *d).collect();
unique.push((set, oc, descs));
}
}
}
unique
}
/// Convert a `WITHOUT ROWID` row from declared column order to on-disk storage
/// order (PK columns first, then the rest).
fn permute_row(meta: &TableMeta, declared: &[Value]) -> Vec<Value> {
// permute_row feeds only the WITHOUT ROWID record encoders, so apply the same
// MEM_IntReal storage substitution as `encode_table_record`: a whole-number
// real in a REAL column is written with the compact integer serial type.
let realified = realify_columns_for_storage(meta, declared);
meta.storage_order
.iter()
.map(|&i| realified[i].clone())
.collect()
}
/// The auto-vacuum mode recorded in a database header: 0 = NONE, 1 = FULL,
/// 2 = INCREMENTAL. Auto-vacuum is on iff the largest-root-page field is
/// non-zero; the incremental-vacuum flag then selects the mode.
fn auto_vacuum_mode(header: &crate::format::DatabaseHeader) -> u32 {
if header.largest_root_page == 0 {
0
} else if header.incremental_vacuum == 0 {
1
} else {
2
}
}
/// Remove an explicit `schema.` qualifier from a qualified `CREATE` statement's
/// text so the SQL stored in the target catalog is bare-named (the `schema.`
/// prefix is invalid in that database's own namespace, and sqlite3 rejects it).
///
/// In an *explicitly* qualified CREATE the first `.` token is the object-name
/// qualifier (only keywords precede the name). `schema` is the resolved
/// qualifier; when it came from the `TEMP` keyword rather than the text (so the
/// first `.` is something else, e.g. `NEW.col` in a trigger body) the leading
/// identifier won't match and the text is returned unchanged.
fn strip_schema_qualifier(sql: &str, schema: &str) -> Result<String> {
use crate::sql::token::Token;
let toks = crate::sql::token::tokenize(sql)?;
for (i, t) in toks.iter().enumerate() {
if i == 0 || !matches!(t.token, Token::Dot) {
continue;
}
let lead = match &toks[i - 1].token {
Token::Word(s) | Token::Ident(s) => Some(s.as_str()),
_ => None,
};
if lead.is_some_and(|s| s.eq_ignore_ascii_case(schema)) {
let schema_start = toks[i - 1].start;
let name_start = toks.get(i + 1).map_or(sql.len(), |s| s.start);
let mut out = String::with_capacity(sql.len());
out.push_str(&sql[..schema_start]);
out.push_str(&sql[name_start..]);
return Ok(out);
}
// The first `.` is not the object qualifier — nothing to strip.
break;
}
Ok(sql.into())
}
/// The inverse of [`permute_row`]: storage order back to declared column order.
/// Only ever runs on a value read back from a `WITHOUT ROWID` clustered index, so
/// it also realifies an integer-serialized `REAL`-column value (see
/// [`promote_real_columns`]).
fn unpermute_row(meta: &TableMeta, storage: Vec<Value>) -> Vec<Value> {
let mut row = alloc::vec![Value::Null; meta.columns.len()];
for (k, &col) in meta.storage_order.iter().enumerate() {
if let Some(v) = storage.get(k) {
row[col] = v.clone();
}
}
promote_real_columns(meta, &mut row);
row
}
/// The column positions of a table's PRIMARY KEY, in key order (column-level
/// `PRIMARY KEY` or a table-level `PRIMARY KEY(...)`). Empty if none.
fn primary_key_positions(ct: &CreateTable) -> Vec<usize> {
primary_key_positions_dir(ct)
.into_iter()
.map(|(p, _)| p)
.collect()
}
/// Like [`primary_key_positions`] but pairs each PK column position with its
/// declared `DESC` flag (`true` = descending). The direction comes from a
/// column-level `PRIMARY KEY DESC` or from each column's `ASC`/`DESC` in a
/// table-level `PRIMARY KEY(col …)`. Used to order a `WITHOUT ROWID` table's
/// clustered b-tree; secondary auto-indexes ignore it (a separate deferral).
fn primary_key_positions_dir(ct: &CreateTable) -> Vec<(usize, bool)> {
for (i, c) in ct.columns.iter().enumerate() {
if let Some(descending) = c.constraints.iter().find_map(|k| match k {
ColumnConstraint::PrimaryKey { descending, .. } => Some(*descending),
_ => None,
}) {
return alloc::vec![(i, descending)];
}
}
for tc in &ct.constraints {
if let TableConstraint::PrimaryKey(cols, _) = tc {
let pos: Option<Vec<(usize, bool)>> = cols
.iter()
.map(|(n, desc)| {
ct.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(n))
.map(|p| (p, *desc))
})
.collect();
if let Some(pos) = pos {
return pos;
}
}
}
Vec::new()
}
/// Parse the `<n>` from `sqlite_autoindex_<table>_<n>` (1-based), if `name` is an
/// automatic index for `table`.
fn autoindex_number(name: &str, table: &str) -> Option<usize> {
let prefix = alloc::format!("sqlite_autoindex_{table}_");
name.strip_prefix(&prefix)?.parse::<usize>().ok()
}
fn find_integer_primary_key(ct: &CreateTable) -> Option<usize> {
for (i, c) in ct.columns.iter().enumerate() {
let is_integer = c
.type_name
.as_deref()
.is_some_and(|t| t.eq_ignore_ascii_case("integer"));
// A column-level `INTEGER PRIMARY KEY` is the rowid alias — EXCEPT when it
// carries the `DESC` keyword, which sqlite treats as an ordinary table
// (the column gets its own index and the rowid is auto-assigned). `ASC`
// and the table-level `PRIMARY KEY(col)` form remain aliases.
let is_pk_alias = c.constraints.iter().any(|k| {
matches!(
k,
ColumnConstraint::PrimaryKey {
descending: false,
..
}
)
});
if is_integer && is_pk_alias {
return Some(i);
}
}
// Table-level single-column PRIMARY KEY over an INTEGER column.
for tc in &ct.constraints {
if let TableConstraint::PrimaryKey(cols, _) = tc
&& cols.len() == 1
&& let Some(i) = ct.columns.iter().position(|c| c.name == cols[0].0)
&& ct.columns[i]
.type_name
.as_deref()
.is_some_and(|t| t.eq_ignore_ascii_case("integer"))
{
return Some(i);
}
}
None
}
#[cfg(all(test, feature = "fts5", feature = "std"))]
mod fts5_index_route_tests {
use super::Connection;
use crate::error::Error;
use crate::fts5_index::INDEX_ROUTE_HITS;
use crate::value::Value;
use core::sync::atomic::Ordering;
use std::sync::Mutex;
/// The global [`INDEX_ROUTE_HITS`] counter is shared across the whole test
/// binary, so these two tests — which assert on its DELTA — must not run
/// concurrently. Serialize them through this lock.
static SERIALIZE: Mutex<()> = Mutex::new(());
fn texts(c: &mut Connection, sql: &str) -> alloc::vec::Vec<alloc::string::String> {
c.query(sql)
.unwrap()
.rows
.into_iter()
.map(|r| match &r[0] {
Value::Text(s) => alloc::string::String::from(s.as_str()),
other => alloc::format!("{other:?}"),
})
.collect()
}
/// A single bare-term, table-wide `MATCH` is served by the segment index
/// (`INDEX_ROUTE_HITS` rises), and returns exactly the same rows — in the same
/// rowid order — as the documents that contain the term.
#[test]
fn bare_term_match_takes_index_route() {
let _guard = SERIALIZE.lock().unwrap_or_else(|e| e.into_inner());
let mut c = Connection::open_memory().unwrap();
c.execute("CREATE VIRTUAL TABLE t USING fts5(body)")
.unwrap();
for (i, body) in [
"the quick brown fox",
"lazy dog sleeps",
"fox and hound",
"nothing relevant here",
"a quick test",
]
.iter()
.enumerate()
{
c.execute(&alloc::format!(
"INSERT INTO t(rowid, body) VALUES({}, '{}')",
i + 1,
body
))
.unwrap();
}
// Bare single term → index route.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows = texts(&mut c, "SELECT body FROM t WHERE t MATCH 'fox'");
let after = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
assert!(after > before, "bare-term MATCH must take the index route");
assert_eq!(rows, ["the quick brown fox", "fox and hound"]);
// Repeated-in-one-doc, multi-doc, and absent terms.
assert_eq!(
texts(&mut c, "SELECT body FROM t WHERE t MATCH 'quick'"),
["the quick brown fox", "a quick test"]
);
assert!(texts(&mut c, "SELECT body FROM t WHERE t MATCH 'zebra'").is_empty());
}
/// Shapes that are neither a single bare term nor a two-term phrase stay on the
/// document scan (`INDEX_ROUTE_HITS` unchanged), still returning correct results.
#[test]
fn non_bare_shapes_stay_on_scan() {
let _guard = SERIALIZE.lock().unwrap_or_else(|e| e.into_inner());
let mut c = Connection::open_memory().unwrap();
c.execute("CREATE VIRTUAL TABLE t USING fts5(body)")
.unwrap();
for (i, body) in ["quick brown fox", "slow brown bear", "quick red fox"]
.iter()
.enumerate()
{
c.execute(&alloc::format!(
"INSERT INTO t(rowid, body) VALUES({}, '{}')",
i + 1,
body
))
.unwrap();
}
// A prefixed/anchored phrase, a NEAR group with the wrong shape, and a
// boolean mixing a phrase/prefix/column-scoped operand must not be
// index-routed. (A bare K-term phrase IS, for any K ≥ 2 — see
// `two_term_phrase_match_takes_index_route` /
// `k_term_phrase_match_takes_index_route` — an N-operand bare-term boolean
// TREE IS — see `bare_term_boolean_tree_match_takes_index_route` — a lone
// bare prefix term IS — see `prefix_term_match_takes_index_route` — and a
// lone two-single-token bare-term NEAR group IS — see
// `two_term_near_match_takes_index_route`.) Every leaf of a routed boolean
// tree must be a plain table-wide bare term; a single non-bare leaf forces
// the whole query back to the scan, and a NEAR group only routes when it is
// the entire query with exactly two bare single-token operands.
for q in [
"\"quick brown\" OR fox",
"^\"quick brown\"",
"^qui*", // anchored prefix → stays on scan
"qui* AND fox", // prefix operand in a boolean → stays on scan
"NEAR(quick brown fox, 3)", // 3 NEAR operands → stays on scan
"NEAR(\"quick brown\" fox)", // a phrase NEAR operand → stays on scan
"NEAR(quick fo*)", // a prefix NEAR operand → stays on scan
"fox AND NEAR(quick brown)", // NEAR inside a boolean → stays on scan
"\"quick brown\" OR bear", // phrase operand → stays on scan
"body : quick OR fox", // column-scoped operand → stays on scan
"quick AND brown AND qui*", // 3 operands, one a prefix → scan
"(quick OR brown) AND fox*", // parenthesized, one a prefix → scan
"quick AND NEAR(brown fox, 2)", // a NEAR leaf in the tree → scan
] {
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let sql = alloc::format!("SELECT body FROM t WHERE t MATCH '{q}'");
let _ = c.query(&sql).unwrap();
let after = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
assert_eq!(after, before, "query {q:?} must stay on the scan");
}
}
/// A two-term phrase (`tbl MATCH '"a b"'`, table-wide and column-scoped) over a
/// fully indexed table is served by the segment index (`INDEX_ROUTE_HITS` rises)
/// and returns exactly the documents whose tokens occur at adjacent positions —
/// the same set the document scan produces.
#[test]
fn two_term_phrase_match_takes_index_route() {
let _guard = SERIALIZE.lock().unwrap_or_else(|e| e.into_inner());
let mut c = Connection::open_memory().unwrap();
c.execute("CREATE VIRTUAL TABLE t USING fts5(title, body)")
.unwrap();
// "quick brown" is adjacent in row 1 (title) and row 4 (body); rows 2/3 have
// the words but not adjacent / not in order / split across columns.
let docs = [
("the quick brown fox", "nothing here"),
("quick red brown fox", "all separate words"),
("brown then quick", "reversed order only"),
("plain title text", "a quick brown hare"),
("quick", "brown"), // split across columns: NOT a phrase match
];
for (i, (title, body)) in docs.iter().enumerate() {
c.execute(&alloc::format!(
"INSERT INTO t(rowid, title, body) VALUES({}, '{}', '{}')",
i + 1,
title,
body
))
.unwrap();
}
// Table-wide phrase: adjacent in some column → rows 1 and 4.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows: alloc::vec::Vec<i64> = c
.query("SELECT rowid FROM t WHERE t MATCH '\"quick brown\"' ORDER BY rowid")
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect();
assert!(
INDEX_ROUTE_HITS.load(Ordering::Relaxed) > before,
"table-wide phrase must take the index route"
);
assert_eq!(rows, [1, 4]);
// Column-scoped phrase: only the body column → row 4.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows: alloc::vec::Vec<i64> = c
.query("SELECT rowid FROM t WHERE t MATCH 'body : \"quick brown\"' ORDER BY rowid")
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect();
assert!(
INDEX_ROUTE_HITS.load(Ordering::Relaxed) > before,
"column-scoped phrase must take the index route"
);
assert_eq!(rows, [4]);
}
/// A K-term phrase (K ≥ 3, `tbl MATCH '"a b c"'`, table-wide and column-scoped,
/// including a repeated-word phrase) over a fully indexed table is served by the
/// segment index (`INDEX_ROUTE_HITS` rises) and returns exactly the documents
/// whose tokens occur at CONSECUTIVE positions in one column — the same set the
/// document scan produces. A run that straddles a column boundary must NOT match.
#[test]
fn k_term_phrase_match_takes_index_route() {
let _guard = SERIALIZE.lock().unwrap_or_else(|e| e.into_inner());
let mut c = Connection::open_memory().unwrap();
c.execute("CREATE VIRTUAL TABLE t USING fts5(title, body)")
.unwrap();
// "quick brown fox" is consecutive in row 1 (title) and row 4 (body); row 2
// has the words non-consecutive, row 3 reversed, row 5 splits the run across
// the column boundary (title ends "quick brown", body starts "fox") so it
// must NOT match. Row 6 carries the repeated-word run "na na na" in title.
let docs = [
("the quick brown fox runs", "nothing here at all"),
("quick red brown gray fox", "all separate words here"),
("fox brown quick reversed", "still reversed only here"),
("plain title text here", "a quick brown fox hops"),
("ends with quick brown", "fox starts the body now"),
("na na na batman here", "plain body without it now"),
];
for (i, (title, body)) in docs.iter().enumerate() {
c.execute(&alloc::format!(
"INSERT INTO t(rowid, title, body) VALUES({}, '{}', '{}')",
i + 1,
title,
body
))
.unwrap();
}
// Table-wide 3-word phrase: consecutive in some column → rows 1 and 4. Row 5
// (split across columns) must NOT appear.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows: alloc::vec::Vec<i64> = c
.query("SELECT rowid FROM t WHERE t MATCH '\"quick brown fox\"' ORDER BY rowid")
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect();
assert!(
INDEX_ROUTE_HITS.load(Ordering::Relaxed) > before,
"table-wide K-term phrase must take the index route"
);
assert_eq!(rows, [1, 4]);
// Column-scoped K-term phrase: only the body column → row 4.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows: alloc::vec::Vec<i64> = c
.query("SELECT rowid FROM t WHERE t MATCH 'body : \"quick brown fox\"' ORDER BY rowid")
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect();
assert!(
INDEX_ROUTE_HITS.load(Ordering::Relaxed) > before,
"column-scoped K-term phrase must take the index route"
);
assert_eq!(rows, [4]);
// Repeated-word 3-term phrase: "na na na" consecutive in title → row 6.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows: alloc::vec::Vec<i64> = c
.query("SELECT rowid FROM t WHERE t MATCH '\"na na na\"' ORDER BY rowid")
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect();
assert!(
INDEX_ROUTE_HITS.load(Ordering::Relaxed) > before,
"repeated-word K-term phrase must take the index route"
);
assert_eq!(rows, [6]);
}
/// A lone two-single-token bare-term `NEAR` group (`tbl MATCH 'NEAR(a b, n)'`,
/// and the default-distance `NEAR(a b)` = n=10) over a fully indexed table is
/// served by the segment index (`INDEX_ROUTE_HITS` rises): it intersects the two
/// terms' doclists and keeps the documents with positions `|pa − pb| <= n + 1`
/// in some column — exactly the set the document scan's NEAR predicate matches.
#[test]
fn two_term_near_match_takes_index_route() {
let _guard = SERIALIZE.lock().unwrap_or_else(|e| e.into_inner());
let mut c = Connection::open_memory().unwrap();
c.execute("CREATE VIRTUAL TABLE t USING fts5(title, body)")
.unwrap();
// a@pos / b@pos per column. Gaps: row1 adjacent (1), row2 gap 2, row3 gap 3,
// row4 only `a`, row5 the pair split across columns (never a NEAR match),
// row6 the pair adjacent only in `body`.
let docs = [
("alpha beta", "nothing here"), // 1: gap 1 in title
("alpha x beta", "irrelevant"), // 2: gap 2 in title
("alpha x y beta", "irrelevant"), // 3: gap 3 in title
("alpha only here", "no second term"), // 4: only alpha
("alpha here", "beta there"), // 5: split across columns
("plain title", "alpha beta close"), // 6: gap 1 in body
];
for (i, (title, body)) in docs.iter().enumerate() {
c.execute(&alloc::format!(
"INSERT INTO t(rowid, title, body) VALUES({}, '{}', '{}')",
i + 1,
title,
body
))
.unwrap();
}
// NEAR(alpha beta, 1) → |pa-pb| <= 2: gap-1 (rows 1, 6) and gap-2 (row 2).
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows: alloc::vec::Vec<i64> = c
.query("SELECT rowid FROM t WHERE t MATCH 'NEAR(alpha beta, 1)' ORDER BY rowid")
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect();
assert!(
INDEX_ROUTE_HITS.load(Ordering::Relaxed) > before,
"two-term NEAR must take the index route"
);
assert_eq!(rows, [1, 2, 6]);
// NEAR(alpha beta, 0) → |pa-pb| <= 1: only adjacent rows 1 and 6.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows: alloc::vec::Vec<i64> = c
.query("SELECT rowid FROM t WHERE t MATCH 'NEAR(alpha beta, 0)' ORDER BY rowid")
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect();
assert!(
INDEX_ROUTE_HITS.load(Ordering::Relaxed) > before,
"two-term NEAR(.,0) must take the index route"
);
assert_eq!(rows, [1, 6]);
// Default distance NEAR(alpha beta) = n=10 → all docs with both terms in
// some column within 11 positions: rows 1, 2, 3, 6 (row 5 is split columns).
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows: alloc::vec::Vec<i64> = c
.query("SELECT rowid FROM t WHERE t MATCH 'NEAR(alpha beta)' ORDER BY rowid")
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect();
assert!(
INDEX_ROUTE_HITS.load(Ordering::Relaxed) > before,
"default-distance NEAR must take the index route"
);
assert_eq!(rows, [1, 2, 3, 6]);
}
/// A lone bare PREFIX term (`tbl MATCH 'pre*'`, table-wide and column-scoped)
/// over a fully indexed table is served by the segment index
/// (`INDEX_ROUTE_HITS` rises): it unions the doclists of every indexed term that
/// begins with the prefix, returning exactly the documents the scan's
/// `doc_token.starts_with(prefix)` predicate matches, in rowid order.
#[test]
fn prefix_term_match_takes_index_route() {
let _guard = SERIALIZE.lock().unwrap_or_else(|e| e.into_inner());
let mut c = Connection::open_memory().unwrap();
c.execute("CREATE VIRTUAL TABLE t USING fts5(title, body)")
.unwrap();
// terms beginning with "qu": quick(1,3 title), quiet(2 body); "fo": fox.
let docs = [
("quick brown fox", "nothing here"),
("calm title", "quiet body now"),
("quick red fox", "all separate"),
("plain title", "no match in body"),
];
for (i, (title, body)) in docs.iter().enumerate() {
c.execute(&alloc::format!(
"INSERT INTO t(rowid, title, body) VALUES({}, '{}', '{}')",
i + 1,
title,
body
))
.unwrap();
}
// Table-wide prefix: any term starting "qu" → rows 1, 2, 3.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows: alloc::vec::Vec<i64> = c
.query("SELECT rowid FROM t WHERE t MATCH 'qu*' ORDER BY rowid")
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect();
assert!(
INDEX_ROUTE_HITS.load(Ordering::Relaxed) > before,
"table-wide prefix must take the index route"
);
assert_eq!(rows, [1, 2, 3]);
// Column-scoped prefix: only the title column → quick in rows 1, 3.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows: alloc::vec::Vec<i64> = c
.query("SELECT rowid FROM t WHERE t MATCH 'title : qu*' ORDER BY rowid")
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect();
assert!(
INDEX_ROUTE_HITS.load(Ordering::Relaxed) > before,
"column-scoped prefix must take the index route"
);
assert_eq!(rows, [1, 3]);
}
/// An N-operand bare-term boolean TREE — two operands (`a AND b`, `a OR b`,
/// `a NOT b`, the implicit-AND `a b`) AND 3+ operands with mixed AND/OR/NOT and
/// parentheses — over a fully indexed table is served by the segment index
/// (`INDEX_ROUTE_HITS` rises) via bottom-up doclist set-ops, returning exactly
/// the same documents — in the same rowid order — as the document scan, with
/// FTS5's `NOT` > `AND` > `OR` precedence honored.
#[test]
fn bare_term_boolean_tree_match_takes_index_route() {
let _guard = SERIALIZE.lock().unwrap_or_else(|e| e.into_inner());
let mut c = Connection::open_memory().unwrap();
c.execute("CREATE VIRTUAL TABLE t USING fts5(body)")
.unwrap();
// term presence by rowid:
// fox: 1, 3, 4, 5 brown: 1, 2, 4 dog: 2, 5
let docs = [
"the quick brown fox", // 1: fox brown
"lazy brown dog", // 2: brown dog
"fox in the henhouse", // 3: fox
"brown fox runs", // 4: fox brown
"a fox and a dog", // 5: fox dog
];
for (i, body) in docs.iter().enumerate() {
c.execute(&alloc::format!(
"INSERT INTO t(rowid, body) VALUES({}, '{}')",
i + 1,
body
))
.unwrap();
}
let ids = |c: &mut Connection, sql: &str| -> alloc::vec::Vec<i64> {
c.query(sql)
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect()
};
// (query, expected rowids) — AND=intersection, OR=union, NOT=difference,
// and the bare juxtaposition is implicit AND.
for (q, want) in [
("fox AND brown", alloc::vec![1i64, 4]),
("fox OR dog", alloc::vec![1, 2, 3, 4, 5]),
("fox NOT brown", alloc::vec![3, 5]),
("brown NOT fox", alloc::vec![2]),
("fox brown", alloc::vec![1, 4]), // implicit AND
("zebra AND fox", alloc::vec![]), // absent operand → empty
("zebra OR dog", alloc::vec![2, 5]),
// 3+ operands and parentheses (term presence by rowid above):
// fox{1,3,4,5} brown{1,2,4} dog{2,5}
("fox AND brown AND dog", alloc::vec![]), // ∩ = {}
("fox OR brown OR dog", alloc::vec![1, 2, 3, 4, 5]), // ∪ = all
("fox brown dog", alloc::vec![]), // implicit AND of three
// Precedence: `fox OR brown AND dog` = `fox OR (brown AND dog)`.
// brown∩dog = {2}; fox{1,3,4,5} ∪ {2} = {1,2,3,4,5}.
("fox OR brown AND dog", alloc::vec![1, 2, 3, 4, 5]),
// Parentheses override: `(fox OR brown) AND dog`.
// fox∪brown = {1,2,3,4,5}; ∩ dog{2,5} = {2,5}.
("(fox OR brown) AND dog", alloc::vec![2, 5]),
// A NOT inside a parenthesized tree: `(fox OR brown) NOT dog`.
// {1,2,3,4,5} − dog{2,5} = {1,3,4}.
("(fox OR brown) NOT dog", alloc::vec![1, 3, 4]),
// NOT binds tighter than AND: `fox AND brown NOT dog`
// = `fox AND (brown NOT dog)`; brown−dog = {1,4}; ∩ fox = {1,4}.
("fox AND brown NOT dog", alloc::vec![1, 4]),
] {
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows = ids(
&mut c,
&alloc::format!("SELECT rowid FROM t WHERE t MATCH '{q}' ORDER BY rowid"),
);
let after = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
assert!(after > before, "boolean {q:?} must take the index route");
assert_eq!(rows, want, "boolean {q:?}");
}
}
/// A column-scoped single bare term (`tbl MATCH 'col : word'`) over a fully
/// indexed multi-column table is served by the segment index
/// (`INDEX_ROUTE_HITS` rises) and returns exactly the documents whose named
/// column contains the term — the same set the document scan produces.
#[test]
fn column_scoped_bare_term_match_takes_index_route() {
let _guard = SERIALIZE.lock().unwrap_or_else(|e| e.into_inner());
let mut c = Connection::open_memory().unwrap();
c.execute("CREATE VIRTUAL TABLE t USING fts5(title, body)")
.unwrap();
// "fox" lands in title for rows 1,4; in body for rows 2,3; in both for 5.
let docs = [
("the fox", "sleeps soundly"),
("a lazy dog", "chases a fox"),
("quiet night", "fox runs past"),
("fox tracks", "across the snow"),
("fox tale", "the fox returns"),
];
for (i, (title, body)) in docs.iter().enumerate() {
c.execute(&alloc::format!(
"INSERT INTO t(rowid, title, body) VALUES({}, '{}', '{}')",
i + 1,
title,
body
))
.unwrap();
}
let ids = |c: &mut Connection, sql: &str| -> alloc::vec::Vec<i64> {
c.query(sql)
.unwrap()
.rows
.into_iter()
.map(|r| match r[0] {
Value::Integer(i) => i,
ref o => panic!("non-integer rowid: {o:?}"),
})
.collect()
};
// title:fox → rows whose TITLE has fox = 1, 4, 5. Index-routed.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows = ids(
&mut c,
"SELECT rowid FROM t WHERE t MATCH 'title : fox' ORDER BY rowid",
);
let after = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
assert!(
after > before,
"column-scoped MATCH must take the index route"
);
assert_eq!(rows, [1, 4, 5]);
// body:fox → rows whose BODY has fox = 2, 3, 5. Also index-routed.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let rows = ids(
&mut c,
"SELECT rowid FROM t WHERE t MATCH 'body:fox' ORDER BY rowid",
);
let after = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
assert!(
after > before,
"compact `body:fox` must take the index route"
);
assert_eq!(rows, [2, 3, 5]);
// A column filter naming a non-existent column is a query error (matching
// sqlite's `no such column`), reported before any routing — the query never
// reaches the index or the scan.
let before = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
let err = c
.query("SELECT rowid FROM t WHERE t MATCH 'nope:fox' ORDER BY rowid")
.expect_err("unknown-column filter must error");
assert!(
matches!(&err, Error::Error(m) if m.contains("no such column: nope")),
"{err:?}"
);
let after = INDEX_ROUTE_HITS.load(Ordering::Relaxed);
assert_eq!(after, before, "an erroring query takes no index route");
}
}